32 Commits
Author SHA1 Message Date
foxyzandClaude Opus 5.5 b949951ef9 Sounds play on flutter_soloud instead of three mpv players
ci / core (push) Successful in 27s
ci / server (push) Successful in 1m3s
ci / updater-windows (push) Successful in 10m15s
SoundEngine (platform/audio) opens the device once (2 s after start,
or on the first sound), loads each synthesized sound into memory (no
temp files), plays overlapping voices and is disposed on window close,
before leaving the server. The SfxService interface and its callers
are unchanged; loudness matches the old mpv path (v^6). WAV only, so
no codec libraries are bundled and no build packages are needed.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:53:36 +03:00
174 changed files with 16900 additions and 4574 deletions

No files matched your search

+3
View File
@@ -43,6 +43,9 @@ jobs:
# The updater's Windows apply path (running exe parked as .old) only # The updater's Windows apply path (running exe parked as .old) only
# means something on real NTFS. Runner notes: see release.yml. # means something on real NTFS. Runner notes: see release.yml.
updater-windows: updater-windows:
# Not for a "Release v…" commit: release.yml builds it on the same
# (only) Windows runner, which this job would hold up.
if: ${{ !startsWith(github.event.head_commit.message, 'Release v') }}
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- name: Checkout - name: Checkout
+1
View File
@@ -17,6 +17,7 @@ coverage/
# Build artifacts that should never be committed # Build artifacts that should never be committed
*.tar.gz *.tar.gz
*.zip *.zip
/dist/
app.*.symbols app.*.symbols
app.*.map.json app.*.map.json
+170 -54
View File
@@ -81,16 +81,22 @@ corner; double-click or ⤢ makes it big again, × sits you out (below) and
double-click on the picture goes fullscreen (the strip hides, the party 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 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 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 show's cover, title and episode top left (a web video: its thumbnail and
name; guests get the art too; right-click to copy the title, or the link
when you have it, or open it in the browser or Jellyfin); before
a video first plays, its banner fills the picture until playback starts. a video first plays, its banner fills the picture until playback starts.
One pill above the controls says what playback waits for ("Press Space One pill bottom centre, just above the controls, says what playback waits for ("Press Space
when you're ready", "Waiting for Bob and Mika · 2/3 ready", "Bob is 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 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 Tab), ready to type; it shows messages (select text to copy it, or
right-click for Copy message and, with a link in it, Copy link / Open in
browser), and its "Activity" toggle adds
joins, ready, seeks and the like between them. Joins, leaves, chat and joins, ready, seeks and the like between them. Joins, leaves, chat and
other news also pop up bottom left with the person's avatar; click a message to open the chat. Unread chat other news also pop up bottom left with the person's avatar; click a message to open the chat
shows as a count on the chat buttons and, in fullscreen with the controls (Settings → Notifications → Chat messages: Only when paused by default,
hidden, as a small chip top right. or All, or Off; messages always reach the chat). Unread chat shows as a count on the chat
buttons and, in fullscreen with the controls hidden and the video paused,
as a small chip top right.
Your name and picture change for everyone in the server right away; the Your name and picture change for everyone in the server right away; the
colour is yours only. colour is yours only.
@@ -124,8 +130,8 @@ disk. Nothing is uploaded or stored anywhere.
What to watch, from three sources (the switch at the top). Drop a video, 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 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 text field, and a small card asks how to open it (put it up, add it to
folder to Files, or fill Link). Up next, add the folder to Files, or fill Link).
- **Jellyfin**: your linked servers (see below); with none linked it - **Jellyfin**: your linked servers (see below); with none linked it
points to Files and Link and offers Jellyfin as optional. Library chips beside the points to Files and Link and offers Jellyfin as optional. Library chips beside the
@@ -140,12 +146,16 @@ folder to Files, or fill Link).
details open on the right with "Put it up · S1E3", mark watched / details open on the right with "Put it up · S1E3", mark watched /
unwatched, remove from Continue and the episode list (click an episode 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. to choose it). Double-click a card or an episode to put it up at once.
Right-click a card or an episode to copy its title or open it in
Jellyfin's web page (the details panel has that button too).
- **Link**: paste an http(s) link: a video file, or a web page with a - **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 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 what it is (a page's title, uploader, length and thumbnail, else the
file name); in a server, pick how the file name); in a server, pick how the
others get it (everyone fetches it, the server fetches it, or through others get it (everyone fetches it, the server fetches it, or through
you). Your last 12 links are listed under it. A link that does not you). Your last 12 links are listed under it (right-click one to copy
or open it). A link seen before shows its preview at once, without
asking the site again. A link that does not
open here (needs cookies, 403, 404, not a video) says why and goes up open here (needs cookies, 403, 404, not a video) says why and goes up
only with "Put it up anyway"; in the player, whoever cannot play it is only with "Put it up anyway"; in the player, whoever cannot play it is
named ("Bob can't play this: Not allowed (403)") and nobody waits for them. named ("Bob can't play this: Not allowed (403)") and nobody waits for them.
@@ -155,6 +165,57 @@ folder to Files, or fill Link).
Put it up and the player opens. Not in a server, the buttons say Play 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)). and it plays for you alone ([Watch solo](#getting-started)).
### Up next (the queue)
In a server (solo too), anything can wait its turn instead of going up
now: **Add to queue** on the Link page (in the way you picked), or
right-click a Jellyfin card or episode, a video in Files, a recent link
or an episode on the Schedule for **Add to queue** / **Play next**; the
Jellyfin details panel has a queue button (right-click it to play next),
and the card for a dropped or pasted video or link has both. A link is
checked first, like Put it up. Every app keeps the next item ready,
its first seconds already loaded, so skipping to it starts at once.
For a link passed on, a member's file or a link the server fetches,
the server starts getting the next item while the current one plays
(from the app of whoever added it, or by fetching the link itself), so
everyone has its start before it goes up; whoever added it keeps the
app open for that. Everyone shares one Up next and anyone can change it; what
you added stays when you leave.
In the player, Up next sits in the top bar, right of the title: the
next item's picture and title (or the next episode's). Rest the mouse on
it for a larger preview (who added it, its length, whether it is ready
to start); ⏭ plays it now, a click opens the queue: drag rows to reorder, hover a
row to play it now (▶) or remove it (×), right-click it for more (copy its
title or link, open it in the browser or in Jellyfin). To add, press
Ctrl+V with the queue open (a copied link or video file goes in at once,
its title and picture showing while it is checked), type one after
**+ Add link**, or drop a link or a video on it. When a video ends, a card shows
what is up next with a 10 s countdown (Cancel stops it on your screen)
and it plays; `>` or Shift+N plays it at once. Under the queue,
**Continues with** shows the next episodes of the Jellyfin series
playing (greyed: they play only once Up next is empty); **+** adds one,
**Clear** drops them for this episode.
A link you pass on ("through me") or a file of yours can only be played
by your app: if you left when its turn comes, it is skipped ("Skipped …:
Foxyz left"). A Jellyfin item needs someone with an account on that
server. Up next is kept by the server while it runs; restarting the
server empties it.
## Schedule
The week's airing anime, one column per day (AniList), each episode
marked with what the Jellyfin server has of it when AnimeFlow is
connected (Settings, Jellyfin & integrations): **on Jellyfin** (Put it
up, right-click to queue), **downloading**, **waiting for release**
(aired in Japan, AnimeFlow downloads it by itself once a release
appears; with when AnimeFlow last searched, or **release rejected** when
it found releases it won't take, the reason on hover), **failed**, **not monitored** (Request asks AnimeFlow for it),
or the time left until it airs. A show AnimeFlow does not follow can be asked for with **Request
series**. Episodes you already watched on Jellyfin carry a check on the
cover; one you started shows how far you got.
## Dedicated server ## Dedicated server
For an always-on box (no display needed): the release tarball also contains For an always-on box (no display needed): the release tarball also contains
@@ -175,6 +236,7 @@ then everyone (you included) uses Join.
| `--[no-]yt-dlp` | on | read web pages (YouTube, X, …) with yt-dlp when the server fetches a link | | `--[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-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 | | `--yt-dlp-cookies` | none | cookies.txt or cookie-extension JSON export for that yt-dlp (sites that refuse guests); copied into the yt-dlp folder at start, removed from it when not given. **An account login**: keep it private, use a throwaway account for YouTube |
| `--log-dir` | `logs` next to `--config`, else next to the binary (systemd: the unit's `StateDirectory`) | folder for the log files (`server-YYYY-MM-DD.log` and the session log `session-YYYY-MM-DD.log`, kept 7 days; passwords, tokens and cookies left out) |
| `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`, `ytDlp`, `ytDlpDir`, `ytDlpCookies`); replaces the flags | | `--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 | | `--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 | | `--check-update` | | print whether a newer release exists, then exit |
@@ -185,6 +247,23 @@ 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 [deploy/watch-together.service](deploy/watch-together.service) (copy it to
`/etc/systemd/system/`, edit the flags, `systemctl enable --now watch-together`). `/etc/systemd/system/`, edit the flags, `systemctl enable --now watch-together`).
Something not playing? Settings → About → **Copy diagnostics** puts the
app's recent log (passwords, tokens and cookies left out) on the
clipboard: paste it to whoever helps you, from both sides if you can.
While you are in a server, the app also sends its log to that server, which
merges everyone's lines and its own into one session log
(`session-YYYY-MM-DD.log` next to the server's logs; the hosting app keeps
it in its own logs folder), so a problem can be read afterwards from every
side. Chat is never logged anywhere, nor passwords, tokens and cookies.
When something goes wrong, press **Mark a problem** (Ctrl+Shift+B, also in
the player's ⚙ and Settings → About) and add a few words if you like: a
marker with your name goes in the session log at that moment. In a server,
Copy diagnostics can add the session log of the last 6 hours (any member
may read it). Don't want to share? Settings → About → **Share diagnostics
with the server** off: your log stays on your machine (the server only
notes that you don't share; a problem you mark still goes in).
## Join ## Join
Join in the strip. Servers you got into are listed first (up to 6), with Join in the strip. Servers you got into are listed first (up to 6), with
@@ -203,19 +282,25 @@ integrations (linked Jellyfin servers, below, the AnimeFlow connection:
"connected as <name>" with what you may do, or why not, and playing the "connected as <name>" with what you may do, or why not, and playing the
next episode automatically); Notifications & sounds (what pops up; the sound next episode automatically); Notifications & sounds (what pops up; the sound
volume and sound set, and every sound with a preview, its own set and a switch); Updates; Keyboard shortcuts (read-only); About volume and sound set, and every sound with a preview, its own set and a switch); Updates; Keyboard shortcuts (read-only); About
(the version). (the version; resting the mouse on the Settings tab shows it too; and the
credits, e.g. that the Minimal player design is inspired by uosc).
Player settings live in the player: ⚙ at the right end of its controls Player settings live in the player too: ⚙ → Player settings, at the
(both designs) opens them, and Settings → Player has the same ones (plus right of its controls, and Settings → Player has the same ones (plus the
the custom shader files). Player design: Standard or uosc (below); controls' layout and the custom shader files). Player design: Standard or
progress line when hidden (on by default: a 2 px line at the bottom of Minimal (below); progress line when hidden (on by default: a thin line at
the video while the controls are hidden); upscaling shader (see below); the bottom of the video while the controls are hidden); upscaling shader
hardware decoding (auto, off or custom; the env var `SYNC_RELAY_HWDEC` (see below); hardware decoding (auto, off or custom; the env var
overrides it); in the player also the subtitle and audio track. Every `SYNC_RELAY_HWDEC` overrides it); in the player also the subtitle and
change applies right away. audio track. Settings → Player → Controls: time display (elapsed / total
or remaining), controls size (normal or large), hide the controls after
2, 3 or 5 s, and which optional buttons sit in the bar (chapter buttons,
subtitles, audio, chat; hidden ones are still in ⚙). Every change
applies right away.
Anywhere in a server (unless you are typing): Space = ready / unready, Anywhere in a server (unless you are typing): Space = ready / unready,
Tab = chat & activity (Tab never moves between buttons). Keys while the Tab = chat & activity (Tab never moves between buttons). Anywhere:
Ctrl+Shift+B = mark a problem in the session log. Keys while the
player fills the window: Left/Right = seek 10 s, Up/Down = volume, Shift+Right = skip 1:30, 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 = PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Esc =
close the chat, then fullscreen, then shrink the player to mini; from the close the chat, then fullscreen, then shrink the player to mini; from the
@@ -224,30 +309,45 @@ mini it asks to leave, Ctrl+R = reload video,
Ctrl+1..6 = Anime4K mode A, B, C, A+A, B+B, C+A, Ctrl+0 = shaders off. Ctrl+1..6 = Anime4K mode A, B, C, A+A, B+B, C+A, Ctrl+0 = shaders off.
Every seek, skip and chapter jump moves everyone. Every seek, skip and chapter jump moves everyone.
Controls: the seek bar marks chapter starts with a diamond; hovering it Controls: both designs have the same buttons in the same places, only
shows the time and the chapter title. The chapters button lists them the look differs. Bottom left: play / pause first (it is your ready, like
(current one checked; click to jump). "1:30" skips ahead 1:30. Audio and Space: ✓ while you are ready and the others are not), previous / next
Subtitles buttons (with the track count) list tracks as "Japanese · E-AC3 episode (when there is one; next plays Up next first), previous / next
2.0 · title"; subtitles have Off. Volume (hover the speaker, scroll on chapter (when the video has chapters), volume and the time (click it to
it, or Up/Down) goes to 150 % like mpv: past the tick at 100 % the bar switch between elapsed / total and the time left). Bottom right:
turns amber and mpv amplifies. Subtitles and Audio (with the track count; tracks listed as "Japanese ·
E-AC3 2.0 · title", subtitles with Off), ⚙, chat and fullscreen last.
Everything else is in ⚙: chapters (current one checked; click to jump),
Skip 1:30, upscaling shader, change video, copy link, reload video,
mark everyone ready, mark a problem, player settings and leave. The top
bar has the title, Up next and back to the library (in fullscreen: exit
fullscreen and the window buttons). Volume (scroll anywhere on the
video, hover the speaker, or Up/Down) goes to 150 % like mpv: past the
tick at 100 % the bar turns amber and mpv amplifies; a scroll or key
shows the volume for a moment. Lists and menus under the mouse scroll
as usual.
The uosc player design (⚙ → Player design, Standard by default, Standard (the default): a seek bar that marks chapter starts with a
switches right away): video controls modeled on mpv's uosc theme. A thick diamond (hovering it shows the time and the chapter title) over a row
timeline across the bottom shows the played part, the cached part ahead of round buttons. Minimal (⚙ → Player settings → Player design, switches
(hatched), chapter starts as diamonds and the time; hovering it shows the right away): a translucent timeline across the bottom tints what has
chapter title and the time there. With the controls hidden it stays as a played, stripes the part not downloaded yet, marks chapter starts with
thin line (unless "Progress line when hidden" is off). Above it flat buttons: ≡ menu, Subtitles and Audio (with the dots (click one to jump), shows the elapsed time left and the total or
track count), the shader badge (the active Anime4K mode, e.g. "A HQ"), the time left right (click it to switch) and, while less than a minute
ready, chapters, 1:30, previous / next episode, change video, volume, ⚙ is buffered, the buffered seconds next to the position; hovering it shows
and fullscreen; the title top left, chat and back-to-library top right. Keys and skip buttons work the time there and the chapter title. With the controls hidden it stays
the same. as a thin line (unless "Progress line when hidden" is off). Its buttons
sit in two glass capsules above it. Minimal's timeline and controls are
inspired by [uosc](https://github.com/tomasklaen/uosc) by tomasklaen
(LGPL-2.1).
Skipping: during the opening a "Skip OP" button floats in the corner (it Skipping: during the opening a "Skip OP" button shows bottom right, just
above the controls (it
jumps to the end of the opening), during the ending "Skip ED" (to the end jumps to the end of the opening), during the ending "Skip ED" (to the end
of the ending only; what comes after is yours to skip). Any skip button of the ending only; what comes after is yours to skip). "Skip OP" and
(also "Skip 1:30") shows for 5 seconds when it comes up, then only while "Skip 1:30" show for 5 seconds when they come up, then only while the
the controls are up (move the mouse). The opening and controls are up (move the mouse); "Skip ED" and "Next episode" show only
while the controls are up. The opening and
ending come from Jellyfin's intro / outro segments when your Jellyfin has ending come from Jellyfin's intro / outro segments when your Jellyfin has
them (needs a segment plugin, e.g. Intro Skipper), else from chapter names them (needs a segment plugin, e.g. Intro Skipper), else from chapter names
(OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening or (OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening or
@@ -294,11 +394,14 @@ Watching:
directly with their own sign-in. Jellyfin tracks progress per account: directly with their own sign-in. Jellyfin tracks progress per account:
your watched state, Continue watching and resume point follow what you your watched state, Continue watching and resume point follow what you
saw (reported on start, every 10 s, on pause, resume, seek and track saw (reported on start, every 10 s, on pause, resume, seek and track
change, and when it stops). change, and when it stops). Watched means you actually played most of
it (or reached the ending after half), not where you stopped: opening
a watched episode to check a moment keeps it watched, and a quick peek
leaves no resume point.
- Friends without an account watch through the server: whoever put the - Friends without an account watch through the server: whoever put the
item up passes it on automatically, with their own sign-in, which never 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 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, session). They show "via server" in the member list (click the avatars). When the app hosts,
or a dedicated server has its own login (`--jellyfin <file>`, see or a dedicated server has its own login (`--jellyfin <file>`, see
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay), the server [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay), the server
fetches it itself instead. fetches it itself instead.
@@ -306,10 +409,11 @@ Watching:
"No Jellyfin account here for ..." with a button to link one; it plays as "No Jellyfin account here for ..." with a button to link one; it plays as
soon as you are signed in. soon as you are signed in.
Episodes: the ⏮ / ⏭ buttons next to the controls (or `<` / `>`, Episodes: the ⏮ / ⏭ buttons next to play (or `<` / `>`,
Shift+P / Shift+N) put up the previous or next episode for everyone. From Shift+P / Shift+N) put up the previous or next episode for everyone. From
the ending (when it is known, see Skipping above; else in the last two the ending (when it is known, see Skipping above; else in the last two
minutes) a "Next episode" button floats in the corner. When an minutes) a "Next episode" button floats in the corner while the controls
are up. When an
episode ends, a card shows what is next with Play next; with "Play the episode ends, a card shows what is next with Play next; with "Play the
next episode automatically" (Settings, on by default) it counts down 10 s next episode automatically" (Settings, on by default) it counts down 10 s
first, and Cancel stops it on your screen (seeking back stops it for first, and Cancel stops it on your screen (seeking back stops it for
@@ -321,22 +425,22 @@ Your choice is remembered per series (and as a default) and the closest
match (language, then title, then codec and channels) is selected on the match (language, then title, then codec and channels) is selected on the
next episode. This is per person; nobody else is affected. next episode. This is per person; nobody else is affected.
"Change video" in the player's controls opens the Library tab (the player ⚙ → Change video in the player opens the Library tab (the player
goes mini): the library starts on the series you are watching with the goes mini): the library starts on the series you are watching with the
current season, else where you left it. current season, else where you left it.
Anyone can put up a file from their own computer, also on a dedicated 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: it streams from your computer through the server to everyone (the
server keeps a few minutes in memory, so seeks and skipping the opening 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 are quick). Keep the app open while it plays. A link goes up "Through
the others get it: me": your computer opens it and passes it on, so it plays for everyone
- Everyone fetches it (default): each app plays the link itself. whenever it plays for you (you can't tell ahead which sites want cookies,
and YouTube may start asking any day). Under "Other ways" there are two more:
- Everyone fetches it: each app plays the link itself.
- Server fetches it: the server downloads it once for everyone, for links - 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, 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 and it must have a password (an open server refuses: anyone could make
it fetch addresses only it can reach). 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 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 download [yt-dlp](https://github.com/yt-dlp/yt-dlp) by themselves (checked
@@ -352,11 +456,23 @@ sites: keep the file private, and for YouTube consider a throwaway
account (Google may flag accounts used with yt-dlp). account (Google may flag accounts used with yt-dlp).
YouTube works in all three too, and so do pages and links that only 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). 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 Through me and Server fetches it never show the link to anyone; they can't pass on live
streams or DRM-protected video (use "Everyone fetches it" for live). 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 Your upload has to beat the video's bitrate: a 25-minute 2 GB episode is
about 11 Mbit/s. about 11 Mbit/s.
The member list (click the avatars) shows where the video comes from on
top: who passes it on, or that the server fetches it, everyone plays the
link, or everyone streams it from Jellyfin, with how many watch through
the server and, for a relay, how fast it arrives against what the video
needs (the bar's mark; past it is keeping up). It turns amber when the
source is too slow or reconnecting, red when it left (the others play
out what the server already has), and whoever passes it on is told once
when their upload is too slow. Each member's line says how they get it
("via server" with their speed, or "direct") after a dot for their
buffer: green enough to play on, amber low, red empty. A red dot on one
member while the source keeps up is their own connection.
## Video quality and shaders ## Video quality and shaders
Each viewer can run mpv GLSL shaders on their own picture; nobody else is Each viewer can run mpv GLSL shaders on their own picture; nobody else is
@@ -365,9 +481,9 @@ affected. The app ships [Anime4K](https://github.com/bloc97/Anime4K) v4.0.1
C (480p), A+A, B+B, C+A, each as HQ (strong GPUs, GTX 1080 and up) or Fast C (480p), A+A, B+B, C+A, each as HQ (strong GPUs, GTX 1080 and up) or Fast
(the guide's low-end set). "Custom shaders" runs your own `.glsl` files. (the guide's low-end set). "Custom shaders" runs your own `.glsl` files.
Pick one with the ✨ button in the player's control bar (also in Pick one with ⚙ → Upscaling shader in the player (also in
fullscreen) or Ctrl+0..6; the change applies live and is saved, so the next fullscreen) or Ctrl+0..6; the change applies live and is saved, so the next
session starts with it. The player's ⚙ and Settings → Player set it too, session starts with it. ⚙ → Player settings and Settings → Player set it too,
with HQ or Fast (used by Ctrl+1..6); Settings → Player holds the custom with HQ or Fast (used by Ctrl+1..6); Settings → Player holds the custom
files. Shaders cost GPU time files. Shaders cost GPU time
every frame and need hardware video rendering: on media_kit's software every frame and need hardware video rendering: on media_kit's software
@@ -379,7 +495,7 @@ fails to load shows a toast and switches shaders off.
- "host offline - check address and port": wrong address, or the port is not open. - "host offline - check address and port": wrong address, or the port is not open.
- "wrong server password": password mismatch. - "wrong server password": password mismatch.
- "host runs a different version": update the app on both sides. - "host runs a different version": update the app on both sides.
- Picture frozen but sound fine: Ctrl+R reloads the video; the app also does it automatically. - Picture frozen but sound fine: Ctrl+R reloads the video.
- Everyone waits at "waiting for X": X has not buffered 4 seconds yet or has not pressed Space. - Everyone waits at "waiting for X": X has not buffered 4 seconds yet or has not pressed Space.
- Windows: the build is new; if something is off there, say so. - Windows: the build is new; if something is off there, say so.
+4 -1
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'src/screens/home/ui.dart'; import 'src/screens/home/ui.dart';
import 'src/shell/app_shell.dart'; import 'src/shell/app_shell.dart';
import 'src/widgets/app_keys.dart'; import 'src/widgets/app_keys.dart';
import 'src/widgets/app_menu.dart';
class WatchTogetherApp extends StatelessWidget { class WatchTogetherApp extends StatelessWidget {
const WatchTogetherApp({super.key}); const WatchTogetherApp({super.key});
@@ -11,7 +12,9 @@ class WatchTogetherApp extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: appName, title: appName,
theme: ThemeData.dark(), // Material's own menus (the player's track menus, sort) wear the
// app menu's surface.
theme: AppMenuStyle.theme(ThemeData.dark()),
shortcuts: appShortcuts, shortcuts: appShortcuts,
home: const AppKeys(child: AppShell()), home: const AppKeys(child: AppShell()),
); );
+23 -8
View File
@@ -9,7 +9,9 @@ import 'package:window_manager/window_manager.dart';
import 'app.dart'; import 'app.dart';
import 'src/playback/yt_dlp_app.dart'; import 'src/playback/yt_dlp_app.dart';
import 'src/platform/app_log.dart';
import 'src/platform/desktop_entry.dart'; import 'src/platform/desktop_entry.dart';
import 'src/platform/window_memory.dart';
import 'src/screens/home/ui.dart'; import 'src/screens/home/ui.dart';
import 'src/state/session_state.dart'; import 'src/state/session_state.dart';
import 'src/state/settings.dart'; import 'src/state/settings.dart';
@@ -40,23 +42,26 @@ Future<void> main(List<String> args) async {
), ),
); );
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await startAppLogging();
await Hive.initFlutter();
await AppSettings.instance.ensureLoaded();
await windowManager.ensureInitialized(); await windowManager.ensureInitialized();
await windowManager.setPreventClose(true); await windowManager.setPreventClose(true);
windowManager.addListener(_CloseGuard()); final memory = WindowMemory(Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null);
const windowOptions = WindowOptions( windowManager.addListener(_CloseGuard(memory));
size: Size(1280, 720), final windowOptions = WindowOptions(
minimumSize: Size(160, 90), size: memory.size,
center: true, minimumSize: const Size(160, 90),
backgroundColor: Color(0xFF000000), center: !memory.hasPosition,
backgroundColor: const Color(0xFF000000),
titleBarStyle: TitleBarStyle.hidden, titleBarStyle: TitleBarStyle.hidden,
title: appName, title: appName,
); );
windowManager.waitUntilReadyToShow(windowOptions, () async { windowManager.waitUntilReadyToShow(windowOptions, () async {
await memory.restore();
await windowManager.show(); await windowManager.show();
await windowManager.focus(); await windowManager.focus();
}); });
await Hive.initFlutter();
await AppSettings.instance.ensureLoaded();
try { try {
await SessionState.instance.loadForm(); await SessionState.instance.loadForm();
} catch (e, st) { } catch (e, st) {
@@ -68,18 +73,28 @@ Future<void> main(List<String> args) async {
// Linux: the launcher entry and icon Wayland needs (release installs). // Linux: the launcher entry and icon Wayland needs (release installs).
unawaited(installDesktopEntry()); unawaited(installDesktopEntry());
runApp(const WatchTogetherApp()); runApp(const WatchTogetherApp());
// The sound device opens once the window is up, not on the first sound.
Future.delayed(const Duration(seconds: 2), SessionState.instance.sfx.warmUp);
} }
/// Close button / Alt+F4: leave gracefully first (others see "left", a /// Close button / Alt+F4: leave gracefully first (others see "left", a
/// hosted server shuts down), bounded so a hung socket can't keep the /// hosted server shuts down), bounded so a hung socket can't keep the
/// window open. /// window open.
class _CloseGuard with WindowListener { class _CloseGuard with WindowListener {
final WindowMemory memory;
bool _closing = false; bool _closing = false;
_CloseGuard(this.memory);
@override @override
Future<void> onWindowClose() async { Future<void> onWindowClose() async {
if (_closing) return; if (_closing) return;
_closing = true; _closing = true;
await memory.save();
try {
await SessionState.instance.sfx.dispose().timeout(const Duration(milliseconds: 500));
} catch (e) {
debugPrint('sound shutdown on close failed: $e');
}
try { try {
await SessionState.instance.leave().timeout(const Duration(seconds: 2)); await SessionState.instance.leave().timeout(const Duration(seconds: 2));
} catch (e) { } catch (e) {
@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../jellyfin/track_prefs.dart' show MpvTrack;
import '../playback/media_marks.dart';
import '../shaders/shader_menu.dart';
import '../shell/mark_problem.dart';
import '../state/settings.dart';
import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart';
import 'frame.dart';
import 'model/controls_layout.dart';
import 'model/controls_model.dart';
import 'player_settings.dart';
// The controls' menus, shared by both skins: the track and chapter lists
// and the ⚙ menu, all on the app's menu (`showAppMenu`), anchored on the
// button that opens them. Each keeps the controls up while open.
/// How many tracks of [type] there are.
int trackCount(List<MpvTrack> tracks, JellyfinStreamType type) => tracks.where((t) => t.type == type).length;
/// The audio or subtitle tracks ("Japanese · E-AC3 2.0 · title"), mpv's
/// selection checked; subtitles start with Off.
List<AppMenuItem<TrackPick>> trackMenuItems(JellyfinStreamType type, List<MpvTrack> tracks) {
final audio = type == JellyfinStreamType.audio;
final list = tracks.where((t) => t.type == type).toList();
return [
if (!audio)
AppMenuItem(
key: const ValueKey('track-off'),
value: (aid: null, sid: null, subtitlesOff: true),
label: 'Off',
checked: !list.any((t) => t.selected),
),
if (audio && list.isEmpty)
const AppMenuItem(value: (aid: null, sid: null, subtitlesOff: false), label: 'None', enabled: false),
for (final t in list)
AppMenuItem(
key: ValueKey('track-${t.type.name}-${t.id}'),
value: audio ? (aid: t.id, sid: null, subtitlesOff: false) : (aid: null, sid: t.id, subtitlesOff: false),
label: t.label,
checked: t.selected,
),
];
}
/// Chapters with their start times, the playing one checked.
List<AppMenuItem<MediaChapter>> chapterMenuItems(List<MediaChapter> chapters, int? current) => [
for (final c in chapters)
AppMenuItem(
key: ValueKey('chapter-${c.index}'),
value: c,
label: c.title.isEmpty ? 'Chapter ${c.index + 1}' : c.title,
hint: formatClock(c.start),
checked: c.index == current,
),
];
Future<T?> _menu<T>(BuildContext context, PlayerControlsModel model, List<AppMenuItem<T>> items) async {
model.menusOpen++;
try {
return await showAppMenu<T>(context, items: items);
} finally {
model.menusOpen--;
}
}
/// The audio or subtitle list; a pick is applied (and remembered).
Future<void> openTrackMenu(BuildContext context, PlayerControlsModel model, JellyfinStreamType type) async {
final pick = await _menu(context, model, trackMenuItems(type, model.tracks.peek()));
if (pick != null) model.pickTrack(pick);
}
/// The chapter list; a pick jumps there for everyone.
Future<void> openChapterMenu(BuildContext context, PlayerControlsModel model) async {
final pick = await _menu(context, model, chapterMenuItems(model.chapters.peek(), model.currentChapter.peek()));
if (pick != null) model.pickChapter(pick);
}
/// The ⚙ menu: every action that is not a bar button.
Future<void> openControlsMenu(BuildContext context, PlayerControlsModel model) async {
final s = AppSettings.instance;
final shader = shaderShortLabel(s.shaderPreset.peek(), s.shaderQuality.peek()) ?? 'Off';
final pick = await _menu(context, model, [
for (final a in model.menu())
AppMenuItem(
key: ValueKey('controls-menu-${a.name}'),
value: a,
label: a.label,
hint: a == ControlAction.shaders ? shader : a.hint,
),
]);
if (pick != null && context.mounted) await runControlAction(context, model, pick);
}
/// Runs a ⚙ menu action; [context] anchors what it opens next.
Future<void> runControlAction(BuildContext context, PlayerControlsModel model, ControlAction action) async {
switch (action) {
case ControlAction.chapters:
await openChapterMenu(context, model);
case ControlAction.subtitles:
await openTrackMenu(context, model, JellyfinStreamType.subtitle);
case ControlAction.audio:
await openTrackMenu(context, model, JellyfinStreamType.audio);
case ControlAction.skipForward:
model.skipForward();
case ControlAction.shaders:
model.menusOpen++;
try {
await ControlsFrame.holdWhile(
context,
() => showShaderMenu(context, renderMode: model.renderMode, onPick: model.pickShader),
);
} finally {
model.menusOpen--;
}
case ControlAction.chat:
model.toggleChat();
case ControlAction.changeVideo:
model.changeVideo();
case ControlAction.copyLink:
final link = model.link();
if (link != null) await copyToClipboard(link, done: 'Link copied');
case ControlAction.reload:
model.reload();
case ControlAction.markAllReady:
model.markAllReady();
case ControlAction.markProblem:
await ControlsFrame.holdWhile(context, () => showMarkProblem(context));
case ControlAction.playerSettings:
await showPlayerSettings(context, renderMode: model.renderMode);
case ControlAction.leave:
model.leave();
}
}
@@ -2,57 +2,74 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Follows whether media_kit's desktop controls around [child] are up, /// What keeps the player's controls up besides the mouse on them: the
/// by the same rule they use: up on mount and on any mouse movement, /// mouse on a floating element over the picture (a pill, a card, the
/// down [hideAfter] later or when the mouse leaves. media_kit does not /// status pill: [ControlsHoldRegion]) or an open popover. While anything
/// expose its visibility; this lets the player draw a progress line while /// holds, the controls stay up; on release they hide on their usual
/// they are down. Never takes input. /// countdown. Both skins follow it (through `ControlsFrame`).
class ControlsHoverProbe extends StatefulWidget { class ControlsHold extends ChangeNotifier {
final ValueNotifier<bool> shown; final _by = <Object>{};
final Duration hideAfter; bool _told = false;
final Widget child; bool _queued = false;
const ControlsHoverProbe({super.key, required this.shown, required this.hideAfter, required this.child}); bool _disposed = false;
@override bool get held => _told;
State<ControlsHoverProbe> createState() => _ControlsHoverProbeState();
}
class _ControlsHoverProbeState extends State<ControlsHoverProbe> { /// [who] holds ([on]) or lets go. Listeners hear it in a microtask, never
Timer? _timer; /// during a build or a dispose.
void set(Object who, bool on) {
@override if (on ? !_by.add(who) : !_by.remove(who)) return;
void initState() { if (_queued) return;
super.initState(); _queued = true;
// Not during this build: the line listening to [shown] may be built. scheduleMicrotask(() {
WidgetsBinding.instance.addPostFrameCallback((_) { _queued = false;
if (mounted) _wake(); if (_disposed) return;
final now = _by.isNotEmpty;
if (now == _told) return;
_told = now;
notifyListeners();
}); });
} }
@override @override
void dispose() { void dispose() {
_timer?.cancel(); _disposed = true;
super.dispose(); super.dispose();
} }
}
void _wake() { /// The mouse on [child] counts as the mouse on the controls: it holds
widget.shown.value = true; /// [hold] while it is there.
_timer?.cancel(); class ControlsHoldRegion extends StatefulWidget {
_timer = Timer(widget.hideAfter, _hide); final ControlsHold? hold;
} final Widget child;
const ControlsHoldRegion({super.key, required this.hold, required this.child});
void _hide() { @override
_timer?.cancel(); State<ControlsHoldRegion> createState() => _ControlsHoldRegionState();
if (mounted) widget.shown.value = false; }
class _ControlsHoldRegionState extends State<ControlsHoldRegion> {
@override
void didUpdateWidget(ControlsHoldRegion oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.hold != widget.hold) oldWidget.hold?.set(this, false);
} }
@override @override
Widget build(BuildContext context) => MouseRegion( void dispose() {
opaque: false, widget.hold?.set(this, false);
hitTestBehavior: HitTestBehavior.translucent, super.dispose();
onEnter: (_) => _wake(), }
onHover: (_) => _wake(),
onExit: (_) => _hide(), @override
Widget build(BuildContext context) {
final hold = widget.hold;
if (hold == null) return widget.child;
return MouseRegion(
onEnter: (_) => hold.set(this, true),
onExit: (_) => hold.set(this, false),
child: widget.child, child: widget.child,
); );
}
} }
+216
View File
@@ -0,0 +1,216 @@
import 'dart:async';
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
import 'package:flutter/material.dart';
import 'controls_probe.dart' show ControlsHold;
// The frame both control skins live in: when the controls are up, and the
// double-click on the picture. Mouse movement shows them; [hideAfter]
// without any (or the mouse leaving) hides them and the cursor. Holds keep
// them up: a seek drag (no hover events arrive), an open menu (a route
// takes the mouse), and the session's [ControlsHold] (the mouse on a
// floating pill or card, the Up next popover).
class ControlsFrame extends StatefulWidget {
/// Draws the controls; [visible] false while they are hidden.
final Widget Function(BuildContext context, bool visible) builder;
final VoidCallback? onDoubleClick;
/// Bands at the top and bottom where a double-click is the bars', not
/// the picture's.
final double barTop;
final double barBottom;
final Duration hideAfter;
/// Follows whether the controls are up (for overlays outside them).
final ValueNotifier<bool>? shown;
/// Keeps them up while held.
final ControlsHold? holdBy;
const ControlsFrame({
super.key,
required this.builder,
this.onDoubleClick,
this.barTop = 0,
this.barBottom = 0,
this.hideAfter = const Duration(seconds: 3),
this.shown,
this.holdBy,
});
/// The frame around [context], for widgets that keep it up (a drag, a
/// menu).
static ControlsFrameState? maybeOf(BuildContext context) => context.findAncestorStateOfType<ControlsFrameState>();
/// Runs [open] (a menu, a popover) with the controls around [context]
/// held up, if there are any.
static Future<T> holdWhile<T>(BuildContext context, Future<T> Function() open) {
final frame = maybeOf(context);
return frame == null ? open() : frame.holdDuring(open);
}
/// The fade of the controls.
static const fade = Duration(milliseconds: 150);
@override
State<ControlsFrame> createState() => ControlsFrameState();
}
class ControlsFrameState extends State<ControlsFrame> {
bool _visible = true;
int _holds = 0;
bool _heldBy = false;
Timer? _timer;
bool get visible => _visible;
@override
void initState() {
super.initState();
widget.holdBy?.addListener(_holdByChanged);
// Not during this build: an overlay listening to [shown] may be built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.shown?.value = true;
});
_hideLater();
}
@override
void didUpdateWidget(ControlsFrame oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.holdBy != widget.holdBy) {
oldWidget.holdBy?.removeListener(_holdByChanged);
widget.holdBy?.addListener(_holdByChanged);
_holdByChanged();
}
if (oldWidget.hideAfter != widget.hideAfter && _visible) _hideLater();
}
@override
void dispose() {
widget.holdBy?.removeListener(_holdByChanged);
_timer?.cancel();
super.dispose();
}
void _holdByChanged() {
final on = widget.holdBy?.held ?? false;
if (on == _heldBy || !mounted) return;
_heldBy = on;
hold(on);
}
/// Show the controls and restart the hide countdown.
void wake() {
if (!_visible && mounted) {
setState(() => _visible = true);
widget.shown?.value = true;
}
_hideLater();
}
/// Keep the controls up while [on] (paired calls).
void hold(bool on) {
_holds = on ? _holds + 1 : (_holds - 1).clamp(0, 1 << 20);
wake();
}
/// Runs [open] (a menu) with the controls held up.
Future<T> holdDuring<T>(Future<T> Function() open) async {
hold(true);
try {
return await open();
} finally {
if (mounted) hold(false);
}
}
void _hideLater() {
_timer?.cancel();
_timer = Timer(widget.hideAfter, _hide);
}
void _hide() {
if (!mounted || _holds > 0 || !_visible) return;
setState(() => _visible = false);
widget.shown?.value = false;
}
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: _visible ? SystemMouseCursors.basic : SystemMouseCursors.none,
onEnter: (_) => wake(),
onHover: (_) => wake(),
onExit: (_) {
_timer?.cancel();
_hide();
},
child: Stack(
fit: StackFit.expand,
children: [
if (widget.onDoubleClick != null)
Positioned.fill(
child: DoubleClickListener(
onDoubleClick: widget.onDoubleClick!,
skipTop: _visible ? widget.barTop : 0,
skipBottom: _visible ? widget.barBottom : 0,
child: const ColoredBox(color: Color(0x00000000), child: SizedBox.expand()),
),
),
widget.builder(context, _visible),
],
),
);
}
}
/// Double-click on the picture (not on the bars) without joining the
/// gesture arena, so single clicks on buttons stay instant.
class DoubleClickListener extends StatefulWidget {
final VoidCallback onDoubleClick;
final double skipTop, skipBottom;
final Widget child;
const DoubleClickListener({
super.key,
required this.onDoubleClick,
this.skipTop = 0,
this.skipBottom = 0,
required this.child,
});
@override
State<DoubleClickListener> createState() => _DoubleClickListenerState();
}
class _DoubleClickListenerState extends State<DoubleClickListener> {
DateTime? _last;
Offset? _lastAt;
void _down(PointerDownEvent e) {
if (e.buttons != kPrimaryMouseButton) return;
final h = context.size?.height ?? 0;
final y = e.localPosition.dy;
if (y < widget.skipTop || y > h - widget.skipBottom) {
_last = null;
return;
}
final now = DateTime.now();
final last = _last, at = _lastAt;
if (last != null &&
at != null &&
now.difference(last) < const Duration(milliseconds: 400) &&
(e.localPosition - at).distance < 24) {
_last = null;
widget.onDoubleClick();
return;
}
_last = now;
_lastAt = e.localPosition;
}
@override
Widget build(BuildContext context) =>
Listener(behavior: HitTestBehavior.translucent, onPointerDown: _down, child: widget.child);
}
@@ -0,0 +1,250 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../../queue/queue_ui.dart';
import '../../shell/now_playing_title.dart';
import '../../state/settings.dart';
import '../control_menus.dart';
import '../controls_probe.dart' show ControlsHold;
import '../frame.dart';
import '../model/controls_layout.dart';
import '../model/controls_model.dart';
import '../model/controls_prefs.dart';
import '../volume.dart';
import 'style.dart';
import 'timeline.dart';
// The Minimal skin (inspired by mpv's uosc): a timeline band across the
// bottom that carries the clock, and above it the buttons in two glass
// capsules, in the layout's order. Top: what plays, Up next and the window
// controls. Hidden, the band shrinks to a thin progress line (unless
// switched off).
class MinimalControls extends StatelessWidget {
final PlayerControlsModel model;
final bool visible;
final double scale;
final ControlsHold? hold;
final Widget? windowButtons;
const MinimalControls({
super.key,
required this.model,
required this.visible,
this.scale = 1,
this.hold,
this.windowButtons,
});
static const _rowGap = 8.0;
/// The button row's height at [scale] (a capsule).
static double rowHeight(double scale) => MinimalStyle.button * scale + 2 * MinimalStyle.capsulePad;
/// Buttons and timeline at [scale].
static double bottomHeight(double scale) => rowHeight(scale) + _rowGap + MinimalTimeline.heightFor(scale);
/// The top bar's height.
static const topHeight = 72.0;
@override
Widget build(BuildContext context) {
const fade = ControlsFrame.fade;
Widget shown(Widget child, {Key? key}) => IgnorePointer(
key: key,
ignoring: !visible,
child: AnimatedOpacity(duration: fade, opacity: visible ? 1 : 0, child: child),
);
return Stack(
children: [
Positioned(
left: 0,
right: 0,
top: 0,
child: shown(
key: const ValueKey('minimal-top'),
DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x99000000), Color(0x00000000)],
),
),
child: SizedBox(
height: topHeight + 24,
child: Padding(padding: const EdgeInsets.fromLTRB(16, 0, 10, 24), child: _top()),
),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
shown(
key: const ValueKey('minimal-buttons'),
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, _rowGap),
child: SizedBox(
height: rowHeight(scale),
child: SignalBuilder(
builder: (context) {
final s = model.snapshot.value;
final layout = model.bar.value;
// The clock lives on the timeline.
List<Widget> cluster(List<BarControl> cs) => [
for (final c in cs)
if (c != BarControl.time) _control(context, c, s),
];
return Row(
children: [
MinimalCapsule(scale: scale, children: cluster(layout.left)),
const Spacer(),
MinimalCapsule(scale: scale, children: cluster(layout.right)),
],
);
},
),
),
),
),
SignalBuilder(
builder: (context) {
final line = AppSettings.instance.progressLine.value;
return AnimatedOpacity(
key: const ValueKey('minimal-timeline-wrap'),
duration: fade,
opacity: visible || line ? 1 : 0,
child: MinimalTimeline(
position: model.position.value,
duration: model.duration.value,
buffered: model.buffered.value,
chapters: model.chapters.value,
expanded: visible,
remaining: model.settings.timeDisplay.value == TimeDisplay.remaining,
scale: scale,
onSeek: model.seek,
onToggleTime: model.toggleTimeDisplay,
),
);
},
),
],
),
),
],
);
}
Widget _top() {
final m = model;
return Row(
children: [
const Expanded(
child: Align(alignment: AlignmentDirectional.centerStart, child: NowPlayingTitle()),
),
UpNextButton(queue: m.queue, onPlayNextEpisode: m.playNextEpisode, look: UpNextLook.minimal, hold: hold),
const SizedBox(width: 4),
SignalBuilder(
builder: (context) {
final full = m.fullscreen.value;
return MinimalButton(
key: const ValueKey('minimal-collapse'),
icon: full ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
tooltip: full ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
onPressed: full ? m.toggleFullscreen : m.collapse,
);
},
),
?windowButtons,
],
);
}
Widget _control(BuildContext context, BarControl c, ControlsSnapshot s) {
final m = model;
final key = ValueKey('control-${c.name}');
final enabled = controlEnabled(c, s);
Widget button(VoidCallback? onPressed, {int? badge, bool accent = false}) => MinimalButton(
key: key,
icon: controlIcon(c, s),
tooltip: controlTooltip(c, s),
badge: badge,
accent: accent,
scale: scale,
onPressed: enabled ? onPressed : null,
);
switch (c) {
case BarControl.playPause:
return Padding(
padding: EdgeInsets.only(right: 4 * scale),
child: button(m.togglePlay, accent: true),
);
case BarControl.previous:
return button(m.previous);
case BarControl.next:
return button(m.next);
case BarControl.chapterPrevious:
return button(() => m.stepChapter(-1));
case BarControl.chapterNext:
return button(() => m.stepChapter(1));
case BarControl.volume:
return SignalBuilder(
key: key,
builder: (context) => VolumeControl(
volume: m.volume.value,
onChanged: m.setVolume,
onToggleMute: m.toggleMute,
sliderColor: Theme.of(context).colorScheme.primary,
trackColor: MinimalStyle.dim.withValues(alpha: 0.4),
button: (context, i, color, tip, onPressed) =>
MinimalButton(icon: i, tooltip: tip, color: color, scale: scale, onPressed: onPressed),
),
);
case BarControl.time:
return const SizedBox.shrink();
case BarControl.subtitles || BarControl.audio:
final type = c == BarControl.audio ? JellyfinStreamType.audio : JellyfinStreamType.subtitle;
return SignalBuilder(
key: key,
builder: (context) => Builder(
builder: (context) => MinimalButton(
icon: controlIcon(c, s),
tooltip: controlTooltip(c, s),
badge: trackCount(m.tracks.value, type),
scale: scale,
onPressed: () => openTrackMenu(context, m, type),
),
),
);
case BarControl.settings:
return Builder(
key: key,
builder: (context) => MinimalButton(
icon: controlIcon(c, s),
tooltip: controlTooltip(c, s),
scale: scale,
onPressed: () => openControlsMenu(context, m),
),
);
case BarControl.chat:
return SignalBuilder(
key: key,
builder: (context) => MinimalButton(
icon: controlIcon(c, s),
tooltip: controlTooltip(c, s),
badge: m.unread.value,
scale: scale,
onPressed: m.toggleChat,
),
);
case BarControl.fullscreen:
return button(m.toggleFullscreen);
}
}
}
@@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import '../../widgets/app_menu.dart' show AppMenuStyle;
// The Minimal skin's look: the app's glass and accent (the theme's primary)
// on a thick timeline band, with soft rounded buttons in two glass capsules.
abstract final class MinimalStyle {
/// The timeline band (no blur: it spans the width over the picture).
static const band = Color(0xCC0E0E12);
/// The capsules holding the buttons: the player pills' glass.
static const capsule = AppMenuStyle.pillGlass;
static const hover = Color(0x1FFFFFFF);
static const text = Colors.white;
static const dim = Color(0xB3FFFFFF);
static const radius = 10.0;
static const fontSize = 13.0;
/// The tooltips and the timeline's hover tip.
static const tip = Color(0xEB18181C);
/// A button's box at scale 1.
static const button = 38.0;
/// The capsules' padding around the buttons.
static const capsulePad = 4.0;
}
/// The Minimal skin's tooltip: the app's dark glass colour, above.
class MinimalTooltip extends StatelessWidget {
final String message;
final Widget child;
const MinimalTooltip({super.key, required this.message, required this.child});
@override
Widget build(BuildContext context) {
if (message.isEmpty) return child;
return Tooltip(
message: message,
preferBelow: false,
verticalOffset: 28,
waitDuration: const Duration(milliseconds: 300),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: MinimalStyle.tip,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppMenuStyle.border, width: 0.5),
),
textStyle: const TextStyle(color: MinimalStyle.text, fontSize: 12),
child: child,
);
}
}
/// A Minimal button: a white icon on a rounded square that lights on hover
/// ([accent]: filled with the theme's primary, the play button), an
/// optional [label] and a count [badge]. Never takes focus.
class MinimalButton extends StatefulWidget {
final IconData icon;
final String tooltip;
final VoidCallback? onPressed;
final String? label;
final int? badge;
final bool accent;
final Color? color;
final double scale;
const MinimalButton({
super.key,
required this.icon,
required this.tooltip,
required this.onPressed,
this.label,
this.badge,
this.accent = false,
this.color,
this.scale = 1,
});
@override
State<MinimalButton> createState() => _MinimalButtonState();
}
class _MinimalButtonState extends State<MinimalButton> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final w = widget;
final primary = Theme.of(context).colorScheme.primary;
final onPrimary = Theme.of(context).colorScheme.onPrimary;
final enabled = w.onPressed != null;
final size = MinimalStyle.button * w.scale;
final fg = !enabled
? MinimalStyle.dim.withValues(alpha: 0.35)
: w.accent
? onPrimary
: (w.color ?? MinimalStyle.text);
final bg = w.accent
? (_hover ? Color.lerp(primary, Colors.white, 0.18)! : primary)
: (_hover && enabled ? MinimalStyle.hover : Colors.transparent);
final badge = w.badge;
return MinimalTooltip(
message: w.tooltip,
child: MouseRegion(
cursor: enabled ? SystemMouseCursors.click : SystemMouseCursors.basic,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: w.onPressed,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
height: size,
constraints: BoxConstraints(minWidth: size),
padding: EdgeInsets.symmetric(horizontal: w.label == null ? 0 : 10 * w.scale),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(w.accent ? size / 2 : MinimalStyle.radius * w.scale),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Stack(
clipBehavior: Clip.none,
children: [
Icon(w.icon, color: fg, size: (w.accent ? 26 : 22) * w.scale),
if (badge != null && badge > 0)
Positioned(
right: -7,
top: -5,
child: Container(
key: const ValueKey('minimal-badge'),
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(color: primary, borderRadius: BorderRadius.circular(999)),
child: Text(
'$badge',
style: TextStyle(color: onPrimary, fontSize: 9, height: 1.35, fontWeight: FontWeight.w700),
),
),
),
],
),
if (w.label != null) ...[
SizedBox(width: 6 * w.scale),
Text(
w.label!,
style: TextStyle(
color: fg,
fontSize: MinimalStyle.fontSize * w.scale,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
],
),
),
),
),
);
}
}
/// A glass capsule around a row of Minimal buttons.
class MinimalCapsule extends StatelessWidget {
final List<Widget> children;
final double scale;
const MinimalCapsule({super.key, required this.children, this.scale = 1});
@override
Widget build(BuildContext context) {
final r = BorderRadius.circular(MinimalStyle.radius * scale + MinimalStyle.capsulePad);
return DecoratedBox(
decoration: BoxDecoration(
color: MinimalStyle.capsule,
borderRadius: r,
border: Border.all(color: AppMenuStyle.border, width: 0.5),
),
child: Padding(
padding: const EdgeInsets.all(MinimalStyle.capsulePad),
child: Row(mainAxisSize: MainAxisSize.min, spacing: 2, children: children),
),
);
}
}
@@ -0,0 +1,578 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../playback/media_marks.dart';
import '../frame.dart';
import 'style.dart';
// The Minimal skin's timeline: a band across the bottom with the played
// part tinted in the accent and the position as a line, the part not
// downloaded yet striped, chapter starts as dots on the top edge (click one
// to jump), the elapsed time left and the total or the time left right,
// the buffered seconds by the position while under a minute, and a tip
// with the time (and the chapter title) under the mouse. Hidden, it shrinks
// to a thin progress line.
//
// Inspired by the timeline of uosc (github.com/tomasklaen/uosc, LGPL-2.1,
// by tomasklaen): the striped unbuffered part, the remaining time and the
// thin line when hidden come from there. The drawing, geometry and values
// here are our own.
/// Sizes at scale 1.
abstract final class MinimalTimelineSpec {
/// The band's height while the controls are up.
static const size = 36.0;
/// What is left of it while they are hidden.
static const lineSize = 3.0;
/// The position line.
static const lineWidth = 2.0;
static const fontSize = 15.0;
/// The times' inset from the ends.
static const inset = 12.0;
/// A chapter dot's radius (doubled-ish while hovered).
static const dot = 3.5;
/// The cache label shows while less than this is buffered ahead.
static const bufferedThreshold = Duration(seconds: 60);
/// Collapse / grow.
static const animation = Duration(milliseconds: 120);
}
/// Fraction of [total] at [d], 0..1 (0 without a duration).
double timelineFraction(Duration d, Duration total) {
final t = total.inMicroseconds;
if (t <= 0) return 0;
return (d.inMicroseconds / t).clamp(0.0, 1.0);
}
/// Where the part not downloaded yet starts (it runs to the end), as a
/// fraction; null when nothing known is missing. We know only the end of
/// mpv's cache ahead ([buffered]); a cache within half a second of the end
/// leaves nothing.
double? uncachedFrom({required Duration position, required Duration buffered, required Duration duration}) {
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
final from = buffered > position ? buffered : position;
if (duration - from <= const Duration(milliseconds: 500)) return null;
return timelineFraction(from, duration);
}
/// The cache label by the position ("12s"): the seconds buffered ahead,
/// while under [MinimalTimelineSpec.bufferedThreshold] and the cache does
/// not reach the end.
String? cacheLabel({required Duration position, required Duration buffered, required Duration duration}) {
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
if (duration - buffered <= const Duration(milliseconds: 500)) return null;
final ahead = buffered > position ? buffered - position : Duration.zero;
if (ahead >= MinimalTimelineSpec.bufferedThreshold) return null;
return '${(ahead.inMilliseconds / 1000).round()}s';
}
/// The band's height while it animates between hidden (0) and shown (1).
double minimalBandSize(double visibility, double scale) =>
MinimalTimelineSpec.lineSize +
((MinimalTimelineSpec.size * scale - MinimalTimelineSpec.lineSize) * visibility).roundToDouble();
/// The Minimal timeline. [expanded] false (the controls hidden) shrinks it,
/// animated, to the progress line. Seeks ([onSeek]) on press, on every drag
/// move and on release; a press on a hovered chapter dot jumps to that
/// chapter, one on the right-hand time switches it ([onToggleTime]).
class MinimalTimeline extends StatefulWidget {
final Duration position;
final Duration duration;
/// End of the cached range.
final Duration buffered;
final List<MediaChapter> chapters;
final bool expanded;
/// The right-hand time: the time left instead of the total.
final bool remaining;
final double scale;
final ValueChanged<Duration> onSeek;
final VoidCallback? onToggleTime;
const MinimalTimeline({
super.key,
required this.position,
required this.duration,
this.buffered = Duration.zero,
this.chapters = const [],
this.expanded = true,
this.remaining = true,
this.scale = 1,
required this.onSeek,
this.onToggleTime,
});
static double heightFor(double scale) => MinimalTimelineSpec.size * scale;
static const collapsedHeight = MinimalTimelineSpec.lineSize;
@override
State<MinimalTimeline> createState() => _MinimalTimelineState();
}
class _MinimalTimelineState extends State<MinimalTimeline> {
Offset? _hover;
bool _dragging = false;
double _dragFraction = 0;
Duration? _lastSent;
// A released seek, shown until the position catches up (else the line
// jumps back to the old position for a moment).
double? _pending;
Timer? _pendingTimer;
double get _scale => widget.scale;
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
void _send(Duration d) {
if (d == _lastSent) return;
_lastSent = d;
widget.onSeek(d);
}
void _drag(bool on) {
if (_dragging == on) return;
setState(() => _dragging = on);
ControlsFrame.maybeOf(context)?.hold(on);
}
void _settle(double fraction) {
_pending = fraction;
_pendingTimer?.cancel();
_pendingTimer = Timer(const Duration(seconds: 2), () {
if (mounted) setState(() => _pending = null);
});
}
@override
void didUpdateWidget(MinimalTimeline old) {
super.didUpdateWidget(old);
if (!widget.expanded && old.expanded) _hover = null;
final p = _pending;
if (p != null && widget.duration > Duration.zero) {
final off = (widget.position - _at(p)).abs();
if (off < const Duration(milliseconds: 1500)) {
_pending = null;
_pendingTimer?.cancel();
}
}
}
@override
void dispose() {
_pendingTimer?.cancel();
// A drag cut short by a rebuild must not keep the controls up.
if (_dragging) ControlsFrame.maybeOf(context)?.hold(false);
super.dispose();
}
double _dotRadius(bool hovered) => MinimalTimelineSpec.dot * _scale * (hovered ? 1.7 : 1);
/// The chapter whose dot is within reach of [at].
int? _hoveredChapter(double width, List<MediaChapter> marks, Offset? at) {
if (at == null || _dragging) return null;
final reach = _dotRadius(false) * 2.5;
int? best;
var bestD = double.infinity;
for (var i = 0; i < marks.length; i++) {
final cx = width * timelineFraction(marks[i].start, widget.duration);
final d = (Offset(cx, 0) - at).distance;
if (d <= reach && d < bestD) {
best = i;
bestD = d;
}
}
return best;
}
String? _rightText(Duration shown) {
final d = widget.duration;
if (d <= Duration.zero) return null;
if (!widget.remaining) return formatClock(d);
final left = d - shown;
return '-${formatClock(left.isNegative ? Duration.zero : left)}';
}
// The painter's text takes the app's font (a TextPainter does not
// inherit it).
String? _font;
TextStyle get _timeStyle => TextStyle(
fontFamily: _font,
color: MinimalStyle.text,
fontSize: MinimalTimelineSpec.fontSize * _scale,
height: 1,
fontWeight: FontWeight.w500,
fontFeatures: const [FontFeature.tabularFigures()],
);
/// Where the right-hand time sits (a press there switches it).
bool _onRightTime(Offset at, double width, String? text) {
if (text == null || widget.onToggleTime == null) return false;
final tp = TextPainter(
text: TextSpan(text: text, style: _timeStyle),
textDirection: TextDirection.ltr,
)..layout();
return at.dx >= width - MinimalTimelineSpec.inset * _scale - tp.width - 6;
}
@override
Widget build(BuildContext context) {
_font = DefaultTextStyle.of(context).style.fontFamily ?? Theme.of(context).textTheme.bodyMedium?.fontFamily;
return LayoutBuilder(
builder: (context, constraints) => TweenAnimationBuilder<double>(
tween: Tween(end: widget.expanded ? 1 : 0),
duration: MinimalTimelineSpec.animation,
builder: (context, visibility, _) => _band(context, constraints.maxWidth, visibility),
),
);
}
Widget _band(BuildContext context, double width, double visibility) {
final expanded = widget.expanded;
final duration = widget.duration;
final size = minimalBandSize(visibility, _scale);
final progress = _dragging ? _dragFraction : (_pending ?? timelineFraction(widget.position, duration));
final shown = _at(progress);
final marks = duration > Duration.zero ? widget.chapters : const <MediaChapter>[];
final hover = expanded ? _hover : null;
final hovered = _hoveredChapter(width, marks, hover);
final hoverFraction = hovered != null
? timelineFraction(marks[hovered].start, duration)
: (hover == null || duration <= Duration.zero || width <= 0 ? null : (hover.dx / width).clamp(0.0, 1.0));
final hoverAt = hoverFraction == null ? null : _at(hoverFraction);
final cursorX = hoverFraction == null ? null : width * hoverFraction;
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
// Text fades out before it would overflow the shrinking band.
final minText = MinimalTimelineSpec.fontSize * _scale;
final textOpacity = ((size - minText) / (minText / 2)).clamp(0.0, 1.0);
final right = _rightText(shown);
final primary = Theme.of(context).colorScheme.primary;
final painter = MinimalTimelinePainter(
progress: progress,
visibility: visibility,
accent: primary,
scale: _scale,
uncached: uncachedFrom(position: widget.position, buffered: widget.buffered, duration: duration),
chapters: [for (final c in marks) timelineFraction(c.start, duration)],
hoveredChapter: hovered,
hoverX: hovered == null ? cursorX : null,
textOpacity: textOpacity,
elapsed: duration > Duration.zero ? formatClock(shown) : null,
right: right,
cache: cacheLabel(position: widget.position, buffered: widget.buffered, duration: duration),
timeStyle: _timeStyle,
);
final bar = SizedBox(
key: const ValueKey('minimal-timeline'),
width: width,
height: size,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: RepaintBoundary(child: CustomPaint(painter: painter)),
),
if (hoverAt != null && cursorX != null)
Positioned(
left: 0,
right: 0,
bottom: size + 8,
height: 120,
child: IgnorePointer(
child: CustomMultiChildLayout(
delegate: _TipLayout(x: cursorX),
children: [
LayoutId(
id: #tip,
child: _HoverTip(time: formatClock(hoverAt), chapter: hoverTitle, scale: _scale),
),
],
),
),
),
],
),
);
if (!expanded) return IgnorePointer(child: bar);
Offset clampTo(Offset p) => Offset(p.dx.clamp(0.0, width), p.dy);
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (e) => setState(() => _hover = clampTo(e.localPosition)),
onHover: (e) => setState(() => _hover = clampTo(e.localPosition)),
onExit: (_) {
if (!_dragging) setState(() => _hover = null);
},
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (e) {
if (width <= 0) return;
final at = clampTo(e.localPosition);
if (_onRightTime(at, width, right)) {
widget.onToggleTime?.call();
return;
}
if (duration <= Duration.zero) return;
final chapter = _hoveredChapter(width, marks, at);
_lastSent = null;
if (chapter != null) {
_settle(timelineFraction(marks[chapter].start, duration));
_send(marks[chapter].start);
setState(() {});
return;
}
_pending = null;
_dragFraction = (at.dx / width).clamp(0.0, 1.0);
_drag(true);
_send(_at(_dragFraction));
},
onPointerMove: (e) {
if (!_dragging) return;
final at = clampTo(e.localPosition);
setState(() {
_hover = at;
_dragFraction = (at.dx / width).clamp(0.0, 1.0);
});
_send(_at(_dragFraction));
},
onPointerUp: (e) {
if (!_dragging) return;
_send(_at(_dragFraction));
_settle(_dragFraction);
final p = e.localPosition;
if (p.dx < 0 || p.dx > width || p.dy < 0 || p.dy > size) _hover = null;
_drag(false);
},
onPointerCancel: (_) => _drag(false),
child: bar,
),
);
}
}
class _HoverTip extends StatelessWidget {
final String time;
final String chapter;
final double scale;
const _HoverTip({required this.time, required this.chapter, required this.scale});
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxWidth: 360),
padding: EdgeInsets.symmetric(horizontal: 10 * scale, vertical: 5 * scale),
decoration: BoxDecoration(
color: MinimalStyle.tip,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0x1FFFFFFF), width: 0.5),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (chapter.isNotEmpty)
Text(
chapter,
key: const ValueKey('minimal-hover-chapter'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: MinimalStyle.text, fontSize: 13 * scale, fontWeight: FontWeight.w600),
),
Text(
time,
key: const ValueKey('minimal-hover-time'),
style: TextStyle(
color: chapter.isEmpty ? MinimalStyle.text : MinimalStyle.dim,
fontSize: 13 * scale,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
);
}
}
/// The hover tip centred on [x], kept inside the band's ends.
class _TipLayout extends MultiChildLayoutDelegate {
final double x;
_TipLayout({required this.x});
@override
void performLayout(Size size) {
const gap = 4.0;
final tip = layoutChild(#tip, BoxConstraints.loose(size));
final left = (x - tip.width / 2).clamp(gap, math.max(gap, size.width - gap - tip.width)).toDouble();
positionChild(#tip, Offset(left, size.height - tip.height));
}
@override
bool shouldRelayout(_TipLayout old) => old.x != x;
}
/// Paints the band: background, the played tint and the (white) position
/// line, the stripes over the part not downloaded, chapter marks, the
/// times, the hover line.
class MinimalTimelinePainter extends CustomPainter {
/// The playing position, 0..1.
final double progress;
/// 0 hidden .. 1 shown.
final double visibility;
final Color accent;
final double scale;
/// Start of the part not downloaded (to the end), as a fraction.
final double? uncached;
/// Chapter starts, as fractions.
final List<double> chapters;
final int? hoveredChapter;
final double? hoverX;
final double textOpacity;
final String? elapsed;
final String? right;
final String? cache;
final TextStyle timeStyle;
const MinimalTimelinePainter({
required this.progress,
this.visibility = 1,
this.accent = Colors.white,
this.scale = 1,
this.uncached,
this.chapters = const [],
this.hoveredChapter,
this.hoverX,
this.textOpacity = 1,
this.elapsed,
this.right,
this.cache,
this.timeStyle = const TextStyle(fontSize: MinimalTimelineSpec.fontSize),
});
/// x of the position line's centre.
double lineX(double width) {
final lw = MinimalTimelineSpec.lineWidth * scale;
return (width * progress).clamp(lw / 2, math.max(lw / 2, width - lw / 2));
}
@override
void paint(Canvas canvas, Size size) {
final w = size.width, h = size.height;
final x = lineX(w);
final band = Offset.zero & size;
// The band, darker as it grows; hidden, a faint track.
canvas.drawRect(band, Paint()..color = Color.lerp(const Color(0x40FFFFFF), MinimalStyle.band, visibility)!);
// Played: an accent tint, solid once the band is a line.
canvas.drawRect(
Rect.fromLTRB(0, 0, x, h),
Paint()..color = accent.withValues(alpha: 0.28 + 0.72 * (1 - visibility)),
);
_stripes(canvas, w, h);
_chapters(canvas, w, h);
if (visibility > 0) {
final lw = MinimalTimelineSpec.lineWidth * scale;
canvas.drawRect(
Rect.fromLTRB(x - lw / 2, 0, x + lw / 2, h),
Paint()..color = Colors.white.withValues(alpha: visibility),
);
}
if (textOpacity > 0) _times(canvas, w, h, x);
final hx = hoverX;
if (hx != null) {
canvas.drawRect(Rect.fromLTRB(hx - 0.5, 0, hx + 0.5, h), Paint()..color = Colors.white.withValues(alpha: 0.45));
}
}
/// Thin 45° stripes over the part not downloaded yet.
void _stripes(Canvas canvas, double w, double h) {
final from = uncached;
if (from == null) return;
final ax = (w * from).floorToDouble();
if (ax >= w) return;
final period = 7.0 * scale;
canvas.save();
canvas.clipRect(Rect.fromLTRB(ax, 0, w, h));
final paint = Paint()
..color = Colors.white.withValues(alpha: 0.10 + 0.08 * (1 - visibility))
..strokeWidth = 1.5 * scale;
for (var c = (ax / period).floorToDouble() * period - h; c <= w; c += period) {
canvas.drawLine(Offset(c, h), Offset(c + h, 0), paint);
}
canvas.restore();
}
void _chapters(Canvas canvas, double w, double h) {
final cut = Paint()..color = Colors.black.withValues(alpha: 0.55);
for (final f in chapters) {
if (f <= 0) continue;
final cx = w * f;
canvas.drawRect(Rect.fromLTRB(cx - 0.5, 0, cx + 0.5, h), cut);
}
if (visibility <= 0) return;
final r = MinimalTimelineSpec.dot * scale * visibility;
void dot(double f, double radius, Color fill) {
final c = Offset(w * f, 0);
canvas.drawCircle(c, radius + 1, Paint()..color = Colors.black.withValues(alpha: 0.7));
canvas.drawCircle(c, radius, Paint()..color = fill);
}
// Not at 0: the band's start says that already.
for (var i = 0; i < chapters.length; i++) {
if (i != hoveredChapter && chapters[i] > 0) dot(chapters[i], r, Colors.white.withValues(alpha: 0.9));
}
final hv = hoveredChapter;
if (hv != null && hv < chapters.length) dot(chapters[hv], r * 1.7, accent);
}
TextPainter _text(String s, TextStyle style) => TextPainter(
text: TextSpan(text: s, style: style),
textDirection: TextDirection.ltr,
)..layout();
/// White text with a soft shadow, so it reads over the accent tint too.
void _label(Canvas canvas, String s, TextStyle style, Offset at) {
final o = textOpacity;
_text(
s,
style.copyWith(
color: (style.color ?? Colors.white).withValues(alpha: o),
shadows: [Shadow(color: Colors.black.withValues(alpha: 0.8 * o), blurRadius: 3)],
),
).paint(canvas, at);
}
void _times(Canvas canvas, double w, double h, double lineX) {
final inset = MinimalTimelineSpec.inset * scale;
final fs = timeStyle.fontSize ?? MinimalTimelineSpec.fontSize;
final cy = h / 2;
final e = elapsed, r = right;
final ew = e == null ? 0.0 : _text(e, timeStyle).width;
final rw = r == null ? 0.0 : _text(r, timeStyle).width;
final c = cache;
if (c != null) {
final cs = timeStyle.copyWith(fontSize: fs * 0.8, color: MinimalStyle.dim);
final cw = _text(c, cs).width;
const margin = 6.0;
var x = lineX + margin;
final minX = inset + margin + ew, maxX = w - inset - margin - rw;
if (x < minX) {
x = minX;
} else if (x + cw > maxX) {
x = maxX - cw;
}
_label(canvas, c, cs, Offset(x, cy - fs * 0.4));
}
if (e != null) _label(canvas, e, timeStyle, Offset(inset, cy - fs / 2));
if (r != null) _label(canvas, r, timeStyle, Offset(w - inset - rw, cy - fs / 2));
}
@override
bool shouldRepaint(MinimalTimelinePainter old) => true;
}
@@ -0,0 +1,235 @@
import 'package:flutter/material.dart' show IconData, Icons;
import '../../playback/media_marks.dart' show formatClock;
import 'controls_prefs.dart';
// The controls layout, defined once for both skins: which controls the
// bottom bar has, in which order, which actions sit in the ⚙ menu instead,
// and their icons, tooltips and labels. Pure: everything here is a
// function of a [ControlsSnapshot] and the user's choices, so tests cover
// it without a player.
/// What playback does as this viewer sees it. In a server "play" is being
/// ready: the video plays once everyone is.
enum PlayState {
/// The video plays (the button pauses: you go unready).
playing,
/// You are ready; the others are not yet (the button unreadies you).
waiting,
/// You are not ready (the button readies you).
paused,
/// The media ended.
finished,
}
/// A control in the bottom bar.
enum BarControl {
playPause,
/// Previous episode.
previous,
/// Up next's first item, else the next episode.
next,
chapterPrevious,
chapterNext,
volume,
time,
subtitles,
audio,
/// ⚙: everything that is not a bar button.
settings,
chat,
fullscreen,
}
/// An action in the ⚙ menu.
enum ControlAction {
chapters('Chapters…', null),
subtitles('Subtitles…', null),
audio('Audio…', null),
skipForward('Skip 1:30', 'Shift+Right'),
shaders('Upscaling shader…', 'Ctrl+0…6'),
chat('Chat & activity', 'Tab'),
changeVideo('Change video', null),
copyLink('Copy link', null),
reload('Reload video', 'Ctrl+R'),
markAllReady('Mark everyone ready', null),
markProblem('Mark a problem', 'Ctrl+Shift+B'),
playerSettings('Player settings…', null),
leave('Leave server', null);
final String label;
final String? hint;
const ControlAction(this.label, this.hint);
}
/// The bar's order, left cluster then right cluster. The play button is
/// always first and fullscreen always last; controls that do not apply
/// (no episode, no chapters) or that the user hid are left out, the rest
/// keep their order.
abstract final class ControlsLayout {
static const left = [
BarControl.playPause,
BarControl.previous,
BarControl.next,
BarControl.chapterPrevious,
BarControl.chapterNext,
BarControl.volume,
BarControl.time,
];
static const right = [
BarControl.subtitles,
BarControl.audio,
BarControl.settings,
BarControl.chat,
BarControl.fullscreen,
];
/// The ⚙ menu's order.
static const menu = ControlAction.values;
}
/// What the layout depends on, at one moment.
class ControlsSnapshot {
final PlayState play;
/// The previous episode's label; null: none.
final String? previousLabel;
/// Up next's first item's title, else the next episode's label; null:
/// nothing next.
final String? nextLabel;
/// [nextLabel] is Up next (not an episode).
final bool nextIsUpNext;
final bool hasChapters;
/// The media's length is known (seeks and skips make sense).
final bool hasDuration;
/// Watching alone (no one to mark ready).
final bool solo;
/// A link to copy (`SessionCoordinator.currentLink`).
final bool hasLink;
final bool fullscreen;
final bool chatOpen;
const ControlsSnapshot({
this.play = PlayState.paused,
this.previousLabel,
this.nextLabel,
this.nextIsUpNext = false,
this.hasChapters = false,
this.hasDuration = false,
this.solo = false,
this.hasLink = false,
this.fullscreen = false,
this.chatOpen = false,
});
}
/// The bar's two clusters for [s], in [ControlsLayout] order.
({List<BarControl> left, List<BarControl> right}) barControls(ControlsSnapshot s, Set<OptionalControl> hidden) {
bool on(BarControl c) => switch (c) {
BarControl.previous => s.previousLabel != null,
BarControl.next => s.nextLabel != null,
BarControl.chapterPrevious || BarControl.chapterNext => s.hasChapters && !hidden.contains(OptionalControl.chapters),
BarControl.subtitles => !hidden.contains(OptionalControl.subtitles),
BarControl.audio => !hidden.contains(OptionalControl.audio),
BarControl.chat => !hidden.contains(OptionalControl.chat),
_ => true,
};
return (
left: [
for (final c in ControlsLayout.left)
if (on(c)) c,
],
right: [
for (final c in ControlsLayout.right)
if (on(c)) c,
],
);
}
/// The ⚙ menu for [s]: every action that applies, minus those the bar
/// already shows.
List<ControlAction> menuActions(ControlsSnapshot s, Set<OptionalControl> hidden) => [
for (final a in ControlsLayout.menu)
if (switch (a) {
ControlAction.chapters => s.hasChapters,
ControlAction.subtitles => hidden.contains(OptionalControl.subtitles),
ControlAction.audio => hidden.contains(OptionalControl.audio),
ControlAction.skipForward => s.hasDuration,
ControlAction.chat => hidden.contains(OptionalControl.chat),
ControlAction.copyLink => s.hasLink,
ControlAction.markAllReady => !s.solo,
_ => true,
})
a,
];
/// Whether a bar control does something now (a skin dims it otherwise).
bool controlEnabled(BarControl c, ControlsSnapshot s) => switch (c) {
BarControl.chapterPrevious || BarControl.chapterNext => s.hasChapters && s.hasDuration,
BarControl.previous => s.previousLabel != null,
BarControl.next => s.nextLabel != null,
_ => true,
};
/// The icon of a bar control (volume and the clock draw their own).
IconData controlIcon(BarControl c, ControlsSnapshot s) => switch (c) {
BarControl.playPause => switch (s.play) {
PlayState.playing => Icons.pause_rounded,
// Ready: the check says so; a click unreadies.
PlayState.waiting => Icons.check_rounded,
PlayState.finished => Icons.replay_rounded,
PlayState.paused => Icons.play_arrow_rounded,
},
BarControl.previous => Icons.skip_previous_rounded,
BarControl.next => Icons.skip_next_rounded,
BarControl.chapterPrevious => Icons.chevron_left_rounded,
BarControl.chapterNext => Icons.chevron_right_rounded,
BarControl.volume => Icons.volume_up_rounded,
BarControl.time => Icons.schedule_rounded,
BarControl.subtitles => Icons.subtitles_outlined,
BarControl.audio => Icons.graphic_eq_rounded,
BarControl.settings => Icons.settings_outlined,
BarControl.chat => s.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded,
BarControl.fullscreen => s.fullscreen ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded,
};
/// The tooltip of a bar control, with its key.
String controlTooltip(BarControl c, ControlsSnapshot s) => switch (c) {
BarControl.playPause => switch (s.play) {
PlayState.playing => 'Pause for everyone (Space)',
PlayState.waiting => 'Ready, waiting for the others (Space: not ready)',
PlayState.paused => s.solo ? 'Play (Space)' : 'Ready: play when everyone is (Space)',
PlayState.finished => 'Ready (Space)',
},
BarControl.previous => 'Previous: ${s.previousLabel ?? ''} (< or Shift+P)',
BarControl.next => '${s.nextIsUpNext ? 'Up next' : 'Next'}: ${s.nextLabel ?? ''} (> or Shift+N)',
BarControl.chapterPrevious => 'Previous chapter (PgUp)',
BarControl.chapterNext => 'Next chapter (PgDn)',
BarControl.volume => 'Volume (scroll, ↑ / ↓)',
BarControl.time => 'Elapsed / remaining (click to switch)',
BarControl.subtitles => 'Subtitles',
BarControl.audio => 'Audio',
BarControl.settings => 'Settings and more',
BarControl.chat => 'Chat & activity (Tab)',
BarControl.fullscreen => s.fullscreen ? 'Exit fullscreen (F, Esc)' : 'Fullscreen (F)',
};
/// The bar's clock: "6:00 / 24:00", or the time left ("-18:00"); "0:00"
/// parts while the length is unknown.
String clockText(Duration position, Duration duration, TimeDisplay display) {
if (display == TimeDisplay.remaining && duration > Duration.zero) {
final left = duration - position;
return '-${formatClock(left.isNegative ? Duration.zero : left)}';
}
return '${formatClock(position)} / ${formatClock(duration)}';
}
@@ -0,0 +1,214 @@
import 'package:flutter/foundation.dart';
import 'package:signals/signals_flutter.dart';
import '../../jellyfin/track_prefs.dart' show MpvTrack;
import '../../playback/media_marks.dart';
import '../../playback/playback_controller.dart';
import '../../queue/queue_controller.dart';
import '../../session/session_coordinator.dart';
import '../../shaders/shader_presets.dart';
import '../../state/session_state.dart';
import '../../state/settings.dart';
import '../player_settings.dart' show applyShaderChoice;
import '../volume.dart' as vol;
import 'controls_layout.dart';
/// A track pick from a track menu: audio id, subtitle id, or off.
typedef TrackPick = ({int? aid, int? sid, bool subtitlesOff});
/// The player controls, independent of how they look: what the controls
/// show (state, as signals) and what they do (actions), wired to the
/// session once. Both skins only draw this and call its actions, so a
/// control added here shows in both. Built by the session player per
/// mount; [fullscreen], [chatOpen] and [unread] are the shell's, passed in.
class PlayerControlsModel {
final SessionCoordinator coord;
final ReadonlySignal<bool> fullscreen;
final ReadonlySignal<bool> chatOpen;
final ReadonlySignal<int> unread;
final VoidCallback _toggleFullscreen;
final VoidCallback _toggleChat;
final VoidCallback _changeVideo;
final VoidCallback _collapse;
final VoidCallback _leave;
PlayerControlsModel({
required this.coord,
required this.fullscreen,
required this.chatOpen,
required this.unread,
required VoidCallback onToggleFullscreen,
required VoidCallback onToggleChat,
required VoidCallback onChangeVideo,
required VoidCallback onCollapse,
required VoidCallback onLeave,
}) : _toggleFullscreen = onToggleFullscreen,
_toggleChat = onToggleChat,
_changeVideo = onChangeVideo,
_collapse = onCollapse,
_leave = onLeave;
PlaybackController get playback => coord.playback;
QueueController get queue => coord.queue;
AppSettings get settings => AppSettings.instance;
// ---------------------------------------------------------------- state
ReadonlySignal<Duration> get position => playback.position;
ReadonlySignal<Duration> get duration => playback.duration;
/// End of the cached range ahead of the position.
ReadonlySignal<Duration> get buffered => playback.buffered;
ReadonlySignal<List<MediaChapter>> get chapters => playback.chapters;
ReadonlySignal<int?> get currentChapter => playback.currentChapter;
ReadonlySignal<List<MpvTrack>> get tracks => playback.tracks;
/// 0..`maxVolume`.
ReadonlySignal<double> get volume => playback.volume;
ReadonlySignal<SkipOffer?> get skipOffer => coord.skipOffer;
ReadonlySignal<RenderMode> get renderMode => playback.renderMode;
/// Play / pause as the session sees it ([PlayState]).
late final Computed<PlayState> play = computed(() {
final r = coord.serverState.value;
final self = coord.selfMember.value;
return switch (r?.transport) {
'playing' => PlayState.playing,
'finished' => PlayState.finished,
_ => (self?.ready ?? false) && (self?.inSet ?? false) ? PlayState.waiting : PlayState.paused,
};
});
/// Everything the layout depends on (not the position: that changes
/// four times a second and only the timeline needs it).
late final Computed<ControlsSnapshot> snapshot = computed(() {
final items = queue.items.value;
final next = coord.nextEpisode.value;
return ControlsSnapshot(
play: play.value,
previousLabel: coord.previousEpisode.value?.label,
nextLabel: items.isNotEmpty ? items.first.title : next?.label,
nextIsUpNext: items.isNotEmpty,
hasChapters: chapters.value.isNotEmpty,
hasDuration: duration.value > Duration.zero,
solo: SessionState.instance.solo.value,
// Not a signal: read again whenever the state changes.
hasLink: coord.serverState.value != null && coord.currentLink() != null,
fullscreen: fullscreen.value,
chatOpen: chatOpen.value,
);
});
/// The bar's clusters, in the layout's order.
late final Computed<({List<BarControl> left, List<BarControl> right})> bar = computed(
() => barControls(snapshot.value, settings.hiddenControls.value),
);
/// What the ⚙ menu lists now.
List<ControlAction> menu() => menuActions(snapshot.peek(), settings.hiddenControls.peek());
/// Ticks on each wheel / key volume step: the skins flash the volume.
final volumeFlash = ValueNotifier(0);
/// A menu of the controls is open (the player's Esc closes it).
int menusOpen = 0;
void dispose() => volumeFlash.dispose();
// -------------------------------------------------------------- actions
/// Space: ready / not ready (being ready is "play" in a server).
void togglePlay() => coord.toggleReady();
/// A seek bar's press or drag; through `player.seek`, the user-seek
/// path, so everyone follows.
void seek(Duration to) => playback.player.seek(to);
/// Arrow keys: ±[secs], relative to a seek still in flight.
void nudge(double secs) => coord.nudge(secs);
/// +1:30 for everyone.
void skipForward() => coord.skipForward();
/// A skip pill (Skip OP / ED / 1:30).
void skip(SkipOffer offer) => coord.skip(offer);
/// PgDn (+1) / PgUp (-1). False when there is no chapter to go to.
bool stepChapter(int delta) => coord.stepChapter(delta);
void pickChapter(MediaChapter c) => coord.seekTo(c.start.inMicroseconds / 1e6);
/// Up next's first item, else the next episode. False when neither.
bool next() {
if (queue.items.peek().isNotEmpty) {
queue.skip();
return true;
}
if (coord.nextEpisode.peek() == null) return false;
coord.playNextEpisode();
return true;
}
/// The previous episode. False without one.
bool previous() {
if (coord.previousEpisode.peek() == null) return false;
coord.playPreviousEpisode();
return true;
}
void playNextEpisode() => coord.playNextEpisode();
void setVolume(double v) => playback.player.setVolume(v.clamp(0.0, vol.maxVolume));
/// One volume step up (+1) or down (-1) from the wheel or ↑ / ↓;
/// flashes the volume readout.
void stepVolume(int step) {
setVolume(vol.stepVolume(volume.peek(), step * vol.volumeStep));
volumeFlash.value++;
}
double _beforeMute = 100;
/// The speaker: mute, or back to the volume before.
void toggleMute() {
final v = volume.peek();
if (v > 0) {
_beforeMute = v;
setVolume(0);
} else {
setVolume(_beforeMute <= 0 ? 100 : _beforeMute);
}
}
void pickTrack(TrackPick p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff);
/// Saved as the choice and applied live; a null quality keeps the
/// saved one.
void pickShader(ShaderPreset preset, ShaderQuality? quality) => applyShaderChoice(preset, quality);
void toggleTimeDisplay() => settings.setTimeDisplay(settings.timeDisplay.peek().toggled);
void toggleFullscreen() => _toggleFullscreen();
void toggleChat() {
coord.markInput();
_toggleChat();
}
void changeVideo() => _changeVideo();
/// Back to the library (keeps playing in the mini player).
void collapse() => _collapse();
/// Asks before leaving the server.
void leave() => _leave();
void reload() {
coord.markInput();
coord.reloadVideo();
}
void markAllReady() => coord.forcePlayAll();
String? link() => coord.currentLink();
}
@@ -0,0 +1,64 @@
// The player controls' user choices (Settings → Player, stored in
// `AppSettings`). Plain values: the layout spec and both skins read them.
/// The controls' look. Both skins draw the same controls model in the same
/// order ([ControlsLayout]); only the look differs.
enum ControlsSkin {
/// Material-style bar: a seek bar over a row of round icon buttons.
standard('Standard'),
/// The app's own timeline skin (inspired by mpv's uosc): a thick band
/// with the buffered part, chapters and times, flat glass buttons.
minimal('Minimal');
final String label;
const ControlsSkin(this.label);
/// A stored name; the old `mpvControls` flag (true: the uosc design)
/// when there is none.
static ControlsSkin fromStored(Object? name, {Object? legacyMpvControls}) {
for (final s in values) {
if (s.name == name) return s;
}
return legacyMpvControls == true ? minimal : standard;
}
}
/// The bar's clock: "6:00 / 24:00" or the time left ("-18:00").
enum TimeDisplay {
elapsed('Elapsed / total'),
remaining('Remaining');
final String label;
const TimeDisplay(this.label);
TimeDisplay get toggled => this == elapsed ? remaining : elapsed;
}
/// Bar and button size.
enum ControlsSize {
normal('Normal', 1.0),
large('Large', 1.25);
final String label;
final double scale;
const ControlsSize(this.label, this.scale);
}
/// Bar buttons the user may hide; hidden ones are still in ⚙.
enum OptionalControl {
chapters('Chapter buttons', 'Previous / next chapter (PgUp / PgDn)'),
subtitles('Subtitles', 'The subtitle track list'),
audio('Audio', 'The audio track list'),
chat('Chat', 'Chat & activity (Tab)');
final String label;
final String help;
const OptionalControl(this.label, this.help);
}
/// The auto-hide delays offered (seconds).
const controlsHideChoices = [2, 3, 5];
/// The default auto-hide delay (seconds).
const defaultControlsHideSecs = 3;
@@ -8,13 +8,15 @@ import '../jellyfin/track_prefs.dart' show MpvTrack;
import '../playback/playback_controller.dart' show PlaybackController, RenderMode; import '../playback/playback_controller.dart' show PlaybackController, RenderMode;
import '../shaders/shader_presets.dart'; import '../shaders/shader_presets.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../shell/mark_problem.dart';
import '../shell/motion.dart'; import '../shell/motion.dart';
import '../state/settings.dart'; import '../state/settings.dart';
import 'uosc/buttons.dart'; import '../widgets/app_menu.dart';
import 'uosc/controls.dart'; import 'frame.dart';
import 'model/controls_prefs.dart';
// Everything about the player, defined once: the rows of the ⚙ card in // Everything about the player, defined once: the rows of the player
// both control designs and of Settings → Player. Each row reads and // settings card (⚙ → Player settings, both skins) and of Settings → Player. Each row reads and
// writes [AppSettings] and applies to a running player right away. // writes [AppSettings] and applies to a running player right away.
/// One setting: a label, one line of help under it, the control at the /// One setting: a label, one line of help under it, the control at the
@@ -56,29 +58,16 @@ class SettingRow extends StatelessWidget {
} }
} }
DropdownButton<T> _dropdown<T>({ AppSelect<T> _dropdown<T>({
required Key key, required Key key,
required T value, required T value,
required List<(T, String)> items, required List<(T, String)> items,
required ValueChanged<T> onChanged, required ValueChanged<T> onChanged,
bool expanded = false, bool expanded = false,
}) => }) =>
DropdownButton<T>( AppSelect<T>(key: key, value: value, items: items, onChanged: onChanged, expanded: expanded);
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. /// The controls' skin: Standard or Minimal. Live.
class PlayerDesignSetting extends StatelessWidget { class PlayerDesignSetting extends StatelessWidget {
const PlayerDesignSetting({super.key}); const PlayerDesignSetting({super.key});
@@ -88,12 +77,100 @@ class PlayerDesignSetting extends StatelessWidget {
return SignalBuilder( return SignalBuilder(
builder: (context) => SettingRow( builder: (context) => SettingRow(
label: 'Player design', label: 'Player design',
help: 'uosc is modeled on mpv: a thick timeline with chapters and what is cached', help: 'Minimal has a thick timeline with chapters and what is downloaded yet',
control: _dropdown<bool>( control: _dropdown<ControlsSkin>(
key: const ValueKey('player-design'), key: const ValueKey('player-design'),
value: s.mpvControls.value, value: s.controlsSkin.value,
items: const [(false, 'Standard'), (true, 'uosc')], items: [for (final k in ControlsSkin.values) (k, k.label)],
onChanged: s.setMpvControls, onChanged: s.setControlsSkin,
),
),
);
}
}
/// The bar's clock: elapsed / total or the time left. Live.
class TimeDisplaySetting extends StatelessWidget {
const TimeDisplaySetting({super.key});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SettingRow(
label: 'Time display',
help: 'Also switched by a click on the time',
control: _dropdown<TimeDisplay>(
key: const ValueKey('time-display'),
value: s.timeDisplay.value,
items: [for (final t in TimeDisplay.values) (t, t.label)],
onChanged: s.setTimeDisplay,
),
),
);
}
}
/// Normal or large bar and buttons. Live.
class ControlsSizeSetting extends StatelessWidget {
const ControlsSizeSetting({super.key});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SettingRow(
label: 'Controls size',
help: 'Large makes the bar and its buttons a quarter bigger',
control: _dropdown<ControlsSize>(
key: const ValueKey('controls-size'),
value: s.controlsSize.value,
items: [for (final z in ControlsSize.values) (z, z.label)],
onChanged: s.setControlsSize,
),
),
);
}
}
/// How long the controls stay up without mouse movement. Live.
class HideDelaySetting extends StatelessWidget {
const HideDelaySetting({super.key});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SettingRow(
label: 'Hide the controls after',
help: 'Without mouse movement over the video',
control: _dropdown<int>(
key: const ValueKey('controls-hide'),
value: s.controlsHideSecs.value,
items: [for (final secs in controlsHideChoices) (secs, '$secs s')],
onChanged: s.setControlsHideSecs,
),
),
);
}
}
/// One optional bar button on or off (off: it is still in ⚙). Live.
class BarButtonSetting extends StatelessWidget {
final OptionalControl control;
const BarButtonSetting({super.key, required this.control});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SettingRow(
label: control.label,
help: control.help,
control: Switch(
key: ValueKey('bar-${control.name}'),
value: !s.hiddenControls.value.contains(control),
onChanged: (on) => s.setControlShown(control, on),
), ),
), ),
); );
@@ -307,7 +384,20 @@ class TrackSetting extends StatelessWidget {
} }
} }
/// The player rows of Settings → Player and the ⚙ card, in order. /// The controls' own choices (Settings → Player → Controls).
List<Widget> controlsSettingRows() => [
const TimeDisplaySetting(),
const ControlsSizeSetting(),
const HideDelaySetting(),
const Padding(
padding: EdgeInsets.only(top: 10, bottom: 2),
child: Text('Buttons in the bar (hidden ones stay in ⚙)', style: TextStyle(fontWeight: FontWeight.w600)),
),
for (final c in OptionalControl.values) BarButtonSetting(control: c),
];
/// The player rows of Settings → Player and the player settings card, in
/// order.
List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [ List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
const PlayerDesignSetting(), const PlayerDesignSetting(),
const ProgressLineSetting(), const ProgressLineSetting(),
@@ -315,7 +405,8 @@ List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
const HwdecSetting(), const HwdecSetting(),
]; ];
/// The ⚙ card: every player setting, plus the running player's tracks. /// The player settings card: every player setting, plus the running
/// player's tracks.
class PlayerSettingsCard extends StatelessWidget { class PlayerSettingsCard extends StatelessWidget {
final ReadonlySignal<RenderMode>? renderMode; final ReadonlySignal<RenderMode>? renderMode;
/// Show the audio / subtitle pickers (a player is running). /// Show the audio / subtitle pickers (a player is running).
@@ -324,19 +415,18 @@ class PlayerSettingsCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( // The app's glass, like the Up next popover and the menus.
return AppGlass(
key: const ValueKey('player-settings-card'), key: const ValueKey('player-settings-card'),
elevation: 12,
borderRadius: BorderRadius.circular(16),
child: SizedBox( child: SizedBox(
width: 420, width: 420,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(18, 14, 18, 10), padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Text('Player', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 16)), const Text('Player', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
const SizedBox(height: 4), const SizedBox(height: 4),
...playerSettingRows(renderMode: renderMode), ...playerSettingRows(renderMode: renderMode),
if (tracks) ...[ if (tracks) ...[
@@ -345,10 +435,17 @@ class PlayerSettingsCard extends StatelessWidget {
const TrackSetting(type: JellyfinStreamType.audio), const TrackSetting(type: JellyfinStreamType.audio),
], ],
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Row(
'Custom shader files: Settings → Player', children: [
Expanded(
child: Text(
'Controls layout and custom shaders: Settings → Player',
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12), style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12),
), ),
),
const MarkProblemButton(),
],
),
], ],
), ),
), ),
@@ -357,51 +454,14 @@ class PlayerSettingsCard extends StatelessWidget {
} }
} }
/// Opens the ⚙ card over the player, near the controls' right end. The /// Opens the player settings card over the player, near the controls'
/// uosc controls stay up while it is open. /// right end; the controls stay up while it is open.
Future<void> showPlayerSettings(BuildContext context, {ReadonlySignal<RenderMode>? renderMode}) { Future<void> showPlayerSettings(BuildContext context, {ReadonlySignal<RenderMode>? renderMode}) =>
Future<void> open() => showPopover<void>( ControlsFrame.holdWhile(
context,
() => showPopover<void>(
context, context,
at: const Alignment(0.96, 0.5), at: const Alignment(0.96, 0.5),
child: PlayerSettingsCard(renderMode: renderMode), child: PlayerSettingsCard(renderMode: renderMode),
),
); );
final controls = UoscControls.maybeOf(context);
return controls == null ? open() : controls.holdDuring(open);
}
/// ⚙ in the standard controls' bottom bar.
class PlayerSettingsButton extends StatelessWidget {
final FocusNode? focusNode;
final ReadonlySignal<RenderMode>? renderMode;
const PlayerSettingsButton({super.key, this.focusNode, this.renderMode});
@override
Widget build(BuildContext context) {
return IconButton(
key: const ValueKey('player-settings'),
focusNode: focusNode,
tooltip: 'Player settings',
color: Colors.white,
iconSize: 20,
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.tune),
onPressed: () => showPlayerSettings(context, renderMode: renderMode),
);
}
}
/// ⚙ in the uosc controls' button row.
class UoscPlayerSettingsButton extends StatelessWidget {
final ReadonlySignal<RenderMode>? renderMode;
const UoscPlayerSettingsButton({super.key, this.renderMode});
@override
Widget build(BuildContext context) {
return UoscButton(
key: const ValueKey('uosc-player-settings'),
icon: Icons.tune_rounded,
tooltip: 'Player settings',
onPressed: () => showPlayerSettings(context, renderMode: renderMode),
);
}
}
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../state/settings.dart';
import 'controls_probe.dart' show ControlsHold;
import 'frame.dart';
import 'minimal/minimal_controls.dart';
import 'model/controls_model.dart';
import 'model/controls_prefs.dart';
import 'standard/standard_controls.dart';
// The session's controls: one [ControlsFrame] (when they are up) around the
// skin the settings pick, both drawing the same [PlayerControlsModel].
// Skin, size and the hide delay follow the settings live.
/// The bottom controls' height in [skin] at [size] (the player's floating
/// pills sit just above it).
double controlsBottomHeight(ControlsSkin skin, ControlsSize size) => switch (skin) {
ControlsSkin.standard => StandardControls.bottomHeight(size.scale),
ControlsSkin.minimal => MinimalControls.bottomHeight(size.scale),
};
/// The top bar's height in [skin].
double controlsTopHeight(ControlsSkin skin) => switch (skin) {
ControlsSkin.standard => StandardControls.topHeight,
ControlsSkin.minimal => MinimalControls.topHeight,
};
class SessionControls extends StatelessWidget {
final PlayerControlsModel model;
/// Follows whether the controls are up.
final ValueNotifier<bool> shown;
final ControlsHold hold;
final VoidCallback onDoubleClick;
final Widget? windowButtons;
const SessionControls({
super.key,
required this.model,
required this.shown,
required this.hold,
required this.onDoubleClick,
this.windowButtons,
});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) {
final skin = s.controlsSkin.value;
final size = s.controlsSize.value;
final scale = size.scale;
return ControlsFrame(
// A new frame per skin: each starts up, like a fresh mount.
key: ValueKey(skin),
shown: shown,
holdBy: hold,
hideAfter: Duration(seconds: s.controlsHideSecs.value),
onDoubleClick: onDoubleClick,
barTop: controlsTopHeight(skin),
barBottom: controlsBottomHeight(skin, size),
builder: (context, visible) => switch (skin) {
ControlsSkin.standard => StandardControls(
model: model,
visible: visible,
scale: scale,
hold: hold,
windowButtons: windowButtons,
),
ControlsSkin.minimal => MinimalControls(
model: model,
visible: visible,
scale: scale,
hold: hold,
windowButtons: windowButtons,
),
},
);
},
);
}
}
@@ -0,0 +1,229 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../playback/media_marks.dart';
import '../frame.dart';
// The Standard skin's seek bar: a Material-style track with the played and
// buffered parts, chapter marks and a hover tip.
/// The seek bar: played / buffered track, a diamond at each chapter start
/// (not the one at 0), and on hover the time with the chapter title above
/// it. Like media_kit's bar it reports a seek on every drag move and on
/// release ([onSeek]); the session routes those through the user-seek path
/// (debounced, everyone follows).
class ChapterSeekBar extends StatefulWidget {
final Duration position;
final Duration duration;
final Duration buffer;
final List<MediaChapter> chapters;
final ValueChanged<Duration> onSeek;
/// The bar's height (its hit area); the track is centred in it.
final double height;
const ChapterSeekBar({
super.key,
required this.position,
required this.duration,
this.buffer = Duration.zero,
this.chapters = const [],
required this.onSeek,
this.height = ChapterSeekBar.defaultHeight,
});
static const defaultHeight = 28.0;
@override
State<ChapterSeekBar> createState() => _ChapterSeekBarState();
}
class _ChapterSeekBarState extends State<ChapterSeekBar> {
double? _hoverX;
bool _dragging = false;
double _dragFraction = 0;
double _fraction(Duration d) {
final total = widget.duration.inMicroseconds;
if (total <= 0) return 0;
return (d.inMicroseconds / total).clamp(0.0, 1.0);
}
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
// A drag sends no hover events: the controls are held up through it.
void _drag(bool on) {
if (_dragging == on) return;
setState(() => _dragging = on);
ControlsFrame.maybeOf(context)?.hold(on);
}
@override
void dispose() {
if (_dragging) ControlsFrame.maybeOf(context)?.hold(false);
super.dispose();
}
void _seekTo(double x, double width) {
if (widget.duration <= Duration.zero || width <= 0) return;
_dragFraction = (x / width).clamp(0.0, 1.0);
widget.onSeek(_at(_dragFraction));
}
@override
Widget build(BuildContext context) {
final primary = Theme.of(context).colorScheme.primary;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final active = _hoverX != null || _dragging;
final barHeight = active ? 5.0 : 3.0;
final played = _dragging ? _dragFraction : _fraction(widget.position);
final mid = widget.height / 2;
final hoverX = _hoverX;
final hoverAt = hoverX == null || width <= 0 ? null : _at(hoverX / width);
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
onHover: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
onExit: (_) => setState(() => _hoverX = null),
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (e) {
_dragFraction = width <= 0 ? 0 : (e.localPosition.dx / width).clamp(0.0, 1.0);
_drag(true);
},
onPointerMove: (e) {
setState(() => _hoverX = e.localPosition.dx.clamp(0, width));
_seekTo(e.localPosition.dx, width);
},
onPointerUp: (e) {
_seekTo(e.localPosition.dx, width);
_drag(false);
},
onPointerCancel: (_) => _drag(false),
child: SizedBox(
key: const ValueKey('seek-bar'),
width: width,
height: widget.height,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
right: 0,
top: mid - barHeight / 2,
height: barHeight,
child: Container(color: Colors.white24),
),
Positioned(
left: 0,
top: mid - barHeight / 2,
height: barHeight,
width: width * _fraction(widget.buffer),
child: Container(color: Colors.white24),
),
if (hoverX != null && !_dragging)
Positioned(
left: 0,
top: mid - barHeight / 2,
height: barHeight,
width: hoverX,
child: Container(color: Colors.white24),
),
Positioned(
left: 0,
top: mid - barHeight / 2,
height: barHeight,
width: width * played,
child: Container(color: primary),
),
for (final c in widget.chapters)
if (c.start > Duration.zero && widget.duration > Duration.zero)
Positioned(
key: ValueKey('chapter-mark-${c.index}'),
left: width * _fraction(c.start) - 4,
top: mid - 4,
width: 8,
height: 8,
child: IgnorePointer(
child: Transform.rotate(
angle: math.pi / 4,
child: Container(
margin: const EdgeInsets.all(1),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.black54, width: 0.8),
),
),
),
),
),
if (active)
Positioned(
left: width * played - 6,
top: mid - 6,
width: 12,
height: 12,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(color: primary, shape: BoxShape.circle),
),
),
),
if (hoverAt != null)
Positioned(
// Kept clear of the window edges (the tip is centred).
left: hoverX!.clamp(math.min(60.0, width / 2), math.max(width - 60, width / 2)),
bottom: widget.height - 2,
child: IgnorePointer(
child: FractionalTranslation(
translation: const Offset(-0.5, 0),
child: _HoverTip(title: hoverTitle, time: formatClock(hoverAt)),
),
),
),
],
),
),
),
);
},
);
}
}
class _HoverTip extends StatelessWidget {
final String title;
final String time;
const _HoverTip({required this.title, required this.time});
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxWidth: 320),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.8), borderRadius: BorderRadius.circular(6)),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (title.isNotEmpty)
Text(
title,
key: const ValueKey('seek-hover-chapter'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600),
),
Text(
time,
key: const ValueKey('seek-hover-time'),
style: const TextStyle(color: Colors.white70, fontSize: 11),
),
],
),
);
}
}
@@ -0,0 +1,340 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../../queue/queue_ui.dart';
import '../../shell/now_playing_title.dart';
import '../../state/settings.dart';
import '../control_menus.dart';
import '../controls_probe.dart' show ControlsHold;
import '../frame.dart';
import '../model/controls_layout.dart';
import '../model/controls_model.dart';
import '../volume.dart';
import 'seek_bar.dart';
// The Standard skin: Material-style bars over the picture. Top: what plays,
// Up next and the window controls; bottom: the seek bar (chapter marks,
// hover time) over a row of round icon buttons in the layout's order.
// Hidden, a thin progress line stays (unless switched off).
class StandardControls extends StatelessWidget {
final PlayerControlsModel model;
final bool visible;
final double scale;
final ControlsHold? hold;
/// Right of the top bar's own buttons (the window buttons in fullscreen).
final Widget? windowButtons;
const StandardControls({
super.key,
required this.model,
required this.visible,
this.scale = 1,
this.hold,
this.windowButtons,
});
/// The top bar: room for the cover (2:3, 60 px tall).
static const topHeight = 72.0;
static const _rowHeight = 56.0;
/// The bottom bar (seek bar and buttons) at [scale].
static double bottomHeight(double scale) => (_rowHeight + ChapterSeekBar.defaultHeight) * scale;
@override
Widget build(BuildContext context) {
const fade = ControlsFrame.fade;
Widget bar(Widget child) => IgnorePointer(
ignoring: !visible,
child: AnimatedOpacity(duration: fade, opacity: visible ? 1 : 0, child: child),
);
// No button here takes focus (Space is the ready key); the top bar's
// Up next keeps its own (its popover has a text field).
return Stack(
children: [
Positioned(
left: 0,
right: 0,
top: 0,
child: bar(
DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x99000000), Color(0x00000000)],
),
),
child: SizedBox(
key: const ValueKey('standard-top'),
height: topHeight,
child: Padding(padding: const EdgeInsets.only(left: 16, right: 8), child: _top()),
),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: bar(
DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x00000000), Color(0xB3000000)],
),
),
child: Padding(
padding: EdgeInsets.only(top: 24 * scale),
child: SizedBox(
key: const ValueKey('standard-bottom'),
height: bottomHeight(scale),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SignalBuilder(
builder: (context) => ChapterSeekBar(
position: model.position.value,
duration: model.duration.value,
buffer: model.buffered.value,
chapters: model.chapters.value,
height: ChapterSeekBar.defaultHeight * scale,
onSeek: model.seek,
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: ExcludeFocus(
child: SignalBuilder(
builder: (context) {
final s = model.snapshot.value;
final layout = model.bar.value;
return Row(
key: const ValueKey('standard-buttons'),
children: [
for (final c in layout.left) _control(context, c, s),
const Spacer(),
for (final c in layout.right) _control(context, c, s),
],
);
},
),
),
),
),
],
),
),
),
),
),
),
// Hidden: the thin progress line.
Positioned(
left: 0,
right: 0,
bottom: 0,
child: IgnorePointer(
child: SignalBuilder(
builder: (context) {
if (visible || !AppSettings.instance.progressLine.value) return const SizedBox.shrink();
final d = model.duration.value, p = model.position.value;
final v = d.inMilliseconds == 0 ? 0.0 : (p.inMilliseconds / d.inMilliseconds).clamp(0.0, 1.0);
return LinearProgressIndicator(
key: const ValueKey('progress-line-standard'),
value: v,
minHeight: 2,
backgroundColor: Colors.transparent,
);
},
),
),
),
],
);
}
Widget _top() {
final m = model;
return Row(
children: [
const Expanded(child: NowPlayingTitle()),
UpNextButton(queue: m.queue, onPlayNextEpisode: m.playNextEpisode, look: UpNextLook.standard, hold: hold),
SignalBuilder(
builder: (context) {
final full = m.fullscreen.value;
return ExcludeFocus(
child: _IconButton(
key: const ValueKey('standard-collapse'),
icon: full ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
tooltip: full ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
onPressed: full ? m.toggleFullscreen : m.collapse,
),
);
},
),
?windowButtons,
],
);
}
Widget _control(BuildContext context, BarControl c, ControlsSnapshot s) {
final m = model;
final key = ValueKey('control-${c.name}');
final enabled = controlEnabled(c, s);
Widget icon(VoidCallback? onPressed, {int badge = 0, double size = 24, Color? color}) => _IconButton(
key: key,
icon: controlIcon(c, s),
tooltip: controlTooltip(c, s),
badge: badge,
size: size * scale,
color: color,
onPressed: enabled ? onPressed : null,
);
switch (c) {
case BarControl.playPause:
final waiting = s.play == PlayState.waiting;
return icon(m.togglePlay, size: 30, color: waiting ? Theme.of(context).colorScheme.primary : null);
case BarControl.previous:
return icon(m.previous);
case BarControl.next:
return icon(m.next);
case BarControl.chapterPrevious:
return icon(() => m.stepChapter(-1));
case BarControl.chapterNext:
return icon(() => m.stepChapter(1));
case BarControl.volume:
return SignalBuilder(
key: key,
builder: (context) => VolumeControl(
volume: m.volume.value,
onChanged: m.setVolume,
onToggleMute: m.toggleMute,
button: (context, i, color, tip, onPressed) =>
_IconButton(icon: i, tooltip: tip, color: color, size: 24 * scale, onPressed: onPressed),
),
);
case BarControl.time:
return SignalBuilder(
key: key,
builder: (context) => _Clock(
text: clockText(m.position.value, m.duration.value, m.settings.timeDisplay.value),
tooltip: controlTooltip(c, s),
scale: scale,
onPressed: m.toggleTimeDisplay,
),
);
case BarControl.subtitles || BarControl.audio:
final type = c == BarControl.audio ? JellyfinStreamType.audio : JellyfinStreamType.subtitle;
return SignalBuilder(
key: key,
builder: (context) => Builder(
builder: (context) => _IconButton(
icon: controlIcon(c, s),
tooltip: controlTooltip(c, s),
badge: trackCount(m.tracks.value, type),
size: 22 * scale,
onPressed: () => openTrackMenu(context, m, type),
),
),
);
case BarControl.settings:
return Builder(
key: key,
builder: (context) => _IconButton(
icon: controlIcon(c, s),
tooltip: controlTooltip(c, s),
size: 22 * scale,
onPressed: () => openControlsMenu(context, m),
),
);
case BarControl.chat:
return SignalBuilder(
key: key,
builder: (context) => _IconButton(
icon: controlIcon(c, s),
tooltip: controlTooltip(c, s),
badge: m.unread.value,
size: 22 * scale,
onPressed: m.toggleChat,
),
);
case BarControl.fullscreen:
return icon(m.toggleFullscreen, size: 26);
}
}
}
/// A round white icon button with an optional count badge (the Material
/// look of media_kit's bar).
class _IconButton extends StatelessWidget {
final IconData icon;
final String tooltip;
final VoidCallback? onPressed;
final int badge;
final double size;
final Color? color;
const _IconButton({
super.key,
required this.icon,
required this.tooltip,
required this.onPressed,
this.badge = 0,
this.size = 24,
this.color,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return IconButton(
tooltip: tooltip,
iconSize: size,
color: color ?? Colors.white,
disabledColor: Colors.white38,
onPressed: onPressed,
icon: Badge(
isLabelVisible: badge > 0,
backgroundColor: scheme.primary,
textColor: scheme.onPrimary,
label: Text('$badge'),
child: Icon(icon),
),
);
}
}
/// The clock: "6:00 / 24:00" or "-18:00"; a click switches.
class _Clock extends StatelessWidget {
final String text;
final String tooltip;
final double scale;
final VoidCallback onPressed;
const _Clock({required this.text, required this.tooltip, required this.scale, required this.onPressed});
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
waitDuration: const Duration(milliseconds: 500),
child: TextButton(
style: TextButton.styleFrom(
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(horizontal: 10 * scale),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: onPressed,
child: Text(
text,
style: TextStyle(fontSize: 14 * scale, fontFeatures: const [FontFeature.tabularFigures()]),
),
),
);
}
}
@@ -1,362 +0,0 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../../jellyfin/track_prefs.dart' show MpvTrack;
import '../../playback/media_marks.dart';
import '../../playback/playback_controller.dart' show RenderMode;
import '../../screens/session/player_controls.dart' show TrackPick;
import '../../shaders/shader_menu.dart';
import '../../shaders/shader_presets.dart';
import '../../state/settings.dart';
import '../volume.dart';
import 'controls.dart';
import 'menu.dart';
import 'style.dart';
/// Flat uosc button: a white icon (optionally a [label] beside it and a
/// small count [badge] in the corner), a faint highlight on hover, a dark
/// tooltip. Never takes focus (the session's keys are global).
class UoscButton extends StatefulWidget {
final IconData icon;
final String tooltip;
final VoidCallback? onPressed;
final String? label;
final int? badge;
/// Tint for an "on" state (ready, shaders on).
final Color? color;
const UoscButton({
super.key,
required this.icon,
required this.tooltip,
required this.onPressed,
this.label,
this.badge,
this.color,
});
@override
State<UoscButton> createState() => _UoscButtonState();
}
class _UoscButtonState extends State<UoscButton> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final w = widget;
final color = w.onPressed == null ? UoscStyle.dim.withValues(alpha: 0.4) : (w.color ?? UoscStyle.text);
final badge = w.badge;
return UoscTooltip(
message: w.tooltip,
child: MouseRegion(
cursor: w.onPressed == null ? SystemMouseCursors.basic : SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: w.onPressed,
child: Container(
height: UoscControls.buttonRowHeight,
constraints: const BoxConstraints(minWidth: UoscControls.buttonRowHeight),
padding: EdgeInsets.symmetric(horizontal: w.label == null ? 0 : 8),
decoration: BoxDecoration(
color: _hover && w.onPressed != null ? UoscStyle.hover : Colors.transparent,
borderRadius: BorderRadius.circular(UoscStyle.radius),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Stack(
clipBehavior: Clip.none,
children: [
Icon(w.icon, color: color, size: 22),
if (badge != null && badge > 0)
Positioned(
right: -8,
bottom: -5,
child: Container(
key: const ValueKey('uosc-badge'),
padding: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
color: UoscStyle.text,
borderRadius: BorderRadius.circular(3),
),
child: Text('$badge',
style: const TextStyle(
color: Colors.black, fontSize: 9, height: 1.3, fontWeight: FontWeight.w700)),
),
),
],
),
if (w.label != null) ...[
const SizedBox(width: 6),
Text(w.label!, style: TextStyle(color: color, fontSize: UoscStyle.fontSize)),
],
],
),
),
),
),
);
}
}
/// Badge-style text button ("HQ" in uosc): a small outlined box with a
/// short label, filled when [active].
class UoscBadgeButton extends StatefulWidget {
final String text;
final String tooltip;
final bool active;
final VoidCallback onPressed;
const UoscBadgeButton({
super.key,
required this.text,
required this.tooltip,
required this.active,
required this.onPressed,
});
@override
State<UoscBadgeButton> createState() => _UoscBadgeButtonState();
}
class _UoscBadgeButtonState extends State<UoscBadgeButton> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final w = widget;
return UoscTooltip(
message: w.tooltip,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: w.onPressed,
child: Container(
height: UoscControls.buttonRowHeight,
padding: const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
color: _hover ? UoscStyle.hover : Colors.transparent,
borderRadius: BorderRadius.circular(UoscStyle.radius),
),
alignment: Alignment.center,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: w.active ? UoscStyle.text : Colors.transparent,
border: Border.all(color: w.active ? UoscStyle.text : UoscStyle.dim, width: 1.4),
borderRadius: BorderRadius.circular(3),
),
child: Text(w.text,
style: TextStyle(
color: w.active ? Colors.black : UoscStyle.dim,
fontSize: 11,
fontWeight: FontWeight.w800,
letterSpacing: 0.3)),
),
),
),
),
);
}
}
/// Volume: click mutes / unmutes, hovering shows a [VolumeBar] up to
/// [maxVolume], the wheel steps 5 %.
class UoscVolumeButton extends StatefulWidget {
/// 0..[maxVolume].
final double volume;
final ValueChanged<double> onChanged;
const UoscVolumeButton({super.key, required this.volume, required this.onChanged});
@override
State<UoscVolumeButton> createState() => _UoscVolumeButtonState();
}
class _UoscVolumeButtonState extends State<UoscVolumeButton> {
double _beforeMute = 100;
bool _hover = false;
@override
Widget build(BuildContext context) {
final v = widget.volume;
final icon = v <= 0 ? Icons.volume_off_rounded : (v < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded);
return MouseRegion(
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: Listener(
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);
}
},
),
],
),
),
);
}
}
/// Subtitles or audio: the icon with the track count; the menu lists the
/// tracks (mpv's selection active).
class UoscTracksButton extends StatelessWidget {
final JellyfinStreamType type;
final List<MpvTrack> tracks;
final void Function(TrackPick pick) onPick;
final ValueChanged<bool>? onMenuOpen;
const UoscTracksButton({super.key, required this.type, required this.tracks, required this.onPick, this.onMenuOpen});
@override
Widget build(BuildContext context) {
final audio = type == JellyfinStreamType.audio;
final count = tracks.where((t) => t.type == type).length;
return UoscButton(
key: ValueKey(audio ? 'uosc-audio' : 'uosc-subtitles'),
icon: audio ? Icons.graphic_eq_rounded : Icons.subtitles_outlined,
tooltip: audio ? 'Audio' : 'Subtitles',
badge: count,
onPressed: () async {
onMenuOpen?.call(true);
final pick = await showUoscMenu<TrackPick>(context,
title: audio ? 'Audio' : 'Subtitles', items: trackMenuItems(type, tracks));
onMenuOpen?.call(false);
if (pick != null) onPick(pick);
},
);
}
}
/// Chapters: the list with start times, the playing one active; a pick
/// jumps there. Nothing without chapters.
class UoscChaptersButton extends StatelessWidget {
final List<MediaChapter> chapters;
final int? current;
final Duration duration;
final ValueChanged<MediaChapter> onPick;
final ValueChanged<bool>? onMenuOpen;
const UoscChaptersButton({
super.key,
required this.chapters,
required this.current,
required this.duration,
required this.onPick,
this.onMenuOpen,
});
@override
Widget build(BuildContext context) {
if (chapters.isEmpty) return const SizedBox.shrink();
return UoscButton(
key: const ValueKey('uosc-chapters'),
icon: Icons.bookmarks_outlined,
tooltip: 'Chapters (PgUp / PgDn)',
onPressed: () async {
onMenuOpen?.call(true);
final pick = await showUoscMenu<MediaChapter>(context,
title: 'Chapters', items: chapterMenuItems(chapters, current, duration));
onMenuOpen?.call(false);
if (pick != null) onPick(pick);
},
);
}
}
/// Shaders as uosc's quality badge: the active Anime4K preset ("A HQ"),
/// "4K" dimmed when off. Opens the shader menu.
class UoscShaderButton extends StatelessWidget {
final ReadonlySignal<RenderMode> renderMode;
final void Function(ShaderPreset preset, ShaderQuality? quality) onPick;
final ValueChanged<bool>? onMenuOpen;
const UoscShaderButton({super.key, required this.renderMode, required this.onPick, this.onMenuOpen});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(builder: (context) {
final preset = s.shaderPreset.value;
final quality = s.shaderQuality.value;
final short = shaderShortLabel(preset, quality);
return Builder(
builder: (context) => UoscBadgeButton(
key: const ValueKey('uosc-shaders'),
text: short ?? '4K',
active: short != null,
tooltip: short == null
? 'Shaders: off (Ctrl+1..6)'
: 'Shaders: ${shaderLabel(preset, quality)} (Ctrl+0 off)',
onPressed: () {
final controls = UoscControls.maybeOf(context);
Future<void> open() =>
showShaderMenu(context, renderMode: renderMode, onPick: onPick, onMenuOpen: onMenuOpen);
controls == null ? open() : controls.holdDuring(open);
},
),
);
});
}
}
/// One entry of the ≡ menu.
typedef UoscAction = ({String title, String? hint, VoidCallback run});
/// ≡: the session's actions as a menu.
class UoscMainMenuButton extends StatelessWidget {
final List<UoscAction> actions;
final ValueChanged<bool>? onMenuOpen;
const UoscMainMenuButton({super.key, required this.actions, this.onMenuOpen});
@override
Widget build(BuildContext context) {
return UoscButton(
key: const ValueKey('uosc-menu-button'),
icon: Icons.menu_rounded,
tooltip: 'Menu',
onPressed: () async {
onMenuOpen?.call(true);
final pick = await showUoscMenu<UoscAction>(context, items: [
for (final a in actions) UoscMenuItem(value: a, title: a.title, hint: a.hint),
]);
onMenuOpen?.call(false);
pick?.run();
},
);
}
}
@@ -1,265 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../../state/settings.dart';
// 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.
final Widget Function(BuildContext context, bool expanded) timeline;
/// Buttons left and right of the row above the timeline.
final List<Widget> left;
final List<Widget> right;
/// Top-right corner (chat, back to the library), shown with the controls.
final Widget? top;
/// Top left (what is playing), shown with the controls; gets the width
/// [top] leaves.
final Widget? topLeft;
final VoidCallback? onDoubleTap;
final Duration hideAfter;
/// Follows whether the controls are up (for overlays outside them).
final ValueNotifier<bool>? shown;
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;
/// The controls around [context], for widgets that need them kept up
/// (a timeline drag, an open menu).
static UoscControlsState? maybeOf(BuildContext context) =>
context.findAncestorStateOfType<UoscControlsState>();
@override
State<UoscControls> createState() => UoscControlsState();
}
class UoscControlsState extends State<UoscControls> {
bool _visible = true;
int _holds = 0;
Timer? _timer;
bool get visible => _visible;
@override
void initState() {
super.initState();
// Not during this build: an overlay listening to [shown] may be built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.shown?.value = true;
});
_hideLater();
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
/// Show the controls and restart the hide countdown.
void wake() {
if (!_visible && mounted) {
setState(() => _visible = true);
widget.shown?.value = true;
}
_hideLater();
}
/// Keep the controls up while [on] (paired calls): a drag sends no
/// hover events and a menu route takes the mouse.
void hold(bool on) {
_holds = on ? _holds + 1 : (_holds - 1).clamp(0, 1 << 20);
wake();
}
/// Runs [open] (a menu) with the controls held up.
Future<T> holdDuring<T>(Future<T> Function() open) async {
hold(true);
try {
return await open();
} finally {
if (mounted) hold(false);
}
}
void _hideLater() {
_timer?.cancel();
_timer = Timer(widget.hideAfter, _hide);
}
void _hide() {
if (!mounted || _holds > 0 || !_visible) return;
setState(() => _visible = false);
widget.shown?.value = false;
}
@override
Widget build(BuildContext context) {
final visible = _visible;
const fade = Duration(milliseconds: 150);
return MouseRegion(
cursor: visible ? SystemMouseCursors.basic : SystemMouseCursors.none,
onEnter: (_) => wake(),
onHover: (_) => wake(),
onExit: (_) {
_timer?.cancel();
_hide();
},
child: Stack(
children: [
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onDoubleTap: widget.onDoubleTap,
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: 160,
child: IgnorePointer(
child: AnimatedOpacity(
duration: fade,
opacity: visible ? 1 : 0,
child: const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x00000000), Color(0x8C000000)],
),
),
),
),
),
),
if (widget.topLeft != null)
Positioned(
left: 0,
right: 0,
top: 0,
height: 120,
child: IgnorePointer(
child: AnimatedOpacity(
duration: fade,
opacity: visible ? 1 : 0,
child: const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x8C000000), Color(0x00000000)],
),
),
),
),
),
),
if (widget.top != null || widget.topLeft != null)
Positioned(
top: 0,
left: 0,
right: 0,
child: IgnorePointer(
key: const ValueKey('uosc-top'),
ignoring: !visible,
child: AnimatedOpacity(
duration: fade,
opacity: visible ? 1 : 0,
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(
left: 0,
right: 0,
bottom: 0,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
IgnorePointer(
key: const ValueKey('uosc-buttons'),
ignoring: !visible,
child: AnimatedOpacity(
duration: fade,
opacity: visible ? 1 : 0,
child: Padding(
padding: const EdgeInsets.fromLTRB(6, 0, 6, 4),
child: SizedBox(
height: UoscControls.buttonRowHeight,
child: Row(children: [...widget.left, const Spacer(), ...widget.right]),
),
),
),
),
// Hidden: the collapsed timeline is the progress line,
// unless the player settings turn it off.
SignalBuilder(
builder: (context) {
final line = AppSettings.instance.progressLine.value;
return AnimatedOpacity(
key: const ValueKey('uosc-timeline-wrap'),
duration: fade,
opacity: visible || line ? 1 : 0,
child: widget.timeline(context, visible),
);
},
),
],
),
),
],
),
);
}
}
/// The session's controls: the standard ones ([material]) or, with the
/// "Player design" setting on uosc, the uosc ones ([mpv]). Switches live
/// when the setting changes.
class SessionControlsSwitch extends StatelessWidget {
final WidgetBuilder material;
final WidgetBuilder mpv;
const SessionControlsSwitch({super.key, required this.material, required this.mpv});
@override
Widget build(BuildContext context) {
return SignalBuilder(
builder: (context) =>
AppSettings.instance.mpvControls.value ? mpv(context) : material(context),
);
}
}
@@ -1,249 +0,0 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../../jellyfin/track_prefs.dart' show MpvTrack;
import '../../playback/media_marks.dart';
import '../../screens/session/player_controls.dart' show TrackPick;
import 'controls.dart';
import 'style.dart';
/// One row of a uosc menu: [title], a dim [hint] on the right (a time, a
/// key), [active] = the current one (highlighted).
class UoscMenuItem<T> {
final T value;
final String title;
final String? hint;
final bool active;
final bool enabled;
const UoscMenuItem({
required this.value,
required this.title,
this.hint,
this.active = false,
this.enabled = true,
});
}
/// A flat dark menu over the controls, above the widget of [context] (the
/// button that opens it), with an optional [title] row. Resolves to the
/// picked value, or null when dismissed (click outside, Esc). Keeps the
/// controls up while open.
Future<T?> showUoscMenu<T>(
BuildContext context, {
String? title,
required List<UoscMenuItem<T>> items,
}) {
final navigator = Navigator.of(context);
final overlay = navigator.overlay!.context.findRenderObject() as RenderBox;
final box = context.findRenderObject() as RenderBox;
final anchor = Rect.fromPoints(
box.localToGlobal(Offset.zero, ancestor: overlay),
box.localToGlobal(box.size.bottomRight(Offset.zero), ancestor: overlay),
);
Future<T?> open() => navigator.push<T>(_UoscMenuRoute<T>(anchor: anchor, title: title, items: items));
final controls = UoscControls.maybeOf(context);
return controls == null ? open() : controls.holdDuring(open);
}
class _UoscMenuRoute<T> extends PopupRoute<T> {
final Rect anchor;
final String? title;
final List<UoscMenuItem<T>> items;
_UoscMenuRoute({required this.anchor, required this.title, required this.items});
@override
Color? get barrierColor => null;
@override
bool get barrierDismissible => true;
@override
String? get barrierLabel => 'Close menu';
@override
Duration get transitionDuration => Duration.zero;
@override
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
return LayoutBuilder(builder: (context, constraints) {
const width = 360.0;
final screen = constraints.biggest;
final left = anchor.left.clamp(8.0, math.max(8.0, screen.width - width - 8));
final maxHeight = math.max(120.0, anchor.top - 16);
return Stack(
children: [
Positioned(
left: left.toDouble(),
bottom: screen.height - anchor.top + 6,
width: math.min(width, screen.width - 16),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight),
child: UoscMenuPanel<T>(
title: title,
items: items,
onPick: (v) => Navigator.of(context).pop(v),
),
),
),
],
);
});
}
}
/// The menu's panel alone (tests pump it without a route).
class UoscMenuPanel<T> extends StatefulWidget {
final String? title;
final List<UoscMenuItem<T>> items;
final ValueChanged<T> onPick;
const UoscMenuPanel({super.key, this.title, required this.items, required this.onPick});
static const rowHeight = 36.0;
@override
State<UoscMenuPanel<T>> createState() => _UoscMenuPanelState<T>();
}
class _UoscMenuPanelState<T> extends State<UoscMenuPanel<T>> {
// Long lists open on the current row.
late final _scroll = ScrollController(
initialScrollOffset: math.max(0, widget.items.indexWhere((i) => i.active) - 3) * UoscMenuPanel.rowHeight);
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final title = widget.title;
return Material(
key: const ValueKey('uosc-menu'),
color: UoscStyle.panel,
borderRadius: BorderRadius.circular(UoscStyle.radius * 2),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (title != null)
Container(
height: 34,
alignment: Alignment.center,
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0x22FFFFFF)))),
child: Text(title,
style: const TextStyle(color: UoscStyle.dim, fontSize: UoscStyle.fontSize, fontWeight: FontWeight.w600)),
),
Flexible(
child: ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.all(4),
controller: _scroll,
itemCount: widget.items.length,
itemExtent: UoscMenuPanel.rowHeight,
itemBuilder: (context, i) => _Row<T>(item: widget.items[i], onPick: widget.onPick),
),
),
],
),
);
}
}
class _Row<T> extends StatefulWidget {
final UoscMenuItem<T> item;
final ValueChanged<T> onPick;
const _Row({super.key, required this.item, required this.onPick});
@override
State<_Row<T>> createState() => _RowState<T>();
}
class _RowState<T> extends State<_Row<T>> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final item = widget.item;
final color = item.active
? UoscStyle.fill
: _hover && item.enabled
? UoscStyle.hover
: Colors.transparent;
final textColor = item.enabled ? UoscStyle.text : UoscStyle.dim.withValues(alpha: 0.4);
return MouseRegion(
cursor: item.enabled ? SystemMouseCursors.click : SystemMouseCursors.basic,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
key: ValueKey('uosc-menu-item-${item.title}'),
behavior: HitTestBehavior.opaque,
onTap: item.enabled ? () => widget.onPick(item.value) : null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(UoscStyle.radius)),
child: Row(
children: [
Expanded(
child: Text(item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: textColor,
fontSize: UoscStyle.fontSize,
fontWeight: item.active ? FontWeight.w600 : FontWeight.normal)),
),
if (item.hint != null) ...[
const SizedBox(width: 12),
Text(item.hint!,
style: TextStyle(
color: item.active ? UoscStyle.text : UoscStyle.dim,
fontSize: 12,
fontFeatures: const [FontFeature.tabularFigures()])),
],
],
),
),
),
);
}
}
/// Chapters with their start times, the playing one active.
List<UoscMenuItem<MediaChapter>> chapterMenuItems(List<MediaChapter> chapters, int? current, Duration duration) {
final withHours = duration >= const Duration(hours: 1);
return [
for (final c in chapters)
UoscMenuItem(
value: c,
title: c.title.isEmpty ? 'Chapter ${c.index + 1}' : c.title,
hint: formatUoscTime(c.start, withHours: withHours),
active: c.index == current,
),
];
}
/// The audio or subtitle tracks ("Japanese · E-AC3 2.0 · title"), mpv's
/// selection active; subtitles start with Off.
List<UoscMenuItem<TrackPick>> trackMenuItems(JellyfinStreamType type, List<MpvTrack> tracks) {
final audio = type == JellyfinStreamType.audio;
final list = tracks.where((t) => t.type == type).toList();
return [
if (!audio)
UoscMenuItem(
value: (aid: null, sid: null, subtitlesOff: true),
title: 'Off',
active: !list.any((t) => t.selected),
),
for (final t in list)
UoscMenuItem(
value: audio ? (aid: t.id, sid: null, subtitlesOff: false) : (aid: null, sid: t.id, subtitlesOff: false),
title: t.label,
hint: '#${t.id}',
active: t.selected,
),
];
}
@@ -1,79 +0,0 @@
import 'package:flutter/material.dart';
// Look of the experimental mpv-style controls, after the uosc theme as the
// owner runs it: flat black panels, white icons, a muted blue fill.
abstract final class UoscStyle {
/// Timeline background and the boxes on it.
static const bar = Color(0xCC000000);
/// Played part of the timeline; also the active menu row.
static const fill = Color(0xFF4C6A8E);
/// Menus and tooltips.
static const panel = Color(0xF0101010);
static const hover = Color(0x24FFFFFF);
static const text = Colors.white;
static const dim = Color(0xA6FFFFFF);
static const radius = 4.0;
static const fontSize = 13.0;
}
/// uosc's clock: "01:51", "1:02:03" style but zero-padded ("01:02:03")
/// once [withHours] (the media is an hour or longer).
String formatUoscTime(Duration d, {bool withHours = false}) {
final t = d.isNegative ? Duration.zero : d;
String two(int n) => n.toString().padLeft(2, '0');
final h = t.inHours, m = t.inMinutes % 60, s = t.inSeconds % 60;
return withHours || h > 0 ? '${two(h)}:${two(m)}:${two(s)}' : '${two(m)}:${two(s)}';
}
/// Dark rounded hover tip ("Subtitles"), shown above its child.
class UoscTooltip extends StatelessWidget {
final String message;
final Widget child;
const UoscTooltip({super.key, required this.message, required this.child});
@override
Widget build(BuildContext context) {
if (message.isEmpty) return child;
return Tooltip(
message: message,
preferBelow: false,
verticalOffset: 26,
waitDuration: const Duration(milliseconds: 250),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: UoscStyle.panel,
borderRadius: BorderRadius.circular(UoscStyle.radius),
),
textStyle: const TextStyle(color: UoscStyle.text, fontSize: 12),
child: child,
);
}
}
/// A small dark box with one line of text (the timeline's times, the
/// hover chapter title).
class UoscBox extends StatelessWidget {
final String text;
final Color color;
final TextStyle style;
const UoscBox(
this.text, {
super.key,
this.color = UoscStyle.panel,
this.style = const TextStyle(color: UoscStyle.text, fontSize: UoscStyle.fontSize),
});
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxWidth: 360),
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(UoscStyle.radius)),
child: Text(text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style.copyWith(fontFeatures: const [FontFeature.tabularFigures()])),
);
}
}
@@ -1,287 +0,0 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../playback/media_marks.dart';
import 'controls.dart';
import 'style.dart';
/// Fraction of [total] at [d], 0..1 (0 without a duration).
double timelineFraction(Duration d, Duration total) {
final t = total.inMicroseconds;
if (t <= 0) return 0;
return (d.inMicroseconds / t).clamp(0.0, 1.0);
}
/// The cached-ahead part of a timeline of [size]: from the playing
/// position to the end of mpv's cache. Null when nothing is cached ahead.
Rect? cacheRect(Size size, {required Duration position, required Duration buffered, required Duration duration}) {
final from = timelineFraction(position, duration) * size.width;
final to = timelineFraction(buffered, duration) * size.width;
if (to - from < 0.5) return null;
return Rect.fromLTRB(from, 0, to, size.height);
}
/// The hatching of [r]: 45° strokes from bottom-left to top-right,
/// [spacing] apart along x, across all of [r] (the painter clips them).
/// The first starts half a step in, so none just grazes the corner.
List<(Offset, Offset)> hatchLines(Rect r, {double spacing = 6}) {
if (r.isEmpty || spacing <= 0) return const [];
final h = r.height;
return [
for (var x = r.left - h + spacing / 2; x < r.right; x += spacing) (Offset(x, r.bottom), Offset(x + h, r.top)),
];
}
/// The uosc timeline: full width, elapsed part filled, the cached range
/// ahead hatched, chapter starts as white diamonds on the top edge, the
/// current time in a box at the left and the duration at the right.
/// Hover shows the chapter title over the hover time. When the controls
/// hide ([expanded] false) it shrinks to a thin line with chapter ticks.
/// Like the media_kit bar it reports a seek on every drag move and on
/// release ([onSeek]); the session sends those through the user-seek path.
class UoscTimeline extends StatefulWidget {
final Duration position;
final Duration duration;
/// End of the cached range ([PlaybackController.buffered]).
final Duration buffered;
final List<MediaChapter> chapters;
final bool expanded;
final ValueChanged<Duration> onSeek;
const UoscTimeline({
super.key,
required this.position,
required this.duration,
this.buffered = Duration.zero,
this.chapters = const [],
this.expanded = true,
required this.onSeek,
});
static const height = 30.0;
static const collapsedHeight = 2.0;
@override
State<UoscTimeline> createState() => _UoscTimelineState();
}
class _UoscTimelineState extends State<UoscTimeline> {
double? _hoverX;
bool _dragging = false;
double _dragFraction = 0;
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
void _seekTo(double x, double width) {
if (widget.duration <= Duration.zero || width <= 0) return;
_dragFraction = (x / width).clamp(0.0, 1.0);
widget.onSeek(_at(_dragFraction));
}
void _drag(bool on) {
if (_dragging == on) return;
setState(() => _dragging = on);
UoscControls.maybeOf(context)?.hold(on);
}
@override
void didUpdateWidget(UoscTimeline old) {
super.didUpdateWidget(old);
if (!widget.expanded && old.expanded) _hoverX = null;
}
@override
void dispose() {
// A drag cut short by a rebuild must not keep the controls up.
if (_dragging) UoscControls.maybeOf(context)?.hold(false);
super.dispose();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
final width = constraints.maxWidth;
final expanded = widget.expanded;
final h = expanded ? UoscTimeline.height : UoscTimeline.collapsedHeight;
final played = _dragging ? _dragFraction : timelineFraction(widget.position, widget.duration);
final hoverX = expanded ? _hoverX : null;
final hoverAt = hoverX == null || width <= 0 ? null : _at(hoverX / width);
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
final withHours = widget.duration >= const Duration(hours: 1);
final marks = [
for (final c in widget.chapters)
if (c.start > Duration.zero && widget.duration > Duration.zero) c,
];
final bar = SizedBox(
key: const ValueKey('uosc-timeline'),
width: width,
height: h,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: CustomPaint(
painter: UoscTimelinePainter(
played: played,
cache: (size) => cacheRect(size,
position: widget.position, buffered: widget.buffered, duration: widget.duration),
cacheKey: (widget.position, widget.buffered, widget.duration),
hoverX: _dragging ? null : hoverX,
hatched: expanded,
),
),
),
for (final c in marks)
if (expanded)
Positioned(
key: ValueKey('uosc-chapter-${c.index}'),
left: width * timelineFraction(c.start, widget.duration) - 4,
top: -4,
width: 8,
height: 8,
child: IgnorePointer(
child: Transform.rotate(
angle: math.pi / 4,
child: Container(
margin: const EdgeInsets.all(1.2),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.black54, width: 0.6),
),
),
),
),
)
else
Positioned(
key: ValueKey('uosc-chapter-tick-${c.index}'),
left: width * timelineFraction(c.start, widget.duration) - 1,
top: -2,
width: 2,
height: h + 2,
child: const IgnorePointer(child: ColoredBox(color: Colors.white70)),
),
if (expanded) ...[
Positioned(
left: 4,
top: 0,
bottom: 0,
child: IgnorePointer(
child: Center(
child: UoscBox(formatUoscTime(_at(played), withHours: withHours),
key: const ValueKey('uosc-time'), color: UoscStyle.bar),
),
),
),
Positioned(
right: 4,
top: 0,
bottom: 0,
child: IgnorePointer(
child: Center(
child: UoscBox(formatUoscTime(widget.duration, withHours: withHours),
key: const ValueKey('uosc-duration'), color: UoscStyle.bar),
),
),
),
],
if (hoverAt != null)
Positioned(
// Kept clear of the window edges (the tip is centred).
left: hoverX!.clamp(math.min(60.0, width / 2), math.max(width - 60, width / 2)),
bottom: h + 8,
child: IgnorePointer(
child: FractionalTranslation(
translation: const Offset(-0.5, 0),
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
if (hoverTitle.isNotEmpty) UoscBox(hoverTitle, key: const ValueKey('uosc-hover-chapter')),
UoscBox(formatUoscTime(hoverAt, withHours: withHours),
key: const ValueKey('uosc-hover-time')),
],
),
),
),
),
],
),
);
if (!expanded) return IgnorePointer(child: bar);
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
onHover: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
onExit: (_) => setState(() => _hoverX = null),
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (e) {
_dragFraction = width <= 0 ? 0 : (e.localPosition.dx / width).clamp(0.0, 1.0);
_drag(true);
},
onPointerMove: (e) {
setState(() => _hoverX = e.localPosition.dx.clamp(0, width));
_seekTo(e.localPosition.dx, width);
},
onPointerUp: (e) {
_seekTo(e.localPosition.dx, width);
_drag(false);
},
onPointerCancel: (_) => _drag(false),
child: bar,
),
);
});
}
}
/// Background, cached range (hatched, or a faint band when collapsed),
/// the played part and the hover line.
class UoscTimelinePainter extends CustomPainter {
final double played;
final Rect? Function(Size size) cache;
/// What [cache] depends on (repaint when it changes).
final Object cacheKey;
final double? hoverX;
final bool hatched;
const UoscTimelinePainter({
required this.played,
required this.cache,
required this.cacheKey,
this.hoverX,
this.hatched = true,
});
@override
void paint(Canvas canvas, Size size) {
final all = Offset.zero & size;
canvas.drawRect(all, Paint()..color = UoscStyle.bar);
final c = cache(size);
if (c != null) {
if (hatched) {
canvas.save();
canvas.clipRect(c);
final stroke = Paint()
..color = const Color(0x40FFFFFF)
..strokeWidth = 1.5;
for (final (a, b) in hatchLines(c)) {
canvas.drawLine(a, b, stroke);
}
canvas.restore();
} else {
canvas.drawRect(c, Paint()..color = const Color(0x40FFFFFF));
}
}
canvas.drawRect(Rect.fromLTWH(0, 0, size.width * played, size.height), Paint()..color = UoscStyle.fill);
final x = hoverX;
if (x != null) {
canvas.drawRect(Rect.fromLTWH(x - 0.5, 0, 1, size.height), Paint()..color = Colors.white70);
}
}
@override
bool shouldRepaint(UoscTimelinePainter old) =>
old.played != played || old.cacheKey != cacheKey || old.hoverX != hoverX || old.hatched != hatched;
}
+228 -46
View File
@@ -1,8 +1,11 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../playback/playback_controller.dart' show PlaybackController; import '../playback/playback_controller.dart' show PlaybackController;
import '../widgets/app_menu.dart' show AppGlass;
/// The player's top volume (mpv's `volume-max`): past 100 % mpv amplifies. /// The player's top volume (mpv's `volume-max`): past 100 % mpv amplifies.
const maxVolume = PlaybackController.maxVolume * 1.0; const maxVolume = PlaybackController.maxVolume * 1.0;
@@ -10,9 +13,170 @@ const maxVolume = PlaybackController.maxVolume * 1.0;
/// The part of the volume bar above 100 %. /// The part of the volume bar above 100 %.
const boostColor = Color(0xFFFFB74D); const boostColor = Color(0xFFFFB74D);
/// One wheel notch or arrow key.
const volumeStep = 5.0;
/// [volume] moved by [delta] (wheel, keys), kept within 0..[maxVolume]. /// [volume] moved by [delta] (wheel, keys), kept within 0..[maxVolume].
double stepVolume(double volume, double delta) => (volume + delta).clamp(0.0, maxVolume); double stepVolume(double volume, double delta) => (volume + delta).clamp(0.0, maxVolume);
/// Turns wheel deltas into volume steps: a mouse notch (a big delta) is
/// one step, small smooth-scroll deltas add up to one. Mostly horizontal
/// scrolls (Shift+wheel, sideways pans) are no steps.
class WheelSteps {
// Flutter makes a notch 53 px on Linux, 60 on Windows (3 lines).
static const _notch = 50.0;
double _sum = 0;
/// Whether [e] is a vertical scroll (the only kind that steps).
static bool vertical(PointerSignalEvent e) =>
e is PointerScrollEvent && e.scrollDelta.dy != 0 && e.scrollDelta.dy.abs() > e.scrollDelta.dx.abs();
/// +1 louder (wheel up), -1 quieter, 0 not a step yet.
int take(PointerScrollEvent e) {
final dy = e.scrollDelta.dy;
if (dy.abs() >= _notch) {
_sum = 0;
return dy < 0 ? 1 : -1;
}
if (_sum != 0 && _sum.sign != dy.sign) _sum = 0;
_sum += dy;
if (_sum.abs() < _notch) return 0;
final step = _sum < 0 ? 1 : -1;
_sum = 0;
return step;
}
/// Claims [e] if it is a vertical scroll that nothing under the mouse
/// claimed first (a list, a menu), then calls [onStep] per step.
void handle(PointerSignalEvent e, ValueChanged<int> onStep) {
if (!vertical(e)) return;
GestureBinding.instance.pointerSignalResolver.register(e, (event) {
final step = take(event as PointerScrollEvent);
if (step != 0) onStep(step);
});
}
}
/// The wheel anywhere over [child] steps the volume ([onStep]: +1 / -1),
/// unless something under the mouse scrolls (it claims the event first)
/// or [enabled] is off.
class VolumeWheel extends StatefulWidget {
final ValueChanged<int> onStep;
final bool enabled;
final Widget child;
const VolumeWheel({super.key, required this.onStep, this.enabled = true, required this.child});
@override
State<VolumeWheel> createState() => _VolumeWheelState();
}
class _VolumeWheelState extends State<VolumeWheel> {
final _steps = WheelSteps();
@override
Widget build(BuildContext context) => Listener(
onPointerSignal: (e) {
if (widget.enabled) _steps.handle(e, widget.onStep);
},
child: widget.child,
);
}
/// Shows [builder] for a second after each [trigger] tick (a wheel or
/// key volume step), fading in and out; ignores the mouse.
class VolumeFlash extends StatefulWidget {
final ValueListenable<int> trigger;
final WidgetBuilder builder;
const VolumeFlash({super.key, required this.trigger, required this.builder});
/// How long the readout stays after the last step.
static const hold = Duration(seconds: 1);
@override
State<VolumeFlash> createState() => _VolumeFlashState();
}
class _VolumeFlashState extends State<VolumeFlash> {
bool _on = false;
Timer? _timer;
@override
void initState() {
super.initState();
widget.trigger.addListener(_flash);
}
@override
void didUpdateWidget(VolumeFlash old) {
super.didUpdateWidget(old);
if (old.trigger != widget.trigger) {
old.trigger.removeListener(_flash);
widget.trigger.addListener(_flash);
}
}
@override
void dispose() {
widget.trigger.removeListener(_flash);
_timer?.cancel();
super.dispose();
}
void _flash() {
if (!mounted) return;
setState(() => _on = true);
_timer?.cancel();
_timer = Timer(VolumeFlash.hold, () {
if (mounted) setState(() => _on = false);
});
}
@override
Widget build(BuildContext context) => IgnorePointer(
child: AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: _on ? 1 : 0,
child: _on || _timer != null ? widget.builder(context) : const SizedBox.shrink(),
),
);
}
/// The volume readout for [VolumeFlash] (both skins): a glass pill
/// (the player's pill look) with the icon, a [VolumeBar] and the
/// percentage.
class VolumeReadout extends StatelessWidget {
final double volume;
const VolumeReadout({super.key, required this.volume});
@override
Widget build(BuildContext context) {
final v = volume.clamp(0.0, maxVolume);
return AppGlass.pill(
key: const ValueKey('volume-readout'),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 14, 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(volumeIcon(v), size: 20, color: v > 100 ? boostColor : Colors.white),
const SizedBox(width: 8),
VolumeBar(volume: v, onChanged: (_) {}, width: 96),
const SizedBox(width: 10),
SizedBox(
width: 44,
child: Text(
'${v.round()} %',
textAlign: TextAlign.right,
style: const TextStyle(color: Colors.white, fontSize: 13, fontFeatures: [FontFeature.tabularFigures()]),
),
),
],
),
),
);
}
}
/// A thin volume track over 0..[maxVolume]: filled up to the volume, the /// 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 /// part above 100 % in [boostColor], a tick at 100 %. Click or drag sets
/// it. /// it.
@@ -61,78 +225,98 @@ class _VolumePainter extends CustomPainter {
..strokeCap = StrokeCap.round; ..strokeCap = StrokeCap.round;
canvas.drawLine(Offset(0, y), Offset(size.width, y), line..color = trackColor); canvas.drawLine(Offset(0, y), Offset(size.width, y), line..color = trackColor);
final full = x(100); final full = x(100);
canvas.drawLine(Offset(full, y - 5), Offset(full, y + 5), Paint() canvas.drawLine(
Offset(full, y - 5),
Offset(full, y + 5),
Paint()
..color = trackColor ..color = trackColor
..strokeWidth = 1.5); ..strokeWidth = 1.5,
);
if (volume > 0) canvas.drawLine(Offset(0, y), Offset(x(volume.clamp(0, 100)), y), line..color = color); 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); 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); canvas.drawCircle(Offset(x(volume), y), 5, Paint()..color = volume > 100 ? boostColor : color);
} }
@override @override
bool shouldRepaint(_VolumePainter old) => bool shouldRepaint(_VolumePainter old) => old.volume != volume || old.color != color || old.trackColor != trackColor;
old.volume != volume || old.color != color || old.trackColor != trackColor;
} }
/// Volume for the standard bar (instead of media_kit's, which stops at /// The speaker icon for [volume].
/// 100): the icon mutes / unmutes, hovering shows a [VolumeBar], the IconData volumeIcon(double volume) =>
/// wheel steps 5 %. volume <= 0 ? Icons.volume_off_rounded : (volume < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded);
class VolumeButton extends StatefulWidget {
final Player player; /// Builds a skin's button for the [VolumeControl].
final FocusNode? focusNode; typedef VolumeButtonBuilder = Widget Function(
const VolumeButton({super.key, required this.player, this.focusNode}); BuildContext context,
IconData icon,
Color? color,
String tooltip,
VoidCallback onPressed,
);
/// The bar's volume, in both skins: the skin's speaker button (click
/// mutes / unmutes), a [VolumeBar] sliding out on hover, the wheel on it
/// stepping [volumeStep] (claimed, so the player's wheel skips it).
class VolumeControl extends StatefulWidget {
/// 0..[maxVolume].
final double volume;
final ValueChanged<double> onChanged;
final VoidCallback onToggleMute;
final VolumeButtonBuilder button;
final Color sliderColor;
final Color trackColor;
final double sliderWidth;
const VolumeControl({
super.key,
required this.volume,
required this.onChanged,
required this.onToggleMute,
required this.button,
this.sliderColor = Colors.white,
this.trackColor = Colors.white30,
this.sliderWidth = 72,
});
@override @override
State<VolumeButton> createState() => _VolumeButtonState(); State<VolumeControl> createState() => _VolumeControlState();
} }
class _VolumeButtonState extends State<VolumeButton> { class _VolumeControlState extends State<VolumeControl> {
final _steps = WheelSteps();
bool _hover = false; bool _hover = false;
double _beforeMute = 100;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final player = widget.player; final v = widget.volume;
return StreamBuilder<double>(
stream: player.stream.volume,
initialData: player.state.volume,
builder: (context, snap) {
final v = snap.data ?? 100;
return MouseRegion( return MouseRegion(
onEnter: (_) => setState(() => _hover = true), onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false), onExit: (_) => setState(() => _hover = false),
child: Listener( child: Listener(
onPointerSignal: (e) { onPointerSignal: (e) => _steps.handle(e, (step) => widget.onChanged(stepVolume(v, step * volumeStep))),
if (e is PointerScrollEvent && e.scrollDelta.dy != 0) {
player.setVolume(stepVolume(v, e.scrollDelta.dy < 0 ? 5 : -5));
}
},
child: Row( child: Row(
key: const ValueKey('volume-control'),
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox(width: 4), widget.button(
IconButton( context,
key: const ValueKey('volume-button'), volumeIcon(v),
focusNode: widget.focusNode, v > 100 ? boostColor : null,
tooltip: 'Volume ${v.round()} %', 'Volume ${v.round()} % (scroll, ↑ / ↓)',
iconSize: 22, widget.onToggleMute,
color: Colors.white,
onPressed: () {
if (v > 0) {
_beforeMute = v;
player.setVolume(0);
} else {
player.setVolume(_beforeMute <= 0 ? 100 : _beforeMute);
}
},
icon: Icon(v <= 0 ? Icons.volume_off : (v < 50 ? Icons.volume_down : Icons.volume_up)),
), ),
AnimatedSize( AnimatedSize(
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
child: _hover child: _hover
? Padding( ? Padding(
padding: const EdgeInsets.only(left: 4, right: 12), padding: const EdgeInsets.only(left: 4, right: 8),
child: VolumeBar(volume: v, onChanged: player.setVolume), child: VolumeBar(
key: const ValueKey('volume-bar'),
volume: v,
onChanged: widget.onChanged,
width: widget.sliderWidth,
color: widget.sliderColor,
trackColor: widget.trackColor,
),
) )
: const SizedBox.shrink(), : const SizedBox.shrink(),
), ),
@@ -140,7 +324,5 @@ class _VolumeButtonState extends State<VolumeButton> {
), ),
), ),
); );
},
);
} }
} }
+45 -6
View File
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import '../../shell/motion.dart'; import '../../shell/motion.dart';
import '../../widgets/app_menu.dart';
import '../folder_store.dart'; import '../folder_store.dart';
import '../name_parser.dart'; import '../name_parser.dart';
import '../scanner.dart'; import '../scanner.dart';
@@ -54,6 +55,8 @@ class FilesPage extends StatefulWidget {
final bool relayed; final bool relayed;
/// Plays [path] in the session; returns an error text, or null. /// Plays [path] in the session; returns an error text, or null.
final Future<String?> Function(String path) onPlay; final Future<String?> Function(String path) onPlay;
/// Adds [path] to Up next (right-click on a video); null: no server.
final Future<String?> Function(String path, {bool next})? onAddToQueue;
final String? nowPlayingPath; final String? nowPlayingPath;
/// Defaults to [thumbnailFor]. /// Defaults to [thumbnailFor].
final ThumbnailLoader? thumbnails; final ThumbnailLoader? thumbnails;
@@ -70,6 +73,7 @@ class FilesPage extends StatefulWidget {
this.solo = false, this.solo = false,
required this.relayed, required this.relayed,
required this.onPlay, required this.onPlay,
this.onAddToQueue,
this.nowPlayingPath, this.nowPlayingPath,
this.thumbnails, this.thumbnails,
this.scan, this.scan,
@@ -238,6 +242,35 @@ class _FilesPageState extends State<FilesPage> {
VoidCallback _tapOf(String path) => () => _play(path); VoidCallback _tapOf(String path) => () => _play(path);
/// Right-click on a video ([context]: its tile): Put it up, Add to
/// queue, Play next.
Future<void> _menu(BuildContext context, String path) async {
final add = widget.onAddToQueue;
if (add == null) return;
final pick = await showAppMenu<int>(context, items: [
AppMenuItem(value: 0, label: widget.solo ? 'Play' : 'Put it up'),
const AppMenuItem(value: 1, label: 'Add to queue'),
const AppMenuItem(value: 2, label: 'Play next'),
]);
if (pick == null || !mounted) return;
if (pick == 0) return _play(path);
String? error;
try {
error = await add(path, next: pick == 2);
} catch (e) {
error = '$e';
}
if (error != null) _showError(error);
}
/// [child] with the right-click menu (when there is a server).
Widget _menuable(String path, Widget child) => widget.onAddToQueue == null
? child
: KeyedSubtree(
key: ValueKey('menu:$path'),
child: Builder(
builder: (context) => GestureDetector(onSecondaryTap: () => _menu(context, path), child: child)));
@override @override
Widget build(BuildContext context) => LayoutBuilder( Widget build(BuildContext context) => LayoutBuilder(
builder: (context, box) { builder: (context, box) {
@@ -468,14 +501,16 @@ class _FilesPageState extends State<FilesPage> {
itemCount: recent.length, itemCount: recent.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
final path = recent[i]; final path = recent[i];
return _RecentRow( return _menuable(
path,
_RecentRow(
key: ValueKey(path), key: ValueKey(path),
path: path, path: path,
state: _stateOf(path), state: _stateOf(path),
thumbs: _thumbs, thumbs: _thumbs,
onTap: _tapOf(path), onTap: _tapOf(path),
onRemove: () => _store.removeRecent(path), onRemove: () => _store.removeRecent(path),
); ));
}, },
), ),
]; ];
@@ -582,7 +617,9 @@ class _FilesPageState extends State<FilesPage> {
onTap: () => setState(() => _expanded.contains(id) ? _expanded.remove(id) : _expanded.add(id)), onTap: () => setState(() => _expanded.contains(id) ? _expanded.remove(id) : _expanded.add(id)),
); );
} }
return FileRow( return _menuable(
v.path,
FileRow(
key: ValueKey(v.path), key: ValueKey(v.path),
path: v.path, path: v.path,
title: v.parsed.label.isEmpty ? v.parsed.series : v.parsed.label, title: v.parsed.label.isEmpty ? v.parsed.series : v.parsed.label,
@@ -591,7 +628,7 @@ class _FilesPageState extends State<FilesPage> {
state: _stateOf(v.path), state: _stateOf(v.path),
thumbs: _thumbs, thumbs: _thumbs,
onTap: _tapOf(v.path), onTap: _tapOf(v.path),
); ));
}, },
), ),
// Everything else (movies, clips) as a thumbnail grid. // Everything else (movies, clips) as a thumbnail grid.
@@ -620,13 +657,15 @@ class _FilesPageState extends State<FilesPage> {
itemCount: loose.length, itemCount: loose.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
final v = loose[i]; final v = loose[i];
return VideoCard( return _menuable(
v.path,
VideoCard(
key: ValueKey(v.path), key: ValueKey(v.path),
video: v, video: v,
state: _stateOf(v.path), state: _stateOf(v.path),
thumbs: _thumbs, thumbs: _thumbs,
onTap: _tapOf(v.path), onTap: _tapOf(v.path),
); ));
}, },
); );
}), }),
@@ -194,6 +194,13 @@ class JellyfinAccounts extends ChangeNotifier {
JellyfinAccount? account(String serverId) => JellyfinAccount? account(String serverId) =>
_accounts.where((a) => a.serverId == serverId).firstOrNull; _accounts.where((a) => a.serverId == serverId).firstOrNull;
/// [itemId]'s page in the web UI of linked server [serverId] ("Open in
/// Jellyfin"); null when that server is not linked here.
String? webUrlOf(String serverId, String itemId) {
final a = account(serverId);
return a == null ? null : jellyfinWebUrl(a.baseUrl, itemId, serverId: serverId);
}
/// The chosen default, else the first linked server. /// The chosen default, else the first linked server.
JellyfinAccount? get defaultAccount => JellyfinAccount? get defaultAccount =>
(_defaultServerId == null ? null : account(_defaultServerId!)) ?? _accounts.firstOrNull; (_defaultServerId == null ? null : account(_defaultServerId!)) ?? _accounts.firstOrNull;
@@ -543,6 +550,17 @@ class JellyfinAccounts extends ChangeNotifier {
/// Drops cached browse results (refresh button, after a pick). /// Drops cached browse results (refresh button, after a pick).
void invalidate(String serverId) => _clearMemo(serverId); void invalidate(String serverId) => _clearMemo(serverId);
final _watched = StreamController<String>.broadcast();
/// Server ids whose watched state this app just changed (a stop report):
/// their cached lists are gone, and browse views reload.
Stream<String> get watched => _watched.stream;
void watchedChanged(String serverId) {
_clearMemo(serverId);
_watched.add(serverId);
}
@override @override
void dispose() { void dispose() {
_disposed = true; _disposed = true;
@@ -6,6 +6,8 @@ import 'package:sync_relay_core/core.dart';
import 'accounts.dart'; import 'accounts.dart';
import 'format.dart'; import 'format.dart';
import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart';
/// One level of the library browser's back stack. /// One level of the library browser's back stack.
sealed class BrowseRoute { sealed class BrowseRoute {
@@ -156,7 +158,14 @@ class BrowseController extends ChangeNotifier {
final JellyfinAccounts accounts; final JellyfinAccounts accounts;
/// Clock for the Continue row's age cut-off (tests pin it). /// Clock for the Continue row's age cut-off (tests pin it).
final DateTime Function() now; final DateTime Function() now;
BrowseController(this.accounts, {this.now = DateTime.now}); BrowseController(this.accounts, {this.now = DateTime.now}) {
// Back from watching: the rows show what was just watched.
_watchedSub = accounts.watched.listen((id) {
if (!_disposed && id == serverId) dataChanged();
});
}
late final StreamSubscription<String> _watchedSub;
String? _serverId; String? _serverId;
List<BrowseRoute> _stack = const [HomeRoute()]; List<BrowseRoute> _stack = const [HomeRoute()];
@@ -258,10 +267,19 @@ class BrowseController extends ChangeNotifier {
@override @override
void dispose() { void dispose() {
_disposed = true; _disposed = true;
unawaited(_watchedSub.cancel());
super.dispose(); super.dispose();
} }
} }
/// An item's title to copy: an episode with its series ("Show · Name").
String jellyfinCopyTitle(JellyfinItem item) {
final series = item.seriesName;
return item.kind == JellyfinItemKind.episode && series != null && series.isNotEmpty
? '$series · ${item.name}'
: item.name;
}
/// What every browser page needs, provided by [JellyfinBrowser]. /// What every browser page needs, provided by [JellyfinBrowser].
class BrowseScope extends InheritedWidget { class BrowseScope extends InheritedWidget {
final JellyfinAccounts accounts; final JellyfinAccounts accounts;
@@ -274,6 +292,9 @@ class BrowseScope extends InheritedWidget {
final ValueChanged<JellyfinItem> onSelect; final ValueChanged<JellyfinItem> onSelect;
/// Double-click: put it up (a series: its next episode). /// Double-click: put it up (a series: its next episode).
final ValueChanged<JellyfinItem> onPutUp; final ValueChanged<JellyfinItem> onPutUp;
/// Adds it to Up next (with next: right after what plays); null: no
/// server (the cards' menu then only copies and opens).
final void Function(JellyfinItem item, {bool next})? onAddToQueue;
/// Home shows the library chips itself (the Library tab has them in its /// Home shows the library chips itself (the Library tab has them in its
/// header instead). /// header instead).
final bool libraryStrip; final bool libraryStrip;
@@ -287,6 +308,7 @@ class BrowseScope extends InheritedWidget {
required this.selectedItemId, required this.selectedItemId,
required this.onSelect, required this.onSelect,
required this.onPutUp, required this.onPutUp,
this.onAddToQueue,
this.libraryStrip = true, this.libraryStrip = true,
required super.child, required super.child,
}); });
@@ -296,6 +318,33 @@ class BrowseScope extends InheritedWidget {
void onOpen(BrowseRoute route) => controller.open(route); void onOpen(BrowseRoute route) => controller.open(route);
/// Right-click on a card ([context]: the card): Put it up, Add to queue,
/// Play next (playable, in a server), Copy title, Open in Jellyfin.
Future<void> cardMenu(BuildContext context, JellyfinItem item) async {
final queue = isSelectable(item) ? onAddToQueue : null;
final pick = await showAppMenu<int>(context, items: [
if (queue != null) ...const [
AppMenuItem(value: 0, label: 'Put it up'),
AppMenuItem(value: 1, label: 'Add to queue'),
AppMenuItem(value: 2, label: 'Play next'),
],
const AppMenuItem(value: 3, label: ReuseText.copyTitle),
const AppMenuItem(value: 4, label: ReuseText.openInJellyfin),
]);
switch (pick) {
case 0:
onPutUp(item);
case 1:
queue!(item);
case 2:
queue!(item, next: true);
case 3:
await copyToClipboard(jellyfinCopyTitle(item), done: 'Title copied');
case 4:
await openLink(jellyfinWebUrl(client.baseUrl, item.id, serverId: account.serverId));
}
}
/// Cached browse call on this server. /// Cached browse call on this server.
Future<T> load<T>(String key, Future<T> Function(JellyfinClient c) fn) => Future<T> load<T>(String key, Future<T> Function(JellyfinClient c) fn) =>
accounts.cached(account.serverId, key, () => fn(client)); accounts.cached(account.serverId, key, () => fn(client));
@@ -30,6 +30,8 @@ class JellyfinBrowser extends StatefulWidget {
final JellyfinSelection? selected; final JellyfinSelection? selected;
final ValueChanged<JellyfinSelection> onSelect; final ValueChanged<JellyfinSelection> onSelect;
final ValueChanged<JellyfinSelection> onPutUp; final ValueChanged<JellyfinSelection> onPutUp;
/// Right-click on a card: Add to queue / Play next; null: no server.
final void Function(JellyfinSelection item, {bool next})? onAddToQueue;
final VoidCallback? onOpenIntegrations; final VoidCallback? onOpenIntegrations;
final Duration searchDebounce; final Duration searchDebounce;
/// Opens here instead of the remembered location (the series of the /// Opens here instead of the remembered location (the series of the
@@ -43,6 +45,7 @@ class JellyfinBrowser extends StatefulWidget {
required this.accounts, required this.accounts,
required this.onSelect, required this.onSelect,
required this.onPutUp, required this.onPutUp,
this.onAddToQueue,
this.controller, this.controller,
this.selected, this.selected,
this.onOpenIntegrations, this.onOpenIntegrations,
@@ -221,6 +224,10 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
selectedItemId: sel != null && sel.serverId == account.serverId ? sel.itemId : null, selectedItemId: sel != null && sel.serverId == account.serverId ? sel.itemId : null,
onSelect: (item) => widget.onSelect(JellyfinSelection(account.serverId, item.id, item)), onSelect: (item) => widget.onSelect(JellyfinSelection(account.serverId, item.id, item)),
onPutUp: (item) => widget.onPutUp(JellyfinSelection(account.serverId, item.id, item)), onPutUp: (item) => widget.onPutUp(JellyfinSelection(account.serverId, item.id, item)),
onAddToQueue: widget.onAddToQueue == null
? null
: (item, {next = false}) =>
widget.onAddToQueue!(JellyfinSelection(account.serverId, item.id, item), next: next),
libraryStrip: widget.libraryStrip, libraryStrip: widget.libraryStrip,
child: Focus( child: Focus(
onKeyEvent: _onKey, onKeyEvent: _onKey,
+1
View File
@@ -186,6 +186,7 @@ class _MediaCardState extends State<MediaCard> {
focusColor: Colors.transparent, focusColor: Colors.transparent,
hoverColor: Colors.transparent, hoverColor: Colors.transparent,
onTap: () => _tap(scope), onTap: () => _tap(scope),
onSecondaryTap: () => scope.cardMenu(context, item),
onHover: (h) => setState(() => _hover = h), onHover: (h) => setState(() => _hover = h),
onFocusChange: (f) => setState(() => _focus = f), onFocusChange: (f) => setState(() => _focus = f),
child: Column( child: Column(
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../shell/motion.dart'; import '../shell/motion.dart';
import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart';
import 'accounts.dart'; import 'accounts.dart';
import 'browse_scope.dart'; import 'browse_scope.dart';
import 'cards.dart' show doubleClickTime, kindIcon; import 'cards.dart' show doubleClickTime, kindIcon;
@@ -44,12 +46,16 @@ class JellyfinDetailsPanel extends StatefulWidget {
final bool solo; final bool solo;
/// Puts [item] (playable) up; returns an error text, or null when sent. /// Puts [item] (playable) up; returns an error text, or null when sent.
final Future<String?> Function(JellyfinSelection item) onPutUp; final Future<String?> Function(JellyfinSelection item) onPutUp;
/// Adds [item] to Up next (with next: right after what plays); null: no
/// server, no queue actions.
final Future<String?> Function(JellyfinSelection item, {bool next})? onAddToQueue;
final VoidCallback onClose; final VoidCallback onClose;
const JellyfinDetailsPanel({ const JellyfinDetailsPanel({
super.key, super.key,
required this.controller, required this.controller,
required this.selection, required this.selection,
required this.onPutUp, required this.onPutUp,
this.onAddToQueue,
required this.onClose, required this.onClose,
this.solo = false, this.solo = false,
}); });
@@ -122,6 +128,55 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
}); });
} }
Future<void> _queue(JellyfinItem target, {bool next = false}) async {
final add = widget.onAddToQueue;
if (add == null || _busy) return;
setState(() {
_busy = true;
_error = null;
});
String? error;
try {
error = await add(JellyfinSelection(_serverId, target.id, target), next: next);
} catch (e) {
error = '$e';
}
if (!mounted) return;
setState(() {
_busy = false;
_error = error;
});
}
/// Right-click on an episode ([context]: its row): Put it up, Add to
/// queue, Play next (in a server), Copy title, Open in Jellyfin.
Future<void> _episodeMenu(BuildContext context, JellyfinItem e) async {
final queue = widget.onAddToQueue != null;
if (queue) setState(() => _targetId = e.id);
final client = _client;
final pick = await showAppMenu<int>(context, items: [
if (queue) ...[
AppMenuItem(value: 0, label: widget.solo ? 'Play' : 'Put it up'),
const AppMenuItem(value: 1, label: 'Add to queue'),
const AppMenuItem(value: 2, label: 'Play next'),
],
const AppMenuItem(value: 3, label: ReuseText.copyTitle),
if (client != null) const AppMenuItem(value: 4, label: ReuseText.openInJellyfin),
]);
switch (pick) {
case 0:
await _putUp(e);
case 1:
await _queue(e);
case 2:
await _queue(e, next: true);
case 3:
await copyToClipboard(jellyfinCopyTitle(e), done: 'Title copied');
case 4:
await openLink(jellyfinWebUrl(client!.baseUrl, e.id, serverId: _serverId));
}
}
/// Runs a watched-state change, then lets every row reload. /// Runs a watched-state change, then lets every row reload.
Future<void> _change(Future<void> Function(JellyfinClient c) action) async { Future<void> _change(Future<void> Function(JellyfinClient c) action) async {
final c = _client; final c = _client;
@@ -307,6 +362,16 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
: const Icon(Icons.play_arrow), : const Icon(Icons.play_arrow),
label: Text(label, overflow: TextOverflow.ellipsis), label: Text(label, overflow: TextOverflow.ellipsis),
), ),
if (playable != null && widget.onAddToQueue != null)
GestureDetector(
onSecondaryTap: _busy ? null : () => _queue(playable, next: true),
child: IconButton(
key: const ValueKey('details-add-queue'),
onPressed: _busy ? null : () => _queue(playable),
tooltip: 'Add to queue (right-click: play next)',
icon: const Icon(Icons.playlist_add_rounded),
),
),
if (playable != null) if (playable != null)
IconButton( IconButton(
key: const ValueKey('details-watched'), key: const ValueKey('details-watched'),
@@ -328,6 +393,13 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
onPressed: () => _c.open(SeriesRoute(d.series!, seasonId: seasonId)), onPressed: () => _c.open(SeriesRoute(d.series!, seasonId: seasonId)),
icon: const Icon(Icons.open_in_new), icon: const Icon(Icons.open_in_new),
), ),
if (client != null)
IconButton(
key: const ValueKey('details-open-jellyfin'),
tooltip: ReuseText.openInJellyfin,
onPressed: () => openLink(jellyfinWebUrl(client.baseUrl, shown.id, serverId: _serverId)),
icon: const Icon(Icons.open_in_browser_rounded),
),
], ],
), ),
if (_error != null) ...[ if (_error != null) ...[
@@ -392,18 +464,14 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
children: [ children: [
const Expanded(child: Text('Episodes', style: TextStyle(fontWeight: FontWeight.w600))), const Expanded(child: Text('Episodes', style: TextStyle(fontWeight: FontWeight.w600))),
if (seasons.length > 1) if (seasons.length > 1)
DropdownButton<String>( Flexible(
child: AppSelect<String?>(
key: const ValueKey('details-season'), key: const ValueKey('details-season'),
value: seasonId, value: seasonId,
isDense: true, items: [for (final s in seasons) (s.id, s.name)],
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), onChanged: (id) => setState(() => _seasonId = id),
), ),
),
], ],
); );
} }
@@ -416,10 +484,12 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
return Material( return Material(
color: isTarget ? scheme.primary.withValues(alpha: 0.10) : Colors.transparent, color: isTarget ? scheme.primary.withValues(alpha: 0.10) : Colors.transparent,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: InkWell( // The row's own context: its menu opens at the row.
child: Builder(builder: (context) => InkWell(
key: ValueKey('details-episode-${e.id}'), key: ValueKey('details-episode-${e.id}'),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
onTap: () => _tapEpisode(e), onTap: () => _tapEpisode(e),
onSecondaryTap: () => _episodeMenu(context, e),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 4), padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 4),
child: Row( child: Row(
@@ -464,7 +534,7 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
], ],
), ),
), ),
), )),
); );
} }
} }
+7 -6
View File
@@ -3,6 +3,7 @@ import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../state/settings.dart'; import '../state/settings.dart';
import '../widgets/app_menu.dart';
import 'browse_scope.dart'; import 'browse_scope.dart';
import 'cards.dart'; import 'cards.dart';
import 'home_rows.dart'; import 'home_rows.dart';
@@ -184,18 +185,18 @@ class _LatestRow extends StatelessWidget {
onPressed: () => scope.onOpen(LibraryRoute(view, sort: 'added')), onPressed: () => scope.onOpen(LibraryRoute(view, sort: 'added')),
child: const Text('Show all'), child: const Text('Show all'),
), ),
PopupMenuButton<void>( AppMenuButton<bool>(
key: ValueKey('latest-menu-${view.id}'), key: ValueKey('latest-menu-${view.id}'),
tooltip: 'Row options', tooltip: 'Row options',
icon: const Icon(Icons.more_vert, size: 18), items: () => [
itemBuilder: (context) => [ AppMenuItem(
CheckedPopupMenuItem(
key: const ValueKey('hide-rereleases'), key: const ValueKey('hide-rereleases'),
value: !hide,
checked: hide, checked: hide,
onTap: () => AppSettings.instance.setHideReReleases(!hide), label: 'Hide re-releases in Latest',
child: const Text('Hide re-releases in Latest'),
), ),
], ],
onSelected: AppSettings.instance.setHideReReleases,
), ),
], ],
), ),
@@ -14,6 +14,7 @@ class JellyfinPickerSource extends StatefulWidget {
final JellyfinSelection? selected; final JellyfinSelection? selected;
final ValueChanged<JellyfinSelection> onSelect; final ValueChanged<JellyfinSelection> onSelect;
final ValueChanged<JellyfinSelection> onPutUp; final ValueChanged<JellyfinSelection> onPutUp;
final void Function(JellyfinSelection item, {bool next})? onAddToQueue;
final VoidCallback? onOpenIntegrations; final VoidCallback? onOpenIntegrations;
final BrowseStart? start; final BrowseStart? start;
final bool libraryStrip; final bool libraryStrip;
@@ -25,6 +26,7 @@ class JellyfinPickerSource extends StatefulWidget {
required this.accounts, required this.accounts,
required this.onSelect, required this.onSelect,
required this.onPutUp, required this.onPutUp,
this.onAddToQueue,
this.controller, this.controller,
this.selected, this.selected,
this.onOpenIntegrations, this.onOpenIntegrations,
@@ -61,6 +63,7 @@ class _JellyfinPickerSourceState extends State<JellyfinPickerSource> {
selected: widget.selected, selected: widget.selected,
onSelect: widget.onSelect, onSelect: widget.onSelect,
onPutUp: widget.onPutUp, onPutUp: widget.onPutUp,
onAddToQueue: widget.onAddToQueue,
onOpenIntegrations: widget.onOpenIntegrations, onOpenIntegrations: widget.onOpenIntegrations,
start: widget.start, start: widget.start,
libraryStrip: widget.libraryStrip, libraryStrip: widget.libraryStrip,
+88 -8
View File
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
@@ -24,6 +25,14 @@ class PlayerSnapshot {
/// go out one after another; a failure is logged and handed to [onError] /// go out one after another; a failure is logged and handed to [onError]
/// (the session shows one quiet toast) and never touches playback. A 401 /// (the session shows one quiet toast) and never touches playback. A 401
/// calls [onUnauthorized] and ends reporting. /// calls [onUnauthorized] and ends reporting.
///
/// Watched or not is [WatchSession]'s rule, not Jellyfin's position
/// percentages (see ARCHITECTURE.md "Jellyfin in a session"): an item
/// played before ([playedBefore]) stays played (re-marked after Playing,
/// Progress without a position, Stopped without one, then marked played
/// again); otherwise Progress leaves the position out past
/// [_noPositionFrom] of the runtime so Jellyfin cannot mark it played on
/// its own, and Stopped carries what the rule says.
class JellyfinProgressReporter { class JellyfinProgressReporter {
final JellyfinClient client; final JellyfinClient client;
final String itemId; final String itemId;
@@ -34,20 +43,41 @@ class JellyfinProgressReporter {
final void Function(Object error)? onError; final void Function(Object error)? onError;
final VoidCallback? onUnauthorized; final VoidCallback? onUnauthorized;
/// What this viewer watched of the item while it was open here.
final WatchSession watch;
JellyfinProgressReporter({ JellyfinProgressReporter({
required this.client, required this.client,
required this.itemId, required this.itemId,
required this.source, required this.source,
required this.playSessionId, required this.playSessionId,
bool playedBefore = false,
int resumeTicksBefore = 0,
this.interval = const Duration(seconds: 10), this.interval = const Duration(seconds: 10),
this.onError, this.onError,
this.onUnauthorized, this.onUnauthorized,
this.onStopped,
DateTime Function()? clock, DateTime Function()? clock,
}) : _clock = clock ?? DateTime.now; }) : _clock = clock ?? DateTime.now,
_keepPlayed = playedBefore,
_runtime = (source.runTimeTicks ?? 0) / jellyfinTicksPerSecond,
watch = WatchSession(
playedBefore: playedBefore, resumeBefore: resumeTicksBefore / jellyfinTicksPerSecond);
/// Past this share of the runtime Progress carries no position: Jellyfin
/// marks an item played past its "max resume" share (90 % by default).
static const _noPositionFrom = 0.85;
/// After Stopped (and a mark played) went out: the item's watched
/// state on the server may differ from what lists show.
final void Function()? onStopped;
Future<void> _chain = Future.value(); Future<void> _chain = Future.value();
bool _playing = false; bool _playing = false;
bool _dead = false; bool _dead = false;
bool _keepPlayed;
double _runtime;
double? _endingStart;
PlayerSnapshot? _last; PlayerSnapshot? _last;
DateTime? _lastAt; DateTime? _lastAt;
DateTime? _lastSentAt; DateTime? _lastSentAt;
@@ -55,13 +85,23 @@ class JellyfinProgressReporter {
/// Between [start] and [stop]. /// Between [start] and [stop].
bool get active => _playing && !_dead; bool get active => _playing && !_dead;
/// The item counts as played here (before, or by the rule at a stop).
bool get keepsPlayed => _keepPlayed;
/// The player's duration (used when Jellyfin gave no runtime) and where
/// the ending starts (Outro segment / ED chapter), seconds.
void timeline({double duration = 0, double? endingStart}) {
if ((source.runTimeTicks ?? 0) <= 0 && duration > 0) _runtime = duration;
_endingStart = endingStart;
}
/// The report body for [s]: ticks, pause, and mpv's aid/sid mapped to /// The report body for [s]: ticks, pause, and mpv's aid/sid mapped to
/// Jellyfin stream indices (-1 = subtitles off). /// Jellyfin stream indices (-1 = subtitles off).
JellyfinPlaybackReport reportFor(PlayerSnapshot s) => JellyfinPlaybackReport( JellyfinPlaybackReport reportFor(PlayerSnapshot s, {bool withPosition = true}) => JellyfinPlaybackReport(
itemId: itemId, itemId: itemId,
mediaSourceId: source.id, mediaSourceId: source.id,
playSessionId: playSessionId, playSessionId: playSessionId,
positionTicks: (s.position * jellyfinTicksPerSecond).round(), positionTicks: withPosition ? _ticks(s.position) : null,
isPaused: s.paused, isPaused: s.paused,
audioStreamIndex: s.aid == null audioStreamIndex: s.aid == null
? null ? null
@@ -71,13 +111,24 @@ class JellyfinProgressReporter {
: source.jellyfinIndexForMpvTrack(JellyfinStreamType.subtitle, s.sid!), : source.jellyfinIndexForMpvTrack(JellyfinStreamType.subtitle, s.sid!),
); );
static int _ticks(double secs) => (secs * jellyfinTicksPerSecond).round();
/// Progress may carry the position: not for a played item, not without
/// a runtime and not near the end (either would let Jellyfin decide).
bool _progressPosition(double position) =>
!_keepPlayed && _runtime > 0 && position < _noPositionFrom * _runtime;
/// Playing: once the media is open. Again after [stop] (played to the /// Playing: once the media is open. Again after [stop] (played to the
/// end, then someone seeked back) starts reporting anew. /// end, then someone seeked back) starts reporting anew. A played item
/// is marked played again right after (Jellyfin up to 10.11 clears it
/// on Playing).
void start(PlayerSnapshot s) { void start(PlayerSnapshot s) {
if (_playing || _dead) return; if (_playing || _dead) return;
_playing = true; _playing = true;
_mark(s); _mark(s);
watch.sample(s.position, playing: !s.paused, at: _clock());
_send('Playing', () => client.reportPlaybackStart(reportFor(s))); _send('Playing', () => client.reportPlaybackStart(reportFor(s)));
if (_keepPlayed) _send('played', () => client.markPlayed(itemId));
} }
/// Progress when due (see the class doc). /// Progress when due (see the class doc).
@@ -85,6 +136,7 @@ class JellyfinProgressReporter {
if (!_playing || _dead) return; if (!_playing || _dead) return;
final last = _last, lastAt = _lastAt, sentAt = _lastSentAt; final last = _last, lastAt = _lastAt, sentAt = _lastSentAt;
final now = _clock(); final now = _clock();
watch.sample(s.position, playing: !s.paused, at: now);
var due = force || last == null || lastAt == null || sentAt == null; var due = force || last == null || lastAt == null || sentAt == null;
if (!due) { if (!due) {
final elapsed = now.difference(lastAt).inMilliseconds / 1000; final elapsed = now.difference(lastAt).inMilliseconds / 1000;
@@ -99,16 +151,44 @@ class JellyfinProgressReporter {
_lastAt = now; _lastAt = now;
if (!due) return; if (!due) return;
_lastSentAt = now; _lastSentAt = now;
_send('Progress', () => client.reportPlaybackProgress(reportFor(s))); final report = reportFor(s, withPosition: _progressPosition(s.position));
_send('Progress', () => client.reportPlaybackProgress(report));
} }
/// Stopped (swap, leave, kick, window close, played to the end). /// Stopped (swap, leave, kick, window close, played to the end), as
/// [WatchSession.verdict] says: played (or kept played) -> no position,
/// then marked played; a peek -> the resume point from before; part way
/// -> the position, held under [_noPositionFrom] of the runtime.
/// Completes when it and everything before it went out. /// Completes when it and everything before it went out.
Future<void> stop(PlayerSnapshot s) { Future<void> stop(PlayerSnapshot s) {
if (!_playing || _dead) return _chain; if (!_playing || _dead) return _chain;
_playing = false; _playing = false;
_send('Stopped', () => client.reportPlaybackStopped(reportFor(s))); watch.sample(s.position, playing: !s.paused, at: _clock());
return _chain; final v = _keepPlayed
? const WatchVerdict(WatchOutcome.keepPlayed)
: watch.verdict(runtime: _runtime, endingStart: _endingStart);
debugPrint('jellyfin $itemId: $v (covered ${watch.covered.round()} s of ${_runtime.round()} s)');
final int? ticks;
switch (v.outcome) {
case WatchOutcome.played || WatchOutcome.keepPlayed:
_keepPlayed = true;
ticks = null;
case WatchOutcome.peek:
ticks = _ticks(watch.resumeBefore);
case WatchOutcome.partial:
final at = v.resumeAt ?? s.position;
ticks = _ticks(_runtime > 0 ? math.min(at, _noPositionFrom * _runtime) : at);
}
final report = JellyfinPlaybackReport(
itemId: itemId, mediaSourceId: source.id, playSessionId: playSessionId, positionTicks: ticks);
_send('Stopped', () => client.reportPlaybackStopped(report));
if (ticks == null) {
_send('played', () => client.markPlayed(itemId));
// Marking played keeps a resume point Progress left (Continue would
// still list it, "3 min left"): clear it.
_send('resume cleared', () => client.clearResumePosition(itemId));
}
return _chain = _chain.then((_) => onStopped?.call());
} }
void _mark(PlayerSnapshot s) { void _mark(PlayerSnapshot s) {
@@ -375,6 +375,7 @@ class _EpisodeTileState extends State<EpisodeTile> {
key: ValueKey('episode-${e.id}'), key: ValueKey('episode-${e.id}'),
focusColor: Colors.transparent, focusColor: Colors.transparent,
onTap: () => _tap(scope), onTap: () => _tap(scope),
onSecondaryTap: () => scope.cardMenu(context, widget.episode),
onHover: (h) => setState(() => _hover = h), onHover: (h) => setState(() => _hover = h),
onFocusChange: (f) => setState(() => _focus = f), onFocusChange: (f) => setState(() => _focus = f),
child: Padding( child: Padding(
@@ -318,10 +318,11 @@ Future<({EpisodeLink? previous, EpisodeLink? next})> findAdjacentEpisodes(OwnPla
} }
} }
/// When the floating "Next episode" pill appears: at [endingStart] (the /// Where the "Next episode" pill's window starts: at [endingStart] (the
/// ED: Jellyfin Outro segment or ED-titled chapter, see `endingRange`) /// ED: Jellyfin Outro segment or ED-titled chapter, see `endingRange`)
/// when one is known inside [runtime], else the last two minutes of /// when one is known inside [runtime], else the last two minutes of
/// [runtime] (all of it for shorter media). /// [runtime] (all of it for shorter media). The pill shows only while the
/// controls are up (`PillGate`), so a guessed window costs nothing.
Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? endingStart}) { Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? endingStart}) {
if (endingStart != null && endingStart > Duration.zero && endingStart < runtime) { if (endingStart != null && endingStart > Duration.zero && endingStart < runtime) {
return endingStart; return endingStart;
+107
View File
@@ -0,0 +1,107 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sync_relay_core/core.dart';
import '../playback/yt_dlp_app.dart';
import '../screens/home/ui.dart' show appName, displayVersion;
import '../state/session_state.dart';
// The app's log on disk (<app support>/logs/app-YYYY-MM-DD.log; core's
// logging.dart writes, rotates and redacts it), debugPrint routed into it,
// and the "Copy diagnostics" text of Settings → About.
final _appLog = createLogger('app');
/// The logs folder, once [startAppLogging] ran.
String? appLogDir;
/// Opens the log files and routes `debugPrint` and uncaught errors into
/// the log. Needs Flutter's binding (path_provider).
Future<void> startAppLogging() async {
try {
final base = await getApplicationSupportDirectory();
final dir = '${base.path}${Platform.pathSeparator}logs';
if (startLogFiles(dir) != null) appLogDir = dir;
} catch (e) {
_appLog.warning('no log files: $e');
}
// Everything the app prints for diagnosis lands in the file too (the
// log's console output replaces the print).
debugPrint = (String? message, {int? wrapWidth}) {
if (message == null || message.isEmpty) return;
final bad = RegExp(r'fail|error|crash|exception', caseSensitive: false).hasMatch(message);
bad ? _appLog.warning(message) : _appLog.info(message);
};
PlatformDispatcher.instance.onError = (error, stack) {
_appLog.severe('uncaught: $error', error, stack);
return false;
};
_appLog.info('$appName ${displayVersion()} on ${Platform.operatingSystem} '
'${Platform.operatingSystemVersion}, pid $pid, Dart ${Platform.version.split(' ').first}');
}
/// The text "Copy diagnostics" gives: versions, then the last [since] of
/// the app log (its hosted server's records included), already redacted;
/// with [sessionLog], the server's session log of the same hours after it
/// (every member's shared lines and the server's, merged).
Future<String> diagnosticsText({Duration since = const Duration(hours: 6), bool sessionLog = false}) async {
final b = StringBuffer()
..writeln('$appName ${displayVersion()}')
..writeln('OS: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}')
..writeln('Dart: ${Platform.version.split(' ').first}');
try {
final mpv = await SessionState.instance.coordinator.playback.mpvVersion().timeout(const Duration(seconds: 2));
b.writeln('mpv: ${mpv ?? 'no player yet'}');
} catch (_) {
b.writeln('mpv: unknown');
}
try {
final y = await AppYtDlp.instance.ytDlp.timeout(const Duration(seconds: 2));
final cookies = y.cookies.read(count: false);
b.writeln('yt-dlp: ${y.version ?? 'not ready'}${y.lastError == null ? '' : ' (${y.lastError})'}, '
'cookies: ${cookies == null ? 'none' : 'set'}');
} catch (_) {
b.writeln('yt-dlp: unknown');
}
final files = logFiles;
b.writeln('Log: ${files?.currentPath ?? 'none (console only)'}');
b.writeln('--- last ${since.inHours} h of the log ---');
b.write(files?.recent(since) ?? '');
if (sessionLog) {
final server = await SessionState.instance.coordinator.sessionLog(hours: since.inHours);
b
..writeln()
..writeln("--- last ${since.inHours} h of the server's session log ---")
..write(server ?? "(none: the server keeps no session log, or it couldn't be reached)");
}
return redactLog(b.toString());
}
/// Saves [text] next to the logs as `diagnostics-<time>.txt`; its path,
/// or null when it could not be written.
Future<String?> saveDiagnostics(String text) async {
final dir = appLogDir;
if (dir == null) return null;
final t = DateTime.now();
String two(int n) => n.toString().padLeft(2, '0');
final path = '$dir${Platform.pathSeparator}diagnostics-${t.year}${two(t.month)}${two(t.day)}-'
'${two(t.hour)}${two(t.minute)}${two(t.second)}.txt';
try {
// Older ones go after the log files' week.
for (final e in Directory(dir).listSync()) {
if (e is File &&
e.uri.pathSegments.last.startsWith('diagnostics-') &&
t.difference(e.statSync().modified) > const Duration(days: 7)) {
e.deleteSync();
}
}
await File(path).writeAsString(text);
return path;
} catch (e) {
_appLog.warning('cannot save diagnostics: $e');
return null;
}
}
@@ -0,0 +1,133 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter_soloud/flutter_soloud.dart';
import 'package:sync_relay_core/core.dart';
final _log = createLogger('sound');
/// What [SoundEngine] needs from an audio library: [SoLoudBackend] in the
/// app, a fake in tests. A sound handle is whatever [load] returns.
abstract class AudioBackend {
Future<void> init();
/// Decodes [wav] (a whole WAV file) into memory under [name].
Future<Object> load(String name, Uint8List wav);
/// Starts one voice of [sound] at [volume] (linear gain, 0..1); voices
/// overlap.
void play(Object sound, double volume);
Future<void> unload(Object sound);
Future<void> deinit();
}
/// flutter_soloud (SoLoud mixing into miniaudio's output device).
class SoLoudBackend implements AudioBackend {
SoLoud get _s => SoLoud.instance;
@override
Future<void> init() => _s.init();
@override
Future<Object> load(String name, Uint8List wav) => _s.loadMem(name, wav);
@override
void play(Object sound, double volume) => _s.play(sound as AudioSource, volume: volume);
@override
Future<void> unload(Object sound) => _s.disposeSource(sound as AudioSource);
@override
Future<void> deinit() => _s.deinitAsync();
}
/// The app's short in-memory sounds. The audio device opens on [warmUp] or
/// the first [play]; if it can't (no device, a broken driver) the engine
/// logs once and every play is silently dropped. Each sound is loaded once
/// per slot from the WAV bytes its renderer gives; a new key in a slot
/// replaces (and frees) what the slot held.
class SoundEngine {
final AudioBackend _backend;
SoundEngine({AudioBackend? backend}) : _backend = backend ?? SoLoudBackend();
Future<bool>? _ready;
var _disposed = false;
/// Per slot: the loaded key and its sound (null: failed to load).
final Map<String, (String, Future<Object?>)> _slots = {};
/// Load names are unique: SoLoud hands back the old sound for a name it
/// already holds, which would defeat a re-render.
var _loads = 0;
/// Opens the audio device now, so the first sound has no wait.
Future<bool> warmUp() => _ready ??= _init();
Future<bool> _init() async {
if (_disposed) return false;
try {
await _backend.init();
return true;
} catch (e) {
_log.warning('no sounds: audio init failed: $e');
return false;
}
}
/// Plays the sound of [slot] at [volume] (linear gain). [key] names what
/// [wav] renders: the same key reuses the loaded sound; another key, or
/// [fresh], renders and loads it again.
Future<void> play(String slot, String key, Future<Uint8List> Function() wav,
{required double volume, bool fresh = false}) async {
if (_disposed || volume <= 0) return;
if (!await warmUp() || _disposed) return;
var held = _slots[slot];
if (held == null || held.$1 != key || fresh) {
if (held != null) unawaited(_unload(held.$2));
held = (key, _load(key, wav));
_slots[slot] = held;
}
final sound = await held.$2;
if (sound == null) {
// Retry on the next play.
if (identical(_slots[slot], held)) _slots.remove(slot);
return;
}
if (_disposed || !identical(_slots[slot], held)) return;
try {
_backend.play(sound, volume.clamp(0.0, 1.0));
} catch (e) {
_log.warning('sound $key: $e');
}
}
Future<Object?> _load(String key, Future<Uint8List> Function() wav) async {
try {
return await _backend.load('$key#${_loads++}', await wav());
} catch (e) {
_log.warning('sound $key: $e');
return null;
}
}
Future<void> _unload(Future<Object?> sound) async {
try {
final s = await sound;
if (s != null && !_disposed) await _backend.unload(s);
} catch (e) {
_log.fine('unload: $e');
}
}
/// Frees every sound and closes the audio device. Plays after it are
/// dropped.
Future<void> dispose() async {
if (_disposed) return;
_disposed = true;
_slots.clear();
final ready = _ready;
if (ready == null || !await ready) return;
try {
// Deinit frees the loaded sounds with the engine.
await _backend.deinit();
} catch (e) {
_log.warning('audio deinit: $e');
}
}
}
@@ -0,0 +1,106 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:screen_retriever/screen_retriever.dart';
import 'package:window_manager/window_manager.dart';
/// The window's size, position and maximized state across restarts (and
/// hot restarts), in the settings box. The size and position kept are
/// the normal (unmaximized) ones; fullscreen is never saved or restored.
/// Wayland lets no app place its own window, so there only the size and
/// maximized state come back (the compositor decides the position).
class WindowMemory with WindowListener {
static const _key = 'windowBounds';
static const defaultSize = Size(1280, 720);
// Null when the settings box failed to open: defaults, nothing saved.
final Box? _box;
Timer? _save;
WindowMemory(this._box);
static bool get _canPlace => !(Platform.isLinux && Platform.environment['WAYLAND_DISPLAY'] != null);
Map? get _saved => _box?.get(_key) is Map ? _box!.get(_key) as Map : null;
/// The size to open at: the last normal size, else [defaultSize].
Size get size {
final s = _saved;
final w = s?['w'], h = s?['h'];
return w is num && h is num && w >= 160 && h >= 90 ? Size(w.toDouble(), h.toDouble()) : defaultSize;
}
/// Whether a saved position exists to place the window at (else center).
bool get hasPosition => _canPlace && _saved?['x'] is num && _saved?['y'] is num;
/// After the window shows: back to the saved position when it still
/// lands on a connected display, maximized when it was; then listens.
Future<void> restore() async {
final s = _saved;
try {
if (s != null && hasPosition) {
final rect = Offset((s['x'] as num).toDouble(), (s['y'] as num).toDouble()) & size;
if (await _onSomeDisplay(rect)) {
await windowManager.setPosition(rect.topLeft);
} else {
await windowManager.center();
}
}
if (s?['max'] == true) await windowManager.maximize();
} catch (e) {
debugPrint('window restore failed: $e');
}
windowManager.addListener(this);
}
// At least a 100×50 corner of the title area on a display's visible area.
static Future<bool> _onSomeDisplay(Rect r) async {
final grip = Rect.fromLTWH(r.left, r.top, r.width.clamp(0, 100).toDouble(), 50);
for (final d in await screenRetriever.getAllDisplays()) {
final area = (d.visiblePosition ?? Offset.zero) & (d.visibleSize ?? d.size);
if (area.overlaps(grip)) return true;
}
return false;
}
void _later() {
_save?.cancel();
_save = Timer(const Duration(milliseconds: 400), () => unawaited(save()));
}
/// Writes the current state now (also on close).
Future<void> save() async {
_save?.cancel();
final box = _box;
if (box == null) return;
try {
if (await windowManager.isFullScreen() || await windowManager.isMinimized()) return;
final max = await windowManager.isMaximized();
final s = Map<String, Object?>.of((_saved ?? const {}).cast<String, Object?>());
s['max'] = max;
if (!max) {
final b = await windowManager.getBounds();
s['w'] = b.width;
s['h'] = b.height;
if (_canPlace) {
s['x'] = b.left;
s['y'] = b.top;
}
}
await box.put(_key, s);
} catch (e) {
debugPrint('window save failed: $e');
}
}
@override
void onWindowResized() => _later();
@override
void onWindowMoved() => _later();
@override
void onWindowMaximize() => _later();
@override
void onWindowUnmaximize() => _later();
}
@@ -1,120 +0,0 @@
import 'dart:typed_data';
/// FNV-1a over a 15x15 grid of RGB(A) pixels in the central half of an
/// image. Used on a small capture of what the compositor shows: the
/// centre is video, the edges carry the (changing) control overlays.
int hashCenterPixels(ByteData rgba, int width, int height) {
var x = 0x811c9dc5;
if (width <= 0 || height <= 0) return x;
for (var gy = 1; gy < 16; gy++) {
for (var gx = 1; gx < 16; gx++) {
final px = (width * (0.25 + 0.5 * gx / 16)).floor();
final py = (height * (0.25 + 0.5 * gy / 16)).floor();
final o = (py * width + px) * 4;
if (o + 3 > rgba.lengthInBytes) continue;
for (var c = 0; c < 3; c++) {
x = ((x ^ rgba.getUint8(o + c)) * 0x01000193) & 0xffffffff;
}
}
}
return x;
}
/// FNV-1a over ~4k bytes spread evenly across a raw frame (mpv
/// `screenshot-raw`); dimension-agnostic, enough to tell frames apart.
int hashSpread(Uint8List bytes) {
var x = 0x811c9dc5;
if (bytes.isEmpty) return x;
final step = bytes.length <= 4096 ? 1 : bytes.length ~/ 4096;
for (var i = 0; i < bytes.length; i += step) {
x = ((x ^ bytes[i]) * 0x01000193) & 0xffffffff;
}
return x;
}
/// One sample of player state, taken about once a second.
class FrameSample {
final DateTime at;
final double position;
/// Fingerprint of the picture currently shown on screen; null when it
/// cannot be captured (no view, hidden window, headless).
final int? frames;
/// Playing, not buffering, rate > 0, video track present, window
/// visible. Anything else disarms the watchdog.
final bool eligible;
const FrameSample({
required this.at,
required this.position,
required this.frames,
required this.eligible,
});
}
/// Detects a frozen picture: position advances normally but the shown
/// picture stands still for [stallAfter]. Seeks (position jumps), pauses,
/// buffering and missing captures all reset the stall clock. A hit is a
/// suspicion only — a still scene looks the same — so the caller confirms
/// it against the decoder and calls [dismiss] when it was a still scene.
/// Fires at most once until the picture is seen moving again for
/// [rearmAfter], so a player that stays (or soon gets) frozen after a
/// reload does not reload in a loop. [reset] keeps that latch.
class FrameWatchdog {
static const stallAfter = Duration(seconds: 3);
static const rearmAfter = Duration(seconds: 10);
FrameSample? _last;
DateTime? _stallSince;
DateTime? _healthySince;
bool _fired = false;
void reset() {
_last = null;
_stallSince = null;
_healthySince = null;
}
/// The last hit was a still scene: re-arm, but wait a full
/// [stallAfter] before suspecting again.
void dismiss() {
_fired = false;
_stallSince = null;
}
/// Returns true exactly when a freeze is suspected.
bool feed(FrameSample s) {
final last = _last;
_last = s;
if (!s.eligible || s.frames == null || last == null ||
!last.eligible || last.frames == null) {
_stallSince = null;
_healthySince = null;
return false;
}
if (s.frames != last.frames) {
_stallSince = null;
_healthySince ??= last.at;
if (s.at.difference(_healthySince!) >= rearmAfter) _fired = false;
return false;
}
_healthySince = null;
final wall = s.at.difference(last.at).inMilliseconds / 1000.0;
final moved = s.position - last.position;
// Normal playback only: a jump (seek) or a standstill (stall, the
// position itself stuck) is not a frozen picture.
final normal = wall > 0 && moved > wall * 0.5 && moved < wall * 2 + 0.5;
if (!normal) {
_stallSince = null;
return false;
}
_stallSince ??= last.at;
if (_fired) return false;
if (s.at.difference(_stallSince!) >= stallAfter) {
_fired = true;
_stallSince = null;
return true;
}
return false;
}
}
@@ -3,12 +3,12 @@ import 'dart:ffi' show Int8;
import 'dart:io'; import 'dart:io';
import 'package:ffi/ffi.dart' show StringUtf8Pointer, malloc; import 'package:ffi/ffi.dart' show StringUtf8Pointer, malloc;
import 'package:flutter/foundation.dart' show listEquals; import 'package:flutter/foundation.dart' show listEquals, mapEquals;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart'; import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType; import 'package:sync_relay_core/core.dart' show JellyfinStreamType, LogThrottle, createLogger;
import '../jellyfin/track_prefs.dart' show MpvTrack; import '../jellyfin/track_prefs.dart' show MpvTrack;
import '../shaders/shader_assets.dart'; import '../shaders/shader_assets.dart';
@@ -16,14 +16,14 @@ import '../shaders/shader_presets.dart';
import '../state/settings.dart'; import '../state/settings.dart';
import '../sync/state_diff.dart'; import '../sync/state_diff.dart';
import '../sync/user_seek.dart'; import '../sync/user_seek.dart';
import 'frame_watchdog.dart';
import 'media_marks.dart'; import 'media_marks.dart';
import 'yt_dlp_app.dart' show AppYtDlp, ytdlFormat; import 'yt_dlp_app.dart' show AppYtDlp, ytdlFormat;
/// Owns one mpv [Player] (and its [VideoController]) per session: created /// Owns the session's mpv [Player] (and its [VideoController]): created
/// by [init] on join, released by [dispose] on leave, swapped by /// by [init] on join, released by [dispose] on leave, swapped by
/// [recreate] for "Reload video". Never reuse a player across sessions — /// [recreate] for "Reload video" and by [openUrl] for every new media (a
/// a GPU-side freeze sticks to the player, not to the widget. /// promoted preload or a fresh player). Never reuse a player for other
/// media — a GPU-side freeze sticks to the player, not to the widget.
class PlaybackController { class PlaybackController {
/// Read-ahead target: about two minutes rides out short outages and /// Read-ahead target: about two minutes rides out short outages and
/// makes skipping an intro instant. /// makes skipping an intro instant.
@@ -41,9 +41,6 @@ class PlaybackController {
SyncPlayer? _player; SyncPlayer? _player;
VideoController? _video; VideoController? _video;
Timer? _watchTimer;
bool _probing = false;
final FrameWatchdog _watchdog = FrameWatchdog();
StreamSubscription<PlayerLog>? _logSub; StreamSubscription<PlayerLog>? _logSub;
final List<StreamSubscription<Object?>> _timeSubs = []; final List<StreamSubscription<Object?>> _timeSubs = [];
String _chapterJson = ''; String _chapterJson = '';
@@ -60,9 +57,6 @@ class PlaybackController {
/// otherwise, so no texture outlives its player. /// otherwise, so no texture outlives its player.
final alive = signal(false); final alive = signal(false);
/// 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). /// mpv's yt-dlp hook could not open the current link (its error text).
void Function(String error)? onYtdlError; void Function(String error)? onYtdlError;
@@ -89,6 +83,7 @@ class PlaybackController {
final p = _player; final p = _player;
if (p == null) return; if (p == null) return;
try { try {
_ytdlPath = path;
final dynamic platform = p.platform; final dynamic platform = p.platform;
if (!identical(p, _ytdlSetOn)) { if (!identical(p, _ytdlSetOn)) {
await platform?.setProperty('script-opts', 'ytdl_hook-ytdl_path=$path'); await platform?.setProperty('script-opts', 'ytdl_hook-ytdl_path=$path');
@@ -108,10 +103,7 @@ class PlaybackController {
Player? _ytdlSetOn; Player? _ytdlSetOn;
String _ytdlRaw = ''; String _ytdlRaw = '';
String? _ytdlPath;
/// Fingerprint of the picture on screen, installed by the video view
/// (`ShownFrameProbe.sample`). Without it the watchdog stays idle.
Future<int?> Function()? shownFrame;
Player get player => _player!; Player get player => _player!;
@@ -157,6 +149,9 @@ class PlaybackController {
/// `demuxer-cache-time`, media_kit's `buffer`), in 250 ms steps. /// `demuxer-cache-time`, media_kit's `buffer`), in 250 ms steps.
final buffered = signal(Duration.zero); final buffered = signal(Duration.zero);
/// The player's volume, 0..[maxVolume] (media_kit's `volume`).
final volume = signal(100.0);
/// The opening ([openingRange]: Intro segment, else an OP-titled /// The opening ([openingRange]: Intro segment, else an OP-titled
/// chapter) and the ending ([endingRange]: Outro, else ED-titled). /// chapter) and the ending ([endingRange]: Outro, else ED-titled).
late final Computed<SkipRange?> opening = late final Computed<SkipRange?> opening =
@@ -199,16 +194,19 @@ class PlaybackController {
return AppSettings.instance.hwdec.peek(); return AppSettings.instance.hwdec.peek();
} }
Future<void> init() async { SyncPlayer _newPlayer({required int bufferSize, required MPVLogLevel logLevel}) => SyncPlayer(
if (_player != null) return;
_player = SyncPlayer(
configuration: PlayerConfiguration( configuration: PlayerConfiguration(
bufferSize: maxBytes, bufferSize: bufferSize,
libass: true, libass: true,
vo: Platform.environment['SYNC_RELAY_VO'], vo: Platform.environment['SYNC_RELAY_VO'],
logLevel: MPVLogLevel.warn, // see [logLevel] logLevel: logLevel,
)) ))
..onUserSeek = (d) => onUserSeek?.call(d); ..onUserSeek = (d) => onUserSeek?.call(d);
Future<void> init() async {
if (_player != null) return;
_player = _newPlayer(bufferSize: maxBytes, logLevel: MPVLogLevel.warn); // see [logLevel]
_held = false;
_shaders = ''; _shaders = '';
renderMode.value = RenderMode.unknown; renderMode.value = RenderMode.unknown;
shaderNotice.value = null; shaderNotice.value = null;
@@ -217,8 +215,6 @@ class PlaybackController {
await _pinBufferProps(); await _pinBufferProps();
await _pin('volume-max', '$maxVolume'); await _pin('volume-max', '$maxVolume');
await applyShaders(); await applyShaders();
_watchdog.reset();
_watchTimer = Timer.periodic(const Duration(seconds: 1), (_) => _watch());
generation.value++; generation.value++;
alive.value = true; alive.value = true;
} }
@@ -236,9 +232,9 @@ class PlaybackController {
} }
} }
Future<void> _pin(String property, String value) async { Future<void> _pin(String property, String value, [Player? on]) async {
try { try {
final dynamic platform = player.platform; final dynamic platform = (on ?? player).platform;
await platform?.setProperty(property, value); await platform?.setProperty(property, value);
final got = '${await platform?.getProperty(property)}'; final got = '${await platform?.getProperty(property)}';
if (!pinMatches(value, got)) { if (!pinMatches(value, got)) {
@@ -251,17 +247,17 @@ class PlaybackController {
} }
} }
Future<void> _pinBufferProps() async { Future<void> _pinBufferProps([Player? on, int ahead = maxBytes, int back = maxBackBytes]) async {
pinMismatches.clear(); if (on == null) pinMismatches.clear();
await _pin('demuxer-readahead-secs', '$readAheadSecs'); await _pin('demuxer-readahead-secs', '$readAheadSecs', on);
await _pin('cache-secs', '$readAheadSecs'); await _pin('cache-secs', '$readAheadSecs', on);
await _pin('demuxer-max-bytes', '$maxBytes'); await _pin('demuxer-max-bytes', '$ahead', on);
await _pin('demuxer-max-back-bytes', '$maxBackBytes'); await _pin('demuxer-max-back-bytes', '$back', on);
final hwdec = _hwdecOverride; final hwdec = _hwdecOverride;
if (hwdec != null) { if (hwdec != null) {
await _pin('hwdec', hwdec); await _pin('hwdec', hwdec, on);
} }
await _pin('stream-lavf-o', streamLavfOptions); await _pin('stream-lavf-o', streamLavfOptions, on);
} }
/// ffmpeg's HTTP options (mpv `stream-lavf-o`, comma form): reconnect /// ffmpeg's HTTP options (mpv `stream-lavf-o`, comma form): reconnect
@@ -368,6 +364,7 @@ class PlaybackController {
// an error there while shaders are on is a shader that failed to load // an error there while shaders are on is a shader that failed to load
// or compile (mpv then renders without it). // or compile (mpv then renders without it).
void _onLog(PlayerLog log) { void _onLog(PlayerLog log) {
_toAppLog(log);
if (log.prefix == 'ytdl_hook' && (log.level == 'error' || log.level == 'fatal')) { if (log.prefix == 'ytdl_hook' && (log.level == 'error' || log.level == 'fatal')) {
final text = log.text.trim().replaceFirst(RegExp(r'^ERROR:\s*'), ''); final text = log.text.trim().replaceFirst(RegExp(r'^ERROR:\s*'), '');
if (text.isNotEmpty) { if (text.isNotEmpty) {
@@ -384,12 +381,11 @@ class PlaybackController {
return; return;
} }
// Nothing playable opened (a track of mpv's yt-dlp EDL refused, ...). // Nothing playable opened (a track of mpv's yt-dlp EDL refused, ...).
if (!_loadErrorSent && if (!_loadErrorSent && _loadFailed(log, duration.peek())) {
duration.peek() == Duration.zero &&
((log.prefix == 'cplayer' && log.level == 'fatal') ||
(log.prefix == 'demux' && log.level == 'error'))) {
_loadErrorSent = true; _loadErrorSent = true;
final http = _httpError; final http = _httpError;
_mpvLog.warning('cannot open the media: [${log.prefix}] ${log.text.trim()}'
'${http == null ? ' (no HTTP error logged; a request that timed out after 5 s looks like this)' : ' ($http)'}');
onLoadError?.call(http == null ? 'the video stream could not be opened' : 'the video server answered $http'); onLoadError?.call(http == null ? 'the video stream could not be opened' : 'the video server answered $http');
return; return;
} }
@@ -412,6 +408,32 @@ class PlaybackController {
} }
} }
// mpv's warnings and errors (the session player's; ffmpeg's through
// whichever player logs them) go to the app log, a few per kind at a
// time: a refused request makes ffmpeg retry and warn on every try.
static final _mpvLog = createLogger('mpv');
static final _mpvThrottle = LogThrottle(burst: 5);
static void _toAppLog(PlayerLog log, {String player = ''}) {
final level = log.level;
if (level != 'warn' && level != 'error' && level != 'fatal') return;
final text = log.text.trim();
if (text.isEmpty) return;
final pass = _mpvThrottle.allow('$player${log.prefix} $level ${text.length > 40 ? text.substring(0, 40) : text}');
if (pass == null) return;
final line = '$pass$player[${log.prefix}] $text';
level == 'warn' ? _mpvLog.info(line) : _mpvLog.warning(line);
}
/// [log] says the media could not be opened: before a [duration] is
/// known, mpv's fatal "nothing to play" or a demuxer error, or a yt-dlp
/// hook error.
static bool _loadFailed(PlayerLog log, Duration duration) {
if (log.prefix == 'ytdl_hook') return log.level == 'error' || log.level == 'fatal';
return duration == Duration.zero &&
((log.prefix == 'cplayer' && log.level == 'fatal') || (log.prefix == 'demux' && log.level == 'error'));
}
void _setRenderMode(RenderMode mode) { void _setRenderMode(RenderMode mode) {
renderMode.value = mode; renderMode.value = mode;
lastRenderMode.value = mode; lastRenderMode.value = mode;
@@ -439,78 +461,37 @@ class PlaybackController {
} }
} }
/// Fingerprint of the decoder's current frame (mpv `screenshot-raw`, /// `mpv-version` and `ffmpeg-version` of the session player, for
/// off-thread); null without video. /// diagnostics; null when there is no player.
Future<int?> _sourceFrame(Player p) async { Future<String?> mpvVersion() async {
final p = _player;
if (p == null) return null;
try { try {
final raw = await p.screenshot(format: null); final dynamic platform = p.platform;
return raw == null || raw.isEmpty ? null : hashSpread(raw); return '${await platform?.getProperty('mpv-version')}, ffmpeg ${await platform?.getProperty('ffmpeg-version')}';
} catch (_) { } catch (_) {
return null; return null;
} }
} }
/// A suspected freeze is real only if mpv's own frames still move: a
/// still scene looks frozen on screen too.
Future<bool> _sourceMoving(Player p) async {
final a = await _sourceFrame(p);
await Future.delayed(const Duration(milliseconds: 500));
if (!identical(p, _player) || !p.state.playing) return false;
final b = await _sourceFrame(p);
return a != null && b != null && a != b;
}
static bool get _windowVisible {
try {
final s = WidgetsBinding.instance.lifecycleState;
return s == null ||
s == AppLifecycleState.resumed ||
s == AppLifecycleState.inactive;
} catch (_) {
return false;
}
}
Future<void> _watch() async {
final p = _player;
final probe = shownFrame;
// Only meaningful once the video view is up.
if (p == null || _video == null || probe == null || _probing) return;
_probing = true;
try {
final frames = await probe();
if (!identical(p, _player)) return;
final s = p.state;
final eligible = s.playing &&
!s.buffering &&
!s.completed &&
s.rate > 0 &&
(s.width ?? 0) > 0 &&
_windowVisible;
final fired = _watchdog.feed(FrameSample(
at: DateTime.now(),
position: positionSecs,
frames: frames,
eligible: eligible,
));
if (fired) {
if (await _sourceMoving(p) && identical(p, _player)) {
debugPrint('video watchdog: picture stuck while mpv frames move');
onFrozen?.call();
} else {
_watchdog.dismiss();
}
}
} finally {
_probing = false;
}
}
/// Opens [url] paused. [headers] go with every request for it (mpv /// Opens [url] paused. [headers] go with every request for it (mpv
/// `http-header-fields`): a Jellyfin token travels there, not in the URL. /// `http-header-fields`): a Jellyfin token travels there, not in the URL.
Future<void> openUrl(String url, {Map<String, String>? headers}) async { /// Each media gets its own player: when [preload] holds the same [url]
if (!_ready) return; /// and [headers], that player becomes this one (true: promoted, its
/// buffer comes along); else a player that already held media is
/// replaced by a fresh one first ([onFreshPlayer] once it is up), unless
/// [keepPlayer] (the same media again, e.g. after a reconnect).
Future<bool> openUrl(String url,
{Map<String, String>? headers, bool keepPlayer = false, void Function()? onFreshPlayer}) async {
if (!_ready) return false;
final pre = _pre;
if (pre != null && pre.matches(url, headers)) {
if (await _promote(pre)) return true;
}
if (_held && !keepPlayer && await _freshPlayer()) onFreshPlayer?.call();
if (!_ready) return false;
try { try {
_held = true;
_chapterJson = ''; _chapterJson = '';
_httpError = null; _httpError = null;
_loadErrorSent = false; _loadErrorSent = false;
@@ -519,15 +500,18 @@ class PlaybackController {
buffered.value = Duration.zero; buffered.value = Duration.zero;
// An empty map, not null: media_kit caches headers per URI. // An empty map, not null: media_kit caches headers per URI.
await player.open(Media(url, httpHeaders: headers ?? const {}), play: false); await player.open(Media(url, httpHeaders: headers ?? const {}), play: false);
return false;
} catch (e, st) { } catch (e, st) {
debugPrint('open ${Uri.tryParse(url)?.replace(query: '') ?? 'media'} failed: $e\n$st'); debugPrint('open ${Uri.tryParse(url)?.replace(query: '') ?? 'media'} failed: $e\n$st');
rethrow; rethrow;
} }
} }
// Position / duration from media_kit's streams; chapter-list and // Position, duration and volume from media_kit's streams; chapter-list and
// track-list observed in mpv (read back as JSON). // track-list observed in mpv (read back as JSON).
void _watchMarks(SyncPlayer p) { void _watchMarks(SyncPlayer p) {
volume.value = p.state.volume;
_timeSubs.add(p.stream.volume.listen((v) => volume.value = v));
_timeSubs.add(p.stream.position.listen((d) { _timeSubs.add(p.stream.position.listen((d) {
// At the end the bar shows full: mpv's last position is the last // At the end the bar shows full: mpv's last position is the last
// frame's start (and the 250 ms steps round down), short of duration. // frame's start (and the 250 ms steps round down), short of duration.
@@ -687,10 +671,9 @@ class PlaybackController {
/// Release the player. The view drops its texture first ([alive] goes /// Release the player. The view drops its texture first ([alive] goes
/// false, one frame passes), then mpv is torn down. /// false, one frame passes), then mpv is torn down.
Future<void> dispose() async { Future<void> dispose() async {
await dropPreload();
final p = _player; final p = _player;
if (p == null) return; if (p == null) return;
_watchTimer?.cancel();
_watchTimer = null;
await _logSub?.cancel(); await _logSub?.cancel();
_logSub = null; _logSub = null;
for (final sub in _timeSubs) { for (final sub in _timeSubs) {
@@ -699,6 +682,7 @@ class PlaybackController {
_timeSubs.clear(); _timeSubs.clear();
_player = null; _player = null;
_video = null; _video = null;
_held = false;
alive.value = false; alive.value = false;
_clearMarks(); _clearMarks();
await _settleUi(); await _settleUi();
@@ -721,6 +705,246 @@ class PlaybackController {
} catch (_) {} } catch (_) {}
} }
// ------------------------------------------------------------ preload
//
// A second, hidden player holding what plays next (Up next's first
// item), open paused at its start and muted, with a small cache: its
// first seconds are in before anyone asks. [openUrl] for the same URL
// promotes it to the session player (see ARCHITECTURE.md "Getting
// ready"). One at a time.
/// Preloading on (env `SYNC_RELAY_PRELOAD=0` turns it off; tests set it).
bool preloadEnabled = Platform.environment['SYNC_RELAY_PRELOAD'] != '0';
/// The preload's forward cache: its first seconds and the container's
/// index, at little memory. Raised to [maxBytes] when promoted.
static const preloadBytes = 48 * 1024 * 1024;
static const _preloadBackBytes = 8 * 1024 * 1024;
_Preload? _pre;
/// The URL preloaded now (null: none).
String? get preloadUrl => _pre?.url;
/// Opens [url] in the hidden preload player (replacing any other), and
/// completes once [need] seconds are buffered or the whole media is:
/// true, false when it failed (or was dropped or replaced meanwhile).
/// The same [url] and [headers] again is the same preload unless
/// [fresh]. [ytdlPath]: mpv's yt-dlp hook for web pages.
Future<bool> preload(String url,
{Map<String, String>? headers, String? ytdlPath, double need = 4, bool fresh = false}) async {
if (!preloadEnabled || !_ready) return false;
final had = _pre;
if (had != null && had.matches(url, headers) && !had.failed && !fresh) return had.ready.future;
// Set before any await: a second call for the same URL finds it.
_pre = null;
if (had != null) unawaited(_disposePreload(had));
final p = _newPlayer(bufferSize: preloadBytes, logLevel: MPVLogLevel.error);
final pre = _pre = _Preload(p, url, headers ?? const {});
pre.logSub = p.stream.log.listen((log) {
// ffmpeg logs through one mpv per process: the first one, and once
// that is gone (a promotion disposes it) the next one created, which
// can be this preload. Its HTTP errors still name the session's.
if (log.prefix == 'ffmpeg') return _onLog(log);
_toAppLog(log, player: 'preload ');
if (_loadFailed(log, p.state.duration)) pre.fail();
});
try {
await _pinBufferProps(p, preloadBytes, _preloadBackBytes);
await _pin('volume-max', '$maxVolume', p);
await _pin('mute', 'yes', p);
final path = ytdlPath ?? _ytdlPath;
if (path != null) {
pre.ytdlRaw = await _setYtdl(p, path);
pre.ytdl = true;
}
// With a view up, its own video output: mpv decodes (media_kit
// starts players with vid=no) and the first frame is there.
if (_video != null) {
pre.video = VideoController(p, configuration: VideoControllerConfiguration(hwdec: _hwdecOverride));
await pre.video!.platform.future;
}
if (!identical(_pre, pre)) {
pre.fail();
return false;
}
await p.open(Media(url, httpHeaders: headers ?? const {}), play: false);
if (!pre.opened.isCompleted) pre.opened.complete();
} catch (e) {
debugPrint('preload failed: $e');
pre.fail();
return false;
}
unawaited(_watchPreload(pre, need));
return pre.ready.future;
}
Future<void> _watchPreload(_Preload pre, double need) async {
final end = DateTime.now().add(const Duration(seconds: 60));
while (identical(_pre, pre) && !pre.failed && DateTime.now().isBefore(end)) {
final st = pre.player.state;
final secs = st.duration.inMilliseconds / 1000;
final buffered = (st.buffer - st.position).inMilliseconds / 1000;
if (secs > 0 && (buffered >= need || st.buffer.inMilliseconds / 1000 >= secs - 0.5)) {
if (!pre.ready.isCompleted) pre.ready.complete(true);
return;
}
await Future.delayed(const Duration(milliseconds: 200));
}
if (!pre.ready.isCompleted) pre.ready.complete(false);
}
/// Drops the preload (nothing to get ready, or something else is).
Future<void> dropPreload() async {
final pre = _pre;
if (pre == null) return;
_pre = null;
await _disposePreload(pre);
}
Future<void> _disposePreload(_Preload pre) async {
pre.fail();
await pre.logSub?.cancel();
try {
await pre.player.dispose();
} catch (_) {}
}
/// [pre] becomes the session player ([_adopt]); the full cache pins
/// replace its small ones.
Future<bool> _promote(_Preload pre) async {
await pre.opened.future;
final old = _player;
if (!identical(_pre, pre) || old == null) return false;
_pre = null;
if (pre.failed) {
unawaited(_disposePreload(pre));
return false;
}
await pre.logSub?.cancel();
if (!pre.ready.isCompleted) pre.ready.complete(true);
final p = pre.player;
await _adopt(p, pre.video, ytdl: pre.ytdl, ytdlRaw: pre.ytdlRaw);
try {
await _pin('mute', 'no', p);
await _pinBufferProps(p);
} catch (e) {
debugPrint('promoted player settings: $e');
}
return true;
}
// The current player has had media opened in it (one fresh from [init]
// has not, so the first open after join or Reload video uses it).
bool _held = false;
// The last replaced player until it is disposed: a fresh player waits
// for it, so at most one old player lingers.
Future<void> _retiring = Future.value();
/// Replaces the current player with a fresh one set up like [init]'s
/// (pins, hwdec, volume, yt-dlp hook, shaders, observers), its video
/// output ready before the view swaps to it when the view is up. True
/// once it is the session player.
Future<bool> _freshPlayer() async {
await _retiring;
final old = _player;
if (old == null) return false;
final p = _newPlayer(bufferSize: maxBytes, logLevel: MPVLogLevel.warn); // see [logLevel]
VideoController? video;
var ytdl = false, ytdlRaw = '';
try {
pinMismatches.clear();
await _pinBufferProps(p);
await _pin('volume-max', '$maxVolume', p);
final path = _ytdlPath;
if (path != null) {
ytdlRaw = await _setYtdl(p, path);
ytdl = true;
}
// The view is up: the new output is created (and its texture
// registered) before the view swaps, so the old picture stays until
// then. Created by the view instead while the render mode is not
// known yet, so the probe hears its GL init ([video]).
if (_video != null && renderMode.peek() != RenderMode.unknown) {
video = VideoController(p, configuration: VideoControllerConfiguration(hwdec: _hwdecOverride));
await video.platform.future;
}
} catch (e) {
debugPrint('fresh player setup: $e');
}
// Disposed or reloaded meanwhile: that player stays.
if (!identical(old, _player)) {
try {
await p.dispose();
} catch (_) {}
return false;
}
await _adopt(p, video, ytdl: ytdl, ytdlRaw: ytdlRaw);
return true;
}
/// mpv's yt-dlp hook on [p] (see [useYtDlp]); returns the raw options set.
static Future<String> _setYtdl(Player p, String path) async {
final dynamic platform = p.platform;
await platform?.setProperty('script-opts', 'ytdl_hook-ytdl_path=$path');
await platform?.setProperty('ytdl-format', ytdlFormat);
final raw = await AppYtDlp.instance.mpvRawOptions();
await platform?.setProperty('ytdl-raw-options', raw);
return raw;
}
/// [p] becomes the session player (a promoted preload or a fresh
/// player): log, marks and track observers move over, the view swaps to
/// its video output ([generation]), volume and shaders carry over, and
/// the old player is paused and disposed once the view let go of its
/// texture (as in [dispose]).
Future<void> _adopt(SyncPlayer p, VideoController? video, {required bool ytdl, String ytdlRaw = ''}) async {
final old = _player!;
final volume = old.state.volume;
await _logSub?.cancel();
for (final sub in _timeSubs) {
await sub.cancel();
}
_timeSubs.clear();
_player = p;
_video = video;
_held = true;
_ytdlSetOn = ytdl ? p : null;
_ytdlRaw = ytdlRaw;
_chapterJson = '';
_httpError = null;
_loadErrorSent = false;
_shaders = '';
final at = p.state.position.inMilliseconds;
position.value = Duration(milliseconds: at - at % 250);
duration.value = p.state.duration;
final ms = p.state.buffer.inMilliseconds;
buffered.value = Duration(milliseconds: ms - ms % 250);
chapters.value = const [];
tracks.value = const [];
_logSub = p.stream.log.listen(_onLog);
_requestLog(p, logLevel);
_watchMarks(p);
generation.value++;
// Normally stopped already (the coordinator leaves the previous
// media first): never two sounds at once.
unawaited(old.pause().catchError((_) {}));
try {
await p.setVolume(volume);
} catch (e) {
debugPrint('new player volume: $e');
}
unawaited(applyShaders());
unawaited(_refreshTracks());
_retiring = () async {
await _settleUi();
try {
await old.dispose();
} catch (_) {}
}();
}
Future<void> stop() async { Future<void> stop() async {
if (!_ready) return; if (!_ready) return;
try { try {
@@ -753,3 +977,27 @@ bool pinMatches(String wanted, String got) {
final a = double.tryParse(wanted), b = double.tryParse(got); final a = double.tryParse(wanted), b = double.tryParse(got);
return a != null && b != null && a == b; return a != null && b != null && a == b;
} }
class _Preload {
final SyncPlayer player;
final String url;
final Map<String, String> headers;
VideoController? video;
StreamSubscription<PlayerLog>? logSub;
/// Completes once the media is handed to mpv (or the preload failed).
final opened = Completer<void>();
bool ytdl = false;
String ytdlRaw = '';
bool failed = false;
final ready = Completer<bool>();
_Preload(this.player, this.url, this.headers);
bool matches(String url, Map<String, String>? headers) =>
url == this.url && mapEquals(headers ?? const {}, this.headers);
void fail() {
failed = true;
if (!ready.isCompleted) ready.complete(false);
if (!opened.isCompleted) opened.complete();
}
}
+42 -10
View File
@@ -7,6 +7,12 @@ import 'package:sync_relay_core/core.dart';
/// mpv's `ytdl-format` for links every player opens itself. /// mpv's `ytdl-format` for links every player opens itself.
const ytdlFormat = 'bv*[height<=1080]+ba/b'; const ytdlFormat = 'bv*[height<=1080]+ba/b';
/// yt-dlp's `--sub-langs` for mpv's hook (its default is `all`), without
/// Bilibili's danmaku: an XML comment feed served raw-deflate, which
/// ffmpeg fails to inflate on every read (a warning each time) while the
/// video waits on it.
const ytdlSubLangs = 'all,-danmaku';
/// The app's yt-dlp (see [YtDlp]): in the app-support folder, shared with /// The app's yt-dlp (see [YtDlp]): in the app-support folder, shared with
/// the hosted server. [probe] remembers what pasted links are for a while, /// the hosted server. [probe] remembers what pasted links are for a while,
/// so the Link preview and the swap ask once. /// so the Link preview and the swap ask once.
@@ -28,30 +34,56 @@ class AppYtDlp {
/// update check afterwards). /// update check afterwards).
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null)); void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
/// mpv's `ytdl-raw-options` for the cookies in the yt-dlp folder ('' for /// mpv's `ytdl-raw-options`: [ytdlSubLangs] and the cookies in the
/// none; see [YtDlpCookies]). /// yt-dlp folder (see [YtDlpCookies]).
Future<String> mpvRawOptions() async { Future<String> mpvRawOptions() async {
const subs = 'sub-langs=%${ytdlSubLangs.length}%$ytdlSubLangs';
try { try {
return (await ytDlp).cookies.mpvRawOptions(); final cookies = (await ytDlp).cookies.mpvRawOptions();
return cookies.isEmpty ? subs : '$subs,$cookies';
} catch (_) { } catch (_) {
return ''; return subs;
} }
} }
final _probes = <String, (DateTime, Future<LinkProbe>)>{}; final _probes = <String, ({DateTime at, DateTime until, Future<LinkProbe> f})>{};
static const _probeTtl = Duration(minutes: 10); static const _probeTtl = Duration(minutes: 10);
Future<LinkProbe> probe(String url) { /// What [url] is, from memory while it is kept (10 minutes from when it
/// was probed; [keep] keeps it longer, for Up next's first item, which
/// is probed again before that runs out). [fresh] probes again: the
/// remembered result stays until the new one is in, and stays when the
/// new one fails.
Future<LinkProbe> probe(String url, {Duration keep = _probeTtl, bool fresh = false}) {
final now = DateTime.now(); final now = DateTime.now();
_probes.removeWhere((_, v) => now.difference(v.$1) > _probeTtl); _probes.removeWhere((_, v) => now.isAfter(v.until));
final hit = _probes[url]; final hit = _probes[url];
if (hit != null) return hit.$2; if (hit != null && !fresh) {
final longer = hit.at.add(keep);
if (longer.isAfter(hit.until)) _probes[url] = (at: hit.at, until: longer, f: hit.f);
return hit.f;
}
final f = ytDlp.then((y) => probeLink(Uri.parse(url), ytDlp: y)); final f = ytDlp.then((y) => probeLink(Uri.parse(url), ytDlp: y));
_probes[url] = (now, f); final until = now.add(keep);
if (hit == null) {
_probes[url] = (at: now, until: until, f: f);
f.catchError((Object _) { f.catchError((Object _) {
_probes.remove(url); if (_probes[url]?.f == f) _probes.remove(url);
return LinkProbe.file(Uri(), -1); return LinkProbe.file(Uri(), -1);
}); });
} else {
f.then<void>((_) {
_probes[url] = (at: now, until: until, f: f);
}, onError: (Object _) {});
}
return f; return f;
} }
/// [url] no longer needs keeping longer: back to the usual 10 minutes.
void release(String url) {
final hit = _probes[url];
if (hit == null) return;
final usual = hit.at.add(_probeTtl);
if (usual.isBefore(hit.until)) _probes[url] = (at: hit.at, until: usual, f: hit.f);
}
} }
@@ -0,0 +1,808 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../files/name_parser.dart';
import '../files/thumbnails.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/format.dart' show pickFor;
import '../jellyfin/session_media.dart';
import '../screens/home/picked_media.dart';
import '../session/media_art.dart';
import '../session/session_coordinator.dart';
import '../state/settings.dart';
import '../sync/avatar_cache.dart';
import 'queue_logic.dart';
// Up next on this client (see ARCHITECTURE.md "Up next"): the server's
// queue as signals, adding to it (probed, with a picture), the end-of-media
// countdown for its first item, putting an item up when this app is the
// one that can, and keeping that item ready.
/// The end-of-media card for the first Up next item. [secondsLeft] null:
/// no countdown here (auto-advance off, cancelled, or another app puts
/// it up), the card offers Play now only.
class UpNextCountdown {
final int mediaId;
final QueueItem item;
final int? secondsLeft;
const UpNextCountdown({required this.mediaId, required this.item, this.secondsLeft});
}
/// The first item's readiness on this app.
enum WarmState { idle, warming, ready, failed }
class QueueController {
final SessionCoordinator coord;
QueueController(this.coord);
/// Queued items' pictures by hash, from `/i/`.
late final PictureCache thumbs = PictureCache(coord.sync,
route: 'i', keep: RelayConstants.maxQueueItems + 8, live: (s) => [for (final q in s.queue) q.thumb]);
/// Up next, in order (empty when nothing is queued or not in a server).
late final Computed<List<QueueItem>> items =
computed(() => coord.serverState.value?.queue ?? const <QueueItem>[]);
final countdown = signal<UpNextCountdown?>(null);
/// The first item's id and how ready it is here (null: nothing to get
/// ready on this app).
final warm = signal<({String id, WarmState state})?>(null);
/// The natural continuation of what plays: the next episodes of the
/// Jellyfin series (not queued; shown greyed under Up next).
final continuation = signal<List<EpisodeLink>>(const []);
/// The mediaId whose continuation was cleared here.
final _hiddenFor = signal(-1);
/// The queue popover is open: the Up next button holds the controls up.
final popoverOpen = signal(false);
// What adder-only items stand for, by their key (a link passed on from
// here, a file here). Never leaves this app.
final Map<String, ({PickedMedia pick, DateTime at})> _local = {};
final Map<String, DateTime> _performing = {};
final Set<String> _skipped = {};
int _cancelledFor = -1;
Timer? _timer;
bool _attached = false;
String _continuationFor = '';
final _random = Random.secure();
String get _me => coord.sync.memberId;
void attach() {
if (_attached) return;
_attached = true;
coord.sync.states.listen(_onState);
coord.sync.feeds.listen(_onFeed);
effect(() {
final next = coord.nextEpisode.value;
final plan = coord.jellyfinPlan.value;
unawaited(_loadContinuation(next, plan));
});
// The continuation and the state decide what to get ready; the media
// playing having started (or sitting out) lets the preload go.
effect(() {
shownContinuation.value;
coord.settledMediaId.value;
coord.selfOut.value;
final r = coord.serverState.value;
untracked(() => _warm(r));
});
}
// ------------------------------------------------------------- adding
/// Adds [pick] to Up next (at the end, or with [next] right after what
/// plays). A link is probed first and refused like Put it up when it
/// will not play. Returns an error text, or null when sent.
Future<String?> add(PickedMedia pick, {bool next = false}) async {
coord.markInput();
if (coord.serverState.peek() == null) return 'not in a server';
if (items.peek().length >= RelayConstants.maxQueueItems) {
return 'Up next is full (${RelayConstants.maxQueueItems} items)';
}
final ({QueueItem item, Uint8List? thumb}) built;
try {
final b = await _build(pick);
if (b is String) return b;
built = b as ({QueueItem item, Uint8List? thumb});
} catch (e) {
return "couldn't add it: $e";
}
final item = built.item;
if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now());
final thumb = built.thumb;
coord.sync.sendQueue(QueueIntent.add(item, next: next, thumb: thumb == null ? '' : base64Encode(thumb)));
coord.showToast('Added ${item.title} to Up next');
return null;
}
/// A continuation episode becomes an Up next item.
Future<String?> promote(EpisodeLink e) => add(e.pick);
/// The item, or an error text.
Future<Object> _build(PickedMedia pick) async {
switch (pick) {
case JellyfinMedia():
final accounts = coord.jellyfin;
if (accounts.account(pick.serverId) == null) return 'that Jellyfin server is not linked any more';
if (accounts.statusOf(pick.serverId) == JellyfinAccountStatus.expired) {
return 'Jellyfin sign-in expired — reconnect it in Settings → Jellyfin & integrations';
}
final client = accounts.clientFor(pick.serverId);
final thumb = client == null ? null : await _jellyfinThumb(client, pick.itemId);
return (
item: QueueItem(
media: mediaForPick(pick),
how: QueueHow.jellyfin,
title: pick.title,
sub: pick.subtitle,
duration: (pick.runtime?.inMilliseconds ?? 0) / 1000,
),
thumb: thumb,
);
case FileMedia(:final path):
final file = File(path);
if (!await file.exists()) return 'file not found — check the path';
final name = pick.label;
final parsed = parseReleaseName(name);
final shot = await thumbnailFor(path);
Uint8List? thumb;
if (shot != null) {
try {
final bytes = await File(shot.imagePath).readAsBytes();
if (bytes.length <= RelayConstants.maxPosterBytes) thumb = bytes;
} catch (_) {}
}
final ep = [
if (parsed.season != null) 'S${parsed.season}',
if (parsed.episode != null) 'E${parsed.episode}',
].join();
return (
item: QueueItem(
media: HelloMedia(name: name, size: await file.length(), duration: 0),
how: QueueHow.file,
title: parsed.series.trim().isEmpty ? name : parsed.series.trim(),
sub: ep,
duration: (shot?.duration.inMilliseconds ?? 0) / 1000,
key: _newKey(),
),
thumb: thumb,
);
case LinkMedia(:final url, :final anyway):
// Alone, a link plays direct: there is nobody to pass it on to.
final relay = coord.solo ? LinkRelay.direct : pick.relay;
final linked = await mediaForLink(coord.jellyfin, url);
final ref = linked.jellyfin;
if (ref != null) {
return (
item: QueueItem(
media: HelloMedia(name: linked.name, size: 0, duration: 0, jellyfin: ref),
how: QueueHow.jellyfin,
title: ref.title.isEmpty ? linked.name : ref.title,
sub: ref.subtitle ?? '',
duration: ref.runtimeSecs ?? 0),
thumb: null,
);
}
LinkProbe? probe;
String? probeError;
try {
probe = await coord.ytDlp.probe(url).timeout(const Duration(seconds: 45));
} catch (e) {
probeError = '$e';
}
final problem = probe == null ? playErrorText(probeError ?? '') : probe.problem;
if (problem != null && !anyway) return problem;
if (relay == LinkRelay.me) {
if (probe == null) return problem ?? "couldn't reach the site";
final relayProblem = probe.relayProblem;
if (relayProblem != null) return relayProblem;
}
if (relay == LinkRelay.server && !coord.serverHasPassword) return 'server fetch needs a server password';
if (relay == LinkRelay.server && probe != null && probe.isPage && probe.info != null) {
final relayProblem = probe.relayProblem;
if (relayProblem != null) return relayProblem;
}
final uri = Uri.tryParse(url) ?? Uri();
final info = probe?.info;
final title = probe?.title ?? linked.name;
final site = info == null
? uri.host
: (info.uploader.isNotEmpty ? info.uploader : (info.extractor.isNotEmpty ? info.extractor : uri.host));
final thumbUrl = info?.thumbnail == null ? null : Uri.tryParse(info!.thumbnail!);
final thumb = thumbUrl == null ? null : (await thumbnailArt(thumbUrl))?.poster;
final how = switch (relay) {
LinkRelay.direct => QueueHow.direct,
LinkRelay.server => QueueHow.server,
LinkRelay.me => QueueHow.through,
};
return (
item: QueueItem(
media: HelloMedia(
name: title,
size: 0,
duration: 0,
url: how == QueueHow.through ? null : url,
relay: how == QueueHow.server ? const MediaRelay(fetch: true) : null),
how: how,
title: title,
sub: site,
duration: info?.duration ?? 0,
key: how == QueueHow.through ? _newKey() : '',
),
thumb: thumb,
);
}
}
String _newKey() => [for (var i = 0; i < 12; i++) _random.nextInt(256).toRadixString(16).padLeft(2, '0')].join();
/// A wide picture of a Jellyfin item: an episode's own still, else the
/// item's (or series') thumb or backdrop, else its poster.
Future<Uint8List?> _jellyfinThumb(JellyfinClient client, String itemId) async {
try {
final item = await client.item(itemId).timeout(const Duration(seconds: 10));
final ref = (item.kind == JellyfinItemKind.episode ? item.ownImage(JellyfinImageType.primary) : null) ??
item.image(JellyfinImageType.thumb) ??
item.image(JellyfinImageType.backdrop) ??
item.image(JellyfinImageType.primary);
if (ref == null) return null;
return await fetchImage(
client.imageUrl(ref, maxWidth: 480, quality: 80), client.authHeaders, RelayConstants.maxPosterBytes);
} catch (e) {
debugPrint('queue: no Jellyfin picture for $itemId: $e');
return null;
}
}
/// [item]'s link as this app may hand it out (Copy link, Open in
/// browser): a link everyone opens (it is in the state), or a link this
/// app passes on (its adder). Null otherwise: a server-fetched link stays
/// with the server, another member's passed-on link with them.
String? linkOf(QueueItem item) => switch (item.how) {
QueueHow.direct => item.media.url,
QueueHow.through when item.by == _me => switch (_local[item.key]?.pick) {
LinkMedia(:final url) => url,
_ => null,
},
_ => null,
};
/// [item]'s page on its Jellyfin server, when that server is linked here.
String? jellyfinPageOf(QueueItem item) {
final ref = item.media.jellyfin;
return ref == null ? null : coord.jellyfin.webUrlOf(ref.serverId, ref.itemId);
}
// ------------------------------------------------------------ editing
void move(String id, int to) {
coord.markInput();
coord.sync.sendQueue(QueueIntent.move(id, to));
}
void remove(String id) {
coord.markInput();
coord.sync.sendQueue(QueueIntent.remove(id));
}
/// Plays [id] now: this app puts it up when it is the one that can,
/// else it asks the server, and the app that can puts it up.
void playNow(String id) {
coord.markInput();
final r = coord.serverState.peek();
final item = r?.queue.where((q) => q.id == id).firstOrNull;
if (r == null || item == null) return;
if (queuePerformer(item, r.members).id == _me) {
_perform(item);
} else {
coord.sync.sendQueue(QueueIntent.play(id));
}
}
/// Plays the first Up next item now.
void skip() {
final first = items.peek().firstOrNull;
if (first != null) playNow(first.id);
}
/// Cancel on the end-of-media card: no countdown here for this media.
void cancelCountdown() {
final c = countdown.peek();
_timer?.cancel();
_timer = null;
if (c == null) return;
_cancelledFor = c.mediaId;
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item);
}
/// "Clear": hides the continuation of what plays now (and its next
/// episode does not come up by itself).
void clearContinuation() {
final id = coord.serverState.peek()?.mediaId;
if (id != null) _hiddenFor.value = id;
if (coord.autoAdvance.peek() != null) coord.cancelAutoAdvance();
}
/// The continuation applies to [mediaId]: nothing is queued and it was
/// not cleared.
bool continuationShown(int mediaId) =>
(coord.serverState.peek()?.queue.isEmpty ?? true) && _hiddenFor.peek() != mediaId;
/// [continuation] unless cleared for the media playing.
late final Computed<List<EpisodeLink>> shownContinuation = computed(() {
final id = coord.serverState.value?.mediaId;
return id != null && _hiddenFor.value == id ? const <EpisodeLink>[] : continuation.value;
});
// --------------------------------------------------------- per state
void _onState(StateBroadcast r) {
_lastState = r;
_prune(r);
final queue = r.queue;
final play = r.queuePlay;
if (play != null) {
final item = queue.where((q) => q.id == play).firstOrNull;
if (item != null) _bringUp(item, r);
}
final finished = r.transport == 'finished';
if (queue.isNotEmpty && r.media == null) {
// Nothing up yet: the first item goes up at once.
_bringUp(queue.first, r);
} else if (finished && queue.isNotEmpty) {
_atEnd(r);
} else {
if (countdown.peek() != null) _stopCountdown();
if (!finished) _cancelledFor = -1;
}
_warm(r);
}
void _onFeed(FeedMessage f) {
if (f.action == 'queue-skip') {
coord.showToast('Skipped ${f.target ?? 'an item'}${f.reason.isEmpty ? '' : ': ${f.reason}'}');
}
}
/// Forgets what no longer matters: performing marks after a minute (a
/// swap that never landed may be tried again), local picks no longer
/// queued.
void _prune(StateBroadcast r) {
final now = DateTime.now();
_performing.removeWhere((_, at) => now.difference(at) > const Duration(minutes: 1));
final keys = {for (final q in r.queue) q.key};
_local.removeWhere((k, v) => !keys.contains(k) && now.difference(v.at) > const Duration(minutes: 1));
final ids = {for (final q in r.queue) q.id};
_skipped.removeWhere((id) => !ids.contains(id));
}
/// [item] should go up now: by this app when it is the one that can;
/// nobody can: the lowest member takes it off with the reason.
void _bringUp(QueueItem item, StateBroadcast r) {
final perf = queuePerformer(item, r.members);
if (perf.id == null) {
final why = perf.why;
if (why != null && queueJanitor(r.members) == _me && _skipped.add(item.id)) {
coord.sync.sendQueue(QueueIntent.remove(item.id, why: why));
}
return;
}
if (perf.id == _me) _perform(item);
}
/// The media finished with Up next waiting: the card, and with
/// auto-advance on the countdown, for its first item.
void _atEnd(StateBroadcast r) {
// Up next goes before the next episode, even one already counting.
if (coord.autoAdvance.peek() != null) coord.cancelAutoAdvance();
final head = r.queue.first;
final perf = queuePerformer(head, r.members);
if (perf.id == null && perf.why != null) {
// Taken off by [_bringUp]'s rule; the next state shows the next one.
_bringUp(head, r);
return;
}
final c = countdown.peek();
if (c != null && c.mediaId == r.mediaId) {
if (c.item.id != head.id || c.item.thumb != head.thumb) {
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: head, secondsLeft: c.secondsLeft);
}
return;
}
final auto = AppSettings.instance.autoAdvance.peek() && _cancelledFor != r.mediaId;
countdown.value = UpNextCountdown(mediaId: r.mediaId, item: head, secondsLeft: auto ? autoAdvanceSecs : null);
_timer?.cancel();
_timer = auto ? Timer.periodic(const Duration(seconds: 1), (_) => _tick()) : null;
}
void _tick() {
final c = countdown.peek();
final r = coord.serverState.peek();
final left = c?.secondsLeft;
if (c == null || left == null || r == null || r.mediaId != c.mediaId || r.transport != 'finished') {
_stopCountdown();
return;
}
if (left > 1) {
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item, secondsLeft: left - 1);
return;
}
_timer?.cancel();
_timer = null;
_cancelledFor = c.mediaId;
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item);
// Decided on the latest state: only the app that can puts it up.
final head = r.queue.firstOrNull;
if (head != null && queuePerformer(head, r.members).id == _me) _perform(head);
}
void _stopCountdown() {
_timer?.cancel();
_timer = null;
countdown.value = null;
}
/// Puts [item] up from here (once per item while it is in flight).
/// When it cannot play after all, it is taken off with the reason.
void _perform(QueueItem item) {
if (_performing.containsKey(item.id)) return;
_performing[item.id] = DateTime.now();
unawaited(() async {
String? error;
try {
error = await _swapTo(item);
} catch (e) {
error = '$e';
}
if (error != null) {
debugPrint('queue: ${item.id} cannot go up: $error');
coord.sync.sendQueue(QueueIntent.remove(item.id, why: "couldn't play it ($error)"));
}
}());
}
Future<String?> _swapTo(QueueItem item) async {
switch (item.how) {
case QueueHow.jellyfin:
final ref = item.media.jellyfin;
final pick = ref == null ? null : pickForRef(coord.jellyfin, ref);
if (pick == null) return 'no Jellyfin account here for it';
coord.swapJellyfin(pick, queueId: item.id);
return null;
case QueueHow.direct:
final url = item.media.url;
if (url == null) return 'no link';
// Each viewer opens it: one computer's probe does not decide.
return coord.swapLink(url, relay: LinkRelay.direct, anyway: true, queueId: item.id);
case QueueHow.server:
coord.swapQueued(HelloMedia(name: item.title, size: 0, duration: 0, relay: const MediaRelay(fetch: true)),
item.id,
art: thumbs.bytesFor(item.thumb));
return null;
case QueueHow.through:
case QueueHow.file:
final local = _local[item.key];
if (local == null) return "it isn't on this computer any more";
// Only this app can pass it on, and a member sitting out has
// nothing open: back first.
if (coord.selfOut.peek()) coord.joinBack();
return coord.swapPicked(local.pick, queueId: item.id);
}
}
// ------------------------------------------------------------ warming
/// How long a warm result is kept, when it is resolved again, and when a
/// failed one is tried again.
static const _warmKeep = Duration(minutes: 35);
static const _warmRefresh = Duration(minutes: 30);
static const _warmRetry = Duration(minutes: 2);
String _warmKey = '';
String? _warmUrl;
Timer? _warmTimer;
int _warmGen = 0;
// What the preload player should hold once the media playing has
// started (null: nothing); [_preloadFresh]: open it again (a link's
// refresh).
({String url, Map<String, String>? headers})? _preloadWant;
bool _preloadFresh = false;
String _preloadKey = '';
bool _preloadBusy = false;
bool _preloadOk = true;
int _preloadGen = 0;
StateBroadcast? _lastState;
bool _ownAccount(String serverId) =>
coord.jellyfin.account(serverId) != null && coord.jellyfin.statusOf(serverId) != JellyfinAccountStatus.expired;
/// What this app gets ready: Up next's first item, else (Up next empty)
/// the first continuation episode (only here when this app plays the
/// series with its own account). Every app does, each its own copy (see
/// [_WarmTarget]); while sitting out only the probe of a link this app
/// puts up (its swap reads it). Null: nothing.
_WarmTarget? _warmTarget(StateBroadcast? r) {
final t = _warmTargetHere(r);
if (t == null || !coord.selfOut.peek()) return t;
return t.probe == null ? null : _WarmTarget(t.id, probe: t.probe);
}
_WarmTarget? _warmTargetHere(StateBroadcast? r) {
if (r == null) return null;
final head = r.queue.firstOrNull;
if (head != null) {
final performer = queuePerformer(head, r.members).id == _me;
final local = _local[head.key]?.pick;
// The server's warm relay of the item, once bytes can flow: what
// everyone without their own copy preloads.
final relay = head.media.relay;
final warmUrl = relay != null && relay.live ? coord.warmRelayUrl(head, relay) : null;
switch (head.how) {
case QueueHow.jellyfin:
final ref = head.media.jellyfin;
if (ref == null) return null;
if (_ownAccount(ref.serverId)) return _WarmTarget(head.id, ref: ref);
// No account here: the server's login passes it on (`/j`), else
// the warm relay a member with an account feeds.
final open = r.relayNext ? coord.jellyfinRelayUrl(ref) : warmUrl;
return open == null ? null : _WarmTarget(head.id, open: open);
case QueueHow.direct:
final url = head.media.url;
if (url == null) return null;
return _WarmTarget(head.id, probe: performer ? url : null, open: url);
case QueueHow.through:
// Only its adder has the link (and feeds the warm relay).
if (!performer) return warmUrl == null ? null : _WarmTarget(head.id, open: warmUrl);
final url = local is LinkMedia ? local.url : null;
return _WarmTarget(head.id, probe: url, open: url);
case QueueHow.file:
if (!performer) return warmUrl == null ? null : _WarmTarget(head.id, open: warmUrl);
// The host's own file plays from its `/m` URL once it is up.
return _WarmTarget(head.id, open: local is FileMedia && coord.hostFile == null ? local.path : null);
case QueueHow.server:
// The server fetches it into its warm relay; everyone preloads that.
if (warmUrl != null) return _WarmTarget(head.id, open: warmUrl);
return performer ? _WarmTarget(head.id) : null;
}
}
final next = shownContinuation.peek().firstOrNull;
if (next == null) return null;
return _WarmTarget('next:${next.item.id}', ref: mediaForPick(next.pick).jellyfin);
}
/// Keeps the one item that plays next ready on this app, so a skip or
/// the end of what plays starts it at once: a link probed when this app
/// puts it up (`AppYtDlp.probe`, kept while it stays first), a Jellyfin
/// item resolved with this app's account
/// ([SessionCoordinator.warmJellyfin]), and then opened in the hidden
/// preload player ([SessionCoordinator.preloadMedia]) once the media
/// playing has started. Done as soon as it is first, again before that
/// runs out, dropped when it is no longer first.
void _warm(StateBroadcast? r) {
_feedWarm(r);
final t = _warmTarget(r);
final key = t?.key ?? '';
if (key != _warmKey) {
_dropWarm(r);
_warmKey = key;
if (t == null) {
if (warm.peek() != null) warm.value = null;
} else if (t.empty) {
warm.value = (id: t.id, state: WarmState.ready);
} else {
warm.value = (id: t.id, state: WarmState.warming);
_resolveWarm(t, fresh: false);
}
}
_syncPreload(r);
}
// The warm relay this app feeds (`<queue id>|<token>`), started once.
String _fedWarm = '';
/// Feeds the server's warm relay of Up next's first item from here when
/// it names this member as its source (a link passed on, a file), or a
/// Jellyfin item this app puts up with its own account when the server
/// asks for a source; see [SessionCoordinator.feedWarm]. The server
/// drops the relay (and with it the source) once the item is no longer
/// first.
void _feedWarm(StateBroadcast? r) {
final head = r?.queue.firstOrNull;
final relay = head?.media.relay;
if (r == null || head == null || relay == null || relay.fetch) return;
final key = '${head.id}|${coord.sync.token}';
if (key == _fedWarm) return;
switch (head.how) {
case QueueHow.through:
case QueueHow.file:
if (relay.source != _me) return;
final pick = _local[head.key]?.pick;
if (pick is FileMedia) {
_fedWarm = key;
coord.feedWarm(head.id, RelayMedia.single(FileRelayBytes(File(pick.path))));
} else if (pick is LinkMedia) {
_fedWarm = key;
unawaited(() async {
try {
// The probe [_resolveWarm] keeps: no second yt-dlp run.
final probe = await coord.ytDlp.probe(pick.url, keep: _warmKeep).timeout(const Duration(seconds: 60));
final y = await coord.ytDlp.ytDlp;
if (_fedWarm != key) return;
coord.feedWarm(head.id, LinkRelayMedia(Uri.parse(pick.url), ytDlp: y, probe: probe));
} catch (e) {
debugPrint('queue: feeding ${head.id}: $e');
}
}());
}
case QueueHow.jellyfin:
final ref = head.media.jellyfin;
if (ref == null || (relay.source.isNotEmpty && relay.source != _me)) return;
if (queuePerformer(head, r.members).id != _me) return;
// Resolved with this app's account by [_resolveWarm].
final w = coord.warmJellyfin;
if (w == null || !sameJellyfinItem(w.ref, ref)) return;
_fedWarm = key;
coord.feedWarm(
head.id, RelayMedia.single(HttpRelayBytes(Uri.parse(w.plan.url), headers: w.plan.headers)));
case QueueHow.direct:
case QueueHow.server:
return;
}
}
void _dropWarm(StateBroadcast? r) {
_warmTimer?.cancel();
_warmTimer = null;
_warmGen++;
_preloadWant = null;
final url = _warmUrl;
_warmUrl = null;
if (url != null) coord.ytDlp.release(url);
// Kept while it is what just went up: that open takes it.
final w = coord.warmJellyfin;
if (w != null && !sameJellyfinItem(w.ref, refOfStateMedia(r?.media))) coord.warmJellyfin = null;
}
void _resolveWarm(_WarmTarget t, {required bool fresh}) {
final gen = ++_warmGen;
unawaited(() async {
var ok = true;
({String url, Map<String, String>? headers})? open;
try {
final probe = t.probe, ref = t.ref, url = t.open;
if (probe != null) {
_warmUrl = probe;
final p = await coord.ytDlp.probe(probe, keep: _warmKeep, fresh: fresh).timeout(const Duration(seconds: 60));
ok = p.problem == null;
}
if (ref != null) {
final plan = await resolveJellyfinRef(coord.jellyfin, ref, relay: false, relayUrl: null);
if (gen != _warmGen) return;
ok = plan is OwnPlay;
if (plan is OwnPlay) {
coord.warmJellyfin = (ref: ref, plan: plan, until: DateTime.now().add(_warmKeep));
open = (url: plan.url, headers: plan.headers);
_feedWarm(coord.serverState.peek());
}
} else if (url != null && ok) {
open = (url: url, headers: null);
}
} catch (e) {
ok = false;
debugPrint('queue: getting ${t.id} ready: $e');
}
if (gen != _warmGen) return;
_preloadWant = ok ? open : null;
// A refresh opens a link again (a page's stream URLs expire); the
// same Jellyfin stream stays as it is.
_preloadFresh = fresh && ok && t.ref == null && open != null;
final waits = ok && open != null && coord.playback.preloadEnabled;
warm.value = (id: t.id, state: !ok ? WarmState.failed : (waits ? WarmState.warming : WarmState.ready));
_syncPreload(coord.serverState.peek());
_warmTimer = Timer(ok ? _warmRefresh : _warmRetry, () => _resolveWarm(t, fresh: true));
}());
}
/// Puts [_preloadWant] in the preload player, or drops what it holds;
/// only once the media playing has started (its own open goes first,
/// and a preload a swap is about to take is never replaced before), at
/// once while sitting out (nothing preloads then).
void _syncPreload(StateBroadcast? r) {
// The newest state seen here: the coordinator's lags behind while the
// same `state` is still being handed to it.
final seen = _lastState;
if (r == null || (seen != null && seen.mediaId > r.mediaId)) r = seen;
if (r == null) return;
final out = coord.selfOut.peek();
if (!out && r.media != null && coord.settledMediaId.peek() != r.mediaId) return;
final want = out ? null : _preloadWant;
final playback = coord.playback;
if (want == null) {
_preloadKey = '';
_preloadGen++;
if (playback.preloadUrl != null) unawaited(playback.dropPreload());
return;
}
if (!playback.preloadEnabled) return;
final key = '${want.url}|${want.headers}';
final fresh = _preloadFresh;
// Asked for already: in flight, there, or failed (tried again with the
// next refresh). One that went away (Reload video) is asked again.
if (!fresh && key == _preloadKey && (_preloadBusy || !_preloadOk || playback.preloadUrl == want.url)) return;
_preloadFresh = false;
_preloadKey = key;
_preloadBusy = true;
final gen = ++_preloadGen;
final id = warm.peek()?.id;
unawaited(coord.preloadMedia(want.url, headers: want.headers, fresh: fresh).then((ok) {
if (gen != _preloadGen) return;
_preloadBusy = false;
_preloadOk = ok;
// A preload that fails leaves the usual open: nothing to say.
if (!ok) debugPrint('queue: preload of $id failed');
final w = warm.peek();
if (w != null && w.id == id && w.state == WarmState.warming) {
warm.value = (id: w.id, state: WarmState.ready);
}
}));
}
// -------------------------------------------------------- continuation
/// The next few episodes after the one playing (own account only).
Future<void> _loadContinuation(EpisodeLink? next, JellyfinPlan? plan) async {
if (next == null || plan is! OwnPlay) {
_continuationFor = '';
if (continuation.peek().isNotEmpty) continuation.value = const [];
return;
}
final key = '${plan.account.serverId}/${next.item.id}';
if (key == _continuationFor) return;
_continuationFor = key;
continuation.value = [next];
final seriesId = next.item.seriesId;
if (seriesId == null) return;
try {
final list = await coord.jellyfin.cached(
plan.account.serverId, 'episodes:$seriesId', () => plan.client.episodes(seriesId));
if (_continuationFor != key) return;
final more = [next];
var at = next.item;
while (more.length < 4) {
final after = episodeAfter(list, at.id);
if (after == null) break;
more.add(EpisodeLink(after, pickFor(plan.account, after)));
at = after;
}
continuation.value = more;
} catch (e) {
debugPrint('queue: continuation of $seriesId: $e');
}
}
}
/// What [QueueController] gets ready for one item on this app: [probe] a
/// link (the app that puts it up), resolve [ref] with this app's own
/// account, and preload [open] (or the resolved stream). None of it:
/// nothing to do here, and it is ready ([empty]).
class _WarmTarget {
final String id;
final String? probe;
final JellyfinRef? ref;
final String? open;
const _WarmTarget(this.id, {this.probe, this.ref, this.open});
bool get empty => probe == null && ref == null && open == null;
String get key => '$id|$probe|$open|${ref?.serverId}/${ref?.itemId}/${ref?.mediaSourceId}';
}
@@ -0,0 +1,76 @@
import 'package:sync_relay_core/core.dart';
import '../jellyfin/session_media.dart' show compareMemberIds;
// Who puts an Up next item up. Pure: only what every client sees in
// `state` decides, so all clients agree without a wire message (like the
// Jellyfin auto-advance swapper).
/// Who puts [item] up: [id] a member id; null with [why] when nobody here
/// can (the item is skipped), null without [why] when nobody is around
/// right now (nothing happens).
typedef QueuePerformer = ({String? id, String? why});
/// - A link passed on by its adder, or the adder's file: only the adder
/// (their app has the file, the cookies). Gone: skipped.
/// - Jellyfin: the adder when it plays that server with its own account,
/// else the lowest member id that does (`jf[serverId] == true`, not
/// stale, not sitting out). Nobody: skipped.
/// - A link everyone opens, or one the server fetches: the adder when
/// present (not stale or sitting out), else the lowest such member id.
QueuePerformer queuePerformer(QueueItem item, List<MemberView> members) {
MemberView? adder;
for (final m in members) {
if (m.id == item.by) adder = m;
}
bool active(MemberView m) => !m.stale && !m.out;
if (item.how.adderOnly) {
if (adder == null) return (id: null, why: '${_name(item)} left');
return (id: adder.id, why: null);
}
final ref = item.media.jellyfin;
if (item.how == QueueHow.jellyfin && ref != null) {
bool can(MemberView m) => active(m) && m.jf[ref.serverId] == true;
if (adder != null && can(adder)) return (id: adder.id, why: null);
final id = autoAdvanceLowest(members.where(can));
return id == null ? (id: null, why: 'nobody here has that Jellyfin server') : (id: id, why: null);
}
if (adder != null && active(adder)) return (id: adder.id, why: null);
return (id: autoAdvanceLowest(members.where(active)), why: null);
}
String _name(QueueItem item) => item.byName.isEmpty ? 'who added it' : item.byName;
/// The lowest member id of [members] ("m2" before "m10"), or null.
String? autoAdvanceLowest(Iterable<MemberView> members) {
final ids = [for (final m in members) m.id]..sort(compareMemberIds);
return ids.isEmpty ? null : ids.first;
}
/// Who takes an item nobody can put up off the queue (`remove` with why):
/// the lowest member id that is not stale.
String? queueJanitor(List<MemberView> members) => autoAdvanceLowest(members.where((m) => !m.stale));
/// How [item] plays, in a few words for its row: "through Foxyz" (you:
/// "through you"), "server fetches", "everyone fetches", "Jellyfin",
/// "Foxyz's file".
String queueHowLabel(QueueItem item, {required String selfId}) {
final mine = item.by == selfId && selfId.isNotEmpty;
final who = mine ? 'you' : _name(item);
return switch (item.how) {
QueueHow.through => 'through $who',
QueueHow.file => mine ? 'your file' : "$who's file",
QueueHow.server => 'server fetches',
QueueHow.direct => 'everyone fetches',
QueueHow.jellyfin => 'Jellyfin',
};
}
/// "1:23:45" / "23:45" for [secs]; empty when unknown.
String queueClock(double secs) {
if (secs <= 0) return '';
final s = secs.round();
final h = s ~/ 3600, m = (s % 3600) ~/ 60, r = s % 60;
String two(int v) => v.toString().padLeft(2, '0');
return h > 0 ? '$h:${two(m)}:${two(r)}' : '$m:${two(r)}';
}
File diff suppressed because it is too large. Load diff
+8 -1
View File
@@ -198,6 +198,8 @@ class AnimeFlowEpisode {
final int episode; final int episode;
final int? absolute; final int? absolute;
final String? title; final String? title;
/// Date-only: midnight Japan time of the broadcast day. Compare it as a
/// Japan day (`sameAirDay` in schedule_model.dart), never a local one.
final DateTime? airDate; final DateTime? airDate;
/// The AniList entry of this episode's season (one AnimeFlow series may /// The AniList entry of this episode's season (one AnimeFlow series may
/// span several AniList entries). /// span several AniList entries).
@@ -208,8 +210,11 @@ class AnimeFlowEpisode {
final EpisodeStatus status; final EpisodeStatus status;
/// 0..1, only while downloading. /// 0..1, only while downloading.
final double? progress; final double? progress;
/// Only when failed. /// Why it failed; for a missing one with rejected releases, a
/// ready-to-show sentence ("2 release(s) found, rejected: …").
final String? reason; final String? reason;
/// Last search for a monitored, not yet downloaded episode.
final DateTime? lastSearchedAt;
const AnimeFlowEpisode({ const AnimeFlowEpisode({
required this.season, required this.season,
@@ -222,6 +227,7 @@ class AnimeFlowEpisode {
required this.status, required this.status,
this.progress, this.progress,
this.reason, this.reason,
this.lastSearchedAt,
}); });
static AnimeFlowEpisode fromJson(Map j) => AnimeFlowEpisode( static AnimeFlowEpisode fromJson(Map j) => AnimeFlowEpisode(
@@ -237,6 +243,7 @@ class AnimeFlowEpisode {
status: EpisodeStatus.parse(j['status']), status: EpisodeStatus.parse(j['status']),
progress: (j['progress'] as num?)?.toDouble(), progress: (j['progress'] as num?)?.toDouble(),
reason: _str(j['reason']), reason: _str(j['reason']),
lastSearchedAt: _time(j['last_searched_at']),
); );
} }
+118 -31
View File
@@ -12,8 +12,12 @@ enum ScheduleStatus {
available, available,
downloading, downloading,
upcoming, upcoming,
/// AnimeFlow's `missing`: aired, monitored, no release grabbed yet;
/// AnimeFlow grabs it by itself once one appears (nothing to request).
/// Also an aired episode of a tracked series AnimeFlow has no row for.
missing, missing,
failed, failed,
/// AnimeFlow does not monitor it: the only status worth a Request.
unmonitored, unmonitored,
/// AnimeFlow does not track this series (or is unreachable). /// AnimeFlow does not track this series (or is unreachable).
notTracked, notTracked,
@@ -44,8 +48,10 @@ class ScheduleEntry {
final ScheduleStatus status; final ScheduleStatus status;
/// 0..1 while downloading. /// 0..1 while downloading.
final double? progress; final double? progress;
/// Why it failed. /// Why it failed, or (missing) why the releases found were rejected.
final String? reason; final String? reason;
/// AnimeFlow's last search for it (missing).
final DateTime? lastSearchedAt;
/// AnimeFlow tracks the series. /// AnimeFlow tracks the series.
final bool tracked; final bool tracked;
/// AnimeFlow's series id (requests). /// AnimeFlow's series id (requests).
@@ -53,11 +59,15 @@ class ScheduleEntry {
/// The AnimeFlow season of the matched episode (requests); untracked /// The AnimeFlow season of the matched episode (requests); untracked
/// on Jellyfin: the TVDB season of the AniList entry. /// on Jellyfin: the TVDB season of the AniList entry.
final int? season; final int? season;
/// Untracked on Jellyfin: the TVDB episode number in [season] (AniList /// The TVDB episode number in [season]: tracked, AnimeFlow's own;
/// episode + the `anilist_map` run's offset). /// untracked on Jellyfin, AniList episode + the `anilist_map` run's
/// offset.
final int? jellyfinEpisode; final int? jellyfinEpisode;
/// Play target once available. /// Play target once available.
final String? jellyfinSeriesId; final String? jellyfinSeriesId;
/// This user's progress on the Jellyfin episode ([confirmOnJellyfin]);
/// null when it is not on Jellyfin or was not looked up.
final EpisodeWatch? watch;
/// The series is on Jellyfin (tracked or not). /// The series is on Jellyfin (tracked or not).
bool get onJellyfin => jellyfinSeriesId != null; bool get onJellyfin => jellyfinSeriesId != null;
@@ -66,23 +76,27 @@ class ScheduleEntry {
required this.status, required this.status,
this.progress, this.progress,
this.reason, this.reason,
this.lastSearchedAt,
this.tracked = false, this.tracked = false,
this.seriesId, this.seriesId,
this.season, this.season,
this.jellyfinEpisode, this.jellyfinEpisode,
this.jellyfinSeriesId, this.jellyfinSeriesId,
this.watch,
}); });
ScheduleEntry withStatus(ScheduleStatus s) => ScheduleEntry( ScheduleEntry copyWith({ScheduleStatus? status, EpisodeWatch? watch}) => ScheduleEntry(
airing: airing, airing: airing,
status: s, status: status ?? this.status,
progress: progress, progress: progress,
reason: reason, reason: reason,
lastSearchedAt: lastSearchedAt,
tracked: tracked, tracked: tracked,
seriesId: seriesId, seriesId: seriesId,
season: season, season: season,
jellyfinEpisode: jellyfinEpisode, jellyfinEpisode: jellyfinEpisode,
jellyfinSeriesId: jellyfinSeriesId, jellyfinSeriesId: jellyfinSeriesId,
watch: watch ?? this.watch,
); );
@override @override
@@ -95,11 +109,59 @@ ScheduleEntry untrackedEntry(Airing a, DateTime now) => ScheduleEntry(
status: a.airingAt.isAfter(now) ? ScheduleStatus.upcoming : ScheduleStatus.notTracked, status: a.airingAt.isAfter(now) ? ScheduleStatus.upcoming : ScheduleStatus.notTracked,
); );
String _day(DateTime d) { /// This user's state of a Jellyfin episode (its `UserData`).
final l = d.toLocal(); class EpisodeWatch {
return '${l.year}-${l.month}-${l.day}'; final bool played;
/// 0..1: the resume point of a partly watched episode (0 = none).
final double progress;
const EpisodeWatch({this.played = false, this.progress = 0});
@override
bool operator ==(Object other) => other is EpisodeWatch && other.played == played && other.progress == progress;
@override
int get hashCode => Object.hash(played, progress);
@override
String toString() => 'EpisodeWatch(${played ? 'played' : '$progress'})';
} }
/// One episode Jellyfin has of a series, as the schedule needs it.
class JellyfinEpisodeRef {
final int season;
final int episode;
/// Jellyfin's date-only premiere date.
final DateTime? premiere;
final EpisodeWatch watch;
const JellyfinEpisodeRef(this.season, this.episode, {this.premiere, this.watch = const EpisodeWatch()});
}
// Air dates (AnimeFlow's `air_date`, Jellyfin's premiere date) are
// date-only: the Japanese broadcast day, sent as midnight Japan time (or
// midnight UTC by older servers). Both land on the right day read as a
// Japan (UTC+9, no DST) calendar day, never as a local one: midnight JST
// is the evening before in Europe and America.
const _jst = Duration(hours: 9);
/// The Japan calendar day of [t].
DateTime _jstDay(DateTime t) {
final j = t.toUtc().add(_jst);
return DateTime.utc(j.year, j.month, j.day);
}
String _dayKey(DateTime d) => '${d.year}-${d.month}-${d.day}';
/// The Japan days whose air date fits a broadcast at [airingAt]: its own
/// and, for a late-night slot (before 05:00 JST, "25:30"), the evening
/// before, which listings often give.
List<DateTime> _airDays(DateTime airingAt) {
final j = airingAt.toUtc().add(_jst);
final d = DateTime.utc(j.year, j.month, j.day);
return [d, if (j.hour < 5) DateTime.utc(j.year, j.month, j.day - 1)];
}
/// The date-only [airDate] is the broadcast day of [airingAt] (see
/// [_airDays]).
bool sameAirDay(DateTime airDate, DateTime airingAt) => _airDays(airingAt).contains(_jstDay(airDate));
class _SeriesRef { class _SeriesRef {
final int seriesId; final int seriesId;
final String? jellyfinSeriesId; final String? jellyfinSeriesId;
@@ -155,7 +217,7 @@ List<ScheduleEntry> matchSchedule(
} }
byEp.putIfAbsent('$id#${e.episode}', () => it); byEp.putIfAbsent('$id#${e.episode}', () => it);
if (e.absolute != null) byAbs.putIfAbsent('$id#${e.absolute}', () => it); if (e.absolute != null) byAbs.putIfAbsent('$id#${e.absolute}', () => it);
if (e.airDate != null) byDay.putIfAbsent('$id@${_day(e.airDate!)}', () => it); if (e.airDate != null) byDay.putIfAbsent('$id@${_dayKey(_jstDay(e.airDate!))}', () => it);
} }
return [ return [
for (final a in airings) for (final a in airings)
@@ -164,17 +226,21 @@ List<ScheduleEntry> matchSchedule(
final ref = refs[id]; final ref = refs[id];
final it = numbered.contains(id) final it = numbered.contains(id)
? byAni['$id#${a.episode}'] ? byAni['$id#${a.episode}']
: byEp['$id#${a.episode}'] ?? byAbs['$id#${a.episode}'] ?? byDay['$id@${_day(a.airingAt)}']; : byEp['$id#${a.episode}'] ??
byAbs['$id#${a.episode}'] ??
_airDays(a.airingAt).map((d) => byDay['$id@${_dayKey(d)}']).nonNulls.firstOrNull;
if (it != null) { if (it != null) {
final e = it.episode; final e = it.episode;
return ScheduleEntry( return ScheduleEntry(
airing: a, airing: a,
status: ScheduleStatus.of(e.status), status: ScheduleStatus.of(e.status),
progress: e.status == EpisodeStatus.downloading ? e.progress : null, progress: e.status == EpisodeStatus.downloading ? e.progress : null,
reason: e.status == EpisodeStatus.failed ? e.reason : null, reason: e.status == EpisodeStatus.failed || e.status == EpisodeStatus.missing ? e.reason : null,
lastSearchedAt: e.lastSearchedAt,
tracked: true, tracked: true,
seriesId: it.seriesId, seriesId: it.seriesId,
season: e.season, season: e.season,
jellyfinEpisode: e.episode,
jellyfinSeriesId: it.jellyfinSeriesId ?? ref?.jellyfinSeriesId ?? lib[id]?.$1, jellyfinSeriesId: it.jellyfinSeriesId ?? ref?.jellyfinSeriesId ?? lib[id]?.$1,
); );
} }
@@ -200,26 +266,45 @@ List<ScheduleEntry> matchSchedule(
]; ];
} }
/// Checks [matchSchedule]'s [ScheduleStatus.inLibrary] entries (series /// The episodes [confirmOnJellyfin] looks up: on Jellyfin, untracked
/// on Jellyfin) against the episodes Jellyfin has: [episodes] maps a /// ([ScheduleStatus.inLibrary]) or available through AnimeFlow.
/// Jellyfin series id to its (season, episode) numbers. Found stays bool needsJellyfinLookup(ScheduleEntry e) =>
/// inLibrary, absent becomes [ScheduleStatus.notOnJellyfin], a series e.jellyfinSeriesId != null && (e.status == ScheduleStatus.inLibrary || e.status == ScheduleStatus.available);
/// missing from [episodes] (not looked up) [ScheduleStatus.aired].
List<ScheduleEntry> confirmOnJellyfin(List<ScheduleEntry> entries, Map<String, Set<(int, int)>> episodes) => [ /// Checks [matchSchedule]'s entries against the episodes Jellyfin has:
/// [episodes] maps a Jellyfin series id to its episodes. An
/// [ScheduleStatus.inLibrary] one found stays inLibrary, absent becomes
/// [ScheduleStatus.notOnJellyfin], a series missing from [episodes] (not
/// looked up) [ScheduleStatus.aired]. Found episodes (inLibrary or
/// available) get this user's [ScheduleEntry.watch]: (season, episode),
/// else the one premiered that day.
List<ScheduleEntry> confirmOnJellyfin(List<ScheduleEntry> entries, Map<String, List<JellyfinEpisodeRef>> episodes) => [
for (final e in entries) for (final e in entries)
if (e.status != ScheduleStatus.inLibrary) if (!needsJellyfinLookup(e))
e e
else else
switch (episodes[e.jellyfinSeriesId]) { switch ((episodes[e.jellyfinSeriesId], e.status)) {
null => e.withStatus(ScheduleStatus.aired), (null, ScheduleStatus.inLibrary) => e.copyWith(status: ScheduleStatus.aired),
final have when have.contains((e.season, e.jellyfinEpisode)) => e, (null, _) => e,
_ => e.withStatus(ScheduleStatus.notOnJellyfin), (final have?, _) => switch (_episodeOf(have, e)) {
final x? => e.copyWith(watch: x.watch),
null when e.status == ScheduleStatus.inLibrary => e.copyWith(status: ScheduleStatus.notOnJellyfin),
null => e,
},
}, },
]; ];
/// The (season, episode) numbers Jellyfin has of a series; null when it JellyfinEpisodeRef? _episodeOf(List<JellyfinEpisodeRef> have, ScheduleEntry e) {
/// cannot tell (no linked account for that server). final byNumber = have.where((x) => x.season == e.season && x.episode == e.jellyfinEpisode).firstOrNull;
typedef JellyfinEpisodeLookup = Future<Set<(int, int)>?> Function(String jellyfinSeriesId); if (byNumber != null || !e.tracked) return byNumber;
// AnimeFlow's numbering missed (specials, renumbered seasons): the
// episode premiered that day, when only one did.
return have.where((x) => x.premiere != null && sameAirDay(x.premiere!, e.airing.airingAt)).singleOrNull;
}
/// The episodes Jellyfin has of a series, as the linked account sees
/// them; null when it cannot tell (no linked account for that server).
typedef JellyfinEpisodeLookup = Future<List<JellyfinEpisodeRef>?> Function(String jellyfinSeriesId);
/// One loaded window. Errors are display text; [entries] is empty only /// One loaded window. Errors are display text; [entries] is empty only
/// when AniList failed with nothing cached. /// when AniList failed with nothing cached.
@@ -262,8 +347,9 @@ class ScheduleRepository {
final AniListClient aniList; final AniListClient aniList;
/// Null = not configured (AniList only). /// Null = not configured (AniList only).
final AnimeFlowClient? animeFlow; final AnimeFlowClient? animeFlow;
/// Confirms "on Jellyfin" per episode ([confirmOnJellyfin]); null = /// Confirms "on Jellyfin" and this user's watched state per episode
/// never confirmed (aired entries read [ScheduleStatus.aired]). /// ([confirmOnJellyfin]); null = never confirmed (aired entries read
/// [ScheduleStatus.aired], nothing shows as watched).
final JellyfinEpisodeLookup? jellyfinEpisodes; final JellyfinEpisodeLookup? jellyfinEpisodes;
final DateTime Function() _now; final DateTime Function() _now;
@@ -303,15 +389,16 @@ class ScheduleRepository {
); );
} }
/// Looks up the episodes of every series an inLibrary entry is on, in /// Looks up the episodes of every series an entry on Jellyfin is on
/// parallel; a failed or impossible lookup leaves that series out. /// ([needsJellyfinLookup]), in parallel, one request per series; a
/// failed or impossible lookup leaves that series out.
Future<List<ScheduleEntry>> _confirm(List<ScheduleEntry> entries) async { Future<List<ScheduleEntry>> _confirm(List<ScheduleEntry> entries) async {
final lookup = jellyfinEpisodes; final lookup = jellyfinEpisodes;
final ids = { final ids = {
for (final e in entries) for (final e in entries)
if (e.status == ScheduleStatus.inLibrary && e.jellyfinSeriesId != null) e.jellyfinSeriesId!, if (needsJellyfinLookup(e)) e.jellyfinSeriesId!,
}; };
final found = <String, Set<(int, int)>>{}; final found = <String, List<JellyfinEpisodeRef>>{};
if (lookup != null) { if (lookup != null) {
await Future.wait([ await Future.wait([
for (final id in ids) for (final id in ids)
@@ -1,7 +1,7 @@
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../../jellyfin/accounts.dart'; import '../../jellyfin/accounts.dart';
import '../../jellyfin/format.dart' show pickFor; import '../../jellyfin/format.dart' show pickFor, progressOf;
import '../../screens/home/picked_media.dart'; import '../../screens/home/picked_media.dart';
import '../anilist.dart'; import '../anilist.dart';
import '../animeflow.dart'; import '../animeflow.dart';
@@ -47,14 +47,9 @@ String friendlyError(Object e) => switch (e) {
_ => '$e', _ => '$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 /// AnimeFlow's episode for [e] (its season and numbering), from the
/// series detail: same AniList id and episode, else absolute number, else /// 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. /// the same air day ([sameAirDay]). Null when the entry is not tracked or nothing fits.
Future<AnimeFlowEpisode?> flowEpisodeFor(AnimeFlowClient client, ScheduleEntry e) async { Future<AnimeFlowEpisode?> flowEpisodeFor(AnimeFlowClient client, ScheduleEntry e) async {
final id = e.seriesId; final id = e.seriesId;
if (id == null) return null; if (id == null) return null;
@@ -63,7 +58,7 @@ Future<AnimeFlowEpisode?> flowEpisodeFor(AnimeFlowClient client, ScheduleEntry e
final mine = eps.where((x) => x.anilistId == null || x.anilistId == a.anilistId).toList(); 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 ?? 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.absolute == a.episode).firstOrNull ??
mine.where((x) => x.airDate != null && _sameDay(x.airDate!, a.airingAt)).firstOrNull; mine.where((x) => x.airDate != null && sameAirDay(x.airDate!, a.airingAt)).firstOrNull;
} }
/// The (season, episode) AnimeFlow uses for [e]; falls back to the /// The (season, episode) AnimeFlow uses for [e]; falls back to the
@@ -112,11 +107,13 @@ JellyfinAccount? jellyfinAccountFor(JellyfinAccounts accounts, AnimeFlowClient?
return accounts.defaultAccount; return accounts.defaultAccount;
} }
/// The (season, episode) numbers of the episodes Jellyfin has of /// The episodes Jellyfin has of [jellyfinSeriesId] (numbers, premiere
/// [jellyfinSeriesId], seen by the account AnimeFlow's ids belong to /// date, this user's watched state), seen by the account AnimeFlow's ids
/// ([jellyfinAccountFor]); null without a linked account. One request per /// belong to ([jellyfinAccountFor]): the user's own; null without a
/// series, memoised a few minutes ([JellyfinAccounts.cached]). /// linked account. One request per series, memoised a few minutes
Future<Set<(int, int)>?> jellyfinEpisodeNumbers( /// ([JellyfinAccounts.cached]; a stop report drops it,
/// [JellyfinAccounts.watched]).
Future<List<JellyfinEpisodeRef>?> jellyfinScheduleEpisodes(
JellyfinAccounts accounts, JellyfinAccounts accounts,
AnimeFlowClient? flow, AnimeFlowClient? flow,
String jellyfinSeriesId, String jellyfinSeriesId,
@@ -125,10 +122,20 @@ Future<Set<(int, int)>?> jellyfinEpisodeNumbers(
final account = jellyfinAccountFor(accounts, flow); final account = jellyfinAccountFor(accounts, flow);
final client = account == null ? null : accounts.clientFor(account.serverId); final client = account == null ? null : accounts.clientFor(account.serverId);
if (account == null || client == null) return null; if (account == null || client == null) return null;
return accounts.cached(account.serverId, 'schedule-episodes:$jellyfinSeriesId', () async => { return accounts.cached(
account.serverId,
'schedule-episodes:$jellyfinSeriesId',
() async => [
for (final x in await client.episodes(jellyfinSeriesId)) for (final x in await client.episodes(jellyfinSeriesId))
if (x.parentIndexNumber != null && x.indexNumber != null) (x.parentIndexNumber!, x.indexNumber!), if (x.parentIndexNumber != null && x.indexNumber != null)
}); JellyfinEpisodeRef(
x.parentIndexNumber!,
x.indexNumber!,
premiere: x.premiereDate,
watch: EpisodeWatch(played: x.userData.played, progress: x.userData.played ? 0 : progressOf(x)),
),
],
);
} }
/// The Jellyfin episode of an available [e] as a media pick. Throws /// The Jellyfin episode of an available [e] as a media pick. Throws
@@ -153,7 +160,7 @@ Future<JellyfinMedia> resolveJellyfinEpisode(JellyfinAccounts accounts, AnimeFlo
final a = e.airing; final a = e.airing;
final item = final item =
eps.where((x) => x.parentIndexNumber == n.season && x.indexNumber == n.episode).firstOrNull ?? 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.premiereDate != null && sameAirDay(x.premiereDate!, a.airingAt)).singleOrNull ??
eps.where((x) => x.indexNumber == a.episode).singleOrNull; eps.where((x) => x.indexNumber == a.episode).singleOrNull;
if (item == null) { if (item == null) {
throw ScheduleActionException('Episode ${a.episode} of ${a.title} is not on Jellyfin yet'); throw ScheduleActionException('Episode ${a.episode} of ${a.title} is not on Jellyfin yet');
@@ -4,9 +4,9 @@ import 'package:flutter/material.dart';
import '../schedule_model.dart'; import '../schedule_model.dart';
// One day of the Schedule board and its airing cards: cover poster on the // 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 // left (air time on it, download progress or this user's watch progress
// status on the right, one full-width action below. Compact mode is a // under it, a check once watched), title, episode and status on the
// slim row. // right, one full-width action below. Compact mode is a slim row.
/// What a card's action does, decided by the page. /// What a card's action does, decided by the page.
enum CardAction { enum CardAction {
@@ -35,8 +35,29 @@ String countdownText(DateTime at, DateTime now) {
return 'in ${d.inMinutes}m'; return 'in ${d.inMinutes}m';
} }
/// Aired, tracked, no release yet: AnimeFlow downloads it by itself.
bool _waiting(ScheduleEntry e, DateTime now) =>
e.status == ScheduleStatus.missing ||
(e.status == ScheduleStatus.upcoming && e.tracked && !e.airing.airingAt.isAfter(now));
const waitingHint = 'Aired in Japan; downloads automatically when a release appears';
String _ago(DateTime t, DateTime now) {
final m = now.difference(t).inMinutes;
return m < 1 ? 'just now' : m < 60 ? '$m min ago' : '${m ~/ 60} h ago';
}
/// Status line: text, colour, icon. /// Status line: text, colour, icon.
(String, Color, IconData) statusBadge(ScheduleEntry e, DateTime now) => switch (e.status) { (String, Color, IconData) statusBadge(ScheduleEntry e, DateTime now) => _waiting(e, now)
? e.reason != null
// Releases exist but AnimeFlow rejected them (the hint says why).
? ('release rejected', Colors.orangeAccent, Icons.block)
: (
e.lastSearchedAt == null ? 'waiting for release' : 'waiting · searched ${_ago(e.lastSearchedAt!, now)}',
Colors.amber.shade200,
Icons.hourglass_top,
)
: switch (e.status) {
ScheduleStatus.available => ('on Jellyfin', Colors.greenAccent, Icons.check_circle), ScheduleStatus.available => ('on Jellyfin', Colors.greenAccent, Icons.check_circle),
ScheduleStatus.downloading => ( ScheduleStatus.downloading => (
e.progress == null ? 'downloading' : 'downloading ${(e.progress! * 100).round()}%', e.progress == null ? 'downloading' : 'downloading ${(e.progress! * 100).round()}%',
@@ -44,14 +65,25 @@ String countdownText(DateTime at, DateTime now) {
Icons.downloading, Icons.downloading,
), ),
ScheduleStatus.upcoming => (countdownText(e.airing.airingAt, now), Colors.amber, Icons.schedule), ScheduleStatus.upcoming => (countdownText(e.airing.airingAt, now), Colors.amber, Icons.schedule),
ScheduleStatus.missing => ('missing', Colors.orangeAccent, Icons.report_gmailerrorred), ScheduleStatus.missing => ('waiting for release', Colors.amber.shade200, Icons.hourglass_top),
ScheduleStatus.failed => ('failed${e.reason == null ? '' : ' · ${e.reason}'}', Colors.redAccent, Icons.error_outline), ScheduleStatus.failed => (
'failed${e.reason == null ? '' : ' · ${e.reason}'}',
Colors.redAccent,
Icons.error_outline,
),
ScheduleStatus.unmonitored => ('not monitored', Colors.grey, Icons.visibility_off_outlined), ScheduleStatus.unmonitored => ('not monitored', Colors.grey, Icons.visibility_off_outlined),
ScheduleStatus.notTracked => ('not in AnimeFlow', Colors.grey, Icons.public), ScheduleStatus.notTracked => ('not in AnimeFlow', Colors.grey, Icons.public),
ScheduleStatus.inLibrary => ('on Jellyfin', Colors.greenAccent, Icons.video_library_outlined), ScheduleStatus.inLibrary => ('on Jellyfin', Colors.greenAccent, Icons.video_library_outlined),
ScheduleStatus.notOnJellyfin => ('aired · not on Jellyfin yet', Colors.grey, Icons.hourglass_empty), ScheduleStatus.notOnJellyfin => ('aired · not on Jellyfin yet', Colors.grey, Icons.hourglass_empty),
ScheduleStatus.aired => ('aired', Colors.grey, Icons.done), ScheduleStatus.aired => ('aired', Colors.grey, Icons.done),
}; };
/// What the status line means, when its text alone does not say.
String? statusHint(ScheduleEntry e, DateTime now) => !_waiting(e, now)
? null
: e.reason != null
? '${e.reason}${e.lastSearchedAt == null ? '' : ' · searched ${_ago(e.lastSearchedAt!, now)}'}'
: waitingHint;
class DayColumn extends StatelessWidget { class DayColumn extends StatelessWidget {
final DateTime day; final DateTime day;
@@ -137,6 +169,9 @@ class AiringCard extends StatelessWidget {
final bool busy; final bool busy;
final VoidCallback onAction; final VoidCallback onAction;
/// Right-click ([context]: the card): the page's menu; null: none.
final void Function(BuildContext context)? onMenu;
const AiringCard({ const AiringCard({
super.key, super.key,
required this.entry, required this.entry,
@@ -146,6 +181,7 @@ class AiringCard extends StatelessWidget {
required this.onAction, required this.onAction,
this.disabledHint, this.disabledHint,
this.busy = false, this.busy = false,
this.onMenu,
}); });
String get _id => '${entry.airing.anilistId}-${entry.airing.episode}'; String get _id => '${entry.airing.anilistId}-${entry.airing.episode}';
@@ -153,6 +189,56 @@ class AiringCard extends StatelessWidget {
Widget _image(String? url, Widget fallback) => Widget _image(String? url, Widget fallback) =>
url == null ? fallback : Image.network(url, fit: BoxFit.cover, errorBuilder: (_, _, _) => fallback); url == null ? fallback : Image.network(url, fit: BoxFit.cover, errorBuilder: (_, _, _) => fallback);
static Widget _hinted(String? hint, Widget child) => hint == null ? child : Tooltip(message: hint, child: child);
bool get _played => entry.watch?.played ?? false;
/// A watched episode's cover is a little dimmer.
Widget _dimmed(Widget cover) => _played ? Opacity(opacity: 0.6, child: cover) : cover;
/// This user's state of the episode on Jellyfin: the library cards'
/// green check once watched, else a thin bar to the resume point.
List<Widget> _watchMarks({bool small = false}) {
final w = entry.watch;
if (w == null) return const [];
if (w.played) {
final size = small ? 16.0 : 22.0;
return [
Positioned(
top: small ? 2 : 4,
right: small ? 2 : 4,
child: Tooltip(
message: 'Watched',
child: Container(
key: ValueKey('watched-$_id'),
constraints: BoxConstraints(minWidth: size, minHeight: size),
alignment: Alignment.center,
decoration: BoxDecoration(color: const Color(0xCC1B5E20), borderRadius: BorderRadius.circular(size / 2)),
child: Icon(Icons.check, size: small ? 11 : 14, color: Colors.white),
),
),
),
];
}
if (w.progress <= 0) return const [];
return [
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Tooltip(
message: '${(w.progress * 100).round()}% watched',
child: LinearProgressIndicator(
key: ValueKey('watch-progress-$_id'),
value: w.progress,
minHeight: small ? 2 : 3,
backgroundColor: Colors.black54,
),
),
),
];
}
Widget? _button(BuildContext context) { Widget? _button(BuildContext context) {
final enabled = disabledHint == null && !busy; final enabled = disabledHint == null && !busy;
final spinner = const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)); final spinner = const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2));
@@ -180,6 +266,7 @@ class AiringCard extends StatelessWidget {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final a = entry.airing; final a = entry.airing;
final (label, color, icon) = statusBadge(entry, now); final (label, color, icon) = statusBadge(entry, now);
final hint = statusHint(entry, now);
final time = clockText(a.airingAt); final time = clockText(a.airingAt);
final tintColor = a.color ?? Colors.blueGrey; final tintColor = a.color ?? Colors.blueGrey;
final tint = DecoratedBox( final tint = DecoratedBox(
@@ -191,7 +278,11 @@ class AiringCard extends StatelessWidget {
), ),
), ),
); );
final tap = (action == CardAction.putUp || action == CardAction.play) && disabledHint == null && !busy ? onAction : null; final tap = (action == CardAction.putUp || action == CardAction.play) && disabledHint == null && !busy
? onAction
: null;
final onMenu = this.onMenu;
final menu = onMenu == null || busy ? null : () => onMenu(context);
if (compact) { if (compact) {
final requestable = action == CardAction.request || action == CardAction.requestSeries; final requestable = action == CardAction.request || action == CardAction.requestSeries;
return Material( return Material(
@@ -201,13 +292,21 @@ class AiringCard extends StatelessWidget {
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
onTap: tap, onTap: tap,
onSecondaryTap: menu,
child: Padding( child: Padding(
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
child: Row( child: Row(
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
child: SizedBox(width: 34, height: 48, child: _image(a.cover, tint)), child: SizedBox(
width: 34,
height: 48,
child: Stack(
fit: StackFit.expand,
children: [_dimmed(_image(a.cover, tint)), ..._watchMarks(small: true)],
),
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
@@ -237,7 +336,7 @@ class AiringCard extends StatelessWidget {
) )
else else
Tooltip( Tooltip(
message: label, message: hint == null ? label : '$label: $hint',
child: Icon(icon, size: 16, color: color), child: Icon(icon, size: 16, color: color),
), ),
], ],
@@ -255,6 +354,7 @@ class AiringCard extends StatelessWidget {
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: InkWell( child: InkWell(
onTap: tap, onTap: tap,
onSecondaryTap: menu,
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Column( child: Column(
@@ -271,7 +371,7 @@ class AiringCard extends StatelessWidget {
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
_image(a.cover, tint), _dimmed(_image(a.cover, tint)),
Positioned( Positioned(
left: 4, left: 4,
top: 4, top: 4,
@@ -287,6 +387,7 @@ class AiringCard extends StatelessWidget {
), ),
), ),
), ),
..._watchMarks(),
if (entry.status == ScheduleStatus.downloading) if (entry.status == ScheduleStatus.downloading)
Positioned( Positioned(
left: 0, left: 0,
@@ -323,6 +424,8 @@ class AiringCard extends StatelessWidget {
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12), style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
), ),
const Spacer(), const Spacer(),
_hinted(
hint,
Row( Row(
children: [ children: [
Icon(icon, size: 14, color: color), Icon(icon, size: 14, color: color),
@@ -337,6 +440,7 @@ class AiringCard extends StatelessWidget {
), ),
], ],
), ),
),
], ],
), ),
), ),
@@ -6,6 +6,7 @@ import '../../jellyfin/accounts.dart';
import '../../screens/home/picked_media.dart'; import '../../screens/home/picked_media.dart';
import '../../shell/avatar.dart'; import '../../shell/avatar.dart';
import '../../shell/motion.dart'; import '../../shell/motion.dart';
import '../../widgets/app_menu.dart';
import '../anilist.dart'; import '../anilist.dart';
import '../animeflow.dart'; import '../animeflow.dart';
import '../schedule_model.dart'; import '../schedule_model.dart';
@@ -15,8 +16,10 @@ import 'schedule_card.dart';
// The Schedule tab: one column per day from last week's Monday to four // 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 // 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 // one thing to do about it (put it up, request it; right-click an
// minute while visible; ETags keep that cheap. // available one to queue it). Refreshes every minute while visible; ETags
// keep that cheap. Episodes on Jellyfin show this user's watched state,
// reloaded after a playback stops (JellyfinAccounts.watched).
enum ScheduleFilter { jellyfin, all } enum ScheduleFilter { jellyfin, all }
@@ -27,6 +30,9 @@ class SchedulePage extends StatefulWidget {
/// Puts a Jellyfin episode up in the server; returns an error to show. /// Puts a Jellyfin episode up in the server; returns an error to show.
final Future<String?> Function(JellyfinMedia pick) onPutUp; final Future<String?> Function(JellyfinMedia pick) onPutUp;
/// Adds an episode to Up next; null: no server, the cards have no menu.
final AddToQueue? onAddToQueue;
final void Function(String message) onToast; final void Function(String message) onToast;
/// Null = the page makes (and closes) its own. /// Null = the page makes (and closes) its own.
@@ -39,6 +45,7 @@ class SchedulePage extends StatefulWidget {
super.key, super.key,
this.solo = false, this.solo = false,
required this.onPutUp, required this.onPutUp,
this.onAddToQueue,
required this.onToast, required this.onToast,
this.repository, this.repository,
this.accounts, this.accounts,
@@ -64,6 +71,7 @@ class _SchedulePageState extends State<SchedulePage> {
bool _refreshing = false; bool _refreshing = false;
int _gen = 0; int _gen = 0;
Timer? _timer; Timer? _timer;
StreamSubscription<String>? _watchedSub;
final _scroll = ScrollController(); final _scroll = ScrollController();
bool _jumped = false; bool _jumped = false;
@@ -75,7 +83,6 @@ class _SchedulePageState extends State<SchedulePage> {
/// The sheet stays built while it slides out. /// The sheet stays built while it slides out.
bool _sheetBuilt = false; bool _sheetBuilt = false;
/// Requested in this run: "anilistId#episode" or "series:anilistId". /// Requested in this run: "anilistId#episode" or "series:anilistId".
final _requested = <String>{}; final _requested = <String>{};
@@ -100,6 +107,11 @@ class _SchedulePageState extends State<SchedulePage> {
_timer = Timer.periodic(SchedulePage.refreshEvery, (_) { _timer = Timer.periodic(SchedulePage.refreshEvery, (_) {
if (mounted && !_refreshing && TickerMode.valuesOf(context).enabled) _load(); if (mounted && !_refreshing && TickerMode.valuesOf(context).enabled) _load();
}); });
// Back from watching: the cards' watched marks follow (the stop report
// already dropped the cached episode lists).
_watchedSub = _accounts.watched.listen((_) {
if (mounted) _load();
});
} }
ScheduleRepository _ownRepo() { ScheduleRepository _ownRepo() {
@@ -107,13 +119,14 @@ class _SchedulePageState extends State<SchedulePage> {
return ScheduleRepository( return ScheduleRepository(
aniList: AniListClient(), aniList: AniListClient(),
animeFlow: flow, animeFlow: flow,
jellyfinEpisodes: (id) => jellyfinEpisodeNumbers(_accounts, flow, id), jellyfinEpisodes: (id) => jellyfinScheduleEpisodes(_accounts, flow, id),
); );
} }
@override @override
void dispose() { void dispose() {
_timer?.cancel(); _timer?.cancel();
_watchedSub?.cancel();
_scroll.dispose(); _scroll.dispose();
if (_ownsRepo) { if (_ownsRepo) {
_repo.aniList.close(); _repo.aniList.close();
@@ -222,9 +235,10 @@ class _SchedulePageState extends State<SchedulePage> {
final me = _me; final me = _me;
final CardAction kind; final CardAction kind;
if (e.tracked) { if (e.tracked) {
kind = e.status == ScheduleStatus.missing || e.status == ScheduleStatus.failed // Only an unmonitored episode is worth asking for: AnimeFlow grabs
? CardAction.request // a missing one (no release yet) by itself once a release appears,
: CardAction.none; // and retries a failed one.
kind = e.status == ScheduleStatus.unmonitored ? CardAction.request : CardAction.none;
} else { } else {
// On Jellyfin already (owner added it by hand): nothing to request. // On Jellyfin already (owner added it by hand): nothing to request.
kind = me != null && !e.onJellyfin ? CardAction.requestSeries : CardAction.none; kind = me != null && !e.onJellyfin ? CardAction.requestSeries : CardAction.none;
@@ -267,6 +281,32 @@ class _SchedulePageState extends State<SchedulePage> {
} }
} }
/// Right-click on an available episode ([context]: its card): Put it
/// up, Add to queue, Play next.
Future<void> _menu(BuildContext context, ScheduleEntry e) async {
final add = widget.onAddToQueue;
if (add == null) return;
final pick = await showAppMenu<int>(context, items: [
AppMenuItem(value: 0, label: widget.solo ? 'Play' : 'Put it up'),
const AppMenuItem(value: 1, label: 'Add to queue'),
const AppMenuItem(value: 2, label: 'Play next'),
]);
if (pick == null || !mounted) return;
if (pick == 0) return _putUp(e);
final key = 'play:${_key(e)}';
if (_busy.contains(key)) return;
setState(() => _busy.add(key));
try {
final media = await resolveJellyfinEpisode(_accounts, _flow, e);
final err = await add(media, next: pick == 2);
if (err != null) _toast(err);
} catch (err) {
_toast(friendlyError(err));
} finally {
if (mounted) setState(() => _busy.remove(key));
}
}
Future<void> _request(ScheduleEntry e) async { Future<void> _request(ScheduleEntry e) async {
final c = _flow; final c = _flow;
if (c == null) return; if (c == null) return;
@@ -520,6 +560,9 @@ class _SchedulePageState extends State<SchedulePage> {
disabledHint: hint, disabledHint: hint,
busy: busy, busy: busy,
onAction: () => _onAction(e, action), onAction: () => _onAction(e, action),
onMenu: widget.onAddToQueue != null && (action == CardAction.putUp || action == CardAction.play) && hint == null
? (context) => _menu(context, e)
: null,
); );
} }
@@ -1,44 +1,27 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../../platform/app_log.dart';
import '../../shell/mark_problem.dart';
import '../../state/session_state.dart';
import '../../state/settings.dart';
import '../../update/update_controller.dart'; import '../../update/update_controller.dart';
import '../../widgets/app_menu.dart';
import '../../widgets/copy_open.dart';
import 'settings_page.dart'; import 'settings_page.dart';
import 'ui.dart'; import 'ui.dart';
import 'update_indicator.dart'; import 'update_indicator.dart';
/// Opens [url] in the default browser (no url_launcher dependency). /// The Minimal player skin's credit (README says the same).
Future<bool> openInBrowser(String url) async { const uoscCredit = 'Player timeline and controls inspired by uosc by tomasklaen, LGPL-2.1 '
try { '(github.com/tomasklaen/uosc).';
final ProcessResult r;
if (Platform.isWindows) {
r = await Process.run('rundll32', ['url.dll,FileProtocolHandler', url]);
} else if (Platform.isMacOS) {
r = await Process.run('open', [url]);
} else {
r = await Process.run('xdg-open', [url]);
}
return r.exitCode == 0;
} catch (_) {
return false;
}
}
/// Name, version, update state and the source link (Settings → About). /// Name, version, update state, the source link and the logs (Settings →
/// About).
class AboutPanel extends StatelessWidget { class AboutPanel extends StatelessWidget {
final UpdateController? updates; final UpdateController? updates;
const AboutPanel({super.key, this.updates}); const AboutPanel({super.key, this.updates});
Future<void> _open(BuildContext context) async {
final messenger = ScaffoldMessenger.maybeOf(context);
if (await openInBrowser(repoUrl)) return;
await Clipboard.setData(const ClipboardData(text: repoUrl));
messenger?.showSnackBar(
const SnackBar(content: Text('Could not open a browser — link copied')));
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = Theme.of(context).textTheme; final t = Theme.of(context).textTheme;
@@ -49,7 +32,7 @@ class AboutPanel extends StatelessWidget {
children: [ children: [
Text(appName, style: t.titleLarge), Text(appName, style: t.titleLarge),
const SizedBox(height: 4), const SizedBox(height: 4),
SelectableText('Version $appVersion', SelectableText('Version ${displayVersion()}',
style: t.bodyMedium?.copyWith(color: Colors.grey)), style: t.bodyMedium?.copyWith(color: Colors.grey)),
const SizedBox(height: 4), const SizedBox(height: 4),
UpdateStatusLine(controller: updates), UpdateStatusLine(controller: updates),
@@ -58,20 +41,87 @@ class AboutPanel extends StatelessWidget {
children: [ children: [
Expanded(child: SelectableText(repoUrl, style: t.bodySmall)), Expanded(child: SelectableText(repoUrl, style: t.bodySmall)),
IconButton( IconButton(
tooltip: 'Copy link', tooltip: ReuseText.copyLink,
icon: const Icon(Icons.copy, size: 18), icon: const Icon(Icons.copy, size: 18),
onPressed: () => onPressed: () => copyToClipboard(repoUrl, done: 'Link copied'),
Clipboard.setData(const ClipboardData(text: repoUrl)),
), ),
FilledButton.tonalIcon( FilledButton.tonalIcon(
onPressed: () => _open(context), onPressed: () => openLink(repoUrl),
icon: const Icon(Icons.open_in_new, size: 18), icon: const Icon(Icons.open_in_new, size: 18),
label: const Text('Source'), label: const Text('Source'),
), ),
], ],
), ),
const SizedBox(height: 4),
Text(uoscCredit, key: const ValueKey('uosc-credit'), style: t.bodySmall?.copyWith(color: Colors.grey)),
const SizedBox(height: kGap),
Text('Something went wrong? Copy diagnostics and send them to whoever helps you: '
'versions and the last hours of the log, passwords, tokens and cookies left out. '
'In a session it can add the server\'s session log, everyone\'s lines merged. '
'Mark a problem (Ctrl+Shift+B) when it happens so the spot is easy to find.',
style: t.bodySmall?.copyWith(color: Colors.grey)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Builder(
builder: (context) => FilledButton.tonalIcon(
key: const ValueKey('copy-diagnostics'),
onPressed: () => _copyDiagnostics(context),
icon: const Icon(Icons.bug_report_outlined, size: 18),
label: const Text('Copy diagnostics'),
),
),
const MarkProblemButton(outlined: true),
OutlinedButton.icon(
onPressed: appLogDir == null ? null : () => _openLogs(),
icon: const Icon(Icons.folder_open, size: 18),
label: const Text('Open logs folder'),
),
],
),
const SizedBox(height: 8),
SignalBuilder(
builder: (context) => SwitchListTile(
key: const ValueKey('share-diagnostics'),
contentPadding: EdgeInsets.zero,
title: const Text('Share diagnostics with the server'),
subtitle: const Text('In a session, this app\'s log goes to the server\'s session log, '
'merged with everyone\'s, so a problem can be read afterwards. Never chat; '
'passwords and tokens left out. Off: only a problem you mark is sent.'),
value: AppSettings.instance.shareDiagnostics.value,
onChanged: AppSettings.instance.setShareDiagnostics,
),
),
], ],
), ),
); );
} }
/// In a session it asks whether to add the server's session log.
static Future<void> _copyDiagnostics(BuildContext context) async {
var withServer = false;
if (SessionState.instance.route.peek() == AppRoute.session) {
final pick = await showAppMenu<bool>(context, items: const [
AppMenuItem(value: false, label: "This app's log"),
AppMenuItem(value: true, label: "This app's and the server's session log"),
]);
if (pick == null) return;
withServer = pick;
}
final text = await diagnosticsText(sessionLog: withServer);
final saved = await saveDiagnostics(text);
await copyToClipboard(text,
done: saved == null ? 'Diagnostics copied' : 'Diagnostics copied (also saved in the logs folder)');
}
static Future<void> _openLogs() async {
final dir = appLogDir;
if (dir == null) return;
// The file manager opens a folder path the way a browser opens a link.
if (!await openInBrowser(Uri.directory(dir).toString())) {
await copyToClipboard(dir, done: "Couldn't open it — folder path copied");
}
}
} }
@@ -27,7 +27,8 @@ class MediaPickerController extends ChangeNotifier {
final TextEditingController link; final TextEditingController link;
final TextEditingController file; final TextEditingController file;
MediaKind _source; MediaKind _source;
LinkRelay _linkRelay = LinkRelay.direct; // Through me unless picked otherwise (Library → Link).
LinkRelay _linkRelay = LinkRelay.me;
JellyfinMedia? _jellyfin; JellyfinMedia? _jellyfin;
JellyfinSelection? _selected; JellyfinSelection? _selected;
@@ -122,3 +122,7 @@ final class JellyfinMedia extends PickedMedia {
@override @override
String toString() => 'JellyfinMedia($serverId/$itemId "$title")'; String toString() => 'JellyfinMedia($serverId/$itemId "$title")';
} }
/// Adds a pick to Up next (with [next]: right after what plays); returns
/// an error text, or null when sent. Null where there is no server.
typedef AddToQueue = Future<String?> Function(PickedMedia media, {bool next});
@@ -12,7 +12,7 @@ import 'update_indicator.dart';
const sessionShortcuts = [ const sessionShortcuts = [
('Space', 'Toggle ready'), ('Space', 'Toggle ready'),
('← / →', 'Seek back / forward 10 s'), ('← / →', 'Seek back / forward 10 s'),
('↑ / ↓', 'Volume up / down (up to 150 %)'), ('↑ / ↓, wheel', 'Volume up / down (up to 150 %)'),
('Shift+→', 'Skip 1:30 (for everyone)'), ('Shift+→', 'Skip 1:30 (for everyone)'),
('PgUp / PgDn', 'Previous / next chapter'), ('PgUp / PgDn', 'Previous / next chapter'),
('F', 'Toggle fullscreen'), ('F', 'Toggle fullscreen'),
@@ -23,6 +23,7 @@ const sessionShortcuts = [
('Ctrl+1…6', 'Shaders: Anime4K mode A, B, C, A+A, B+B, C+A'), ('Ctrl+1…6', 'Shaders: Anime4K mode A, B, C, A+A, B+B, C+A'),
('Ctrl+0', 'Shaders off'), ('Ctrl+0', 'Shaders off'),
('Esc', 'Close the panel or fullscreen, shrink the player, then ask to leave'), ('Esc', 'Close the panel or fullscreen, shrink the player, then ask to leave'),
('Ctrl+Shift+B', 'Mark a problem in the session log (anywhere)'),
]; ];
/// One titled card of the Settings tab (rounded Material, so list tiles /// One titled card of the Settings tab (rounded Material, so list tiles
+17
View File
@@ -1,10 +1,27 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart' show appVersion, isDevBuild;
/// Product name (working title). Change it here only. /// Product name (working title). Change it here only.
const appName = 'Starsync'; const appName = 'Starsync';
const repoUrl = 'https://git.senkochan.moe/Foxyz/sync_replay_project'; const repoUrl = 'https://git.senkochan.moe/Foxyz/sync_replay_project';
/// The pubspec's version: the Flutter tool defines it for every build and
/// run (`flutter run` too).
const _pubspecVersion = String.fromEnvironment('FLUTTER_BUILD_NAME');
/// The short commit a packaged build was made from (`tool/package_linux.sh`;
/// `-dirty` with uncommitted changes); empty otherwise.
const _gitCommit = String.fromEnvironment('GIT_COMMIT');
/// The version to show: a release's own ("1.1.2"), a test build's with its
/// commit ("1.1.3-dev.12 · 1a2b3c4"), and for a dev run (no `APP_VERSION`)
/// the pubspec's with "dev" ("1.1.2 · dev run").
String displayVersion({String version = appVersion, String pubspec = _pubspecVersion, String commit = _gitCommit}) {
if (version == '0.0.0-dev') return pubspec.isEmpty ? 'dev run' : '$pubspec · dev run';
return isDevBuild(version) && commit.isNotEmpty ? '$version · $commit' : version;
}
const kRadius = 12.0; const kRadius = 12.0;
const kGap = 16.0; const kGap = 16.0;
@@ -2,43 +2,14 @@ import 'package:flutter/material.dart';
import '../../jellyfin/session_media.dart'; import '../../jellyfin/session_media.dart';
import '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs; import '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs;
import '../../widgets/app_menu.dart' show AppGlass;
import 'player_controls.dart' show FloatingPill; import 'player_controls.dart' show FloatingPill;
// Jellyfin pieces of the session player. Plain widgets fed by the // Jellyfin pieces of the session player. Plain widgets fed by the
// coordinator's signals, so tests can pump them without the player. // coordinator's signals, so tests can pump them without the player.
/// Previous / next episode in the video controls. Nothing when there is /// Floating "Next episode ›" from the ED (or the last two minutes), while
/// no such episode or this viewer cannot put it up (no account). /// the controls are up (`PillGate`): faint at rest, lit on hover.
class EpisodeSkipButton extends StatelessWidget {
final EpisodeLink? episode;
final bool next;
final VoidCallback onPressed;
final FocusNode? focusNode;
const EpisodeSkipButton(
{super.key, required this.episode, required this.next, required this.onPressed, this.focusNode});
@override
Widget build(BuildContext context) {
final e = episode;
if (e == null) return const SizedBox.shrink();
return Tooltip(
message: '${next ? 'Next' : 'Previous'}: ${e.label} (${next ? '> or Shift+N' : '< or Shift+P'})',
child: IconButton(
key: ValueKey(next ? 'episode-next' : 'episode-previous'),
focusNode: focusNode,
color: Colors.white,
iconSize: 22,
visualDensity: VisualDensity.compact,
icon: Icon(next ? Icons.skip_next_rounded : Icons.skip_previous_rounded),
onPressed: onPressed,
),
);
}
}
/// Floating "Next episode ›" from the ED (or the last two minutes):
/// faint at rest, lit on hover. Lives outside the auto-hiding controls, so
/// it shows in fullscreen and with the controls hidden.
class NextEpisodePill extends StatelessWidget { class NextEpisodePill extends StatelessWidget {
final EpisodeLink episode; final EpisodeLink episode;
final VoidCallback onPressed; final VoidCallback onPressed;
@@ -70,27 +41,36 @@ class EndOfEpisodeCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final c = countdown; final c = countdown;
return Card( // The same card as Up next's (`UpNextCard`): the app's glass, 400 wide.
return AppGlass(
key: const ValueKey('end-of-episode'), key: const ValueKey('end-of-episode'),
color: Colors.black.withValues(alpha: 0.85), child: SizedBox(
width: 400,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 12, 12), padding: const EdgeInsets.all(12),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 380),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text(c == null ? 'Up next' : 'Next episode in ${c.secondsLeft} s', Text(c == null ? 'Up next' : 'Next episode in ${c.secondsLeft} s',
style: t.labelLarge?.copyWith(color: Colors.white70)), style: const TextStyle(color: Colors.white60, fontSize: 12)),
const SizedBox(height: 4), const SizedBox(height: 2),
Text('Next: ${next.label}', Text('Next: ${next.label}',
maxLines: 2, overflow: TextOverflow.ellipsis, style: t.titleMedium), maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600, height: 1.25)),
if (c != null) ...[ if (c != null) ...[
const SizedBox(height: 8), const SizedBox(height: 10),
LinearProgressIndicator(value: c.secondsLeft / autoAdvanceSecs), ClipRRect(
borderRadius: BorderRadius.circular(2),
child: TweenAnimationBuilder<double>(
tween: Tween(end: c.secondsLeft / autoAdvanceSecs),
duration: const Duration(seconds: 1),
builder: (context, v, _) =>
LinearProgressIndicator(value: v, minHeight: 3, backgroundColor: const Color(0x1FFFFFFF)),
),
),
], ],
const SizedBox(height: 10), const SizedBox(height: 10),
Row( Row(
@@ -166,25 +146,3 @@ class JellyfinBlockedCard extends StatelessWidget {
); );
} }
} }
/// Small member-list badge: plays the Jellyfin item through the host.
class ViaHostBadge extends StatelessWidget {
const ViaHostBadge({super.key});
@override
Widget build(BuildContext context) {
return Tooltip(
message: 'No Jellyfin account here: watches through the host',
child: Container(
key: const ValueKey('via-host'),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: Colors.white10,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: Colors.white24),
),
child: const Text('via host', style: TextStyle(fontSize: 10, color: Colors.white70)),
),
);
}
}
@@ -1,534 +1,19 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../../jellyfin/track_prefs.dart' show MpvTrack;
import '../../playback/media_marks.dart'; import '../../playback/media_marks.dart';
import '../../widgets/app_menu.dart' show AppGlass;
// Session control-bar pieces built on the playback signals (chapters, // The player's floating action pills (Skip OP / ED / 1:30, Next episode):
// tracks, skip offers): the seek bar with chapter marks, the chapter and // plain widgets fed by values and callbacks, so tests pump them without a
// track menus, the 1:30 skip button and the floating skip pill. Plain // player. They live in the action area, outside the controls.
// widgets fed by values and callbacks, so tests pump them without a player.
/// Keeps media_kit's desktop controls shown, as a mouse hover would. /// The player's action pill (Next episode, Skip OP / ED / 1:30): the
/// While a seek bar drag runs no hover events arrive, and the controls /// status pill's glass ([AppGlass.pill]) at [height], faint at rest, lit
/// would hide (and unmount the bar) mid-drag. A guarded dynamic call on /// on hover. Lives in the player's action area, outside the auto-hiding
/// the private controls state; nothing happens outside those controls. /// controls (also in fullscreen); `PillGate` shows it with them.
void keepVideoControlsAwake(BuildContext context) {
context.visitAncestorElements((e) {
if (e is StatefulElement &&
e.state.runtimeType.toString() == '_MaterialDesktopVideoControlsState') {
try {
(e.state as dynamic).onHover();
} catch (_) {}
return false;
}
return true;
});
}
/// The seek bar: played / buffered track, a diamond at each chapter start
/// (not the one at 0), and on hover the time with the chapter title above
/// it. Like media_kit's bar it reports a seek on every drag move and on
/// release ([onSeek]); the session routes those through the user-seek path
/// (debounced, everyone follows).
class ChapterSeekBar extends StatefulWidget {
final Duration position;
final Duration duration;
final Duration buffer;
final List<MediaChapter> chapters;
final ValueChanged<Duration> onSeek;
/// Any pointer activity on the bar (keeps the controls up).
final VoidCallback? onActivity;
const ChapterSeekBar({
super.key,
required this.position,
required this.duration,
this.buffer = Duration.zero,
this.chapters = const [],
required this.onSeek,
this.onActivity,
});
static const height = 28.0;
@override
State<ChapterSeekBar> createState() => _ChapterSeekBarState();
}
class _ChapterSeekBarState extends State<ChapterSeekBar> {
double? _hoverX;
bool _dragging = false;
double _dragFraction = 0;
double _fraction(Duration d) {
final total = widget.duration.inMicroseconds;
if (total <= 0) return 0;
return (d.inMicroseconds / total).clamp(0.0, 1.0);
}
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
void _seekTo(double x, double width) {
if (widget.duration <= Duration.zero || width <= 0) return;
_dragFraction = (x / width).clamp(0.0, 1.0);
widget.onSeek(_at(_dragFraction));
}
@override
Widget build(BuildContext context) {
final primary = Theme.of(context).colorScheme.primary;
return LayoutBuilder(builder: (context, constraints) {
final width = constraints.maxWidth;
final active = _hoverX != null || _dragging;
final barHeight = active ? 5.0 : 3.0;
final played = _dragging ? _dragFraction : _fraction(widget.position);
const mid = ChapterSeekBar.height / 2;
final hoverX = _hoverX;
final hoverAt = hoverX == null || width <= 0 ? null : _at(hoverX / width);
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
onHover: (e) {
setState(() => _hoverX = e.localPosition.dx.clamp(0, width));
widget.onActivity?.call();
},
onExit: (_) => setState(() => _hoverX = null),
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (e) {
widget.onActivity?.call();
setState(() {
_dragging = true;
_dragFraction = width <= 0 ? 0 : (e.localPosition.dx / width).clamp(0.0, 1.0);
});
},
onPointerMove: (e) {
widget.onActivity?.call();
setState(() => _hoverX = e.localPosition.dx.clamp(0, width));
_seekTo(e.localPosition.dx, width);
},
onPointerUp: (e) {
_seekTo(e.localPosition.dx, width);
setState(() => _dragging = false);
widget.onActivity?.call();
},
onPointerCancel: (_) => setState(() => _dragging = false),
child: SizedBox(
key: const ValueKey('seek-bar'),
width: width,
height: ChapterSeekBar.height,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
right: 0,
top: mid - barHeight / 2,
height: barHeight,
child: Container(color: Colors.white24),
),
Positioned(
left: 0,
top: mid - barHeight / 2,
height: barHeight,
width: width * _fraction(widget.buffer),
child: Container(color: Colors.white24),
),
if (hoverX != null && !_dragging)
Positioned(
left: 0,
top: mid - barHeight / 2,
height: barHeight,
width: hoverX,
child: Container(color: Colors.white24),
),
Positioned(
left: 0,
top: mid - barHeight / 2,
height: barHeight,
width: width * played,
child: Container(color: primary),
),
for (final c in widget.chapters)
if (c.start > Duration.zero && widget.duration > Duration.zero)
Positioned(
key: ValueKey('chapter-mark-${c.index}'),
left: width * _fraction(c.start) - 4,
top: mid - 4,
width: 8,
height: 8,
child: IgnorePointer(
child: Transform.rotate(
angle: math.pi / 4,
child: Container(
margin: const EdgeInsets.all(1),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.black54, width: 0.8),
),
),
),
),
),
if (active)
Positioned(
left: width * played - 6,
top: mid - 6,
width: 12,
height: 12,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(color: primary, shape: BoxShape.circle),
),
),
),
if (hoverAt != null)
Positioned(
// Kept clear of the window edges (the tip is centred).
left: hoverX!.clamp(math.min(60.0, width / 2), math.max(width - 60, width / 2)),
bottom: ChapterSeekBar.height - 2,
child: IgnorePointer(
child: FractionalTranslation(
translation: const Offset(-0.5, 0),
child: _HoverTip(title: hoverTitle, time: formatClock(hoverAt)),
),
),
),
],
),
),
),
);
});
}
}
class _HoverTip extends StatelessWidget {
final String title;
final String time;
const _HoverTip({required this.title, required this.time});
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxWidth: 320),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(6),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (title.isNotEmpty)
Text(title,
key: const ValueKey('seek-hover-chapter'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600)),
Text(time,
key: const ValueKey('seek-hover-time'),
style: const TextStyle(color: Colors.white70, fontSize: 11)),
],
),
);
}
}
/// [ChapterSeekBar] on a live player: position, duration and buffer from
/// its streams. Seeks go to `player.seek`, which the session's
/// `SyncPlayer` turns into user seeks.
class PlayerSeekBar extends StatefulWidget {
final Player player;
final List<MediaChapter> chapters;
const PlayerSeekBar({super.key, required this.player, required this.chapters});
@override
State<PlayerSeekBar> createState() => _PlayerSeekBarState();
}
class _PlayerSeekBarState extends State<PlayerSeekBar> {
final List<StreamSubscription<Object>> _subs = [];
late Duration _position, _duration, _buffer;
@override
void initState() {
super.initState();
_listen();
}
@override
void didUpdateWidget(PlayerSeekBar old) {
super.didUpdateWidget(old);
if (!identical(old.player, widget.player)) {
_cancel();
_listen();
}
}
void _listen() {
final p = widget.player;
_position = p.state.position;
_duration = p.state.duration;
_buffer = p.state.buffer;
void set(VoidCallback f) {
if (mounted) setState(f);
}
_subs.addAll([
p.stream.position.listen((d) => set(() => _position = d)),
p.stream.duration.listen((d) => set(() => _duration = d)),
p.stream.buffer.listen((d) => set(() => _buffer = d)),
]);
}
void _cancel() {
for (final s in _subs) {
s.cancel();
}
_subs.clear();
}
@override
void dispose() {
_cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ChapterSeekBar(
position: _position,
duration: _duration,
buffer: _buffer,
chapters: widget.chapters,
onSeek: (d) => widget.player.seek(d),
onActivity: () => keepVideoControlsAwake(context),
);
}
}
RelativeRect _menuPosition(BuildContext context) {
final button = context.findRenderObject() as RenderBox;
final overlay = Navigator.of(context).overlay!.context.findRenderObject() as RenderBox;
return RelativeRect.fromRect(
Rect.fromPoints(
button.localToGlobal(Offset.zero, ancestor: overlay),
button.localToGlobal(button.size.bottomRight(Offset.zero), ancestor: overlay),
),
Offset.zero & overlay.size,
);
}
/// Control-bar icon with a small count in the corner.
class _BadgedIcon extends StatelessWidget {
final IconData icon;
final int count;
const _BadgedIcon(this.icon, this.count);
@override
Widget build(BuildContext context) {
return Stack(
clipBehavior: Clip.none,
children: [
Icon(icon),
if (count > 0)
Positioned(
right: -7,
top: -5,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0.5),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(999),
),
child: Text('$count',
style: const TextStyle(color: Colors.white, fontSize: 9, fontWeight: FontWeight.bold)),
),
),
],
);
}
}
/// Chapter list for the controls: current chapter checked, a pick seeks
/// to its start. Nothing when the file has no chapters.
class ChaptersControlButton extends StatelessWidget {
final List<MediaChapter> chapters;
final int? current;
final ValueChanged<MediaChapter> onPick;
final FocusNode? focusNode;
final ValueChanged<bool>? onMenuOpen;
const ChaptersControlButton({
super.key,
required this.chapters,
required this.current,
required this.onPick,
this.focusNode,
this.onMenuOpen,
});
@override
Widget build(BuildContext context) {
if (chapters.isEmpty) return const SizedBox.shrink();
return Tooltip(
message: 'Chapters (PgUp / PgDn)',
child: IconButton(
key: const ValueKey('chapters-button'),
focusNode: focusNode,
color: Colors.white,
iconSize: 20,
visualDensity: VisualDensity.compact,
icon: _BadgedIcon(Icons.format_list_bulleted, chapters.length),
onPressed: () => _open(context),
),
);
}
Future<void> _open(BuildContext context) async {
final position = _menuPosition(context);
onMenuOpen?.call(true);
final pick = await showMenu<MediaChapter>(
context: context,
position: position,
popUpAnimationStyle: AnimationStyle.noAnimation,
constraints: const BoxConstraints(minWidth: 240, maxWidth: 420),
items: [
for (final c in chapters)
CheckedPopupMenuItem(
value: c,
checked: c.index == current,
height: 36,
child: Row(
children: [
Expanded(
child: Text(c.title.isEmpty ? 'Chapter ${c.index + 1}' : c.title,
maxLines: 1, overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 12),
Text(formatClock(c.start), style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
),
),
],
);
onMenuOpen?.call(false);
if (pick != null) onPick(pick);
}
}
/// A track pick from [TrackMenuButton]: audio id, subtitle id, or off.
typedef TrackPick = ({int? aid, int? sid, bool subtitlesOff});
/// Audio or subtitle menu for the controls bar (the count of tracks on the
/// icon): "Japanese · E-AC3 2.0 · title", mpv's selection checked,
/// subtitles with Off. The choice is remembered for the next Jellyfin item
/// (per viewer).
class TrackMenuButton extends StatelessWidget {
final JellyfinStreamType type;
/// All tracks; only those of [type] are listed.
final List<MpvTrack> tracks;
final void Function(TrackPick pick) onPick;
final FocusNode? focusNode;
final ValueChanged<bool>? onMenuOpen;
const TrackMenuButton({
super.key,
required this.type,
required this.tracks,
required this.onPick,
this.focusNode,
this.onMenuOpen,
});
bool get _audio => type == JellyfinStreamType.audio;
@override
Widget build(BuildContext context) {
final list = tracks.where((t) => t.type == type).toList();
return Tooltip(
message: _audio ? 'Audio' : 'Subtitles',
child: IconButton(
key: ValueKey(_audio ? 'audio-button' : 'subtitles-button'),
focusNode: focusNode,
color: Colors.white,
iconSize: 20,
visualDensity: VisualDensity.compact,
icon: _BadgedIcon(_audio ? Icons.audiotrack_outlined : Icons.subtitles_outlined, list.length),
onPressed: () => _open(context, list),
),
);
}
Future<void> _open(BuildContext context, List<MpvTrack> list) async {
final position = _menuPosition(context);
onMenuOpen?.call(true);
final pick = await showMenu<TrackPick>(
context: context,
position: position,
popUpAnimationStyle: AnimationStyle.noAnimation,
constraints: const BoxConstraints(minWidth: 280, maxWidth: 460),
items: [
if (!_audio)
CheckedPopupMenuItem(
value: (aid: null, sid: null, subtitlesOff: true),
checked: !list.any((t) => t.selected),
height: 36,
child: const Text('Off'),
),
if (_audio && list.isEmpty)
const PopupMenuItem(enabled: false, height: 32, child: Text('None')),
for (final t in list)
CheckedPopupMenuItem(
value: _audio
? (aid: t.id, sid: null, subtitlesOff: false)
: (aid: null, sid: t.id, subtitlesOff: false),
checked: t.selected,
height: 36,
child: Text(t.label, maxLines: 1, overflow: TextOverflow.ellipsis),
),
],
);
onMenuOpen?.call(false);
if (pick != null) onPick(pick);
}
}
/// "1:30" in the controls: skip forward [fallbackSkip] (Shift+Right).
class SkipForwardButton extends StatelessWidget {
final VoidCallback onPressed;
final FocusNode? focusNode;
const SkipForwardButton({super.key, required this.onPressed, this.focusNode});
@override
Widget build(BuildContext context) {
return Tooltip(
message: 'Skip 1:30 for everyone (Shift+Right)',
child: TextButton.icon(
key: const ValueKey('skip-forward-button'),
focusNode: focusNode,
style: TextButton.styleFrom(
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 8),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: onPressed,
icon: const Icon(Icons.fast_forward_rounded, size: 16),
label: const Text('1:30', style: TextStyle(fontSize: 12)),
),
);
}
}
/// Floating pill over the video (Next episode, Skip OP / ED / 1:30):
/// faint at rest, lit on hover. Lives outside the auto-hiding controls, so
/// it shows in fullscreen and with the controls hidden.
class FloatingPill extends StatefulWidget { class FloatingPill extends StatefulWidget {
/// Every action pill's height: the status pill's without avatars.
static const height = 44.0;
final String label; final String label;
final String tooltip; final String tooltip;
final IconData icon; final IconData icon;
@@ -540,7 +25,7 @@ class FloatingPill extends StatefulWidget {
required this.label, required this.label,
required this.tooltip, required this.tooltip,
required this.onPressed, required this.onPressed,
this.icon = Icons.chevron_right, this.icon = Icons.chevron_right_rounded,
this.tapKey, this.tapKey,
}); });
@@ -553,38 +38,31 @@ class _FloatingPillState extends State<FloatingPill> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final primary = Theme.of(context).colorScheme.primary; const fade = Duration(milliseconds: 150);
return MouseRegion( return MouseRegion(
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true), onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false), onExit: (_) => setState(() => _hover = false),
child: Tooltip( child: Tooltip(
message: widget.tooltip, message: widget.tooltip,
child: AnimatedScale(
duration: const Duration(milliseconds: 150),
scale: _hover ? 1.06 : 1,
child: AnimatedOpacity( child: AnimatedOpacity(
duration: const Duration(milliseconds: 150), duration: fade,
opacity: _hover ? 1 : 0.45, opacity: _hover ? 1 : 0.45,
child: GestureDetector( child: GestureDetector(
key: widget.tapKey, key: widget.tapKey,
onTap: widget.onPressed, onTap: widget.onPressed,
child: AppGlass.pill(
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 150), duration: fade,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9), height: FloatingPill.height,
decoration: BoxDecoration( padding: const EdgeInsets.only(left: 16, right: 12),
color: Colors.black.withValues(alpha: 0.75), color: _hover ? Colors.white12 : Colors.transparent,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: _hover ? primary : Colors.white24),
boxShadow: _hover
? [BoxShadow(color: primary.withValues(alpha: 0.45), blurRadius: 16)]
: const [],
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(widget.label, style: const TextStyle(color: Colors.white, fontSize: 13)), Text(widget.label,
const SizedBox(width: 4), style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)),
const SizedBox(width: 6),
Icon(widget.icon, color: Colors.white, size: 18), Icon(widget.icon, color: Colors.white, size: 18),
], ],
), ),
+309
View File
@@ -0,0 +1,309 @@
import 'package:sync_relay_core/core.dart';
import '../jellyfin/session_media.dart';
// Who fetches the current media and how it spreads, from the state alone:
// the members card's source line and each member's route and buffer.
/// "4.1 MB/s", "850 kB/s".
String formatRate(int bytesPerSec) {
if (bytesPerSec >= 1000000) {
final mb = bytesPerSec / 1e6;
return '${mb.toStringAsFixed(mb >= 10 ? 0 : 1)} MB/s';
}
return '${(bytesPerSec / 1000).round()} kB/s';
}
/// How the current media reaches the party.
enum DeliveryKind {
/// A link every app opens itself.
link,
/// The hosting app's file, read by everyone from its server.
hostFile,
/// A file the dedicated server was started with.
serverFile,
/// A member's machine passes it on through the server.
memberRelay,
/// The server reads the link itself.
serverFetch,
/// Jellyfin: own accounts, the server's login for the rest.
jellyfin,
}
/// How a member gets the bytes.
enum DeliveryRoute {
/// It serves them (a relay source, the host's file).
source,
/// It reads them from the server.
server,
/// It fetches them itself (a link, its own Jellyfin account).
direct,
}
enum BufferHealth { good, low, starving }
/// The relay source's condition.
enum SourceState {
ok,
/// Measured slower than the media's bitrate.
slow,
/// Its channel is down (or the server has not read the link yet).
waiting,
/// It left the server.
left,
}
/// Members' names by id, kept after they leave: "Foxyz left" needs the
/// name the state no longer has.
class MemberNames {
final _names = <String, String>{};
void observe(StateBroadcast r) {
if (_names.length > 256) _names.clear();
for (final m in r.members) {
_names[m.id] = m.name;
}
}
String? operator [](String id) => _names[id];
}
/// The member [m]'s route and buffer.
class MemberDelivery {
/// Null when unknown (a Jellyfin member that has not reported yet).
final DeliveryRoute? route;
final DeliveryKind kind;
/// Bytes per second the server sends it lately.
final int rate;
/// Null when it does not count: sitting out, reconnecting, cannot play.
final BufferHealth? health;
final double buffered;
/// The media is a Jellyfin item (direct = its own account).
final bool jellyfin;
const MemberDelivery({
required this.route,
required this.kind,
this.jellyfin = false,
required this.rate,
required this.health,
required this.buffered,
});
/// "via server · 3.2 MB/s", "direct", "passes it on"; empty when unknown.
String get short => switch (route) {
DeliveryRoute.source => kind == DeliveryKind.hostFile ? 'hosts the file' : 'passes it on',
DeliveryRoute.server => rate > 0 ? 'via server · ${formatRate(rate)}' : 'via server',
DeliveryRoute.direct => jellyfin ? 'own Jellyfin account' : 'direct',
null => '',
};
/// [short] and the buffer: "via server · 3.2 MB/s · 45 s buffered".
String get detail => [
if (short.isNotEmpty) short,
if (health != null) health == BufferHealth.starving ? 'buffer empty' : '${buffered.round()} s buffered',
].join(' · ');
}
/// The current media's path for everyone: what the members card's source
/// line says.
class Delivery {
final DeliveryKind kind;
/// The member serving it ([DeliveryKind.memberRelay], [DeliveryKind.hostFile]); empty otherwise.
final String sourceId;
final String sourceName;
final bool sourceIsSelf;
final SourceState state;
/// `media.relay.rate` / `need` (0 unknown).
final int rate, need;
/// Members (not sitting out, not the source) reading from the server,
/// and fetching it themselves.
final int viaServer, direct;
const Delivery({
required this.kind,
this.sourceId = '',
this.sourceName = '',
this.sourceIsSelf = false,
this.state = SourceState.ok,
this.rate = 0,
this.need = 0,
this.viaServer = 0,
this.direct = 0,
});
bool get relayed => kind == DeliveryKind.memberRelay || kind == DeliveryKind.serverFetch;
/// Rate against bitrate, both known: 1 = exactly what the media needs.
double? get headroom => rate > 0 && need > 0 ? rate / need : null;
String get title {
final who = sourceIsSelf ? 'You' : sourceName;
switch (kind) {
case DeliveryKind.link:
return 'Everyone plays the link directly';
case DeliveryKind.serverFile:
return 'The server has the file';
case DeliveryKind.hostFile:
return sourceIsSelf ? 'You host the file' : '$who hosts the file';
case DeliveryKind.jellyfin:
return viaServer == 0 ? 'Everyone streams it from Jellyfin' : 'From Jellyfin';
case DeliveryKind.serverFetch:
return switch (state) {
SourceState.waiting => 'The server is getting the video…',
SourceState.slow => 'The server fetches it slower than it plays',
_ => 'The server fetches it',
};
case DeliveryKind.memberRelay:
return switch (state) {
SourceState.left => '$sourceName left: playing what the server has',
SourceState.waiting => sourceIsSelf ? 'Reconnecting you to the server…' : 'Waiting for $who to reconnect',
SourceState.slow => sourceIsSelf ? 'Your upload is slower than the video' : "$who's upload is slower than the video",
SourceState.ok => sourceIsSelf ? 'You pass it on through the server' : '$who passes it on through the server',
};
}
}
/// "3 through the server · 4.1 MB/s, needs 2.3 MB/s"; empty when
/// there is nothing to add.
String get detail {
final parts = <String>[
if (direct > 0 && (kind == DeliveryKind.jellyfin || kind == DeliveryKind.memberRelay))
'$direct with their own account',
if (viaServer > 0 && kind != DeliveryKind.link) '$viaServer through the server',
if (rate > 0 && state != SourceState.left) need > 0 ? '${formatRate(rate)}, needs ${formatRate(need)}' : formatRate(rate),
];
return parts.join(' · ');
}
/// The hover text: what the numbers mean.
String get explain => switch (kind) {
DeliveryKind.memberRelay || DeliveryKind.serverFetch => [
if (rate > 0)
'${kind == DeliveryKind.serverFetch ? 'The server gets' : sourceIsSelf ? 'You send' : '$sourceName sends'} '
'${formatRate(rate)} while the server asks for more.',
if (need > 0) 'The video averages ${formatRate(need)}.',
if (state == SourceState.slow) 'Slower than the video: the others will pause to buffer.',
if (rate == 0) 'The speed shows once bytes have flowed for a few seconds.',
].join(' '),
DeliveryKind.hostFile || DeliveryKind.serverFile => 'Everyone else reads the file from the server.',
DeliveryKind.jellyfin => 'Members with an account on that Jellyfin stream it themselves; '
'the rest through the server.',
DeliveryKind.link => 'Each app opens the link itself; the server only keeps time.',
};
}
/// The current media's [Delivery]; null with nothing up or nobody else in
/// the server. [names] remembers a source that left.
Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? names}) {
final media = r.media;
if (media == null || r.members.length < 2) return null;
final rel = media['relay'];
final relay = rel is Map ? MediaRelay.fromJson(Map<String, Object?>.from(rel)) : null;
final ref = refOfStateMedia(media);
final url = media['url'];
final kind = relay != null
? (relay.fetch ? DeliveryKind.serverFetch : DeliveryKind.memberRelay)
: ref != null
? DeliveryKind.jellyfin
: url is String && url.isNotEmpty
? DeliveryKind.link
: r.members.any((m) => m.host)
? DeliveryKind.hostFile
: DeliveryKind.serverFile;
final sourceId = switch (kind) {
DeliveryKind.memberRelay => relay!.source,
DeliveryKind.hostFile => r.members.firstWhere((m) => m.host).id,
_ => '',
};
final source = r.members.where((m) => m.id == sourceId).firstOrNull;
var via = 0, direct = 0;
for (final m in r.members) {
if (m.out || m.id == sourceId) continue;
switch (routeOf(m, kind, sourceId, ref, r.relay)) {
case DeliveryRoute.server:
via++;
case DeliveryRoute.direct:
direct++;
default:
break;
}
}
// A Jellyfin item that everyone streams with their own account: the
// relay stands by, nothing goes through it.
if (kind == DeliveryKind.memberRelay && ref != null && via == 0 && source != null) {
return Delivery(kind: DeliveryKind.jellyfin, direct: direct);
}
final state = relay == null
? SourceState.ok
: kind == DeliveryKind.memberRelay && source == null
? SourceState.left
: !relay.live
? SourceState.waiting
: relay.slow
? SourceState.slow
: SourceState.ok;
return Delivery(
kind: kind,
sourceId: sourceId,
sourceName: source?.name ?? names?[sourceId] ?? 'The member passing it on',
sourceIsSelf: sourceId.isNotEmpty && sourceId == selfId,
state: state,
rate: relay?.rate ?? 0,
need: relay?.need ?? 0,
viaServer: via,
direct: direct,
);
}
/// How [m] gets media of [kind] served by [sourceId]. Jellyfin: by its
/// last `report.jf` for [ref]'s server ([jellyfinRelay]: the server can
/// relay it); null before it reported.
DeliveryRoute? routeOf(MemberView m, DeliveryKind kind, String sourceId, JellyfinRef? ref, bool jellyfinRelay) {
if (sourceId.isNotEmpty && m.id == sourceId) return DeliveryRoute.source;
switch (kind) {
case DeliveryKind.link:
return DeliveryRoute.direct;
case DeliveryKind.hostFile:
case DeliveryKind.serverFile:
case DeliveryKind.serverFetch:
return DeliveryRoute.server;
case DeliveryKind.memberRelay:
case DeliveryKind.jellyfin:
final own = ref == null ? null : m.jf[ref.serverId];
if (own == true) return DeliveryRoute.direct;
if (kind == DeliveryKind.memberRelay) return DeliveryRoute.server;
return own == false && jellyfinRelay ? DeliveryRoute.server : null;
}
}
/// [m]'s route and buffer for [d] (from [deliveryOf] on [r]).
MemberDelivery memberDeliveryOf(MemberView m, StateBroadcast r, Delivery d) {
final counts = !m.out && !m.stale && m.error.isEmpty;
final need = r.bufferNeed;
final ref = refOfStateMedia(r.media);
final health = !counts
? null
: m.buffered >= need
? BufferHealth.good
: r.transport == 'playing' && m.buffered < 1
? BufferHealth.starving
: BufferHealth.low;
return MemberDelivery(
route: m.out ? null : routeOf(m, d.kind, d.sourceId, ref, r.relay),
kind: d.kind,
jellyfin: ref != null,
rate: m.rate,
health: health,
buffered: m.buffered,
);
}
/// What the source is told once per media when it is too slow; null
/// while it keeps up (or this member is not the source).
String? slowSourceWarning(StateBroadcast r, String selfId) {
final rel = r.media?['relay'];
if (rel is! Map || selfId.isEmpty) return null;
final relay = MediaRelay.fromJson(Map<String, Object?>.from(rel));
if (relay.source != selfId || !relay.slow) return null;
return 'Your upload (${formatRate(relay.rate)}) is slower than this video needs '
'(${formatRate(relay.need)}): the others will pause to buffer now and then.';
}
+30 -2
View File
@@ -1,16 +1,19 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:isolate';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:image/image.dart' as img;
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../files/name_parser.dart'; import '../files/name_parser.dart';
import '../schedule/anilist.dart'; import '../schedule/anilist.dart';
// Art for media this client swapped in (`art`, see PROTOCOL.md "Media // Art for media this client swapped in (`art`, see PROTOCOL.md "Media
// art"): Jellyfin images with this install's account, else an AniList // art"): Jellyfin images with this install's account, a web link's
// title search. Everything here fails quietly to "no art". // thumbnail, else an AniList title search. Everything here fails quietly
// to "no art".
/// Poster and banner bytes; either may be null. /// Poster and banner bytes; either may be null.
typedef ArtBytes = ({Uint8List? poster, Uint8List? banner}); typedef ArtBytes = ({Uint8List? poster, Uint8List? banner});
@@ -68,6 +71,31 @@ Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
return (poster: poster, banner: banner); return (poster: poster, banner: banner);
} }
/// A web video's thumbnail at [url] as both cover and banner (the same
/// bytes, so the same hash: what tells clients it is a thumbnail, see
/// `NowPlaying.thumbnail`); shrunk to a JPEG under the cover cap when
/// larger; null when it cannot be had.
Future<ArtBytes?> thumbnailArt(Uri url,
{Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async {
var bytes = await fetch(url, const {}, 8 * 1024 * 1024);
if (bytes != null && bytes.length > RelayConstants.maxPosterBytes) bytes = await _shrinkApart(bytes);
return bytes == null ? null : (poster: bytes, banner: bytes);
}
// Its own function, so the isolate's closure holds nothing but [raw].
Future<Uint8List?> _shrinkApart(Uint8List raw) => Isolate.run<Uint8List?>(() => _shrinkThumbnail(raw));
Uint8List? _shrinkThumbnail(Uint8List raw) {
final decoded = img.decodeImage(raw);
if (decoded == null) return null;
for (final width in const [1280, 960, 640]) {
final small = decoded.width > width ? img.copyResize(decoded, width: width, interpolation: img.Interpolation.average) : decoded;
final jpg = img.encodeJpg(small, quality: 82);
if (jpg.length <= RelayConstants.maxPosterBytes) return jpg;
}
return null;
}
/// GET [url]: the bytes when 200, at most [maxBytes] and a known image /// GET [url]: the bytes when 200, at most [maxBytes] and a known image
/// type (see `avatarImageType`); else null. /// type (see `avatarImageType`); else null.
Future<Uint8List?> fetchImage(Uri url, Map<String, String> headers, int maxBytes) async { Future<Uint8List?> fetchImage(Uri url, Map<String, String> headers, int maxBytes) async {
@@ -9,7 +9,11 @@ class NowPlaying {
/// Portrait cover (2:3) and wide banner; '' when none. /// Portrait cover (2:3) and wide banner; '' when none.
final String posterHash; final String posterHash;
final String bannerHash; final String bannerHash;
const NowPlaying({required this.title, this.episode, this.posterHash = '', this.bannerHash = ''}); /// A web video: its art is its thumbnail (the same picture as cover and
/// banner, or none yet), shown wide, under the video's own name.
final bool thumbnail;
const NowPlaying(
{required this.title, this.episode, this.posterHash = '', this.bannerHash = '', this.thumbnail = false});
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
@@ -17,8 +21,9 @@ class NowPlaying {
other.title == title && other.title == title &&
other.episode == episode && other.episode == episode &&
other.posterHash == posterHash && other.posterHash == posterHash &&
other.bannerHash == bannerHash; other.bannerHash == bannerHash &&
other.thumbnail == thumbnail;
@override @override
int get hashCode => Object.hash(title, episode, posterHash, bannerHash); int get hashCode => Object.hash(title, episode, posterHash, bannerHash, thumbnail);
} }
@@ -1,3 +1,5 @@
import 'package:sync_relay_core/core.dart' show hasMediaExtension;
import '../files/name_parser.dart'; import '../files/name_parser.dart';
import '../jellyfin/session_media.dart'; import '../jellyfin/session_media.dart';
import 'now_playing.dart'; import 'now_playing.dart';
@@ -17,12 +19,37 @@ NowPlaying? nowPlayingOf(Map<String, Object?>? media) {
final poster = art['poster'] is String ? art['poster'] as String : ''; final poster = art['poster'] is String ? art['poster'] as String : '';
final banner = art['banner'] is String ? art['banner'] as String : ''; final banner = art['banner'] is String ? art['banner'] as String : '';
final ref = refOfStateMedia(media); final ref = refOfStateMedia(media);
// A web video: its name as is, no episode guessing, its thumbnail as
// the art. Told by the art once it is up (the same picture as cover and
// banner), before that by the media itself, so it never changes layout.
final web = poster.isNotEmpty || banner.isNotEmpty ? poster == banner : ref == null && isWebVideo(media);
if (web) {
return NowPlaying(title: name, posterHash: poster, bannerHash: banner, thumbnail: true);
}
final (title, episode) = ref != null final (title, episode) = ref != null
? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle) ? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle)
: fileTitle(name); : fileTitle(name);
return NowPlaying(title: title, episode: episode, posterHash: poster, bannerHash: banner); return NowPlaying(title: title, episode: episode, posterHash: poster, bannerHash: banner);
} }
/// [media] (`state.media`, not Jellyfin) is a web page's video, whose art
/// will be its thumbnail: a shared http(s) link that is no media file (the
/// everyone-opens-it mode), or a relayed one (passed on by a member or
/// fetched by the server: the URL stays private) named by the page's title,
/// not a file name. A relayed file keeps its file name's extension.
bool isWebVideo(Map<String, Object?> media) {
final url = media['url'];
if (url is String && url.isNotEmpty) {
final uri = Uri.tryParse(url);
return uri != null &&
(uri.isScheme('http') || uri.isScheme('https')) &&
uri.host.isNotEmpty &&
!hasMediaExtension(uri);
}
final name = media['name'] is String ? media['name'] as String : '';
return media['relay'] is Map && name.isNotEmpty && !_ext.hasMatch(name);
}
/// Jellyfin: [name] is the item's, [subtitle] "Series · S1E6" (or /// Jellyfin: [name] is the item's, [subtitle] "Series · S1E6" (or
/// "Series - S01E02") for episodes, the year for movies. Episodes give the /// "Series - S01E02") for episodes, the year for movies. Episodes give the
/// series and "S1 E6 · Name"; anything else the name alone. /// series and "S1 E6 · Name"; anything else the name alone.
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
@@ -16,6 +17,7 @@ import '../playback/playback_controller.dart';
import '../platform/attention.dart'; import '../platform/attention.dart';
import '../playback/yt_dlp_app.dart'; import '../playback/yt_dlp_app.dart';
import '../schedule/anilist.dart'; import '../schedule/anilist.dart';
import '../screens/home/ui.dart' show appName, displayVersion;
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
import '../sfx/sfx.dart'; import '../sfx/sfx.dart';
import '../state/settings.dart'; import '../state/settings.dart';
@@ -23,11 +25,16 @@ import '../sync/avatar_cache.dart';
import '../sync/state_diff.dart'; import '../sync/state_diff.dart';
import '../sync/sync_client.dart'; import '../sync/sync_client.dart';
import '../sync/user_seek.dart'; import '../sync/user_seek.dart';
import '../queue/queue_controller.dart';
import 'delivery.dart';
import 'health.dart'; import 'health.dart';
import 'idle.dart'; import 'idle.dart';
import 'media_art.dart'; import 'media_art.dart';
import 'now_playing.dart'; import 'now_playing.dart';
import 'now_playing_info.dart'; import 'now_playing_info.dart';
import 'switch_trace.dart';
final _log = createLogger('session');
/// Thrown when a connect is superseded by cancel/leave before it finished. /// Thrown when a connect is superseded by cancel/leave before it finished.
class ConnectCancelled implements Exception { class ConnectCancelled implements Exception {
@@ -168,11 +175,43 @@ class SessionCoordinator {
/// The app's yt-dlp and link probes (tests pass their own). /// The app's yt-dlp and link probes (tests pass their own).
final AppYtDlp ytDlp; final AppYtDlp ytDlp;
final SyncClient sync = SyncClient(); final SyncClient sync = SyncClient();
/// This app's log lines to the server's session log (Settings: "Share
/// diagnostics with the server"), and "Mark a problem".
late final DiagUploader diag = DiagUploader(
target: _diagTarget, version: '$appName ${displayVersion()} on ${Platform.operatingSystem}');
DiagTarget? _diagTarget() {
final uri = sync.wsUri;
if (sync.token.isEmpty || uri == null || !sync.clockSync.hasSample) return null;
return (base: DiagUploader.baseOf(uri), token: sync.token, offsetMs: sync.clockSync.offsetMs);
}
/// "Mark a problem": a marker with [note] in the server's session log
/// (and this app's log). False when not in a session: then only this
/// app's log has it.
Future<bool> markProblem(String note) async {
if (!diag.running) {
createLogger(DiagUploader.markLogger).warning('=== PROBLEM marked${note.trim().isEmpty ? '' : ': ${note.trim()}'} ===');
return false;
}
await diag.markProblem(note);
return true;
}
/// The server's session log of the last [hours], null when not in a
/// session or the server has none.
Future<String?> sessionLog({int hours = 6}) => diag.fetchSessionLog(hours: hours);
/// Up next: the server's queue, adding to it, and putting items up.
late final QueueController queue = QueueController(this);
/// The server has a password (it fetches links only then).
bool get serverHasPassword => (_params?.secret ?? '').isNotEmpty;
/// Member pictures by `avatarHash`, fetched from the server. /// Member pictures by `avatarHash`, fetched from the server.
late final AvatarCache avatars = AvatarCache(sync); late final AvatarCache avatars = AvatarCache(sync);
/// The current media's poster and banner by `state.media.art` hash. /// The current media's poster and banner by `state.media.art` hash.
late final ArtCache art = ArtCache(sync); late final ArtCache art = ArtCache(sync);
final PlaybackController playback = PlaybackController(); final PlaybackController playback = PlaybackController();
/// Timings of each media change here (see [SwitchTrace]).
final SwitchTrace switchTrace = SwitchTrace();
final StateDiff diff = StateDiff(); final StateDiff diff = StateDiff();
final health = final health =
@@ -197,10 +236,17 @@ class SessionCoordinator {
/// Set when this viewer cannot play the Jellyfin media: no account and /// Set when this viewer cannot play the Jellyfin media: no account and
/// no relay. The session shows it with a way to link an account. /// no relay. The session shows it with a way to link an account.
final jellyfinBlocked = signal<BlockedPlay?>(null); final jellyfinBlocked = signal<BlockedPlay?>(null);
/// An item this app is about to put up, resolved ahead with its own
/// account (Up next's first item, the next episode; see
/// `QueueController`): opening it then skips PlaybackInfo. Used once,
/// until [until].
({JellyfinRef ref, OwnPlay plan, DateTime until})? warmJellyfin;
/// Neighbouring episodes this viewer can put up (own account only). /// Neighbouring episodes this viewer can put up (own account only).
final previousEpisode = signal<EpisodeLink?>(null); final previousEpisode = signal<EpisodeLink?>(null);
final nextEpisode = signal<EpisodeLink?>(null); final nextEpisode = signal<EpisodeLink?>(null);
/// The floating "Next episode" pill near the end of an episode. /// The "Next episode" pill's window ([nextPromptStart]: the known ending,
/// else the last two minutes); the pill shows in it only while the
/// controls are up.
final nextPromptVisible = signal(false); final nextPromptVisible = signal(false);
/// End-of-episode countdown; null when none runs here. /// End-of-episode countdown; null when none runs here.
final autoAdvance = signal<AutoAdvance?>(null); final autoAdvance = signal<AutoAdvance?>(null);
@@ -269,13 +315,22 @@ class SessionCoordinator {
int _epoch = 0; int _epoch = 0;
Timer? _reconnectTimer; Timer? _reconnectTimer;
int _mediaId = -1; int _mediaId = -1;
// The mediaId whose media the player holds (or is opening); -1 for none.
// Unlike [_mediaId] it survives a lost connection, so a reconnect to the
// same media keeps the player, and a swap stops the previous media.
int _playerMediaId = -1;
// [_playerMediaId] at the last open: the same media again (a reconnect,
// joining back) keeps the player, any other gets a fresh one (see
// [_openMedia]).
int _openedFor = -1;
String? _openUrl; String? _openUrl;
String _wsHost = ''; String _wsHost = '';
int _wsPort = 0; int _wsPort = 0;
_RelayOrigin? _pendingOrigin; _RelayOrigin? _pendingOrigin;
// This client's last swap, until a state shows it landed (a newer // This client's last swap, until a state shows it landed (a newer
// mediaId than [after] with this member as `swapBy`): its art goes up then. // mediaId than [after] with this member as `swapBy`): its art goes up then.
({HelloMedia media, int after})? _pendingArt; // [link]: the web link it came from (a relayed one has no `url`).
({HelloMedia media, int after, String? link, Uint8List? art})? _pendingArt;
AniListClient? _aniList; AniListClient? _aniList;
// What this member serves for media [$1]; survives a reconnect (the // What this member serves for media [$1]; survives a reconnect (the
// state then looks new again). // state then looks new again).
@@ -284,6 +339,10 @@ class SessionCoordinator {
int _relaySourceMediaId = -1; int _relaySourceMediaId = -1;
/// This member serves the current media's bytes through the server. /// This member serves the current media's bytes through the server.
final relaying = signal<bool>(false); final relaying = signal<bool>(false);
/// Names by member id, kept after they leave (a relay source that left).
final memberNames = MemberNames();
// The media this member was told its upload is too slow for.
int _slowWarnedFor = -1;
/// What this client put up last in this connection (a solo session /// What this client put up last in this connection (a solo session
/// carries it over when it becomes a hosted server). /// carries it over when it becomes a hosted server).
PickedMedia? get lastPick => _lastPick; PickedMedia? get lastPick => _lastPick;
@@ -335,16 +394,17 @@ class SessionCoordinator {
jellyfin = jellyfin ?? JellyfinAccounts.instance, jellyfin = jellyfin ?? JellyfinAccounts.instance,
trackPrefs = trackPrefs ?? TrackPrefs(); trackPrefs = trackPrefs ?? TrackPrefs();
/// Player lifetime = one session: created here on connect, disposed by /// The session's first player: created here on connect, disposed by
/// [leave]. Never reused across sessions (a GPU-side video freeze sticks /// [leave]; each new media gets its own ([PlaybackController.openUrl]).
/// Never reused across sessions or media (a GPU-side video freeze sticks
/// to the player). /// to the player).
Future<void> initPlayer() async { Future<void> initPlayer() async {
if (_playerReady) return; if (_playerReady) return;
try { try {
playback.onFrozen = () => unawaited(reloadVideo(auto: true));
playback.onYtdlError = _setPlayerError; playback.onYtdlError = _setPlayerError;
playback.onLoadError = _setPlayerError; playback.onLoadError = _setPlayerError;
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6); playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
_bufferSub ??= playback.buffered.subscribe((_) => _onBuffered());
await playback.init(); await playback.init();
_playerReady = true; _playerReady = true;
} catch (e, st) { } catch (e, st) {
@@ -356,7 +416,7 @@ class SessionCoordinator {
} }
void _setPlayerError(String raw) { void _setPlayerError(String raw) {
debugPrint('player cannot open media $_mediaId: $raw'); _log.warning('player cannot open media $_mediaId: $raw');
_playerErrorFor = _mediaId; _playerErrorFor = _mediaId;
_playerErrorDuration = playback.duration.peek(); _playerErrorDuration = playback.duration.peek();
playerError.value = playErrorText(raw); playerError.value = playErrorText(raw);
@@ -384,14 +444,26 @@ class SessionCoordinator {
_memberErrors = now; _memberErrors = now;
} }
bool _reloadingVideo = false; /// Remembers names, and tells this member once per media when it
/// passes the video on slower than the video plays.
void _warnSlowSource(StateBroadcast r) {
memberNames.observe(r);
if (_slowWarnedFor == r.mediaId) return;
final warning = slowSourceWarning(r, sync.memberId);
if (warning == null) return;
_slowWarnedFor = r.mediaId;
_pushToast(warning);
}
/// "Reload video" (button, Ctrl+R, frozen-picture watchdog): fresh bool _reloadingVideo = false;
void Function()? _bufferSub;
/// "Reload video" (button, Ctrl+R, status menu): fresh
/// player + video output, current media reopened. While it runs the /// player + video output, current media reopened. While it runs the
/// player counts as not ready, so reports/seek detection pause and the /// player counts as not ready, so reports/seek detection pause and the
/// next broadcast resyncs position and play/pause (diff reset + play /// next broadcast resyncs position and play/pause (diff reset + play
/// edge). The ready flag is never touched. /// edge). The ready flag is never touched.
Future<void> reloadVideo({bool auto = false}) async { Future<void> reloadVideo() async {
// Also allowed after a failed reload (player gone, session still on). // Also allowed after a failed reload (player gone, session still on).
if (_params == null || _reloadingVideo) return; if (_params == null || _reloadingVideo) return;
_reloadingVideo = true; _reloadingVideo = true;
@@ -411,16 +483,6 @@ class SessionCoordinator {
_playerReady = playback.alive.peek(); _playerReady = playback.alive.peek();
_reloadingVideo = false; _reloadingVideo = false;
} }
if (auto) {
_toastId++;
toasts.value = [
...toasts.peek(),
ToastItem(
id: _toastId,
text: 'Video froze — reloaded',
at: DateTime.now().toUtc())
];
}
} }
void markInput() => _idle.activity(DateTime.now().toUtc()); void markInput() => _idle.activity(DateTime.now().toUtc());
@@ -436,6 +498,9 @@ class SessionCoordinator {
_wsPort = params.port; _wsPort = params.port;
diff.reset(); diff.reset();
_mediaId = -1; _mediaId = -1;
_playerMediaId = -1;
_openedFor = -1;
settledMediaId.value = -1;
_openUrl = null; _openUrl = null;
playerError.value = ''; playerError.value = '';
_memberErrors = const {}; _memberErrors = const {};
@@ -460,6 +525,9 @@ class SessionCoordinator {
} }
markInput(); markInput();
_subscribe(); _subscribe();
diag
..share = AppSettings.instance.shareDiagnostics.peek()
..start();
feedLog.value = []; feedLog.value = [];
toasts.value = []; toasts.value = [];
await initPlayer(); await initPlayer();
@@ -505,6 +573,7 @@ class SessionCoordinator {
} }
void _subscribe() { void _subscribe() {
queue.attach();
_stateSub ??= sync.states.listen(_onState); _stateSub ??= sync.states.listen(_onState);
_feedSub ??= sync.feeds.listen(_onFeed); _feedSub ??= sync.feeds.listen(_onFeed);
_nudgeSub ??= sync.nudges.listen(_onNudge); _nudgeSub ??= sync.nudges.listen(_onNudge);
@@ -521,52 +590,129 @@ class SessionCoordinator {
}); });
} }
/// Opens [url] (with [headers] on every request); false on failure. /// Opens [url] (with [headers] on every request); false on failure. A
/// new media gets a fresh player (or the preload holding it); the media
/// the player held before (a reconnect) keeps it.
Future<bool> _openMedia(String url, {Map<String, String>? headers}) async { Future<bool> _openMedia(String url, {Map<String, String>? headers}) async {
if (url == _openUrl) return true; if (url == _openUrl) return true;
_log.info('media $_mediaId: open ${_urlKind(url, headers)}: ${logUrl(url)}'
'${headers == null || headers.isEmpty ? '' : ' (headers: ${headers.keys.join(', ')})'}');
final keepPlayer = _playerMediaId == _openedFor;
_openedFor = _playerMediaId;
_openUrl = url; _openUrl = url;
_openHeaders = headers; _openHeaders = headers;
diff.reset(); diff.reset();
_opening = true; _opening = true;
final mediaId = _mediaId;
var ok = false;
try { try {
await _prepareYtDlp(url); await _prepareYtDlp(url);
await playback.openUrl(url, headers: headers); switchTrace.mark(mediaId, SwitchTrace.open);
final promoted = await playback.openUrl(url,
headers: headers,
keepPlayer: keepPlayer,
onFreshPlayer: () => switchTrace.mark(mediaId, SwitchTrace.player));
switchTrace.mark(mediaId, promoted ? SwitchTrace.preloaded : SwitchTrace.opened);
health.value = health.value =
const HealthStatus(HealthSeverity.ok, 'connected'); const HealthStatus(HealthSeverity.ok, 'connected');
return true; ok = true;
} catch (e) { } catch (e) {
debugPrint('open media failed: $e'); debugPrint('open media failed: $e');
_setPlayerError('$e'); _setPlayerError('$e');
return false;
} finally { } finally {
_opening = false; _opening = false;
} }
// Tell the server at once (a preloaded media already has its buffer).
final r = serverState.peek();
if (ok && r != null && r.mediaId == mediaId && url == _openUrl && sync.token.isNotEmpty && _playerReady) {
_sendReport(r);
}
return ok;
}
// The media whose buffer reached the gate's need was reported at once.
int _bufferReportedFor = -1;
/// While held, the report that the buffer reached the gate's need goes
/// out as it happens, not on the next tick.
void _onBuffered() {
final r = serverState.peek();
if (r == null || r.transport != 'held' || _bufferReportedFor == r.mediaId) return;
if (_lastOut || _opening || _openUrl == null || _playerMediaId != r.mediaId || !_playerReady) return;
if (sync.token.isEmpty || playback.bufferedSecsAhead < r.bufferNeed) return;
_bufferReportedFor = r.mediaId;
_sendReport(r);
}
/// What [url] is, for the log.
String _urlKind(String url, Map<String, String>? headers) {
if (url.startsWith('edl://')) return 'relay (tracks)';
final u = Uri.tryParse(url);
if (u == null || !(u.isScheme('http') || u.isScheme('https'))) return 'local file';
if (u.host == _wsHost && u.port == _wsPort && u.pathSegments.isNotEmpty) {
return switch (u.pathSegments.first) {
'r' => 'relay',
'm' => "the host's file",
'j' => "Jellyfin through the server's login",
_ => 'server URL',
};
}
if (headers != null && headers.isNotEmpty) return 'Jellyfin, own sign-in';
return 'link, fetched here';
} }
/// A web link (not this server's): mpv's yt-dlp hook gets the app's /// A web link (not this server's): mpv's yt-dlp hook gets the app's
/// managed yt-dlp, downloaded first if needed; a media file name does /// managed yt-dlp, downloaded first if needed; a media file name does
/// not wait for that. /// not wait for that.
Future<void> _prepareYtDlp(String url) async { Future<void> _prepareYtDlp(String url) async {
final path = await _ytDlpPathFor(url);
if (path != null) await playback.useYtDlp(path);
}
/// The yt-dlp mpv's hook needs for [url] (null: none, not a web link).
Future<String?> _ytDlpPathFor(String url) async {
final uri = Uri.tryParse(url); final uri = Uri.tryParse(url);
if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) return; if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) return null;
if (uri.host == _wsHost && uri.port == _wsPort) return; if (uri.host == _wsHost && uri.port == _wsPort) return null;
try { try {
final y = await ytDlp.ytDlp; final y = await ytDlp.ytDlp;
final ready = y.ensure(); final ready = y.ensure();
final path = hasMediaExtension(uri) return hasMediaExtension(uri)
? y.path ? y.path
: await ready.timeout(const Duration(seconds: 90), onTimeout: () => null); : await ready.timeout(const Duration(seconds: 90), onTimeout: () => null);
if (path != null) await playback.useYtDlp(path);
} catch (e) { } catch (e) {
debugPrint('yt-dlp not ready: $e'); debugPrint('yt-dlp not ready: $e');
return null;
} }
} }
/// The media of the latest `state` is open here (or has nothing to
/// open, could not, or waits for the server): its mediaId. Up next
/// preloads what comes next only then, so the two never compete, and a
/// preload a swap is about to take is never replaced first.
final settledMediaId = signal(-1);
/// Opens [url] in the hidden preload player ([PlaybackController.preload]);
/// [openUrl] for the same URL and headers then starts from it. True
/// once its first seconds are in.
Future<bool> preloadMedia(String url, {Map<String, String>? headers, bool fresh = false}) async {
if (!_playerReady) return false;
final path = await _ytDlpPathFor(url);
if (!_playerReady) return false;
return playback.preload(url, headers: headers, ytdlPath: path, fresh: fresh);
}
bool _relayWaitShown = false; bool _relayWaitShown = false;
/// The server-fetched media this viewer opens once it is live. /// The server-fetched media this viewer opens once it is live.
int _awaitLiveFor = -1; int _awaitLiveFor = -1;
/// A relay nobody feeds yet: the server's fetch before it is read, or a
/// member's before its channel is up while that member is still here
/// (once it left, the server's buffer is all there is: open that).
bool _waitForSource(StateBroadcast r, MediaRelay relay) =>
!relay.live && (relay.fetch || r.members.any((m) => m.id == relay.source));
/// Relayed through a member whose channel is down: say who everyone is /// Relayed through a member whose channel is down: say who everyone is
/// waiting for (the buffer still plays what it has). /// waiting for (the buffer still plays what it has).
void _relayStatus(StateBroadcast r) { void _relayStatus(StateBroadcast r) {
@@ -579,8 +725,9 @@ class SessionCoordinator {
jellyfinPlan.peek() is! OwnPlay; jellyfinPlan.peek() is! OwnPlay;
if (waiting) { if (waiting) {
final who = r.members.where((m) => m.id == relay.source).firstOrNull?.name; final who = r.members.where((m) => m.id == relay.source).firstOrNull?.name;
final gone = memberNames[relay.source] ?? 'the member who put this up';
health.value = HealthStatus(HealthSeverity.warn, health.value = HealthStatus(HealthSeverity.warn,
who == null ? 'the member who put this up left' : 'waiting for $who to pass the video on'); who == null ? '$gone left: playing what the server has' : 'waiting for $who to pass the video on');
_relayWaitShown = true; _relayWaitShown = true;
} else if (_relayWaitShown) { } else if (_relayWaitShown) {
_relayWaitShown = false; _relayWaitShown = false;
@@ -590,13 +737,62 @@ class SessionCoordinator {
/// This viewer's `/r/` URL; with tracks, a pieces track's playlist, or /// This viewer's `/r/` URL; with tracks, a pieces track's playlist, or
/// an mpv EDL of every track when there are several (a page's separate /// an mpv EDL of every track when there are several (a page's separate
/// video and audio). /// video and audio). A relay started in Up next ([queueId]) keeps its
String _relayMediaUrlFor(String name, int mediaId, [List<RelayTrackSpec> tracks = const []]) => /// queue id in the URL, so the preload of its warm relay matches.
relayViewerUrl('http://$_wsHost:$_wsPort/r/default/${sync.token}/$mediaId', name, tracks: tracks); String _relayMediaUrlFor(String name, int mediaId,
[List<RelayTrackSpec> tracks = const [], String queueId = '']) =>
relayViewerUrl('http://$_wsHost:$_wsPort/r/default/${sync.token}/${queueId.isEmpty ? mediaId : queueId}', name,
tracks: tracks);
/// This viewer's URL of the warm relay of Up next's first [item]
/// (`queue[0].media.relay`, see ARCHITECTURE "Getting ready"): the same
/// URL the media has once the item goes up, so the preload is promoted.
String warmRelayUrl(QueueItem item, MediaRelay relay) =>
_relayMediaUrlFor(item.media.name.isEmpty ? item.title : item.media.name, -1, relay.tracks, item.id);
// This member feeds the warm relay of Up next item `queueId` (its
// link, file or Jellyfin stream) before the item goes up; the swap
// that puts it up takes the source over.
({String queueId, RelaySource src})? _warmSource;
/// Feeds the warm relay of Up next item [queueId] from here with
/// [media]; one already feeding it is kept. The server pulls only while
/// someone preloads it, and closes the channel once the item is no
/// longer first (the source then stops).
void feedWarm(String queueId, RelayMedia media) {
if (_warmSource?.queueId == queueId) {
unawaited(media.close());
return;
}
stopWarmSource();
final src = RelaySource(
Uri.parse('ws://$_wsHost:$_wsPort/r/default/${sync.token}/source?q=${Uri.encodeQueryComponent(queueId)}'),
media);
_warmSource = (queueId: queueId, src: src);
unawaited(() async {
try {
await src.start();
} catch (e) {
debugPrint('warm relay source of $queueId failed: $e');
if (identical(_warmSource?.src, src)) _warmSource = null;
return;
}
await src.done;
if (identical(_warmSource?.src, src)) _warmSource = null;
}());
}
void stopWarmSource() {
final w = _warmSource;
_warmSource = null;
if (w != null) unawaited(w.src.close());
}
/// Serves [media] for media [mediaId] on this member's relay channel. /// Serves [media] for media [mediaId] on this member's relay channel.
/// The server only pulls while someone watches through it. /// The server only pulls while someone watches through it. When the
void _startRelaySource(int mediaId, RelayMedia media, {bool announce = false}) { /// media came from Up next item [queueId] whose warm relay this member
/// feeds, that source carries on instead.
void _startRelaySource(int mediaId, RelayMedia media, {bool announce = false, String queueId = ''}) {
// Back after a reconnect on the same media: it is still serving (and // Back after a reconnect on the same media: it is still serving (and
// redials by itself). // redials by itself).
if (_relaySource != null && _relaySourceMediaId == mediaId) { if (_relaySource != null && _relaySourceMediaId == mediaId) {
@@ -604,6 +800,24 @@ class SessionCoordinator {
return; return;
} }
_stopRelaySource(); _stopRelaySource();
final warm = _warmSource;
if (queueId.isNotEmpty && warm != null && warm.queueId == queueId) {
_warmSource = null;
unawaited(media.close());
_relaySource = warm.src;
_relaySourceMediaId = mediaId;
relaying.value = true;
if (announce) {
_pushToast('Passing the video on through the server. Keep the app open while it plays.');
}
unawaited(warm.src.done.then((_) {
if (identical(_relaySource, warm.src)) {
_relaySource = null;
relaying.value = false;
}
}));
return;
}
final src = RelaySource( final src = RelaySource(
Uri.parse('ws://$_wsHost:$_wsPort/r/default/${sync.token}/source'), media); Uri.parse('ws://$_wsHost:$_wsPort/r/default/${sync.token}/source'), media);
_relaySource = src; _relaySource = src;
@@ -654,7 +868,9 @@ class SessionCoordinator {
void _onState(StateBroadcast r) { void _onState(StateBroadcast r) {
_lastStateAt = DateTime.now().toUtc(); _lastStateAt = DateTime.now().toUtc();
serverState.value = r; serverState.value = r;
if (r.transport == 'playing') switchTrace.mark(r.mediaId, SwitchTrace.playing);
_toastMemberErrors(r); _toastMemberErrors(r);
_warnSlowSource(r);
_checkPlayerError(r.mediaId); _checkPlayerError(r.mediaId);
_maybeSendArt(r); _maybeSendArt(r);
_queuedState = r; _queuedState = r;
@@ -690,6 +906,7 @@ class SessionCoordinator {
_lastEventId = r.eventId; _lastEventId = r.eventId;
if (r.mediaId != _mediaId) { if (r.mediaId != _mediaId) {
_mediaId = r.mediaId; _mediaId = r.mediaId;
switchTrace.start(r.mediaId);
final ref = refOfStateMedia(r.media); final ref = refOfStateMedia(r.media);
// Back after a dropped connection on the same item played with our // Back after a dropped connection on the same item played with our
// own account: nothing to resolve or reopen. // own account: nothing to resolve or reopen.
@@ -713,28 +930,37 @@ class SessionCoordinator {
_seekGate.reset(); _seekGate.reset();
_clearPendingSeek(); _clearPendingSeek();
_leaveJellyfinItem(); _leaveJellyfinItem();
await _leavePreviousMedia(r.mediaId);
final name = (r.media?['name'] as String?) ?? ''; final name = (r.media?['name'] as String?) ?? '';
if (ref != null) { if (ref != null) {
await _playJellyfin(ref, r, await _playJellyfin(ref, r,
origin: origin is _JellyfinOrigin ? origin : null, origin: origin is _JellyfinOrigin ? origin : null,
memberRelayUrl: relay != null && !mine ? _relayMediaUrlFor(name, r.mediaId) : null); memberRelayUrl:
relay != null && !mine ? _relayMediaUrlFor(name, r.mediaId, const [], relay.queueId) : null);
} else if (origin is _FileOrigin) { } else if (origin is _FileOrigin) {
// The source first: viewers wait on it, not on this player.
_startRelaySource(r.mediaId, RelayMedia.single(FileRelayBytes(File(origin.path))),
announce: true, queueId: relay?.queueId ?? '');
await _openMedia(origin.path); await _openMedia(origin.path);
_startRelaySource(r.mediaId, RelayMedia.single(FileRelayBytes(File(origin.path))), announce: true);
} else if (origin is _LinkOrigin) { } else if (origin is _LinkOrigin) {
await _openMedia(origin.url);
_startRelaySource( _startRelaySource(
r.mediaId, LinkRelayMedia(Uri.parse(origin.url), ytDlp: origin.ytDlp, probe: origin.probe), r.mediaId, LinkRelayMedia(Uri.parse(origin.url), ytDlp: origin.ytDlp, probe: origin.probe),
announce: true); announce: true, queueId: relay?.queueId ?? '');
await _openMedia(origin.url);
} else if (relay != null && !mine) { } else if (relay != null && !mine) {
if (relay.fetch && !relay.live) { if (_waitForSource(r, relay)) {
// The server is still getting it (yt-dlp takes seconds; mpv // The server is still getting it (yt-dlp takes seconds), or the
// gives up on a silent request after 5): open once it is live. // member passing it on has not dialled in yet (its app opens the
// link first): mpv gives up on a silent request after 5 s, so
// open once it is live.
_openUrl = null; _openUrl = null;
_awaitLiveFor = r.mediaId; _awaitLiveFor = r.mediaId;
health.value = const HealthStatus(HealthSeverity.info, 'the server is getting the video…'); final who = r.members.where((m) => m.id == relay.source).firstOrNull?.name;
_log.info('media ${r.mediaId}: waiting for ${relay.fetch ? 'the server' : who} before opening the relay');
health.value = HealthStatus(HealthSeverity.info,
relay.fetch ? 'the server is getting the video…' : 'waiting for $who to pass the video on…');
} else { } else {
await _openMedia(_relayMediaUrlFor(name, r.mediaId, relay.tracks)); await _openMedia(_relayMediaUrlFor(name, r.mediaId, relay.tracks, relay.queueId));
} }
} else { } else {
var url = stateMediaUrl(r); var url = stateMediaUrl(r);
@@ -751,10 +977,12 @@ class SessionCoordinator {
} }
_seekGate.reset(); _seekGate.reset();
_clearPendingSeek(); _clearPendingSeek();
} else if (_awaitLiveFor == r.mediaId && (stateMediaRelay(r)?.live ?? false)) { if (_mediaId == r.mediaId) settledMediaId.value = r.mediaId;
} else if (_awaitLiveFor == r.mediaId && stateMediaRelay(r) != null && !_waitForSource(r, stateMediaRelay(r)!)) {
_awaitLiveFor = -1; _awaitLiveFor = -1;
final relay = stateMediaRelay(r)!;
await _openMedia( await _openMedia(
_relayMediaUrlFor((r.media?['name'] as String?) ?? '', r.mediaId, stateMediaRelay(r)!.tracks)); _relayMediaUrlFor((r.media?['name'] as String?) ?? '', r.mediaId, relay.tracks, relay.queueId));
} }
_jellyfinTransport(r); _jellyfinTransport(r);
_relayStatus(r); _relayStatus(r);
@@ -837,6 +1065,7 @@ class SessionCoordinator {
starving.value = null; starving.value = null;
if (_mediaId == -1 && _openUrl == null) return; if (_mediaId == -1 && _openUrl == null) return;
_mediaId = -1; _mediaId = -1;
_playerMediaId = -1;
_openUrl = null; _openUrl = null;
_openHeaders = null; _openHeaders = null;
_seekGate.reset(); _seekGate.reset();
@@ -849,6 +1078,24 @@ class SessionCoordinator {
} catch (_) {} } catch (_) {}
} }
/// A new media [mediaId]: the previous one leaves the player at once, so
/// nobody sees or hears it while the new one resolves, is fetched by the
/// server or opens. A reconnect to the media the player holds keeps it.
Future<void> _leavePreviousMedia(int mediaId) async {
final held = _playerMediaId;
_playerMediaId = mediaId;
// Opened from the welcome, before any state: that was this media.
if (held == -1 && _openedFor == -1 && _openUrl != null) _openedFor = mediaId;
if (held == -1 || held == mediaId) return;
_openUrl = null;
_openHeaders = null;
diff.reset();
if (!_playerReady) return;
try {
await playback.stop();
} catch (_) {}
}
void _updateStarving(StateBroadcast r, double bufferedAhead) { void _updateStarving(StateBroadcast r, double bufferedAhead) {
if (r.transport == 'playing') { if (r.transport == 'playing') {
starving.value = null; starving.value = null;
@@ -904,6 +1151,10 @@ class SessionCoordinator {
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: f.actor) ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: f.actor)
]; ];
} }
// The server gave up fetching what everyone waits for.
if (f.action == 'fetch-fail' && _awaitLiveFor != -1 && _awaitLiveFor == serverState.peek()?.mediaId) {
health.value = const HealthStatus(HealthSeverity.error, "the server couldn't fetch this video");
}
final selfName = selfMember.peek()?.name; final selfName = selfMember.peek()?.name;
// Sat out by someone else: say who (the feed names members by name). // Sat out by someone else: say who (the feed names members by name).
if (f.action == 'out' && f.target != null && f.target == selfName && f.actor != selfName) { if (f.action == 'out' && f.target != null && f.target == selfName && f.actor != selfName) {
@@ -949,27 +1200,42 @@ class SessionCoordinator {
_ticker = Timer.periodic(const Duration(seconds: 1), (_) => _tick()); _ticker = Timer.periodic(const Duration(seconds: 1), (_) => _tick());
} }
Future<void> _tick() async { /// The 1 s `report` (also sent at once when a new media is open, so the
final now = DateTime.now().toUtc(); /// server's gate does not wait for the next tick).
toasts.value = void _sendReport(StateBroadcast r) {
toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList();
final r = serverState.peek();
if (sync.token.isNotEmpty) sync.sendPing();
if (sync.token.isNotEmpty && r != null && _playerReady) {
// Duration only once the open media is the server's current one. // Duration only once the open media is the server's current one.
// Sitting out: still a report (it keeps this member fresh), empty. // Sitting out: still a report (it keeps this member fresh), empty.
final known = !_lastOut && !_opening && _openUrl != null && _mediaId == r.mediaId; final known = !_lastOut && !_opening && _openUrl != null && _mediaId == r.mediaId;
// The buffer counts only while the player holds the server's current
// media: before a swap's media is open it is the previous one's (or
// none), and the start must wait for this member.
final holds = !_lastOut && !_opening && _openUrl != null && _playerMediaId == r.mediaId;
_checkPlayerError(r.mediaId); _checkPlayerError(r.mediaId);
final error = playerError.peek(); final error = playerError.peek();
final buffered = holds ? playback.bufferedSecsAhead : 0.0;
if (holds && buffered >= r.bufferNeed) switchTrace.mark(r.mediaId, SwitchTrace.buffered);
sync.sendReport( sync.sendReport(
position: _lastOut ? 0 : playback.positionSecs, position: _lastOut ? 0 : playback.positionSecs,
buffered: _lastOut ? 0 : playback.bufferedSecsAhead, buffered: buffered,
eventId: r.eventId, eventId: r.eventId,
duration: known ? playback.durationSecs : 0, duration: known ? playback.durationSecs : 0,
mediaId: known ? r.mediaId : 0, mediaId: known ? r.mediaId : 0,
jf: _jfAccessFor(r), jf: _jfAccessFor(r),
error: known && _playerErrorFor == r.mediaId ? error : '', error: known && _playerErrorFor == r.mediaId ? error : '',
); );
}
Future<void> _tick() async {
final now = DateTime.now().toUtc();
toasts.value =
toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList();
final r = serverState.peek();
if (sync.token.isNotEmpty) sync.sendPing();
diag
..share = AppSettings.instance.shareDiagnostics.peek()
..tick();
if (sync.token.isNotEmpty && r != null && _playerReady) {
_sendReport(r);
if (!_lastOut) { if (!_lastOut) {
_checkSeekLanded(r); _checkSeekLanded(r);
unawaited(_tickJellyfin(r)); unawaited(_tickJellyfin(r));
@@ -1240,12 +1506,23 @@ class SessionCoordinator {
sync.sendNudge(memberId); sync.sendNudge(memberId);
} }
void _swap(HelloMedia media) { /// [queueId]: the Up next item this puts up (the server takes it off
// Putting something up means watching it: back first. /// the queue); [art]: a picture to send as its art (a queued item's).
void _swap(HelloMedia media, {String? link, String? queueId, Uint8List? art}) {
// Putting something up means watching it: back first. Not for Up next
// coming up by itself: whoever puts it up may be sitting out.
final self = selfMember.peek(); final self = selfMember.peek();
if (self != null && self.out) sync.sendOut(self.id, false); if (self != null && self.out && queueId == null) sync.sendOut(self.id, false);
_pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0); _pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0, link: link ?? media.url, art: art);
sync.sendSwap(media); sync.sendSwap(media, queueId: queueId);
}
/// An Up next item put up as queued, for one this app cannot build
/// itself (a server-fetched link: the server has the URL); [art] goes up
/// as its art.
void swapQueued(HelloMedia media, String queueId, {Uint8List? art}) {
markInput();
_swap(media, queueId: queueId, art: art);
} }
void _maybeSendArt(StateBroadcast r) { void _maybeSendArt(StateBroadcast r) {
@@ -1253,24 +1530,45 @@ class SessionCoordinator {
if (pending == null || r.mediaId <= pending.after) return; if (pending == null || r.mediaId <= pending.after) return;
if (sync.memberId.isEmpty || r.media?['swapBy'] != sync.memberId) return; if (sync.memberId.isEmpty || r.media?['swapBy'] != sync.memberId) return;
_pendingArt = null; _pendingArt = null;
unawaited(_sendArt(r.mediaId, pending.media)); final link = pending.link;
_ownLink = link == null || link.isEmpty ? null : (r.mediaId, link);
unawaited(_sendArt(r.mediaId, pending.media, pending.link, given: pending.art));
} }
/// Fetches the art of [media] (this client's swap, now [mediaId]) and /// The link this app put up as [StateBroadcast.mediaId] (passed on or
/// sends it; quietly nothing on any failure. /// fetched by the server, so not in the state).
Future<void> _sendArt(int mediaId, HelloMedia media) async { (int, String)? _ownLink;
/// The link of what plays, as this app may hand it out (Copy link, Open
/// in browser): one this app put up, else one everyone opens (it is in
/// the state); null otherwise (another member's passed-on link, a
/// server fetch by someone else, Jellyfin, files).
String? currentLink() {
final r = serverState.peek();
if (r == null || r.media == null) return null;
final own = _ownLink;
if (own != null && own.$1 == r.mediaId) return own.$2;
final url = stateMediaUrl(r);
return url != null && (url.startsWith('http://') || url.startsWith('https://')) ? url : null;
}
/// Fetches the art of [media] (this client's swap, now [mediaId], from
/// web [link] if any) and sends it; quietly nothing on any failure.
Future<void> _sendArt(int mediaId, HelloMedia media, String? link, {Uint8List? given}) async {
try { try {
final ref = media.jellyfin; final ref = media.jellyfin;
final client = ref == null ? null : jellyfin.clientFor(ref.serverId); final client = ref == null ? null : jellyfin.clientFor(ref.serverId);
final ArtBytes? bytes; final ArtBytes? bytes;
if (ref != null && client != null) { if (given != null) {
bytes = (poster: given, banner: given);
} else if (ref != null && client != null) {
bytes = await jellyfinArt(client, ref.itemId); bytes = await jellyfinArt(client, ref.itemId);
} else if (ref == null && (media.url ?? '').isNotEmpty) { } else if (ref == null && (link ?? '').isNotEmpty) {
// A web link is not a show: never guess. A page's thumbnail is // A web link is not a show: never guess. A page's thumbnail is
// its banner. // its cover and banner both.
final thumb = (await _probeFor(media.url!))?.info?.thumbnail; final thumb = (await _probeFor(link!))?.info?.thumbnail;
final uri = thumb == null ? null : Uri.tryParse(thumb); final uri = thumb == null ? null : Uri.tryParse(thumb);
bytes = uri == null ? null : (banner: await fetchImage(uri, const {}, RelayConstants.maxBannerBytes), poster: null); bytes = uri == null ? null : await thumbnailArt(uri);
} else { } else {
final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1; final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1;
bytes = await aniListArt(_aniList ??= AniListClient(), name); bytes = await aniListArt(_aniList ??= AniListClient(), name);
@@ -1293,13 +1591,14 @@ class SessionCoordinator {
/// probe's [LinkProbe.problem], or unreachable) is refused with the /// probe's [LinkProbe.problem], or unreachable) is refused with the
/// reason in plain words unless [anyway]. Returns an error text, or null /// reason in plain words unless [anyway]. Returns an error text, or null
/// when sent. /// when sent.
Future<String?> swapLink(String url, {LinkRelay relay = LinkRelay.direct, bool anyway = false}) async { Future<String?> swapLink(String url,
{LinkRelay relay = LinkRelay.direct, bool anyway = false, String? queueId}) async {
markInput(); markInput();
_lastPick = LinkMedia(url, relay: relay, anyway: anyway); _lastPick = LinkMedia(url, relay: relay, anyway: anyway);
final media = await mediaForLink(jellyfin, url); final media = await mediaForLink(jellyfin, url);
if (media.jellyfin != null) { if (media.jellyfin != null) {
_pendingOrigin = _JellyfinOrigin(url); _pendingOrigin = _JellyfinOrigin(url);
_swap(media.copyWith(relay: const MediaRelay())); _swap(media.copyWith(relay: const MediaRelay()), queueId: queueId);
return null; return null;
} }
LinkProbe? probe; LinkProbe? probe;
@@ -1315,7 +1614,7 @@ class SessionCoordinator {
final uri = Uri.tryParse(url) ?? Uri(); final uri = Uri.tryParse(url) ?? Uri();
final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host); final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host);
if (relay == LinkRelay.direct) { if (relay == LinkRelay.direct) {
_swap(HelloMedia(name: name, size: 0, duration: 0, url: url)); _swap(HelloMedia(name: name, size: 0, duration: 0, url: url), queueId: queueId);
return null; return null;
} }
if (relay == LinkRelay.me) { if (relay == LinkRelay.me) {
@@ -1325,11 +1624,14 @@ class SessionCoordinator {
final relayProblem = probe.relayProblem; final relayProblem = probe.relayProblem;
if (relayProblem != null) return relayProblem; if (relayProblem != null) return relayProblem;
final y = await ytDlp.ytDlp; final y = await ytDlp.ytDlp;
final check = LinkRelayMedia(uri, ytDlp: y, probe: probe); // Up next's item this app already feeds (its warm relay): read.
final warm = _warmSource;
final fed = queueId != null && warm != null && warm.queueId == queueId && warm.src.tracks.isNotEmpty;
final check = fed ? null : LinkRelayMedia(uri, ytDlp: y, probe: probe);
var size = 0; var size = 0;
final List<RelayTrackSpec> specs; final List<RelayTrackSpec> specs;
try { try {
final tracks = await check.open().timeout(const Duration(seconds: 15)); final tracks = fed ? warm.src.tracks : await check!.open().timeout(const Duration(seconds: 15));
for (final t in tracks) { for (final t in tracks) {
// A playlist is read (DRM and live refused) but has no size. // A playlist is read (DRM and live refused) but has no size.
final pieces = t.pieces; final pieces = t.pieces;
@@ -1343,11 +1645,11 @@ class SessionCoordinator {
} catch (e) { } catch (e) {
return 'cannot pass this link on: $e'; return 'cannot pass this link on: $e';
} finally { } finally {
await check.close(); await check?.close();
} }
_pendingOrigin = _LinkOrigin(url, probe, y); _pendingOrigin = _LinkOrigin(url, probe, y);
_swap(HelloMedia( _swap(HelloMedia(
name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs))); name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs)), link: url, queueId: queueId);
return null; return null;
} }
// The server reads it with its own yt-dlp; only a page this app knows // The server reads it with its own yt-dlp; only a page this app knows
@@ -1357,7 +1659,8 @@ class SessionCoordinator {
return probe.relayProblem; return probe.relayProblem;
} }
_swap(HelloMedia( _swap(HelloMedia(
name: name, size: max(0, probe?.size ?? 0), duration: 0, url: url, relay: const MediaRelay(fetch: true))); name: name, size: max(0, probe?.size ?? 0), duration: 0, url: url, relay: const MediaRelay(fetch: true)),
queueId: queueId);
return null; return null;
} }
@@ -1371,43 +1674,44 @@ class SessionCoordinator {
/// A Jellyfin pick (browser, next / previous episode) goes up as a ref, /// A Jellyfin pick (browser, next / previous episode) goes up as a ref,
/// relayed by this member for viewers without an account. /// relayed by this member for viewers without an account.
void swapJellyfin(JellyfinMedia media) { void swapJellyfin(JellyfinMedia media, {String? queueId}) {
markInput(); markInput();
_lastPick = media; _lastPick = media;
_pendingOrigin = const _JellyfinOrigin(null); _pendingOrigin = const _JellyfinOrigin(null);
_swap(mediaForPick(media).copyWith(relay: const MediaRelay())); _swap(mediaForPick(media).copyWith(relay: const MediaRelay()), queueId: queueId);
} }
/// A file on this computer. The host's app serves it itself; anyone /// A file on this computer. The host's app serves it itself; anyone
/// else's goes through the server's relay. Returns an error text, or null /// else's goes through the server's relay. Returns an error text, or null
/// when sent. /// when sent.
Future<String?> swapFile(String path) async { Future<String?> swapFile(String path, {String? queueId}) async {
markInput(); markInput();
_lastPick = FileMedia(path); _lastPick = FileMedia(path);
final host = hostFile; final host = hostFile;
if (host != null) { if (host != null) {
final file = await host(path); final file = await host(path);
if (file == null) return 'file not found — check the path'; if (file == null) return 'file not found — check the path';
_swap(HelloMedia(name: file.name, size: file.size, duration: 0)); _swap(HelloMedia(name: file.name, size: file.size, duration: 0), queueId: queueId);
return null; return null;
} }
final file = File(path); final file = File(path);
if (!await file.exists()) return 'file not found — check the path'; if (!await file.exists()) return 'file not found — check the path';
_pendingOrigin = _FileOrigin(path); _pendingOrigin = _FileOrigin(path);
_swap(HelloMedia( _swap(HelloMedia(
name: file.uri.pathSegments.last, size: await file.length(), duration: 0, relay: const MediaRelay())); name: file.uri.pathSegments.last, size: await file.length(), duration: 0, relay: const MediaRelay()),
queueId: queueId);
return null; return null;
} }
/// Any picker selection. /// Any picker selection; [queueId] when it is an Up next item.
Future<String?> swapPicked(PickedMedia media) async { Future<String?> swapPicked(PickedMedia media, {String? queueId}) async {
switch (media) { switch (media) {
case LinkMedia(:final url, :final relay, :final anyway): case LinkMedia(:final url, :final relay, :final anyway):
return swapLink(url, relay: relay, anyway: anyway); return swapLink(url, relay: relay, anyway: anyway, queueId: queueId);
case JellyfinMedia(): case JellyfinMedia():
swapJellyfin(media); swapJellyfin(media, queueId: queueId);
case FileMedia(:final path): case FileMedia(:final path):
return swapFile(path); return swapFile(path, queueId: queueId);
} }
return null; return null;
} }
@@ -1511,6 +1815,9 @@ class SessionCoordinator {
); );
} }
/// This member's `/j/` URL for [ref] (the server's Jellyfin login).
String? jellyfinRelayUrl(JellyfinRef ref) => _relayUrlFor(ref);
String? _relayUrlFor(JellyfinRef ref) { String? _relayUrlFor(JellyfinRef ref) {
if (sync.token.isEmpty) return null; if (sync.token.isEmpty) return null;
final welcome = sync.relayUrl; final welcome = sync.relayUrl;
@@ -1522,11 +1829,18 @@ class SessionCoordinator {
return relayUrlFor(_wsHost, _wsPort, sync.token, ref.itemId); return relayUrlFor(_wsHost, _wsPort, sync.token, ref.itemId);
} }
/// `report.jf`: every linked account that works (so a queued Jellyfin
/// item finds someone to put it up while other media plays), then how
/// the current item plays here.
Map<String, bool>? _jfAccessFor(StateBroadcast r) { Map<String, bool>? _jfAccessFor(StateBroadcast r) {
final linked = {
for (final a in jellyfin.accounts)
if (jellyfin.statusOf(a.serverId) != JellyfinAccountStatus.expired) a.serverId: true,
};
final ref = _jfRef; final ref = _jfRef;
final access = jellyfinPlan.peek()?.jfAccess; final access = jellyfinPlan.peek()?.jfAccess;
if (ref == null || access == null || r.mediaId != _jfMediaId) return null; if (ref == null || access == null || r.mediaId != _jfMediaId) return linked.isEmpty ? null : linked;
return {ref.serverId: access}; return {...linked, ref.serverId: access};
} }
/// Resolves [ref] for this viewer and opens it: own account (direct /// Resolves [ref] for this viewer and opens it: own account (direct
@@ -1549,17 +1863,20 @@ class SessionCoordinator {
} }
JellyfinPlan plan; JellyfinPlan plan;
try { try {
plan = await resolveJellyfinRef(jellyfin, ref, plan = _takeWarmJellyfin(ref) ??
await resolveJellyfinRef(jellyfin, ref,
relay: r.relay, relayUrl: _relayUrlFor(ref), memberRelayUrl: memberRelayUrl); relay: r.relay, relayUrl: _relayUrlFor(ref), memberRelayUrl: memberRelayUrl);
} finally { } finally {
if (gen == _jfGen) _opening = false; if (gen == _jfGen) _opening = false;
} }
if (gen != _jfGen) return; if (gen != _jfGen) return;
switchTrace.mark(r.mediaId, SwitchTrace.resolved);
// This member put it up: serve it to viewers without an account, with // This member put it up: serve it to viewers without an account, with
// its own sign-in, else the pasted link (played directly here too). // its own sign-in, else the pasted link (played directly here too).
if (origin != null) { if (origin != null) {
if (plan is OwnPlay) { if (plan is OwnPlay) {
_startRelaySource(r.mediaId, RelayMedia.single(HttpRelayBytes(Uri.parse(plan.url), headers: plan.headers))); _startRelaySource(r.mediaId, RelayMedia.single(HttpRelayBytes(Uri.parse(plan.url), headers: plan.headers)),
queueId: stateMediaRelay(r)?.queueId ?? '');
} else if (origin.link != null) { } else if (origin.link != null) {
plan = RelayPlay(origin.link!); plan = RelayPlay(origin.link!);
_startRelaySource(r.mediaId, RelayMedia.single(HttpRelayBytes(Uri.parse(origin.link!)))); _startRelaySource(r.mediaId, RelayMedia.single(HttpRelayBytes(Uri.parse(origin.link!))));
@@ -1585,23 +1902,35 @@ class SessionCoordinator {
case OwnPlay(:final url, :final headers): case OwnPlay(:final url, :final headers):
jellyfinBlocked.value = null; jellyfinBlocked.value = null;
if (!await _openMedia(url, headers: headers) || gen != _jfGen) return; if (!await _openMedia(url, headers: headers) || gen != _jfGen) return;
// Played before and the resume point, fresh (a warmed plan's item
// is older): the watched rule never unmarks a played item.
final known = plan.item?.userData;
final userDataF = plan.client
.userData(ref.itemId)
.timeout(const Duration(seconds: 5))
.then<JellyfinUserData?>((d) => d, onError: (_) => known);
unawaited(_findEpisodes(plan, gen));
unawaited(_loadSegments(plan, ref, gen));
// Playing carries the tracks: wait (bounded) until the file is in.
await playback.waitForTracks();
if (gen != _jfGen) return;
final before = await userDataF;
if (gen != _jfGen) return;
final reporter = JellyfinProgressReporter( final reporter = JellyfinProgressReporter(
client: plan.client, client: plan.client,
itemId: ref.itemId, itemId: ref.itemId,
source: plan.source, source: plan.source,
playSessionId: plan.info.playSessionId, playSessionId: plan.info.playSessionId,
playedBefore: before?.played ?? false,
resumeTicksBefore: before?.playbackPositionTicks ?? 0,
onUnauthorized: () => jellyfin.markExpired(ref.serverId), onUnauthorized: () => jellyfin.markExpired(ref.serverId),
onStopped: () => jellyfin.watchedChanged(ref.serverId),
onError: (e) { onError: (e) {
if (_jfToastShown) return; if (_jfToastShown) return;
_jfToastShown = true; _jfToastShown = true;
_pushToast('Jellyfin progress not saved: ${e is JellyfinException && e.isUnauthorized ? 'sign-in expired' : 'server unreachable'}'); _pushToast('Jellyfin progress not saved: ${e is JellyfinException && e.isUnauthorized ? 'sign-in expired' : 'server unreachable'}');
}, },
); );
unawaited(_findEpisodes(plan, gen));
unawaited(_loadSegments(plan, ref, gen));
// Playing carries the tracks: wait (bounded) until the file is in.
await playback.waitForTracks();
if (gen != _jfGen) return;
final snap = await _snapshot(); final snap = await _snapshot();
if (gen != _jfGen || snap == null) return; if (gen != _jfGen || snap == null) return;
// Installed once started: the per-state restart (after the end) // Installed once started: the per-state restart (after the end)
@@ -1611,6 +1940,18 @@ class SessionCoordinator {
await _applyTrackChoice(plan, gen); await _applyTrackChoice(plan, gen);
} }
/// [warmJellyfin] when it is [ref], still fresh and its account
/// unchanged.
OwnPlay? _takeWarmJellyfin(JellyfinRef ref) {
final w = warmJellyfin;
if (w == null || !sameJellyfinItem(w.ref, ref)) return null;
warmJellyfin = null;
final fresh = DateTime.now().isBefore(w.until) &&
jellyfin.account(ref.serverId)?.token == w.plan.account.token &&
jellyfin.statusOf(ref.serverId) != JellyfinAccountStatus.expired;
return fresh ? w.plan : null;
}
Future<void> _findEpisodes(OwnPlay plan, int gen) async { Future<void> _findEpisodes(OwnPlay plan, int gen) async {
final around = await findAdjacentEpisodes(plan); final around = await findAdjacentEpisodes(plan);
if (gen != _jfGen) return; if (gen != _jfGen) return;
@@ -1663,7 +2004,10 @@ class SessionCoordinator {
final snap = await _snapshot(); final snap = await _snapshot();
if (snap == null) return; if (snap == null) return;
if (reporter != null && identical(reporter, _reporter) && r.transport != 'finished') { if (reporter != null && identical(reporter, _reporter) && r.transport != 'finished') {
reporter.update(snap); final ending = playback.ending.peek()?.start;
reporter
..timeline(duration: playback.durationSecs, endingStart: ending == null ? null : ending.inMilliseconds / 1000)
..update(snap);
} }
final next = nextEpisode.peek(); final next = nextEpisode.peek();
final duration = playback.durationSecs; final duration = playback.durationSecs;
@@ -1701,7 +2045,9 @@ class SessionCoordinator {
} }
nextPromptVisible.value = false; nextPromptVisible.value = false;
final next = nextEpisode.peek(); final next = nextEpisode.peek();
// Up next goes first; "Clear" in it hides the next episode here.
if (next == null || if (next == null ||
!queue.continuationShown(r.mediaId) ||
!AppSettings.instance.autoAdvance.peek() || !AppSettings.instance.autoAdvance.peek() ||
_advanceDoneFor == r.mediaId || _advanceDoneFor == r.mediaId ||
autoAdvance.peek()?.mediaId == r.mediaId) { autoAdvance.peek()?.mediaId == r.mediaId) {
@@ -1913,13 +2259,17 @@ class SessionCoordinator {
reconnecting.value = false; reconnecting.value = false;
await _stopJellyfin(); await _stopJellyfin();
_stopRelaySource(); _stopRelaySource();
stopWarmSource();
_pendingOrigin = null; _pendingOrigin = null;
_pendingArt = null; _pendingArt = null;
_ownLink = null;
_activeOrigin = null; _activeOrigin = null;
_playerReady = false; _playerReady = false;
try { try {
await playback.dispose(); await playback.dispose();
} catch (_) {} } catch (_) {}
// The last lines go before the token does.
await diag.stop();
try { try {
await sync.disconnect(); await sync.disconnect();
} catch (_) {} } catch (_) {}
@@ -1941,6 +2291,7 @@ class SessionCoordinator {
await _nudgeSub?.cancel(); await _nudgeSub?.cancel();
await _errSub?.cancel(); await _errSub?.cancel();
await _statusSub?.cancel(); await _statusSub?.cancel();
_bufferSub?.call();
_aniList?.close(); _aniList?.close();
await sync.dispose(); await sync.dispose();
await playback.dispose(); await playback.dispose();
@@ -0,0 +1,55 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
/// Timings of one media change on this client, from the `state` naming
/// the new media to the server playing it (see ARCHITECTURE.md "Getting
/// ready"). Printed when [enabled] (env `SYNC_RELAY_TRACE_SWITCH=1`);
/// marks are always kept for the last few media, tests read them.
class SwitchTrace {
/// Phases, in the order they usually happen.
static const state = 'state';
static const resolved = 'resolved';
static const open = 'open';
/// A fresh player is up for it (no preload held it).
static const player = 'player';
static const opened = 'opened';
static const preloaded = 'preloaded';
static const buffered = 'buffered';
static const playing = 'playing';
static bool enabled = (Platform.environment['SYNC_RELAY_TRACE_SWITCH'] ?? '').isNotEmpty;
final String who;
SwitchTrace([this.who = '']);
final Map<int, Map<String, int>> _marks = {};
final Map<int, Stopwatch> _clocks = {};
/// Milliseconds from [state] to each phase of media [mediaId].
Map<String, int> of(int mediaId) => Map.unmodifiable(_marks[mediaId] ?? const {});
/// Starts the clock for [mediaId] (its first `state`).
void start(int mediaId) {
if (_clocks.containsKey(mediaId)) return;
_clocks[mediaId] = Stopwatch()..start();
_marks[mediaId] = {state: 0};
while (_clocks.length > 4) {
final oldest = _clocks.keys.first;
_clocks.remove(oldest);
_marks.remove(oldest);
}
}
/// Records [phase] once for [mediaId]; [playing] ends the trace.
void mark(int mediaId, String phase) {
final clock = _clocks[mediaId];
final marks = _marks[mediaId];
if (clock == null || marks == null || marks.containsKey(phase)) return;
marks[phase] = clock.elapsedMilliseconds;
if (phase == playing && enabled) {
debugPrint('switch${who.isEmpty ? '' : ' $who'} m$mediaId: '
'${[for (final e in marks.entries) '${e.key} +${e.value} ms'].join(', ')}');
}
}
}
+34 -62
View File
@@ -1,88 +1,60 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:media_kit/media_kit.dart';
import 'package:path_provider/path_provider.dart';
import '../platform/audio/sound_engine.dart';
import 'sfx.dart'; import 'sfx.dart';
import 'sound_bank.dart'; import 'sound_bank.dart';
import 'synth.dart'; import 'synth.dart';
/// Plays [SoundSpec]s: each is rendered once to a WAV in the temp dir and /// Plays [SoundSpec]s on a [SoundEngine]: each is rendered once (off the
/// played on a small pool of audio-only mpv players (so two sounds can /// UI isolate) to WAV bytes and loaded into memory; sounds overlap.
/// overlap). [volume] (0..1) is read per play and applied on a square /// [volume] (0..1) is read per play and applied as v^6: the square curve
/// curve (ears are log); [enabled] filters cues. /// (ears are log) the app always used, times mpv's own cubic volume it
/// used to play through, so the slider sounds as it did; [enabled]
/// filters cues.
class SynthSfx implements SfxService { class SynthSfx implements SfxService {
final double Function() volume; final double Function() volume;
final bool Function(SfxKind kind) enabled; final bool Function(SfxKind kind) enabled;
/// Which sound set each sound plays from. /// Which sound set each sound plays from.
final SoundPreset Function(SfxKind kind) presetFor; final SoundPreset Function(SfxKind kind) presetFor;
SynthSfx({required this.volume, bool Function(SfxKind kind)? enabled, SoundPreset Function(SfxKind kind)? presetFor}) final SoundEngine engine;
SynthSfx(
{required this.volume,
bool Function(SfxKind kind)? enabled,
SoundPreset Function(SfxKind kind)? presetFor,
SoundEngine? engine})
: enabled = enabled ?? ((_) => true), : enabled = enabled ?? ((_) => true),
presetFor = presetFor ?? ((_) => defaultPreset); presetFor = presetFor ?? ((_) => defaultPreset),
engine = engine ?? SoundEngine();
static const _poolSize = 3;
final List<Player> _pool = [];
var _next = 0;
final Map<String, Future<String>> _files = {};
Directory? _dir;
@override @override
Future<void> play(SfxCue cue) async { Future<void> play(SfxCue cue) {
if (!enabled(cue.kind)) return; if (!enabled(cue.kind)) return Future.value();
final preset = presetFor(cue.kind); return _play(cue.kind, cue.readyIndex, presetFor(cue.kind));
await playSpec('${preset.name}-${cue.kind.name}-${cue.readyIndex}',
soundFor(cue.kind, readyIndex: cue.readyIndex, preset: preset));
} }
/// Plays [kind] even when it is switched off (Settings' preview), always /// Plays [kind] even when it is switched off (Settings' preview), always
/// freshly rendered: an edited spec plays after a hot reload. /// freshly rendered: an edited spec plays after a hot reload.
Future<void> preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) { Future<void> preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) =>
final p = preset ?? presetFor(kind); _play(kind, readyIndex, preset ?? presetFor(kind), fresh: true);
final key = '${p.name}-${kind.name}-$readyIndex';
_files.remove(key);
return playSpec(key, soundFor(kind, readyIndex: readyIndex, preset: p));
}
/// Plays [spec], cached under [key] (same key = same sound). /// One slot per sound and ready step: a new preset replaces it.
Future<void> playSpec(String key, SoundSpec spec) async { Future<void> _play(SfxKind kind, int readyIndex, SoundPreset preset, {bool fresh = false}) {
final v = volume(); final v = volume();
if (v <= 0) return; return engine.play('${kind.name}-$readyIndex', '${preset.name}-${kind.name}-$readyIndex',
try { () => renderWav(soundFor(kind, readyIndex: readyIndex, preset: preset)),
final path = await (_files[key] ??= _write(key, spec)); volume: math.pow(v, 6).toDouble(), fresh: fresh);
final player = await _player();
await player.setVolume(100 * v * v);
await player.open(Media(path));
} catch (e) {
_files.remove(key);
debugPrint('sfx $key: $e');
}
} }
Future<String> _write(String key, SoundSpec spec) async { /// Opens the audio device ahead of the first sound.
_dir ??= await Directory('${(await getTemporaryDirectory()).path}/watch_together_sfx').create(recursive: true); Future<void> warmUp() => engine.warmUp();
final f = File('${_dir!.path}/$key.wav');
await f.writeAsBytes(wav(render(spec)));
return f.path;
}
Future<Player> _player() async { Future<void> dispose() => engine.dispose();
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();
}
} }
/// [spec] as WAV bytes, rendered on a worker isolate.
Future<Uint8List> renderWav(SoundSpec spec) => compute(_renderWav, spec, debugLabel: 'sfx render');
Uint8List _renderWav(SoundSpec spec) => wav(render(spec));
+2 -45
View File
@@ -8,50 +8,6 @@ import 'shader_presets.dart';
/// A shader pick from the menu; a null quality keeps the saved one. /// A shader pick from the menu; a null quality keeps the saved one.
typedef ShaderPick = (ShaderPreset, ShaderQuality?); typedef ShaderPick = (ShaderPreset, ShaderQuality?);
/// Compact shader button for the video controls bar (normal and
/// fullscreen). Opens a small menu: Off, the six Anime4K modes with
/// HQ / Fast, Custom. The menu is a route, so it outlives the controls
/// bar hiding under it; [onPick] runs on the caller's side.
class ShaderControlButton extends StatelessWidget {
final FocusNode focusNode;
final ReadonlySignal<RenderMode> renderMode;
final void Function(ShaderPreset preset, ShaderQuality? quality) onPick;
final ValueChanged<bool>? onMenuOpen;
const ShaderControlButton({
super.key,
required this.focusNode,
required this.renderMode,
required this.onPick,
this.onMenuOpen,
});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(builder: (context) {
final preset = s.shaderPreset.value;
final quality = s.shaderQuality.value;
final short = shaderShortLabel(preset, quality) ?? 'Shaders';
return Tooltip(
message: 'Video shaders (Ctrl+1..6, Ctrl+0 off)',
child: TextButton.icon(
focusNode: focusNode,
style: TextButton.styleFrom(
foregroundColor:
preset == ShaderPreset.off ? Colors.white : Colors.amberAccent,
padding: const EdgeInsets.symmetric(horizontal: 8),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () => showShaderMenu(context,
renderMode: renderMode, onPick: onPick, onMenuOpen: onMenuOpen),
icon: const Icon(Icons.auto_awesome, size: 16),
label: Text(short, style: const TextStyle(fontSize: 12)),
),
);
});
}
}
/// "A HQ", "C+A Fast", "Custom"; null when shaders are off. /// "A HQ", "C+A Fast", "Custom"; null when shaders are off.
String? shaderShortLabel(ShaderPreset preset, ShaderQuality quality) => String? shaderShortLabel(ShaderPreset preset, ShaderQuality quality) =>
switch (preset) { switch (preset) {
@@ -61,7 +17,8 @@ String? shaderShortLabel(ShaderPreset preset, ShaderQuality quality) =>
}; };
/// The shader menu, anchored on [context]'s box (the button that opens /// The shader menu, anchored on [context]'s box (the button that opens
/// it): Off, the six Anime4K modes with HQ / Fast, Custom. /// it; the controls' ⚙): Off, the six Anime4K modes with HQ / Fast,
/// Custom. A route, so it outlives the controls hiding under it.
Future<void> showShaderMenu( Future<void> showShaderMenu(
BuildContext context, { BuildContext context, {
required ReadonlySignal<RenderMode> renderMode, required ReadonlySignal<RenderMode> renderMode,
+26 -3
View File
@@ -7,6 +7,7 @@ import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTar
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import '../controls/frame.dart' show DoubleClickListener;
import '../files/folder_store.dart'; import '../files/folder_store.dart';
import '../files/ui/files_page.dart' show showFilesFolder; import '../files/ui/files_page.dart' show showFilesFolder;
import '../jellyfin/accounts.dart'; import '../jellyfin/accounts.dart';
@@ -379,7 +380,9 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
if (error != null || !mounted) return error; if (error != null || !mounted) return error;
} }
// Alone, a link plays direct: there is nobody to pass it on to. // Alone, a link plays direct: there is nobody to pass it on to.
if (media is LinkMedia && session.solo.peek()) media = LinkMedia(media.url, anyway: media.anyway); if (media is LinkMedia && session.solo.peek()) {
media = LinkMedia(media.url, relay: LinkRelay.direct, anyway: media.anyway);
}
final error = await session.coordinator.swapPicked(media); final error = await session.coordinator.swapPicked(media);
if (error != null || !mounted) return error; if (error != null || !mounted) return error;
final f = session.lastForm; final f = session.lastForm;
@@ -418,7 +421,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
// --------------------------------------------------------------- handoff // --------------------------------------------------------------- handoff
/// Nothing (a dialog, a popover) is over the shell. /// Nothing (a dialog, a popover) is over the shell.
bool get _onTop => !_handoffOpen && (ModalRoute.of(context)?.isCurrent ?? true); bool get _onTop => !_handoffOpen && handoffHolds == 0 && (ModalRoute.of(context)?.isCurrent ?? true);
/// Ctrl+V outside a text field (a focused field pastes on its own). /// Ctrl+V outside a text field (a focused field pastes on its own).
bool _onKey(KeyEvent e) { bool _onKey(KeyEvent e) {
@@ -458,7 +461,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_handoffOpen = true; _handoffOpen = true;
final HandoffAction? action; final HandoffAction? action;
try { try {
action = await showPopover<HandoffAction>(context, at: align, child: HandoffCard(item: item, solo: _alone.peek())); action = await showPopover<HandoffAction>(context,
at: align, child: HandoffCard(item: item, solo: _alone.peek(), queue: _online.peek()));
} finally { } finally {
_handoffOpen = false; _handoffOpen = false;
} }
@@ -472,6 +476,22 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
await FolderStore.instance.ensureLoaded(); await FolderStore.instance.ensureLoaded();
FolderStore.instance.addRecent(paths.first); FolderStore.instance.addRecent(paths.first);
} }
case (HandoffVideos(:final paths), HandoffAction.queue || HandoffAction.playNext):
// In order: with Play next, the last one added goes first.
final next = action == HandoffAction.playNext;
for (final p in next ? paths.reversed : paths) {
final error = await SessionState.instance.coordinator.queue.add(FileMedia(p), next: next);
if (error != null) {
SessionState.instance.coordinator.showToast(error);
break;
}
}
case (HandoffLink(:final url), HandoffAction.queue || HandoffAction.playNext):
// In the Link page's mode, probed and refused like there.
final coord = SessionState.instance.coordinator;
final error = await coord.queue
.add(LinkMedia(url, relay: _picker.linkRelay), next: action == HandoffAction.playNext);
if (error != null) coord.showToast(error);
case (HandoffVideos(:final paths), HandoffAction.addFolder): case (HandoffVideos(:final paths), HandoffAction.addFolder):
await _addFolder(File(paths.first).parent.path); await _addFolder(File(paths.first).parent.path);
case (HandoffFolder(:final path), _): case (HandoffFolder(:final path), _):
@@ -671,11 +691,14 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
), ),
jellyfinStart: _libraryStart, jellyfinStart: _libraryStart,
onPutUp: _putUp, onPutUp: _putUp,
// Up next needs a server (a solo session has one).
onAddToQueue: online ? coord.queue.add : null,
onOpenIntegrations: () => _openSettings(SettingsSection.integrations), onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
), ),
ShellTab.schedule => SchedulePage( ShellTab.schedule => SchedulePage(
solo: alone, solo: alone,
onPutUp: _putUp, onPutUp: _putUp,
onAddToQueue: online ? coord.queue.add : null,
onToast: coord.showToast, onToast: coord.showToast,
), ),
ShellTab.settings => SettingsTab( ShellTab.settings => SettingsTab(
+11 -1
View File
@@ -5,6 +5,7 @@ import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../screens/home/profile.dart'; import '../screens/home/profile.dart';
import '../session/delivery.dart';
import '../session/session_coordinator.dart'; import '../session/session_coordinator.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import 'motion.dart'; import 'motion.dart';
@@ -22,6 +23,9 @@ class AvatarInfo {
/// Why their player cannot open the media (`members[].error`): an error /// Why their player cannot open the media (`members[].error`): an error
/// mark; empty when it can. /// mark; empty when it can.
final String error; final String error;
/// How it gets the media and its buffer ([MemberDelivery.detail]), for
/// the hover; empty when unknown.
final String delivery;
const AvatarInfo({ const AvatarInfo({
this.id = '', this.id = '',
required this.name, required this.name,
@@ -35,6 +39,7 @@ class AvatarInfo {
this.self = false, this.self = false,
this.out = false, this.out = false,
this.error = '', this.error = '',
this.delivery = '',
}); });
} }
@@ -103,6 +108,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
final self = m.id.isNotEmpty && m.id == coord.sync.memberId; final self = m.id.isNotEmpty && m.id == coord.sync.memberId;
final relay = r == null ? null : stateMediaRelay(r); final relay = r == null ? null : stateMediaRelay(r);
final playing = r?.media != null; final playing = r?.media != null;
final delivery = r == null ? null : deliveryOf(r, selfId: coord.sync.memberId, names: coord.memberNames);
return AvatarInfo( return AvatarInfo(
id: m.id, id: m.id,
name: m.name, name: m.name,
@@ -118,6 +124,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
self: self, self: self,
out: m.out, out: m.out,
error: m.error, error: m.error,
delivery: delivery == null ? '' : memberDeliveryOf(m, r!, delivery).detail,
); );
} }
@@ -304,9 +311,12 @@ class _AvatarStackState extends State<AvatarStack> {
left: i * step, left: i * step,
top: 0, top: 0,
child: Tooltip( child: Tooltip(
message: visible[i].error.isEmpty message: [
visible[i].error.isEmpty
? visible[i].name ? visible[i].name
: "${visible[i].name} can't play this: ${visible[i].error}", : "${visible[i].name} can't play this: ${visible[i].error}",
if (visible[i].delivery.isNotEmpty) visible[i].delivery,
].join('\n'),
child: StatusAvatar(info: visible[i], size: size), child: StatusAvatar(info: visible[i], size: size),
), ),
), ),
+60 -2
View File
@@ -3,6 +3,8 @@ import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart';
import '../widgets/text_focus.dart'; import '../widgets/text_focus.dart';
import 'avatar.dart'; import 'avatar.dart';
@@ -158,7 +160,30 @@ class ActivityRow extends StatelessWidget {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final time = _clock(item.at); final time = _clock(item.at);
if (item.action == 'chat') { if (item.action == 'chat') {
return Padding( final link = firstLinkIn(item.reason);
// Right-click on the text: its selection menu plus the whole message
// and its link; beside it, the same as the app's menu.
final message = SelectableText(
item.reason,
contextMenuBuilder: (context, state) => AdaptiveTextSelectionToolbar.buttonItems(
anchors: state.contextMenuAnchors,
buttonItems: [
...state.contextMenuButtonItems,
for (final (label, action) in _messageActions(item.reason, link))
ContextMenuButtonItem(
label: label,
onPressed: () {
state.hideToolbar();
action();
},
),
],
),
);
return Builder(
builder: (context) => GestureDetector(
onSecondaryTap: () => _messageMenu(context, item.reason, link),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.symmetric(vertical: 6),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -181,12 +206,14 @@ class ActivityRow extends StatelessWidget {
Text(time, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)), Text(time, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
], ],
), ),
SelectableText(item.reason), message,
], ],
), ),
), ),
], ],
), ),
),
),
); );
} }
final (icon, color, text) = item.actor == 'server' ? serverFeedStyle(item) : feedStyle(item, scheme); final (icon, color, text) = item.actor == 'server' ? serverFeedStyle(item) : feedStyle(item, scheme);
@@ -206,6 +233,28 @@ class ActivityRow extends StatelessWidget {
} }
} }
/// The first http(s) link in [text], if any.
String? firstLinkIn(String text) => RegExp(r'https?://[^\s<>"]+', caseSensitive: false).firstMatch(text)?.group(0);
/// What a chat message offers: Copy message, and its link's Copy link and
/// Open in browser.
List<(String, VoidCallback)> _messageActions(String text, String? link) => [
(ReuseText.copyMessage, () => copyToClipboard(text, done: 'Message copied')),
if (link != null) ...[
(ReuseText.copyLink, () => copyToClipboard(link, done: 'Link copied')),
(ReuseText.openInBrowser, () => openLink(link)),
],
];
/// Right-click beside a chat message: [_messageActions] in the app's menu.
Future<void> _messageMenu(BuildContext context, String text, String? link) async {
final actions = _messageActions(text, link);
final pick = await showAppMenu<int>(context, items: [
for (final (i, (label, _)) in actions.indexed) AppMenuItem(value: i, label: label),
]);
if (pick != null) actions[pick].$2();
}
/// Icon, colour and wording of a member's feed action. /// Icon, colour and wording of a member's feed action.
(IconData, Color, String) feedStyle(FeedMessage f, ColorScheme scheme) { (IconData, Color, String) feedStyle(FeedMessage f, ColorScheme scheme) {
switch (f.action) { switch (f.action) {
@@ -237,6 +286,15 @@ class ActivityRow extends StatelessWidget {
return (Icons.bedtime, Colors.blueGrey, who == null || who.isEmpty ? 'is sitting out' : 'sat $who out'); return (Icons.bedtime, Colors.blueGrey, who == null || who.isEmpty ? 'is sitting out' : 'sat $who out');
case 'back': case 'back':
return (Icons.login, Colors.green, 'joined back'); return (Icons.login, Colors.green, 'joined back');
case 'queue-add':
return (Icons.playlist_add_rounded, scheme.primary, 'added ${f.target ?? ''} to Up next');
case 'queue-remove':
return (Icons.playlist_remove_rounded, Colors.grey, 'removed ${f.target ?? ''} from Up next');
case 'queue-skip':
final why = f.reason.isEmpty ? '' : ' · ${f.reason}';
return (Icons.redo_rounded, Colors.orange, 'skipped ${f.target ?? ''}$why');
case 'fetch-fail':
return (Icons.cloud_off, Colors.red, "couldn't fetch ${f.target ?? 'the video'}");
default: default:
final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.target}'; final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.target}';
return (Icons.info_outline, Colors.grey, '${f.action}$target'); return (Icons.info_outline, Colors.grey, '${f.action}$target');
@@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
import '../session/delivery.dart';
import 'motion.dart';
// The members card's view of [Delivery]: one source line on top, a buffer
// dot on each member. Nothing of it sits on the picture.
Color _tone(SourceState s, ColorScheme scheme) => switch (s) {
SourceState.ok => scheme.primary,
SourceState.slow || SourceState.waiting => Colors.amber,
SourceState.left => Colors.redAccent.shade200,
};
IconData _icon(Delivery d) => switch (d.state) {
SourceState.left => Icons.link_off_rounded,
SourceState.waiting => Icons.hourglass_top_rounded,
_ => switch (d.kind) {
DeliveryKind.memberRelay => Icons.cell_tower,
DeliveryKind.serverFetch => Icons.cloud_download_outlined,
DeliveryKind.hostFile => Icons.folder_shared_outlined,
DeliveryKind.serverFile => Icons.dns_outlined,
DeliveryKind.link => Icons.public,
DeliveryKind.jellyfin => Icons.video_library_outlined,
},
};
/// Where the current media comes from and how it reaches the others:
/// "Foxyz passes it on through the server", "3 through the server ·
/// 4.1 MB/s, needs 2.3 MB/s" and a bar of the source's speed against the
/// video's (past the mark: keeping up). Amber when slow or waiting, red
/// when the source left.
class SourceLine extends StatelessWidget {
final Delivery delivery;
const SourceLine({super.key, required this.delivery});
@override
Widget build(BuildContext context) {
final d = delivery;
final scheme = Theme.of(context).colorScheme;
final tone = _tone(d.state, scheme);
final detail = d.detail;
final headroom = d.state == SourceState.left ? null : d.headroom;
return Tooltip(
message: d.explain,
waitDuration: const Duration(milliseconds: 400),
child: Container(
key: const ValueKey('source-line'),
margin: const EdgeInsets.fromLTRB(12, 0, 12, 6),
padding: const EdgeInsets.fromLTRB(10, 8, 12, 8),
decoration: BoxDecoration(
color: d.state == SourceState.ok ? scheme.surfaceContainerHigh : tone.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 1),
child: Icon(_icon(d), size: 18, color: tone),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(d.title, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
if (detail.isNotEmpty) ...[
const SizedBox(height: 2),
Text(detail, style: TextStyle(fontSize: 11.5, color: scheme.onSurfaceVariant)),
],
if (headroom != null) ...[
const SizedBox(height: 6),
_HeadroomBar(headroom: headroom, slow: d.state == SourceState.slow),
],
],
),
),
],
),
),
);
}
}
/// The source's speed on a scale to twice the video's bitrate; the mark
/// in the middle is what the video needs.
class _HeadroomBar extends StatelessWidget {
final double headroom;
final bool slow;
const _HeadroomBar({required this.headroom, required this.slow});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final fill = (headroom / 2).clamp(0.04, 1.0);
final color = slow ? Colors.amber : Colors.greenAccent.shade400;
return SizedBox(
key: const ValueKey('headroom-bar'),
height: 4,
child: LayoutBuilder(
builder: (context, box) => Stack(
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.outlineVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
AnimatedContainer(
duration: easeTime,
curve: ease,
width: box.maxWidth * fill,
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(2)),
),
Positioned(
left: box.maxWidth / 2 - 0.75,
top: -1,
bottom: -1,
child: Container(width: 1.5, color: scheme.onSurface.withValues(alpha: 0.7)),
),
],
),
),
);
}
}
/// A member's buffer at a glance: green enough to play on, amber low,
/// red empty while playing.
Color healthColor(BufferHealth h) => switch (h) {
BufferHealth.good => Colors.greenAccent.shade400,
BufferHealth.low => Colors.amber,
BufferHealth.starving => Colors.redAccent.shade200,
};
/// The members card's subtitle: [status] ("ready"), then how the member
/// gets the media (the source's own pill says it serves it), after a dot
/// coloured by its buffer; the hover says how much it has buffered.
class DeliveryStatus extends StatelessWidget {
final String status;
final MemberDelivery? delivery;
const DeliveryStatus({super.key, required this.status, this.delivery});
@override
Widget build(BuildContext context) {
final d = delivery;
final route = d == null || d.route == DeliveryRoute.source ? '' : d.short;
final text = Text([status, if (route.isNotEmpty) route].join(' · '),
maxLines: 1, overflow: TextOverflow.ellipsis);
if (d == null) return text;
final health = d.health;
return Tooltip(
message: d.detail,
waitDuration: const Duration(milliseconds: 400),
child: Row(
children: [
if (health != null) ...[
AnimatedContainer(
key: const ValueKey('health-dot'),
duration: easeTime,
curve: ease,
width: 7,
height: 7,
decoration: BoxDecoration(color: healthColor(health), shape: BoxShape.circle),
),
const SizedBox(width: 6),
],
Flexible(child: text),
],
),
);
}
}
+21 -1
View File
@@ -9,6 +9,11 @@ import '../files/ui/file_tiles.dart' show baseName;
// field: what it is ([classifyHandoff], pure) and the small card that asks // field: what it is ([classifyHandoff], pure) and the small card that asks
// how to open it ([HandoffCard]). The shell wires both. // how to open it ([HandoffCard]). The shell wires both.
/// Open overlays over the shell that take drops and pastes themselves
/// (the Up next popover, which is not a route): the shell's own handoff
/// stays off while any is open.
int handoffHolds = 0;
/// What was dropped or pasted. /// What was dropped or pasted.
sealed class Handoff { sealed class Handoff {
const Handoff(); const Handoff();
@@ -100,6 +105,10 @@ enum HandoffAction {
addFolder, addFolder,
/// Open Library → Link with the URL filled. /// Open Library → Link with the URL filled.
openLink, openLink,
/// Add the videos (or the link, in the Link page's mode) to Up next.
queue,
/// The same, right after what plays.
playNext,
} }
/// The card asking how to open a [Handoff] (videos, a folder or a link); /// The card asking how to open a [Handoff] (videos, a folder or a link);
@@ -108,7 +117,9 @@ class HandoffCard extends StatelessWidget {
final Handoff item; final Handoff item;
/// Nobody else watches (offline or solo): videos play, no relay talk. /// Nobody else watches (offline or solo): videos play, no relay talk.
final bool solo; final bool solo;
const HandoffCard({super.key, required this.item, required this.solo}); /// In a server: videos and links can go to Up next.
final bool queue;
const HandoffCard({super.key, required this.item, required this.solo, this.queue = false});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -119,6 +130,13 @@ class HandoffCard extends StatelessWidget {
key: ValueKey('handoff-${a.name}'), onPressed: () => pick(a), icon: Icon(icon, size: 18), label: Text(label)) key: ValueKey('handoff-${a.name}'), onPressed: () => pick(a), icon: Icon(icon, size: 18), label: Text(label))
: OutlinedButton.icon( : OutlinedButton.icon(
key: ValueKey('handoff-${a.name}'), onPressed: () => pick(a), icon: Icon(icon, size: 18), label: Text(label)); key: ValueKey('handoff-${a.name}'), onPressed: () => pick(a), icon: Icon(icon, size: 18), label: Text(label));
final many = switch (item) { HandoffVideos(:final paths) => paths.length > 1, _ => false };
final queueButtons = [
if (queue) ...[
button(HandoffAction.queue, Icons.playlist_add_rounded, many ? 'Add all to queue' : 'Add to queue'),
button(HandoffAction.playNext, Icons.queue_play_next_rounded, many ? 'Play all next' : 'Play next'),
],
];
final (IconData icon, String title, String detail, List<Widget> buttons) = switch (item) { final (IconData icon, String title, String detail, List<Widget> buttons) = switch (item) {
HandoffVideos(:final paths) => ( HandoffVideos(:final paths) => (
@@ -127,6 +145,7 @@ class HandoffCard extends StatelessWidget {
paths.length > 1 ? 'and ${paths.length - 1} more · the first one goes up' : 'A video on this computer', 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.putUp, Icons.play_arrow, solo ? 'Play' : 'Put it up', primary: true),
...queueButtons,
button(HandoffAction.addFolder, Icons.create_new_folder_outlined, 'Add its folder to Files'), button(HandoffAction.addFolder, Icons.create_new_folder_outlined, 'Add its folder to Files'),
], ],
), ),
@@ -143,6 +162,7 @@ class HandoffCard extends StatelessWidget {
[ [
button(HandoffAction.openLink, Icons.link, solo ? 'Open in Link' : 'Put it up as a link…', button(HandoffAction.openLink, Icons.link, solo ? 'Open in Link' : 'Put it up as a link…',
primary: true), primary: true),
...queueButtons,
], ],
), ),
HandoffImage() || HandoffNothing() => (Icons.help_outline, '', '', const <Widget>[]), HandoffImage() || HandoffNothing() => (Icons.help_outline, '', '', const <Widget>[]),
+398 -32
View File
@@ -3,10 +3,13 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart' show LinkProbe, hasMediaExtension, parseJellyfinLink, playErrorText; import 'package:sync_relay_core/core.dart'
show LinkProbe, hasMediaExtension, parseJellyfinLink, playErrorText, unreachableText;
import '../playback/yt_dlp_app.dart'; import '../playback/yt_dlp_app.dart';
import '../screens/home/media_picker.dart'; import '../screens/home/media_picker.dart';
import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart';
import '../state/connect_prefs.dart'; import '../state/connect_prefs.dart';
import 'motion.dart'; import 'motion.dart';
@@ -21,20 +24,33 @@ class RecentLink {
final String url; final String url;
final String title; final String title;
final String? thumbnail; final String? thumbnail;
/// The preview's second line ("youtube.com · Channel · 3:07"); null for
/// links kept before it was.
final String? subtitle;
final DateTime at; final DateTime at;
const RecentLink({required this.url, required this.title, this.thumbnail, required this.at}); const RecentLink({required this.url, required this.title, this.thumbnail, this.subtitle, required this.at});
Map<String, Object?> toJson() => /// What it showed when put up (the host for a second line, when older).
{'url': url, 'title': title, 'thumb': ?thumbnail, 'at': at.millisecondsSinceEpoch}; LinkPreview get preview =>
LinkPreview(title: title, subtitle: subtitle ?? _host(Uri.tryParse(url) ?? Uri()), thumbnail: thumbnail);
Map<String, Object?> toJson() => {
'url': url,
'title': title,
'thumb': ?thumbnail,
'sub': ?subtitle,
'at': at.millisecondsSinceEpoch,
};
static RecentLink? fromJson(Object? j) { static RecentLink? fromJson(Object? j) {
if (j is! Map) return null; if (j is! Map) return null;
final url = j['url'], title = j['title'], at = j['at'], thumb = j['thumb']; final url = j['url'], title = j['title'], at = j['at'], thumb = j['thumb'], sub = j['sub'];
if (url is! String || url.isEmpty) return null; if (url is! String || url.isEmpty) return null;
return RecentLink( return RecentLink(
url: url, url: url,
title: title is String && title.isNotEmpty ? title : LinkMedia(url).label, title: title is String && title.isNotEmpty ? title : LinkMedia(url).label,
thumbnail: thumb is String && thumb.isNotEmpty ? thumb : null, thumbnail: thumb is String && thumb.isNotEmpty ? thumb : null,
subtitle: sub is String && sub.isNotEmpty ? sub : null,
at: DateTime.fromMillisecondsSinceEpoch(at is int ? at : 0), at: DateTime.fromMillisecondsSinceEpoch(at is int ? at : 0),
); );
} }
@@ -60,13 +76,18 @@ List<RecentLink> decodeRecentLinks(Object? raw) {
class RecentLinks { class RecentLinks {
static final RecentLinks instance = RecentLinks(); static final RecentLinks instance = RecentLinks();
RecentLinks({Future<Object?> Function()? read, Future<void> Function(String value)? write}) /// [read] / [write] given (tests): [previews] defaults to one kept in
/// memory only.
RecentLinks({Future<Object?> Function()? read, Future<void> Function(String value)? write, LinkPreviewStore? previews})
: _read = read ?? _hiveRead, : _read = read ?? _hiveRead,
_write = write ?? _hiveWrite; _write = write ?? _hiveWrite,
previews = previews ?? (read == null && write == null ? LinkPreviewStore() : LinkPreviewStore.memory());
final Future<Object?> Function() _read; final Future<Object?> Function() _read;
final Future<void> Function(String value) _write; final Future<void> Function(String value) _write;
final links = ValueNotifier<List<RecentLink>>(const []); final links = ValueNotifier<List<RecentLink>>(const []);
/// What links pasted or put up before turned out to be.
final LinkPreviewStore previews;
Future<void>? _load; Future<void>? _load;
static Future<Object?> _hiveRead() async => static Future<Object?> _hiveRead() async =>
@@ -111,6 +132,108 @@ class LinkPreview {
/// null when it looks playable. /// null when it looks playable.
final String? problem; final String? problem;
const LinkPreview({required this.title, required this.subtitle, this.thumbnail, this.problem}); const LinkPreview({required this.title, required this.subtitle, this.thumbnail, this.problem});
/// Display text only: never a cookie or a resolved stream URL.
Map<String, Object?> toJson() => {'title': title, 'sub': subtitle, 'thumb': ?thumbnail};
static LinkPreview? fromJson(Object? j) {
if (j is! Map) return null;
final title = j['title'], sub = j['sub'], thumb = j['thumb'];
if (title is! String || title.isEmpty || sub is! String) return null;
return LinkPreview(title: title, subtitle: sub, thumbnail: thumb is String && thumb.isNotEmpty ? thumb : null);
}
}
/// A [LinkPreview] kept, and when it was found.
typedef StoredLinkPreview = ({LinkPreview preview, DateTime at});
/// How long a kept preview is shown as is; older, it is shown and
/// refreshed quietly behind it.
const linkPreviewFreshFor = Duration(days: 1);
const _keyLinkPreviews = 'linkPreviews';
/// What links turned out to be, by URL, so a link pasted or picked again
/// shows its preview at once instead of waiting seconds for yt-dlp. Only
/// links that looked playable, at most [maxEntries] (the oldest go), in
/// the `connect_form` box. Display text only ([LinkPreview.toJson]).
class LinkPreviewStore {
static const maxEntries = 200;
LinkPreviewStore({Future<Object?> Function()? read, Future<void> Function(String value)? write})
: _read = read ?? _hiveRead,
_write = write ?? _hiveWrite;
/// Kept in memory only.
LinkPreviewStore.memory()
: _read = _none,
_write = _drop;
final Future<Object?> Function() _read;
final Future<void> Function(String value) _write;
// Oldest first (insertion order); a put moves an entry to the end.
final _entries = <String, StoredLinkPreview>{};
Future<void>? _load;
static Future<Object?> _none() async => null;
static Future<void> _drop(String _) async {}
static Future<Object?> _hiveRead() async =>
(connectFormBoxIfOpen() ?? await openConnectFormBox()).get(_keyLinkPreviews);
static Future<void> _hiveWrite(String value) async =>
(connectFormBoxIfOpen() ?? await openConnectFormBox()).put(_keyLinkPreviews, value);
Future<void> ensureLoaded() => _load ??= () async {
try {
final raw = await _read();
final j = raw is String ? jsonDecode(raw) : raw;
if (j is! Map) return;
final read = <String, StoredLinkPreview>{};
for (final e in j.entries) {
final v = e.value;
final p = LinkPreview.fromJson(v);
if (e.key is! String || p == null || v is! Map) continue;
final at = v['at'];
read[e.key as String] = (preview: p, at: DateTime.fromMillisecondsSinceEpoch(at is int ? at : 0));
}
// Something found meanwhile is newer.
final sorted = read.entries.toList()..sort((a, b) => a.value.at.compareTo(b.value.at));
final mine = Map.of(_entries);
_entries
..clear()
..addEntries(sorted.where((e) => !mine.containsKey(e.key)))
..addAll(mine);
} catch (e) {
debugPrint('load link previews failed: $e');
}
}();
/// [url]'s kept preview, if any (after [ensureLoaded]).
StoredLinkPreview? lookup(String url) => _entries[url];
/// Keeps [preview] of [url] when it looked playable; one with a problem
/// forgets what was kept. Loads first, so a save never drops what is on
/// disk.
Future<void> put(String url, LinkPreview preview, {DateTime? at}) async {
await ensureLoaded();
_entries.remove(url);
if (preview.problem == null) _entries[url] = (preview: preview, at: at ?? DateTime.now());
while (_entries.length > maxEntries) {
_entries.remove(_entries.keys.first);
}
await _save();
}
Future<void> _save() async {
try {
await _write(jsonEncode({
for (final e in _entries.entries) e.key: {...e.value.preview.toJson(), 'at': e.value.at.millisecondsSinceEpoch},
}));
} catch (e) {
debugPrint('save link previews failed: $e');
}
}
} }
/// What a link is (see [AppYtDlp.probe]). /// What a link is (see [AppYtDlp.probe]).
@@ -164,6 +287,19 @@ Future<LinkPreview?> previewLink(String url, {LinkProber probe = defaultLinkProb
); );
} }
/// [url]'s preview at once when it was kept before ([LinkPreviewStore]),
/// else probed and kept (the Up next popover's paste; the Link page has
/// its own refresh rules).
Future<LinkPreview?> cachedLinkPreview(String url, {RecentLinks? recents, LinkProber probe = defaultLinkProber}) async {
final store = (recents ?? RecentLinks.instance).previews;
await store.ensureLoaded();
final kept = store.lookup(url);
if (kept != null) return kept.preview;
final p = await previewLink(url, probe: probe);
if (p != null && parseJellyfinLink(url) == null) unawaited(store.put(url, p));
return p;
}
/// "today", "yesterday", "3 days ago", "last week", "5 weeks ago", ... /// "today", "yesterday", "3 days ago", "last week", "5 weeks ago", ...
String agoText(DateTime at, DateTime now) { String agoText(DateTime at, DateTime now) {
final d = now.difference(at).inDays; final d = now.difference(at).inDays;
@@ -178,7 +314,17 @@ String agoText(DateTime at, DateTime now) {
// -------------------------------------------------------------- widget // -------------------------------------------------------------- widget
const _modes = [ // Through me is the default and the main choice: it plays whatever this
// computer can open, and nobody can tell ahead which sites will want
// sign-in cookies (YouTube may start asking any day). The other two sit
// behind "Other ways".
const _main = (
LinkRelay.me,
Icons.cell_tower,
'Through me',
'Your computer opens it and passes it on, so it plays for everyone whenever it plays for you.'
);
const _others = [
(LinkRelay.direct, Icons.groups_outlined, 'Everyone fetches it', 'Each app plays the link itself.'), (LinkRelay.direct, Icons.groups_outlined, 'Everyone fetches it', 'Each app plays the link itself.'),
( (
LinkRelay.server, LinkRelay.server,
@@ -186,7 +332,6 @@ const _modes = [
'Server fetches it', 'Server fetches it',
'Downloaded once by the server. For links slow or blocked for some.' '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) { String linkRelayHelp(LinkRelay r) => switch (r) {
@@ -208,12 +353,15 @@ class LibraryLinkSource extends StatefulWidget {
final bool solo; final bool solo;
/// Puts the link up; returns an error text, or null when sent. /// Puts the link up; returns an error text, or null when sent.
final Future<String?> Function(LinkMedia media) onPutUp; final Future<String?> Function(LinkMedia media) onPutUp;
/// Adds the link to Up next in the chosen mode; null: no server.
final AddToQueue? onAddToQueue;
final RecentLinks recents; final RecentLinks recents;
final LinkProber probe; final LinkProber probe;
const LibraryLinkSource({ const LibraryLinkSource({
super.key, super.key,
required this.controller, required this.controller,
required this.onPutUp, required this.onPutUp,
this.onAddToQueue,
required this.recents, required this.recents,
this.solo = false, this.solo = false,
this.probe = defaultLinkProber, this.probe = defaultLinkProber,
@@ -232,6 +380,10 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
String? _error; String? _error;
/// [_error] is the probe's problem: "Put it up anyway" is offered. /// [_error] is the probe's problem: "Put it up anyway" is offered.
bool _canForce = false; bool _canForce = false;
/// "Other ways" opened (always shown while one of them is picked).
bool _otherWays = false;
LinkPreviewStore get _store => widget.recents.previews;
@override @override
void initState() { void initState() {
@@ -239,6 +391,10 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
widget.recents.ensureLoaded(); widget.recents.ensureLoaded();
_c.link.addListener(_typed); _c.link.addListener(_typed);
_typed(); _typed();
// A link already in the field may have been seen before.
_store.ensureLoaded().then((_) {
if (mounted && _c.link.text.trim() == _previewUrl) _preview(_previewUrl);
});
} }
@override @override
@@ -252,7 +408,12 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
final url = _c.link.text.trim(); final url = _c.link.text.trim();
if (url == _previewUrl) return; if (url == _previewUrl) return;
_debounce?.cancel(); _debounce?.cancel();
// Seen before (picked from Recent, pasted again): at once.
if (_previews.containsKey(url) || _known(url) != null) {
_preview(url);
} else {
_debounce = Timer(const Duration(milliseconds: 350), () => _preview(url)); _debounce = Timer(const Duration(milliseconds: 350), () => _preview(url));
}
if (mounted) { if (mounted) {
setState(() { setState(() {
_error = null; _error = null;
@@ -261,17 +422,58 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
} }
} }
/// What [url] was found to be before: the kept preview, else what its
/// Recent card showed.
StoredLinkPreview? _known(String url) {
if (url.isEmpty) return null;
final kept = _store.lookup(url);
if (kept != null) return kept;
for (final l in widget.recents.links.value) {
if (l.url == url) return (preview: l.preview, at: l.at);
}
return null;
}
/// The preview of [url]: from this page's memory, else kept from before
/// (refreshed quietly once older than [linkPreviewFreshFor]), else
/// probed (yt-dlp takes seconds).
Future<void> _preview(String url) async { Future<void> _preview(String url) async {
_previewUrl = url; _previewUrl = url;
if (url.isEmpty || _previews.containsKey(url)) { if (url.isEmpty || _previews.containsKey(url)) {
if (mounted) setState(() {}); if (mounted) setState(() {});
return; return;
} }
final known = _known(url);
if (known != null) {
_previews[url] = known.preview;
if (mounted) setState(() {});
if (DateTime.now().difference(known.at) > linkPreviewFreshFor) unawaited(_refresh(url));
return;
}
final p = await previewLink(url, probe: widget.probe); final p = await previewLink(url, probe: widget.probe);
_previews[url] = p; _previews[url] = p;
_keep(url, p);
if (mounted) setState(() {}); if (mounted) setState(() {});
} }
/// Probes [url] again behind what is shown; a site out of reach keeps
/// what was found before. Returns the preview shown afterwards.
Future<LinkPreview?> _refresh(String url) async {
final p = await previewLink(url, probe: widget.probe);
if (p == null || p.problem == unreachableText) return _previews[url];
_previews[url] = p;
_keep(url, p);
if (mounted) setState(() {});
return p;
}
/// Keeps what [url] turned out to be for next time (a Jellyfin link's
/// preview needs no probe).
void _keep(String url, LinkPreview? p) {
if (p == null || parseJellyfinLink(url) != null) return;
unawaited(_store.put(url, p));
}
Future<void> _paste() async { Future<void> _paste() async {
try { try {
final text = (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim(); final text = (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim();
@@ -299,13 +501,23 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
error = '$e'; error = '$e';
} }
} }
final canForce = problem != null; var canForce = problem != null;
if (error != null && problem == null && !anyway) {
// The kept preview said it plays but it did not: show what it is now
// (the put-up just probed it, so this is quick), "anyway" if refused.
canForce = (await _refresh(url))?.problem != null;
}
if (error == null) { if (error == null) {
final p = _previews[url] ?? await previewLink(url, probe: widget.probe); var p = _previews[url];
if (p == null) {
p = _previews[url] = await previewLink(url, probe: widget.probe);
_keep(url, p);
}
unawaited(widget.recents.add(RecentLink( unawaited(widget.recents.add(RecentLink(
url: url, url: url,
title: p?.title ?? LinkMedia(url).label, title: p?.title ?? LinkMedia(url).label,
thumbnail: p?.thumbnail, thumbnail: p?.thumbnail,
subtitle: p?.problem == null ? p?.subtitle : null,
at: DateTime.now(), at: DateTime.now(),
))); )));
} }
@@ -317,6 +529,87 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
}); });
} }
/// "Add to queue" (or, from a recent card's menu, [url] and [next]):
/// the link in the chosen mode; the probe refuses what will not play.
Future<void> _queue({String? url, bool next = false}) async {
final add = widget.onAddToQueue;
final link = url ?? _c.link.text.trim();
if (add == null || link.isEmpty || _busy) return;
setState(() {
_busy = true;
_error = null;
_canForce = false;
});
String? error;
try {
error = await add(LinkMedia(link, relay: widget.solo ? LinkRelay.direct : _c.linkRelay), next: next);
} catch (e) {
error = '$e';
}
if (!mounted) return;
setState(() {
_busy = false;
_error = error;
});
}
/// Right-click on a recent link: put it up or queue it, copy or open it.
Future<void> _recentMenu(BuildContext context, RecentLink l) async {
final pick = await showAppMenu<String>(context, items: [
AppMenuItem(value: 'up', label: widget.solo ? 'Play' : 'Put it up'),
if (widget.onAddToQueue != null) ...const [
AppMenuItem(value: 'queue', label: 'Add to queue'),
AppMenuItem(value: 'next', label: 'Play next'),
],
const AppMenuItem(value: 'copy', label: ReuseText.copyLink),
const AppMenuItem(value: 'title', label: ReuseText.copyTitle),
const AppMenuItem(value: 'open', label: ReuseText.openInBrowser),
]);
if (pick == null || !mounted) return;
switch (pick) {
case 'copy':
return copyToClipboard(l.url, done: 'Link copied');
case 'title':
return copyToClipboard(l.title, done: 'Title copied');
case 'open':
return openLink(l.url);
}
_c.link.text = l.url;
switch (pick) {
case 'up':
await _go();
case 'queue' || 'next':
await _queue(url: l.url, next: pick == 'next');
}
}
/// Right-click on the preview: copy or open the link.
Future<void> _previewMenu(BuildContext context, String url, LinkPreview? p) async {
final pick = await showAppMenu<String>(context, items: [
const AppMenuItem(value: 'copy', label: ReuseText.copyLink),
if (p != null) const AppMenuItem(value: 'title', label: ReuseText.copyTitle),
const AppMenuItem(value: 'open', label: ReuseText.openInBrowser),
]);
switch (pick) {
case 'copy':
await copyToClipboard(url, done: 'Link copied');
case 'title':
await copyToClipboard(p!.title, done: 'Title copied');
case 'open':
await openLink(url);
}
}
Widget _modeCard((LinkRelay, IconData, String, String) m, {bool compact = false}) => _ModeCard(
key: ValueKey('link-mode-${m.$1.name}'),
icon: m.$2,
title: m.$3,
sub: m.$4,
compact: compact,
selected: _c.linkRelay == m.$1,
onTap: () => setState(() => _c.linkRelay = m.$1),
);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
@@ -351,6 +644,22 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
), ),
); );
final queue = widget.onAddToQueue == null
? null
: Tooltip(
message: 'Plays after what is on now (right-click a recent link for Play next)',
waitDuration: const Duration(milliseconds: 600),
child: OutlinedButton.icon(
key: const ValueKey('link-add-queue'),
onPressed: url.isEmpty || _busy ? null : () => _queue(),
icon: const Icon(Icons.playlist_add_rounded),
label: const Text('Add to queue'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 56),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
),
);
return SingleChildScrollView( return SingleChildScrollView(
key: const ValueKey('media-link'), key: const ValueKey('media-link'),
@@ -387,8 +696,14 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
field, field,
const SizedBox(height: 10), const SizedBox(height: 10),
button, button,
if (queue != null) ...[const SizedBox(height: 8), queue],
]) ])
: Row(children: [Expanded(child: field), const SizedBox(width: 12), button]), : Row(children: [
Expanded(child: field),
const SizedBox(width: 12),
button,
if (queue != null) ...[const SizedBox(width: 8), queue],
]),
), ),
if (solo || _error != null) ...[ if (solo || _error != null) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -414,28 +729,58 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
? const SizedBox(width: double.infinity) ? const SizedBox(width: double.infinity)
: Padding( : Padding(
padding: const EdgeInsets.only(top: 14), padding: const EdgeInsets.only(top: 14),
child: Builder(
builder: (context) => GestureDetector(
onSecondaryTap: () => _previewMenu(context, url, preview),
child: _PreviewCard(preview: preview, loading: loading), child: _PreviewCard(preview: preview, loading: loading),
), ),
), ),
),
),
if (!solo) ...[ if (!solo) ...[
const SizedBox(height: 28), const SizedBox(height: 28),
Text('How the others get it', style: t.titleSmall), Text('How the others get it', style: t.titleSmall),
const SizedBox(height: 10), const SizedBox(height: 10),
LayoutBuilder(builder: (context, box) { _modeCard(_main),
final cards = [ const SizedBox(height: 6),
for (final m in _modes) Builder(builder: (context) {
_ModeCard( final open = _otherWays || _c.linkRelay != LinkRelay.me;
key: ValueKey('link-mode-${m.$1.name}'), return Column(
icon: m.$2, crossAxisAlignment: CrossAxisAlignment.start,
title: m.$3, children: [
sub: m.$4, TextButton.icon(
selected: _c.linkRelay == m.$1, key: const ValueKey('link-other-ways'),
onTap: () => setState(() => _c.linkRelay = m.$1), // Closing while one of them is picked goes back to Through me.
onPressed: () => setState(() {
_otherWays = !open;
if (open) _c.linkRelay = LinkRelay.me;
}),
style: TextButton.styleFrom(foregroundColor: scheme.onSurfaceVariant),
iconAlignment: IconAlignment.end,
icon: AnimatedRotation(
turns: open ? 0.5 : 0,
duration: easeTime,
curve: ease,
child: const Icon(Icons.keyboard_arrow_down_rounded, size: 18),
), ),
]; label: const Text('Other ways'),
),
AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topCenter,
child: !open
? const SizedBox(width: double.infinity)
: Padding(
padding: const EdgeInsets.only(top: 4),
child: LayoutBuilder(builder: (context, box) {
final cards = [for (final m in _others) _modeCard(m, compact: true)];
if (box.maxWidth < 560) { if (box.maxWidth < 560) {
return Column(children: [ return Column(children: [
for (final (i, c) in cards.indexed) ...[if (i > 0) const SizedBox(height: 8), c], for (final (i, c) in cards.indexed) ...[
if (i > 0) const SizedBox(height: 8),
c,
],
]); ]);
} }
return IntrinsicHeight( return IntrinsicHeight(
@@ -450,6 +795,11 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
), ),
); );
}), }),
),
),
],
);
}),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(linkRelayHelp(_c.linkRelay), style: t.bodySmall?.copyWith(color: Colors.grey)), Text(linkRelayHelp(_c.linkRelay), style: t.bodySmall?.copyWith(color: Colors.grey)),
], ],
@@ -459,7 +809,8 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
? const SizedBox.shrink() ? const SizedBox.shrink()
: Padding( : Padding(
padding: const EdgeInsets.only(top: 32), padding: const EdgeInsets.only(top: 32),
child: _RecentGrid(recents: recents, onPick: (l) => _c.link.text = l.url), child: _RecentGrid(
recents: recents, onPick: (l) => _c.link.text = l.url, onMenu: _recentMenu),
), ),
), ),
], ],
@@ -542,6 +893,8 @@ class _ModeCard extends StatelessWidget {
final IconData icon; final IconData icon;
final String title, sub; final String title, sub;
final bool selected; final bool selected;
/// A secondary choice: icon beside the title, a quieter card.
final bool compact;
final VoidCallback onTap; final VoidCallback onTap;
const _ModeCard({ const _ModeCard({
super.key, super.key,
@@ -550,6 +903,7 @@ class _ModeCard extends StatelessWidget {
required this.sub, required this.sub,
required this.selected, required this.selected,
required this.onTap, required this.onTap,
this.compact = false,
}); });
@override @override
@@ -565,18 +919,23 @@ class _ModeCard extends StatelessWidget {
duration: easeTime, duration: easeTime,
curve: ease, curve: ease,
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(12), padding: EdgeInsets.all(compact ? 10 : 14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: selected ? scheme.primary.withValues(alpha: 0.10) : scheme.surfaceContainerLow, color: selected ? scheme.primary.withValues(alpha: 0.10) : scheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
border: Border.all(color: selected ? scheme.primary : Colors.transparent, width: 2), border: Border.all(color: selected ? scheme.primary : Colors.transparent, width: 2),
), ),
child: Row(
crossAxisAlignment: compact ? CrossAxisAlignment.start : CrossAxisAlignment.center,
children: [
Icon(icon, size: compact ? 20 : 24, color: selected ? scheme.primary : scheme.onSurfaceVariant),
SizedBox(width: compact ? 10 : 14),
Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon(icon, color: selected ? scheme.primary : scheme.onSurfaceVariant), Text(title,
const SizedBox(height: 8), style: TextStyle(fontWeight: FontWeight.w600, color: selected ? scheme.primary : null)),
Text(title, style: TextStyle(fontWeight: FontWeight.w600, color: selected ? scheme.primary : null)),
const SizedBox(height: 2), const SizedBox(height: 2),
Text(sub, Text(sub,
maxLines: 2, maxLines: 2,
@@ -585,6 +944,9 @@ class _ModeCard extends StatelessWidget {
], ],
), ),
), ),
],
),
),
), ),
); );
} }
@@ -593,7 +955,9 @@ class _ModeCard extends StatelessWidget {
class _RecentGrid extends StatelessWidget { class _RecentGrid extends StatelessWidget {
final List<RecentLink> recents; final List<RecentLink> recents;
final ValueChanged<RecentLink> onPick; final ValueChanged<RecentLink> onPick;
const _RecentGrid({required this.recents, required this.onPick}); /// Right-click: Put it up / Add to queue / Play next, copy or open it.
final void Function(BuildContext context, RecentLink link) onMenu;
const _RecentGrid({required this.recents, required this.onPick, required this.onMenu});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -618,9 +982,11 @@ class _RecentGrid extends StatelessWidget {
child: Tooltip( child: Tooltip(
message: l.url, message: l.url,
waitDuration: const Duration(milliseconds: 600), waitDuration: const Duration(milliseconds: 600),
child: InkWell( // The card's own context: the menu opens at the card.
child: Builder(builder: (context) => InkWell(
key: ValueKey('recent-link-${l.url}'), key: ValueKey('recent-link-${l.url}'),
onTap: () => onPick(l), onTap: () => onPick(l),
onSecondaryTap: () => onMenu(context, l),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -647,7 +1013,7 @@ class _RecentGrid extends StatelessWidget {
), ),
], ],
), ),
), )),
), ),
), ),
], ],
+37 -2
View File
@@ -78,6 +78,8 @@ class LibraryTab extends StatefulWidget {
final BrowseStart? jellyfinStart; final BrowseStart? jellyfinStart;
/// Puts the pick up; returns an error text, or null when sent. /// Puts the pick up; returns an error text, or null when sent.
final Future<String?> Function(PickedMedia media) onPutUp; final Future<String?> Function(PickedMedia media) onPutUp;
/// Adds a pick to Up next; null without a server (no queue actions).
final AddToQueue? onAddToQueue;
final VoidCallback? onPutUpDone; final VoidCallback? onPutUpDone;
final VoidCallback? onOpenIntegrations; final VoidCallback? onOpenIntegrations;
/// Defaults to [RecentLinks.instance]. /// Defaults to [RecentLinks.instance].
@@ -97,6 +99,7 @@ class LibraryTab extends StatefulWidget {
required this.solo, required this.solo,
required this.isHost, required this.isHost,
required this.onPutUp, required this.onPutUp,
this.onAddToQueue,
this.onPutUpDone, this.onPutUpDone,
this.jellyfinStart, this.jellyfinStart,
this.onOpenIntegrations, this.onOpenIntegrations,
@@ -164,6 +167,13 @@ class _LibraryTabState extends State<LibraryTab> {
/// A Jellyfin card or episode: a series goes up as its next episode. /// A Jellyfin card or episode: a series goes up as its next episode.
Future<String?> _putUpJellyfin(JellyfinSelection sel) async { Future<String?> _putUpJellyfin(JellyfinSelection sel) async {
final pick = await _jellyfinPick(sel);
return pick is JellyfinMedia ? _put(pick) : pick as String;
}
/// What a Jellyfin card stands for (a series: its next episode), or an
/// error text.
Future<Object> _jellyfinPick(JellyfinSelection sel) async {
final account = widget.accounts.account(sel.serverId); final account = widget.accounts.account(sel.serverId);
final client = widget.accounts.clientFor(sel.serverId); final client = widget.accounts.clientFor(sel.serverId);
if (account == null || client == null) return 'that Jellyfin server is not linked any more'; if (account == null || client == null) return 'that Jellyfin server is not linked any more';
@@ -175,7 +185,23 @@ class _LibraryTabState extends State<LibraryTab> {
return e.message; return e.message;
} }
if (item == null) return 'nothing to play in there'; if (item == null) return 'nothing to play in there';
return _put(pickFor(account, item)); return pickFor(account, item);
}
/// "Add to queue" on a Jellyfin card, episode or the details panel.
Future<String?> _queueJellyfin(JellyfinSelection sel, {bool next = false}) async {
final add = widget.onAddToQueue;
if (add == null) return null;
final pick = await _jellyfinPick(sel);
return pick is JellyfinMedia ? add(pick, next: next) : pick as String;
}
/// A card's menu: errors as a snack bar.
Future<void> _cardQueue(JellyfinSelection sel, {bool next = false}) async {
final error = await _queueJellyfin(sel, next: next);
if (error != null && mounted) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error)));
}
} }
/// Double-click on a card: put it up, errors as a snack bar. /// Double-click on a card: put it up, errors as a snack bar.
@@ -235,6 +261,7 @@ class _LibraryTabState extends State<LibraryTab> {
selected: _c.selected, selected: _c.selected,
onSelect: _c.select, onSelect: _c.select,
onPutUp: _doublePutUp, onPutUp: _doublePutUp,
onAddToQueue: widget.onAddToQueue == null ? null : _cardQueue,
onOpenIntegrations: widget.onOpenIntegrations, onOpenIntegrations: widget.onOpenIntegrations,
start: widget.jellyfinStart, start: widget.jellyfinStart,
libraryStrip: false, libraryStrip: false,
@@ -251,6 +278,7 @@ class _LibraryTabState extends State<LibraryTab> {
recents: widget.recentLinks ?? RecentLinks.instance, recents: widget.recentLinks ?? RecentLinks.instance,
probe: widget.probe, probe: widget.probe,
onPutUp: _put, onPutUp: _put,
onAddToQueue: widget.onAddToQueue,
), ),
MediaKind.file => _files(context), MediaKind.file => _files(context),
}; };
@@ -282,6 +310,7 @@ class _LibraryTabState extends State<LibraryTab> {
selection: shown, selection: shown,
solo: widget.solo, solo: widget.solo,
onPutUp: _putUpJellyfin, onPutUp: _putUpJellyfin,
onAddToQueue: widget.onAddToQueue == null ? null : _queueJellyfin,
onClose: () => _c.select(null), onClose: () => _c.select(null),
), ),
)); ));
@@ -348,7 +377,13 @@ class _LibraryTabState extends State<LibraryTab> {
if (b != null) { if (b != null) {
return b(context, solo: widget.solo, relayed: relayed, onPlay: play, nowPlayingPath: now); return b(context, solo: widget.solo, relayed: relayed, onPlay: play, nowPlayingPath: now);
} }
return FilesPage(solo: widget.solo, relayed: relayed, onPlay: play, nowPlayingPath: now); final add = widget.onAddToQueue;
return FilesPage(
solo: widget.solo,
relayed: relayed,
onPlay: play,
onAddToQueue: add == null ? null : (path, {next = false}) => add(FileMedia(path), next: next),
nowPlayingPath: now);
}); });
} }
} }
@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart' show RelayConstants;
import '../state/session_state.dart';
import '../widgets/app_menu.dart' show AppGlass;
import 'motion.dart';
// "Mark a problem" (Ctrl+Shift+B, the player's ⚙, Settings → About): a
// marker line in the server's session log at this moment, with an
// optional short note, so whoever reads the log finds the spot.
/// The key that opens it.
const markProblemKey = SingleActivator(LogicalKeyboardKey.keyB, control: true, shift: true);
/// Asks for the note, then marks; the toast says where it went.
Future<void> showMarkProblem(BuildContext context) async {
final note = await showPopover<String>(context, at: Alignment.center, child: const MarkProblemCard());
if (note == null) return;
final coord = SessionState.instance.coordinator;
final inSession = await coord.markProblem(note);
coord.showToast(inSession ? 'Marked in the session log' : "Marked in this app's log");
}
/// The small card: a one-line note and Mark. Pops with the note (maybe
/// empty), or nothing on Cancel / Esc.
class MarkProblemCard extends StatefulWidget {
const MarkProblemCard({super.key});
@override
State<MarkProblemCard> createState() => _MarkProblemCardState();
}
class _MarkProblemCardState extends State<MarkProblemCard> {
final _note = TextEditingController();
@override
void dispose() {
_note.dispose();
super.dispose();
}
void _mark() => Navigator.of(context).pop(_note.text.trim());
@override
Widget build(BuildContext context) {
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
return AppGlass(
key: const ValueKey('mark-problem-card'),
child: SizedBox(
width: 380,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Mark a problem', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
const SizedBox(height: 4),
Text("Puts a marker in the server's session log right now, so whoever reads it finds the moment.",
style: TextStyle(color: muted, fontSize: 12)),
const SizedBox(height: 8),
TextField(
key: const ValueKey('mark-problem-note'),
controller: _note,
autofocus: true,
maxLength: RelayConstants.maxProblemNoteLength,
decoration: const InputDecoration(
isDense: true,
hintText: 'What happened? (optional)',
counterText: '',
),
onSubmitted: (_) => _mark(),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
const SizedBox(width: 8),
FilledButton.tonal(
key: const ValueKey('mark-problem-go'),
onPressed: _mark,
child: const Text('Mark'),
),
],
),
],
),
),
),
);
}
}
/// "Mark a problem" as a quiet row button (the ⚙ card, Settings → About).
class MarkProblemButton extends StatelessWidget {
final bool outlined;
const MarkProblemButton({super.key, this.outlined = false});
@override
Widget build(BuildContext context) {
const icon = Icon(Icons.flag_outlined, size: 18);
const label = Text('Mark a problem');
void open() => showMarkProblem(context);
return Tooltip(
message: 'Ctrl+Shift+B',
child: outlined
? OutlinedButton.icon(key: const ValueKey('mark-problem'), onPressed: open, icon: icon, label: label)
: TextButton.icon(key: const ValueKey('mark-problem'), onPressed: open, icon: icon, label: label),
);
}
}
@@ -4,6 +4,8 @@ import 'package:signals/signals_flutter.dart';
import '../session/now_playing.dart'; import '../session/now_playing.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart';
import 'motion.dart'; import 'motion.dart';
/// Tests: art bytes by hash instead of the session's cache. /// Tests: art bytes by hash instead of the session's cache.
@@ -47,18 +49,20 @@ Widget fadeInImage(ImageProvider image, {BoxFit fit = BoxFit.cover}) => Image(
); );
/// The portrait cover (2:3) of the current media, a quiet tile with a /// The portrait cover (2:3) of the current media, a quiet tile with a
/// film icon until (or unless) its art arrives. /// film icon until (or unless) its art arrives; [wide] (a video
/// thumbnail): 16:9 at the same height.
class ArtCover extends StatelessWidget { class ArtCover extends StatelessWidget {
final String hash; final String hash;
final double width; final double width;
const ArtCover({super.key, required this.hash, this.width = 40}); final bool wide;
const ArtCover({super.key, required this.hash, this.width = 40, this.wide = false});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final image = artImage(hash); final image = artImage(hash);
final r = BorderRadius.circular(width >= 80 ? 10 : 6); final r = BorderRadius.circular(width >= 80 ? 10 : 6);
return Container( return Container(
width: width, width: wide ? width * 1.5 * 16 / 9 : width,
height: width * 1.5, height: width * 1.5,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: r, borderRadius: r,
@@ -116,7 +120,7 @@ class NowPlayingBlock extends StatelessWidget {
key: ValueKey('now-playing:${n.title}\n${episode ?? ''}'), key: ValueKey('now-playing:${n.title}\n${episode ?? ''}'),
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (cover) ...[ArtCover(hash: n.posterHash), const SizedBox(width: 12)], if (cover) ...[ArtCover(hash: n.posterHash, wide: n.thumbnail), const SizedBox(width: 12)],
Flexible( Flexible(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -151,14 +155,17 @@ class NowPlayingBlock extends StatelessWidget {
} }
} }
/// [NowPlayingBlock] of the session's current media. /// [NowPlayingBlock] of the session's current media; right-click copies
/// its title or link, or opens it ([nowPlayingMenu]).
class NowPlayingTitle extends StatelessWidget { class NowPlayingTitle extends StatelessWidget {
final bool cover; final bool cover;
final bool compact; final bool compact;
const NowPlayingTitle({super.key, this.cover = true, this.compact = false}); const NowPlayingTitle({super.key, this.cover = true, this.compact = false});
@override @override
Widget build(BuildContext context) => ListenableBuilder( Widget build(BuildContext context) => GestureDetector(
onSecondaryTap: () => nowPlayingMenu(context),
child: ListenableBuilder(
listenable: artChanges(), listenable: artChanges(),
builder: (context, _) => SignalBuilder( builder: (context, _) => SignalBuilder(
builder: (context) => NowPlayingBlock( builder: (context) => NowPlayingBlock(
@@ -167,5 +174,38 @@ class NowPlayingTitle extends StatelessWidget {
compact: compact, compact: compact,
), ),
), ),
),
); );
} }
/// Copy title; with the link known here ([SessionCoordinator.currentLink])
/// Copy link and Open in browser; a Jellyfin item on a server linked here,
/// Open in Jellyfin. [context]: where the menu opens.
Future<void> nowPlayingMenu(BuildContext context) async {
final coord = SessionState.instance.coordinator;
final now = coord.nowPlaying.peek();
if (now == null) return;
final link = coord.currentLink();
final ref = coord.currentRef.peek();
final page = ref == null ? null : coord.jellyfin.webUrlOf(ref.serverId, ref.itemId);
final episode = now.episode;
final title = episode == null || episode.isEmpty ? now.title : '${now.title} · $episode';
final pick = await showAppMenu<String>(context, items: [
const AppMenuItem(value: 'title', label: ReuseText.copyTitle),
if (link != null) ...const [
AppMenuItem(value: 'link', label: ReuseText.copyLink),
AppMenuItem(value: 'open', label: ReuseText.openInBrowser),
],
if (page != null) const AppMenuItem(value: 'jellyfin', label: ReuseText.openInJellyfin),
]);
switch (pick) {
case 'title':
await copyToClipboard(title, done: 'Title copied');
case 'link':
await copyToClipboard(link!, done: 'Link copied');
case 'open':
await openLink(link!);
case 'jellyfin':
await openLink(page!);
}
}
+30 -27
View File
@@ -1,18 +1,21 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import '../screens/home/ui.dart' show appName, displayVersion;
import '../screens/home/update_indicator.dart'; import '../screens/home/update_indicator.dart';
import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge; import '../session/delivery.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../update/update_controller.dart'; import '../update/update_controller.dart';
import '../widgets/app_keys.dart'; import '../widgets/app_keys.dart';
import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart';
import '../widgets/window_buttons.dart'; import '../widgets/window_buttons.dart';
import 'avatar.dart'; import 'avatar.dart';
import 'delivery_line.dart';
import 'motion.dart'; import 'motion.dart';
/// The shell's tabs, in strip order. /// The shell's tabs, in strip order.
@@ -34,10 +37,6 @@ const stripAvatarSize = 42.0;
/// so the tabs keep their room. /// so the tabs keep their room.
const stripCompactBelow = 1100.0; 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 60 px strip over everything: your avatar (profile), the tabs, and
/// the party: offline Join a friend / Host, solo a "Solo" chip, Invite /// the party: offline Join a friend / Host, solo a "Solo" chip, Invite
/// friends and Stop, online the avatars (members card), server and /// friends and Stop, online the avatars (members card), server and
@@ -126,6 +125,8 @@ class PartyStrip extends StatelessWidget {
label: t.label, label: t.label,
selected: t == tab, selected: t == tab,
compact: compact, compact: compact,
// The version, a rest of the mouse away.
tooltip: t == ShellTab.settings ? '$appName ${displayVersion()}' : null,
onTap: () => onTab(t), onTap: () => onTab(t),
), ),
], ],
@@ -366,18 +367,21 @@ class StripTab extends StatelessWidget {
final bool selected; final bool selected;
final bool compact; final bool compact;
final VoidCallback onTap; final VoidCallback onTap;
/// Shown after resting on the tab.
final String? tooltip;
const StripTab({ const StripTab({
super.key, super.key,
required this.label, required this.label,
required this.selected, required this.selected,
required this.onTap, required this.onTap,
this.compact = false, this.compact = false,
this.tooltip,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
return Semantics( final tab = Semantics(
selected: selected, selected: selected,
button: true, button: true,
child: InkWell( child: InkWell(
@@ -415,6 +419,8 @@ class StripTab extends StatelessWidget {
), ),
), ),
); );
final tip = tooltip;
return tip == null ? tab : Tooltip(message: tip, waitDuration: const Duration(milliseconds: 600), child: tab);
} }
} }
@@ -482,6 +488,9 @@ class MembersCard extends StatelessWidget {
final address = hosting final address = hosting
? null ? null
: '${session.lastForm.host}:${session.lastForm.port}'; : '${session.lastForm.host}:${session.lastForm.port}';
final delivery = r == null
? null
: deliveryOf(r, selfId: coord.sync.memberId, names: coord.memberNames);
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -493,11 +502,12 @@ class MembersCard extends StatelessWidget {
style: const TextStyle(fontWeight: FontWeight.w600), style: const TextStyle(fontWeight: FontWeight.w600),
), ),
), ),
if (delivery != null) SourceLine(delivery: delivery),
for (final m in r?.members ?? const <MemberView>[]) for (final m in r?.members ?? const <MemberView>[])
_MemberTile( _MemberTile(
member: m, member: m,
info: memberAvatarInfo(m, r, coord), info: memberAvatarInfo(m, r, coord),
viaHost: viaHostOf(m, coord.currentRef.value), delivery: delivery == null ? null : memberDeliveryOf(m, r!, delivery),
), ),
if (r == null) if (r == null)
Padding( Padding(
@@ -515,8 +525,7 @@ class MembersCard extends StatelessWidget {
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: TextButton.icon( child: TextButton.icon(
key: const ValueKey('copy-invite'), key: const ValueKey('copy-invite'),
onPressed: () => onPressed: () => copyToClipboard(address, done: 'Invite address copied'),
Clipboard.setData(ClipboardData(text: address)),
icon: const Icon(Icons.copy, size: 16), icon: const Icon(Icons.copy, size: 16),
label: const Text('Copy invite address'), label: const Text('Copy invite address'),
), ),
@@ -539,11 +548,12 @@ class MembersCard extends StatelessWidget {
class _MemberTile extends StatelessWidget { class _MemberTile extends StatelessWidget {
final MemberView member; final MemberView member;
final AvatarInfo info; final AvatarInfo info;
final bool viaHost; /// How it gets the media and its buffer; null with nothing up or alone.
final MemberDelivery? delivery;
const _MemberTile({ const _MemberTile({
required this.member, required this.member,
required this.info, required this.info,
required this.viaHost, this.delivery,
}); });
@override @override
@@ -574,13 +584,11 @@ class _MemberTile extends StatelessWidget {
if (info.host) const Pill('host', Colors.amber), if (info.host) const Pill('host', Colors.amber),
if (info.relaying) if (info.relaying)
const Pill('passing the video on', Colors.lightBlueAccent), const Pill('passing the video on', Colors.lightBlueAccent),
if (viaHost) const ViaHostBadge(),
], ],
), ),
subtitle: Text(status), subtitle: DeliveryStatus(status: status, delivery: delivery),
trailing: PopupMenuButton<String>( trailing: AppMenuButton<String>(
tooltip: 'More', tooltip: 'More',
icon: const Icon(Icons.more_vert, size: 18),
onSelected: (v) { onSelected: (v) {
switch (v) { switch (v) {
case 'force': case 'force':
@@ -593,18 +601,13 @@ class _MemberTile extends StatelessWidget {
coord.joinBack(); coord.joinBack();
} }
}, },
itemBuilder: (_) => [ items: () => [
if (!m.out) if (!m.out) AppMenuItem(value: 'force', label: m.ready ? 'Force unready' : 'Force ready'),
PopupMenuItem( if (!info.self) const AppMenuItem(value: 'nudge', label: 'Nudge'),
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) if (info.self && m.out)
const PopupMenuItem(value: 'back', child: Text('Join back')) const AppMenuItem(value: 'back', label: 'Join back')
else if (!m.out) else if (!m.out)
const PopupMenuItem(value: 'out', child: Text('Sit out')), const AppMenuItem(value: 'out', label: 'Sit out'),
], ],
), ),
); );
@@ -632,7 +635,7 @@ class _HostInviteState extends State<_HostInvite> {
future: _addresses, future: _addresses,
builder: (context, snap) { builder: (context, snap) {
final list = snap.data ?? const <String>[]; final list = snap.data ?? const <String>[];
void copy(String a) => Clipboard.setData(ClipboardData(text: a)); void copy(String a) => copyToClipboard(a, done: 'Invite address copied');
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Column( child: Column(
+5 -20
View File
@@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
@@ -7,6 +6,7 @@ import 'package:sync_relay_core/core.dart';
import '../session/health.dart'; import '../session/health.dart';
import '../session/session_coordinator.dart'; import '../session/session_coordinator.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../widgets/app_menu.dart' show AppGlass;
import 'avatar.dart'; import 'avatar.dart';
import 'motion.dart'; import 'motion.dart';
@@ -241,32 +241,17 @@ class _PlayerStatusState extends State<PlayerStatus> {
} }
} }
/// Frosted dark glass in a pill: the status pill, the unread chip. /// The player's pill surface (the status pill, the action pills, the
/// unread chip): the app's glass as a stadium ([AppGlass.pill]).
class PlayerGlass extends StatelessWidget { class PlayerGlass extends StatelessWidget {
final Widget child; final Widget child;
const PlayerGlass({super.key, required this.child}); const PlayerGlass({super.key, required this.child});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) => ConstrainedBox(
final r = BorderRadius.circular(999);
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560), constraints: const BoxConstraints(maxWidth: 560),
child: ClipRRect( child: AppGlass.pill(child: child),
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 { class _StatusLine extends StatelessWidget {
File diff suppressed because it is too large. Load diff
+49 -24
View File
@@ -18,10 +18,10 @@ import '../screens/home/about_page.dart';
import '../screens/home/settings_page.dart'; import '../screens/home/settings_page.dart';
import '../sfx/sfx.dart'; import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart'; import '../sfx/sound_bank.dart';
import '../sfx/synth_sfx.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../state/settings.dart'; import '../state/settings.dart';
import '../update/update_controller.dart'; import '../update/update_controller.dart';
import '../widgets/app_menu.dart';
import 'avatar.dart'; import 'avatar.dart';
import 'motion.dart'; import 'motion.dart';
@@ -120,12 +120,16 @@ class SettingsTab extends StatelessWidget {
return [ return [
const SettingsHintCard( const SettingsHintCard(
icon: Icons.tune, icon: Icons.tune,
text: "Player settings are also in the player's ⚙, at the right end of its controls.", text: "Player settings are also in the player: ⚙ → Player settings, at the right of its controls.",
), ),
SettingsPanel( SettingsPanel(
title: 'Player', title: 'Player',
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()), child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()),
), ),
SettingsPanel(
title: 'Controls',
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: controlsSettingRows()),
),
const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()), const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()),
const SettingsPanel(title: 'yt-dlp', child: YtDlpSettings()), const SettingsPanel(title: 'yt-dlp', child: YtDlpSettings()),
]; ];
@@ -622,11 +626,15 @@ class _CookieBrowserDialogState extends State<_CookieBrowserDialog> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
DropdownButtonFormField<String>( Row(
initialValue: _browser, children: [
decoration: const InputDecoration(labelText: 'Browser'), const Expanded(child: Text('Browser')),
items: [for (final b in cookieBrowsers) DropdownMenuItem(value: b, child: Text(b))], AppSelect<String>(
onChanged: (v) => setState(() => _browser = v ?? _browser), value: _browser,
items: [for (final b in cookieBrowsers) (b, b)],
onChanged: (v) => setState(() => _browser = v),
),
],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
@@ -723,8 +731,8 @@ class _AnimeFlowCardState extends State<AnimeFlowCard> {
} }
} }
/// What pops up and what makes a sound. No switches: there is nothing to /// What pops up (chat pop-ups: [AppSettings.chatPopups]) and what makes
/// choose yet. /// a sound.
class NotificationsInfo extends StatelessWidget { class NotificationsInfo extends StatelessWidget {
const NotificationsInfo({super.key}); const NotificationsInfo({super.key});
@@ -746,11 +754,35 @@ class NotificationsInfo extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
line('Joins, leaves, video changes and chat', line('Joins, leaves and video changes',
'Pop up bottom left for 5 s, with the person\'s picture'), 'Pop up bottom left for 5 s, with the person\'s picture'),
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Chat messages'),
Text('Pop up the same way; always in Chat & activity (Tab)', style: muted),
],
),
),
SignalBuilder(
builder: (context) => AppSelect<ChatPopups>(
key: const ValueKey('chat-popups'),
value: AppSettings.instance.chatPopups.value,
onChanged: AppSettings.instance.setChatPopups,
items: [for (final m in ChatPopups.values) (m, m.label)],
),
),
],
),
),
line('Sat out or nudged by someone', 'Only on your end, with their picture'), line('Sat out or nudged by someone', 'Only on your end, with their picture'),
line('Ready, seeks and pauses', 'Only in Chat & activity (Tab), never as a pop-up'), line('Ready, seeks and pauses', 'Only in Chat & activity (Tab), never as a pop-up'),
line('App notices', 'Shader changes, a frozen video reloaded, the idle warning'), line('App notices', 'Shader changes, the idle warning'),
], ],
), ),
), ),
@@ -765,10 +797,7 @@ class NotificationsInfo extends StatelessWidget {
class SoundSettings extends StatelessWidget { class SoundSettings extends StatelessWidget {
const SoundSettings({super.key}); const SoundSettings({super.key});
void _preview(SfxKind kind) { void _preview(SfxKind kind) => unawaited(SessionState.instance.sfx.preview(kind, readyIndex: 2));
final sfx = SessionState.instance.sfx;
if (sfx is SynthSfx) unawaited(sfx.preview(kind, readyIndex: 2));
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -804,17 +833,15 @@ class SoundSettings extends StatelessWidget {
Row( Row(
children: [ children: [
const Expanded(child: Text('Sound set')), const Expanded(child: Text('Sound set')),
DropdownButton<String>( AppSelect<String>(
key: const ValueKey('sound-preset'), key: const ValueKey('sound-preset'),
value: overall, value: overall,
onChanged: (v) { onChanged: (v) {
if (v == null) return;
s.setSoundPreset(v); s.setSoundPreset(v);
_preview(SfxKind.ready); _preview(SfxKind.ready);
}, },
items: [ items: [
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries) for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries) (p.name, name),
DropdownMenuItem(value: p.name, child: Text(name)),
], ],
), ),
], ],
@@ -839,19 +866,17 @@ class SoundSettings extends StatelessWidget {
children: [Text(name), Text(help, style: muted)], children: [Text(name), Text(help, style: muted)],
), ),
), ),
DropdownButton<String?>( AppSelect<String?>(
key: ValueKey('sound-preset-${kind.name}'), key: ValueKey('sound-preset-${kind.name}'),
value: per[kind.name], value: per[kind.name],
underline: const SizedBox.shrink(),
style: muted, style: muted,
onChanged: (v) { onChanged: (v) {
s.setSoundPresetFor(kind.name, v); s.setSoundPresetFor(kind.name, v);
if (volume > 0) _preview(kind); if (volume > 0) _preview(kind);
}, },
items: [ items: [
const DropdownMenuItem<String?>(value: null, child: Text('Same as set')), (null, 'Same as set'),
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries) for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries) (p.name, name),
DropdownMenuItem<String?>(value: p.name, child: Text(name)),
], ],
), ),
// A nudge always plays (an explicit poke): no switch, // A nudge always plays (an explicit poke): no switch,
+2 -1
View File
@@ -82,7 +82,8 @@ class StartCard extends StatelessWidget {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
if (!small) ...[ // A thumbnail already fills the card as the banner.
if (!small && !now.thumbnail) ...[
ArtCover(hash: now.posterHash, width: coverWidth), ArtCover(hash: now.posterHash, width: coverWidth),
const SizedBox(width: 24), const SizedBox(width: 24),
], ],
+8 -2
View File
@@ -5,6 +5,7 @@ import 'package:signals/signals_flutter.dart';
import '../session/session_coordinator.dart' show ToastItem; import '../session/session_coordinator.dart' show ToastItem;
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../state/settings.dart';
import 'avatar.dart'; import 'avatar.dart';
import 'motion.dart'; import 'motion.dart';
@@ -47,7 +48,7 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
/// The coordinator's toasts as cards with avatars, bottom-left: each /// The coordinator's toasts as cards with avatars, bottom-left: each
/// slides in, stays [life], then slides out and collapses its gap. A click /// 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 /// on a chat toast opens the chat ([onOpenChat]); the rest let clicks
/// through. /// through. Chat toasts follow [AppSettings.chatPopups].
class PartyToasts extends StatefulWidget { class PartyToasts extends StatefulWidget {
final Duration life; final Duration life;
final VoidCallback? onOpenChat; final VoidCallback? onOpenChat;
@@ -76,8 +77,13 @@ class _PartyToastsState extends State<PartyToasts> {
if (fresh.isEmpty) return; if (fresh.isEmpty) return;
// Only new toasts re-run this, not the party they mention. // Only new toasts re-run this, not the party they mention.
untracked(() { untracked(() {
final playing = coord.serverState.value?.transport == 'playing';
final popups = AppSettings.instance.chatPopups.value;
for (final t in fresh) { for (final t in fresh) {
_add(shellToastFor(t, (n) => avatarForName(n, coord))); final toast = shellToastFor(t, (n) => avatarForName(n, coord));
// Chat per Settings → Notifications (it is in the chat either way).
if (toast.chat && !popups.shows(playing: playing)) continue;
_add(toast);
} }
}); });
}); });
@@ -33,10 +33,12 @@ class ServerIsolate {
/// Spawns the server and waits until it listens. Throws a /// Spawns the server and waits until it listens. Throws a
/// [StateError] with the server's message if it cannot start. /// [StateError] with the server's message if it cannot start.
/// [loopbackOnly]: 127.0.0.1 only (the solo session). /// [loopbackOnly]: 127.0.0.1 only (the solo session). [logDir]: where
/// the server writes its session log (none when null).
static Future<ServerIsolate> start( static Future<ServerIsolate> start(
AppConfig config, { AppConfig config, {
bool loopbackOnly = false, bool loopbackOnly = false,
String? logDir,
required void Function(String line) onLog, required void Function(String line) onLog,
required void Function(String error) onCrash, required void Function(String error) onCrash,
Duration timeout = const Duration(seconds: 15), Duration timeout = const Duration(seconds: 15),
@@ -57,9 +59,9 @@ class ServerIsolate {
if (msg is! Map) return; if (msg is! Map) return;
switch (msg['t']) { switch (msg['t']) {
case 'log': case 'log':
final err = msg['error']; final err = msg['error'], stack = msg['stack'];
onLog('${msg['level']} ${msg['logger']}: ${msg['message']}' onLog('${msg['level']} ${msg['logger']}: ${msg['message']}'
'${err == null ? '' : ' ($err)'}'); '${err == null ? '' : ' ($err)'}${stack == null ? '' : '\n$stack'}');
case 'ready': case 'ready':
if (!startup.isCompleted) { if (!startup.isCompleted) {
startup.complete((msg['port'] as int, msg['stop'] as SendPort)); startup.complete((msg['port'] as int, msg['stop'] as SendPort));
@@ -85,7 +87,7 @@ class ServerIsolate {
final Isolate isolate; final Isolate isolate;
try { try {
isolate = await Isolate.spawn( isolate = await Isolate.spawn(
_serverMain, ({...config.toMap(), if (loopbackOnly) 'loopbackOnly': true}, events.sendPort), _serverMain, ({...config.toMap(), if (loopbackOnly) 'loopbackOnly': true, 'logDir': ?logDir}, events.sendPort),
errorsAreFatal: false, errorsAreFatal: false,
onError: errors.sendPort, onError: errors.sendPort,
onExit: exit.sendPort, onExit: exit.sendPort,
@@ -10,6 +10,7 @@ import '../jellyfin/accounts.dart';
import '../jellyfin/session_media.dart'; import '../jellyfin/session_media.dart';
import '../screens/home/health_probe.dart'; import '../screens/home/health_probe.dart';
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
import '../platform/app_log.dart' show appLogDir;
import '../playback/yt_dlp_app.dart'; import '../playback/yt_dlp_app.dart';
import '../sfx/sfx.dart'; import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart'; import '../sfx/sound_bank.dart';
@@ -159,7 +160,8 @@ class SessionState {
final route = signal(AppRoute.home); final route = signal(AppRoute.home);
final recents = signal<List<RecentServer>>(const []); final recents = signal<List<RecentServer>>(const []);
final status = signal(const HealthStatus(HealthSeverity.info, 'idle')); final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
final SfxService sfx = SynthSfx( /// Owns the app's audio device ([SynthSfx.dispose] on close).
final SynthSfx sfx = SynthSfx(
volume: () => AppSettings.instance.soundVolume.peek(), volume: () => AppSettings.instance.soundVolume.peek(),
// A nudge is an explicit poke: it ignores its switch (not the volume). // A nudge is an explicit poke: it ignores its switch (not the volume).
enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name), enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name),
@@ -379,7 +381,11 @@ class SessionState {
ytDlpDir: await _sharedYtDlpDir(), ytDlpDir: await _sharedYtDlpDir(),
), ),
loopbackOnly: soloSession, loopbackOnly: soloSession,
onLog: (line) => debugPrint('[server] $line'), // Its session log (every member's shared diagnostics and its own
// records) goes next to the app's; not for a solo session.
logDir: soloSession ? null : appLogDir,
// Its records join the app's log as `server.<logger>`.
onLog: (line) => relogLine('server', line),
onCrash: (cause) => _onServerCrash(server, cause), onCrash: (cause) => _onServerCrash(server, cause),
); );
} catch (e, st) { } catch (e, st) {
+93 -9
View File
@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
import 'package:hive_ce/hive.dart'; import 'package:hive_ce/hive.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import '../controls/model/controls_prefs.dart';
import '../shaders/shader_presets.dart'; import '../shaders/shader_presets.dart';
// App preferences (Settings page), one Hive box. Signals start at their // App preferences (Settings page), one Hive box. Signals start at their
@@ -17,7 +18,14 @@ const _keyShaderPreset = 'shaderPreset';
const _keyShaderQuality = 'shaderQuality'; const _keyShaderQuality = 'shaderQuality';
const _keyCustomShaders = 'customShaders'; const _keyCustomShaders = 'customShaders';
const _keyAutoAdvance = 'autoAdvance'; const _keyAutoAdvance = 'autoAdvance';
/// Legacy: true was the uosc design (now [ControlsSkin.minimal]); read
/// once when [_keyControlsSkin] is missing.
const _keyMpvControls = 'mpvControls'; const _keyMpvControls = 'mpvControls';
const _keyControlsSkin = 'controlsSkin';
const _keyTimeDisplay = 'timeDisplay';
const _keyControlsSize = 'controlsSize';
const _keyControlsHideSecs = 'controlsHideSecs';
const _keyHiddenControls = 'hiddenControls';
const _keyHideReReleases = 'hideReReleases'; const _keyHideReReleases = 'hideReReleases';
const _keyProgressLine = 'progressLine'; const _keyProgressLine = 'progressLine';
const _keySoundVolume = 'soundVolume'; const _keySoundVolume = 'soundVolume';
@@ -27,9 +35,28 @@ const _keyWelcomeDismissed = 'welcomeDismissed';
const _keySoundPresetFor = 'soundPresetFor'; const _keySoundPresetFor = 'soundPresetFor';
const _keyUnreadyOnSwap = 'unreadyOnSwap'; const _keyUnreadyOnSwap = 'unreadyOnSwap';
const _keyIdleSitsOut = 'idleSitsOut'; const _keyIdleSitsOut = 'idleSitsOut';
const _keyChatPopups = 'chatPopups';
const _keyShareDiagnostics = 'shareDiagnostics';
enum HwdecMode { auto, off, custom } enum HwdecMode { auto, off, custom }
/// When chat messages pop up as toasts (they always reach the chat).
enum ChatPopups {
all('All'),
paused('Only when paused'),
off('Off');
final String label;
const ChatPopups(this.label);
/// Whether a chat message pops up now ([playing]: the video plays).
bool shows({required bool playing}) => switch (this) {
ChatPopups.all => true,
ChatPopups.paused => !playing,
ChatPopups.off => false,
};
}
class AppSettings { class AppSettings {
static final AppSettings instance = AppSettings(); static final AppSettings instance = AppSettings();
@@ -43,15 +70,26 @@ class AppSettings {
final unreadyOnSwap = signal(false); final unreadyOnSwap = signal(false);
/// Session: the idle auto-unready sits this member out instead. /// Session: the idle auto-unready sits this member out instead.
final idleSitsOut = signal(false); final idleSitsOut = signal(false);
/// Player design: the uosc controls (`controls/uosc/`) instead of the /// The player controls' skin (`controls/`). Applies live.
/// standard ones (media_kit's with our bars). Applies live. final controlsSkin = signal(ControlsSkin.standard);
final mpvControls = signal(false); /// The bar's clock: elapsed / total or the time left.
final timeDisplay = signal(TimeDisplay.elapsed);
final controlsSize = signal(ControlsSize.normal);
/// Seconds without mouse movement before the controls hide.
final controlsHideSecs = signal(defaultControlsHideSecs);
/// Optional bar buttons switched off (they stay in ⚙).
final hiddenControls = signal<Set<OptionalControl>>(const {});
/// Library: leave out of "Latest in X" what was added long after it /// Library: leave out of "Latest in X" what was added long after it
/// first aired (re-uploads, old seasons added late). /// first aired (re-uploads, old seasons added late).
final hideReReleases = signal(true); final hideReReleases = signal(true);
/// A 2 px progress line at the bottom of the video while the controls /// A 2 px progress line at the bottom of the video while the controls
/// are hidden (both designs). Applies live. /// are hidden (both skins). Applies live.
final progressLine = signal(true); final progressLine = signal(true);
/// Send this app's log lines to the server's session log while in a
/// session (never chat). Off: nothing is sent but a note that it is off.
final shareDiagnostics = signal(true);
/// Chat message toasts (default: only when paused). Applies live.
final chatPopups = signal(ChatPopups.paused);
/// App sounds (`sfx/`): 0..1, 0 = silent. /// App sounds (`sfx/`): 0..1, 0 = silent.
final soundVolume = signal(1.0); final soundVolume = signal(1.0);
/// `SfxKind` names switched off one by one. /// `SfxKind` names switched off one by one.
@@ -103,12 +141,26 @@ class AppSettings {
if (unready is bool) unreadyOnSwap.value = unready; if (unready is bool) unreadyOnSwap.value = unready;
final idleOut = box.get(_keyIdleSitsOut); final idleOut = box.get(_keyIdleSitsOut);
if (idleOut is bool) idleSitsOut.value = idleOut; if (idleOut is bool) idleSitsOut.value = idleOut;
final mpv = box.get(_keyMpvControls); controlsSkin.value =
if (mpv is bool) mpvControls.value = mpv; ControlsSkin.fromStored(box.get(_keyControlsSkin), legacyMpvControls: box.get(_keyMpvControls));
final time = box.get(_keyTimeDisplay);
timeDisplay.value = TimeDisplay.values.where((t) => t.name == time).firstOrNull ?? TimeDisplay.elapsed;
final size = box.get(_keyControlsSize);
controlsSize.value = ControlsSize.values.where((t) => t.name == size).firstOrNull ?? ControlsSize.normal;
final hide = box.get(_keyControlsHideSecs);
if (hide is int && controlsHideChoices.contains(hide)) controlsHideSecs.value = hide;
final hidden = box.get(_keyHiddenControls);
if (hidden is List) {
hiddenControls.value = Set.unmodifiable(OptionalControl.values.where((c) => hidden.contains(c.name)));
}
final hideRe = box.get(_keyHideReReleases); final hideRe = box.get(_keyHideReReleases);
if (hideRe is bool) hideReReleases.value = hideRe; if (hideRe is bool) hideReReleases.value = hideRe;
final line = box.get(_keyProgressLine); final line = box.get(_keyProgressLine);
if (line is bool) progressLine.value = line; if (line is bool) progressLine.value = line;
final share = box.get(_keyShareDiagnostics);
if (share is bool) shareDiagnostics.value = share;
final popups = box.get(_keyChatPopups);
chatPopups.value = ChatPopups.values.where((m) => m.name == popups).firstOrNull ?? ChatPopups.paused;
final vol = box.get(_keySoundVolume); final vol = box.get(_keySoundVolume);
if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1); if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1);
final off = box.get(_keySoundsOff); final off = box.get(_keySoundsOff);
@@ -171,9 +223,31 @@ class AppSettings {
_put(_keyIdleSitsOut, on); _put(_keyIdleSitsOut, on);
} }
void setMpvControls(bool on) { void setControlsSkin(ControlsSkin skin) {
mpvControls.value = on; controlsSkin.value = skin;
_put(_keyMpvControls, on); _put(_keyControlsSkin, skin.name);
}
void setTimeDisplay(TimeDisplay t) {
timeDisplay.value = t;
_put(_keyTimeDisplay, t.name);
}
void setControlsSize(ControlsSize size) {
controlsSize.value = size;
_put(_keyControlsSize, size.name);
}
void setControlsHideSecs(int secs) {
controlsHideSecs.value = secs;
_put(_keyControlsHideSecs, secs);
}
void setControlShown(OptionalControl c, bool on) {
final next = {...hiddenControls.value};
on ? next.remove(c) : next.add(c);
hiddenControls.value = Set.unmodifiable(next);
_put(_keyHiddenControls, [for (final c in next) c.name]);
} }
void setProgressLine(bool on) { void setProgressLine(bool on) {
@@ -181,6 +255,16 @@ class AppSettings {
_put(_keyProgressLine, on); _put(_keyProgressLine, on);
} }
void setShareDiagnostics(bool on) {
shareDiagnostics.value = on;
_put(_keyShareDiagnostics, on);
}
void setChatPopups(ChatPopups mode) {
chatPopups.value = mode;
_put(_keyChatPopups, mode.name);
}
void setSoundVolume(double v) { void setSoundVolume(double v) {
soundVolume.value = v.clamp(0, 1); soundVolume.value = v.clamp(0, 1);
_put(_keySoundVolume, soundVolume.value); _put(_keySoundVolume, soundVolume.value);
+7 -2
View File
@@ -208,8 +208,13 @@ class SyncClient {
_send(PingMessage(clientMs: _clock().millisecondsSinceEpoch).toJson()); _send(PingMessage(clientMs: _clock().millisecondsSinceEpoch).toJson());
} }
void sendSwap(HelloMedia media) { void sendSwap(HelloMedia media, {String? queueId}) {
_send(SwapIntent(media: media).toJson()); _send(SwapIntent(media: media, queueId: queueId).toJson());
}
/// One change to Up next (see [QueueIntent]).
void sendQueue(QueueIntent intent) {
_send(intent.toJson());
} }
void sendReport({ void sendReport({
@@ -107,7 +107,8 @@ class UpdateController {
final root = _root; final root = _root;
if (root != null) await cleanupUpdateLeftovers(root); if (root != null) await cleanupUpdateLeftovers(root);
try { try {
final m = await fetchManifest(url: manifestUrl); final m = await fetchLatestManifest(
currentVersion: currentVersion, url: manifestUrl);
if (!isNewerVersion(currentVersion, m.version)) { if (!isNewerVersion(currentVersion, m.version)) {
_log.info('up to date ($currentVersion, latest ${m.version})'); _log.info('up to date ($currentVersion, latest ${m.version})');
lastCheck.value = 'Up to date'; lastCheck.value = 'Up to date';
+9 -2
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../shell/mark_problem.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import 'text_focus.dart'; import 'text_focus.dart';
@@ -15,11 +16,12 @@ Map<ShortcutActivator, Intent> get appShortcuts => {
const SingleActivator(LogicalKeyboardKey.space): const DoNothingIntent(), const SingleActivator(LogicalKeyboardKey.space): const DoNothingIntent(),
}; };
/// The window's two app-wide keys, ahead of whatever has focus: while in a /// The window's app-wide keys, ahead of whatever has focus: while in a
/// server, Space is ready / not ready and Tab opens or closes the chat, /// server, Space is ready / not ready and Tab opens or closes the chat,
/// on every tab and in every player mode. Not while a text field has /// on every tab and in every player mode. Not while a text field has
/// focus (the chat field handles Tab and Esc itself) nor while a dialog or /// focus (the chat field handles Tab and Esc itself) nor while a dialog or
/// menu is in front of [child]. /// menu is in front of [child]. Ctrl+Shift+B ([markProblemKey]) marks a
/// problem anywhere, a text field focused or not.
class AppKeys extends StatefulWidget { class AppKeys extends StatefulWidget {
/// Who toggles the chat, latest first: the strip while it is shown, the /// Who toggles the chat, latest first: the strip while it is shown, the
/// full player in fullscreen (both hold the shell's toggle). /// full player in fullscreen (both hold the shell's toggle).
@@ -53,6 +55,11 @@ class _AppKeysState extends State<AppKeys> {
bool _key(KeyEvent event) { bool _key(KeyEvent event) {
if (event is! KeyDownEvent || !mounted) return false; if (event is! KeyDownEvent || !mounted) return false;
final key = event.logicalKey; final key = event.logicalKey;
if (markProblemKey.accepts(event, HardwareKeyboard.instance)) {
if (ModalRoute.of(context)?.isCurrent == false) return false;
showMarkProblem(context);
return true;
}
if (key != LogicalKeyboardKey.space && key != LogicalKeyboardKey.tab) return false; if (key != LogicalKeyboardKey.space && key != LogicalKeyboardKey.tab) return false;
if (SessionState.instance.route.peek() != AppRoute.session) return false; if (SessionState.instance.route.peek() != AppRoute.session) return false;
if (ModalRoute.of(context)?.isCurrent == false) return false; if (ModalRoute.of(context)?.isCurrent == false) return false;
+502
View File
@@ -0,0 +1,502 @@
import 'dart:math' as math;
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../controls/frame.dart';
import '../shell/motion.dart';
// The app's one menu: a frosted dark panel anchored under (or over) the
// widget that opens it, rows with a hover and keyboard highlight, a check
// on the current one. [AppSelect] is the value picker built on it (in place
// of a dropdown), [AppMenuButton] the "⋮" action menu. The menu is a popup
// route: a click outside or Esc closes it, arrows and Enter pick, and the
// player's keys stay off while it is open (in fullscreen too).
/// The menu surface, shared with Material's own menus via [appMenuTheme].
abstract final class AppMenuStyle {
/// Over a blur ([showAppMenu]).
static const glass = Color(0xD918181C);
/// Without a blur (Material menus).
static const solid = Color(0xF518181C);
static const border = Color(0x1FFFFFFF);
static const hover = Color(0x14FFFFFF);
static const focus = Color(0x24FFFFFF);
/// The player's pills over the picture: lighter, so less of it is hidden.
static const pillGlass = Color(0x8C000000);
static const blur = 16.0;
static const radius = 12.0;
static const rowRadius = 8.0;
static const rowHeight = 36.0;
static const maxWidth = 360.0;
static final shape = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radius),
side: const BorderSide(color: border, width: 0.5),
);
/// The same surface for `showMenu`, `PopupMenuButton` and `MenuAnchor`.
static ThemeData theme(ThemeData base) => base.copyWith(
popupMenuTheme: PopupMenuThemeData(
color: solid,
surfaceTintColor: Colors.transparent,
elevation: 8,
shape: shape,
menuPadding: const EdgeInsets.all(4),
),
menuTheme: MenuThemeData(
style: MenuStyle(
backgroundColor: const WidgetStatePropertyAll(solid),
surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent),
elevation: const WidgetStatePropertyAll(8),
shape: WidgetStatePropertyAll(shape),
padding: const WidgetStatePropertyAll(EdgeInsets.all(4)),
),
),
);
}
/// The app's one frosted surface: a blur under a dark tint, a hairline
/// border and (cards, menus) a soft shadow. Menus, popovers and the
/// player's cards use it as is; the player's pills with [pill].
class AppGlass extends StatelessWidget {
final Widget child;
final double radius;
final Color color;
final bool shadow;
const AppGlass({
super.key,
required this.child,
this.radius = AppMenuStyle.radius,
this.color = AppMenuStyle.glass,
this.shadow = true,
});
/// A stadium over the picture: the lighter tint, no shadow.
const AppGlass.pill({super.key, required this.child})
: radius = 999,
color = AppMenuStyle.pillGlass,
shadow = false;
@override
Widget build(BuildContext context) {
final r = BorderRadius.circular(radius);
final glass = ClipRRect(
borderRadius: r,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: AppMenuStyle.blur, sigmaY: AppMenuStyle.blur),
child: Material(
color: color,
shape: RoundedRectangleBorder(borderRadius: r, side: const BorderSide(color: AppMenuStyle.border, width: 0.5)),
child: child,
),
),
);
if (!shadow) return glass;
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: r,
boxShadow: const [BoxShadow(color: Color(0x66000000), blurRadius: 24, offset: Offset(0, 8))],
),
child: glass,
);
}
}
/// One row: [label], a dim [hint] at the right, [checked] = the current
/// one (null: the menu has no checks for this row).
class AppMenuItem<T> {
final T value;
final String label;
final String? hint;
final bool? checked;
final bool enabled;
final Key? key;
const AppMenuItem({
required this.value,
required this.label,
this.hint,
this.checked,
this.enabled = true,
this.key,
});
}
/// Opens the menu at [context]'s box (the button). Resolves to the picked
/// value, or null when dismissed. [minWidth]: at least this wide (a picker
/// is as wide as its button).
///
/// [textAt]: where the button's own label starts inside it; the menu then
/// sits so its rows' text lines up with that label (a picker), else it
/// lines up with the button's left edge.
Future<T?> showAppMenu<T>(
BuildContext context, {
required List<AppMenuItem<T>> items,
double minWidth = 0,
double? textAt,
}) async {
final i = await _openMenu(context, items, minWidth, textAt);
return i == null ? null : items[i].value;
}
/// The picked row's index (the value may itself be null).
Future<int?> _openMenu(BuildContext context, List<AppMenuItem<Object?>> items, double minWidth, [double? textAt]) {
final navigator = Navigator.of(context);
final overlay = navigator.overlay!.context.findRenderObject() as RenderBox;
final box = context.findRenderObject() as RenderBox;
final anchor = Rect.fromPoints(
box.localToGlobal(Offset.zero, ancestor: overlay),
box.localToGlobal(box.size.bottomRight(Offset.zero), ancestor: overlay),
);
// Lines the rows' text up with the button's label: panel padding (4)
// plus the row's text inset, against [textAt].
final shift = textAt == null ? 0.0 : textAt - 4 - _MenuRow.textInset(items.any((i) => i.checked != null));
Future<int?> open() => navigator.push<int>(_AppMenuRoute(
anchor: anchor.translate(shift, 0),
items: items,
minWidth: minWidth - shift,
capturedThemes: InheritedTheme.capture(from: context, to: navigator.context),
));
// Over the player's controls: keep them up while the menu is open.
return ControlsFrame.holdWhile(context, open);
}
class _AppMenuRoute extends PopupRoute<int> {
final Rect anchor;
final List<AppMenuItem<Object?>> items;
final double minWidth;
final CapturedThemes capturedThemes;
_AppMenuRoute({required this.anchor, required this.items, required this.minWidth, required this.capturedThemes});
static const _gap = 4.0;
static const _margin = 8.0;
// Opens down unless the room below is short and above is roomier.
bool _below(Size screen) {
final want = items.length * AppMenuStyle.rowHeight + 8;
final below = screen.height - anchor.bottom - _gap - _margin;
final above = anchor.top - _gap - _margin;
return below >= want || below >= above;
}
@override
Color? get barrierColor => null;
@override
bool get barrierDismissible => true;
@override
String? get barrierLabel => 'Close menu';
@override
Duration get transitionDuration => const Duration(milliseconds: 200);
@override
Duration get reverseTransitionDuration => const Duration(milliseconds: 120);
@override
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
return LayoutBuilder(builder: (context, constraints) {
final below = _below(constraints.biggest);
final a = CurvedAnimation(parent: animation, curve: ease, reverseCurve: Curves.easeIn);
return CustomSingleChildLayout(
delegate: _MenuLayout(anchor: anchor, below: below, minWidth: minWidth),
child: FadeTransition(
opacity: a,
child: ScaleTransition(
scale: Tween(begin: 0.96, end: 1.0).animate(a),
alignment: below ? Alignment.topCenter : Alignment.bottomCenter,
child: capturedThemes.wrap(_AppMenuPanel(
items: items,
onPick: (i) => Navigator.of(context).pop(i),
)),
),
),
);
});
}
}
class _MenuLayout extends SingleChildLayoutDelegate {
final Rect anchor;
final bool below;
final double minWidth;
const _MenuLayout({required this.anchor, required this.below, required this.minWidth});
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
const m = _AppMenuRoute._margin, gap = _AppMenuRoute._gap;
final maxW = math.max(0.0, math.min(math.max(AppMenuStyle.maxWidth, minWidth), constraints.maxWidth - 2 * m));
final maxH = below ? constraints.maxHeight - anchor.bottom - gap - m : anchor.top - gap - m;
return BoxConstraints(
minWidth: math.min(minWidth, maxW),
maxWidth: maxW,
maxHeight: math.max(AppMenuStyle.rowHeight + 8, maxH),
);
}
@override
Offset getPositionForChild(Size size, Size childSize) {
const m = _AppMenuRoute._margin, gap = _AppMenuRoute._gap;
final x = anchor.left.clamp(m, math.max(m, size.width - childSize.width - m)).toDouble();
final y = below ? anchor.bottom + gap : anchor.top - gap - childSize.height;
return Offset(x, y.clamp(m, math.max(m, size.height - childSize.height - m)).toDouble());
}
@override
bool shouldRelayout(_MenuLayout old) => old.anchor != anchor || old.below != below || old.minWidth != minWidth;
}
class _AppMenuPanel extends StatefulWidget {
final List<AppMenuItem<Object?>> items;
final ValueChanged<int> onPick;
const _AppMenuPanel({required this.items, required this.onPick});
@override
State<_AppMenuPanel> createState() => _AppMenuPanelState();
}
class _AppMenuPanelState extends State<_AppMenuPanel> {
final _current = GlobalKey();
bool _keys = false;
// Focus (and scroll to) the current row, else the first enabled one.
late final int _start = () {
final checked = widget.items.indexWhere((i) => i.checked == true && i.enabled);
return checked >= 0 ? checked : widget.items.indexWhere((i) => i.enabled);
}();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final c = _current.currentContext;
if (c != null && c.mounted) Scrollable.ensureVisible(c, alignment: 0.5);
});
}
@override
Widget build(BuildContext context) {
final checks = widget.items.any((i) => i.checked != null);
final panel = AppGlass(
key: const ValueKey('app-menu'),
child: IntrinsicWidth(
child: SingleChildScrollView(
padding: const EdgeInsets.all(4),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (i, item) in widget.items.indexed)
_MenuRow(
key: i == _start ? _current : null,
item: item,
checks: checks,
autofocus: i == _start,
showFocus: _keys,
onTap: () => widget.onPick(i),
),
],
),
),
),
);
return Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (_, e) {
if (!_keys && e is KeyDownEvent) setState(() => _keys = true);
return KeyEventResult.ignored;
},
child: panel,
);
}
}
class _MenuRow extends StatelessWidget {
final AppMenuItem<Object?> item;
final bool checks;
final bool autofocus;
// Keyboard in use: the focused row shows (else focus stays invisible,
// so the current row doesn't look hovered when the menu opens).
final bool showFocus;
final VoidCallback onTap;
const _MenuRow(
{super.key,
required this.item,
required this.checks,
required this.autofocus,
required this.showFocus,
required this.onTap});
/// Where a row's text starts, from the row's left edge.
static double textInset(bool checks) => checks ? 8 + 22 : 12;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final on = item.checked == true;
final color = item.enabled ? scheme.onSurface : scheme.onSurface.withValues(alpha: 0.38);
return InkWell(
key: item.key,
autofocus: autofocus,
onTap: item.enabled ? onTap : null,
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
hoverColor: AppMenuStyle.hover,
focusColor: showFocus ? AppMenuStyle.focus : Colors.transparent,
highlightColor: AppMenuStyle.focus,
splashFactory: NoSplash.splashFactory,
child: Container(
height: AppMenuStyle.rowHeight,
padding: EdgeInsets.only(left: checks ? 8 : 12, right: 16),
child: Row(
children: [
if (checks)
SizedBox(
width: 22,
child: on ? Icon(Icons.check_rounded, size: 16, color: scheme.primary) : null,
),
Flexible(
fit: FlexFit.tight,
child: Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 14, color: color),
),
),
if (item.hint != null) ...[
const SizedBox(width: 16),
Text(item.hint!, style: TextStyle(fontSize: 12, color: scheme.onSurfaceVariant)),
],
],
),
),
);
}
}
/// A value picker (in place of a dropdown): the current label and a small
/// chevron on a soft rounded surface; the menu lists [items] (value,
/// label) with the current one checked. [onChanged] null: disabled.
/// [expanded]: fills the width it gets (the label ellipsizes).
class AppSelect<T> extends StatefulWidget {
/// Where the label starts inside the button (its left padding); the
/// menu's text lines up with it.
static const labelInset = 10.0;
final T value;
final List<(T, String)> items;
final ValueChanged<T>? onChanged;
final bool expanded;
final TextStyle? style;
const AppSelect({
super.key,
required this.value,
required this.items,
required this.onChanged,
this.expanded = false,
this.style,
});
@override
State<AppSelect<T>> createState() => _AppSelectState<T>();
}
class _AppSelectState<T> extends State<AppSelect<T>> {
bool _open = false;
Future<void> _show(BuildContext context) async {
final box = context.findRenderObject() as RenderBox;
setState(() => _open = true);
final items = [
for (final (v, label) in widget.items) AppMenuItem<T>(value: v, label: label, checked: v == widget.value),
];
final i = await _openMenu(context, items, box.size.width, AppSelect.labelInset);
if (!mounted) return;
setState(() => _open = false);
if (i != null) widget.onChanged?.call(widget.items[i].$1);
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final enabled = widget.onChanged != null && widget.items.isNotEmpty;
final label = widget.items.where((e) => e.$1 == widget.value).firstOrNull?.$2 ?? '';
final base = Theme.of(context).textTheme.bodyMedium!.merge(widget.style);
final style = enabled ? base : base.copyWith(color: base.color?.withValues(alpha: 0.38));
final r = BorderRadius.circular(AppMenuStyle.rowRadius);
final text = Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: style);
return Semantics(
button: true,
value: label,
child: Builder(
builder: (context) => Material(
type: MaterialType.transparency,
child: InkWell(
onTap: enabled ? () => _show(context) : null,
borderRadius: r,
hoverColor: AppMenuStyle.hover,
focusColor: AppMenuStyle.focus,
highlightColor: Colors.transparent,
splashFactory: NoSplash.splashFactory,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
curve: ease,
padding: const EdgeInsets.only(left: AppSelect.labelInset, right: 6, top: 6, bottom: 6),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: _open ? 0.10 : 0.05),
borderRadius: r,
border: Border.all(color: _open ? scheme.primary.withValues(alpha: 0.6) : AppMenuStyle.border, width: 0.5),
),
child: Row(
mainAxisSize: widget.expanded ? MainAxisSize.max : MainAxisSize.min,
children: [
if (widget.expanded) Expanded(child: text) else Flexible(child: text),
const SizedBox(width: 4),
AnimatedRotation(
turns: _open ? 0.5 : 0,
duration: easeTime,
curve: ease,
child: Icon(Icons.keyboard_arrow_down_rounded,
size: 18, color: enabled ? scheme.onSurfaceVariant : style.color),
),
],
),
),
),
),
),
);
}
}
/// "⋮" (or [icon]) opening an action menu of [items]; [onSelected] gets
/// the picked value.
class AppMenuButton<T> extends StatelessWidget {
final List<AppMenuItem<T>> Function() items;
final ValueChanged<T> onSelected;
final String? tooltip;
final Widget icon;
const AppMenuButton({
super.key,
required this.items,
required this.onSelected,
this.tooltip,
this.icon = const Icon(Icons.more_vert, size: 18),
});
@override
Widget build(BuildContext context) {
return IconButton(
tooltip: tooltip,
icon: icon,
onPressed: () async {
final list = items();
if (list.isEmpty) return;
final i = await _openMenu(context, list, 0);
if (i != null) onSelected(list[i].value);
},
);
}
}
@@ -0,0 +1,66 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import '../state/session_state.dart';
// Reusing what the app shows elsewhere: copying a link or a title, opening
// a page in the browser. One wording and one confirmation (a small toast)
// for every menu that offers them.
/// The labels of the reuse actions, the same in every menu.
abstract final class ReuseText {
static const copyLink = 'Copy link';
static const copyTitle = 'Copy title';
static const copyMessage = 'Copy message';
static const openInBrowser = 'Open in browser';
static const openInJellyfin = 'Open in Jellyfin';
}
/// Tests: where the confirmation goes instead of the session's toasts.
@visibleForTesting
void Function(String text)? debugReuseToast;
void _toast(String text) {
final debug = debugReuseToast;
if (debug != null) return debug(text);
SessionState.instance.coordinator.showToast(text);
}
/// Opens [url] in the default browser (no url_launcher dependency); false
/// when no browser could be started.
Future<bool> openInBrowser(String url) async {
try {
final ProcessResult r;
if (Platform.isWindows) {
r = await Process.run('rundll32', ['url.dll,FileProtocolHandler', url]);
} else if (Platform.isMacOS) {
r = await Process.run('open', [url]);
} else {
r = await Process.run('xdg-open', [url]);
}
return r.exitCode == 0;
} catch (_) {
return false;
}
}
/// Puts [text] on the clipboard and confirms it with a toast ([done]:
/// its text, e.g. "Link copied").
Future<void> copyToClipboard(String text, {String done = 'Copied'}) async {
try {
await Clipboard.setData(ClipboardData(text: text));
} catch (_) {
_toast("Couldn't copy");
return;
}
_toast(done);
}
/// Opens [url] in the browser; when none opens, copies it instead and
/// says so.
Future<void> openLink(String url) async {
if (await openInBrowser(url)) return;
await copyToClipboard(url, done: "Couldn't open a browser — link copied");
}
@@ -1,65 +0,0 @@
import 'dart:ui' as ui;
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import '../playback/frame_watchdog.dart';
/// Fingerprints what the compositor really shows of the video. Place
/// [anchor] under a `RepaintBoundary` around the video (the session player
/// does); [sample] captures that boundary around the newest mounted anchor
/// at ~200 px wide — the texture included — and hashes its centre. This is the only per-presented-frame signal: mpv's own
/// counters keep ticking when the Flutter texture is stuck.
class ShownFrameProbe {
final _anchors = <_AnchorState>[];
Widget anchor() => _Anchor(probe: this);
Future<int?> sample() async {
for (final a in _anchors.reversed) {
if (!a.mounted) continue;
final boundary = a.context.findAncestorRenderObjectOfType<RenderRepaintBoundary>();
if (boundary == null || !boundary.attached || !boundary.hasSize) return null;
final width = boundary.size.width;
if (width <= 0) return null;
ui.Image? image;
try {
image = await boundary.toImage(pixelRatio: (200 / width).clamp(0.02, 1.0));
final data = await image.toByteData(format: ui.ImageByteFormat.rawRgba);
if (data == null) return null;
return hashCenterPixels(data, image.width, image.height);
} catch (_) {
// Route not painted (covered) or mid-teardown: no reading.
return null;
} finally {
image?.dispose();
}
}
return null;
}
}
class _Anchor extends StatefulWidget {
final ShownFrameProbe probe;
const _Anchor({required this.probe});
@override
State<_Anchor> createState() => _AnchorState();
}
class _AnchorState extends State<_Anchor> {
@override
void initState() {
super.initState();
widget.probe._anchors.add(this);
}
@override
void dispose() {
widget.probe._anchors.remove(this);
super.dispose();
}
@override
Widget build(BuildContext context) => const SizedBox.shrink();
}
+20 -5
View File
@@ -1,9 +1,24 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
/// True while keyboard focus sits in a text field. The focused node's /// True while keyboard focus sits in a text field, or anywhere under a
/// context belongs to the `Focus` that `EditableText` builds, so look for /// [KeyboardClaim]. The focused node's context belongs to the `Focus` that
/// an `EditableText` above it (walking FocusNode parents never meets one). /// `EditableText` builds, so look for an `EditableText` above it (walking
/// FocusNode parents never meets one).
bool textInputFocused([FocusNode? node]) { bool textInputFocused([FocusNode? node]) {
final n = node ?? FocusManager.instance.primaryFocus; final c = (node ?? FocusManager.instance.primaryFocus)?.context;
return n?.context?.findAncestorWidgetOfExactType<EditableText>() != null; if (c == null) return false;
return c.findAncestorWidgetOfExactType<EditableText>() != null ||
c.findAncestorWidgetOfExactType<KeyboardClaim>() != null;
}
/// Keyboard focus under it counts as typing ([textInputFocused]): a panel
/// with keys of its own (the Up next popover's Ctrl+V) keeps the app's
/// global keys (Space, Tab, the player's) off while it has focus, as a
/// text field does.
class KeyboardClaim extends StatelessWidget {
final Widget child;
const KeyboardClaim({super.key, required this.child});
@override
Widget build(BuildContext context) => child;
} }
@@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
flutter_soloud
jni jni
) )
+52 -3
View File
@@ -153,6 +153,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.2" version: "0.1.2"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_picker: file_picker:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -227,6 +235,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_soloud:
dependency: "direct main"
description:
name: flutter_soloud
sha256: "1c297e52182c5e8fd447ae8430bf0b598445dc586e72863ceeef0416242b2ae8"
url: "https://pub.dev"
source: hosted
version: "5.2.1"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -246,6 +262,14 @@ packages:
url: "https://git.senkochan.moe/Foxyz/fox_utils.git" url: "https://git.senkochan.moe/Foxyz/fox_utils.git"
source: git source: git
version: "0.1.9" version: "0.1.9"
glob:
dependency: transitive
description:
name: glob
sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
hive_ce: hive_ce:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -342,6 +366,15 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.12.0" version: "4.12.0"
leaf_widgets:
dependency: "direct main"
description:
path: "."
ref: "v0.1.0"
resolved-ref: c0ab4a828beab620ac228693a018659d82dec196
url: "https://git.senkochan.moe/Foxyz/leaf_widgets.git"
source: git
version: "0.0.1"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -486,6 +519,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "2.1.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "90eaad8059ebc2004a63810a4696ea9acdb02473e47ed3b6b3bafb100ac77e31"
url: "https://pub.dev"
source: hosted
version: "0.19.5"
objective_c: objective_c:
dependency: transitive dependency: transitive
description: description:
@@ -614,6 +655,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.0" version: "7.0.0"
process:
dependency: transitive
description:
name: process
sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891"
url: "https://pub.dev"
source: hosted
version: "5.0.6"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
@@ -663,7 +712,7 @@ packages:
source: hosted source: hosted
version: "2.0.6" version: "2.0.6"
screen_retriever: screen_retriever:
dependency: transitive dependency: "direct main"
description: description:
name: screen_retriever name: screen_retriever
sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6 sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6
@@ -777,14 +826,14 @@ packages:
path: "../../packages/core" path: "../../packages/core"
relative: true relative: true
source: path source: path
version: "1.1.1" version: "1.2.0"
sync_relay_server: sync_relay_server:
dependency: "direct main" dependency: "direct main"
description: description:
path: "../../packages/server" path: "../../packages/server"
relative: true relative: true
source: path source: path
version: "1.1.1" version: "1.2.0"
synchronized: synchronized:
dependency: transitive dependency: transitive
description: description:
+16 -1
View File
@@ -1,7 +1,7 @@
name: watch_together name: watch_together
description: Watch-together desktop app (working title, repo sync_relay_project). One binary. description: Watch-together desktop app (working title, repo sync_relay_project). One binary.
publish_to: none publish_to: none
version: 1.1.2 version: 1.2.0
environment: environment:
sdk: '>=3.13.0-0 <4.0.0' sdk: '>=3.13.0-0 <4.0.0'
@@ -23,6 +23,7 @@ dependencies:
hive_ce_flutter: ^2.3.4 hive_ce_flutter: ^2.3.4
image: ^4.9.2 image: ^4.9.2
window_manager: ^0.5.2 window_manager: ^0.5.2
screen_retriever: ^0.2.2
crypto: ^3.0.6 crypto: ^3.0.6
ffi: ^2.1.0 ffi: ^2.1.0
# Drag & drop and clipboard files/images (vendored desktop_drop + pasteboard). # Drag & drop and clipboard files/images (vendored desktop_drop + pasteboard).
@@ -30,6 +31,20 @@ dependencies:
git: git:
url: https://git.senkochan.moe/Foxyz/fox_utils.git url: https://git.senkochan.moe/Foxyz/fox_utils.git
ref: v0.1.9 ref: v0.1.9
# LeafPopover (the Up next popover).
leaf_widgets:
git:
url: https://git.senkochan.moe/Foxyz/leaf_widgets.git
ref: v0.1.0
# App sounds (sfx/, platform/audio/): SoLoud + miniaudio, built from
# source by its build hook (needs only a C++ toolchain).
flutter_soloud: ^5.2.1
# The sounds are WAVs made in memory: no Ogg/Opus/FLAC codecs, so the
# build hook downloads and bundles no Xiph libraries.
hooks:
user_defines:
flutter_soloud:
no_xiph_libs: true
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
+20
View File
@@ -65,4 +65,24 @@ void main() {
expect(readies, 1); expect(readies, 1);
expect(panel, findsNothing, reason: 'Space pressed a focused chat button'); expect(panel, findsNothing, reason: 'Space pressed a focused chat button');
}); });
testWidgets('Ctrl+Shift+B opens Mark a problem; Esc closes it unmarked', (tester) async {
await tester.pumpWidget(MaterialApp(
theme: ThemeData.dark(),
shortcuts: appShortcuts,
home: const AppKeys(child: Scaffold(body: SizedBox())),
));
final card = find.byKey(const ValueKey('mark-problem-card'));
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyB);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pumpAndSettle();
expect(card, findsOneWidget);
expect(textInputFocused(), isTrue, reason: 'the note field takes the keys');
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(card, findsNothing);
});
} }
+218
View File
@@ -0,0 +1,218 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:signals/signals_flutter.dart';
import 'package:watch_together/src/controls/controls_probe.dart';
import 'package:watch_together/src/controls/model/controls_layout.dart';
import 'package:watch_together/src/controls/model/controls_model.dart';
import 'package:watch_together/src/controls/model/controls_prefs.dart';
import 'package:watch_together/src/controls/session_controls.dart';
import 'package:watch_together/src/playback/media_marks.dart';
import 'package:watch_together/src/state/session_state.dart';
import 'package:watch_together/src/state/settings.dart';
// The controls model and its layout spec: which controls the bar has, in
// which order, what goes to ⚙; and both skins drawing the same order.
const none = <OptionalControl>{};
void main() {
group('bar layout', () {
test('play first, fullscreen last; no episode, no chapters: the base set', () {
final bar = barControls(const ControlsSnapshot(), none);
expect(bar.left, [BarControl.playPause, BarControl.volume, BarControl.time]);
expect(bar.right, [BarControl.subtitles, BarControl.audio, BarControl.settings, BarControl.chat, BarControl.fullscreen]);
});
test('episodes and chapters slot in after play, in the spec order', () {
final bar = barControls(
const ControlsSnapshot(previousLabel: 'S1E6', nextLabel: 'S1E8', hasChapters: true, hasDuration: true), none);
expect(bar.left, [
BarControl.playPause,
BarControl.previous,
BarControl.next,
BarControl.chapterPrevious,
BarControl.chapterNext,
BarControl.volume,
BarControl.time,
]);
expect(barControls(const ControlsSnapshot(nextLabel: 'Up'), none).left,
[BarControl.playPause, BarControl.next, BarControl.volume, BarControl.time]);
});
test('hidden optional buttons leave the bar and show in ⚙', () {
const s = ControlsSnapshot(hasChapters: true, hasDuration: true);
final hidden = OptionalControl.values.toSet();
final bar = barControls(s, hidden);
expect(bar.left, [BarControl.playPause, BarControl.volume, BarControl.time]);
expect(bar.right, [BarControl.settings, BarControl.fullscreen]);
expect(menuActions(s, hidden), containsAllInOrder([ControlAction.subtitles, ControlAction.audio, ControlAction.chat]));
final shown = menuActions(s, none);
expect(shown, isNot(contains(ControlAction.subtitles)));
expect(shown, isNot(contains(ControlAction.chat)));
});
test('⚙ menu: what applies, in a fixed order', () {
expect(menuActions(const ControlsSnapshot(), none), [
ControlAction.shaders,
ControlAction.changeVideo,
ControlAction.reload,
ControlAction.markAllReady,
ControlAction.markProblem,
ControlAction.playerSettings,
ControlAction.leave,
]);
final all = menuActions(const ControlsSnapshot(hasChapters: true, hasDuration: true, hasLink: true), none);
expect(all.first, ControlAction.chapters);
expect(all, containsAllInOrder([ControlAction.skipForward, ControlAction.copyLink]));
expect(menuActions(const ControlsSnapshot(solo: true), none), isNot(contains(ControlAction.markAllReady)),
reason: 'nobody to mark ready');
});
test('play / pause follows the session: playing, ready and waiting, not ready', () {
expect(controlIcon(BarControl.playPause, const ControlsSnapshot(play: PlayState.playing)), Icons.pause_rounded);
expect(controlIcon(BarControl.playPause, const ControlsSnapshot(play: PlayState.paused)), Icons.play_arrow_rounded);
expect(controlIcon(BarControl.playPause, const ControlsSnapshot(play: PlayState.waiting)), Icons.check_rounded);
expect(controlTooltip(BarControl.playPause, const ControlsSnapshot(play: PlayState.playing)),
'Pause for everyone (Space)');
expect(controlTooltip(BarControl.playPause, const ControlsSnapshot(solo: true)), 'Play (Space)');
});
test('episode buttons: the label and the keys', () {
const s = ControlsSnapshot(previousLabel: 'S1E6 Before', nextLabel: 'S1E7 Battle with the Heroes');
expect(controlTooltip(BarControl.next, s), 'Next: S1E7 Battle with the Heroes (> or Shift+N)');
expect(controlTooltip(BarControl.previous, s), 'Previous: S1E6 Before (< or Shift+P)');
expect(controlTooltip(BarControl.next, const ControlsSnapshot(nextLabel: 'Clip', nextIsUpNext: true)),
'Up next: Clip (> or Shift+N)');
expect(controlEnabled(BarControl.chapterNext, const ControlsSnapshot(hasChapters: true)), isFalse,
reason: 'no duration yet');
});
test('clock: elapsed / total, or the time left', () {
const p = Duration(minutes: 6), d = Duration(minutes: 24);
expect(clockText(p, d, TimeDisplay.elapsed), '6:00 / 24:00');
expect(clockText(p, d, TimeDisplay.remaining), '-18:00');
expect(clockText(p, Duration.zero, TimeDisplay.remaining), '6:00 / 0:00', reason: 'length unknown');
expect(TimeDisplay.elapsed.toggled, TimeDisplay.remaining);
});
});
group('both skins draw the model', () {
final s = AppSettings.instance;
final coord = SessionState.instance.coordinator;
tearDown(() {
s.controlsSkin.value = ControlsSkin.standard;
s.hiddenControls.value = const {};
s.progressLine.value = true;
coord.playback.chapters.value = const [];
coord.playback.duration.value = Duration.zero;
});
Future<PlayerControlsModel> pump(WidgetTester tester, ControlsSkin skin) async {
tester.view.physicalSize = const Size(1280, 720);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
s.controlsSkin.value = skin;
final model = PlayerControlsModel(
coord: coord,
fullscreen: signal(false),
chatOpen: signal(false),
unread: signal(0),
onToggleFullscreen: () {},
onToggleChat: () {},
onChangeVideo: () {},
onCollapse: () {},
onLeave: () {},
);
final shown = ValueNotifier(true);
final hold = ControlsHold();
addTearDown(() {
model.dispose();
shown.dispose();
hold.dispose();
});
await tester.pumpWidget(MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(
backgroundColor: Colors.black,
body: SessionControls(model: model, shown: shown, hold: hold, onDoubleClick: () {}),
),
));
await tester.pump();
return model;
}
/// The bar's controls left to right, by their keys.
List<BarControl> drawn(WidgetTester tester) {
final found = <(double, BarControl)>[];
for (final c in BarControl.values) {
final f = find.byKey(ValueKey('control-${c.name}'));
if (f.evaluate().isEmpty) continue;
final r = tester.getRect(f);
if (r.width > 0) found.add((r.left, c));
}
found.sort((a, b) => a.$1.compareTo(b.$1));
return [for (final (_, c) in found) c];
}
for (final skin in ControlsSkin.values) {
testWidgets('${skin.label}: the layout\'s order, play leftmost, fullscreen rightmost', (tester) async {
coord.playback.duration.value = const Duration(minutes: 24);
coord.playback.chapters.value = chaptersFromStarts([
('A', Duration.zero),
('B', const Duration(minutes: 2)),
], const Duration(minutes: 24));
final model = await pump(tester, skin);
final bar = model.bar.value;
final want = [
...bar.left,
...bar.right,
].where((c) => skin == ControlsSkin.standard || c != BarControl.time).toList();
expect(drawn(tester), want);
expect(want.first, BarControl.playPause);
expect(want.last, BarControl.fullscreen);
expect(want, contains(BarControl.chapterNext));
s.setControlShown(OptionalControl.chat, false);
await tester.pump();
expect(drawn(tester), isNot(contains(BarControl.chat)));
});
}
testWidgets('⚙ lists the rest', (tester) async {
await pump(tester, ControlsSkin.minimal);
await tester.tap(find.byKey(const ValueKey('control-settings')));
await tester.pumpAndSettle();
for (final a in [ControlAction.reload, ControlAction.markProblem, ControlAction.playerSettings]) {
expect(find.byKey(ValueKey('controls-menu-${a.name}')), findsOneWidget, reason: a.name);
}
expect(find.byKey(const ValueKey('controls-menu-chapters')), findsNothing, reason: 'no chapters');
await tester.tapAt(const Offset(5, 5));
await tester.pumpAndSettle();
});
testWidgets('Minimal hidden: the timeline stays as a line unless switched off', (tester) async {
s.controlsHideSecs.value = 2;
addTearDown(() => s.controlsHideSecs.value = defaultControlsHideSecs);
await pump(tester, ControlsSkin.minimal);
double opacity() => tester.widget<AnimatedOpacity>(find.byKey(const ValueKey('minimal-timeline-wrap'))).opacity;
await tester.pump(const Duration(seconds: 3));
await tester.pumpAndSettle();
expect(opacity(), 1, reason: 'hidden, line on');
s.progressLine.value = false;
await tester.pump();
expect(opacity(), 0, reason: 'hidden, line off');
});
testWidgets('Standard hidden: a thin progress line unless switched off', (tester) async {
await pump(tester, ControlsSkin.standard);
expect(find.byKey(const ValueKey('progress-line-standard')), findsNothing, reason: 'controls up');
await tester.pump(const Duration(seconds: 4));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('progress-line-standard')), findsOneWidget);
s.progressLine.value = false;
await tester.pump();
expect(find.byKey(const ValueKey('progress-line-standard')), findsNothing);
});
});
}
+141
View File
@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/session/delivery.dart';
import 'package:watch_together/src/shell/delivery_line.dart';
MemberView _m(String id, String name, {double buffered = 30, bool host = false, int rate = 0}) =>
MemberView(id: id, name: name, ready: true, inSet: true, buffered: buffered, stale: false, host: host, rate: rate);
StateBroadcast _state(Map<String, Object?>? media, List<MemberView> members, {String transport = 'playing'}) =>
StateBroadcast(
eventId: 1,
transport: transport,
position: 10,
serverTime: DateTime.now().toUtc(),
mediaId: 2,
media: media,
members: members,
);
Map<String, Object?> _relayed(MediaRelay relay) =>
{'name': 'x.mkv', 'size': 0, 'duration': 1000, 'relay': relay.toJson()};
void main() {
final foxyz = _m('m1', 'Foxyz');
final bob = _m('m2', 'Bob', rate: 3200000);
final ann = _m('m3', 'Ann');
test('a member relay: who passes it on, how many read it, how fast', () {
final r = _state(
_relayed(const MediaRelay(source: 'm1', live: true, rate: 4100000, need: 2300000)), [foxyz, bob, ann]);
final d = deliveryOf(r, selfId: 'm2')!;
expect(d.kind, DeliveryKind.memberRelay);
expect(d.state, SourceState.ok);
expect(d.title, 'Foxyz passes it on through the server');
expect(d.detail, '2 through the server · 4.1 MB/s, needs 2.3 MB/s');
expect(d.headroom, closeTo(4.1 / 2.3, 0.01));
expect(deliveryOf(r, selfId: 'm1')!.title, 'You pass it on through the server');
final mb = memberDeliveryOf(bob, r, d);
expect(mb.route, DeliveryRoute.server);
expect(mb.detail, 'via server · 3.2 MB/s · 30 s buffered');
expect(memberDeliveryOf(foxyz, r, d).route, DeliveryRoute.source);
});
test('slow source: amber for everyone, the warning for the source only', () {
final r = _state(
_relayed(const MediaRelay(source: 'm1', live: true, rate: 1200000, need: 2300000)), [foxyz, bob]);
expect(deliveryOf(r, selfId: 'm2')!.state, SourceState.slow);
expect(deliveryOf(r, selfId: 'm2')!.title, "Foxyz's upload is slower than the video");
expect(slowSourceWarning(r, 'm1'), contains('Your upload (1.2 MB/s) is slower than this video needs (2.3 MB/s)'));
expect(slowSourceWarning(r, 'm2'), isNull);
// Bitrate unknown: rates without a comparison, no warning.
final unknown = _state(_relayed(const MediaRelay(source: 'm1', live: true, rate: 1200000)), [foxyz, bob]);
expect(deliveryOf(unknown, selfId: 'm2')!.state, SourceState.ok);
expect(deliveryOf(unknown, selfId: 'm2')!.detail, '1 through the server · 1.2 MB/s');
expect(slowSourceWarning(unknown, 'm1'), isNull);
});
test('the source left: named from before, no rates', () {
final names = MemberNames()..observe(_state(null, [foxyz, bob]));
final r = _state(_relayed(const MediaRelay(source: 'm1', rate: 4100000, need: 2300000)), [bob, ann]);
final d = deliveryOf(r, selfId: 'm2', names: names)!;
expect(d.state, SourceState.left);
expect(d.title, 'Foxyz left: playing what the server has');
expect(d.detail, '2 through the server');
});
test('channel down: waiting', () {
final r = _state(_relayed(const MediaRelay(source: 'm1')), [foxyz, bob]);
expect(deliveryOf(r, selfId: 'm2')!.title, 'Waiting for Foxyz to reconnect');
});
test('links are direct, the host file is the host\'s, solo shows nothing', () {
final link = _state({'name': 'v', 'url': 'https://example.com/v.mp4'}, [foxyz, bob]);
final d = deliveryOf(link, selfId: 'm2')!;
expect(d.title, 'Everyone plays the link directly');
expect(d.detail, isEmpty);
expect(memberDeliveryOf(bob, link, d).short, 'direct');
final file = _state({'name': 'f.mkv'}, [_m('m1', 'Foxyz', host: true), bob]);
expect(deliveryOf(file, selfId: 'm2')!.title, 'Foxyz hosts the file');
expect(deliveryOf(_state({'name': 'f.mkv'}, [bob]), selfId: 'm2'), isNull);
expect(deliveryOf(_state(null, [foxyz, bob]), selfId: 'm2'), isNull);
});
test('Jellyfin: own accounts direct, the rest via server', () {
const ref = {'serverId': 'srv', 'itemId': '0123456789abcdef0123456789abcdef', 'title': 'Pilot'};
MemberView jf(String id, String name, bool own) =>
MemberView(id: id, name: name, ready: true, inSet: true, buffered: 30, stale: false, jf: {'srv': own});
final all = [jf('m1', 'Foxyz', true), jf('m2', 'Bob', true)];
final media = {'name': 'Pilot', 'jellyfin': ref, 'relay': const MediaRelay(source: 'm1', live: true).toJson()};
final d = deliveryOf(_state(media, all), selfId: 'm2')!;
expect(d.title, 'Everyone streams it from Jellyfin', reason: 'the relay stands by');
final mixed = _state(media, [...all, jf('m3', 'Guest', false)]);
final m = deliveryOf(mixed, selfId: 'm2')!;
expect(m.kind, DeliveryKind.memberRelay);
expect(m.detail, '1 with their own account · 1 through the server');
expect(memberDeliveryOf(jf('m2', 'Bob', true), mixed, m).short, 'own Jellyfin account');
});
test('buffer health: enough, low, empty while playing', () {
final r = _state({'name': 'v', 'url': 'https://e/v', 'duration': 1000}, [foxyz, bob]);
final d = deliveryOf(r, selfId: 'm2')!;
expect(memberDeliveryOf(_m('a', 'A', buffered: 30), r, d).health, BufferHealth.good);
expect(memberDeliveryOf(_m('a', 'A', buffered: 2), r, d).health, BufferHealth.low);
expect(memberDeliveryOf(_m('a', 'A', buffered: 0), r, d).health, BufferHealth.starving);
final held = _state(r.media, [foxyz, bob], transport: 'held');
expect(memberDeliveryOf(_m('a', 'A', buffered: 0), held, d).health, BufferHealth.low);
});
test('rates read as MB/s or kB/s', () {
expect(formatRate(4100000), '4.1 MB/s');
expect(formatRate(12000000), '12 MB/s');
expect(formatRate(850000), '850 kB/s');
});
testWidgets('the source line and a member\'s status fit the members card', (tester) async {
final r = _state(
_relayed(const MediaRelay(source: 'm1', live: true, rate: 1200000, need: 2300000)), [foxyz, bob, ann]);
final d = deliveryOf(r, selfId: 'm2')!;
await tester.pumpWidget(MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(
body: Center(
child: SizedBox(
width: 340,
child: Column(mainAxisSize: MainAxisSize.min, children: [
SourceLine(delivery: d),
DeliveryStatus(status: 'ready', delivery: memberDeliveryOf(bob, r, d)),
]),
),
),
),
));
await tester.pumpAndSettle();
expect(find.text("Foxyz's upload is slower than the video"), findsOneWidget);
expect(find.byKey(const ValueKey('headroom-bar')), findsOneWidget);
expect(find.text('ready · via server · 3.2 MB/s'), findsOneWidget);
expect(find.byKey(const ValueKey('health-dot')), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
-108
View File
@@ -1,108 +0,0 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/playback/frame_watchdog.dart';
final _t0 = DateTime.utc(2026, 1, 1);
/// Feeds one sample per second; [frames]/[pos] give the value at second i.
List<bool> _run(
FrameWatchdog w,
int seconds, {
required int? Function(int i) frames,
double Function(int i)? pos,
bool Function(int i)? eligible,
int start = 0,
}) {
return [
for (var i = start; i < start + seconds; i++)
w.feed(FrameSample(
at: _t0.add(Duration(seconds: i)),
position: pos?.call(i) ?? i.toDouble(),
frames: frames(i),
eligible: eligible?.call(i) ?? true,
)),
];
}
void main() {
test('hashes tell frames apart and ignore the edges', () {
final a = ByteData(100 * 60 * 4);
final b = ByteData(100 * 60 * 4);
expect(hashCenterPixels(a, 100, 60), hashCenterPixels(b, 100, 60));
b.setUint8((2 * 100 + 2) * 4, 255); // corner: overlay area
expect(hashCenterPixels(a, 100, 60), hashCenterPixels(b, 100, 60));
b.setUint8((30 * 100 + 50) * 4, 255); // centre: video
expect(hashCenterPixels(a, 100, 60), isNot(hashCenterPixels(b, 100, 60)));
final raw = Uint8List(1 << 20);
final h = hashSpread(raw);
raw[raw.length ~/ 2 - (raw.length ~/ 2) % (raw.length ~/ 4096)] = 1;
expect(hashSpread(raw), isNot(h));
});
test('frames advancing never fires', () {
final w = FrameWatchdog();
expect(_run(w, 30, frames: (i) => i * 30), everyElement(isFalse));
});
test('frames stuck while position advances fires once after ~3 s', () {
final w = FrameWatchdog();
final fired = _run(w, 20, frames: (i) => i < 5 ? i * 30 : 150);
expect(fired.where((f) => f).length, 1);
expect(fired.indexOf(true), 8, reason: 'stuck from t=5, 3 s later');
});
test('pause, buffering, missing data and seeks do not fire', () {
final w = FrameWatchdog();
// Paused / buffering: not eligible.
expect(_run(w, 10, frames: (_) => 100, eligible: (_) => false),
everyElement(isFalse));
// No video output (headless / audio-only).
expect(_run(w, 10, frames: (_) => null, start: 10), everyElement(isFalse));
// Position stuck too (network stall): not a frozen picture.
expect(_run(w, 10, frames: (_) => 100, pos: (_) => 50, start: 20),
everyElement(isFalse));
// Seeking every 2 s with frames stuck in between: jumps reset it.
expect(
_run(w, 12,
frames: (_) => 100,
pos: (i) => i.isEven ? i * 10.0 : i * 10.0 - 9,
start: 30),
everyElement(isFalse));
});
test('no reload loop: re-arms only after 10 s of healthy frames', () {
final w = FrameWatchdog();
expect(_run(w, 10, frames: (_) => 7).where((f) => f).length, 1);
w.reset(); // new player
// Brief healthy spell, then stuck again: must not fire.
expect(
_run(w, 15, frames: (i) => i < 13 ? i : 13, start: 10)
.where((f) => f)
.length,
0);
// Healthy for 10 s+, then stuck: fires again.
w.reset();
expect(
_run(w, 25, frames: (i) => i < 42 ? i : 42, start: 30)
.where((f) => f)
.length,
1);
});
test('still scene: dismiss re-arms after another full stall', () {
final w = FrameWatchdog();
final fired = <int>[];
for (var i = 0; i < 20; i++) {
if (w.feed(FrameSample(
at: _t0.add(Duration(seconds: i)),
position: i.toDouble(),
frames: 5,
eligible: true))) {
fired.add(i);
w.dismiss();
}
}
expect(fired, [3, 6, 9, 12, 15, 18]);
});
}
+1 -1
View File
@@ -87,7 +87,7 @@ void main() {
expect(c.source, MediaKind.jellyfin); expect(c.source, MediaKind.jellyfin);
c.pasteText(' https://example.com/a.mkv '); c.pasteText(' https://example.com/a.mkv ');
expect(c.source, MediaKind.link); expect(c.source, MediaKind.link);
expect(c.selection, const LinkMedia('https://example.com/a.mkv')); expect(c.selection, const LinkMedia('https://example.com/a.mkv', relay: LinkRelay.me));
c.pasteText('/home/me/b.mkv'); c.pasteText('/home/me/b.mkv');
expect(c.source, MediaKind.file); expect(c.source, MediaKind.file);
expect(c.selection, const FileMedia('/home/me/b.mkv')); expect(c.selection, const FileMedia('/home/me/b.mkv'));
@@ -307,7 +307,7 @@ void main() {
final c = MediaPickerController(); final c = MediaPickerController();
expect(c.selection, isNull); expect(c.selection, isNull);
c.pasteText('https://example.com/a.mkv'); c.pasteText('https://example.com/a.mkv');
expect(c.selection, const LinkMedia('https://example.com/a.mkv')); expect(c.selection, const LinkMedia('https://example.com/a.mkv', relay: LinkRelay.me));
c.pasteText('/home/me/b.mkv'); c.pasteText('/home/me/b.mkv');
expect(c.selection, const FileMedia('/home/me/b.mkv')); expect(c.selection, const FileMedia('/home/me/b.mkv'));
c.source = MediaKind.jellyfin; c.source = MediaKind.jellyfin;
+10 -2
View File
@@ -149,6 +149,9 @@ class _FakeJellyfin {
res.statusCode = 204; res.statusCode = 204;
return res.close(); return res.close();
} }
if (path.startsWith('/UserPlayedItems/') || path.startsWith('/UserItems/')) {
return json({'Played': path.startsWith('/UserPlayedItems/'), 'PlaybackPositionTicks': 0});
}
if (RegExp(r'^/Videos/\w+/stream$').hasMatch(path)) return _stream(req); if (RegExp(r'^/Videos/\w+/stream$').hasMatch(path)) return _stream(req);
res.statusCode = 404; res.statusCode = 404;
await res.close(); await res.close();
@@ -378,8 +381,13 @@ void main() {
expect(ep1.last, 'Stopped'); expect(ep1.last, 'Stopped');
expect(ep1.where((k) => k == 'Stopped'), hasLength(1)); expect(ep1.where((k) => k == 'Stopped'), hasLength(1));
final stopped1 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep1); final stopped1 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep1);
expect(stopped1.body!['PositionTicks'], greaterThan(19 * jellyfinTicksPerSecond), // Part of it watched, then a seek to the end: not watched (see
reason: 'played to the end'); // WatchSession). A resume point capped at 85 % of the 20 s, so
// Jellyfin cannot mark it played on its own.
expect(stopped1.body!['PositionTicks'], lessThan(18 * 10000000),
reason: 'part way: resume point under 90 %, so not marked played');
expect(jelly.calls.any((c) => c.method == 'POST' && c.path == '/UserPlayedItems/$_ep1'), isFalse,
reason: 'seeking to the end is not watching');
expect(stopped1.body!['PlaySessionId'], 'ps-ua-$_ep1'); expect(stopped1.body!['PlaySessionId'], 'ps-ua-$_ep1');
expect(ep2.first, 'Playing'); expect(ep2.first, 'Playing');
expect(ep2.last, 'Stopped', reason: 'leaving reports Stopped'); expect(ep2.last, 'Stopped', reason: 'leaving reports Stopped');
+53 -20
View File
@@ -12,9 +12,9 @@ import 'package:watch_together/src/jellyfin/session_media.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart'; import 'package:watch_together/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/screens/home/media_picker.dart'; import 'package:watch_together/src/screens/home/media_picker.dart';
import 'package:watch_together/src/screens/session/jellyfin_widgets.dart'; import 'package:watch_together/src/screens/session/jellyfin_widgets.dart';
import 'package:watch_together/src/session/delivery.dart';
import 'package:watch_together/src/session/session_coordinator.dart' show AutoAdvance; import 'package:watch_together/src/session/session_coordinator.dart' show AutoAdvance;
import 'package:watch_together/src/shell/library_tab.dart' show putUpJellyfinMedia; import 'package:watch_together/src/shell/library_tab.dart' show putUpJellyfinMedia;
import 'package:watch_together/src/shell/party_strip.dart' show viaHostOf;
import 'jellyfin_accounts_test.dart'; import 'jellyfin_accounts_test.dart';
import 'jellyfin_browser_test.dart' show app, setSize, settle; import 'jellyfin_browser_test.dart' show app, setSize, settle;
@@ -41,6 +41,14 @@ FakeJellyfin playbackFake() {
return const JellyfinHttpResponse(204, ''); return const JellyfinHttpResponse(204, '');
}); });
} }
fake.on('POST', '/UserPlayedItems/$episodeId', (_, _) {
reports.add((path: '/UserPlayedItems', body: const {}));
return const JellyfinHttpResponse(200, '{"Played":true}');
});
fake.on('POST', '/UserItems/$episodeId/UserData', (_, body) {
reports.add((path: '/UserData', body: jsonDecode(body!) as Map<String, dynamic>));
return const JellyfinHttpResponse(200, '{}');
});
return fake; return fake;
} }
@@ -261,11 +269,46 @@ void main() {
'ItemId': episodeId, 'ItemId': episodeId,
'MediaSourceId': episodeId, 'MediaSourceId': episodeId,
'PlaySessionId': playSessionId, 'PlaySessionId': playSessionId,
'PositionTicks': 3005000000, 'PositionTicks': 0,
}); }, reason: '11 s watched is a peek: the resume point from before (none) stays');
expect(errors, isEmpty); expect(errors, isEmpty);
}); });
test('watched to the end: Stopped without a position, then marked played', () async {
reporter.start(const PlayerSnapshot(position: 0, paused: false));
for (var i = 1; i <= 1420; i++) {
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: i));
reporter.update(PlayerSnapshot(position: i.toDouble(), paused: false));
}
await reporter.stop(const PlayerSnapshot(position: 1420, paused: true));
expect(reports.map((r) => r.path.split('/').last).toList().sublist(reports.length - 3),
['Stopped', 'UserPlayedItems', 'UserData']);
expect(reports[reports.length - 3].body.containsKey('PositionTicks'), isFalse);
expect(reports.last.body, {'PlaybackPositionTicks': 0}, reason: 'the resume point Progress left is cleared');
final nearEnd = [for (final r in reports) if (r.path.endsWith('Progress')) r.body]
.where((b) => b.containsKey('PositionTicks') && b['PositionTicks'] >= 1207 * jellyfinTicksPerSecond);
expect(nearEnd, isEmpty, reason: 'near the end Progress carries no position: Jellyfin must not decide');
});
test('played before: a peek never unmarks it', () async {
final r = JellyfinProgressReporter(
client: accounts.clientFor(fixtureServerId)!,
itemId: episodeId,
source: fixtureSource(),
playSessionId: playSessionId,
playedBefore: true,
clock: () => now,
);
r.start(const PlayerSnapshot(position: 700, paused: false));
now = now.add(const Duration(seconds: 1));
r.update(const PlayerSnapshot(position: 701, paused: true));
await r.stop(const PlayerSnapshot(position: 701, paused: true));
expect(reports.map((r) => r.path.split('/').last),
['Playing', 'UserPlayedItems', 'Progress', 'Stopped', 'UserPlayedItems', 'UserData']);
expect(reports[2].body.containsKey('PositionTicks'), isFalse);
expect(reports[3].body.containsKey('PositionTicks'), isFalse);
});
test('played to the end, then seeked back: a fresh Playing', () async { test('played to the end, then seeked back: a fresh Playing', () async {
reporter.start(const PlayerSnapshot(position: 0, paused: false)); reporter.start(const PlayerSnapshot(position: 0, paused: false));
await reporter.stop(const PlayerSnapshot(position: 1440, paused: true)); await reporter.stop(const PlayerSnapshot(position: 1440, paused: true));
@@ -341,11 +384,13 @@ void main() {
expect(compareMemberIds('m9', 'm10'), lessThan(0)); expect(compareMemberIds('m9', 'm10'), lessThan(0));
}); });
test('member badge: via host only for relay viewers of the current item', () { test('member route: via server only for relay viewers of the current item', () {
expect(viaHostOf(member('m1', 'G', jf: {fixtureServerId: false}), episodeRef), isTrue); DeliveryRoute? route(MemberView m, {bool relay = true}) =>
expect(viaHostOf(member('m1', 'G', jf: {fixtureServerId: true}), episodeRef), isFalse); routeOf(m, DeliveryKind.jellyfin, '', episodeRef, relay);
expect(viaHostOf(member('m1', 'G'), episodeRef), isFalse); expect(route(member('m1', 'G', jf: {fixtureServerId: false})), DeliveryRoute.server);
expect(viaHostOf(member('m1', 'G', jf: {fixtureServerId: false}), null), isFalse); expect(route(member('m1', 'G', jf: {fixtureServerId: false}), relay: false), isNull);
expect(route(member('m1', 'G', jf: {fixtureServerId: true})), DeliveryRoute.direct);
expect(route(member('m1', 'G')), isNull);
}); });
}); });
@@ -456,18 +501,6 @@ void main() {
const JellyfinMedia( const JellyfinMedia(
serverId: fixtureServerId, baseUrl: 'b', itemId: nextEpisodeId, title: 'Battle with the Heroes')); serverId: fixtureServerId, baseUrl: 'b', itemId: nextEpisodeId, title: 'Battle with the Heroes'));
testWidgets('next / previous buttons: tooltip, hidden without an episode', (tester) async {
var pressed = 0;
await tester.pumpWidget(app(Row(children: [
EpisodeSkipButton(episode: null, next: false, onPressed: () {}),
EpisodeSkipButton(episode: next, next: true, onPressed: () => pressed++),
])));
expect(find.byKey(const ValueKey('episode-previous')), findsNothing);
expect(find.byTooltip('Next: S1E7 Battle with the Heroes (> or Shift+N)'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('episode-next')));
expect(pressed, 1);
});
testWidgets('end card: Next prompt, countdown with Cancel', (tester) async { testWidgets('end card: Next prompt, countdown with Cancel', (tester) async {
var play = 0, cancel = 0; var play = 0, cancel = 0;
await tester.pumpWidget(app(EndOfEpisodeCard( await tester.pumpWidget(app(EndOfEpisodeCard(
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff. Show more