Compare commits
32
Commits
dev
...
b949951ef9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b949951ef9 | ||
|
|
fd8064611a | ||
|
|
959eed38a4 | ||
|
|
bcc6d4bac8 | ||
|
|
4526fe86d9 | ||
|
|
8e82a1f7be | ||
|
|
4d74cee67d | ||
|
|
d4952db497 | ||
|
|
50b2b000a4 | ||
|
|
197fe113a1 | ||
|
|
7487b4deb1 | ||
|
|
4ab37c55cf | ||
|
|
572104f49e | ||
|
|
a407d3cbb0 | ||
|
|
1ec4fc8b13 | ||
|
|
9572e8457d | ||
|
|
d8585fb78e | ||
|
|
e52a7633a0 | ||
|
|
c3fe8b682d | ||
|
|
1508d2f804 | ||
|
|
86eb30f498 | ||
|
|
ddd7a21195 | ||
|
|
4275abe470 | ||
|
|
cf2805d3bd | ||
|
|
52f1c481a6 | ||
|
|
771b961b32 | ||
|
|
6b10cae4e6 | ||
|
|
95cd668cfb | ||
|
|
2a7b553597 | ||
|
|
27b4ec2c96 | ||
|
|
ab133dd82f | ||
|
|
1b0a3db439 |
No files matched your search
@@ -43,6 +43,9 @@ jobs:
|
||||
# The updater's Windows apply path (running exe parked as .old) only
|
||||
# means something on real NTFS. Runner notes: see release.yml.
|
||||
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
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -17,6 +17,7 @@ coverage/
|
||||
# Build artifacts that should never be committed
|
||||
*.tar.gz
|
||||
*.zip
|
||||
/dist/
|
||||
app.*.symbols
|
||||
app.*.map.json
|
||||
|
||||
|
||||
@@ -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
|
||||
shows in a corner while paused, the window buttons sit in the player's top
|
||||
bar while its controls are up), Esc leaves it. The controls show the
|
||||
show's cover, title and episode top left (guests get the cover too); before
|
||||
show's cover, title and episode top left (a web video: its thumbnail and
|
||||
name; guests get the art too; right-click to copy the title, or the link
|
||||
when you have it, or open it in the browser or Jellyfin); before
|
||||
a video first plays, its banner fills the picture until playback starts.
|
||||
One pill 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
|
||||
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
|
||||
other news also pop up bottom left with the person's avatar; click a message to open the chat. Unread chat
|
||||
shows as a count on the chat buttons and, in fullscreen with the controls
|
||||
hidden, as a small chip top right.
|
||||
other news also pop up bottom left with the person's avatar; click a message to open the chat
|
||||
(Settings → Notifications → Chat messages: Only when paused by default,
|
||||
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
|
||||
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,
|
||||
a folder or a link anywhere on the window, or press Ctrl+V outside a
|
||||
text field, and a small card asks how to open it (put it up, add the
|
||||
folder to Files, or fill Link).
|
||||
text field, and a small card asks how to open it (put it up, add it to
|
||||
Up next, add the folder to Files, or fill Link).
|
||||
|
||||
- **Jellyfin**: your linked servers (see below); with none linked it
|
||||
points to Files and Link and offers Jellyfin as optional. Library chips beside the
|
||||
@@ -140,12 +146,16 @@ folder to Files, or fill Link).
|
||||
details open on the right with "Put it up · S1E3", mark watched /
|
||||
unwatched, remove from Continue and the episode list (click an episode
|
||||
to choose it). Double-click a card or an episode to put it up at once.
|
||||
Right-click a card or an episode to copy its title or open it in
|
||||
Jellyfin's web page (the details panel has that button too).
|
||||
- **Link**: paste an http(s) link: a video file, or a web page with a
|
||||
video (YouTube, X, and whatever else yt-dlp knows). The preview shows
|
||||
what it is (a page's title, uploader, length and thumbnail, else the
|
||||
file name); in a server, pick how the
|
||||
others get it (everyone fetches it, the server fetches it, or through
|
||||
you). Your last 12 links are listed under it. 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
|
||||
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.
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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 |
|
||||
| `--yt-dlp-dir` | next to the binary | writable folder for the server's yt-dlp (systemd: the unit's `StateDirectory`) |
|
||||
| `--yt-dlp-cookies` | none | cookies.txt or cookie-extension JSON export for that yt-dlp (sites that refuse guests); copied into the yt-dlp folder at start, removed from it when not given. **An account login**: keep it private, use a throwaway account for YouTube |
|
||||
| `--log-dir` | `logs` next to `--config`, else next to the binary (systemd: the unit's `StateDirectory`) | folder for the log files (`server-YYYY-MM-DD.log` and the session log `session-YYYY-MM-DD.log`, kept 7 days; passwords, tokens and cookies left out) |
|
||||
| `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`, `ytDlp`, `ytDlpDir`, `ytDlpCookies`); replaces the flags |
|
||||
| `--upgrade` | | update the install folder to the latest release ([Install and update](#install-and-update)), then exit |
|
||||
| `--check-update` | | print whether a newer release exists, then exit |
|
||||
@@ -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
|
||||
`/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 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
|
||||
next episode automatically); Notifications & sounds (what pops up; the sound
|
||||
volume and sound set, and every sound with a preview, its own set and a switch); Updates; Keyboard shortcuts (read-only); About
|
||||
(the version).
|
||||
(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
|
||||
(both designs) opens them, and Settings → Player has the same ones (plus
|
||||
the custom shader files). Player design: Standard or uosc (below);
|
||||
progress line when hidden (on by default: a 2 px line at the bottom of
|
||||
the video while the controls are hidden); upscaling shader (see below);
|
||||
hardware decoding (auto, off or custom; the env var `SYNC_RELAY_HWDEC`
|
||||
overrides it); in the player also the subtitle and audio track. Every
|
||||
change applies right away.
|
||||
Player settings live in the player too: ⚙ → Player settings, at the
|
||||
right of its controls, and Settings → Player has the same ones (plus the
|
||||
controls' layout and the custom shader files). Player design: Standard or
|
||||
Minimal (below); progress line when hidden (on by default: a thin line at
|
||||
the bottom of the video while the controls are hidden); upscaling shader
|
||||
(see below); hardware decoding (auto, off or custom; the env var
|
||||
`SYNC_RELAY_HWDEC` overrides it); in the player also the subtitle and
|
||||
audio track. Settings → Player → Controls: time display (elapsed / total
|
||||
or remaining), controls size (normal or large), hide the controls after
|
||||
2, 3 or 5 s, and which optional buttons sit in the bar (chapter buttons,
|
||||
subtitles, audio, chat; hidden ones are still in ⚙). Every change
|
||||
applies right away.
|
||||
|
||||
Anywhere in a server (unless you are typing): Space = ready / unready,
|
||||
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,
|
||||
PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Esc =
|
||||
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.
|
||||
Every seek, skip and chapter jump moves everyone.
|
||||
|
||||
Controls: the seek bar marks chapter starts with a diamond; hovering it
|
||||
shows the time and the chapter title. The chapters button lists them
|
||||
(current one checked; click to jump). "1:30" skips ahead 1:30. Audio and
|
||||
Subtitles buttons (with the track count) list tracks as "Japanese · E-AC3
|
||||
2.0 · title"; subtitles have Off. Volume (hover the speaker, scroll on
|
||||
it, or Up/Down) goes to 150 % like mpv: past the tick at 100 % the bar
|
||||
turns amber and mpv amplifies.
|
||||
Controls: both designs have the same buttons in the same places, only
|
||||
the look differs. Bottom left: play / pause first (it is your ready, like
|
||||
Space: ✓ while you are ready and the others are not), previous / next
|
||||
episode (when there is one; next plays Up next first), previous / next
|
||||
chapter (when the video has chapters), volume and the time (click it to
|
||||
switch between elapsed / total and the time left). Bottom right:
|
||||
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,
|
||||
switches right away): video controls modeled on mpv's uosc theme. A thick
|
||||
timeline across the bottom shows the played part, the cached part ahead
|
||||
(hatched), chapter starts as diamonds and the time; hovering it shows the
|
||||
chapter title and the time there. With the controls hidden it stays as a
|
||||
thin line (unless "Progress line when hidden" is off). Above it flat buttons: ≡ menu, Subtitles and Audio (with the
|
||||
track count), the shader badge (the active Anime4K mode, e.g. "A HQ"),
|
||||
ready, chapters, 1:30, previous / next episode, change video, volume, ⚙
|
||||
and fullscreen; the title top left, chat and back-to-library top right. Keys and skip buttons work
|
||||
the same.
|
||||
Standard (the default): a seek bar that marks chapter starts with a
|
||||
diamond (hovering it shows the time and the chapter title) over a row
|
||||
of round buttons. Minimal (⚙ → Player settings → Player design, switches
|
||||
right away): a translucent timeline across the bottom tints what has
|
||||
played, stripes the part not downloaded yet, marks chapter starts with
|
||||
dots (click one to jump), shows the elapsed time left and the total or
|
||||
the time left right (click it to switch) and, while less than a minute
|
||||
is buffered, the buffered seconds next to the position; hovering it shows
|
||||
the time there and the chapter title. With the controls hidden it stays
|
||||
as a thin line (unless "Progress line when hidden" is off). Its buttons
|
||||
sit in two glass capsules above it. Minimal's timeline and controls are
|
||||
inspired by [uosc](https://github.com/tomasklaen/uosc) by tomasklaen
|
||||
(LGPL-2.1).
|
||||
|
||||
Skipping: during the opening a "Skip OP" button 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
|
||||
of the ending only; what comes after is yours to skip). Any skip button
|
||||
(also "Skip 1:30") shows for 5 seconds when it comes up, then only while
|
||||
the controls are up (move the mouse). The opening and
|
||||
of the ending only; what comes after is yours to skip). "Skip OP" and
|
||||
"Skip 1:30" show for 5 seconds when they come up, then only while the
|
||||
controls are up (move the mouse); "Skip ED" and "Next episode" show only
|
||||
while the controls are up. The opening and
|
||||
ending come from Jellyfin's intro / outro segments when your Jellyfin has
|
||||
them (needs a segment plugin, e.g. Intro Skipper), else from chapter names
|
||||
(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:
|
||||
your watched state, Continue watching and resume point follow what you
|
||||
saw (reported on start, every 10 s, on pause, resume, seek and track
|
||||
change, and when it stops).
|
||||
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
|
||||
item up passes it on automatically, with their own sign-in, which never
|
||||
leaves their computer (only the current item, only to people in the
|
||||
session). They show "via host" in the member list (click the avatars). When the app hosts,
|
||||
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
|
||||
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay), the server
|
||||
fetches it itself instead.
|
||||
@@ -306,10 +409,11 @@ Watching:
|
||||
"No Jellyfin account here for ..." with a button to link one; it plays as
|
||||
soon as you are signed in.
|
||||
|
||||
Episodes: the ⏮ / ⏭ buttons next to the controls (or `<` / `>`,
|
||||
Episodes: the ⏮ / ⏭ buttons next to play (or `<` / `>`,
|
||||
Shift+P / Shift+N) put up the previous or next episode for everyone. From
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
current season, else where you left it.
|
||||
|
||||
Anyone can put up a file from their own computer, also on a dedicated
|
||||
server: it streams from your computer through the server to everyone (the
|
||||
server keeps a few minutes in memory, so seeks and skipping the opening
|
||||
are quick). Keep the app open while it plays. For a link you choose how
|
||||
the others get it:
|
||||
- Everyone fetches it (default): each app plays the link itself.
|
||||
are quick). Keep the app open while it plays. A link goes up "Through
|
||||
me": your computer opens it and passes it on, so it plays for everyone
|
||||
whenever it plays for you (you can't tell ahead which sites want cookies,
|
||||
and YouTube may start asking any day). Under "Other ways" there are two more:
|
||||
- Everyone fetches it: each app plays the link itself.
|
||||
- Server fetches it: the server downloads it once for everyone, for links
|
||||
that are slow or blocked for some. The server must be able to reach it,
|
||||
and it must have a password (an open server refuses: anyone could make
|
||||
it fetch addresses only it can reach).
|
||||
- Through me: your computer downloads it and passes it on, for links only
|
||||
you can open.
|
||||
|
||||
Web pages work in all three: the app and the dedicated server each
|
||||
download [yt-dlp](https://github.com/yt-dlp/yt-dlp) by themselves (checked
|
||||
@@ -352,11 +456,23 @@ sites: keep the file private, and for YouTube consider a throwaway
|
||||
account (Google may flag accounts used with yt-dlp).
|
||||
YouTube works in all three too, and so do pages and links that only
|
||||
offer streaming playlists (HLS `.m3u8`, DASH `.mpd`, the HLS of X videos).
|
||||
The last two never show the link to anyone; they can't pass on live
|
||||
Through me and Server fetches it never show the link to anyone; they can't pass on live
|
||||
streams or DRM-protected video (use "Everyone fetches it" for live).
|
||||
Your upload has to beat the video's bitrate: a 25-minute 2 GB episode is
|
||||
about 11 Mbit/s.
|
||||
|
||||
The member list (click the avatars) shows where the video comes from on
|
||||
top: who passes it on, or that the server fetches it, everyone plays the
|
||||
link, or everyone streams it from Jellyfin, with how many watch through
|
||||
the server and, for a relay, how fast it arrives against what the video
|
||||
needs (the bar's mark; past it is keeping up). It turns amber when the
|
||||
source is too slow or 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
|
||||
|
||||
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
|
||||
(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
|
||||
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
|
||||
files. Shaders cost GPU time
|
||||
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.
|
||||
- "wrong server password": password mismatch.
|
||||
- "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.
|
||||
- Windows: the build is new; if something is off there, say so.
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'src/screens/home/ui.dart';
|
||||
import 'src/shell/app_shell.dart';
|
||||
import 'src/widgets/app_keys.dart';
|
||||
import 'src/widgets/app_menu.dart';
|
||||
|
||||
class WatchTogetherApp extends StatelessWidget {
|
||||
const WatchTogetherApp({super.key});
|
||||
@@ -11,7 +12,9 @@ class WatchTogetherApp extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
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,
|
||||
home: const AppKeys(child: AppShell()),
|
||||
);
|
||||
|
||||
@@ -9,7 +9,9 @@ import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'src/playback/yt_dlp_app.dart';
|
||||
import 'src/platform/app_log.dart';
|
||||
import 'src/platform/desktop_entry.dart';
|
||||
import 'src/platform/window_memory.dart';
|
||||
import 'src/screens/home/ui.dart';
|
||||
import 'src/state/session_state.dart';
|
||||
import 'src/state/settings.dart';
|
||||
@@ -40,23 +42,26 @@ Future<void> main(List<String> args) async {
|
||||
),
|
||||
);
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await startAppLogging();
|
||||
await Hive.initFlutter();
|
||||
await AppSettings.instance.ensureLoaded();
|
||||
await windowManager.ensureInitialized();
|
||||
await windowManager.setPreventClose(true);
|
||||
windowManager.addListener(_CloseGuard());
|
||||
const windowOptions = WindowOptions(
|
||||
size: Size(1280, 720),
|
||||
minimumSize: Size(160, 90),
|
||||
center: true,
|
||||
backgroundColor: Color(0xFF000000),
|
||||
final memory = WindowMemory(Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null);
|
||||
windowManager.addListener(_CloseGuard(memory));
|
||||
final windowOptions = WindowOptions(
|
||||
size: memory.size,
|
||||
minimumSize: const Size(160, 90),
|
||||
center: !memory.hasPosition,
|
||||
backgroundColor: const Color(0xFF000000),
|
||||
titleBarStyle: TitleBarStyle.hidden,
|
||||
title: appName,
|
||||
);
|
||||
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||
await memory.restore();
|
||||
await windowManager.show();
|
||||
await windowManager.focus();
|
||||
});
|
||||
await Hive.initFlutter();
|
||||
await AppSettings.instance.ensureLoaded();
|
||||
try {
|
||||
await SessionState.instance.loadForm();
|
||||
} catch (e, st) {
|
||||
@@ -68,18 +73,28 @@ Future<void> main(List<String> args) async {
|
||||
// Linux: the launcher entry and icon Wayland needs (release installs).
|
||||
unawaited(installDesktopEntry());
|
||||
runApp(const WatchTogetherApp());
|
||||
// The sound device opens once the window is up, not on the first sound.
|
||||
Future.delayed(const Duration(seconds: 2), SessionState.instance.sfx.warmUp);
|
||||
}
|
||||
|
||||
/// Close button / Alt+F4: leave gracefully first (others see "left", a
|
||||
/// hosted server shuts down), bounded so a hung socket can't keep the
|
||||
/// window open.
|
||||
class _CloseGuard with WindowListener {
|
||||
final WindowMemory memory;
|
||||
bool _closing = false;
|
||||
_CloseGuard(this.memory);
|
||||
|
||||
@override
|
||||
Future<void> onWindowClose() async {
|
||||
if (_closing) return;
|
||||
_closing = true;
|
||||
await memory.save();
|
||||
try {
|
||||
await SessionState.instance.sfx.dispose().timeout(const Duration(milliseconds: 500));
|
||||
} catch (e) {
|
||||
debugPrint('sound shutdown on close failed: $e');
|
||||
}
|
||||
try {
|
||||
await SessionState.instance.leave().timeout(const Duration(seconds: 2));
|
||||
} 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';
|
||||
|
||||
/// Follows whether media_kit's desktop controls around [child] are up,
|
||||
/// by the same rule they use: up on mount and on any mouse movement,
|
||||
/// down [hideAfter] later or when the mouse leaves. media_kit does not
|
||||
/// expose its visibility; this lets the player draw a progress line while
|
||||
/// they are down. Never takes input.
|
||||
class ControlsHoverProbe extends StatefulWidget {
|
||||
final ValueNotifier<bool> shown;
|
||||
final Duration hideAfter;
|
||||
final Widget child;
|
||||
const ControlsHoverProbe({super.key, required this.shown, required this.hideAfter, required this.child});
|
||||
/// What keeps the player's controls up besides the mouse on them: the
|
||||
/// mouse on a floating element over the picture (a pill, a card, the
|
||||
/// status pill: [ControlsHoldRegion]) or an open popover. While anything
|
||||
/// holds, the controls stay up; on release they hide on their usual
|
||||
/// countdown. Both skins follow it (through `ControlsFrame`).
|
||||
class ControlsHold extends ChangeNotifier {
|
||||
final _by = <Object>{};
|
||||
bool _told = false;
|
||||
bool _queued = false;
|
||||
bool _disposed = false;
|
||||
|
||||
@override
|
||||
State<ControlsHoverProbe> createState() => _ControlsHoverProbeState();
|
||||
}
|
||||
bool get held => _told;
|
||||
|
||||
class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Not during this build: the line listening to [shown] may be built.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _wake();
|
||||
/// [who] holds ([on]) or lets go. Listeners hear it in a microtask, never
|
||||
/// during a build or a dispose.
|
||||
void set(Object who, bool on) {
|
||||
if (on ? !_by.add(who) : !_by.remove(who)) return;
|
||||
if (_queued) return;
|
||||
_queued = true;
|
||||
scheduleMicrotask(() {
|
||||
_queued = false;
|
||||
if (_disposed) return;
|
||||
final now = _by.isNotEmpty;
|
||||
if (now == _told) return;
|
||||
_told = now;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
void _wake() {
|
||||
widget.shown.value = true;
|
||||
_timer?.cancel();
|
||||
_timer = Timer(widget.hideAfter, _hide);
|
||||
}
|
||||
/// The mouse on [child] counts as the mouse on the controls: it holds
|
||||
/// [hold] while it is there.
|
||||
class ControlsHoldRegion extends StatefulWidget {
|
||||
final ControlsHold? hold;
|
||||
final Widget child;
|
||||
const ControlsHoldRegion({super.key, required this.hold, required this.child});
|
||||
|
||||
void _hide() {
|
||||
_timer?.cancel();
|
||||
if (mounted) widget.shown.value = false;
|
||||
@override
|
||||
State<ControlsHoldRegion> createState() => _ControlsHoldRegionState();
|
||||
}
|
||||
|
||||
class _ControlsHoldRegionState extends State<ControlsHoldRegion> {
|
||||
@override
|
||||
void didUpdateWidget(ControlsHoldRegion oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.hold != widget.hold) oldWidget.hold?.set(this, false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => MouseRegion(
|
||||
opaque: false,
|
||||
hitTestBehavior: HitTestBehavior.translucent,
|
||||
onEnter: (_) => _wake(),
|
||||
onHover: (_) => _wake(),
|
||||
onExit: (_) => _hide(),
|
||||
void dispose() {
|
||||
widget.hold?.set(this, false);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hold = widget.hold;
|
||||
if (hold == null) return widget.child;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => hold.set(this, true),
|
||||
onExit: (_) => hold.set(this, false),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 '../shaders/shader_presets.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../shell/mark_problem.dart';
|
||||
import '../shell/motion.dart';
|
||||
import '../state/settings.dart';
|
||||
import 'uosc/buttons.dart';
|
||||
import 'uosc/controls.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import 'frame.dart';
|
||||
import 'model/controls_prefs.dart';
|
||||
|
||||
// Everything about the player, defined once: the rows of the ⚙ card in
|
||||
// both control designs and of Settings → Player. Each row reads and
|
||||
// Everything about the player, defined once: the rows of the player
|
||||
// settings card (⚙ → Player settings, both skins) and of Settings → Player. Each row reads and
|
||||
// writes [AppSettings] and applies to a running player right away.
|
||||
|
||||
/// 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 T value,
|
||||
required List<(T, String)> items,
|
||||
required ValueChanged<T> onChanged,
|
||||
bool expanded = false,
|
||||
}) =>
|
||||
DropdownButton<T>(
|
||||
key: key,
|
||||
value: value,
|
||||
isExpanded: expanded,
|
||||
underline: const SizedBox.shrink(),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
items: [
|
||||
for (final (v, label) in items)
|
||||
DropdownMenuItem(value: v, child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) onChanged(v);
|
||||
},
|
||||
);
|
||||
AppSelect<T>(key: key, value: value, items: items, onChanged: onChanged, expanded: expanded);
|
||||
|
||||
/// Standard (our bars over media_kit's controls) or uosc. Live.
|
||||
/// The controls' skin: Standard or Minimal. Live.
|
||||
class PlayerDesignSetting extends StatelessWidget {
|
||||
const PlayerDesignSetting({super.key});
|
||||
|
||||
@@ -88,12 +77,100 @@ class PlayerDesignSetting extends StatelessWidget {
|
||||
return SignalBuilder(
|
||||
builder: (context) => SettingRow(
|
||||
label: 'Player design',
|
||||
help: 'uosc is modeled on mpv: a thick timeline with chapters and what is cached',
|
||||
control: _dropdown<bool>(
|
||||
help: 'Minimal has a thick timeline with chapters and what is downloaded yet',
|
||||
control: _dropdown<ControlsSkin>(
|
||||
key: const ValueKey('player-design'),
|
||||
value: s.mpvControls.value,
|
||||
items: const [(false, 'Standard'), (true, 'uosc')],
|
||||
onChanged: s.setMpvControls,
|
||||
value: s.controlsSkin.value,
|
||||
items: [for (final k in ControlsSkin.values) (k, k.label)],
|
||||
onChanged: s.setControlsSkin,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The bar's clock: elapsed / total or the time left. Live.
|
||||
class TimeDisplaySetting extends StatelessWidget {
|
||||
const TimeDisplaySetting({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
return SignalBuilder(
|
||||
builder: (context) => SettingRow(
|
||||
label: 'Time display',
|
||||
help: 'Also switched by a click on the time',
|
||||
control: _dropdown<TimeDisplay>(
|
||||
key: const ValueKey('time-display'),
|
||||
value: s.timeDisplay.value,
|
||||
items: [for (final t in TimeDisplay.values) (t, t.label)],
|
||||
onChanged: s.setTimeDisplay,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Normal or large bar and buttons. Live.
|
||||
class ControlsSizeSetting extends StatelessWidget {
|
||||
const ControlsSizeSetting({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
return SignalBuilder(
|
||||
builder: (context) => SettingRow(
|
||||
label: 'Controls size',
|
||||
help: 'Large makes the bar and its buttons a quarter bigger',
|
||||
control: _dropdown<ControlsSize>(
|
||||
key: const ValueKey('controls-size'),
|
||||
value: s.controlsSize.value,
|
||||
items: [for (final z in ControlsSize.values) (z, z.label)],
|
||||
onChanged: s.setControlsSize,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// How long the controls stay up without mouse movement. Live.
|
||||
class HideDelaySetting extends StatelessWidget {
|
||||
const HideDelaySetting({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
return SignalBuilder(
|
||||
builder: (context) => SettingRow(
|
||||
label: 'Hide the controls after',
|
||||
help: 'Without mouse movement over the video',
|
||||
control: _dropdown<int>(
|
||||
key: const ValueKey('controls-hide'),
|
||||
value: s.controlsHideSecs.value,
|
||||
items: [for (final secs in controlsHideChoices) (secs, '$secs s')],
|
||||
onChanged: s.setControlsHideSecs,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One optional bar button on or off (off: it is still in ⚙). Live.
|
||||
class BarButtonSetting extends StatelessWidget {
|
||||
final OptionalControl control;
|
||||
const BarButtonSetting({super.key, required this.control});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
return SignalBuilder(
|
||||
builder: (context) => SettingRow(
|
||||
label: control.label,
|
||||
help: control.help,
|
||||
control: Switch(
|
||||
key: ValueKey('bar-${control.name}'),
|
||||
value: !s.hiddenControls.value.contains(control),
|
||||
onChanged: (on) => s.setControlShown(control, on),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -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}) => [
|
||||
const PlayerDesignSetting(),
|
||||
const ProgressLineSetting(),
|
||||
@@ -315,7 +405,8 @@ List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
|
||||
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 {
|
||||
final ReadonlySignal<RenderMode>? renderMode;
|
||||
/// Show the audio / subtitle pickers (a player is running).
|
||||
@@ -324,19 +415,18 @@ class PlayerSettingsCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
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'),
|
||||
elevation: 12,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: SizedBox(
|
||||
width: 420,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 10),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
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),
|
||||
...playerSettingRows(renderMode: renderMode),
|
||||
if (tracks) ...[
|
||||
@@ -345,10 +435,17 @@ class PlayerSettingsCard extends StatelessWidget {
|
||||
const TrackSetting(type: JellyfinStreamType.audio),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Custom shader files: Settings → Player',
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Controls layout and custom shaders: Settings → Player',
|
||||
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
|
||||
/// uosc controls stay up while it is open.
|
||||
Future<void> showPlayerSettings(BuildContext context, {ReadonlySignal<RenderMode>? renderMode}) {
|
||||
Future<void> open() => showPopover<void>(
|
||||
/// Opens the player settings card over the player, near the controls'
|
||||
/// right end; the controls stay up while it is open.
|
||||
Future<void> showPlayerSettings(BuildContext context, {ReadonlySignal<RenderMode>? renderMode}) =>
|
||||
ControlsFrame.holdWhile(
|
||||
context,
|
||||
() => showPopover<void>(
|
||||
context,
|
||||
at: const Alignment(0.96, 0.5),
|
||||
child: PlayerSettingsCard(renderMode: renderMode),
|
||||
),
|
||||
);
|
||||
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;
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
|
||||
import '../playback/playback_controller.dart' show PlaybackController;
|
||||
import '../widgets/app_menu.dart' show AppGlass;
|
||||
|
||||
/// The player's top volume (mpv's `volume-max`): past 100 % mpv amplifies.
|
||||
const maxVolume = PlaybackController.maxVolume * 1.0;
|
||||
@@ -10,9 +13,170 @@ const maxVolume = PlaybackController.maxVolume * 1.0;
|
||||
/// The part of the volume bar above 100 %.
|
||||
const boostColor = Color(0xFFFFB74D);
|
||||
|
||||
/// One wheel notch or arrow key.
|
||||
const volumeStep = 5.0;
|
||||
|
||||
/// [volume] moved by [delta] (wheel, keys), kept within 0..[maxVolume].
|
||||
double stepVolume(double volume, double delta) => (volume + delta).clamp(0.0, maxVolume);
|
||||
|
||||
/// Turns wheel deltas into volume steps: a mouse notch (a big delta) is
|
||||
/// one step, small smooth-scroll deltas add up to one. Mostly horizontal
|
||||
/// scrolls (Shift+wheel, sideways pans) are no steps.
|
||||
class WheelSteps {
|
||||
// Flutter makes a notch 53 px on Linux, 60 on Windows (3 lines).
|
||||
static const _notch = 50.0;
|
||||
double _sum = 0;
|
||||
|
||||
/// Whether [e] is a vertical scroll (the only kind that steps).
|
||||
static bool vertical(PointerSignalEvent e) =>
|
||||
e is PointerScrollEvent && e.scrollDelta.dy != 0 && e.scrollDelta.dy.abs() > e.scrollDelta.dx.abs();
|
||||
|
||||
/// +1 louder (wheel up), -1 quieter, 0 not a step yet.
|
||||
int take(PointerScrollEvent e) {
|
||||
final dy = e.scrollDelta.dy;
|
||||
if (dy.abs() >= _notch) {
|
||||
_sum = 0;
|
||||
return dy < 0 ? 1 : -1;
|
||||
}
|
||||
if (_sum != 0 && _sum.sign != dy.sign) _sum = 0;
|
||||
_sum += dy;
|
||||
if (_sum.abs() < _notch) return 0;
|
||||
final step = _sum < 0 ? 1 : -1;
|
||||
_sum = 0;
|
||||
return step;
|
||||
}
|
||||
|
||||
/// Claims [e] if it is a vertical scroll that nothing under the mouse
|
||||
/// claimed first (a list, a menu), then calls [onStep] per step.
|
||||
void handle(PointerSignalEvent e, ValueChanged<int> onStep) {
|
||||
if (!vertical(e)) return;
|
||||
GestureBinding.instance.pointerSignalResolver.register(e, (event) {
|
||||
final step = take(event as PointerScrollEvent);
|
||||
if (step != 0) onStep(step);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The wheel anywhere over [child] steps the volume ([onStep]: +1 / -1),
|
||||
/// unless something under the mouse scrolls (it claims the event first)
|
||||
/// or [enabled] is off.
|
||||
class VolumeWheel extends StatefulWidget {
|
||||
final ValueChanged<int> onStep;
|
||||
final bool enabled;
|
||||
final Widget child;
|
||||
const VolumeWheel({super.key, required this.onStep, this.enabled = true, required this.child});
|
||||
|
||||
@override
|
||||
State<VolumeWheel> createState() => _VolumeWheelState();
|
||||
}
|
||||
|
||||
class _VolumeWheelState extends State<VolumeWheel> {
|
||||
final _steps = WheelSteps();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Listener(
|
||||
onPointerSignal: (e) {
|
||||
if (widget.enabled) _steps.handle(e, widget.onStep);
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows [builder] for a second after each [trigger] tick (a wheel or
|
||||
/// key volume step), fading in and out; ignores the mouse.
|
||||
class VolumeFlash extends StatefulWidget {
|
||||
final ValueListenable<int> trigger;
|
||||
final WidgetBuilder builder;
|
||||
const VolumeFlash({super.key, required this.trigger, required this.builder});
|
||||
|
||||
/// How long the readout stays after the last step.
|
||||
static const hold = Duration(seconds: 1);
|
||||
|
||||
@override
|
||||
State<VolumeFlash> createState() => _VolumeFlashState();
|
||||
}
|
||||
|
||||
class _VolumeFlashState extends State<VolumeFlash> {
|
||||
bool _on = false;
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.trigger.addListener(_flash);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(VolumeFlash old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.trigger != widget.trigger) {
|
||||
old.trigger.removeListener(_flash);
|
||||
widget.trigger.addListener(_flash);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.trigger.removeListener(_flash);
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _flash() {
|
||||
if (!mounted) return;
|
||||
setState(() => _on = true);
|
||||
_timer?.cancel();
|
||||
_timer = Timer(VolumeFlash.hold, () {
|
||||
if (mounted) setState(() => _on = false);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
opacity: _on ? 1 : 0,
|
||||
child: _on || _timer != null ? widget.builder(context) : const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The volume readout for [VolumeFlash] (both skins): a glass pill
|
||||
/// (the player's pill look) with the icon, a [VolumeBar] and the
|
||||
/// percentage.
|
||||
class VolumeReadout extends StatelessWidget {
|
||||
final double volume;
|
||||
const VolumeReadout({super.key, required this.volume});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final v = volume.clamp(0.0, maxVolume);
|
||||
return AppGlass.pill(
|
||||
key: const ValueKey('volume-readout'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 14, 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(volumeIcon(v), size: 20, color: v > 100 ? boostColor : Colors.white),
|
||||
const SizedBox(width: 8),
|
||||
VolumeBar(volume: v, onChanged: (_) {}, width: 96),
|
||||
const SizedBox(width: 10),
|
||||
SizedBox(
|
||||
width: 44,
|
||||
child: Text(
|
||||
'${v.round()} %',
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13, fontFeatures: [FontFeature.tabularFigures()]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A thin volume track over 0..[maxVolume]: filled up to the volume, the
|
||||
/// part above 100 % in [boostColor], a tick at 100 %. Click or drag sets
|
||||
/// it.
|
||||
@@ -61,78 +225,98 @@ class _VolumePainter extends CustomPainter {
|
||||
..strokeCap = StrokeCap.round;
|
||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), line..color = trackColor);
|
||||
final full = x(100);
|
||||
canvas.drawLine(Offset(full, y - 5), Offset(full, y + 5), Paint()
|
||||
canvas.drawLine(
|
||||
Offset(full, y - 5),
|
||||
Offset(full, y + 5),
|
||||
Paint()
|
||||
..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 > 100) canvas.drawLine(Offset(full, y), Offset(x(volume), y), line..color = boostColor);
|
||||
canvas.drawCircle(Offset(x(volume), y), 5, Paint()..color = volume > 100 ? boostColor : color);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_VolumePainter old) =>
|
||||
old.volume != volume || old.color != color || old.trackColor != trackColor;
|
||||
bool shouldRepaint(_VolumePainter old) => old.volume != volume || old.color != color || old.trackColor != trackColor;
|
||||
}
|
||||
|
||||
/// Volume for the standard bar (instead of media_kit's, which stops at
|
||||
/// 100): the icon mutes / unmutes, hovering shows a [VolumeBar], the
|
||||
/// wheel steps 5 %.
|
||||
class VolumeButton extends StatefulWidget {
|
||||
final Player player;
|
||||
final FocusNode? focusNode;
|
||||
const VolumeButton({super.key, required this.player, this.focusNode});
|
||||
/// The speaker icon for [volume].
|
||||
IconData volumeIcon(double volume) =>
|
||||
volume <= 0 ? Icons.volume_off_rounded : (volume < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded);
|
||||
|
||||
/// Builds a skin's button for the [VolumeControl].
|
||||
typedef VolumeButtonBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
IconData icon,
|
||||
Color? color,
|
||||
String tooltip,
|
||||
VoidCallback onPressed,
|
||||
);
|
||||
|
||||
/// The bar's volume, in both skins: the skin's speaker button (click
|
||||
/// mutes / unmutes), a [VolumeBar] sliding out on hover, the wheel on it
|
||||
/// stepping [volumeStep] (claimed, so the player's wheel skips it).
|
||||
class VolumeControl extends StatefulWidget {
|
||||
/// 0..[maxVolume].
|
||||
final double volume;
|
||||
final ValueChanged<double> onChanged;
|
||||
final VoidCallback onToggleMute;
|
||||
final VolumeButtonBuilder button;
|
||||
final Color sliderColor;
|
||||
final Color trackColor;
|
||||
final double sliderWidth;
|
||||
const VolumeControl({
|
||||
super.key,
|
||||
required this.volume,
|
||||
required this.onChanged,
|
||||
required this.onToggleMute,
|
||||
required this.button,
|
||||
this.sliderColor = Colors.white,
|
||||
this.trackColor = Colors.white30,
|
||||
this.sliderWidth = 72,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VolumeButton> createState() => _VolumeButtonState();
|
||||
State<VolumeControl> createState() => _VolumeControlState();
|
||||
}
|
||||
|
||||
class _VolumeButtonState extends State<VolumeButton> {
|
||||
class _VolumeControlState extends State<VolumeControl> {
|
||||
final _steps = WheelSteps();
|
||||
bool _hover = false;
|
||||
double _beforeMute = 100;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final player = widget.player;
|
||||
return StreamBuilder<double>(
|
||||
stream: player.stream.volume,
|
||||
initialData: player.state.volume,
|
||||
builder: (context, snap) {
|
||||
final v = snap.data ?? 100;
|
||||
final v = widget.volume;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: Listener(
|
||||
onPointerSignal: (e) {
|
||||
if (e is PointerScrollEvent && e.scrollDelta.dy != 0) {
|
||||
player.setVolume(stepVolume(v, e.scrollDelta.dy < 0 ? 5 : -5));
|
||||
}
|
||||
},
|
||||
onPointerSignal: (e) => _steps.handle(e, (step) => widget.onChanged(stepVolume(v, step * volumeStep))),
|
||||
child: Row(
|
||||
key: const ValueKey('volume-control'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
key: const ValueKey('volume-button'),
|
||||
focusNode: widget.focusNode,
|
||||
tooltip: 'Volume ${v.round()} %',
|
||||
iconSize: 22,
|
||||
color: Colors.white,
|
||||
onPressed: () {
|
||||
if (v > 0) {
|
||||
_beforeMute = v;
|
||||
player.setVolume(0);
|
||||
} else {
|
||||
player.setVolume(_beforeMute <= 0 ? 100 : _beforeMute);
|
||||
}
|
||||
},
|
||||
icon: Icon(v <= 0 ? Icons.volume_off : (v < 50 ? Icons.volume_down : Icons.volume_up)),
|
||||
widget.button(
|
||||
context,
|
||||
volumeIcon(v),
|
||||
v > 100 ? boostColor : null,
|
||||
'Volume ${v.round()} % (scroll, ↑ / ↓)',
|
||||
widget.onToggleMute,
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: _hover
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 4, right: 12),
|
||||
child: VolumeBar(volume: v, onChanged: player.setVolume),
|
||||
padding: const EdgeInsets.only(left: 4, right: 8),
|
||||
child: VolumeBar(
|
||||
key: const ValueKey('volume-bar'),
|
||||
volume: v,
|
||||
onChanged: widget.onChanged,
|
||||
width: widget.sliderWidth,
|
||||
color: widget.sliderColor,
|
||||
trackColor: widget.trackColor,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
@@ -140,7 +324,5 @@ class _VolumeButtonState extends State<VolumeButton> {
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../../shell/motion.dart';
|
||||
import '../../widgets/app_menu.dart';
|
||||
import '../folder_store.dart';
|
||||
import '../name_parser.dart';
|
||||
import '../scanner.dart';
|
||||
@@ -54,6 +55,8 @@ class FilesPage extends StatefulWidget {
|
||||
final bool relayed;
|
||||
/// Plays [path] in the session; returns an error text, or null.
|
||||
final Future<String?> Function(String path) onPlay;
|
||||
/// Adds [path] to Up next (right-click on a video); null: no server.
|
||||
final Future<String?> Function(String path, {bool next})? onAddToQueue;
|
||||
final String? nowPlayingPath;
|
||||
/// Defaults to [thumbnailFor].
|
||||
final ThumbnailLoader? thumbnails;
|
||||
@@ -70,6 +73,7 @@ class FilesPage extends StatefulWidget {
|
||||
this.solo = false,
|
||||
required this.relayed,
|
||||
required this.onPlay,
|
||||
this.onAddToQueue,
|
||||
this.nowPlayingPath,
|
||||
this.thumbnails,
|
||||
this.scan,
|
||||
@@ -238,6 +242,35 @@ class _FilesPageState extends State<FilesPage> {
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) => LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
@@ -468,14 +501,16 @@ class _FilesPageState extends State<FilesPage> {
|
||||
itemCount: recent.length,
|
||||
itemBuilder: (context, i) {
|
||||
final path = recent[i];
|
||||
return _RecentRow(
|
||||
return _menuable(
|
||||
path,
|
||||
_RecentRow(
|
||||
key: ValueKey(path),
|
||||
path: path,
|
||||
state: _stateOf(path),
|
||||
thumbs: _thumbs,
|
||||
onTap: _tapOf(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)),
|
||||
);
|
||||
}
|
||||
return FileRow(
|
||||
return _menuable(
|
||||
v.path,
|
||||
FileRow(
|
||||
key: ValueKey(v.path),
|
||||
path: v.path,
|
||||
title: v.parsed.label.isEmpty ? v.parsed.series : v.parsed.label,
|
||||
@@ -591,7 +628,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
state: _stateOf(v.path),
|
||||
thumbs: _thumbs,
|
||||
onTap: _tapOf(v.path),
|
||||
);
|
||||
));
|
||||
},
|
||||
),
|
||||
// Everything else (movies, clips) as a thumbnail grid.
|
||||
@@ -620,13 +657,15 @@ class _FilesPageState extends State<FilesPage> {
|
||||
itemCount: loose.length,
|
||||
itemBuilder: (context, i) {
|
||||
final v = loose[i];
|
||||
return VideoCard(
|
||||
return _menuable(
|
||||
v.path,
|
||||
VideoCard(
|
||||
key: ValueKey(v.path),
|
||||
video: v,
|
||||
state: _stateOf(v.path),
|
||||
thumbs: _thumbs,
|
||||
onTap: _tapOf(v.path),
|
||||
);
|
||||
));
|
||||
},
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -194,6 +194,13 @@ class JellyfinAccounts extends ChangeNotifier {
|
||||
JellyfinAccount? account(String serverId) =>
|
||||
_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.
|
||||
JellyfinAccount? get defaultAccount =>
|
||||
(_defaultServerId == null ? null : account(_defaultServerId!)) ?? _accounts.firstOrNull;
|
||||
@@ -543,6 +550,17 @@ class JellyfinAccounts extends ChangeNotifier {
|
||||
/// Drops cached browse results (refresh button, after a pick).
|
||||
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
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
|
||||
@@ -6,6 +6,8 @@ import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'accounts.dart';
|
||||
import 'format.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import '../widgets/copy_open.dart';
|
||||
|
||||
/// One level of the library browser's back stack.
|
||||
sealed class BrowseRoute {
|
||||
@@ -156,7 +158,14 @@ class BrowseController extends ChangeNotifier {
|
||||
final JellyfinAccounts accounts;
|
||||
/// Clock for the Continue row's age cut-off (tests pin it).
|
||||
final DateTime Function() now;
|
||||
BrowseController(this.accounts, {this.now = DateTime.now});
|
||||
BrowseController(this.accounts, {this.now = DateTime.now}) {
|
||||
// Back from watching: the rows show what was just watched.
|
||||
_watchedSub = accounts.watched.listen((id) {
|
||||
if (!_disposed && id == serverId) dataChanged();
|
||||
});
|
||||
}
|
||||
|
||||
late final StreamSubscription<String> _watchedSub;
|
||||
|
||||
String? _serverId;
|
||||
List<BrowseRoute> _stack = const [HomeRoute()];
|
||||
@@ -258,10 +267,19 @@ class BrowseController extends ChangeNotifier {
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
unawaited(_watchedSub.cancel());
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// An item's title to copy: an episode with its series ("Show · Name").
|
||||
String jellyfinCopyTitle(JellyfinItem item) {
|
||||
final series = item.seriesName;
|
||||
return item.kind == JellyfinItemKind.episode && series != null && series.isNotEmpty
|
||||
? '$series · ${item.name}'
|
||||
: item.name;
|
||||
}
|
||||
|
||||
/// What every browser page needs, provided by [JellyfinBrowser].
|
||||
class BrowseScope extends InheritedWidget {
|
||||
final JellyfinAccounts accounts;
|
||||
@@ -274,6 +292,9 @@ class BrowseScope extends InheritedWidget {
|
||||
final ValueChanged<JellyfinItem> onSelect;
|
||||
/// Double-click: put it up (a series: its next episode).
|
||||
final ValueChanged<JellyfinItem> onPutUp;
|
||||
/// Adds it to Up next (with next: right after what plays); null: no
|
||||
/// server (the cards' menu then only copies and opens).
|
||||
final void Function(JellyfinItem item, {bool next})? onAddToQueue;
|
||||
/// Home shows the library chips itself (the Library tab has them in its
|
||||
/// header instead).
|
||||
final bool libraryStrip;
|
||||
@@ -287,6 +308,7 @@ class BrowseScope extends InheritedWidget {
|
||||
required this.selectedItemId,
|
||||
required this.onSelect,
|
||||
required this.onPutUp,
|
||||
this.onAddToQueue,
|
||||
this.libraryStrip = true,
|
||||
required super.child,
|
||||
});
|
||||
@@ -296,6 +318,33 @@ class BrowseScope extends InheritedWidget {
|
||||
|
||||
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.
|
||||
Future<T> load<T>(String key, Future<T> Function(JellyfinClient c) fn) =>
|
||||
accounts.cached(account.serverId, key, () => fn(client));
|
||||
|
||||
@@ -30,6 +30,8 @@ class JellyfinBrowser extends StatefulWidget {
|
||||
final JellyfinSelection? selected;
|
||||
final ValueChanged<JellyfinSelection> onSelect;
|
||||
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 Duration searchDebounce;
|
||||
/// Opens here instead of the remembered location (the series of the
|
||||
@@ -43,6 +45,7 @@ class JellyfinBrowser extends StatefulWidget {
|
||||
required this.accounts,
|
||||
required this.onSelect,
|
||||
required this.onPutUp,
|
||||
this.onAddToQueue,
|
||||
this.controller,
|
||||
this.selected,
|
||||
this.onOpenIntegrations,
|
||||
@@ -221,6 +224,10 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
|
||||
selectedItemId: sel != null && sel.serverId == account.serverId ? sel.itemId : null,
|
||||
onSelect: (item) => widget.onSelect(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,
|
||||
child: Focus(
|
||||
onKeyEvent: _onKey,
|
||||
|
||||
@@ -186,6 +186,7 @@ class _MediaCardState extends State<MediaCard> {
|
||||
focusColor: Colors.transparent,
|
||||
hoverColor: Colors.transparent,
|
||||
onTap: () => _tap(scope),
|
||||
onSecondaryTap: () => scope.cardMenu(context, item),
|
||||
onHover: (h) => setState(() => _hover = h),
|
||||
onFocusChange: (f) => setState(() => _focus = f),
|
||||
child: Column(
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../shell/motion.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import '../widgets/copy_open.dart';
|
||||
import 'accounts.dart';
|
||||
import 'browse_scope.dart';
|
||||
import 'cards.dart' show doubleClickTime, kindIcon;
|
||||
@@ -44,12 +46,16 @@ class JellyfinDetailsPanel extends StatefulWidget {
|
||||
final bool solo;
|
||||
/// Puts [item] (playable) up; returns an error text, or null when sent.
|
||||
final Future<String?> Function(JellyfinSelection item) onPutUp;
|
||||
/// Adds [item] to Up next (with next: right after what plays); null: no
|
||||
/// server, no queue actions.
|
||||
final Future<String?> Function(JellyfinSelection item, {bool next})? onAddToQueue;
|
||||
final VoidCallback onClose;
|
||||
const JellyfinDetailsPanel({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.selection,
|
||||
required this.onPutUp,
|
||||
this.onAddToQueue,
|
||||
required this.onClose,
|
||||
this.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.
|
||||
Future<void> _change(Future<void> Function(JellyfinClient c) action) async {
|
||||
final c = _client;
|
||||
@@ -307,6 +362,16 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
||||
: const Icon(Icons.play_arrow),
|
||||
label: Text(label, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
if (playable != null && widget.onAddToQueue != null)
|
||||
GestureDetector(
|
||||
onSecondaryTap: _busy ? null : () => _queue(playable, next: true),
|
||||
child: IconButton(
|
||||
key: const ValueKey('details-add-queue'),
|
||||
onPressed: _busy ? null : () => _queue(playable),
|
||||
tooltip: 'Add to queue (right-click: play next)',
|
||||
icon: const Icon(Icons.playlist_add_rounded),
|
||||
),
|
||||
),
|
||||
if (playable != null)
|
||||
IconButton(
|
||||
key: const ValueKey('details-watched'),
|
||||
@@ -328,6 +393,13 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
||||
onPressed: () => _c.open(SeriesRoute(d.series!, seasonId: seasonId)),
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
),
|
||||
if (client != null)
|
||||
IconButton(
|
||||
key: const ValueKey('details-open-jellyfin'),
|
||||
tooltip: ReuseText.openInJellyfin,
|
||||
onPressed: () => openLink(jellyfinWebUrl(client.baseUrl, shown.id, serverId: _serverId)),
|
||||
icon: const Icon(Icons.open_in_browser_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_error != null) ...[
|
||||
@@ -392,18 +464,14 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
||||
children: [
|
||||
const Expanded(child: Text('Episodes', style: TextStyle(fontWeight: FontWeight.w600))),
|
||||
if (seasons.length > 1)
|
||||
DropdownButton<String>(
|
||||
Flexible(
|
||||
child: AppSelect<String?>(
|
||||
key: const ValueKey('details-season'),
|
||||
value: seasonId,
|
||||
isDense: true,
|
||||
underline: const SizedBox.shrink(),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
items: [
|
||||
for (final s in seasons)
|
||||
DropdownMenuItem(value: s.id, child: Text(s.name, overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
items: [for (final s in seasons) (s.id, s.name)],
|
||||
onChanged: (id) => setState(() => _seasonId = id),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -416,10 +484,12 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
||||
return Material(
|
||||
color: isTarget ? scheme.primary.withValues(alpha: 0.10) : Colors.transparent,
|
||||
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}'),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () => _tapEpisode(e),
|
||||
onSecondaryTap: () => _episodeMenu(context, e),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 4),
|
||||
child: Row(
|
||||
@@ -464,7 +534,7 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../state/settings.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import 'browse_scope.dart';
|
||||
import 'cards.dart';
|
||||
import 'home_rows.dart';
|
||||
@@ -184,18 +185,18 @@ class _LatestRow extends StatelessWidget {
|
||||
onPressed: () => scope.onOpen(LibraryRoute(view, sort: 'added')),
|
||||
child: const Text('Show all'),
|
||||
),
|
||||
PopupMenuButton<void>(
|
||||
AppMenuButton<bool>(
|
||||
key: ValueKey('latest-menu-${view.id}'),
|
||||
tooltip: 'Row options',
|
||||
icon: const Icon(Icons.more_vert, size: 18),
|
||||
itemBuilder: (context) => [
|
||||
CheckedPopupMenuItem(
|
||||
items: () => [
|
||||
AppMenuItem(
|
||||
key: const ValueKey('hide-rereleases'),
|
||||
value: !hide,
|
||||
checked: hide,
|
||||
onTap: () => AppSettings.instance.setHideReReleases(!hide),
|
||||
child: const Text('Hide re-releases in Latest'),
|
||||
label: 'Hide re-releases in Latest',
|
||||
),
|
||||
],
|
||||
onSelected: AppSettings.instance.setHideReReleases,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -14,6 +14,7 @@ class JellyfinPickerSource extends StatefulWidget {
|
||||
final JellyfinSelection? selected;
|
||||
final ValueChanged<JellyfinSelection> onSelect;
|
||||
final ValueChanged<JellyfinSelection> onPutUp;
|
||||
final void Function(JellyfinSelection item, {bool next})? onAddToQueue;
|
||||
final VoidCallback? onOpenIntegrations;
|
||||
final BrowseStart? start;
|
||||
final bool libraryStrip;
|
||||
@@ -25,6 +26,7 @@ class JellyfinPickerSource extends StatefulWidget {
|
||||
required this.accounts,
|
||||
required this.onSelect,
|
||||
required this.onPutUp,
|
||||
this.onAddToQueue,
|
||||
this.controller,
|
||||
this.selected,
|
||||
this.onOpenIntegrations,
|
||||
@@ -61,6 +63,7 @@ class _JellyfinPickerSourceState extends State<JellyfinPickerSource> {
|
||||
selected: widget.selected,
|
||||
onSelect: widget.onSelect,
|
||||
onPutUp: widget.onPutUp,
|
||||
onAddToQueue: widget.onAddToQueue,
|
||||
onOpenIntegrations: widget.onOpenIntegrations,
|
||||
start: widget.start,
|
||||
libraryStrip: widget.libraryStrip,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.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]
|
||||
/// (the session shows one quiet toast) and never touches playback. A 401
|
||||
/// 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 {
|
||||
final JellyfinClient client;
|
||||
final String itemId;
|
||||
@@ -34,20 +43,41 @@ class JellyfinProgressReporter {
|
||||
final void Function(Object error)? onError;
|
||||
final VoidCallback? onUnauthorized;
|
||||
|
||||
/// What this viewer watched of the item while it was open here.
|
||||
final WatchSession watch;
|
||||
|
||||
JellyfinProgressReporter({
|
||||
required this.client,
|
||||
required this.itemId,
|
||||
required this.source,
|
||||
required this.playSessionId,
|
||||
bool playedBefore = false,
|
||||
int resumeTicksBefore = 0,
|
||||
this.interval = const Duration(seconds: 10),
|
||||
this.onError,
|
||||
this.onUnauthorized,
|
||||
this.onStopped,
|
||||
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();
|
||||
bool _playing = false;
|
||||
bool _dead = false;
|
||||
bool _keepPlayed;
|
||||
double _runtime;
|
||||
double? _endingStart;
|
||||
PlayerSnapshot? _last;
|
||||
DateTime? _lastAt;
|
||||
DateTime? _lastSentAt;
|
||||
@@ -55,13 +85,23 @@ class JellyfinProgressReporter {
|
||||
/// Between [start] and [stop].
|
||||
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
|
||||
/// Jellyfin stream indices (-1 = subtitles off).
|
||||
JellyfinPlaybackReport reportFor(PlayerSnapshot s) => JellyfinPlaybackReport(
|
||||
JellyfinPlaybackReport reportFor(PlayerSnapshot s, {bool withPosition = true}) => JellyfinPlaybackReport(
|
||||
itemId: itemId,
|
||||
mediaSourceId: source.id,
|
||||
playSessionId: playSessionId,
|
||||
positionTicks: (s.position * jellyfinTicksPerSecond).round(),
|
||||
positionTicks: withPosition ? _ticks(s.position) : null,
|
||||
isPaused: s.paused,
|
||||
audioStreamIndex: s.aid == null
|
||||
? null
|
||||
@@ -71,13 +111,24 @@ class JellyfinProgressReporter {
|
||||
: 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
|
||||
/// 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) {
|
||||
if (_playing || _dead) return;
|
||||
_playing = true;
|
||||
_mark(s);
|
||||
watch.sample(s.position, playing: !s.paused, at: _clock());
|
||||
_send('Playing', () => client.reportPlaybackStart(reportFor(s)));
|
||||
if (_keepPlayed) _send('played', () => client.markPlayed(itemId));
|
||||
}
|
||||
|
||||
/// Progress when due (see the class doc).
|
||||
@@ -85,6 +136,7 @@ class JellyfinProgressReporter {
|
||||
if (!_playing || _dead) return;
|
||||
final last = _last, lastAt = _lastAt, sentAt = _lastSentAt;
|
||||
final now = _clock();
|
||||
watch.sample(s.position, playing: !s.paused, at: now);
|
||||
var due = force || last == null || lastAt == null || sentAt == null;
|
||||
if (!due) {
|
||||
final elapsed = now.difference(lastAt).inMilliseconds / 1000;
|
||||
@@ -99,16 +151,44 @@ class JellyfinProgressReporter {
|
||||
_lastAt = now;
|
||||
if (!due) return;
|
||||
_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.
|
||||
Future<void> stop(PlayerSnapshot s) {
|
||||
if (!_playing || _dead) return _chain;
|
||||
_playing = false;
|
||||
_send('Stopped', () => client.reportPlaybackStopped(reportFor(s)));
|
||||
return _chain;
|
||||
watch.sample(s.position, playing: !s.paused, at: _clock());
|
||||
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) {
|
||||
|
||||
@@ -375,6 +375,7 @@ class _EpisodeTileState extends State<EpisodeTile> {
|
||||
key: ValueKey('episode-${e.id}'),
|
||||
focusColor: Colors.transparent,
|
||||
onTap: () => _tap(scope),
|
||||
onSecondaryTap: () => scope.cardMenu(context, widget.episode),
|
||||
onHover: (h) => setState(() => _hover = h),
|
||||
onFocusChange: (f) => setState(() => _focus = f),
|
||||
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`)
|
||||
/// 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}) {
|
||||
if (endingStart != null && endingStart > Duration.zero && endingStart < runtime) {
|
||||
return endingStart;
|
||||
|
||||
@@ -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 '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:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.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 '../shaders/shader_assets.dart';
|
||||
@@ -16,14 +16,14 @@ import '../shaders/shader_presets.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../sync/state_diff.dart';
|
||||
import '../sync/user_seek.dart';
|
||||
import 'frame_watchdog.dart';
|
||||
import 'media_marks.dart';
|
||||
import 'yt_dlp_app.dart' show AppYtDlp, ytdlFormat;
|
||||
|
||||
/// Owns one mpv [Player] (and its [VideoController]) per session: created
|
||||
/// Owns the session's mpv [Player] (and its [VideoController]): created
|
||||
/// by [init] on join, released by [dispose] on leave, swapped by
|
||||
/// [recreate] for "Reload video". Never reuse a player across sessions —
|
||||
/// a GPU-side freeze sticks to the player, not to the widget.
|
||||
/// [recreate] for "Reload video" and by [openUrl] for every new media (a
|
||||
/// 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 {
|
||||
/// Read-ahead target: about two minutes rides out short outages and
|
||||
/// makes skipping an intro instant.
|
||||
@@ -41,9 +41,6 @@ class PlaybackController {
|
||||
|
||||
SyncPlayer? _player;
|
||||
VideoController? _video;
|
||||
Timer? _watchTimer;
|
||||
bool _probing = false;
|
||||
final FrameWatchdog _watchdog = FrameWatchdog();
|
||||
StreamSubscription<PlayerLog>? _logSub;
|
||||
final List<StreamSubscription<Object?>> _timeSubs = [];
|
||||
String _chapterJson = '';
|
||||
@@ -60,9 +57,6 @@ class PlaybackController {
|
||||
/// otherwise, so no texture outlives its player.
|
||||
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).
|
||||
void Function(String error)? onYtdlError;
|
||||
|
||||
@@ -89,6 +83,7 @@ class PlaybackController {
|
||||
final p = _player;
|
||||
if (p == null) return;
|
||||
try {
|
||||
_ytdlPath = path;
|
||||
final dynamic platform = p.platform;
|
||||
if (!identical(p, _ytdlSetOn)) {
|
||||
await platform?.setProperty('script-opts', 'ytdl_hook-ytdl_path=$path');
|
||||
@@ -108,10 +103,7 @@ class PlaybackController {
|
||||
|
||||
Player? _ytdlSetOn;
|
||||
String _ytdlRaw = '';
|
||||
|
||||
/// Fingerprint of the picture on screen, installed by the video view
|
||||
/// (`ShownFrameProbe.sample`). Without it the watchdog stays idle.
|
||||
Future<int?> Function()? shownFrame;
|
||||
String? _ytdlPath;
|
||||
|
||||
Player get player => _player!;
|
||||
|
||||
@@ -157,6 +149,9 @@ class PlaybackController {
|
||||
/// `demuxer-cache-time`, media_kit's `buffer`), in 250 ms steps.
|
||||
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
|
||||
/// chapter) and the ending ([endingRange]: Outro, else ED-titled).
|
||||
late final Computed<SkipRange?> opening =
|
||||
@@ -199,16 +194,19 @@ class PlaybackController {
|
||||
return AppSettings.instance.hwdec.peek();
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
if (_player != null) return;
|
||||
_player = SyncPlayer(
|
||||
SyncPlayer _newPlayer({required int bufferSize, required MPVLogLevel logLevel}) => SyncPlayer(
|
||||
configuration: PlayerConfiguration(
|
||||
bufferSize: maxBytes,
|
||||
bufferSize: bufferSize,
|
||||
libass: true,
|
||||
vo: Platform.environment['SYNC_RELAY_VO'],
|
||||
logLevel: MPVLogLevel.warn, // see [logLevel]
|
||||
logLevel: logLevel,
|
||||
))
|
||||
..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 = '';
|
||||
renderMode.value = RenderMode.unknown;
|
||||
shaderNotice.value = null;
|
||||
@@ -217,8 +215,6 @@ class PlaybackController {
|
||||
await _pinBufferProps();
|
||||
await _pin('volume-max', '$maxVolume');
|
||||
await applyShaders();
|
||||
_watchdog.reset();
|
||||
_watchTimer = Timer.periodic(const Duration(seconds: 1), (_) => _watch());
|
||||
generation.value++;
|
||||
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 {
|
||||
final dynamic platform = player.platform;
|
||||
final dynamic platform = (on ?? player).platform;
|
||||
await platform?.setProperty(property, value);
|
||||
final got = '${await platform?.getProperty(property)}';
|
||||
if (!pinMatches(value, got)) {
|
||||
@@ -251,17 +247,17 @@ class PlaybackController {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pinBufferProps() async {
|
||||
pinMismatches.clear();
|
||||
await _pin('demuxer-readahead-secs', '$readAheadSecs');
|
||||
await _pin('cache-secs', '$readAheadSecs');
|
||||
await _pin('demuxer-max-bytes', '$maxBytes');
|
||||
await _pin('demuxer-max-back-bytes', '$maxBackBytes');
|
||||
Future<void> _pinBufferProps([Player? on, int ahead = maxBytes, int back = maxBackBytes]) async {
|
||||
if (on == null) pinMismatches.clear();
|
||||
await _pin('demuxer-readahead-secs', '$readAheadSecs', on);
|
||||
await _pin('cache-secs', '$readAheadSecs', on);
|
||||
await _pin('demuxer-max-bytes', '$ahead', on);
|
||||
await _pin('demuxer-max-back-bytes', '$back', on);
|
||||
final hwdec = _hwdecOverride;
|
||||
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
|
||||
@@ -368,6 +364,7 @@ class PlaybackController {
|
||||
// an error there while shaders are on is a shader that failed to load
|
||||
// or compile (mpv then renders without it).
|
||||
void _onLog(PlayerLog log) {
|
||||
_toAppLog(log);
|
||||
if (log.prefix == 'ytdl_hook' && (log.level == 'error' || log.level == 'fatal')) {
|
||||
final text = log.text.trim().replaceFirst(RegExp(r'^ERROR:\s*'), '');
|
||||
if (text.isNotEmpty) {
|
||||
@@ -384,12 +381,11 @@ class PlaybackController {
|
||||
return;
|
||||
}
|
||||
// Nothing playable opened (a track of mpv's yt-dlp EDL refused, ...).
|
||||
if (!_loadErrorSent &&
|
||||
duration.peek() == Duration.zero &&
|
||||
((log.prefix == 'cplayer' && log.level == 'fatal') ||
|
||||
(log.prefix == 'demux' && log.level == 'error'))) {
|
||||
if (!_loadErrorSent && _loadFailed(log, duration.peek())) {
|
||||
_loadErrorSent = true;
|
||||
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');
|
||||
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) {
|
||||
renderMode.value = mode;
|
||||
lastRenderMode.value = mode;
|
||||
@@ -439,78 +461,37 @@ class PlaybackController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fingerprint of the decoder's current frame (mpv `screenshot-raw`,
|
||||
/// off-thread); null without video.
|
||||
Future<int?> _sourceFrame(Player p) async {
|
||||
/// `mpv-version` and `ffmpeg-version` of the session player, for
|
||||
/// diagnostics; null when there is no player.
|
||||
Future<String?> mpvVersion() async {
|
||||
final p = _player;
|
||||
if (p == null) return null;
|
||||
try {
|
||||
final raw = await p.screenshot(format: null);
|
||||
return raw == null || raw.isEmpty ? null : hashSpread(raw);
|
||||
final dynamic platform = p.platform;
|
||||
return '${await platform?.getProperty('mpv-version')}, ffmpeg ${await platform?.getProperty('ffmpeg-version')}';
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// A suspected freeze is real only if mpv's own frames still move: a
|
||||
/// still scene looks frozen on screen too.
|
||||
Future<bool> _sourceMoving(Player p) async {
|
||||
final a = await _sourceFrame(p);
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
if (!identical(p, _player) || !p.state.playing) return false;
|
||||
final b = await _sourceFrame(p);
|
||||
return a != null && b != null && a != b;
|
||||
}
|
||||
|
||||
static bool get _windowVisible {
|
||||
try {
|
||||
final s = WidgetsBinding.instance.lifecycleState;
|
||||
return s == null ||
|
||||
s == AppLifecycleState.resumed ||
|
||||
s == AppLifecycleState.inactive;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _watch() async {
|
||||
final p = _player;
|
||||
final probe = shownFrame;
|
||||
// Only meaningful once the video view is up.
|
||||
if (p == null || _video == null || probe == null || _probing) return;
|
||||
_probing = true;
|
||||
try {
|
||||
final frames = await probe();
|
||||
if (!identical(p, _player)) return;
|
||||
final s = p.state;
|
||||
final eligible = s.playing &&
|
||||
!s.buffering &&
|
||||
!s.completed &&
|
||||
s.rate > 0 &&
|
||||
(s.width ?? 0) > 0 &&
|
||||
_windowVisible;
|
||||
final fired = _watchdog.feed(FrameSample(
|
||||
at: DateTime.now(),
|
||||
position: positionSecs,
|
||||
frames: frames,
|
||||
eligible: eligible,
|
||||
));
|
||||
if (fired) {
|
||||
if (await _sourceMoving(p) && identical(p, _player)) {
|
||||
debugPrint('video watchdog: picture stuck while mpv frames move');
|
||||
onFrozen?.call();
|
||||
} else {
|
||||
_watchdog.dismiss();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_probing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens [url] paused. [headers] go with every request for it (mpv
|
||||
/// `http-header-fields`): a Jellyfin token travels there, not in the URL.
|
||||
Future<void> openUrl(String url, {Map<String, String>? headers}) async {
|
||||
if (!_ready) return;
|
||||
/// Each media gets its own player: when [preload] holds the same [url]
|
||||
/// 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 {
|
||||
_held = true;
|
||||
_chapterJson = '';
|
||||
_httpError = null;
|
||||
_loadErrorSent = false;
|
||||
@@ -519,15 +500,18 @@ class PlaybackController {
|
||||
buffered.value = Duration.zero;
|
||||
// An empty map, not null: media_kit caches headers per URI.
|
||||
await player.open(Media(url, httpHeaders: headers ?? const {}), play: false);
|
||||
return false;
|
||||
} catch (e, st) {
|
||||
debugPrint('open ${Uri.tryParse(url)?.replace(query: '') ?? 'media'} failed: $e\n$st');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Position / duration from media_kit's streams; chapter-list and
|
||||
// Position, duration and volume from media_kit's streams; chapter-list and
|
||||
// track-list observed in mpv (read back as JSON).
|
||||
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) {
|
||||
// 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.
|
||||
@@ -687,10 +671,9 @@ class PlaybackController {
|
||||
/// Release the player. The view drops its texture first ([alive] goes
|
||||
/// false, one frame passes), then mpv is torn down.
|
||||
Future<void> dispose() async {
|
||||
await dropPreload();
|
||||
final p = _player;
|
||||
if (p == null) return;
|
||||
_watchTimer?.cancel();
|
||||
_watchTimer = null;
|
||||
await _logSub?.cancel();
|
||||
_logSub = null;
|
||||
for (final sub in _timeSubs) {
|
||||
@@ -699,6 +682,7 @@ class PlaybackController {
|
||||
_timeSubs.clear();
|
||||
_player = null;
|
||||
_video = null;
|
||||
_held = false;
|
||||
alive.value = false;
|
||||
_clearMarks();
|
||||
await _settleUi();
|
||||
@@ -721,6 +705,246 @@ class PlaybackController {
|
||||
} 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 {
|
||||
if (!_ready) return;
|
||||
try {
|
||||
@@ -753,3 +977,27 @@ bool pinMatches(String wanted, String got) {
|
||||
final a = double.tryParse(wanted), b = double.tryParse(got);
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,12 @@ import 'package:sync_relay_core/core.dart';
|
||||
/// mpv's `ytdl-format` for links every player opens itself.
|
||||
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 hosted server. [probe] remembers what pasted links are for a while,
|
||||
/// so the Link preview and the swap ask once.
|
||||
@@ -28,30 +34,56 @@ class AppYtDlp {
|
||||
/// update check afterwards).
|
||||
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
|
||||
|
||||
/// mpv's `ytdl-raw-options` for the cookies in the yt-dlp folder ('' for
|
||||
/// none; see [YtDlpCookies]).
|
||||
/// mpv's `ytdl-raw-options`: [ytdlSubLangs] and the cookies in the
|
||||
/// yt-dlp folder (see [YtDlpCookies]).
|
||||
Future<String> mpvRawOptions() async {
|
||||
const subs = 'sub-langs=%${ytdlSubLangs.length}%$ytdlSubLangs';
|
||||
try {
|
||||
return (await ytDlp).cookies.mpvRawOptions();
|
||||
final cookies = (await ytDlp).cookies.mpvRawOptions();
|
||||
return cookies.isEmpty ? subs : '$subs,$cookies';
|
||||
} 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);
|
||||
|
||||
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();
|
||||
_probes.removeWhere((_, v) => now.difference(v.$1) > _probeTtl);
|
||||
_probes.removeWhere((_, v) => now.isAfter(v.until));
|
||||
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));
|
||||
_probes[url] = (now, f);
|
||||
final until = now.add(keep);
|
||||
if (hit == null) {
|
||||
_probes[url] = (at: now, until: until, f: f);
|
||||
f.catchError((Object _) {
|
||||
_probes.remove(url);
|
||||
if (_probes[url]?.f == f) _probes.remove(url);
|
||||
return LinkProbe.file(Uri(), -1);
|
||||
});
|
||||
} else {
|
||||
f.then<void>((_) {
|
||||
_probes[url] = (at: now, until: until, f: f);
|
||||
}, onError: (Object _) {});
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
/// [url] no longer needs keeping longer: back to the usual 10 minutes.
|
||||
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
@@ -198,6 +198,8 @@ class AnimeFlowEpisode {
|
||||
final int episode;
|
||||
final int? absolute;
|
||||
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;
|
||||
/// The AniList entry of this episode's season (one AnimeFlow series may
|
||||
/// span several AniList entries).
|
||||
@@ -208,8 +210,11 @@ class AnimeFlowEpisode {
|
||||
final EpisodeStatus status;
|
||||
/// 0..1, only while downloading.
|
||||
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;
|
||||
/// Last search for a monitored, not yet downloaded episode.
|
||||
final DateTime? lastSearchedAt;
|
||||
|
||||
const AnimeFlowEpisode({
|
||||
required this.season,
|
||||
@@ -222,6 +227,7 @@ class AnimeFlowEpisode {
|
||||
required this.status,
|
||||
this.progress,
|
||||
this.reason,
|
||||
this.lastSearchedAt,
|
||||
});
|
||||
|
||||
static AnimeFlowEpisode fromJson(Map j) => AnimeFlowEpisode(
|
||||
@@ -237,6 +243,7 @@ class AnimeFlowEpisode {
|
||||
status: EpisodeStatus.parse(j['status']),
|
||||
progress: (j['progress'] as num?)?.toDouble(),
|
||||
reason: _str(j['reason']),
|
||||
lastSearchedAt: _time(j['last_searched_at']),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,12 @@ enum ScheduleStatus {
|
||||
available,
|
||||
downloading,
|
||||
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,
|
||||
failed,
|
||||
/// AnimeFlow does not monitor it: the only status worth a Request.
|
||||
unmonitored,
|
||||
/// AnimeFlow does not track this series (or is unreachable).
|
||||
notTracked,
|
||||
@@ -44,8 +48,10 @@ class ScheduleEntry {
|
||||
final ScheduleStatus status;
|
||||
/// 0..1 while downloading.
|
||||
final double? progress;
|
||||
/// Why it failed.
|
||||
/// Why it failed, or (missing) why the releases found were rejected.
|
||||
final String? reason;
|
||||
/// AnimeFlow's last search for it (missing).
|
||||
final DateTime? lastSearchedAt;
|
||||
/// AnimeFlow tracks the series.
|
||||
final bool tracked;
|
||||
/// AnimeFlow's series id (requests).
|
||||
@@ -53,11 +59,15 @@ class ScheduleEntry {
|
||||
/// The AnimeFlow season of the matched episode (requests); untracked
|
||||
/// on Jellyfin: the TVDB season of the AniList entry.
|
||||
final int? season;
|
||||
/// Untracked on Jellyfin: the TVDB episode number in [season] (AniList
|
||||
/// episode + the `anilist_map` run's offset).
|
||||
/// The TVDB episode number in [season]: tracked, AnimeFlow's own;
|
||||
/// untracked on Jellyfin, AniList episode + the `anilist_map` run's
|
||||
/// offset.
|
||||
final int? jellyfinEpisode;
|
||||
/// Play target once available.
|
||||
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).
|
||||
bool get onJellyfin => jellyfinSeriesId != null;
|
||||
|
||||
@@ -66,23 +76,27 @@ class ScheduleEntry {
|
||||
required this.status,
|
||||
this.progress,
|
||||
this.reason,
|
||||
this.lastSearchedAt,
|
||||
this.tracked = false,
|
||||
this.seriesId,
|
||||
this.season,
|
||||
this.jellyfinEpisode,
|
||||
this.jellyfinSeriesId,
|
||||
this.watch,
|
||||
});
|
||||
|
||||
ScheduleEntry withStatus(ScheduleStatus s) => ScheduleEntry(
|
||||
ScheduleEntry copyWith({ScheduleStatus? status, EpisodeWatch? watch}) => ScheduleEntry(
|
||||
airing: airing,
|
||||
status: s,
|
||||
status: status ?? this.status,
|
||||
progress: progress,
|
||||
reason: reason,
|
||||
lastSearchedAt: lastSearchedAt,
|
||||
tracked: tracked,
|
||||
seriesId: seriesId,
|
||||
season: season,
|
||||
jellyfinEpisode: jellyfinEpisode,
|
||||
jellyfinSeriesId: jellyfinSeriesId,
|
||||
watch: watch ?? this.watch,
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -95,11 +109,59 @@ ScheduleEntry untrackedEntry(Airing a, DateTime now) => ScheduleEntry(
|
||||
status: a.airingAt.isAfter(now) ? ScheduleStatus.upcoming : ScheduleStatus.notTracked,
|
||||
);
|
||||
|
||||
String _day(DateTime d) {
|
||||
final l = d.toLocal();
|
||||
return '${l.year}-${l.month}-${l.day}';
|
||||
/// This user's state of a Jellyfin episode (its `UserData`).
|
||||
class EpisodeWatch {
|
||||
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 {
|
||||
final int seriesId;
|
||||
final String? jellyfinSeriesId;
|
||||
@@ -155,7 +217,7 @@ List<ScheduleEntry> matchSchedule(
|
||||
}
|
||||
byEp.putIfAbsent('$id#${e.episode}', () => it);
|
||||
if (e.absolute != null) byAbs.putIfAbsent('$id#${e.absolute}', () => it);
|
||||
if (e.airDate != null) byDay.putIfAbsent('$id@${_day(e.airDate!)}', () => it);
|
||||
if (e.airDate != null) byDay.putIfAbsent('$id@${_dayKey(_jstDay(e.airDate!))}', () => it);
|
||||
}
|
||||
return [
|
||||
for (final a in airings)
|
||||
@@ -164,17 +226,21 @@ List<ScheduleEntry> matchSchedule(
|
||||
final ref = refs[id];
|
||||
final it = numbered.contains(id)
|
||||
? 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) {
|
||||
final e = it.episode;
|
||||
return ScheduleEntry(
|
||||
airing: a,
|
||||
status: ScheduleStatus.of(e.status),
|
||||
progress: e.status == EpisodeStatus.downloading ? e.progress : null,
|
||||
reason: e.status == EpisodeStatus.failed ? e.reason : null,
|
||||
reason: e.status == EpisodeStatus.failed || e.status == EpisodeStatus.missing ? e.reason : null,
|
||||
lastSearchedAt: e.lastSearchedAt,
|
||||
tracked: true,
|
||||
seriesId: it.seriesId,
|
||||
season: e.season,
|
||||
jellyfinEpisode: e.episode,
|
||||
jellyfinSeriesId: it.jellyfinSeriesId ?? ref?.jellyfinSeriesId ?? lib[id]?.$1,
|
||||
);
|
||||
}
|
||||
@@ -200,26 +266,45 @@ List<ScheduleEntry> matchSchedule(
|
||||
];
|
||||
}
|
||||
|
||||
/// Checks [matchSchedule]'s [ScheduleStatus.inLibrary] entries (series
|
||||
/// on Jellyfin) against the episodes Jellyfin has: [episodes] maps a
|
||||
/// Jellyfin series id to its (season, episode) numbers. Found stays
|
||||
/// inLibrary, absent becomes [ScheduleStatus.notOnJellyfin], a series
|
||||
/// missing from [episodes] (not looked up) [ScheduleStatus.aired].
|
||||
List<ScheduleEntry> confirmOnJellyfin(List<ScheduleEntry> entries, Map<String, Set<(int, int)>> episodes) => [
|
||||
/// The episodes [confirmOnJellyfin] looks up: on Jellyfin, untracked
|
||||
/// ([ScheduleStatus.inLibrary]) or available through AnimeFlow.
|
||||
bool needsJellyfinLookup(ScheduleEntry e) =>
|
||||
e.jellyfinSeriesId != null && (e.status == ScheduleStatus.inLibrary || e.status == ScheduleStatus.available);
|
||||
|
||||
/// 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)
|
||||
if (e.status != ScheduleStatus.inLibrary)
|
||||
if (!needsJellyfinLookup(e))
|
||||
e
|
||||
else
|
||||
switch (episodes[e.jellyfinSeriesId]) {
|
||||
null => e.withStatus(ScheduleStatus.aired),
|
||||
final have when have.contains((e.season, e.jellyfinEpisode)) => e,
|
||||
_ => e.withStatus(ScheduleStatus.notOnJellyfin),
|
||||
switch ((episodes[e.jellyfinSeriesId], e.status)) {
|
||||
(null, ScheduleStatus.inLibrary) => e.copyWith(status: ScheduleStatus.aired),
|
||||
(null, _) => e,
|
||||
(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
|
||||
/// cannot tell (no linked account for that server).
|
||||
typedef JellyfinEpisodeLookup = Future<Set<(int, int)>?> Function(String jellyfinSeriesId);
|
||||
JellyfinEpisodeRef? _episodeOf(List<JellyfinEpisodeRef> have, ScheduleEntry e) {
|
||||
final byNumber = have.where((x) => x.season == e.season && x.episode == e.jellyfinEpisode).firstOrNull;
|
||||
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
|
||||
/// when AniList failed with nothing cached.
|
||||
@@ -262,8 +347,9 @@ class ScheduleRepository {
|
||||
final AniListClient aniList;
|
||||
/// Null = not configured (AniList only).
|
||||
final AnimeFlowClient? animeFlow;
|
||||
/// Confirms "on Jellyfin" per episode ([confirmOnJellyfin]); null =
|
||||
/// never confirmed (aired entries read [ScheduleStatus.aired]).
|
||||
/// Confirms "on Jellyfin" and this user's watched state per episode
|
||||
/// ([confirmOnJellyfin]); null = never confirmed (aired entries read
|
||||
/// [ScheduleStatus.aired], nothing shows as watched).
|
||||
final JellyfinEpisodeLookup? jellyfinEpisodes;
|
||||
final DateTime Function() _now;
|
||||
|
||||
@@ -303,15 +389,16 @@ class ScheduleRepository {
|
||||
);
|
||||
}
|
||||
|
||||
/// Looks up the episodes of every series an inLibrary entry is on, in
|
||||
/// parallel; a failed or impossible lookup leaves that series out.
|
||||
/// Looks up the episodes of every series an entry on Jellyfin is on
|
||||
/// ([needsJellyfinLookup]), in parallel, one request per series; a
|
||||
/// failed or impossible lookup leaves that series out.
|
||||
Future<List<ScheduleEntry>> _confirm(List<ScheduleEntry> entries) async {
|
||||
final lookup = jellyfinEpisodes;
|
||||
final ids = {
|
||||
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) {
|
||||
await Future.wait([
|
||||
for (final id in ids)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:sync_relay_core/core.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 '../anilist.dart';
|
||||
import '../animeflow.dart';
|
||||
@@ -47,14 +47,9 @@ String friendlyError(Object e) => switch (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
|
||||
/// 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 {
|
||||
final id = e.seriesId;
|
||||
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();
|
||||
return mine.where((x) => x.anilistId == a.anilistId && x.episode == a.episode).firstOrNull ??
|
||||
mine.where((x) => x.absolute == a.episode).firstOrNull ??
|
||||
mine.where((x) => x.airDate != null && _sameDay(x.airDate!, a.airingAt)).firstOrNull;
|
||||
mine.where((x) => x.airDate != null && sameAirDay(x.airDate!, a.airingAt)).firstOrNull;
|
||||
}
|
||||
|
||||
/// The (season, episode) AnimeFlow uses for [e]; falls back to the
|
||||
@@ -112,11 +107,13 @@ JellyfinAccount? jellyfinAccountFor(JellyfinAccounts accounts, AnimeFlowClient?
|
||||
return accounts.defaultAccount;
|
||||
}
|
||||
|
||||
/// The (season, episode) numbers of the episodes Jellyfin has of
|
||||
/// [jellyfinSeriesId], seen by the account AnimeFlow's ids belong to
|
||||
/// ([jellyfinAccountFor]); null without a linked account. One request per
|
||||
/// series, memoised a few minutes ([JellyfinAccounts.cached]).
|
||||
Future<Set<(int, int)>?> jellyfinEpisodeNumbers(
|
||||
/// The episodes Jellyfin has of [jellyfinSeriesId] (numbers, premiere
|
||||
/// date, this user's watched state), seen by the account AnimeFlow's ids
|
||||
/// belong to ([jellyfinAccountFor]): the user's own; null without a
|
||||
/// linked account. One request per series, memoised a few minutes
|
||||
/// ([JellyfinAccounts.cached]; a stop report drops it,
|
||||
/// [JellyfinAccounts.watched]).
|
||||
Future<List<JellyfinEpisodeRef>?> jellyfinScheduleEpisodes(
|
||||
JellyfinAccounts accounts,
|
||||
AnimeFlowClient? flow,
|
||||
String jellyfinSeriesId,
|
||||
@@ -125,10 +122,20 @@ Future<Set<(int, int)>?> jellyfinEpisodeNumbers(
|
||||
final account = jellyfinAccountFor(accounts, flow);
|
||||
final client = account == null ? null : accounts.clientFor(account.serverId);
|
||||
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))
|
||||
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
|
||||
@@ -153,7 +160,7 @@ Future<JellyfinMedia> resolveJellyfinEpisode(JellyfinAccounts accounts, AnimeFlo
|
||||
final a = e.airing;
|
||||
final item =
|
||||
eps.where((x) => x.parentIndexNumber == n.season && x.indexNumber == n.episode).firstOrNull ??
|
||||
eps.where((x) => x.premiereDate != null && _sameDay(x.premiereDate!, a.airingAt)).singleOrNull ??
|
||||
eps.where((x) => x.premiereDate != null && sameAirDay(x.premiereDate!, a.airingAt)).singleOrNull ??
|
||||
eps.where((x) => x.indexNumber == a.episode).singleOrNull;
|
||||
if (item == null) {
|
||||
throw ScheduleActionException('Episode ${a.episode} of ${a.title} is not on Jellyfin yet');
|
||||
|
||||
@@ -4,9 +4,9 @@ import 'package:flutter/material.dart';
|
||||
import '../schedule_model.dart';
|
||||
|
||||
// One day of the Schedule board and its airing cards: cover poster on the
|
||||
// left (air time on it, download progress under it), title, episode and
|
||||
// status on the right, one full-width action below. Compact mode is a
|
||||
// slim row.
|
||||
// left (air time on it, download progress or this user's watch progress
|
||||
// under it, a check once watched), title, episode and status on the
|
||||
// right, one full-width action below. Compact mode is a slim row.
|
||||
|
||||
/// What a card's action does, decided by the page.
|
||||
enum CardAction {
|
||||
@@ -35,8 +35,29 @@ String countdownText(DateTime at, DateTime now) {
|
||||
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.
|
||||
(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.downloading => (
|
||||
e.progress == null ? 'downloading' : 'downloading ${(e.progress! * 100).round()}%',
|
||||
@@ -44,14 +65,25 @@ String countdownText(DateTime at, DateTime now) {
|
||||
Icons.downloading,
|
||||
),
|
||||
ScheduleStatus.upcoming => (countdownText(e.airing.airingAt, now), Colors.amber, Icons.schedule),
|
||||
ScheduleStatus.missing => ('missing', Colors.orangeAccent, Icons.report_gmailerrorred),
|
||||
ScheduleStatus.failed => ('failed${e.reason == null ? '' : ' · ${e.reason}'}', Colors.redAccent, Icons.error_outline),
|
||||
ScheduleStatus.missing => ('waiting for release', Colors.amber.shade200, Icons.hourglass_top),
|
||||
ScheduleStatus.failed => (
|
||||
'failed${e.reason == null ? '' : ' · ${e.reason}'}',
|
||||
Colors.redAccent,
|
||||
Icons.error_outline,
|
||||
),
|
||||
ScheduleStatus.unmonitored => ('not monitored', Colors.grey, Icons.visibility_off_outlined),
|
||||
ScheduleStatus.notTracked => ('not in AnimeFlow', Colors.grey, Icons.public),
|
||||
ScheduleStatus.inLibrary => ('on Jellyfin', Colors.greenAccent, Icons.video_library_outlined),
|
||||
ScheduleStatus.notOnJellyfin => ('aired · not on Jellyfin yet', Colors.grey, Icons.hourglass_empty),
|
||||
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 {
|
||||
final DateTime day;
|
||||
@@ -137,6 +169,9 @@ class AiringCard extends StatelessWidget {
|
||||
final bool busy;
|
||||
final VoidCallback onAction;
|
||||
|
||||
/// Right-click ([context]: the card): the page's menu; null: none.
|
||||
final void Function(BuildContext context)? onMenu;
|
||||
|
||||
const AiringCard({
|
||||
super.key,
|
||||
required this.entry,
|
||||
@@ -146,6 +181,7 @@ class AiringCard extends StatelessWidget {
|
||||
required this.onAction,
|
||||
this.disabledHint,
|
||||
this.busy = false,
|
||||
this.onMenu,
|
||||
});
|
||||
|
||||
String get _id => '${entry.airing.anilistId}-${entry.airing.episode}';
|
||||
@@ -153,6 +189,56 @@ class AiringCard extends StatelessWidget {
|
||||
Widget _image(String? url, Widget 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) {
|
||||
final enabled = disabledHint == null && !busy;
|
||||
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 a = entry.airing;
|
||||
final (label, color, icon) = statusBadge(entry, now);
|
||||
final hint = statusHint(entry, now);
|
||||
final time = clockText(a.airingAt);
|
||||
final tintColor = a.color ?? Colors.blueGrey;
|
||||
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) {
|
||||
final requestable = action == CardAction.request || action == CardAction.requestSeries;
|
||||
return Material(
|
||||
@@ -201,13 +292,21 @@ class AiringCard extends StatelessWidget {
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: tap,
|
||||
onSecondaryTap: menu,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(width: 34, height: 48, child: _image(a.cover, tint)),
|
||||
child: SizedBox(
|
||||
width: 34,
|
||||
height: 48,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [_dimmed(_image(a.cover, tint)), ..._watchMarks(small: true)],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
@@ -237,7 +336,7 @@ class AiringCard extends StatelessWidget {
|
||||
)
|
||||
else
|
||||
Tooltip(
|
||||
message: label,
|
||||
message: hint == null ? label : '$label: $hint',
|
||||
child: Icon(icon, size: 16, color: color),
|
||||
),
|
||||
],
|
||||
@@ -255,6 +354,7 @@ class AiringCard extends StatelessWidget {
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: tap,
|
||||
onSecondaryTap: menu,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
@@ -271,7 +371,7 @@ class AiringCard extends StatelessWidget {
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_image(a.cover, tint),
|
||||
_dimmed(_image(a.cover, tint)),
|
||||
Positioned(
|
||||
left: 4,
|
||||
top: 4,
|
||||
@@ -287,6 +387,7 @@ class AiringCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
..._watchMarks(),
|
||||
if (entry.status == ScheduleStatus.downloading)
|
||||
Positioned(
|
||||
left: 0,
|
||||
@@ -323,6 +424,8 @@ class AiringCard extends StatelessWidget {
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
const Spacer(),
|
||||
_hinted(
|
||||
hint,
|
||||
Row(
|
||||
children: [
|
||||
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 '../../shell/avatar.dart';
|
||||
import '../../shell/motion.dart';
|
||||
import '../../widgets/app_menu.dart';
|
||||
import '../anilist.dart';
|
||||
import '../animeflow.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
|
||||
// weeks out (AniList), each airing with what AnimeFlow has of it, and the
|
||||
// one thing to do about it (put it up, request it). Refreshes every
|
||||
// minute while visible; ETags keep that cheap.
|
||||
// one thing to do about it (put it up, request it; right-click an
|
||||
// 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 }
|
||||
|
||||
@@ -27,6 +30,9 @@ class SchedulePage extends StatefulWidget {
|
||||
|
||||
/// Puts a Jellyfin episode up in the server; returns an error to show.
|
||||
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;
|
||||
|
||||
/// Null = the page makes (and closes) its own.
|
||||
@@ -39,6 +45,7 @@ class SchedulePage extends StatefulWidget {
|
||||
super.key,
|
||||
this.solo = false,
|
||||
required this.onPutUp,
|
||||
this.onAddToQueue,
|
||||
required this.onToast,
|
||||
this.repository,
|
||||
this.accounts,
|
||||
@@ -64,6 +71,7 @@ class _SchedulePageState extends State<SchedulePage> {
|
||||
bool _refreshing = false;
|
||||
int _gen = 0;
|
||||
Timer? _timer;
|
||||
StreamSubscription<String>? _watchedSub;
|
||||
final _scroll = ScrollController();
|
||||
bool _jumped = false;
|
||||
|
||||
@@ -75,7 +83,6 @@ class _SchedulePageState extends State<SchedulePage> {
|
||||
/// The sheet stays built while it slides out.
|
||||
bool _sheetBuilt = false;
|
||||
|
||||
|
||||
/// Requested in this run: "anilistId#episode" or "series:anilistId".
|
||||
final _requested = <String>{};
|
||||
|
||||
@@ -100,6 +107,11 @@ class _SchedulePageState extends State<SchedulePage> {
|
||||
_timer = Timer.periodic(SchedulePage.refreshEvery, (_) {
|
||||
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() {
|
||||
@@ -107,13 +119,14 @@ class _SchedulePageState extends State<SchedulePage> {
|
||||
return ScheduleRepository(
|
||||
aniList: AniListClient(),
|
||||
animeFlow: flow,
|
||||
jellyfinEpisodes: (id) => jellyfinEpisodeNumbers(_accounts, flow, id),
|
||||
jellyfinEpisodes: (id) => jellyfinScheduleEpisodes(_accounts, flow, id),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_watchedSub?.cancel();
|
||||
_scroll.dispose();
|
||||
if (_ownsRepo) {
|
||||
_repo.aniList.close();
|
||||
@@ -222,9 +235,10 @@ class _SchedulePageState extends State<SchedulePage> {
|
||||
final me = _me;
|
||||
final CardAction kind;
|
||||
if (e.tracked) {
|
||||
kind = e.status == ScheduleStatus.missing || e.status == ScheduleStatus.failed
|
||||
? CardAction.request
|
||||
: CardAction.none;
|
||||
// Only an unmonitored episode is worth asking for: AnimeFlow grabs
|
||||
// a missing one (no release yet) by itself once a release appears,
|
||||
// and retries a failed one.
|
||||
kind = e.status == ScheduleStatus.unmonitored ? CardAction.request : CardAction.none;
|
||||
} else {
|
||||
// On Jellyfin already (owner added it by hand): nothing to request.
|
||||
kind = me != null && !e.onJellyfin ? CardAction.requestSeries : CardAction.none;
|
||||
@@ -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 {
|
||||
final c = _flow;
|
||||
if (c == null) return;
|
||||
@@ -520,6 +560,9 @@ class _SchedulePageState extends State<SchedulePage> {
|
||||
disabledHint: hint,
|
||||
busy: busy,
|
||||
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/services.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:signals/signals_flutter.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 '../../widgets/app_menu.dart';
|
||||
import '../../widgets/copy_open.dart';
|
||||
import 'settings_page.dart';
|
||||
import 'ui.dart';
|
||||
import 'update_indicator.dart';
|
||||
|
||||
/// Opens [url] in the default browser (no url_launcher dependency).
|
||||
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;
|
||||
}
|
||||
}
|
||||
/// The Minimal player skin's credit (README says the same).
|
||||
const uoscCredit = 'Player timeline and controls inspired by uosc by tomasklaen, LGPL-2.1 '
|
||||
'(github.com/tomasklaen/uosc).';
|
||||
|
||||
/// Name, version, update state and the source link (Settings → About).
|
||||
/// Name, version, update state, the source link and the logs (Settings →
|
||||
/// About).
|
||||
class AboutPanel extends StatelessWidget {
|
||||
final UpdateController? 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
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
@@ -49,7 +32,7 @@ class AboutPanel extends StatelessWidget {
|
||||
children: [
|
||||
Text(appName, style: t.titleLarge),
|
||||
const SizedBox(height: 4),
|
||||
SelectableText('Version $appVersion',
|
||||
SelectableText('Version ${displayVersion()}',
|
||||
style: t.bodyMedium?.copyWith(color: Colors.grey)),
|
||||
const SizedBox(height: 4),
|
||||
UpdateStatusLine(controller: updates),
|
||||
@@ -58,20 +41,87 @@ class AboutPanel extends StatelessWidget {
|
||||
children: [
|
||||
Expanded(child: SelectableText(repoUrl, style: t.bodySmall)),
|
||||
IconButton(
|
||||
tooltip: 'Copy link',
|
||||
tooltip: ReuseText.copyLink,
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
onPressed: () =>
|
||||
Clipboard.setData(const ClipboardData(text: repoUrl)),
|
||||
onPressed: () => copyToClipboard(repoUrl, done: 'Link copied'),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => _open(context),
|
||||
onPressed: () => openLink(repoUrl),
|
||||
icon: const Icon(Icons.open_in_new, size: 18),
|
||||
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 file;
|
||||
MediaKind _source;
|
||||
LinkRelay _linkRelay = LinkRelay.direct;
|
||||
// Through me unless picked otherwise (Library → Link).
|
||||
LinkRelay _linkRelay = LinkRelay.me;
|
||||
JellyfinMedia? _jellyfin;
|
||||
JellyfinSelection? _selected;
|
||||
|
||||
|
||||
@@ -122,3 +122,7 @@ final class JellyfinMedia extends PickedMedia {
|
||||
@override
|
||||
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 = [
|
||||
('Space', 'Toggle ready'),
|
||||
('← / →', 'Seek back / forward 10 s'),
|
||||
('↑ / ↓', 'Volume up / down (up to 150 %)'),
|
||||
('↑ / ↓, wheel', 'Volume up / down (up to 150 %)'),
|
||||
('Shift+→', 'Skip 1:30 (for everyone)'),
|
||||
('PgUp / PgDn', 'Previous / next chapter'),
|
||||
('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+0', 'Shaders off'),
|
||||
('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
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart' show appVersion, isDevBuild;
|
||||
|
||||
/// Product name (working title). Change it here only.
|
||||
const appName = 'Starsync';
|
||||
|
||||
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 kGap = 16.0;
|
||||
|
||||
|
||||
@@ -2,43 +2,14 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../jellyfin/session_media.dart';
|
||||
import '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs;
|
||||
import '../../widgets/app_menu.dart' show AppGlass;
|
||||
import 'player_controls.dart' show FloatingPill;
|
||||
|
||||
// Jellyfin pieces of the session player. Plain widgets fed by the
|
||||
// coordinator's signals, so tests can pump them without the player.
|
||||
|
||||
/// Previous / next episode in the video controls. Nothing when there is
|
||||
/// no such episode or this viewer cannot put it up (no account).
|
||||
class EpisodeSkipButton extends StatelessWidget {
|
||||
final EpisodeLink? episode;
|
||||
final bool next;
|
||||
final VoidCallback onPressed;
|
||||
final FocusNode? focusNode;
|
||||
const EpisodeSkipButton(
|
||||
{super.key, required this.episode, required this.next, required this.onPressed, this.focusNode});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final e = episode;
|
||||
if (e == null) return const SizedBox.shrink();
|
||||
return Tooltip(
|
||||
message: '${next ? 'Next' : 'Previous'}: ${e.label} (${next ? '> or Shift+N' : '< or Shift+P'})',
|
||||
child: IconButton(
|
||||
key: ValueKey(next ? 'episode-next' : 'episode-previous'),
|
||||
focusNode: focusNode,
|
||||
color: Colors.white,
|
||||
iconSize: 22,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: Icon(next ? Icons.skip_next_rounded : Icons.skip_previous_rounded),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Floating "Next episode ›" from the ED (or the last two minutes):
|
||||
/// faint at rest, lit on hover. Lives outside the auto-hiding controls, so
|
||||
/// it shows in fullscreen and with the controls hidden.
|
||||
/// Floating "Next episode ›" from the ED (or the last two minutes), while
|
||||
/// the controls are up (`PillGate`): faint at rest, lit on hover.
|
||||
class NextEpisodePill extends StatelessWidget {
|
||||
final EpisodeLink episode;
|
||||
final VoidCallback onPressed;
|
||||
@@ -70,27 +41,36 @@ class EndOfEpisodeCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
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'),
|
||||
color: Colors.black.withValues(alpha: 0.85),
|
||||
child: SizedBox(
|
||||
width: 400,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 12, 12),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 380),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(c == null ? 'Up next' : 'Next episode in ${c.secondsLeft} s',
|
||||
style: t.labelLarge?.copyWith(color: Colors.white70)),
|
||||
const SizedBox(height: 4),
|
||||
style: const TextStyle(color: Colors.white60, fontSize: 12)),
|
||||
const SizedBox(height: 2),
|
||||
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) ...[
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(value: c.secondsLeft / autoAdvanceSecs),
|
||||
const SizedBox(height: 10),
|
||||
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),
|
||||
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: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 '../../widgets/app_menu.dart' show AppGlass;
|
||||
|
||||
// Session control-bar pieces built on the playback signals (chapters,
|
||||
// tracks, skip offers): the seek bar with chapter marks, the chapter and
|
||||
// track menus, the 1:30 skip button and the floating skip pill. Plain
|
||||
// widgets fed by values and callbacks, so tests pump them without a player.
|
||||
// The player's floating action pills (Skip OP / ED / 1:30, Next episode):
|
||||
// plain widgets fed by values and callbacks, so tests pump them without a
|
||||
// player. They live in the action area, outside the controls.
|
||||
|
||||
/// Keeps media_kit's desktop controls shown, as a mouse hover would.
|
||||
/// While a seek bar drag runs no hover events arrive, and the controls
|
||||
/// would hide (and unmount the bar) mid-drag. A guarded dynamic call on
|
||||
/// the private controls state; nothing happens outside those controls.
|
||||
void keepVideoControlsAwake(BuildContext context) {
|
||||
context.visitAncestorElements((e) {
|
||||
if (e is StatefulElement &&
|
||||
e.state.runtimeType.toString() == '_MaterialDesktopVideoControlsState') {
|
||||
try {
|
||||
(e.state as dynamic).onHover();
|
||||
} catch (_) {}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/// The seek bar: played / buffered track, a diamond at each chapter start
|
||||
/// (not the one at 0), and on hover the time with the chapter title above
|
||||
/// it. Like media_kit's bar it reports a seek on every drag move and on
|
||||
/// release ([onSeek]); the session routes those through the user-seek path
|
||||
/// (debounced, everyone follows).
|
||||
class ChapterSeekBar extends StatefulWidget {
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
final Duration buffer;
|
||||
final List<MediaChapter> chapters;
|
||||
final ValueChanged<Duration> onSeek;
|
||||
/// Any pointer activity on the bar (keeps the controls up).
|
||||
final VoidCallback? onActivity;
|
||||
const ChapterSeekBar({
|
||||
super.key,
|
||||
required this.position,
|
||||
required this.duration,
|
||||
this.buffer = Duration.zero,
|
||||
this.chapters = const [],
|
||||
required this.onSeek,
|
||||
this.onActivity,
|
||||
});
|
||||
|
||||
static const height = 28.0;
|
||||
|
||||
@override
|
||||
State<ChapterSeekBar> createState() => _ChapterSeekBarState();
|
||||
}
|
||||
|
||||
class _ChapterSeekBarState extends State<ChapterSeekBar> {
|
||||
double? _hoverX;
|
||||
bool _dragging = false;
|
||||
double _dragFraction = 0;
|
||||
|
||||
double _fraction(Duration d) {
|
||||
final total = widget.duration.inMicroseconds;
|
||||
if (total <= 0) return 0;
|
||||
return (d.inMicroseconds / total).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
|
||||
|
||||
void _seekTo(double x, double width) {
|
||||
if (widget.duration <= Duration.zero || width <= 0) return;
|
||||
_dragFraction = (x / width).clamp(0.0, 1.0);
|
||||
widget.onSeek(_at(_dragFraction));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
return LayoutBuilder(builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final active = _hoverX != null || _dragging;
|
||||
final barHeight = active ? 5.0 : 3.0;
|
||||
final played = _dragging ? _dragFraction : _fraction(widget.position);
|
||||
const mid = ChapterSeekBar.height / 2;
|
||||
final hoverX = _hoverX;
|
||||
final hoverAt = hoverX == null || width <= 0 ? null : _at(hoverX / width);
|
||||
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
|
||||
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
|
||||
onHover: (e) {
|
||||
setState(() => _hoverX = e.localPosition.dx.clamp(0, width));
|
||||
widget.onActivity?.call();
|
||||
},
|
||||
onExit: (_) => setState(() => _hoverX = null),
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: (e) {
|
||||
widget.onActivity?.call();
|
||||
setState(() {
|
||||
_dragging = true;
|
||||
_dragFraction = width <= 0 ? 0 : (e.localPosition.dx / width).clamp(0.0, 1.0);
|
||||
});
|
||||
},
|
||||
onPointerMove: (e) {
|
||||
widget.onActivity?.call();
|
||||
setState(() => _hoverX = e.localPosition.dx.clamp(0, width));
|
||||
_seekTo(e.localPosition.dx, width);
|
||||
},
|
||||
onPointerUp: (e) {
|
||||
_seekTo(e.localPosition.dx, width);
|
||||
setState(() => _dragging = false);
|
||||
widget.onActivity?.call();
|
||||
},
|
||||
onPointerCancel: (_) => setState(() => _dragging = false),
|
||||
child: SizedBox(
|
||||
key: const ValueKey('seek-bar'),
|
||||
width: width,
|
||||
height: ChapterSeekBar.height,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: mid - barHeight / 2,
|
||||
height: barHeight,
|
||||
child: Container(color: Colors.white24),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: mid - barHeight / 2,
|
||||
height: barHeight,
|
||||
width: width * _fraction(widget.buffer),
|
||||
child: Container(color: Colors.white24),
|
||||
),
|
||||
if (hoverX != null && !_dragging)
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: mid - barHeight / 2,
|
||||
height: barHeight,
|
||||
width: hoverX,
|
||||
child: Container(color: Colors.white24),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: mid - barHeight / 2,
|
||||
height: barHeight,
|
||||
width: width * played,
|
||||
child: Container(color: primary),
|
||||
),
|
||||
for (final c in widget.chapters)
|
||||
if (c.start > Duration.zero && widget.duration > Duration.zero)
|
||||
Positioned(
|
||||
key: ValueKey('chapter-mark-${c.index}'),
|
||||
left: width * _fraction(c.start) - 4,
|
||||
top: mid - 4,
|
||||
width: 8,
|
||||
height: 8,
|
||||
child: IgnorePointer(
|
||||
child: Transform.rotate(
|
||||
angle: math.pi / 4,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.black54, width: 0.8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (active)
|
||||
Positioned(
|
||||
left: width * played - 6,
|
||||
top: mid - 6,
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(color: primary, shape: BoxShape.circle),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hoverAt != null)
|
||||
Positioned(
|
||||
// Kept clear of the window edges (the tip is centred).
|
||||
left: hoverX!.clamp(math.min(60.0, width / 2), math.max(width - 60, width / 2)),
|
||||
bottom: ChapterSeekBar.height - 2,
|
||||
child: IgnorePointer(
|
||||
child: FractionalTranslation(
|
||||
translation: const Offset(-0.5, 0),
|
||||
child: _HoverTip(title: hoverTitle, time: formatClock(hoverAt)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _HoverTip extends StatelessWidget {
|
||||
final String title;
|
||||
final String time;
|
||||
const _HoverTip({required this.title, required this.time});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 320),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.8),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (title.isNotEmpty)
|
||||
Text(title,
|
||||
key: const ValueKey('seek-hover-chapter'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600)),
|
||||
Text(time,
|
||||
key: const ValueKey('seek-hover-time'),
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [ChapterSeekBar] on a live player: position, duration and buffer from
|
||||
/// its streams. Seeks go to `player.seek`, which the session's
|
||||
/// `SyncPlayer` turns into user seeks.
|
||||
class PlayerSeekBar extends StatefulWidget {
|
||||
final Player player;
|
||||
final List<MediaChapter> chapters;
|
||||
const PlayerSeekBar({super.key, required this.player, required this.chapters});
|
||||
|
||||
@override
|
||||
State<PlayerSeekBar> createState() => _PlayerSeekBarState();
|
||||
}
|
||||
|
||||
class _PlayerSeekBarState extends State<PlayerSeekBar> {
|
||||
final List<StreamSubscription<Object>> _subs = [];
|
||||
late Duration _position, _duration, _buffer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_listen();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PlayerSeekBar old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (!identical(old.player, widget.player)) {
|
||||
_cancel();
|
||||
_listen();
|
||||
}
|
||||
}
|
||||
|
||||
void _listen() {
|
||||
final p = widget.player;
|
||||
_position = p.state.position;
|
||||
_duration = p.state.duration;
|
||||
_buffer = p.state.buffer;
|
||||
void set(VoidCallback f) {
|
||||
if (mounted) setState(f);
|
||||
}
|
||||
|
||||
_subs.addAll([
|
||||
p.stream.position.listen((d) => set(() => _position = d)),
|
||||
p.stream.duration.listen((d) => set(() => _duration = d)),
|
||||
p.stream.buffer.listen((d) => set(() => _buffer = d)),
|
||||
]);
|
||||
}
|
||||
|
||||
void _cancel() {
|
||||
for (final s in _subs) {
|
||||
s.cancel();
|
||||
}
|
||||
_subs.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChapterSeekBar(
|
||||
position: _position,
|
||||
duration: _duration,
|
||||
buffer: _buffer,
|
||||
chapters: widget.chapters,
|
||||
onSeek: (d) => widget.player.seek(d),
|
||||
onActivity: () => keepVideoControlsAwake(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RelativeRect _menuPosition(BuildContext context) {
|
||||
final button = context.findRenderObject() as RenderBox;
|
||||
final overlay = Navigator.of(context).overlay!.context.findRenderObject() as RenderBox;
|
||||
return RelativeRect.fromRect(
|
||||
Rect.fromPoints(
|
||||
button.localToGlobal(Offset.zero, ancestor: overlay),
|
||||
button.localToGlobal(button.size.bottomRight(Offset.zero), ancestor: overlay),
|
||||
),
|
||||
Offset.zero & overlay.size,
|
||||
);
|
||||
}
|
||||
|
||||
/// Control-bar icon with a small count in the corner.
|
||||
class _BadgedIcon extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final int count;
|
||||
const _BadgedIcon(this.icon, this.count);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Icon(icon),
|
||||
if (count > 0)
|
||||
Positioned(
|
||||
right: -7,
|
||||
top: -5,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0.5),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text('$count',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 9, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Chapter list for the controls: current chapter checked, a pick seeks
|
||||
/// to its start. Nothing when the file has no chapters.
|
||||
class ChaptersControlButton extends StatelessWidget {
|
||||
final List<MediaChapter> chapters;
|
||||
final int? current;
|
||||
final ValueChanged<MediaChapter> onPick;
|
||||
final FocusNode? focusNode;
|
||||
final ValueChanged<bool>? onMenuOpen;
|
||||
const ChaptersControlButton({
|
||||
super.key,
|
||||
required this.chapters,
|
||||
required this.current,
|
||||
required this.onPick,
|
||||
this.focusNode,
|
||||
this.onMenuOpen,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (chapters.isEmpty) return const SizedBox.shrink();
|
||||
return Tooltip(
|
||||
message: 'Chapters (PgUp / PgDn)',
|
||||
child: IconButton(
|
||||
key: const ValueKey('chapters-button'),
|
||||
focusNode: focusNode,
|
||||
color: Colors.white,
|
||||
iconSize: 20,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: _BadgedIcon(Icons.format_list_bulleted, chapters.length),
|
||||
onPressed: () => _open(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _open(BuildContext context) async {
|
||||
final position = _menuPosition(context);
|
||||
onMenuOpen?.call(true);
|
||||
final pick = await showMenu<MediaChapter>(
|
||||
context: context,
|
||||
position: position,
|
||||
popUpAnimationStyle: AnimationStyle.noAnimation,
|
||||
constraints: const BoxConstraints(minWidth: 240, maxWidth: 420),
|
||||
items: [
|
||||
for (final c in chapters)
|
||||
CheckedPopupMenuItem(
|
||||
value: c,
|
||||
checked: c.index == current,
|
||||
height: 36,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(c.title.isEmpty ? 'Chapter ${c.index + 1}' : c.title,
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(formatClock(c.start), style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
onMenuOpen?.call(false);
|
||||
if (pick != null) onPick(pick);
|
||||
}
|
||||
}
|
||||
|
||||
/// A track pick from [TrackMenuButton]: audio id, subtitle id, or off.
|
||||
typedef TrackPick = ({int? aid, int? sid, bool subtitlesOff});
|
||||
|
||||
/// Audio or subtitle menu for the controls bar (the count of tracks on the
|
||||
/// icon): "Japanese · E-AC3 2.0 · title", mpv's selection checked,
|
||||
/// subtitles with Off. The choice is remembered for the next Jellyfin item
|
||||
/// (per viewer).
|
||||
class TrackMenuButton extends StatelessWidget {
|
||||
final JellyfinStreamType type;
|
||||
/// All tracks; only those of [type] are listed.
|
||||
final List<MpvTrack> tracks;
|
||||
final void Function(TrackPick pick) onPick;
|
||||
final FocusNode? focusNode;
|
||||
final ValueChanged<bool>? onMenuOpen;
|
||||
const TrackMenuButton({
|
||||
super.key,
|
||||
required this.type,
|
||||
required this.tracks,
|
||||
required this.onPick,
|
||||
this.focusNode,
|
||||
this.onMenuOpen,
|
||||
});
|
||||
|
||||
bool get _audio => type == JellyfinStreamType.audio;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final list = tracks.where((t) => t.type == type).toList();
|
||||
return Tooltip(
|
||||
message: _audio ? 'Audio' : 'Subtitles',
|
||||
child: IconButton(
|
||||
key: ValueKey(_audio ? 'audio-button' : 'subtitles-button'),
|
||||
focusNode: focusNode,
|
||||
color: Colors.white,
|
||||
iconSize: 20,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: _BadgedIcon(_audio ? Icons.audiotrack_outlined : Icons.subtitles_outlined, list.length),
|
||||
onPressed: () => _open(context, list),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _open(BuildContext context, List<MpvTrack> list) async {
|
||||
final position = _menuPosition(context);
|
||||
onMenuOpen?.call(true);
|
||||
final pick = await showMenu<TrackPick>(
|
||||
context: context,
|
||||
position: position,
|
||||
popUpAnimationStyle: AnimationStyle.noAnimation,
|
||||
constraints: const BoxConstraints(minWidth: 280, maxWidth: 460),
|
||||
items: [
|
||||
if (!_audio)
|
||||
CheckedPopupMenuItem(
|
||||
value: (aid: null, sid: null, subtitlesOff: true),
|
||||
checked: !list.any((t) => t.selected),
|
||||
height: 36,
|
||||
child: const Text('Off'),
|
||||
),
|
||||
if (_audio && list.isEmpty)
|
||||
const PopupMenuItem(enabled: false, height: 32, child: Text('None')),
|
||||
for (final t in list)
|
||||
CheckedPopupMenuItem(
|
||||
value: _audio
|
||||
? (aid: t.id, sid: null, subtitlesOff: false)
|
||||
: (aid: null, sid: t.id, subtitlesOff: false),
|
||||
checked: t.selected,
|
||||
height: 36,
|
||||
child: Text(t.label, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
);
|
||||
onMenuOpen?.call(false);
|
||||
if (pick != null) onPick(pick);
|
||||
}
|
||||
}
|
||||
|
||||
/// "1:30" in the controls: skip forward [fallbackSkip] (Shift+Right).
|
||||
class SkipForwardButton extends StatelessWidget {
|
||||
final VoidCallback onPressed;
|
||||
final FocusNode? focusNode;
|
||||
const SkipForwardButton({super.key, required this.onPressed, this.focusNode});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: 'Skip 1:30 for everyone (Shift+Right)',
|
||||
child: TextButton.icon(
|
||||
key: const ValueKey('skip-forward-button'),
|
||||
focusNode: focusNode,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
onPressed: onPressed,
|
||||
icon: const Icon(Icons.fast_forward_rounded, size: 16),
|
||||
label: const Text('1:30', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// The player's action pill (Next episode, Skip OP / ED / 1:30): the
|
||||
/// status pill's glass ([AppGlass.pill]) at [height], faint at rest, lit
|
||||
/// on hover. Lives in the player's action area, outside the auto-hiding
|
||||
/// controls (also in fullscreen); `PillGate` shows it with them.
|
||||
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 tooltip;
|
||||
final IconData icon;
|
||||
@@ -540,7 +25,7 @@ class FloatingPill extends StatefulWidget {
|
||||
required this.label,
|
||||
required this.tooltip,
|
||||
required this.onPressed,
|
||||
this.icon = Icons.chevron_right,
|
||||
this.icon = Icons.chevron_right_rounded,
|
||||
this.tapKey,
|
||||
});
|
||||
|
||||
@@ -553,38 +38,31 @@ class _FloatingPillState extends State<FloatingPill> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
const fade = Duration(milliseconds: 150);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: Tooltip(
|
||||
message: widget.tooltip,
|
||||
child: AnimatedScale(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
scale: _hover ? 1.06 : 1,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
duration: fade,
|
||||
opacity: _hover ? 1 : 0.45,
|
||||
child: GestureDetector(
|
||||
key: widget.tapKey,
|
||||
onTap: widget.onPressed,
|
||||
child: AppGlass.pill(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.75),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: _hover ? primary : Colors.white24),
|
||||
boxShadow: _hover
|
||||
? [BoxShadow(color: primary.withValues(alpha: 0.45), blurRadius: 16)]
|
||||
: const [],
|
||||
),
|
||||
duration: fade,
|
||||
height: FloatingPill.height,
|
||||
padding: const EdgeInsets.only(left: 16, right: 12),
|
||||
color: _hover ? Colors.white12 : Colors.transparent,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(widget.label, style: const TextStyle(color: Colors.white, fontSize: 13)),
|
||||
const SizedBox(width: 4),
|
||||
Text(widget.label,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(width: 6),
|
||||
Icon(widget.icon, color: Colors.white, size: 18),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../files/name_parser.dart';
|
||||
import '../schedule/anilist.dart';
|
||||
|
||||
// Art for media this client swapped in (`art`, see PROTOCOL.md "Media
|
||||
// art"): Jellyfin images with this install's account, else an AniList
|
||||
// title search. Everything here fails quietly to "no art".
|
||||
// art"): Jellyfin images with this install's account, a web link's
|
||||
// thumbnail, else an AniList title search. Everything here fails quietly
|
||||
// to "no art".
|
||||
|
||||
/// Poster and banner bytes; either may be null.
|
||||
typedef ArtBytes = ({Uint8List? poster, Uint8List? banner});
|
||||
@@ -68,6 +71,31 @@ Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
|
||||
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
|
||||
/// type (see `avatarImageType`); else null.
|
||||
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.
|
||||
final String posterHash;
|
||||
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
|
||||
bool operator ==(Object other) =>
|
||||
@@ -17,8 +21,9 @@ class NowPlaying {
|
||||
other.title == title &&
|
||||
other.episode == episode &&
|
||||
other.posterHash == posterHash &&
|
||||
other.bannerHash == bannerHash;
|
||||
other.bannerHash == bannerHash &&
|
||||
other.thumbnail == thumbnail;
|
||||
|
||||
@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 '../jellyfin/session_media.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 banner = art['banner'] is String ? art['banner'] as String : '';
|
||||
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
|
||||
? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle)
|
||||
: fileTitle(name);
|
||||
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
|
||||
/// "Series - S01E02") for episodes, the year for movies. Episodes give the
|
||||
/// series and "S1 E6 · Name"; anything else the name alone.
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
@@ -16,6 +17,7 @@ import '../playback/playback_controller.dart';
|
||||
import '../platform/attention.dart';
|
||||
import '../playback/yt_dlp_app.dart';
|
||||
import '../schedule/anilist.dart';
|
||||
import '../screens/home/ui.dart' show appName, displayVersion;
|
||||
import '../screens/home/picked_media.dart';
|
||||
import '../sfx/sfx.dart';
|
||||
import '../state/settings.dart';
|
||||
@@ -23,11 +25,16 @@ import '../sync/avatar_cache.dart';
|
||||
import '../sync/state_diff.dart';
|
||||
import '../sync/sync_client.dart';
|
||||
import '../sync/user_seek.dart';
|
||||
import '../queue/queue_controller.dart';
|
||||
import 'delivery.dart';
|
||||
import 'health.dart';
|
||||
import 'idle.dart';
|
||||
import 'media_art.dart';
|
||||
import 'now_playing.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.
|
||||
class ConnectCancelled implements Exception {
|
||||
@@ -168,11 +175,43 @@ class SessionCoordinator {
|
||||
/// The app's yt-dlp and link probes (tests pass their own).
|
||||
final AppYtDlp ytDlp;
|
||||
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.
|
||||
late final AvatarCache avatars = AvatarCache(sync);
|
||||
/// The current media's poster and banner by `state.media.art` hash.
|
||||
late final ArtCache art = ArtCache(sync);
|
||||
final PlaybackController playback = PlaybackController();
|
||||
/// Timings of each media change here (see [SwitchTrace]).
|
||||
final SwitchTrace switchTrace = SwitchTrace();
|
||||
final StateDiff diff = StateDiff();
|
||||
|
||||
final health =
|
||||
@@ -197,10 +236,17 @@ class SessionCoordinator {
|
||||
/// 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.
|
||||
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).
|
||||
final previousEpisode = 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);
|
||||
/// End-of-episode countdown; null when none runs here.
|
||||
final autoAdvance = signal<AutoAdvance?>(null);
|
||||
@@ -269,13 +315,22 @@ class SessionCoordinator {
|
||||
int _epoch = 0;
|
||||
Timer? _reconnectTimer;
|
||||
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 _wsHost = '';
|
||||
int _wsPort = 0;
|
||||
_RelayOrigin? _pendingOrigin;
|
||||
// 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.
|
||||
({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;
|
||||
// What this member serves for media [$1]; survives a reconnect (the
|
||||
// state then looks new again).
|
||||
@@ -284,6 +339,10 @@ class SessionCoordinator {
|
||||
int _relaySourceMediaId = -1;
|
||||
/// This member serves the current media's bytes through the server.
|
||||
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
|
||||
/// carries it over when it becomes a hosted server).
|
||||
PickedMedia? get lastPick => _lastPick;
|
||||
@@ -335,16 +394,17 @@ class SessionCoordinator {
|
||||
jellyfin = jellyfin ?? JellyfinAccounts.instance,
|
||||
trackPrefs = trackPrefs ?? TrackPrefs();
|
||||
|
||||
/// Player lifetime = one session: created here on connect, disposed by
|
||||
/// [leave]. Never reused across sessions (a GPU-side video freeze sticks
|
||||
/// The session's first player: created here on connect, disposed by
|
||||
/// [leave]; each new media gets its own ([PlaybackController.openUrl]).
|
||||
/// Never reused across sessions or media (a GPU-side video freeze sticks
|
||||
/// to the player).
|
||||
Future<void> initPlayer() async {
|
||||
if (_playerReady) return;
|
||||
try {
|
||||
playback.onFrozen = () => unawaited(reloadVideo(auto: true));
|
||||
playback.onYtdlError = _setPlayerError;
|
||||
playback.onLoadError = _setPlayerError;
|
||||
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
|
||||
_bufferSub ??= playback.buffered.subscribe((_) => _onBuffered());
|
||||
await playback.init();
|
||||
_playerReady = true;
|
||||
} catch (e, st) {
|
||||
@@ -356,7 +416,7 @@ class SessionCoordinator {
|
||||
}
|
||||
|
||||
void _setPlayerError(String raw) {
|
||||
debugPrint('player cannot open media $_mediaId: $raw');
|
||||
_log.warning('player cannot open media $_mediaId: $raw');
|
||||
_playerErrorFor = _mediaId;
|
||||
_playerErrorDuration = playback.duration.peek();
|
||||
playerError.value = playErrorText(raw);
|
||||
@@ -384,14 +444,26 @@ class SessionCoordinator {
|
||||
_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 counts as not ready, so reports/seek detection pause and the
|
||||
/// next broadcast resyncs position and play/pause (diff reset + play
|
||||
/// 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).
|
||||
if (_params == null || _reloadingVideo) return;
|
||||
_reloadingVideo = true;
|
||||
@@ -411,16 +483,6 @@ class SessionCoordinator {
|
||||
_playerReady = playback.alive.peek();
|
||||
_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());
|
||||
@@ -436,6 +498,9 @@ class SessionCoordinator {
|
||||
_wsPort = params.port;
|
||||
diff.reset();
|
||||
_mediaId = -1;
|
||||
_playerMediaId = -1;
|
||||
_openedFor = -1;
|
||||
settledMediaId.value = -1;
|
||||
_openUrl = null;
|
||||
playerError.value = '';
|
||||
_memberErrors = const {};
|
||||
@@ -460,6 +525,9 @@ class SessionCoordinator {
|
||||
}
|
||||
markInput();
|
||||
_subscribe();
|
||||
diag
|
||||
..share = AppSettings.instance.shareDiagnostics.peek()
|
||||
..start();
|
||||
feedLog.value = [];
|
||||
toasts.value = [];
|
||||
await initPlayer();
|
||||
@@ -505,6 +573,7 @@ class SessionCoordinator {
|
||||
}
|
||||
|
||||
void _subscribe() {
|
||||
queue.attach();
|
||||
_stateSub ??= sync.states.listen(_onState);
|
||||
_feedSub ??= sync.feeds.listen(_onFeed);
|
||||
_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 {
|
||||
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;
|
||||
_openHeaders = headers;
|
||||
diff.reset();
|
||||
_opening = true;
|
||||
final mediaId = _mediaId;
|
||||
var ok = false;
|
||||
try {
|
||||
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 =
|
||||
const HealthStatus(HealthSeverity.ok, 'connected');
|
||||
return true;
|
||||
ok = true;
|
||||
} catch (e) {
|
||||
debugPrint('open media failed: $e');
|
||||
_setPlayerError('$e');
|
||||
return false;
|
||||
} finally {
|
||||
_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
|
||||
/// managed yt-dlp, downloaded first if needed; a media file name does
|
||||
/// not wait for that.
|
||||
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);
|
||||
if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) return;
|
||||
if (uri.host == _wsHost && uri.port == _wsPort) return;
|
||||
if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) return null;
|
||||
if (uri.host == _wsHost && uri.port == _wsPort) return null;
|
||||
try {
|
||||
final y = await ytDlp.ytDlp;
|
||||
final ready = y.ensure();
|
||||
final path = hasMediaExtension(uri)
|
||||
return hasMediaExtension(uri)
|
||||
? y.path
|
||||
: await ready.timeout(const Duration(seconds: 90), onTimeout: () => null);
|
||||
if (path != null) await playback.useYtDlp(path);
|
||||
} catch (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;
|
||||
|
||||
/// The server-fetched media this viewer opens once it is live.
|
||||
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
|
||||
/// waiting for (the buffer still plays what it has).
|
||||
void _relayStatus(StateBroadcast r) {
|
||||
@@ -579,8 +725,9 @@ class SessionCoordinator {
|
||||
jellyfinPlan.peek() is! OwnPlay;
|
||||
if (waiting) {
|
||||
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,
|
||||
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;
|
||||
} else if (_relayWaitShown) {
|
||||
_relayWaitShown = false;
|
||||
@@ -590,13 +737,62 @@ class SessionCoordinator {
|
||||
|
||||
/// 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
|
||||
/// video and audio).
|
||||
String _relayMediaUrlFor(String name, int mediaId, [List<RelayTrackSpec> tracks = const []]) =>
|
||||
relayViewerUrl('http://$_wsHost:$_wsPort/r/default/${sync.token}/$mediaId', name, tracks: tracks);
|
||||
/// video and audio). A relay started in Up next ([queueId]) keeps its
|
||||
/// queue id in the URL, so the preload of its warm relay matches.
|
||||
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.
|
||||
/// The server only pulls while someone watches through it.
|
||||
void _startRelaySource(int mediaId, RelayMedia media, {bool announce = false}) {
|
||||
/// The server only pulls while someone watches through it. When the
|
||||
/// 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
|
||||
// redials by itself).
|
||||
if (_relaySource != null && _relaySourceMediaId == mediaId) {
|
||||
@@ -604,6 +800,24 @@ class SessionCoordinator {
|
||||
return;
|
||||
}
|
||||
_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(
|
||||
Uri.parse('ws://$_wsHost:$_wsPort/r/default/${sync.token}/source'), media);
|
||||
_relaySource = src;
|
||||
@@ -654,7 +868,9 @@ class SessionCoordinator {
|
||||
void _onState(StateBroadcast r) {
|
||||
_lastStateAt = DateTime.now().toUtc();
|
||||
serverState.value = r;
|
||||
if (r.transport == 'playing') switchTrace.mark(r.mediaId, SwitchTrace.playing);
|
||||
_toastMemberErrors(r);
|
||||
_warnSlowSource(r);
|
||||
_checkPlayerError(r.mediaId);
|
||||
_maybeSendArt(r);
|
||||
_queuedState = r;
|
||||
@@ -690,6 +906,7 @@ class SessionCoordinator {
|
||||
_lastEventId = r.eventId;
|
||||
if (r.mediaId != _mediaId) {
|
||||
_mediaId = r.mediaId;
|
||||
switchTrace.start(r.mediaId);
|
||||
final ref = refOfStateMedia(r.media);
|
||||
// Back after a dropped connection on the same item played with our
|
||||
// own account: nothing to resolve or reopen.
|
||||
@@ -713,28 +930,37 @@ class SessionCoordinator {
|
||||
_seekGate.reset();
|
||||
_clearPendingSeek();
|
||||
_leaveJellyfinItem();
|
||||
await _leavePreviousMedia(r.mediaId);
|
||||
final name = (r.media?['name'] as String?) ?? '';
|
||||
if (ref != null) {
|
||||
await _playJellyfin(ref, r,
|
||||
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) {
|
||||
// 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);
|
||||
_startRelaySource(r.mediaId, RelayMedia.single(FileRelayBytes(File(origin.path))), announce: true);
|
||||
} else if (origin is _LinkOrigin) {
|
||||
await _openMedia(origin.url);
|
||||
_startRelaySource(
|
||||
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) {
|
||||
if (relay.fetch && !relay.live) {
|
||||
// The server is still getting it (yt-dlp takes seconds; mpv
|
||||
// gives up on a silent request after 5): open once it is live.
|
||||
if (_waitForSource(r, relay)) {
|
||||
// The server is still getting it (yt-dlp takes seconds), or the
|
||||
// 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;
|
||||
_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 {
|
||||
await _openMedia(_relayMediaUrlFor(name, r.mediaId, relay.tracks));
|
||||
await _openMedia(_relayMediaUrlFor(name, r.mediaId, relay.tracks, relay.queueId));
|
||||
}
|
||||
} else {
|
||||
var url = stateMediaUrl(r);
|
||||
@@ -751,10 +977,12 @@ class SessionCoordinator {
|
||||
}
|
||||
_seekGate.reset();
|
||||
_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;
|
||||
final relay = stateMediaRelay(r)!;
|
||||
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);
|
||||
_relayStatus(r);
|
||||
@@ -837,6 +1065,7 @@ class SessionCoordinator {
|
||||
starving.value = null;
|
||||
if (_mediaId == -1 && _openUrl == null) return;
|
||||
_mediaId = -1;
|
||||
_playerMediaId = -1;
|
||||
_openUrl = null;
|
||||
_openHeaders = null;
|
||||
_seekGate.reset();
|
||||
@@ -849,6 +1078,24 @@ class SessionCoordinator {
|
||||
} 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) {
|
||||
if (r.transport == 'playing') {
|
||||
starving.value = null;
|
||||
@@ -904,6 +1151,10 @@ class SessionCoordinator {
|
||||
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;
|
||||
// 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) {
|
||||
@@ -949,27 +1200,42 @@ class SessionCoordinator {
|
||||
_ticker = Timer.periodic(const Duration(seconds: 1), (_) => _tick());
|
||||
}
|
||||
|
||||
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();
|
||||
if (sync.token.isNotEmpty && r != null && _playerReady) {
|
||||
/// The 1 s `report` (also sent at once when a new media is open, so the
|
||||
/// server's gate does not wait for the next tick).
|
||||
void _sendReport(StateBroadcast r) {
|
||||
// Duration only once the open media is the server's current one.
|
||||
// Sitting out: still a report (it keeps this member fresh), empty.
|
||||
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);
|
||||
final error = playerError.peek();
|
||||
final buffered = holds ? playback.bufferedSecsAhead : 0.0;
|
||||
if (holds && buffered >= r.bufferNeed) switchTrace.mark(r.mediaId, SwitchTrace.buffered);
|
||||
sync.sendReport(
|
||||
position: _lastOut ? 0 : playback.positionSecs,
|
||||
buffered: _lastOut ? 0 : playback.bufferedSecsAhead,
|
||||
buffered: buffered,
|
||||
eventId: r.eventId,
|
||||
duration: known ? playback.durationSecs : 0,
|
||||
mediaId: known ? r.mediaId : 0,
|
||||
jf: _jfAccessFor(r),
|
||||
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) {
|
||||
_checkSeekLanded(r);
|
||||
unawaited(_tickJellyfin(r));
|
||||
@@ -1240,12 +1506,23 @@ class SessionCoordinator {
|
||||
sync.sendNudge(memberId);
|
||||
}
|
||||
|
||||
void _swap(HelloMedia media) {
|
||||
// Putting something up means watching it: back first.
|
||||
/// [queueId]: the Up next item this puts up (the server takes it off
|
||||
/// 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();
|
||||
if (self != null && self.out) sync.sendOut(self.id, false);
|
||||
_pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0);
|
||||
sync.sendSwap(media);
|
||||
if (self != null && self.out && queueId == null) sync.sendOut(self.id, false);
|
||||
_pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0, link: link ?? media.url, art: art);
|
||||
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) {
|
||||
@@ -1253,24 +1530,45 @@ class SessionCoordinator {
|
||||
if (pending == null || r.mediaId <= pending.after) return;
|
||||
if (sync.memberId.isEmpty || r.media?['swapBy'] != sync.memberId) return;
|
||||
_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
|
||||
/// sends it; quietly nothing on any failure.
|
||||
Future<void> _sendArt(int mediaId, HelloMedia media) async {
|
||||
/// The link this app put up as [StateBroadcast.mediaId] (passed on or
|
||||
/// fetched by the server, so not in the state).
|
||||
(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 {
|
||||
final ref = media.jellyfin;
|
||||
final client = ref == null ? null : jellyfin.clientFor(ref.serverId);
|
||||
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);
|
||||
} 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
|
||||
// its banner.
|
||||
final thumb = (await _probeFor(media.url!))?.info?.thumbnail;
|
||||
// its cover and banner both.
|
||||
final thumb = (await _probeFor(link!))?.info?.thumbnail;
|
||||
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 {
|
||||
final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1;
|
||||
bytes = await aniListArt(_aniList ??= AniListClient(), name);
|
||||
@@ -1293,13 +1591,14 @@ class SessionCoordinator {
|
||||
/// probe's [LinkProbe.problem], or unreachable) is refused with the
|
||||
/// reason in plain words unless [anyway]. Returns an error text, or null
|
||||
/// 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();
|
||||
_lastPick = LinkMedia(url, relay: relay, anyway: anyway);
|
||||
final media = await mediaForLink(jellyfin, url);
|
||||
if (media.jellyfin != null) {
|
||||
_pendingOrigin = _JellyfinOrigin(url);
|
||||
_swap(media.copyWith(relay: const MediaRelay()));
|
||||
_swap(media.copyWith(relay: const MediaRelay()), queueId: queueId);
|
||||
return null;
|
||||
}
|
||||
LinkProbe? probe;
|
||||
@@ -1315,7 +1614,7 @@ class SessionCoordinator {
|
||||
final uri = Uri.tryParse(url) ?? Uri();
|
||||
final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host);
|
||||
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;
|
||||
}
|
||||
if (relay == LinkRelay.me) {
|
||||
@@ -1325,11 +1624,14 @@ class SessionCoordinator {
|
||||
final relayProblem = probe.relayProblem;
|
||||
if (relayProblem != null) return relayProblem;
|
||||
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;
|
||||
final List<RelayTrackSpec> specs;
|
||||
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) {
|
||||
// A playlist is read (DRM and live refused) but has no size.
|
||||
final pieces = t.pieces;
|
||||
@@ -1343,11 +1645,11 @@ class SessionCoordinator {
|
||||
} catch (e) {
|
||||
return 'cannot pass this link on: $e';
|
||||
} finally {
|
||||
await check.close();
|
||||
await check?.close();
|
||||
}
|
||||
_pendingOrigin = _LinkOrigin(url, probe, y);
|
||||
_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;
|
||||
}
|
||||
// The server reads it with its own yt-dlp; only a page this app knows
|
||||
@@ -1357,7 +1659,8 @@ class SessionCoordinator {
|
||||
return probe.relayProblem;
|
||||
}
|
||||
_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;
|
||||
}
|
||||
|
||||
@@ -1371,43 +1674,44 @@ class SessionCoordinator {
|
||||
|
||||
/// A Jellyfin pick (browser, next / previous episode) goes up as a ref,
|
||||
/// relayed by this member for viewers without an account.
|
||||
void swapJellyfin(JellyfinMedia media) {
|
||||
void swapJellyfin(JellyfinMedia media, {String? queueId}) {
|
||||
markInput();
|
||||
_lastPick = media;
|
||||
_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
|
||||
/// else's goes through the server's relay. Returns an error text, or null
|
||||
/// when sent.
|
||||
Future<String?> swapFile(String path) async {
|
||||
Future<String?> swapFile(String path, {String? queueId}) async {
|
||||
markInput();
|
||||
_lastPick = FileMedia(path);
|
||||
final host = hostFile;
|
||||
if (host != null) {
|
||||
final file = await host(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;
|
||||
}
|
||||
final file = File(path);
|
||||
if (!await file.exists()) return 'file not found — check the path';
|
||||
_pendingOrigin = _FileOrigin(path);
|
||||
_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;
|
||||
}
|
||||
|
||||
/// Any picker selection.
|
||||
Future<String?> swapPicked(PickedMedia media) async {
|
||||
/// Any picker selection; [queueId] when it is an Up next item.
|
||||
Future<String?> swapPicked(PickedMedia media, {String? queueId}) async {
|
||||
switch (media) {
|
||||
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():
|
||||
swapJellyfin(media);
|
||||
swapJellyfin(media, queueId: queueId);
|
||||
case FileMedia(:final path):
|
||||
return swapFile(path);
|
||||
return swapFile(path, queueId: queueId);
|
||||
}
|
||||
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) {
|
||||
if (sync.token.isEmpty) return null;
|
||||
final welcome = sync.relayUrl;
|
||||
@@ -1522,11 +1829,18 @@ class SessionCoordinator {
|
||||
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) {
|
||||
final linked = {
|
||||
for (final a in jellyfin.accounts)
|
||||
if (jellyfin.statusOf(a.serverId) != JellyfinAccountStatus.expired) a.serverId: true,
|
||||
};
|
||||
final ref = _jfRef;
|
||||
final access = jellyfinPlan.peek()?.jfAccess;
|
||||
if (ref == null || access == null || r.mediaId != _jfMediaId) return null;
|
||||
return {ref.serverId: access};
|
||||
if (ref == null || access == null || r.mediaId != _jfMediaId) return linked.isEmpty ? null : linked;
|
||||
return {...linked, ref.serverId: access};
|
||||
}
|
||||
|
||||
/// Resolves [ref] for this viewer and opens it: own account (direct
|
||||
@@ -1549,17 +1863,20 @@ class SessionCoordinator {
|
||||
}
|
||||
JellyfinPlan plan;
|
||||
try {
|
||||
plan = await resolveJellyfinRef(jellyfin, ref,
|
||||
plan = _takeWarmJellyfin(ref) ??
|
||||
await resolveJellyfinRef(jellyfin, ref,
|
||||
relay: r.relay, relayUrl: _relayUrlFor(ref), memberRelayUrl: memberRelayUrl);
|
||||
} finally {
|
||||
if (gen == _jfGen) _opening = false;
|
||||
}
|
||||
if (gen != _jfGen) return;
|
||||
switchTrace.mark(r.mediaId, SwitchTrace.resolved);
|
||||
// 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).
|
||||
if (origin != null) {
|
||||
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) {
|
||||
plan = RelayPlay(origin.link!);
|
||||
_startRelaySource(r.mediaId, RelayMedia.single(HttpRelayBytes(Uri.parse(origin.link!))));
|
||||
@@ -1585,23 +1902,35 @@ class SessionCoordinator {
|
||||
case OwnPlay(:final url, :final headers):
|
||||
jellyfinBlocked.value = null;
|
||||
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(
|
||||
client: plan.client,
|
||||
itemId: ref.itemId,
|
||||
source: plan.source,
|
||||
playSessionId: plan.info.playSessionId,
|
||||
playedBefore: before?.played ?? false,
|
||||
resumeTicksBefore: before?.playbackPositionTicks ?? 0,
|
||||
onUnauthorized: () => jellyfin.markExpired(ref.serverId),
|
||||
onStopped: () => jellyfin.watchedChanged(ref.serverId),
|
||||
onError: (e) {
|
||||
if (_jfToastShown) return;
|
||||
_jfToastShown = true;
|
||||
_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();
|
||||
if (gen != _jfGen || snap == null) return;
|
||||
// Installed once started: the per-state restart (after the end)
|
||||
@@ -1611,6 +1940,18 @@ class SessionCoordinator {
|
||||
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 {
|
||||
final around = await findAdjacentEpisodes(plan);
|
||||
if (gen != _jfGen) return;
|
||||
@@ -1663,7 +2004,10 @@ class SessionCoordinator {
|
||||
final snap = await _snapshot();
|
||||
if (snap == null) return;
|
||||
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 duration = playback.durationSecs;
|
||||
@@ -1701,7 +2045,9 @@ class SessionCoordinator {
|
||||
}
|
||||
nextPromptVisible.value = false;
|
||||
final next = nextEpisode.peek();
|
||||
// Up next goes first; "Clear" in it hides the next episode here.
|
||||
if (next == null ||
|
||||
!queue.continuationShown(r.mediaId) ||
|
||||
!AppSettings.instance.autoAdvance.peek() ||
|
||||
_advanceDoneFor == r.mediaId ||
|
||||
autoAdvance.peek()?.mediaId == r.mediaId) {
|
||||
@@ -1913,13 +2259,17 @@ class SessionCoordinator {
|
||||
reconnecting.value = false;
|
||||
await _stopJellyfin();
|
||||
_stopRelaySource();
|
||||
stopWarmSource();
|
||||
_pendingOrigin = null;
|
||||
_pendingArt = null;
|
||||
_ownLink = null;
|
||||
_activeOrigin = null;
|
||||
_playerReady = false;
|
||||
try {
|
||||
await playback.dispose();
|
||||
} catch (_) {}
|
||||
// The last lines go before the token does.
|
||||
await diag.stop();
|
||||
try {
|
||||
await sync.disconnect();
|
||||
} catch (_) {}
|
||||
@@ -1941,6 +2291,7 @@ class SessionCoordinator {
|
||||
await _nudgeSub?.cancel();
|
||||
await _errSub?.cancel();
|
||||
await _statusSub?.cancel();
|
||||
_bufferSub?.call();
|
||||
_aniList?.close();
|
||||
await sync.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(', ')}');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +1,60 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
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 'sound_bank.dart';
|
||||
import 'synth.dart';
|
||||
|
||||
/// Plays [SoundSpec]s: each is rendered once to a WAV in the temp dir and
|
||||
/// played on a small pool of audio-only mpv players (so two sounds can
|
||||
/// overlap). [volume] (0..1) is read per play and applied on a square
|
||||
/// curve (ears are log); [enabled] filters cues.
|
||||
/// Plays [SoundSpec]s on a [SoundEngine]: each is rendered once (off the
|
||||
/// UI isolate) to WAV bytes and loaded into memory; sounds overlap.
|
||||
/// [volume] (0..1) is read per play and applied as v^6: the square curve
|
||||
/// (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 {
|
||||
final double Function() volume;
|
||||
final bool Function(SfxKind kind) enabled;
|
||||
/// Which sound set each sound plays from.
|
||||
final SoundPreset Function(SfxKind kind) presetFor;
|
||||
SynthSfx({required this.volume, bool Function(SfxKind kind)? enabled, SoundPreset Function(SfxKind kind)? presetFor})
|
||||
final SoundEngine engine;
|
||||
SynthSfx(
|
||||
{required this.volume,
|
||||
bool Function(SfxKind kind)? enabled,
|
||||
SoundPreset Function(SfxKind kind)? presetFor,
|
||||
SoundEngine? engine})
|
||||
: enabled = enabled ?? ((_) => true),
|
||||
presetFor = presetFor ?? ((_) => defaultPreset);
|
||||
|
||||
static const _poolSize = 3;
|
||||
final List<Player> _pool = [];
|
||||
var _next = 0;
|
||||
final Map<String, Future<String>> _files = {};
|
||||
Directory? _dir;
|
||||
presetFor = presetFor ?? ((_) => defaultPreset),
|
||||
engine = engine ?? SoundEngine();
|
||||
|
||||
@override
|
||||
Future<void> play(SfxCue cue) async {
|
||||
if (!enabled(cue.kind)) return;
|
||||
final preset = presetFor(cue.kind);
|
||||
await playSpec('${preset.name}-${cue.kind.name}-${cue.readyIndex}',
|
||||
soundFor(cue.kind, readyIndex: cue.readyIndex, preset: preset));
|
||||
Future<void> play(SfxCue cue) {
|
||||
if (!enabled(cue.kind)) return Future.value();
|
||||
return _play(cue.kind, cue.readyIndex, presetFor(cue.kind));
|
||||
}
|
||||
|
||||
/// Plays [kind] even when it is switched off (Settings' preview), always
|
||||
/// freshly rendered: an edited spec plays after a hot reload.
|
||||
Future<void> preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) {
|
||||
final p = preset ?? presetFor(kind);
|
||||
final key = '${p.name}-${kind.name}-$readyIndex';
|
||||
_files.remove(key);
|
||||
return playSpec(key, soundFor(kind, readyIndex: readyIndex, preset: p));
|
||||
}
|
||||
Future<void> preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) =>
|
||||
_play(kind, readyIndex, preset ?? presetFor(kind), fresh: true);
|
||||
|
||||
/// Plays [spec], cached under [key] (same key = same sound).
|
||||
Future<void> playSpec(String key, SoundSpec spec) async {
|
||||
/// One slot per sound and ready step: a new preset replaces it.
|
||||
Future<void> _play(SfxKind kind, int readyIndex, SoundPreset preset, {bool fresh = false}) {
|
||||
final v = volume();
|
||||
if (v <= 0) return;
|
||||
try {
|
||||
final path = await (_files[key] ??= _write(key, spec));
|
||||
final player = await _player();
|
||||
await player.setVolume(100 * v * v);
|
||||
await player.open(Media(path));
|
||||
} catch (e) {
|
||||
_files.remove(key);
|
||||
debugPrint('sfx $key: $e');
|
||||
}
|
||||
return engine.play('${kind.name}-$readyIndex', '${preset.name}-${kind.name}-$readyIndex',
|
||||
() => renderWav(soundFor(kind, readyIndex: readyIndex, preset: preset)),
|
||||
volume: math.pow(v, 6).toDouble(), fresh: fresh);
|
||||
}
|
||||
|
||||
Future<String> _write(String key, SoundSpec spec) async {
|
||||
_dir ??= await Directory('${(await getTemporaryDirectory()).path}/watch_together_sfx').create(recursive: true);
|
||||
final f = File('${_dir!.path}/$key.wav');
|
||||
await f.writeAsBytes(wav(render(spec)));
|
||||
return f.path;
|
||||
}
|
||||
/// Opens the audio device ahead of the first sound.
|
||||
Future<void> warmUp() => engine.warmUp();
|
||||
|
||||
Future<Player> _player() async {
|
||||
if (_pool.length < _poolSize) {
|
||||
final p = Player(
|
||||
configuration: const PlayerConfiguration(vo: 'null', osc: false, libass: false, title: 'sfx'),
|
||||
);
|
||||
await (p.platform as NativePlayer).setProperty('audio-display', 'no');
|
||||
_pool.add(p);
|
||||
return p;
|
||||
}
|
||||
return _pool[_next++ % _poolSize];
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
for (final p in _pool) {
|
||||
await p.dispose();
|
||||
}
|
||||
_pool.clear();
|
||||
}
|
||||
Future<void> dispose() => engine.dispose();
|
||||
}
|
||||
|
||||
/// [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));
|
||||
@@ -8,50 +8,6 @@ import 'shader_presets.dart';
|
||||
/// A shader pick from the menu; a null quality keeps the saved one.
|
||||
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.
|
||||
String? shaderShortLabel(ShaderPreset preset, ShaderQuality quality) =>
|
||||
switch (preset) {
|
||||
@@ -61,7 +17,8 @@ String? shaderShortLabel(ShaderPreset preset, ShaderQuality quality) =>
|
||||
};
|
||||
|
||||
/// 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(
|
||||
BuildContext context, {
|
||||
required ReadonlySignal<RenderMode> renderMode,
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTar
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../controls/frame.dart' show DoubleClickListener;
|
||||
import '../files/folder_store.dart';
|
||||
import '../files/ui/files_page.dart' show showFilesFolder;
|
||||
import '../jellyfin/accounts.dart';
|
||||
@@ -379,7 +380,9 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
if (error != null || !mounted) return error;
|
||||
}
|
||||
// Alone, a link plays direct: there is nobody to pass it on to.
|
||||
if (media is LinkMedia && session.solo.peek()) media = LinkMedia(media.url, 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);
|
||||
if (error != null || !mounted) return error;
|
||||
final f = session.lastForm;
|
||||
@@ -418,7 +421,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
// --------------------------------------------------------------- handoff
|
||||
|
||||
/// 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).
|
||||
bool _onKey(KeyEvent e) {
|
||||
@@ -458,7 +461,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
_handoffOpen = true;
|
||||
final HandoffAction? action;
|
||||
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 {
|
||||
_handoffOpen = false;
|
||||
}
|
||||
@@ -472,6 +476,22 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
await FolderStore.instance.ensureLoaded();
|
||||
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):
|
||||
await _addFolder(File(paths.first).parent.path);
|
||||
case (HandoffFolder(:final path), _):
|
||||
@@ -671,11 +691,14 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
),
|
||||
jellyfinStart: _libraryStart,
|
||||
onPutUp: _putUp,
|
||||
// Up next needs a server (a solo session has one).
|
||||
onAddToQueue: online ? coord.queue.add : null,
|
||||
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
|
||||
),
|
||||
ShellTab.schedule => SchedulePage(
|
||||
solo: alone,
|
||||
onPutUp: _putUp,
|
||||
onAddToQueue: online ? coord.queue.add : null,
|
||||
onToast: coord.showToast,
|
||||
),
|
||||
ShellTab.settings => SettingsTab(
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../screens/home/profile.dart';
|
||||
import '../session/delivery.dart';
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'motion.dart';
|
||||
@@ -22,6 +23,9 @@ class AvatarInfo {
|
||||
/// Why their player cannot open the media (`members[].error`): an error
|
||||
/// mark; empty when it can.
|
||||
final String error;
|
||||
/// How it gets the media and its buffer ([MemberDelivery.detail]), for
|
||||
/// the hover; empty when unknown.
|
||||
final String delivery;
|
||||
const AvatarInfo({
|
||||
this.id = '',
|
||||
required this.name,
|
||||
@@ -35,6 +39,7 @@ class AvatarInfo {
|
||||
this.self = false,
|
||||
this.out = false,
|
||||
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 relay = r == null ? null : stateMediaRelay(r);
|
||||
final playing = r?.media != null;
|
||||
final delivery = r == null ? null : deliveryOf(r, selfId: coord.sync.memberId, names: coord.memberNames);
|
||||
return AvatarInfo(
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
@@ -118,6 +124,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
|
||||
self: self,
|
||||
out: m.out,
|
||||
error: m.error,
|
||||
delivery: delivery == null ? '' : memberDeliveryOf(m, r!, delivery).detail,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -304,9 +311,12 @@ class _AvatarStackState extends State<AvatarStack> {
|
||||
left: i * step,
|
||||
top: 0,
|
||||
child: Tooltip(
|
||||
message: visible[i].error.isEmpty
|
||||
message: [
|
||||
visible[i].error.isEmpty
|
||||
? visible[i].name
|
||||
: "${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),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:flutter/services.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../state/session_state.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import '../widgets/copy_open.dart';
|
||||
import '../widgets/text_focus.dart';
|
||||
import 'avatar.dart';
|
||||
|
||||
@@ -158,7 +160,30 @@ class ActivityRow extends StatelessWidget {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final time = _clock(item.at);
|
||||
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),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -181,12 +206,14 @@ class ActivityRow extends StatelessWidget {
|
||||
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);
|
||||
@@ -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.
|
||||
(IconData, Color, String) feedStyle(FeedMessage f, ColorScheme scheme) {
|
||||
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');
|
||||
case '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:
|
||||
final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,11 @@ import '../files/ui/file_tiles.dart' show baseName;
|
||||
// field: what it is ([classifyHandoff], pure) and the small card that asks
|
||||
// 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.
|
||||
sealed class Handoff {
|
||||
const Handoff();
|
||||
@@ -100,6 +105,10 @@ enum HandoffAction {
|
||||
addFolder,
|
||||
/// Open Library → Link with the URL filled.
|
||||
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);
|
||||
@@ -108,7 +117,9 @@ class HandoffCard extends StatelessWidget {
|
||||
final Handoff item;
|
||||
/// Nobody else watches (offline or solo): videos play, no relay talk.
|
||||
final bool solo;
|
||||
const HandoffCard({super.key, required this.item, required this.solo});
|
||||
/// 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
|
||||
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))
|
||||
: OutlinedButton.icon(
|
||||
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) {
|
||||
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',
|
||||
[
|
||||
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'),
|
||||
],
|
||||
),
|
||||
@@ -143,6 +162,7 @@ class HandoffCard extends StatelessWidget {
|
||||
[
|
||||
button(HandoffAction.openLink, Icons.link, solo ? 'Open in Link' : 'Put it up as a link…',
|
||||
primary: true),
|
||||
...queueButtons,
|
||||
],
|
||||
),
|
||||
HandoffImage() || HandoffNothing() => (Icons.help_outline, '', '', const <Widget>[]),
|
||||
|
||||
@@ -3,10 +3,13 @@ import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.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 '../screens/home/media_picker.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import '../widgets/copy_open.dart';
|
||||
import '../state/connect_prefs.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
@@ -21,20 +24,33 @@ class RecentLink {
|
||||
final String url;
|
||||
final String title;
|
||||
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;
|
||||
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() =>
|
||||
{'url': url, 'title': title, 'thumb': ?thumbnail, 'at': at.millisecondsSinceEpoch};
|
||||
/// What it showed when put up (the host for a second line, when older).
|
||||
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) {
|
||||
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;
|
||||
return RecentLink(
|
||||
url: url,
|
||||
title: title is String && title.isNotEmpty ? title : LinkMedia(url).label,
|
||||
thumbnail: thumb is String && thumb.isNotEmpty ? thumb : null,
|
||||
subtitle: sub is String && sub.isNotEmpty ? sub : null,
|
||||
at: DateTime.fromMillisecondsSinceEpoch(at is int ? at : 0),
|
||||
);
|
||||
}
|
||||
@@ -60,13 +76,18 @@ List<RecentLink> decodeRecentLinks(Object? raw) {
|
||||
class 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,
|
||||
_write = write ?? _hiveWrite;
|
||||
_write = write ?? _hiveWrite,
|
||||
previews = previews ?? (read == null && write == null ? LinkPreviewStore() : LinkPreviewStore.memory());
|
||||
|
||||
final Future<Object?> Function() _read;
|
||||
final Future<void> Function(String value) _write;
|
||||
final links = ValueNotifier<List<RecentLink>>(const []);
|
||||
/// What links pasted or put up before turned out to be.
|
||||
final LinkPreviewStore previews;
|
||||
Future<void>? _load;
|
||||
|
||||
static Future<Object?> _hiveRead() async =>
|
||||
@@ -111,6 +132,108 @@ class LinkPreview {
|
||||
/// null when it looks playable.
|
||||
final String? 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]).
|
||||
@@ -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", ...
|
||||
String agoText(DateTime at, DateTime now) {
|
||||
final d = now.difference(at).inDays;
|
||||
@@ -178,7 +314,17 @@ String agoText(DateTime at, DateTime now) {
|
||||
|
||||
// -------------------------------------------------------------- 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.server,
|
||||
@@ -186,7 +332,6 @@ const _modes = [
|
||||
'Server fetches it',
|
||||
'Downloaded once by the server. For links slow or blocked for some.'
|
||||
),
|
||||
(LinkRelay.me, Icons.cell_tower, 'Through me', 'Your computer passes it on. For links only you can open.'),
|
||||
];
|
||||
|
||||
String linkRelayHelp(LinkRelay r) => switch (r) {
|
||||
@@ -208,12 +353,15 @@ class LibraryLinkSource extends StatefulWidget {
|
||||
final bool solo;
|
||||
/// Puts the link up; returns an error text, or null when sent.
|
||||
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 LinkProber probe;
|
||||
const LibraryLinkSource({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.onPutUp,
|
||||
this.onAddToQueue,
|
||||
required this.recents,
|
||||
this.solo = false,
|
||||
this.probe = defaultLinkProber,
|
||||
@@ -232,6 +380,10 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
String? _error;
|
||||
/// [_error] is the probe's problem: "Put it up anyway" is offered.
|
||||
bool _canForce = false;
|
||||
/// "Other ways" opened (always shown while one of them is picked).
|
||||
bool _otherWays = false;
|
||||
|
||||
LinkPreviewStore get _store => widget.recents.previews;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -239,6 +391,10 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
widget.recents.ensureLoaded();
|
||||
_c.link.addListener(_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
|
||||
@@ -252,7 +408,12 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
final url = _c.link.text.trim();
|
||||
if (url == _previewUrl) return;
|
||||
_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));
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_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 {
|
||||
_previewUrl = url;
|
||||
if (url.isEmpty || _previews.containsKey(url)) {
|
||||
if (mounted) setState(() {});
|
||||
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);
|
||||
_previews[url] = p;
|
||||
_keep(url, p);
|
||||
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 {
|
||||
try {
|
||||
final text = (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim();
|
||||
@@ -299,13 +501,23 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
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) {
|
||||
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(
|
||||
url: url,
|
||||
title: p?.title ?? LinkMedia(url).label,
|
||||
thumbnail: p?.thumbnail,
|
||||
subtitle: p?.problem == null ? p?.subtitle : null,
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
@@ -351,6 +644,22 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
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(
|
||||
key: const ValueKey('media-link'),
|
||||
@@ -387,8 +696,14 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
field,
|
||||
const SizedBox(height: 10),
|
||||
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) ...[
|
||||
const SizedBox(height: 8),
|
||||
@@ -414,28 +729,58 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
? const SizedBox(width: double.infinity)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(top: 14),
|
||||
child: Builder(
|
||||
builder: (context) => GestureDetector(
|
||||
onSecondaryTap: () => _previewMenu(context, url, preview),
|
||||
child: _PreviewCard(preview: preview, loading: loading),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!solo) ...[
|
||||
const SizedBox(height: 28),
|
||||
Text('How the others get it', style: t.titleSmall),
|
||||
const SizedBox(height: 10),
|
||||
LayoutBuilder(builder: (context, box) {
|
||||
final cards = [
|
||||
for (final m in _modes)
|
||||
_ModeCard(
|
||||
key: ValueKey('link-mode-${m.$1.name}'),
|
||||
icon: m.$2,
|
||||
title: m.$3,
|
||||
sub: m.$4,
|
||||
selected: _c.linkRelay == m.$1,
|
||||
onTap: () => setState(() => _c.linkRelay = m.$1),
|
||||
_modeCard(_main),
|
||||
const SizedBox(height: 6),
|
||||
Builder(builder: (context) {
|
||||
final open = _otherWays || _c.linkRelay != LinkRelay.me;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
key: const ValueKey('link-other-ways'),
|
||||
// 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) {
|
||||
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(
|
||||
@@ -450,6 +795,11 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 8),
|
||||
Text(linkRelayHelp(_c.linkRelay), style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
],
|
||||
@@ -459,7 +809,8 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
? const SizedBox.shrink()
|
||||
: Padding(
|
||||
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 String title, sub;
|
||||
final bool selected;
|
||||
/// A secondary choice: icon beside the title, a quieter card.
|
||||
final bool compact;
|
||||
final VoidCallback onTap;
|
||||
const _ModeCard({
|
||||
super.key,
|
||||
@@ -550,6 +903,7 @@ class _ModeCard extends StatelessWidget {
|
||||
required this.sub,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -565,18 +919,23 @@ class _ModeCard extends StatelessWidget {
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
padding: EdgeInsets.all(compact ? 10 : 14),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? scheme.primary.withValues(alpha: 0.10) : scheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: selected ? scheme.primary : Colors.transparent, width: 2),
|
||||
),
|
||||
child: 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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: selected ? scheme.primary : scheme.onSurfaceVariant),
|
||||
const SizedBox(height: 8),
|
||||
Text(title, style: TextStyle(fontWeight: FontWeight.w600, color: selected ? scheme.primary : null)),
|
||||
Text(title,
|
||||
style: TextStyle(fontWeight: FontWeight.w600, color: selected ? scheme.primary : null)),
|
||||
const SizedBox(height: 2),
|
||||
Text(sub,
|
||||
maxLines: 2,
|
||||
@@ -585,6 +944,9 @@ class _ModeCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -593,7 +955,9 @@ class _ModeCard extends StatelessWidget {
|
||||
class _RecentGrid extends StatelessWidget {
|
||||
final List<RecentLink> recents;
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
@@ -618,9 +982,11 @@ class _RecentGrid extends StatelessWidget {
|
||||
child: Tooltip(
|
||||
message: l.url,
|
||||
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}'),
|
||||
onTap: () => onPick(l),
|
||||
onSecondaryTap: () => onMenu(context, l),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -647,7 +1013,7 @@ class _RecentGrid extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -78,6 +78,8 @@ class LibraryTab extends StatefulWidget {
|
||||
final BrowseStart? jellyfinStart;
|
||||
/// Puts the pick up; returns an error text, or null when sent.
|
||||
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? onOpenIntegrations;
|
||||
/// Defaults to [RecentLinks.instance].
|
||||
@@ -97,6 +99,7 @@ class LibraryTab extends StatefulWidget {
|
||||
required this.solo,
|
||||
required this.isHost,
|
||||
required this.onPutUp,
|
||||
this.onAddToQueue,
|
||||
this.onPutUpDone,
|
||||
this.jellyfinStart,
|
||||
this.onOpenIntegrations,
|
||||
@@ -164,6 +167,13 @@ class _LibraryTabState extends State<LibraryTab> {
|
||||
|
||||
/// A Jellyfin card or episode: a series goes up as its next episode.
|
||||
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 client = widget.accounts.clientFor(sel.serverId);
|
||||
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;
|
||||
}
|
||||
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.
|
||||
@@ -235,6 +261,7 @@ class _LibraryTabState extends State<LibraryTab> {
|
||||
selected: _c.selected,
|
||||
onSelect: _c.select,
|
||||
onPutUp: _doublePutUp,
|
||||
onAddToQueue: widget.onAddToQueue == null ? null : _cardQueue,
|
||||
onOpenIntegrations: widget.onOpenIntegrations,
|
||||
start: widget.jellyfinStart,
|
||||
libraryStrip: false,
|
||||
@@ -251,6 +278,7 @@ class _LibraryTabState extends State<LibraryTab> {
|
||||
recents: widget.recentLinks ?? RecentLinks.instance,
|
||||
probe: widget.probe,
|
||||
onPutUp: _put,
|
||||
onAddToQueue: widget.onAddToQueue,
|
||||
),
|
||||
MediaKind.file => _files(context),
|
||||
};
|
||||
@@ -282,6 +310,7 @@ class _LibraryTabState extends State<LibraryTab> {
|
||||
selection: shown,
|
||||
solo: widget.solo,
|
||||
onPutUp: _putUpJellyfin,
|
||||
onAddToQueue: widget.onAddToQueue == null ? null : _queueJellyfin,
|
||||
onClose: () => _c.select(null),
|
||||
),
|
||||
));
|
||||
@@ -348,7 +377,13 @@ class _LibraryTabState extends State<LibraryTab> {
|
||||
if (b != null) {
|
||||
return b(context, solo: widget.solo, relayed: relayed, onPlay: play, nowPlayingPath: now);
|
||||
}
|
||||
return FilesPage(solo: widget.solo, relayed: relayed, onPlay: play, nowPlayingPath: now);
|
||||
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 '../state/session_state.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import '../widgets/copy_open.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
/// 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
|
||||
/// 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 {
|
||||
final String hash;
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final image = artImage(hash);
|
||||
final r = BorderRadius.circular(width >= 80 ? 10 : 6);
|
||||
return Container(
|
||||
width: width,
|
||||
width: wide ? width * 1.5 * 16 / 9 : width,
|
||||
height: width * 1.5,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: r,
|
||||
@@ -116,7 +120,7 @@ class NowPlayingBlock extends StatelessWidget {
|
||||
key: ValueKey('now-playing:${n.title}\n${episode ?? ''}'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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(
|
||||
child: Column(
|
||||
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 {
|
||||
final bool cover;
|
||||
final bool compact;
|
||||
const NowPlayingTitle({super.key, this.cover = true, this.compact = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListenableBuilder(
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
onSecondaryTap: () => nowPlayingMenu(context),
|
||||
child: ListenableBuilder(
|
||||
listenable: artChanges(),
|
||||
builder: (context, _) => SignalBuilder(
|
||||
builder: (context) => NowPlayingBlock(
|
||||
@@ -167,5 +174,38 @@ class NowPlayingTitle extends StatelessWidget {
|
||||
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!);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,21 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../screens/home/ui.dart' show appName, displayVersion;
|
||||
import '../screens/home/update_indicator.dart';
|
||||
import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge;
|
||||
import '../session/delivery.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import '../widgets/app_keys.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import '../widgets/copy_open.dart';
|
||||
import '../widgets/window_buttons.dart';
|
||||
import 'avatar.dart';
|
||||
import 'delivery_line.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
/// The shell's tabs, in strip order.
|
||||
@@ -34,10 +37,6 @@ const stripAvatarSize = 42.0;
|
||||
/// so the tabs keep their room.
|
||||
const stripCompactBelow = 1100.0;
|
||||
|
||||
/// The member plays the current Jellyfin item through the host's relay.
|
||||
bool viaHostOf(MemberView m, JellyfinRef? ref) =>
|
||||
ref != null && m.jf[ref.serverId] == false;
|
||||
|
||||
/// The 60 px strip over everything: your avatar (profile), the tabs, and
|
||||
/// the party: offline Join a friend / Host, solo a "Solo" chip, Invite
|
||||
/// friends and Stop, online the avatars (members card), server and
|
||||
@@ -126,6 +125,8 @@ class PartyStrip extends StatelessWidget {
|
||||
label: t.label,
|
||||
selected: t == tab,
|
||||
compact: compact,
|
||||
// The version, a rest of the mouse away.
|
||||
tooltip: t == ShellTab.settings ? '$appName ${displayVersion()}' : null,
|
||||
onTap: () => onTab(t),
|
||||
),
|
||||
],
|
||||
@@ -366,18 +367,21 @@ class StripTab extends StatelessWidget {
|
||||
final bool selected;
|
||||
final bool compact;
|
||||
final VoidCallback onTap;
|
||||
/// Shown after resting on the tab.
|
||||
final String? tooltip;
|
||||
const StripTab({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
this.compact = false,
|
||||
this.tooltip,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Semantics(
|
||||
final tab = Semantics(
|
||||
selected: selected,
|
||||
button: true,
|
||||
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
|
||||
? null
|
||||
: '${session.lastForm.host}:${session.lastForm.port}';
|
||||
final delivery = r == null
|
||||
? null
|
||||
: deliveryOf(r, selfId: coord.sync.memberId, names: coord.memberNames);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -493,11 +502,12 @@ class MembersCard extends StatelessWidget {
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
if (delivery != null) SourceLine(delivery: delivery),
|
||||
for (final m in r?.members ?? const <MemberView>[])
|
||||
_MemberTile(
|
||||
member: m,
|
||||
info: memberAvatarInfo(m, r, coord),
|
||||
viaHost: viaHostOf(m, coord.currentRef.value),
|
||||
delivery: delivery == null ? null : memberDeliveryOf(m, r!, delivery),
|
||||
),
|
||||
if (r == null)
|
||||
Padding(
|
||||
@@ -515,8 +525,7 @@ class MembersCard extends StatelessWidget {
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
key: const ValueKey('copy-invite'),
|
||||
onPressed: () =>
|
||||
Clipboard.setData(ClipboardData(text: address)),
|
||||
onPressed: () => copyToClipboard(address, done: 'Invite address copied'),
|
||||
icon: const Icon(Icons.copy, size: 16),
|
||||
label: const Text('Copy invite address'),
|
||||
),
|
||||
@@ -539,11 +548,12 @@ class MembersCard extends StatelessWidget {
|
||||
class _MemberTile extends StatelessWidget {
|
||||
final MemberView member;
|
||||
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({
|
||||
required this.member,
|
||||
required this.info,
|
||||
required this.viaHost,
|
||||
this.delivery,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -574,13 +584,11 @@ class _MemberTile extends StatelessWidget {
|
||||
if (info.host) const Pill('host', Colors.amber),
|
||||
if (info.relaying)
|
||||
const Pill('passing the video on', Colors.lightBlueAccent),
|
||||
if (viaHost) const ViaHostBadge(),
|
||||
],
|
||||
),
|
||||
subtitle: Text(status),
|
||||
trailing: PopupMenuButton<String>(
|
||||
subtitle: DeliveryStatus(status: status, delivery: delivery),
|
||||
trailing: AppMenuButton<String>(
|
||||
tooltip: 'More',
|
||||
icon: const Icon(Icons.more_vert, size: 18),
|
||||
onSelected: (v) {
|
||||
switch (v) {
|
||||
case 'force':
|
||||
@@ -593,18 +601,13 @@ class _MemberTile extends StatelessWidget {
|
||||
coord.joinBack();
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
if (!m.out)
|
||||
PopupMenuItem(
|
||||
value: 'force',
|
||||
child: Text(m.ready ? 'Force unready' : 'Force ready'),
|
||||
),
|
||||
if (!info.self)
|
||||
const PopupMenuItem(value: 'nudge', child: Text('Nudge')),
|
||||
items: () => [
|
||||
if (!m.out) AppMenuItem(value: 'force', label: m.ready ? 'Force unready' : 'Force ready'),
|
||||
if (!info.self) const AppMenuItem(value: 'nudge', label: 'Nudge'),
|
||||
if (info.self && m.out)
|
||||
const PopupMenuItem(value: 'back', child: Text('Join back'))
|
||||
const AppMenuItem(value: 'back', label: 'Join back')
|
||||
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,
|
||||
builder: (context, snap) {
|
||||
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(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||
child: Column(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' show ImageFilter;
|
||||
|
||||
import 'package:flutter/material.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/session_coordinator.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../widgets/app_menu.dart' show AppGlass;
|
||||
import 'avatar.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 {
|
||||
final Widget child;
|
||||
const PlayerGlass({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final r = BorderRadius.circular(999);
|
||||
return ConstrainedBox(
|
||||
Widget build(BuildContext context) => ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: ClipRRect(
|
||||
borderRadius: r,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
borderRadius: r,
|
||||
border: Border.all(color: Colors.white12, width: 0.5),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: AppGlass.pill(child: child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusLine extends StatelessWidget {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -18,10 +18,10 @@ import '../screens/home/about_page.dart';
|
||||
import '../screens/home/settings_page.dart';
|
||||
import '../sfx/sfx.dart';
|
||||
import '../sfx/sound_bank.dart';
|
||||
import '../sfx/synth_sfx.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
@@ -120,12 +120,16 @@ class SettingsTab extends StatelessWidget {
|
||||
return [
|
||||
const SettingsHintCard(
|
||||
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(
|
||||
title: 'Player',
|
||||
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: 'yt-dlp', child: YtDlpSettings()),
|
||||
];
|
||||
@@ -622,11 +626,15 @@ class _CookieBrowserDialogState extends State<_CookieBrowserDialog> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _browser,
|
||||
decoration: const InputDecoration(labelText: 'Browser'),
|
||||
items: [for (final b in cookieBrowsers) DropdownMenuItem(value: b, child: Text(b))],
|
||||
onChanged: (v) => setState(() => _browser = v ?? _browser),
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(child: Text('Browser')),
|
||||
AppSelect<String>(
|
||||
value: _browser,
|
||||
items: [for (final b in cookieBrowsers) (b, b)],
|
||||
onChanged: (v) => setState(() => _browser = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
@@ -723,8 +731,8 @@ class _AnimeFlowCardState extends State<AnimeFlowCard> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What pops up and what makes a sound. No switches: there is nothing to
|
||||
/// choose yet.
|
||||
/// What pops up (chat pop-ups: [AppSettings.chatPopups]) and what makes
|
||||
/// a sound.
|
||||
class NotificationsInfo extends StatelessWidget {
|
||||
const NotificationsInfo({super.key});
|
||||
|
||||
@@ -746,11 +754,35 @@ class NotificationsInfo extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
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'),
|
||||
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('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 {
|
||||
const SoundSettings({super.key});
|
||||
|
||||
void _preview(SfxKind kind) {
|
||||
final sfx = SessionState.instance.sfx;
|
||||
if (sfx is SynthSfx) unawaited(sfx.preview(kind, readyIndex: 2));
|
||||
}
|
||||
void _preview(SfxKind kind) => unawaited(SessionState.instance.sfx.preview(kind, readyIndex: 2));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -804,17 +833,15 @@ class SoundSettings extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(child: Text('Sound set')),
|
||||
DropdownButton<String>(
|
||||
AppSelect<String>(
|
||||
key: const ValueKey('sound-preset'),
|
||||
value: overall,
|
||||
onChanged: (v) {
|
||||
if (v == null) return;
|
||||
s.setSoundPreset(v);
|
||||
_preview(SfxKind.ready);
|
||||
},
|
||||
items: [
|
||||
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries)
|
||||
DropdownMenuItem(value: p.name, child: Text(name)),
|
||||
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries) (p.name, name),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -839,19 +866,17 @@ class SoundSettings extends StatelessWidget {
|
||||
children: [Text(name), Text(help, style: muted)],
|
||||
),
|
||||
),
|
||||
DropdownButton<String?>(
|
||||
AppSelect<String?>(
|
||||
key: ValueKey('sound-preset-${kind.name}'),
|
||||
value: per[kind.name],
|
||||
underline: const SizedBox.shrink(),
|
||||
style: muted,
|
||||
onChanged: (v) {
|
||||
s.setSoundPresetFor(kind.name, v);
|
||||
if (volume > 0) _preview(kind);
|
||||
},
|
||||
items: [
|
||||
const DropdownMenuItem<String?>(value: null, child: Text('Same as set')),
|
||||
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries)
|
||||
DropdownMenuItem<String?>(value: p.name, child: Text(name)),
|
||||
(null, 'Same as set'),
|
||||
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries) (p.name, name),
|
||||
],
|
||||
),
|
||||
// A nudge always plays (an explicit poke): no switch,
|
||||
|
||||
@@ -82,7 +82,8 @@ class StartCard extends StatelessWidget {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (!small) ...[
|
||||
// A thumbnail already fills the card as the banner.
|
||||
if (!small && !now.thumbnail) ...[
|
||||
ArtCover(hash: now.posterHash, width: coverWidth),
|
||||
const SizedBox(width: 24),
|
||||
],
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../session/session_coordinator.dart' show ToastItem;
|
||||
import '../state/session_state.dart';
|
||||
import '../state/settings.dart';
|
||||
import 'avatar.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
|
||||
/// slides in, stays [life], then slides out and collapses its gap. A click
|
||||
/// on a chat toast opens the chat ([onOpenChat]); the rest let clicks
|
||||
/// through.
|
||||
/// through. Chat toasts follow [AppSettings.chatPopups].
|
||||
class PartyToasts extends StatefulWidget {
|
||||
final Duration life;
|
||||
final VoidCallback? onOpenChat;
|
||||
@@ -76,8 +77,13 @@ class _PartyToastsState extends State<PartyToasts> {
|
||||
if (fresh.isEmpty) return;
|
||||
// Only new toasts re-run this, not the party they mention.
|
||||
untracked(() {
|
||||
final playing = coord.serverState.value?.transport == 'playing';
|
||||
final popups = AppSettings.instance.chatPopups.value;
|
||||
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
|
||||
/// [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(
|
||||
AppConfig config, {
|
||||
bool loopbackOnly = false,
|
||||
String? logDir,
|
||||
required void Function(String line) onLog,
|
||||
required void Function(String error) onCrash,
|
||||
Duration timeout = const Duration(seconds: 15),
|
||||
@@ -57,9 +59,9 @@ class ServerIsolate {
|
||||
if (msg is! Map) return;
|
||||
switch (msg['t']) {
|
||||
case 'log':
|
||||
final err = msg['error'];
|
||||
final err = msg['error'], stack = msg['stack'];
|
||||
onLog('${msg['level']} ${msg['logger']}: ${msg['message']}'
|
||||
'${err == null ? '' : ' ($err)'}');
|
||||
'${err == null ? '' : ' ($err)'}${stack == null ? '' : '\n$stack'}');
|
||||
case 'ready':
|
||||
if (!startup.isCompleted) {
|
||||
startup.complete((msg['port'] as int, msg['stop'] as SendPort));
|
||||
@@ -85,7 +87,7 @@ class ServerIsolate {
|
||||
final Isolate isolate;
|
||||
try {
|
||||
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,
|
||||
onError: errors.sendPort,
|
||||
onExit: exit.sendPort,
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/session_media.dart';
|
||||
import '../screens/home/health_probe.dart';
|
||||
import '../screens/home/picked_media.dart';
|
||||
import '../platform/app_log.dart' show appLogDir;
|
||||
import '../playback/yt_dlp_app.dart';
|
||||
import '../sfx/sfx.dart';
|
||||
import '../sfx/sound_bank.dart';
|
||||
@@ -159,7 +160,8 @@ class SessionState {
|
||||
final route = signal(AppRoute.home);
|
||||
final recents = signal<List<RecentServer>>(const []);
|
||||
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(),
|
||||
// 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),
|
||||
@@ -379,7 +381,11 @@ class SessionState {
|
||||
ytDlpDir: await _sharedYtDlpDir(),
|
||||
),
|
||||
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),
|
||||
);
|
||||
} catch (e, st) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../controls/model/controls_prefs.dart';
|
||||
import '../shaders/shader_presets.dart';
|
||||
|
||||
// App preferences (Settings page), one Hive box. Signals start at their
|
||||
@@ -17,7 +18,14 @@ const _keyShaderPreset = 'shaderPreset';
|
||||
const _keyShaderQuality = 'shaderQuality';
|
||||
const _keyCustomShaders = 'customShaders';
|
||||
const _keyAutoAdvance = 'autoAdvance';
|
||||
/// Legacy: true was the uosc design (now [ControlsSkin.minimal]); read
|
||||
/// once when [_keyControlsSkin] is missing.
|
||||
const _keyMpvControls = 'mpvControls';
|
||||
const _keyControlsSkin = 'controlsSkin';
|
||||
const _keyTimeDisplay = 'timeDisplay';
|
||||
const _keyControlsSize = 'controlsSize';
|
||||
const _keyControlsHideSecs = 'controlsHideSecs';
|
||||
const _keyHiddenControls = 'hiddenControls';
|
||||
const _keyHideReReleases = 'hideReReleases';
|
||||
const _keyProgressLine = 'progressLine';
|
||||
const _keySoundVolume = 'soundVolume';
|
||||
@@ -27,9 +35,28 @@ const _keyWelcomeDismissed = 'welcomeDismissed';
|
||||
const _keySoundPresetFor = 'soundPresetFor';
|
||||
const _keyUnreadyOnSwap = 'unreadyOnSwap';
|
||||
const _keyIdleSitsOut = 'idleSitsOut';
|
||||
const _keyChatPopups = 'chatPopups';
|
||||
const _keyShareDiagnostics = 'shareDiagnostics';
|
||||
|
||||
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 {
|
||||
static final AppSettings instance = AppSettings();
|
||||
|
||||
@@ -43,15 +70,26 @@ class AppSettings {
|
||||
final unreadyOnSwap = signal(false);
|
||||
/// Session: the idle auto-unready sits this member out instead.
|
||||
final idleSitsOut = signal(false);
|
||||
/// Player design: the uosc controls (`controls/uosc/`) instead of the
|
||||
/// standard ones (media_kit's with our bars). Applies live.
|
||||
final mpvControls = signal(false);
|
||||
/// The player controls' skin (`controls/`). Applies live.
|
||||
final controlsSkin = signal(ControlsSkin.standard);
|
||||
/// The bar's clock: elapsed / total or the time left.
|
||||
final timeDisplay = signal(TimeDisplay.elapsed);
|
||||
final controlsSize = signal(ControlsSize.normal);
|
||||
/// Seconds without mouse movement before the controls hide.
|
||||
final controlsHideSecs = signal(defaultControlsHideSecs);
|
||||
/// Optional bar buttons switched off (they stay in ⚙).
|
||||
final hiddenControls = signal<Set<OptionalControl>>(const {});
|
||||
/// Library: leave out of "Latest in X" what was added long after it
|
||||
/// first aired (re-uploads, old seasons added late).
|
||||
final hideReReleases = signal(true);
|
||||
/// A 2 px progress line at the bottom of the video while the controls
|
||||
/// are hidden (both designs). Applies live.
|
||||
/// are hidden (both skins). Applies live.
|
||||
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.
|
||||
final soundVolume = signal(1.0);
|
||||
/// `SfxKind` names switched off one by one.
|
||||
@@ -103,12 +141,26 @@ class AppSettings {
|
||||
if (unready is bool) unreadyOnSwap.value = unready;
|
||||
final idleOut = box.get(_keyIdleSitsOut);
|
||||
if (idleOut is bool) idleSitsOut.value = idleOut;
|
||||
final mpv = box.get(_keyMpvControls);
|
||||
if (mpv is bool) mpvControls.value = mpv;
|
||||
controlsSkin.value =
|
||||
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);
|
||||
if (hideRe is bool) hideReReleases.value = hideRe;
|
||||
final line = box.get(_keyProgressLine);
|
||||
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);
|
||||
if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1);
|
||||
final off = box.get(_keySoundsOff);
|
||||
@@ -171,9 +223,31 @@ class AppSettings {
|
||||
_put(_keyIdleSitsOut, on);
|
||||
}
|
||||
|
||||
void setMpvControls(bool on) {
|
||||
mpvControls.value = on;
|
||||
_put(_keyMpvControls, on);
|
||||
void setControlsSkin(ControlsSkin skin) {
|
||||
controlsSkin.value = skin;
|
||||
_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) {
|
||||
@@ -181,6 +255,16 @@ class AppSettings {
|
||||
_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) {
|
||||
soundVolume.value = v.clamp(0, 1);
|
||||
_put(_keySoundVolume, soundVolume.value);
|
||||
|
||||
@@ -208,8 +208,13 @@ class SyncClient {
|
||||
_send(PingMessage(clientMs: _clock().millisecondsSinceEpoch).toJson());
|
||||
}
|
||||
|
||||
void sendSwap(HelloMedia media) {
|
||||
_send(SwapIntent(media: media).toJson());
|
||||
void sendSwap(HelloMedia media, {String? queueId}) {
|
||||
_send(SwapIntent(media: media, queueId: queueId).toJson());
|
||||
}
|
||||
|
||||
/// One change to Up next (see [QueueIntent]).
|
||||
void sendQueue(QueueIntent intent) {
|
||||
_send(intent.toJson());
|
||||
}
|
||||
|
||||
void sendReport({
|
||||
|
||||
@@ -107,7 +107,8 @@ class UpdateController {
|
||||
final root = _root;
|
||||
if (root != null) await cleanupUpdateLeftovers(root);
|
||||
try {
|
||||
final m = await fetchManifest(url: manifestUrl);
|
||||
final m = await fetchLatestManifest(
|
||||
currentVersion: currentVersion, url: manifestUrl);
|
||||
if (!isNewerVersion(currentVersion, m.version)) {
|
||||
_log.info('up to date ($currentVersion, latest ${m.version})');
|
||||
lastCheck.value = 'Up to date';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../shell/mark_problem.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'text_focus.dart';
|
||||
|
||||
@@ -15,11 +16,12 @@ Map<ShortcutActivator, Intent> get appShortcuts => {
|
||||
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,
|
||||
/// 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
|
||||
/// 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 {
|
||||
/// Who toggles the chat, latest first: the strip while it is shown, the
|
||||
/// full player in fullscreen (both hold the shell's toggle).
|
||||
@@ -53,6 +55,11 @@ class _AppKeysState extends State<AppKeys> {
|
||||
bool _key(KeyEvent event) {
|
||||
if (event is! KeyDownEvent || !mounted) return false;
|
||||
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 (SessionState.instance.route.peek() != AppRoute.session) return false;
|
||||
if (ModalRoute.of(context)?.isCurrent == false) return false;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,9 +1,24 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// True while keyboard focus sits in a text field. The focused node's
|
||||
/// context belongs to the `Focus` that `EditableText` builds, so look for
|
||||
/// an `EditableText` above it (walking FocusNode parents never meets one).
|
||||
/// True while keyboard focus sits in a text field, or anywhere under a
|
||||
/// [KeyboardClaim]. The focused node's context belongs to the `Focus` that
|
||||
/// `EditableText` builds, so look for an `EditableText` above it (walking
|
||||
/// FocusNode parents never meets one).
|
||||
bool textInputFocused([FocusNode? node]) {
|
||||
final n = node ?? FocusManager.instance.primaryFocus;
|
||||
return n?.context?.findAncestorWidgetOfExactType<EditableText>() != null;
|
||||
final c = (node ?? FocusManager.instance.primaryFocus)?.context;
|
||||
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
|
||||
flutter_soloud
|
||||
jni
|
||||
)
|
||||
|
||||
|
||||
@@ -153,6 +153,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.2"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
file_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -227,6 +235,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -246,6 +262,14 @@ packages:
|
||||
url: "https://git.senkochan.moe/Foxyz/fox_utils.git"
|
||||
source: git
|
||||
version: "0.1.9"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
hive_ce:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -342,6 +366,15 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -486,6 +519,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -614,6 +655,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
process:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: process
|
||||
sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.6"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -663,7 +712,7 @@ packages:
|
||||
source: hosted
|
||||
version: "2.0.6"
|
||||
screen_retriever:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: screen_retriever
|
||||
sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6
|
||||
@@ -777,14 +826,14 @@ packages:
|
||||
path: "../../packages/core"
|
||||
relative: true
|
||||
source: path
|
||||
version: "1.1.1"
|
||||
version: "1.2.0"
|
||||
sync_relay_server:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../packages/server"
|
||||
relative: true
|
||||
source: path
|
||||
version: "1.1.1"
|
||||
version: "1.2.0"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: watch_together
|
||||
description: Watch-together desktop app (working title, repo sync_relay_project). One binary.
|
||||
publish_to: none
|
||||
version: 1.1.2
|
||||
version: 1.2.0
|
||||
environment:
|
||||
sdk: '>=3.13.0-0 <4.0.0'
|
||||
|
||||
@@ -23,6 +23,7 @@ dependencies:
|
||||
hive_ce_flutter: ^2.3.4
|
||||
image: ^4.9.2
|
||||
window_manager: ^0.5.2
|
||||
screen_retriever: ^0.2.2
|
||||
crypto: ^3.0.6
|
||||
ffi: ^2.1.0
|
||||
# Drag & drop and clipboard files/images (vendored desktop_drop + pasteboard).
|
||||
@@ -30,6 +31,20 @@ dependencies:
|
||||
git:
|
||||
url: https://git.senkochan.moe/Foxyz/fox_utils.git
|
||||
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:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
@@ -65,4 +65,24 @@ void main() {
|
||||
expect(readies, 1);
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
@@ -87,7 +87,7 @@ void main() {
|
||||
expect(c.source, MediaKind.jellyfin);
|
||||
c.pasteText(' https://example.com/a.mkv ');
|
||||
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');
|
||||
expect(c.source, MediaKind.file);
|
||||
expect(c.selection, const FileMedia('/home/me/b.mkv'));
|
||||
|
||||
@@ -307,7 +307,7 @@ void main() {
|
||||
final c = MediaPickerController();
|
||||
expect(c.selection, isNull);
|
||||
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');
|
||||
expect(c.selection, const FileMedia('/home/me/b.mkv'));
|
||||
c.source = MediaKind.jellyfin;
|
||||
|
||||
@@ -149,6 +149,9 @@ class _FakeJellyfin {
|
||||
res.statusCode = 204;
|
||||
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);
|
||||
res.statusCode = 404;
|
||||
await res.close();
|
||||
@@ -378,8 +381,13 @@ void main() {
|
||||
expect(ep1.last, 'Stopped');
|
||||
expect(ep1.where((k) => k == 'Stopped'), hasLength(1));
|
||||
final stopped1 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep1);
|
||||
expect(stopped1.body!['PositionTicks'], greaterThan(19 * jellyfinTicksPerSecond),
|
||||
reason: 'played to the end');
|
||||
// Part of it watched, then a seek to the end: not watched (see
|
||||
// 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(ep2.first, 'Playing');
|
||||
expect(ep2.last, 'Stopped', reason: 'leaving reports Stopped');
|
||||
|
||||
@@ -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/screens/home/media_picker.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/shell/library_tab.dart' show putUpJellyfinMedia;
|
||||
import 'package:watch_together/src/shell/party_strip.dart' show viaHostOf;
|
||||
|
||||
import 'jellyfin_accounts_test.dart';
|
||||
import 'jellyfin_browser_test.dart' show app, setSize, settle;
|
||||
@@ -41,6 +41,14 @@ FakeJellyfin playbackFake() {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -261,11 +269,46 @@ void main() {
|
||||
'ItemId': episodeId,
|
||||
'MediaSourceId': episodeId,
|
||||
'PlaySessionId': playSessionId,
|
||||
'PositionTicks': 3005000000,
|
||||
});
|
||||
'PositionTicks': 0,
|
||||
}, reason: '11 s watched is a peek: the resume point from before (none) stays');
|
||||
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 {
|
||||
reporter.start(const PlayerSnapshot(position: 0, paused: false));
|
||||
await reporter.stop(const PlayerSnapshot(position: 1440, paused: true));
|
||||
@@ -341,11 +384,13 @@ void main() {
|
||||
expect(compareMemberIds('m9', 'm10'), lessThan(0));
|
||||
});
|
||||
|
||||
test('member badge: via host only for relay viewers of the current item', () {
|
||||
expect(viaHostOf(member('m1', 'G', jf: {fixtureServerId: false}), episodeRef), isTrue);
|
||||
expect(viaHostOf(member('m1', 'G', jf: {fixtureServerId: true}), episodeRef), isFalse);
|
||||
expect(viaHostOf(member('m1', 'G'), episodeRef), isFalse);
|
||||
expect(viaHostOf(member('m1', 'G', jf: {fixtureServerId: false}), null), isFalse);
|
||||
test('member route: via server only for relay viewers of the current item', () {
|
||||
DeliveryRoute? route(MemberView m, {bool relay = true}) =>
|
||||
routeOf(m, DeliveryKind.jellyfin, '', episodeRef, relay);
|
||||
expect(route(member('m1', 'G', jf: {fixtureServerId: false})), DeliveryRoute.server);
|
||||
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(
|
||||
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 {
|
||||
var play = 0, cancel = 0;
|
||||
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
Reference in new issue
Block a user