Compare commits
44
Commits
v1.1.0
..
bcc6d4bac8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
6d388483ef | ||
|
|
bbecdcc4d8 | ||
|
|
f1ccf88b06 | ||
|
|
d3be13ecda | ||
|
|
1b04e002c2 | ||
|
|
f23cc7b311 | ||
|
|
473c82ec0a | ||
|
|
e621b392af | ||
|
|
884f208330 | ||
|
|
cc45b86271 | ||
|
|
84c5cda668 | ||
|
|
bc96cb13d9 | ||
|
|
f7f2f0cddd | ||
|
|
05b06b1fc1 | ||
|
|
748d58f036 |
No files matched your search
@@ -1,6 +1,9 @@
|
||||
name: ci
|
||||
on:
|
||||
# Branches only: a release tag points at a commit CI already checked on
|
||||
# main, and release.yml builds it.
|
||||
push:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
@@ -60,3 +63,8 @@ jobs:
|
||||
dart pub get
|
||||
if ($LASTEXITCODE) { exit 1 }
|
||||
dart test test/updater_test.dart
|
||||
# The release build, without publishing: a broken Windows build shows
|
||||
# here, not only on a tag.
|
||||
- name: Windows build
|
||||
shell: powershell
|
||||
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/package_windows.ps1
|
||||
@@ -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,18 +146,76 @@ 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.
|
||||
you). Your last 12 links are listed under it (right-click one to copy
|
||||
or open it). A link seen before shows its preview at once, without
|
||||
asking the site again. A link that does not
|
||||
open here (needs cookies, 403, 404, not a video) says why and goes up
|
||||
only with "Put it up anyway"; in the player, whoever cannot play it is
|
||||
named ("Bob can't play this: Not allowed (403)") and nobody waits for them.
|
||||
- **Files**: video folders you add on this computer, grouped by series
|
||||
(click a series to list its episodes), or any file through Browse. The others stream it from you.
|
||||
|
||||
Put it up and the player opens. Not in a server, the buttons say Play
|
||||
and it plays for you alone ([Watch solo](#getting-started)).
|
||||
|
||||
### 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 next to the chat button: 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
|
||||
@@ -172,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 |
|
||||
@@ -182,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
|
||||
@@ -200,7 +282,7 @@ 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).
|
||||
|
||||
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
|
||||
@@ -212,7 +294,8 @@ overrides it); in the player also the subtitle and audio track. Every
|
||||
change applies right away.
|
||||
|
||||
Anywhere in a server (unless you are typing): Space = ready / unready,
|
||||
Tab = chat & activity (Tab never moves between buttons). Keys while the
|
||||
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
|
||||
@@ -225,26 +308,32 @@ 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.
|
||||
2.0 · title"; subtitles have Off. 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
|
||||
switches right away): video controls modeled on mpv's uosc theme. A
|
||||
translucent timeline across the bottom marks the position with a thin
|
||||
line, hatches the part not downloaded yet, shows chapter starts as
|
||||
diamonds (click one to jump), the elapsed time left, the time remaining
|
||||
right 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). 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
|
||||
and fullscreen; the title top left, Up next, chat and back-to-library top right. Keys and skip buttons work
|
||||
the same.
|
||||
|
||||
Skipping: during the opening a "Skip OP" button floats in the corner (it
|
||||
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
|
||||
@@ -291,11 +380,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,7 +398,8 @@ Watching:
|
||||
Episodes: the ⏮ / ⏭ buttons next to the controls (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
|
||||
@@ -325,15 +418,15 @@ 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
|
||||
@@ -349,11 +442,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
|
||||
@@ -376,7 +481,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) {
|
||||
@@ -74,12 +79,15 @@ Future<void> main(List<String> args) async {
|
||||
/// hosted server shuts down), bounded so a hung socket can't keep the
|
||||
/// window open.
|
||||
class _CloseGuard with WindowListener {
|
||||
final WindowMemory memory;
|
||||
bool _closing = false;
|
||||
_CloseGuard(this.memory);
|
||||
|
||||
@override
|
||||
Future<void> onWindowClose() async {
|
||||
if (_closing) return;
|
||||
_closing = true;
|
||||
await memory.save();
|
||||
try {
|
||||
await SessionState.instance.leave().timeout(const Duration(seconds: 2));
|
||||
} catch (e) {
|
||||
|
||||
@@ -2,16 +2,92 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// What keeps the player's controls up besides the mouse on them: the
|
||||
/// mouse on a floating element over the picture (a pill, a card, the
|
||||
/// status pill: [ControlsHoldRegion]) or an open popover. While anything
|
||||
/// holds, the controls stay up; on release they hide on their usual
|
||||
/// countdown. Both designs follow it ([ControlsHoverProbe] with
|
||||
/// media_kit's controls, `UoscControls`).
|
||||
class ControlsHold extends ChangeNotifier {
|
||||
final _by = <Object>{};
|
||||
bool _told = false;
|
||||
bool _queued = false;
|
||||
bool _disposed = false;
|
||||
|
||||
bool get held => _told;
|
||||
|
||||
/// [who] holds ([on]) or lets go. Listeners hear it in a microtask, never
|
||||
/// during a build or a dispose.
|
||||
void set(Object who, bool on) {
|
||||
if (on ? !_by.add(who) : !_by.remove(who)) return;
|
||||
if (_queued) return;
|
||||
_queued = true;
|
||||
scheduleMicrotask(() {
|
||||
_queued = false;
|
||||
if (_disposed) return;
|
||||
final now = _by.isNotEmpty;
|
||||
if (now == _told) return;
|
||||
_told = now;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// The mouse on [child] counts as the mouse on the controls: it holds
|
||||
/// [hold] while it is there.
|
||||
class ControlsHoldRegion extends StatefulWidget {
|
||||
final ControlsHold? hold;
|
||||
final Widget child;
|
||||
const ControlsHoldRegion({super.key, required this.hold, required this.child});
|
||||
|
||||
@override
|
||||
State<ControlsHoldRegion> createState() => _ControlsHoldRegionState();
|
||||
}
|
||||
|
||||
class _ControlsHoldRegionState extends State<ControlsHoldRegion> {
|
||||
@override
|
||||
void didUpdateWidget(ControlsHoldRegion oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.hold != widget.hold) oldWidget.hold?.set(this, false);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.hold?.set(this, false);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hold = widget.hold;
|
||||
if (hold == null) return widget.child;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => hold.set(this, true),
|
||||
onExit: (_) => hold.set(this, false),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// they are down. With [hold] held it keeps them (and itself) up. Never
|
||||
/// takes input.
|
||||
class ControlsHoverProbe extends StatefulWidget {
|
||||
final ValueNotifier<bool> shown;
|
||||
final Duration hideAfter;
|
||||
final ControlsHold? hold;
|
||||
final Widget child;
|
||||
const ControlsHoverProbe({super.key, required this.shown, required this.hideAfter, required this.child});
|
||||
const ControlsHoverProbe(
|
||||
{super.key, required this.shown, required this.hideAfter, this.hold, required this.child});
|
||||
|
||||
@override
|
||||
State<ControlsHoverProbe> createState() => _ControlsHoverProbeState();
|
||||
@@ -19,22 +95,67 @@ class ControlsHoverProbe extends StatefulWidget {
|
||||
|
||||
class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
|
||||
Timer? _timer;
|
||||
// While held: media_kit's own countdown is restarted before it ends.
|
||||
Timer? _keep;
|
||||
|
||||
bool get _held => widget.hold?.held ?? false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.hold?.addListener(_holdChanged);
|
||||
// Not during this build: the line listening to [shown] may be built.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _wake();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ControlsHoverProbe oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.hold != widget.hold) {
|
||||
oldWidget.hold?.removeListener(_holdChanged);
|
||||
widget.hold?.addListener(_holdChanged);
|
||||
_holdChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.hold?.removeListener(_holdChanged);
|
||||
_timer?.cancel();
|
||||
_keep?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _holdChanged() {
|
||||
if (!mounted) return;
|
||||
_keep?.cancel();
|
||||
_keep = null;
|
||||
if (_held) {
|
||||
_keep = Timer.periodic(widget.hideAfter * 0.5, (_) => _pokeControls());
|
||||
}
|
||||
_pokeControls();
|
||||
_wake();
|
||||
}
|
||||
|
||||
/// media_kit's controls state is private: a guarded dynamic call to its
|
||||
/// hover handler, as a mouse movement on it would make.
|
||||
void _pokeControls() {
|
||||
if (!mounted) return;
|
||||
void visit(Element e) {
|
||||
if (e is StatefulElement && e.state.runtimeType.toString() == '_MaterialDesktopVideoControlsState') {
|
||||
try {
|
||||
(e.state as dynamic).onHover();
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
e.visitChildElements(visit);
|
||||
}
|
||||
|
||||
context.visitChildElements(visit);
|
||||
}
|
||||
|
||||
void _wake() {
|
||||
widget.shown.value = true;
|
||||
_timer?.cancel();
|
||||
@@ -43,7 +164,8 @@ class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
|
||||
|
||||
void _hide() {
|
||||
_timer?.cancel();
|
||||
if (mounted) widget.shown.value = false;
|
||||
if (!mounted || _held) return;
|
||||
widget.shown.value = false;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -52,7 +174,9 @@ class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
|
||||
hitTestBehavior: HitTestBehavior.translucent,
|
||||
onEnter: (_) => _wake(),
|
||||
onHover: (_) => _wake(),
|
||||
onExit: (_) => _hide(),
|
||||
// Held (the mouse went onto a pill or the popover): media_kit's
|
||||
// controls just hid on their own exit; bring them straight back.
|
||||
onExit: (_) => _held ? _pokeControls() : _hide(),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,10 @@ 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 '../widgets/app_menu.dart';
|
||||
import 'uosc/buttons.dart';
|
||||
import 'uosc/controls.dart';
|
||||
|
||||
@@ -56,27 +58,14 @@ 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.
|
||||
class PlayerDesignSetting extends StatelessWidget {
|
||||
@@ -324,19 +313,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,9 +333,16 @@ class PlayerSettingsCard extends StatelessWidget {
|
||||
const TrackSetting(type: JellyfinStreamType.audio),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Custom shader files: Settings → Player',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Custom shader files: Settings → Player',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const MarkProblemButton(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
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;
|
||||
@@ -168,7 +167,7 @@ class _UoscBadgeButtonState extends State<UoscBadgeButton> {
|
||||
}
|
||||
|
||||
/// Volume: click mutes / unmutes, hovering shows a [VolumeBar] up to
|
||||
/// [maxVolume], the wheel steps 5 %.
|
||||
/// [maxVolume], the wheel steps [volumeStep].
|
||||
class UoscVolumeButton extends StatefulWidget {
|
||||
/// 0..[maxVolume].
|
||||
final double volume;
|
||||
@@ -180,6 +179,7 @@ class UoscVolumeButton extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _UoscVolumeButtonState extends State<UoscVolumeButton> {
|
||||
final _steps = WheelSteps();
|
||||
double _beforeMute = 100;
|
||||
bool _hover = false;
|
||||
|
||||
@@ -191,11 +191,8 @@ class _UoscVolumeButtonState extends State<UoscVolumeButton> {
|
||||
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));
|
||||
}
|
||||
},
|
||||
// Claimed here, so the player's own wheel handler skips it.
|
||||
onPointerSignal: (e) => _steps.handle(e, (step) => widget.onChanged(stepVolume(v, step * volumeStep))),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
@@ -4,13 +4,14 @@ import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../../state/settings.dart';
|
||||
import '../controls_probe.dart' show ControlsHold;
|
||||
|
||||
// The mpv-style (uosc) controls frame: a full-width timeline at the
|
||||
// bottom with a row of flat buttons above it, the title top left and a
|
||||
// corner widget top right.
|
||||
// Mouse movement shows them; after [UoscControls.hideAfter] without any
|
||||
// they fade out, the timeline shrinks to a 2 px line (gone when "Progress
|
||||
// line when hidden" is off) and the cursor hides. Our own frame: no
|
||||
// they fade out, the timeline shrinks to uosc's progress line (gone when
|
||||
// "Progress line when hidden" is off) and the cursor hides. Our own frame: no
|
||||
// media_kit controls state to reach into.
|
||||
|
||||
class UoscControls extends StatefulWidget {
|
||||
@@ -28,6 +29,8 @@ class UoscControls extends StatefulWidget {
|
||||
final Duration hideAfter;
|
||||
/// Follows whether the controls are up (for overlays outside them).
|
||||
final ValueNotifier<bool>? shown;
|
||||
/// Keeps them up while held (the mouse on an overlay, an open popover).
|
||||
final ControlsHold? holdBy;
|
||||
const UoscControls({
|
||||
super.key,
|
||||
required this.timeline,
|
||||
@@ -38,9 +41,12 @@ class UoscControls extends StatefulWidget {
|
||||
this.onDoubleTap,
|
||||
this.hideAfter = const Duration(seconds: 3),
|
||||
this.shown,
|
||||
this.holdBy,
|
||||
});
|
||||
|
||||
static const buttonRowHeight = 40.0;
|
||||
/// Under the button row, above the timeline.
|
||||
static const rowBottomPadding = 4.0;
|
||||
|
||||
/// The controls around [context], for widgets that need them kept up
|
||||
/// (a timeline drag, an open menu).
|
||||
@@ -54,6 +60,7 @@ class UoscControls extends StatefulWidget {
|
||||
class UoscControlsState extends State<UoscControls> {
|
||||
bool _visible = true;
|
||||
int _holds = 0;
|
||||
bool _heldBy = false;
|
||||
Timer? _timer;
|
||||
|
||||
bool get visible => _visible;
|
||||
@@ -61,6 +68,7 @@ class UoscControlsState extends State<UoscControls> {
|
||||
@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;
|
||||
@@ -68,12 +76,30 @@ class UoscControlsState extends State<UoscControls> {
|
||||
_hideLater();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(UoscControls oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.holdBy != widget.holdBy) {
|
||||
oldWidget.holdBy?.removeListener(_holdByChanged);
|
||||
widget.holdBy?.addListener(_holdByChanged);
|
||||
_holdByChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.holdBy?.removeListener(_holdByChanged);
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _holdByChanged() {
|
||||
final on = widget.holdBy?.held ?? false;
|
||||
if (on == _heldBy || !mounted) return;
|
||||
_heldBy = on;
|
||||
hold(on);
|
||||
}
|
||||
|
||||
/// Show the controls and restart the hide countdown.
|
||||
void wake() {
|
||||
if (!_visible && mounted) {
|
||||
@@ -185,8 +211,9 @@ class UoscControlsState extends State<UoscControls> {
|
||||
child: AnimatedOpacity(
|
||||
duration: fade,
|
||||
opacity: visible ? 1 : 0,
|
||||
// The buttons centred on the title, like the standard
|
||||
// design's top bar.
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
@@ -196,7 +223,8 @@ class UoscControlsState extends State<UoscControls> {
|
||||
: Align(alignment: AlignmentDirectional.centerStart, child: widget.topLeft),
|
||||
),
|
||||
),
|
||||
?widget.top,
|
||||
if (widget.top != null)
|
||||
Padding(padding: const EdgeInsets.fromLTRB(0, 12, 6, 0), child: widget.top),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -217,7 +245,7 @@ class UoscControlsState extends State<UoscControls> {
|
||||
duration: fade,
|
||||
opacity: visible ? 1 : 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(6, 0, 6, 4),
|
||||
padding: const EdgeInsets.fromLTRB(6, 0, 6, UoscControls.rowBottomPadding),
|
||||
child: SizedBox(
|
||||
height: UoscControls.buttonRowHeight,
|
||||
child: Row(children: [...widget.left, const Spacer(), ...widget.right]),
|
||||
|
||||
@@ -4,9 +4,7 @@ import 'package:flutter/material.dart';
|
||||
// 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.
|
||||
/// The active menu row.
|
||||
static const fill = Color(0xFF4C6A8E);
|
||||
/// Menus and tooltips.
|
||||
static const panel = Color(0xF0101010);
|
||||
@@ -51,8 +49,8 @@ class UoscTooltip extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// A small dark box with one line of text (the timeline's times, the
|
||||
/// hover chapter title).
|
||||
/// A small dark box with one line of text (the timeline's hover time and
|
||||
/// chapter title).
|
||||
class UoscBox extends StatelessWidget {
|
||||
final String text;
|
||||
final Color color;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -6,6 +7,66 @@ import '../../playback/media_marks.dart';
|
||||
import 'controls.dart';
|
||||
import 'style.dart';
|
||||
|
||||
// A port of uosc's timeline (`elements/Timeline.lua`, defaults from
|
||||
// `uosc.conf` / `main.lua`), style `line`, scale 1: a translucent black
|
||||
// band, the position a thin white line (no filled progress), the
|
||||
// uncached part hatched, chapter diamonds on the top edge, elapsed time
|
||||
// left and the time remaining right, the buffered seconds next to the
|
||||
// position while under the threshold, and a tooltip with the time (and
|
||||
// the chapter title) under the mouse.
|
||||
|
||||
/// uosc's defaults at scale 1.
|
||||
abstract final class UoscTimelineSpec {
|
||||
/// `timeline_size`: the band's height when expanded.
|
||||
static const size = 40.0;
|
||||
|
||||
/// `timeline_border`: band above the line, also the diamonds' border.
|
||||
static const border = 1.0;
|
||||
|
||||
/// `timeline_line_width`: the position line.
|
||||
static const lineWidth = 2.0;
|
||||
|
||||
/// `progress_size`: what is left when the controls hide.
|
||||
static const progressSize = 2.0;
|
||||
|
||||
/// `progress_line_width`: the position line once minimized.
|
||||
static const progressLineWidth = 20.0;
|
||||
|
||||
/// floor(min((size + 60) * 0.2, size * 0.96)).
|
||||
static const fontSize = 20.0;
|
||||
|
||||
/// max(floor((size - fontSize) / 2.5), 4): the times' inset.
|
||||
static const spacing = 8.0;
|
||||
|
||||
/// `text_border`: the outline of text drawn on the band.
|
||||
static const textBorder = 1.2;
|
||||
|
||||
/// `opacity.timeline` (the band) and `opacity.chapters` (diamond fill).
|
||||
static const opacity = 0.9;
|
||||
static const chaptersOpacity = 0.8;
|
||||
|
||||
/// Gap between the band and its tooltips, and between tooltips.
|
||||
static const tooltipGap = 2.0;
|
||||
|
||||
/// `buffered_time_threshold`: the cache label shows below this.
|
||||
static const bufferedThreshold = Duration(seconds: 60);
|
||||
|
||||
/// max((size + border) / 10, 3): diamond radius cap.
|
||||
static const chapterSize = (size + border) / 10;
|
||||
}
|
||||
|
||||
/// uosc's `format_time(seconds, duration)`: mpv's "HH:MM:SS", trimmed to
|
||||
/// "MM:SS" for media under an hour and "SS" under a minute; negative
|
||||
/// values keep their minus ("-01:47:30").
|
||||
String uoscClock(Duration d, Duration max) {
|
||||
final neg = d.isNegative && d.inSeconds != 0;
|
||||
final t = d.abs();
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
final full = '${two(t.inHours)}:${two(t.inMinutes % 60)}:${two(t.inSeconds % 60)}';
|
||||
final trim = max < const Duration(minutes: 1) ? 6 : (max < const Duration(hours: 1) ? 3 : 0);
|
||||
return '${neg ? '-' : ''}${full.substring(trim)}';
|
||||
}
|
||||
|
||||
/// Fraction of [total] at [d], 0..1 (0 without a duration).
|
||||
double timelineFraction(Duration d, Duration total) {
|
||||
final t = total.inMicroseconds;
|
||||
@@ -13,36 +74,104 @@ double timelineFraction(Duration d, Duration total) {
|
||||
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);
|
||||
/// Where the uncached part starts (it runs to the end), as a fraction;
|
||||
/// null when nothing known is uncached. We know only the end of mpv's
|
||||
/// cache ahead ([buffered]); without it (0) uosc draws no ranges either,
|
||||
/// and a cache that reaches the end (within uosc's 0.5 s) leaves none.
|
||||
double? uncachedFrom({required Duration position, required Duration buffered, required Duration duration}) {
|
||||
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
|
||||
final from = buffered > position ? buffered : position;
|
||||
if (duration - from <= const Duration(milliseconds: 500)) return null;
|
||||
return timelineFraction(from, duration);
|
||||
}
|
||||
|
||||
/// The 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 cache label next to the position ("12s"): the seconds buffered
|
||||
/// ahead, shown while under [UoscTimelineSpec.bufferedThreshold] and the
|
||||
/// cache does not reach the end (uosc drops it at eof-cached).
|
||||
String? cacheLabel({required Duration position, required Duration buffered, required Duration duration}) {
|
||||
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
|
||||
if (duration - buffered <= const Duration(milliseconds: 500)) return null;
|
||||
final ahead = buffered > position ? buffered - position : Duration.zero;
|
||||
if (ahead >= UoscTimelineSpec.bufferedThreshold) return null;
|
||||
return '${(ahead.inMilliseconds / 1000).round()}s';
|
||||
}
|
||||
|
||||
/// The 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.
|
||||
/// The band's geometry at one size, as uosc lays it out: the position
|
||||
/// line takes [lineWidth] pixels, and times map around it (`t2x`) so no
|
||||
/// time lies inside the line.
|
||||
class UoscTimelineGeometry {
|
||||
final double width;
|
||||
|
||||
/// The foreground height (without the top border).
|
||||
final double size;
|
||||
|
||||
/// The playing position, 0..1.
|
||||
final double progress;
|
||||
final double lineWidth;
|
||||
const UoscTimelineGeometry({
|
||||
required this.width,
|
||||
required this.size,
|
||||
required this.progress,
|
||||
this.lineWidth = UoscTimelineSpec.lineWidth,
|
||||
});
|
||||
|
||||
/// [size] while the band animates between hidden (0) and shown (1)
|
||||
/// (`get_effective_size`).
|
||||
static double sizeAt(double visibility) =>
|
||||
UoscTimelineSpec.progressSize +
|
||||
((UoscTimelineSpec.size - UoscTimelineSpec.progressSize) * visibility).ceilToDouble();
|
||||
|
||||
/// The position line's width at [size]: it grows to
|
||||
/// [UoscTimelineSpec.progressLineWidth] over the last eighth of the
|
||||
/// collapse.
|
||||
static double lineWidthAt(double size) {
|
||||
const p = UoscTimelineSpec.progressSize;
|
||||
final minimized = 1 - math.min((size - p) / ((UoscTimelineSpec.size - p) / 8), 1.0);
|
||||
return UoscTimelineSpec.lineWidth + (UoscTimelineSpec.progressLineWidth - UoscTimelineSpec.lineWidth) * minimized;
|
||||
}
|
||||
|
||||
double get height => size + UoscTimelineSpec.border;
|
||||
|
||||
/// Top of the foreground (below the border).
|
||||
double get top => UoscTimelineSpec.border;
|
||||
double get lineLeft => ((width - lineWidth) * progress).roundToDouble();
|
||||
double get lineRight => ((width - lineWidth) * progress + lineWidth).roundToDouble();
|
||||
|
||||
/// x of [fraction] (uosc's `t2x`): past the position, shifted over the line.
|
||||
double x(double fraction) {
|
||||
final lw = lineWidth - 1;
|
||||
final at = 0.5 + (width - lw - 1) * fraction;
|
||||
return fraction <= progress ? at : at + lw;
|
||||
}
|
||||
|
||||
/// The fraction at [x] (uosc's `get_time_at_x`): x on the line is the
|
||||
/// position.
|
||||
double fractionAt(double x) {
|
||||
const lw = UoscTimelineSpec.lineWidth - 1;
|
||||
final timeWidth = width - lw - 1;
|
||||
if (timeWidth <= 0) return 0;
|
||||
final a = timeWidth * progress, b = a + lw;
|
||||
var at = x - 0.5;
|
||||
if (at > b) {
|
||||
at -= lw;
|
||||
} else if (at > a) {
|
||||
at = a;
|
||||
}
|
||||
return (at / timeWidth).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// Chapter diamond radius at this size; doubled while hovered.
|
||||
double get diamondRadius => math.min(math.max(1.0, size * 0.8), UoscTimelineSpec.chapterSize);
|
||||
}
|
||||
|
||||
/// The uosc timeline. [expanded] false (the controls hidden) shrinks it,
|
||||
/// animated, to the progress line. Seeks ([onSeek]) on press, on every
|
||||
/// drag move and on release; a press on a hovered chapter diamond jumps
|
||||
/// to that chapter. The session sends those through the user-seek path.
|
||||
class UoscTimeline extends StatefulWidget {
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
|
||||
/// End of the cached range ([PlaybackController.buffered]).
|
||||
final Duration buffered;
|
||||
final List<MediaChapter> chapters;
|
||||
@@ -58,24 +187,32 @@ class UoscTimeline extends StatefulWidget {
|
||||
required this.onSeek,
|
||||
});
|
||||
|
||||
static const height = 30.0;
|
||||
static const collapsedHeight = 2.0;
|
||||
static const height = UoscTimelineSpec.size + UoscTimelineSpec.border;
|
||||
static const collapsedHeight = UoscTimelineSpec.progressSize + UoscTimelineSpec.border;
|
||||
|
||||
/// uosc's `animation_duration`.
|
||||
static const animation = Duration(milliseconds: 100);
|
||||
|
||||
@override
|
||||
State<UoscTimeline> createState() => _UoscTimelineState();
|
||||
}
|
||||
|
||||
class _UoscTimelineState extends State<UoscTimeline> {
|
||||
double? _hoverX;
|
||||
Offset? _hover;
|
||||
bool _dragging = false;
|
||||
double _dragFraction = 0;
|
||||
Duration? _lastSent;
|
||||
// A released seek, shown until the position catches up (else the line
|
||||
// jumps back to the old position for a moment).
|
||||
double? _pending;
|
||||
Timer? _pendingTimer;
|
||||
|
||||
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
|
||||
|
||||
void _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 _send(Duration d) {
|
||||
if (d == _lastSent) return;
|
||||
_lastSent = d;
|
||||
widget.onSeek(d);
|
||||
}
|
||||
|
||||
void _drag(bool on) {
|
||||
@@ -84,204 +221,425 @@ class _UoscTimelineState extends State<UoscTimeline> {
|
||||
UoscControls.maybeOf(context)?.hold(on);
|
||||
}
|
||||
|
||||
void _settle(double fraction) {
|
||||
_pending = fraction;
|
||||
_pendingTimer?.cancel();
|
||||
_pendingTimer = Timer(const Duration(seconds: 2), () {
|
||||
if (mounted) setState(() => _pending = null);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(UoscTimeline old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (!widget.expanded && old.expanded) _hoverX = null;
|
||||
if (!widget.expanded && old.expanded) _hover = null;
|
||||
final p = _pending;
|
||||
if (p != null && widget.duration > Duration.zero) {
|
||||
final off = (widget.position - _at(p)).abs();
|
||||
if (off < const Duration(milliseconds: 1500)) {
|
||||
_pending = null;
|
||||
_pendingTimer?.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pendingTimer?.cancel();
|
||||
// A drag cut short by a rebuild must not keep the controls up.
|
||||
if (_dragging) UoscControls.maybeOf(context)?.hold(false);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// The chapter whose diamond is within twice its radius of [at].
|
||||
int? _hoveredChapter(UoscTimelineGeometry geo, List<MediaChapter> marks, Offset? at) {
|
||||
if (at == null || _dragging) return null;
|
||||
final reach = geo.diamondRadius * 2;
|
||||
int? best;
|
||||
var bestD = double.infinity;
|
||||
for (var i = 0; i < marks.length; i++) {
|
||||
final cx = geo.x(timelineFraction(marks[i].start, widget.duration));
|
||||
final d = (Offset(cx, 0) - at).distance;
|
||||
if (d <= reach && d < bestD) {
|
||||
best = i;
|
||||
bestD = d;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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,
|
||||
final font = DefaultTextStyle.of(context).style.fontFamily ?? Theme.of(context).textTheme.bodyMedium?.fontFamily;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: widget.expanded ? 1 : 0),
|
||||
duration: UoscTimeline.animation,
|
||||
builder: (context, visibility, _) => _band(width, visibility, font),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _band(double width, double visibility, String? font) {
|
||||
final expanded = widget.expanded;
|
||||
final duration = widget.duration;
|
||||
final size = UoscTimelineGeometry.sizeAt(visibility);
|
||||
final progress = _dragging ? _dragFraction : (_pending ?? timelineFraction(widget.position, duration));
|
||||
final shown = _at(progress);
|
||||
final geo = UoscTimelineGeometry(
|
||||
width: width,
|
||||
size: size,
|
||||
progress: progress,
|
||||
lineWidth: UoscTimelineGeometry.lineWidthAt(size),
|
||||
);
|
||||
final marks = duration > Duration.zero ? widget.chapters : const <MediaChapter>[];
|
||||
final hover = expanded ? _hover : null;
|
||||
final hovered = _hoveredChapter(geo, marks, hover);
|
||||
final hoverFraction = hovered != null
|
||||
? timelineFraction(marks[hovered].start, duration)
|
||||
: (hover == null || duration <= Duration.zero ? null : geo.fractionAt(hover.dx));
|
||||
final hoverAt = hoverFraction == null ? null : _at(hoverFraction);
|
||||
final cursorX = hovered != null ? geo.x(hoverFraction!) : hover?.dx;
|
||||
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
|
||||
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
|
||||
// Text fades out just before it would overflow the shrinking band.
|
||||
const hideBelow = UoscTimelineSpec.fontSize * 0.8;
|
||||
final textOpacity = (size - hideBelow).clamp(0.0, hideBelow / 2) / (hideBelow / 2);
|
||||
final radius = geo.diamondRadius;
|
||||
final timestampGap = UoscTimelineSpec.tooltipGap + (hovered != null ? radius * 2 : radius).roundToDouble();
|
||||
final painter = UoscTimelinePainter(
|
||||
geometry: geo,
|
||||
visibility: visibility,
|
||||
uncached: uncachedFrom(position: widget.position, buffered: widget.buffered, duration: duration),
|
||||
chapters: [for (final c in marks) timelineFraction(c.start, duration)],
|
||||
hoveredChapter: hovered,
|
||||
hoverX: cursorX,
|
||||
textOpacity: textOpacity,
|
||||
elapsed: duration > Duration.zero ? uoscClock(shown, duration) : null,
|
||||
remaining: duration > Duration.zero ? uoscClock(shown - duration, duration) : null,
|
||||
cache: cacheLabel(position: widget.position, buffered: widget.buffered, duration: duration),
|
||||
fontFamily: font,
|
||||
);
|
||||
final bar = SizedBox(
|
||||
key: const ValueKey('uosc-timeline'),
|
||||
width: width,
|
||||
height: geo.height,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: RepaintBoundary(child: CustomPaint(painter: painter)),
|
||||
),
|
||||
if (hoverAt != null && cursorX != null)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: geo.height + timestampGap,
|
||||
height: 160,
|
||||
child: IgnorePointer(
|
||||
child: CustomMultiChildLayout(
|
||||
delegate: _TooltipLayout(x: cursorX),
|
||||
children: [
|
||||
LayoutId(
|
||||
id: #time,
|
||||
child: UoscBox(
|
||||
uoscClock(hoverAt, duration),
|
||||
key: const ValueKey('uosc-hover-time'),
|
||||
color: Colors.black,
|
||||
style: _tipStyle,
|
||||
),
|
||||
),
|
||||
if (hoverTitle.isNotEmpty)
|
||||
LayoutId(
|
||||
id: #chapter,
|
||||
child: UoscBox(
|
||||
hoverTitle,
|
||||
key: const ValueKey('uosc-hover-chapter'),
|
||||
color: Colors.black,
|
||||
style: _tipStyle.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
);
|
||||
});
|
||||
],
|
||||
),
|
||||
);
|
||||
if (!expanded) return IgnorePointer(child: bar);
|
||||
Offset clampTo(Offset p) => Offset(p.dx.clamp(0.0, width), p.dy);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (e) => setState(() => _hover = clampTo(e.localPosition)),
|
||||
onHover: (e) => setState(() => _hover = clampTo(e.localPosition)),
|
||||
onExit: (_) {
|
||||
if (!_dragging) setState(() => _hover = null);
|
||||
},
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: (e) {
|
||||
if (duration <= Duration.zero || width <= 0) return;
|
||||
final at = clampTo(e.localPosition);
|
||||
final chapter = _hoveredChapter(geo, marks, at);
|
||||
_lastSent = null;
|
||||
if (chapter != null) {
|
||||
final f = timelineFraction(marks[chapter].start, duration);
|
||||
_settle(f);
|
||||
_send(marks[chapter].start);
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
_pending = null;
|
||||
_dragFraction = geo.fractionAt(at.dx);
|
||||
_drag(true);
|
||||
_send(_at(_dragFraction));
|
||||
},
|
||||
onPointerMove: (e) {
|
||||
if (!_dragging) return;
|
||||
final at = clampTo(e.localPosition);
|
||||
setState(() {
|
||||
_hover = at;
|
||||
_dragFraction = geo.fractionAt(at.dx);
|
||||
});
|
||||
_send(_at(_dragFraction));
|
||||
},
|
||||
onPointerUp: (e) {
|
||||
if (!_dragging) return;
|
||||
_send(_at(_dragFraction));
|
||||
_settle(_dragFraction);
|
||||
final p = e.localPosition;
|
||||
if (p.dx < 0 || p.dx > width || p.dy < 0 || p.dy > geo.height) _hover = null;
|
||||
_drag(false);
|
||||
},
|
||||
onPointerCancel: (_) => _drag(false),
|
||||
child: bar,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static const _tipStyle = TextStyle(color: UoscStyle.text, fontSize: UoscTimelineSpec.fontSize, height: 1);
|
||||
}
|
||||
|
||||
/// Background, cached range (hatched, or a faint band when collapsed),
|
||||
/// the played part and the hover line.
|
||||
/// The hover tooltips: the time centred on [x], the chapter title over
|
||||
/// it; each kept [UoscTimelineSpec.tooltipGap] inside the band's ends.
|
||||
class _TooltipLayout extends MultiChildLayoutDelegate {
|
||||
final double x;
|
||||
_TooltipLayout({required this.x});
|
||||
|
||||
@override
|
||||
void performLayout(Size size) {
|
||||
const gap = UoscTimelineSpec.tooltipGap;
|
||||
double left(double w) => (x - w / 2).clamp(gap, math.max(gap, size.width - gap - w));
|
||||
final loose = BoxConstraints.loose(size);
|
||||
final time = layoutChild(#time, loose);
|
||||
final timeTop = size.height - time.height;
|
||||
positionChild(#time, Offset(left(time.width), timeTop));
|
||||
if (hasChild(#chapter)) {
|
||||
final chapter = layoutChild(#chapter, loose);
|
||||
positionChild(#chapter, Offset(left(chapter.width), timeTop - gap - chapter.height));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_TooltipLayout old) => old.x != x;
|
||||
}
|
||||
|
||||
/// Paints the band as uosc does: background, the position line, the
|
||||
/// uncached hatching, chapter diamonds, the times, the hover line.
|
||||
class UoscTimelinePainter extends CustomPainter {
|
||||
final double played;
|
||||
final Rect? Function(Size size) cache;
|
||||
/// What [cache] depends on (repaint when it changes).
|
||||
final Object cacheKey;
|
||||
final UoscTimelineGeometry geometry;
|
||||
|
||||
/// 0 hidden .. 1 shown (picks the hatching texture and its strength).
|
||||
final double visibility;
|
||||
|
||||
/// Start of the uncached part (to the end), as a fraction.
|
||||
final double? uncached;
|
||||
|
||||
/// Chapter starts, as fractions.
|
||||
final List<double> chapters;
|
||||
final int? hoveredChapter;
|
||||
|
||||
/// The hover line (the mouse, or the hovered chapter).
|
||||
final double? hoverX;
|
||||
final bool hatched;
|
||||
final double textOpacity;
|
||||
final String? elapsed;
|
||||
final String? remaining;
|
||||
final String? cache;
|
||||
final String? fontFamily;
|
||||
const UoscTimelinePainter({
|
||||
required this.played,
|
||||
required this.cache,
|
||||
required this.cacheKey,
|
||||
required this.geometry,
|
||||
this.visibility = 1,
|
||||
this.uncached,
|
||||
this.chapters = const [],
|
||||
this.hoveredChapter,
|
||||
this.hoverX,
|
||||
this.hatched = true,
|
||||
this.textOpacity = 1,
|
||||
this.elapsed,
|
||||
this.remaining,
|
||||
this.cache,
|
||||
this.fontFamily,
|
||||
});
|
||||
|
||||
double get played => geometry.progress;
|
||||
|
||||
@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 g = geometry;
|
||||
final w = size.width, bottom = size.height, top = g.top;
|
||||
final line = Rect.fromLTRB(g.lineLeft, top, g.lineRight, bottom);
|
||||
// Background (`opacity.timeline`) around the position line.
|
||||
final bg = Path()
|
||||
..fillType = PathFillType.evenOdd
|
||||
..addRect(Offset.zero & size)
|
||||
..addRect(line);
|
||||
canvas.drawPath(bg, Paint()..color = Colors.black.withValues(alpha: UoscTimelineSpec.opacity));
|
||||
canvas.drawRect(line, Paint()..color = Colors.white);
|
||||
_hatch(canvas, w, top, bottom);
|
||||
_chapters(canvas);
|
||||
if (textOpacity > 0) _times(canvas, w, top, bottom, line);
|
||||
final x = hoverX;
|
||||
if (x != null) {
|
||||
canvas.drawRect(Rect.fromLTWH(x - 0.5, 0, 1, size.height), Paint()..color = Colors.white70);
|
||||
final onLine = line.left - 0.5 < x && x < line.right + 0.5;
|
||||
canvas.drawRect(
|
||||
Rect.fromLTRB(x - 0.5, top, x + 0.5, bottom),
|
||||
Paint()..color = (onLine ? Colors.black : Colors.white).withValues(alpha: 0.33),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// uosc's texture "b" (shown) or "a" (hidden) at 80 px: "/" stripes
|
||||
/// 1.6 px thick, white then black shifted by size / 24 (or / 28).
|
||||
void _hatch(Canvas canvas, double w, double top, double bottom) {
|
||||
final from = uncached;
|
||||
if (from == null) return;
|
||||
final ax = from * (geometry.width) < 0.5 ? 0.0 : geometry.x(from).floorToDouble();
|
||||
if (ax >= w) return;
|
||||
final shown = visibility > 0;
|
||||
const tile = 80.0, unit = tile / 100;
|
||||
final period = (shown ? 100 / 12 : 100 / 14) * unit;
|
||||
final phase = shown ? 0.0 : 5.727 * unit;
|
||||
final offset = tile / (shown ? 24 : 28);
|
||||
final h = bottom - top;
|
||||
canvas.save();
|
||||
canvas.clipRect(Rect.fromLTRB(ax, top, w, bottom));
|
||||
void stripes(Color color, double opacity, double anchor) {
|
||||
final paint = Paint()
|
||||
..color = color.withValues(alpha: opacity)
|
||||
..strokeWidth = 2 * unit;
|
||||
// x + (y - bottom) = anchor + phase + k * period.
|
||||
final base = anchor + phase;
|
||||
final k0 = ((ax - h - base) / period).floor() - 1;
|
||||
for (var k = k0; base + k * period <= w + 1; k++) {
|
||||
final c = base + k * period;
|
||||
canvas.drawLine(Offset(c, bottom), Offset(c + h, top), paint);
|
||||
}
|
||||
}
|
||||
|
||||
stripes(Colors.white, 0.4 - 0.2 * visibility, 0);
|
||||
stripes(Colors.black, 0.6 - 0.2 * visibility, offset);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
void _chapters(Canvas canvas) {
|
||||
final g = geometry;
|
||||
final r = g.diamondRadius;
|
||||
if (r <= 0) return;
|
||||
void diamond(double fraction, double radius) {
|
||||
final c = Offset(g.x(fraction), 0);
|
||||
Path shape(double r) => Path()
|
||||
..moveTo(c.dx - r, c.dy)
|
||||
..lineTo(c.dx, c.dy - r)
|
||||
..lineTo(c.dx + r, c.dy)
|
||||
..lineTo(c.dx, c.dy + r)
|
||||
..close();
|
||||
final inner = shape(radius);
|
||||
// A 1 px border outside the fill (`\bord`), the fill translucent.
|
||||
final outer = shape(radius + UoscTimelineSpec.border * math.sqrt2);
|
||||
canvas.drawPath(Path.combine(PathOperation.difference, outer, inner), Paint()..color = Colors.black);
|
||||
canvas.drawPath(inner, Paint()..color = Colors.white.withValues(alpha: UoscTimelineSpec.chaptersOpacity));
|
||||
}
|
||||
|
||||
for (var i = 0; i < chapters.length; i++) {
|
||||
if (i != hoveredChapter) diamond(chapters[i], r);
|
||||
}
|
||||
final h = hoveredChapter;
|
||||
if (h != null && h < chapters.length) diamond(chapters[h], r * 2);
|
||||
}
|
||||
|
||||
TextPainter _text(String s, double fontSize, Color color, {Paint? foreground}) => TextPainter(
|
||||
text: TextSpan(
|
||||
text: s,
|
||||
style: TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: fontSize,
|
||||
height: 1,
|
||||
color: foreground == null ? color : null,
|
||||
foreground: foreground,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
|
||||
/// Text on the band: white with a black outline, inverted where it
|
||||
/// crosses the position line (uosc clips one copy to each).
|
||||
void _label(Canvas canvas, String s, double fontSize, double opacity, Offset at, Rect line, Size size) {
|
||||
void copy(Color fill, Color outline) {
|
||||
_text(
|
||||
s,
|
||||
fontSize,
|
||||
fill,
|
||||
foreground: Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = UoscTimelineSpec.textBorder * 2
|
||||
..strokeJoin = StrokeJoin.round
|
||||
..color = outline.withValues(alpha: opacity),
|
||||
).paint(canvas, at);
|
||||
_text(s, fontSize, fill.withValues(alpha: opacity)).paint(canvas, at);
|
||||
}
|
||||
|
||||
canvas.save();
|
||||
canvas.clipPath(
|
||||
Path()
|
||||
..fillType = PathFillType.evenOdd
|
||||
..addRect(Rect.fromLTWH(-20, -20, size.width + 40, size.height + 40))
|
||||
..addRect(line),
|
||||
);
|
||||
copy(Colors.white, Colors.black);
|
||||
canvas.restore();
|
||||
canvas.save();
|
||||
canvas.clipRect(line);
|
||||
copy(Colors.black, Colors.white);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
void _times(Canvas canvas, double w, double top, double bottom, Rect line) {
|
||||
const fs = UoscTimelineSpec.fontSize, spacing = UoscTimelineSpec.spacing;
|
||||
final size = Size(w, bottom);
|
||||
final cy = top + (bottom - top) / 2;
|
||||
final e = elapsed, r = remaining;
|
||||
final ew = e == null ? 0.0 : _text(e, fs, Colors.white).width;
|
||||
final rw = r == null ? 0.0 : _text(r, fs, Colors.white).width;
|
||||
final c = cache;
|
||||
if (c != null) {
|
||||
const cfs = fs * 0.8, margin = 5.0;
|
||||
final cw = _text(c, cfs, Colors.white).width;
|
||||
var x = line.right + margin;
|
||||
final minX = spacing + margin + ew, maxX = w - spacing - margin - rw;
|
||||
if (x < minX) {
|
||||
x = minX;
|
||||
} else if (x + cw > maxX) {
|
||||
x = maxX - cw;
|
||||
}
|
||||
_label(canvas, c, cfs, textOpacity * 0.6, Offset(x, cy - cfs / 2), line, size);
|
||||
}
|
||||
if (e != null) _label(canvas, e, fs, textOpacity, Offset(spacing, cy - fs / 2), line, size);
|
||||
if (r != null) _label(canvas, r, fs, textOpacity, Offset(w - spacing - rw, cy - fs / 2), line, size);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(UoscTimelinePainter old) =>
|
||||
old.played != played || old.cacheKey != cacheKey || old.hoverX != hoverX || old.hatched != hatched;
|
||||
bool shouldRepaint(UoscTimelinePainter old) => true;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../volume.dart' show maxVolume;
|
||||
|
||||
// uosc's volume slider (`elements/Volume.lua`) as it flashes on a volume
|
||||
// change: a vertical band at the right edge, black at `opacity.slider`
|
||||
// with a white gauge from the bottom, notches at 100 %, the value at the
|
||||
// bottom (black on the gauge, white off it).
|
||||
|
||||
/// The flashed uosc volume slider; size it with [UoscVolumeSlider.width]
|
||||
/// and the height uosc gives it (see [UoscVolumeSlider.heightFor]).
|
||||
class UoscVolumeSlider extends StatelessWidget {
|
||||
/// 0..[maxVolume].
|
||||
final double volume;
|
||||
const UoscVolumeSlider({super.key, required this.volume});
|
||||
|
||||
/// `volume_size`.
|
||||
static const width = 40.0;
|
||||
|
||||
/// uosc: min(size * 8, 80 % of the room between the bars), less the
|
||||
/// mute button (one [width]) below it.
|
||||
static double heightFor(double room) => math.min(width * 8, room * 0.8) - width;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => CustomPaint(
|
||||
key: const ValueKey('uosc-volume-flash'),
|
||||
painter: UoscVolumePainter(
|
||||
volume: volume,
|
||||
fontFamily: DefaultTextStyle.of(context).style.fontFamily ?? Theme.of(context).textTheme.bodyMedium?.fontFamily,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class UoscVolumePainter extends CustomPainter {
|
||||
final double volume;
|
||||
final String? fontFamily;
|
||||
const UoscVolumePainter({required this.volume, this.fontFamily});
|
||||
|
||||
/// `volume_border`, `border_radius`.
|
||||
static const border = 1.0;
|
||||
static const radius = 4.0;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final w = size.width, h = size.height;
|
||||
if (w <= 0 || h <= w) return;
|
||||
final v = volume.clamp(0.0, maxVolume);
|
||||
final nudgeY = h - (h * (100 / maxVolume)).roundToDouble();
|
||||
final nudge = (w * 0.18).roundToDouble();
|
||||
final spacing = (w * 0.2).roundToDouble();
|
||||
Path notches(double inset) {
|
||||
final n = nudge - inset;
|
||||
return Path()
|
||||
..moveTo(0, nudgeY - nudge)
|
||||
..lineTo(inset + n, nudgeY)
|
||||
..lineTo(0, nudgeY + nudge)
|
||||
..close()
|
||||
..moveTo(w, nudgeY - nudge)
|
||||
..lineTo(w - inset - n, nudgeY)
|
||||
..lineTo(w, nudgeY + nudge)
|
||||
..close();
|
||||
}
|
||||
|
||||
final bg = RRect.fromRectAndRadius(Offset.zero & size, const Radius.circular(radius + border));
|
||||
final volumeY = border + (h - border * 2) * (1 - math.min(v / maxVolume, 1));
|
||||
final gauge = RRect.fromRectAndRadius(
|
||||
Rect.fromLTRB(border, math.min(volumeY, h - border), w - border, h - border),
|
||||
const Radius.circular(radius),
|
||||
);
|
||||
// The notches cut into both.
|
||||
canvas.save();
|
||||
canvas.clipPath(
|
||||
Path()
|
||||
..fillType = PathFillType.evenOdd
|
||||
..addRect(Offset.zero & size)
|
||||
..addPath(notches(0), Offset.zero),
|
||||
);
|
||||
canvas.drawPath(
|
||||
Path()
|
||||
..fillType = PathFillType.evenOdd
|
||||
..addRRect(bg)
|
||||
..addRRect(gauge),
|
||||
Paint()..color = Colors.black.withValues(alpha: 0.9),
|
||||
);
|
||||
canvas.restore();
|
||||
canvas.save();
|
||||
canvas.clipPath(
|
||||
Path()
|
||||
..fillType = PathFillType.evenOdd
|
||||
..addRect(Offset.zero & size)
|
||||
..addPath(notches(border), Offset.zero),
|
||||
);
|
||||
canvas.drawRRect(gauge, Paint()..color = Colors.white);
|
||||
canvas.restore();
|
||||
|
||||
// round(volume * 10) / 10, as Lua prints it ("85", "85.5").
|
||||
final tenths = (v * 10).round();
|
||||
final text = tenths % 10 == 0 ? '${tenths ~/ 10}' : (tenths / 10).toString();
|
||||
final fontSize = (w * 0.6 - text.length * (w / 20)).roundToDouble();
|
||||
TextPainter label(Color color) => TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(fontFamily: fontFamily, fontSize: fontSize, height: 1, color: color),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
final dark = label(Colors.black), light = label(Colors.white);
|
||||
final at = Offset((w - dark.width) / 2, h - spacing - dark.height);
|
||||
canvas.save();
|
||||
canvas.clipRRect(gauge);
|
||||
dark.paint(canvas, at);
|
||||
canvas.restore();
|
||||
canvas.save();
|
||||
canvas.clipPath(
|
||||
Path()
|
||||
..fillType = PathFillType.evenOdd
|
||||
..addRect(Offset.zero & size)
|
||||
..addRRect(gauge),
|
||||
);
|
||||
light.paint(canvas, at);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(UoscVolumePainter old) => old.volume != volume || old.fontFamily != fontFamily;
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
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 +14,174 @@ 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});
|
||||
|
||||
/// uosc's `flash_duration`.
|
||||
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 standard design's volume readout for [VolumeFlash]: 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(
|
||||
v <= 0 ? Icons.volume_off : (v < 50 ? Icons.volume_down : Icons.volume_up),
|
||||
size: 20,
|
||||
color: v > 100 ? boostColor : Colors.white,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
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,22 +230,25 @@ 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()
|
||||
..color = trackColor
|
||||
..strokeWidth = 1.5);
|
||||
canvas.drawLine(
|
||||
Offset(full, y - 5),
|
||||
Offset(full, y + 5),
|
||||
Paint()
|
||||
..color = trackColor
|
||||
..strokeWidth = 1.5,
|
||||
);
|
||||
if (volume > 0) canvas.drawLine(Offset(0, y), Offset(x(volume.clamp(0, 100)), y), line..color = color);
|
||||
if (volume > 100) canvas.drawLine(Offset(full, y), Offset(x(volume), y), line..color = boostColor);
|
||||
canvas.drawCircle(Offset(x(volume), y), 5, Paint()..color = volume > 100 ? boostColor : color);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_VolumePainter old) =>
|
||||
old.volume != volume || old.color != color || old.trackColor != trackColor;
|
||||
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 %.
|
||||
/// wheel steps [volumeStep].
|
||||
class VolumeButton extends StatefulWidget {
|
||||
final Player player;
|
||||
final FocusNode? focusNode;
|
||||
@@ -87,6 +259,7 @@ class VolumeButton extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _VolumeButtonState extends State<VolumeButton> {
|
||||
final _steps = WheelSteps();
|
||||
bool _hover = false;
|
||||
double _beforeMute = 100;
|
||||
|
||||
@@ -102,11 +275,9 @@ class _VolumeButtonState extends State<VolumeButton> {
|
||||
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));
|
||||
}
|
||||
},
|
||||
// Claimed here, so the player's own wheel handler skips it.
|
||||
onPointerSignal: (e) =>
|
||||
_steps.handle(e, (step) => player.setVolume(stepVolume(player.state.volume, step * volumeStep))),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
@@ -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(
|
||||
key: ValueKey(path),
|
||||
path: path,
|
||||
state: _stateOf(path),
|
||||
thumbs: _thumbs,
|
||||
onTap: _tapOf(path),
|
||||
onRemove: () => _store.removeRecent(path),
|
||||
);
|
||||
return _menuable(
|
||||
path,
|
||||
_RecentRow(
|
||||
key: ValueKey(path),
|
||||
path: path,
|
||||
state: _stateOf(path),
|
||||
thumbs: _thumbs,
|
||||
onTap: _tapOf(path),
|
||||
onRemove: () => _store.removeRecent(path),
|
||||
));
|
||||
},
|
||||
),
|
||||
];
|
||||
@@ -582,16 +617,18 @@ class _FilesPageState extends State<FilesPage> {
|
||||
onTap: () => setState(() => _expanded.contains(id) ? _expanded.remove(id) : _expanded.add(id)),
|
||||
);
|
||||
}
|
||||
return FileRow(
|
||||
key: ValueKey(v.path),
|
||||
path: v.path,
|
||||
title: v.parsed.label.isEmpty ? v.parsed.series : v.parsed.label,
|
||||
subtitle: v.name,
|
||||
size: formatSize(v.size),
|
||||
state: _stateOf(v.path),
|
||||
thumbs: _thumbs,
|
||||
onTap: _tapOf(v.path),
|
||||
);
|
||||
return _menuable(
|
||||
v.path,
|
||||
FileRow(
|
||||
key: ValueKey(v.path),
|
||||
path: v.path,
|
||||
title: v.parsed.label.isEmpty ? v.parsed.series : v.parsed.label,
|
||||
subtitle: v.name,
|
||||
size: formatSize(v.size),
|
||||
state: _stateOf(v.path),
|
||||
thumbs: _thumbs,
|
||||
onTap: _tapOf(v.path),
|
||||
));
|
||||
},
|
||||
),
|
||||
// Everything else (movies, clips) as a thumbnail grid.
|
||||
@@ -620,13 +657,15 @@ class _FilesPageState extends State<FilesPage> {
|
||||
itemCount: loose.length,
|
||||
itemBuilder: (context, i) {
|
||||
final v = loose[i];
|
||||
return VideoCard(
|
||||
key: ValueKey(v.path),
|
||||
video: v,
|
||||
state: _stateOf(v.path),
|
||||
thumbs: _thumbs,
|
||||
onTap: _tapOf(v.path),
|
||||
);
|
||||
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,17 +464,13 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
||||
children: [
|
||||
const Expanded(child: Text('Episodes', style: TextStyle(fontWeight: FontWeight.w600))),
|
||||
if (seasons.length > 1)
|
||||
DropdownButton<String>(
|
||||
key: const ValueKey('details-season'),
|
||||
value: seasonId,
|
||||
isDense: true,
|
||||
underline: const SizedBox.shrink(),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
items: [
|
||||
for (final s in seasons)
|
||||
DropdownMenuItem(value: s.id, child: Text(s.name, overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
onChanged: (id) => setState(() => _seasonId = id),
|
||||
Flexible(
|
||||
child: AppSelect<String?>(
|
||||
key: const ValueKey('details-season'),
|
||||
value: seasonId,
|
||||
items: [for (final s in seasons) (s.id, s.name)],
|
||||
onChanged: (id) => setState(() => _seasonId = id),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ffi' show Int8;
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart' show listEquals;
|
||||
import 'package:ffi/ffi.dart' show StringUtf8Pointer, malloc;
|
||||
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';
|
||||
@@ -14,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.
|
||||
@@ -39,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 = '';
|
||||
@@ -58,12 +57,25 @@ 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;
|
||||
|
||||
/// The current media failed to load (the HTTP error when there was one,
|
||||
/// e.g. a CDN's 403), at most once per [openUrl].
|
||||
void Function(String error)? onLoadError;
|
||||
|
||||
// Per opened media: the last HTTP error ffmpeg logged, and whether a
|
||||
// load error was reported.
|
||||
String? _httpError;
|
||||
bool _loadErrorSent = false;
|
||||
|
||||
/// mpv's log level for this app. media_kit drains mpv's log on the UI
|
||||
/// thread one line at a time, so a chatty level (ffmpeg retrying a
|
||||
/// refused request logs every attempt) can starve the UI: warnings and
|
||||
/// errors only, except while the video output starts ([video]), when
|
||||
/// the render-mode probe needs mpv's verbose GL init lines.
|
||||
static const logLevel = 'warn';
|
||||
|
||||
/// Points mpv's yt-dlp hook at [path] (the app's managed copy) for web
|
||||
/// page links, at most 1080p, once per player; its cookies
|
||||
/// (`ytdl-raw-options`) whenever they changed.
|
||||
@@ -71,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');
|
||||
@@ -90,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!;
|
||||
|
||||
@@ -162,6 +172,7 @@ class PlaybackController {
|
||||
final existing = _video;
|
||||
if (existing != null) return existing;
|
||||
final hwdec = _hwdecOverride;
|
||||
_requestLog(player, 'v'); // back to [logLevel] by _detectRenderMode
|
||||
// media_kit's video output sets hwdec itself on attach ('auto' unless
|
||||
// configured), which used to override SYNC_RELAY_HWDEC.
|
||||
final video = _video = VideoController(
|
||||
@@ -180,17 +191,19 @@ class PlaybackController {
|
||||
return AppSettings.instance.hwdec.peek();
|
||||
}
|
||||
|
||||
SyncPlayer _newPlayer({required int bufferSize, required MPVLogLevel logLevel}) => SyncPlayer(
|
||||
configuration: PlayerConfiguration(
|
||||
bufferSize: bufferSize,
|
||||
libass: true,
|
||||
vo: Platform.environment['SYNC_RELAY_VO'],
|
||||
logLevel: logLevel,
|
||||
))
|
||||
..onUserSeek = (d) => onUserSeek?.call(d);
|
||||
|
||||
Future<void> init() async {
|
||||
if (_player != null) return;
|
||||
_player = SyncPlayer(
|
||||
configuration: PlayerConfiguration(
|
||||
bufferSize: maxBytes,
|
||||
libass: true,
|
||||
vo: Platform.environment['SYNC_RELAY_VO'],
|
||||
// Verbose: the render-mode probe needs mpv's GL init lines.
|
||||
logLevel: MPVLogLevel.v,
|
||||
))
|
||||
..onUserSeek = (d) => onUserSeek?.call(d);
|
||||
_player = _newPlayer(bufferSize: maxBytes, logLevel: MPVLogLevel.warn); // see [logLevel]
|
||||
_held = false;
|
||||
_shaders = '';
|
||||
renderMode.value = RenderMode.unknown;
|
||||
shaderNotice.value = null;
|
||||
@@ -199,8 +212,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;
|
||||
}
|
||||
@@ -218,9 +229,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)) {
|
||||
@@ -233,18 +244,39 @@ 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, on);
|
||||
}
|
||||
|
||||
/// ffmpeg's HTTP options (mpv `stream-lavf-o`, comma form): reconnect
|
||||
/// after a dropped connection, also on a streamed (growing) relay
|
||||
/// response, and a server error (5xx) while connecting; a refused request
|
||||
/// (4xx) fails at once. At most 3 tries per read, 4 s apart at most.
|
||||
static const streamLavfOptions = 'reconnect_streamed=1,reconnect_on_http_error=5xx,'
|
||||
'reconnect_delay_max=4,reconnect_max_retries=3';
|
||||
|
||||
// mpv_request_log_messages on [p]'s handle (media_kit has no API for it).
|
||||
static void _requestLog(Player p, String level) {
|
||||
try {
|
||||
final dynamic platform = p.platform;
|
||||
final name = level.toNativeUtf8();
|
||||
try {
|
||||
platform.mpv.mpv_request_log_messages(platform.ctx, name.cast<Int8>());
|
||||
} finally {
|
||||
malloc.free(name);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('mpv log level $level not set: $e');
|
||||
}
|
||||
await _pin('stream-lavf-o',
|
||||
'reconnect_streamed=1,reconnect_on_http_error=1,reconnect_delay_max=4');
|
||||
}
|
||||
|
||||
/// Puts the saved shader choice (Settings, Ctrl+0..6, shader button) on
|
||||
@@ -329,9 +361,29 @@ 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) onYtdlError?.call(text);
|
||||
if (text.isNotEmpty) {
|
||||
_loadErrorSent = true;
|
||||
onYtdlError?.call(text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// ffmpeg logs through one mpv instance per process (the first one
|
||||
// alive), so the HTTP status is a bonus, not a given.
|
||||
if (log.prefix == 'ffmpeg') {
|
||||
final http = RegExp(r'HTTP error \d{3}.*').firstMatch(log.text);
|
||||
if (http != null) _httpError = http[0]!.trim();
|
||||
return;
|
||||
}
|
||||
// Nothing playable opened (a track of mpv's yt-dlp EDL refused, ...).
|
||||
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;
|
||||
}
|
||||
if (log.prefix != 'libmpv_render') return;
|
||||
@@ -353,6 +405,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;
|
||||
@@ -364,10 +442,12 @@ class PlaybackController {
|
||||
Future<void> _detectRenderMode(Player p, VideoController video) async {
|
||||
try {
|
||||
await video.platform.future;
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
} catch (_) {
|
||||
return;
|
||||
} finally {
|
||||
if (identical(p, _player)) _requestLog(p, logLevel);
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
if (!identical(p, _player) || renderMode.peek() != RenderMode.unknown) {
|
||||
return;
|
||||
}
|
||||
@@ -378,84 +458,46 @@ 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;
|
||||
chapters.value = const [];
|
||||
tracks.value = const [];
|
||||
buffered.value = Duration.zero;
|
||||
// An empty map, not null: media_kit caches headers per URI.
|
||||
await player.open(Media(url, httpHeaders: headers ?? const {}), play: false);
|
||||
return false;
|
||||
} catch (e, st) {
|
||||
debugPrint('open ${Uri.tryParse(url)?.replace(query: '') ?? 'media'} failed: $e\n$st');
|
||||
rethrow;
|
||||
@@ -624,10 +666,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) {
|
||||
@@ -636,6 +677,7 @@ class PlaybackController {
|
||||
_timeSubs.clear();
|
||||
_player = null;
|
||||
_video = null;
|
||||
_held = false;
|
||||
alive.value = false;
|
||||
_clearMarks();
|
||||
await _settleUi();
|
||||
@@ -658,6 +700,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 {
|
||||
@@ -690,3 +972,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);
|
||||
f.catchError((Object _) {
|
||||
_probes.remove(url);
|
||||
return LinkProbe.file(Uri(), -1);
|
||||
});
|
||||
final until = now.add(keep);
|
||||
if (hit == null) {
|
||||
_probes[url] = (at: now, until: until, f: f);
|
||||
f.catchError((Object _) {
|
||||
if (_probes[url]?.f == f) _probes.remove(url);
|
||||
return LinkProbe.file(Uri(), -1);
|
||||
});
|
||||
} else {
|
||||
f.then<void>((_) {
|
||||
_probes[url] = (at: now, until: until, f: f);
|
||||
}, onError: (Object _) {});
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
/// [url] no longer needs keeping longer: back to the usual 10 minutes.
|
||||
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']),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -248,7 +255,6 @@ class AnimeFlowScheduleItem {
|
||||
final int? anidbId;
|
||||
final int? tvdbId;
|
||||
final String? jellyfinSeriesId;
|
||||
final bool followed;
|
||||
|
||||
const AnimeFlowScheduleItem({
|
||||
required this.episode,
|
||||
@@ -257,7 +263,6 @@ class AnimeFlowScheduleItem {
|
||||
this.anidbId,
|
||||
this.tvdbId,
|
||||
this.jellyfinSeriesId,
|
||||
this.followed = false,
|
||||
});
|
||||
|
||||
static AnimeFlowScheduleItem fromJson(Map j) => AnimeFlowScheduleItem(
|
||||
@@ -267,7 +272,6 @@ class AnimeFlowScheduleItem {
|
||||
anidbId: _int(j['anidb_id']),
|
||||
tvdbId: _int(j['tvdb_id']),
|
||||
jellyfinSeriesId: _str(j['jellyfin_series_id']),
|
||||
followed: j['followed'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -296,7 +300,6 @@ class AnimeFlowSeries {
|
||||
final String? jellyfinSeriesId;
|
||||
final SeriesState state;
|
||||
final bool monitored;
|
||||
final bool followed;
|
||||
final int episodesTotal;
|
||||
final int episodesAvailable;
|
||||
final DateTime? nextAirDate;
|
||||
@@ -312,7 +315,6 @@ class AnimeFlowSeries {
|
||||
this.jellyfinSeriesId,
|
||||
this.state = SeriesState.releasing,
|
||||
this.monitored = false,
|
||||
this.followed = false,
|
||||
this.episodesTotal = 0,
|
||||
this.episodesAvailable = 0,
|
||||
this.nextAirDate,
|
||||
@@ -328,7 +330,6 @@ class AnimeFlowSeries {
|
||||
jellyfinSeriesId: _str(j['jellyfin_series_id']),
|
||||
state: SeriesState.parse(j['state']),
|
||||
monitored: j['monitored'] == true,
|
||||
followed: j['followed'] == true,
|
||||
episodesTotal: _int(j['episodes_total']) ?? 0,
|
||||
episodesAvailable: _int(j['episodes_available']) ?? 0,
|
||||
nextAirDate: _time(j['next_air_date']),
|
||||
@@ -684,10 +685,6 @@ class AnimeFlowClient {
|
||||
return AnimeFlowSeries.fromJson(j is Map ? j : const {});
|
||||
}
|
||||
|
||||
/// Follow (or unfollow) a series [view].
|
||||
Future<void> setFollowed(int seriesId, bool followed) =>
|
||||
_call(followed ? 'PUT' : 'DELETE', '/series/$seriesId/follow');
|
||||
|
||||
/// Titles that can be requested [request].
|
||||
Future<List<AnimeFlowSearchHit>> search(String q) async => [
|
||||
for (final h in _items(await _call('GET', '/search', query: {'q': q}))) AnimeFlowSearchHit.fromJson(h),
|
||||
|
||||
@@ -12,14 +12,25 @@ 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,
|
||||
/// Not tracked by AnimeFlow, but the series is on Jellyfin (`/library`):
|
||||
/// the episode may be there; Put it up finds out.
|
||||
inLibrary;
|
||||
/// Not tracked by AnimeFlow, the series is on Jellyfin (`/library`) and
|
||||
/// so is the episode ([confirmOnJellyfin]; [matchSchedule] alone sets
|
||||
/// it from the series).
|
||||
inLibrary,
|
||||
/// Not tracked, the series is on Jellyfin, the episode aired but is not
|
||||
/// there yet.
|
||||
notOnJellyfin,
|
||||
/// Not tracked, the series is on Jellyfin, the episode aired; whether
|
||||
/// it is there is unknown (no linked account, lookup failed).
|
||||
aired;
|
||||
|
||||
static ScheduleStatus of(EpisodeStatus s) => switch (s) {
|
||||
EpisodeStatus.available => available,
|
||||
@@ -37,17 +48,26 @@ 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;
|
||||
final bool followed;
|
||||
/// AnimeFlow's series id (follow, requests).
|
||||
/// AnimeFlow's series id (requests).
|
||||
final int? seriesId;
|
||||
/// The AnimeFlow season of the matched episode (requests).
|
||||
/// The AnimeFlow season of the matched episode (requests); untracked
|
||||
/// on Jellyfin: the TVDB season of the AniList entry.
|
||||
final int? season;
|
||||
/// 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;
|
||||
|
||||
@@ -56,13 +76,29 @@ class ScheduleEntry {
|
||||
required this.status,
|
||||
this.progress,
|
||||
this.reason,
|
||||
this.lastSearchedAt,
|
||||
this.tracked = false,
|
||||
this.followed = false,
|
||||
this.seriesId,
|
||||
this.season,
|
||||
this.jellyfinEpisode,
|
||||
this.jellyfinSeriesId,
|
||||
this.watch,
|
||||
});
|
||||
|
||||
ScheduleEntry copyWith({ScheduleStatus? status, EpisodeWatch? watch}) => ScheduleEntry(
|
||||
airing: airing,
|
||||
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
|
||||
String toString() => 'ScheduleEntry(${airing.anilistId}#${airing.episode} $status)';
|
||||
}
|
||||
@@ -73,16 +109,63 @@ 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;
|
||||
bool followed;
|
||||
_SeriesRef(this.seriesId, this.jellyfinSeriesId, this.followed);
|
||||
_SeriesRef(this.seriesId, this.jellyfinSeriesId);
|
||||
}
|
||||
|
||||
/// Merges AniList [airings] with AnimeFlow's [schedule] rows and tracked
|
||||
@@ -98,54 +181,66 @@ List<ScheduleEntry> matchSchedule(
|
||||
DateTime? now,
|
||||
}) {
|
||||
final at = now ?? DateTime.now();
|
||||
// AniList id -> (Jellyfin series, its TVDB season) from /library.
|
||||
final lib = <int, (String, int)>{};
|
||||
// AniList id -> (Jellyfin series, its TVDB season, TVDB - AniList
|
||||
// episode offset) from /library.
|
||||
final lib = <int, (String, int, int)>{};
|
||||
for (final item in library) {
|
||||
if (item.jellyfinSeriesId.isEmpty) continue;
|
||||
for (final r in item.anilistMap) {
|
||||
lib[r.anilistId] ??= (item.jellyfinSeriesId, r.season);
|
||||
lib[r.anilistId] ??= (item.jellyfinSeriesId, r.season, r.offset);
|
||||
}
|
||||
for (final e in item.anilistIds.entries) {
|
||||
lib[e.value] ??= (item.jellyfinSeriesId, e.key);
|
||||
lib[e.value] ??= (item.jellyfinSeriesId, e.key, 0);
|
||||
}
|
||||
}
|
||||
final refs = <int, _SeriesRef>{};
|
||||
for (final s in series) {
|
||||
final id = s.anilistId;
|
||||
if (id != null) refs[id] ??= _SeriesRef(s.seriesId, s.jellyfinSeriesId, s.followed);
|
||||
if (id != null) refs[id] ??= _SeriesRef(s.seriesId, s.jellyfinSeriesId);
|
||||
}
|
||||
// AnimeFlow's AniList episode number wins outright: TVDB numbering runs
|
||||
// on across split entries (JoJo S6E4 is "2nd & 3rd STAGE" ep 3), so a TVDB
|
||||
// or absolute number, or an air day, is only a guess for AniList entries
|
||||
// AnimeFlow gives no AniList numbers for.
|
||||
final byAni = <String, AnimeFlowScheduleItem>{};
|
||||
final byEp = <String, AnimeFlowScheduleItem>{};
|
||||
final byAbs = <String, AnimeFlowScheduleItem>{};
|
||||
final byDay = <String, AnimeFlowScheduleItem>{};
|
||||
final numbered = <int>{};
|
||||
for (final it in schedule) {
|
||||
final e = it.episode, id = e.anilistId;
|
||||
if (id == null) continue;
|
||||
final ref = refs[id] ??= _SeriesRef(it.seriesId, it.jellyfinSeriesId, it.followed);
|
||||
if (it.followed) ref.followed = true;
|
||||
// AnimeFlow's AniList episode number first: TVDB numbering can run on
|
||||
// across split cours.
|
||||
if (e.anilistEpisode != null) byEp.putIfAbsent('$id#${e.anilistEpisode}', () => it);
|
||||
refs[id] ??= _SeriesRef(it.seriesId, it.jellyfinSeriesId);
|
||||
if (e.anilistEpisode != null) {
|
||||
numbered.add(id);
|
||||
byAni.putIfAbsent('$id#${e.anilistEpisode}', () => it);
|
||||
}
|
||||
byEp.putIfAbsent('$id#${e.episode}', () => it);
|
||||
if (e.absolute != null) byAbs.putIfAbsent('$id#${e.absolute}', () => it);
|
||||
if (e.airDate != null) byDay.putIfAbsent('$id@${_day(e.airDate!)}', () => it);
|
||||
if (e.airDate != null) byDay.putIfAbsent('$id@${_dayKey(_jstDay(e.airDate!))}', () => it);
|
||||
}
|
||||
return [
|
||||
for (final a in airings)
|
||||
() {
|
||||
final id = a.anilistId;
|
||||
final ref = refs[id];
|
||||
final it = byEp['$id#${a.episode}'] ?? byAbs['$id#${a.episode}'] ?? byDay['$id@${_day(a.airingAt)}'];
|
||||
final it = numbered.contains(id)
|
||||
? byAni['$id#${a.episode}']
|
||||
: 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,
|
||||
followed: it.followed || (ref?.followed ?? false),
|
||||
seriesId: it.seriesId,
|
||||
season: e.season,
|
||||
jellyfinEpisode: e.episode,
|
||||
jellyfinSeriesId: it.jellyfinSeriesId ?? ref?.jellyfinSeriesId ?? lib[id]?.$1,
|
||||
);
|
||||
}
|
||||
@@ -156,6 +251,7 @@ List<ScheduleEntry> matchSchedule(
|
||||
airing: a,
|
||||
status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.inLibrary,
|
||||
season: onJf.$2,
|
||||
jellyfinEpisode: a.episode + onJf.$3,
|
||||
jellyfinSeriesId: onJf.$1,
|
||||
);
|
||||
}
|
||||
@@ -163,7 +259,6 @@ List<ScheduleEntry> matchSchedule(
|
||||
airing: a,
|
||||
status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.missing,
|
||||
tracked: true,
|
||||
followed: ref.followed,
|
||||
seriesId: ref.seriesId,
|
||||
jellyfinSeriesId: ref.jellyfinSeriesId ?? lib[id]?.$1,
|
||||
);
|
||||
@@ -171,6 +266,46 @@ List<ScheduleEntry> matchSchedule(
|
||||
];
|
||||
}
|
||||
|
||||
/// 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 (!needsJellyfinLookup(e))
|
||||
e
|
||||
else
|
||||
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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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.
|
||||
class ScheduleSnapshot {
|
||||
@@ -206,12 +341,16 @@ class ScheduleSnapshot {
|
||||
|
||||
/// Loads AniList and AnimeFlow for a window and merges them.
|
||||
class ScheduleRepository {
|
||||
ScheduleRepository({required this.aniList, this.animeFlow, DateTime Function()? now})
|
||||
ScheduleRepository({required this.aniList, this.animeFlow, this.jellyfinEpisodes, DateTime Function()? now})
|
||||
: _now = now ?? DateTime.now;
|
||||
|
||||
final AniListClient aniList;
|
||||
/// Null = not configured (AniList only).
|
||||
final AnimeFlowClient? animeFlow;
|
||||
/// 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;
|
||||
|
||||
/// Last week's Monday 00:00 local .. 4 weeks later.
|
||||
@@ -236,7 +375,8 @@ class ScheduleRepository {
|
||||
final flow = await flowF;
|
||||
final airings = [...ani.airings]..sort((a, b) => a.airingAt.compareTo(b.airingAt));
|
||||
final entries = flow.error == null
|
||||
? matchSchedule(airings, schedule: flow.schedule, series: flow.series, library: flow.library, now: now)
|
||||
? await _confirm(
|
||||
matchSchedule(airings, schedule: flow.schedule, series: flow.series, library: flow.library, now: now))
|
||||
: [for (final a in airings) untrackedEntry(a, now)];
|
||||
return ScheduleSnapshot(
|
||||
from: start,
|
||||
@@ -249,6 +389,27 @@ class ScheduleRepository {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 (needsJellyfinLookup(e)) e.jellyfinSeriesId!,
|
||||
};
|
||||
final found = <String, List<JellyfinEpisodeRef>>{};
|
||||
if (lookup != null) {
|
||||
await Future.wait([
|
||||
for (final id in ids)
|
||||
lookup(id).then((eps) {
|
||||
if (eps != null) found[id] = eps;
|
||||
}, onError: (_) {}),
|
||||
]);
|
||||
}
|
||||
return confirmOnJellyfin(entries, found);
|
||||
}
|
||||
|
||||
Future<({AnimeFlowMe? me, List<AnimeFlowScheduleItem> schedule, List<AnimeFlowSeries> series, List<AnimeFlowLibraryItem> library, Object? error})>
|
||||
_animeFlow(DateTime from, DateTime to, bool force) async {
|
||||
final c = animeFlow;
|
||||
|
||||
@@ -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
|
||||
@@ -78,7 +73,7 @@ Future<({int season, int episode})> flowNumbering(AnimeFlowClient? client, Sched
|
||||
}
|
||||
}
|
||||
if (ep != null) return (season: ep.season, episode: ep.episode);
|
||||
return (season: e.season ?? 1, episode: e.airing.episode);
|
||||
return (season: e.season ?? 1, episode: e.jellyfinEpisode ?? e.airing.episode);
|
||||
}
|
||||
|
||||
/// The AniDB id of an untracked AniList entry, from AnimeFlow's search:
|
||||
@@ -112,6 +107,37 @@ JellyfinAccount? jellyfinAccountFor(JellyfinAccounts accounts, AnimeFlowClient?
|
||||
return accounts.defaultAccount;
|
||||
}
|
||||
|
||||
/// 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,
|
||||
) async {
|
||||
await accounts.ensureLoaded(validate: false);
|
||||
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 => [
|
||||
for (final x in await client.episodes(jellyfinSeriesId))
|
||||
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
|
||||
/// [ScheduleActionException] (or a client error) when it cannot be found.
|
||||
Future<JellyfinMedia> resolveJellyfinEpisode(JellyfinAccounts accounts, AnimeFlowClient? flow, ScheduleEntry e) async {
|
||||
@@ -134,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,21 +35,55 @@ 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) {
|
||||
ScheduleStatus.available => ('on Jellyfin', Colors.greenAccent, Icons.check_circle),
|
||||
ScheduleStatus.downloading => (
|
||||
e.progress == null ? 'downloading' : 'downloading ${(e.progress! * 100).round()}%',
|
||||
Colors.lightBlueAccent,
|
||||
Icons.downloading,
|
||||
),
|
||||
ScheduleStatus.upcoming => (countdownText(e.airing.airingAt, now), Colors.amber, Icons.schedule),
|
||||
ScheduleStatus.missing => ('missing', Colors.orangeAccent, Icons.report_gmailerrorred),
|
||||
ScheduleStatus.failed => ('failed${e.reason == null ? '' : ' · ${e.reason}'}', Colors.redAccent, Icons.error_outline),
|
||||
ScheduleStatus.unmonitored => ('not monitored', Colors.grey, Icons.visibility_off_outlined),
|
||||
ScheduleStatus.notTracked => ('not in AnimeFlow', Colors.grey, Icons.public),
|
||||
ScheduleStatus.inLibrary => ('on Jellyfin', Colors.greenAccent, Icons.video_library_outlined),
|
||||
};
|
||||
(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()}%',
|
||||
Colors.lightBlueAccent,
|
||||
Icons.downloading,
|
||||
),
|
||||
ScheduleStatus.upcoming => (countdownText(e.airing.airingAt, now), Colors.amber, Icons.schedule),
|
||||
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;
|
||||
@@ -125,7 +159,6 @@ class AiringCard extends StatelessWidget {
|
||||
final ScheduleEntry entry;
|
||||
final DateTime now;
|
||||
final bool compact;
|
||||
final bool followed;
|
||||
final CardAction action;
|
||||
|
||||
/// Why [action] is disabled (no request scope, not on Jellyfin, ...);
|
||||
@@ -136,20 +169,19 @@ class AiringCard extends StatelessWidget {
|
||||
final bool busy;
|
||||
final VoidCallback onAction;
|
||||
|
||||
/// Null hides the bookmark (untracked series).
|
||||
final VoidCallback? onFollow;
|
||||
/// Right-click ([context]: the card): the page's menu; null: none.
|
||||
final void Function(BuildContext context)? onMenu;
|
||||
|
||||
const AiringCard({
|
||||
super.key,
|
||||
required this.entry,
|
||||
required this.now,
|
||||
required this.compact,
|
||||
required this.followed,
|
||||
required this.action,
|
||||
required this.onAction,
|
||||
this.disabledHint,
|
||||
this.busy = false,
|
||||
this.onFollow,
|
||||
this.onMenu,
|
||||
});
|
||||
|
||||
String get _id => '${entry.airing.anilistId}-${entry.airing.episode}';
|
||||
@@ -157,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));
|
||||
@@ -179,27 +261,12 @@ class AiringCard extends StatelessWidget {
|
||||
return disabledHint == null ? b : Tooltip(message: disabledHint!, child: b);
|
||||
}
|
||||
|
||||
Widget? _follow(BuildContext context, double size) {
|
||||
final f = onFollow;
|
||||
if (f == null) return null;
|
||||
return IconButton(
|
||||
key: ValueKey('follow-$_id'),
|
||||
onPressed: f,
|
||||
tooltip: followed ? 'Stop following' : 'Follow',
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: BoxConstraints.tightFor(width: size + 10, height: size + 10),
|
||||
iconSize: size,
|
||||
color: followed ? Theme.of(context).colorScheme.primary : null,
|
||||
icon: Icon(followed ? Icons.bookmark : Icons.bookmark_outline),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final a = entry.airing;
|
||||
final (label, color, icon) = statusBadge(entry, now);
|
||||
final hint = statusHint(entry, now);
|
||||
final time = clockText(a.airingAt);
|
||||
final tintColor = a.color ?? Colors.blueGrey;
|
||||
final tint = DecoratedBox(
|
||||
@@ -211,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(
|
||||
@@ -221,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(
|
||||
@@ -244,7 +323,6 @@ class AiringCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (followed && onFollow == null) Icon(Icons.bookmark, size: 14, color: scheme.primary),
|
||||
if (requestable)
|
||||
Tooltip(
|
||||
message: disabledHint ?? (action == CardAction.request ? 'Request' : 'Request series'),
|
||||
@@ -258,7 +336,7 @@ class AiringCard extends StatelessWidget {
|
||||
)
|
||||
else
|
||||
Tooltip(
|
||||
message: label,
|
||||
message: hint == null ? label : '$label: $hint',
|
||||
child: Icon(icon, size: 16, color: color),
|
||||
),
|
||||
],
|
||||
@@ -268,7 +346,6 @@ class AiringCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
final button = _button(context);
|
||||
final follow = _follow(context, 16);
|
||||
// The cover poster leads: it is what people recognise at a glance.
|
||||
return Material(
|
||||
key: ValueKey('card-$_id'),
|
||||
@@ -277,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(
|
||||
@@ -293,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,
|
||||
@@ -309,6 +387,7 @@ class AiringCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
..._watchMarks(),
|
||||
if (entry.status == ScheduleStatus.downloading)
|
||||
Positioned(
|
||||
left: 0,
|
||||
@@ -338,40 +417,29 @@ class AiringCard extends StatelessWidget {
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.25),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Episode ${a.episode}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
if (follow != null)
|
||||
follow
|
||||
else if (followed)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: Icon(Icons.bookmark, size: 14, color: scheme.primary),
|
||||
),
|
||||
],
|
||||
Text(
|
||||
'Episode ${a.episode}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
const Spacer(),
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: color, fontSize: 12),
|
||||
_hinted(
|
||||
hint,
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: color, fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,10 +16,12 @@ 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, following, all }
|
||||
enum ScheduleFilter { jellyfin, all }
|
||||
|
||||
class SchedulePage extends StatefulWidget {
|
||||
/// Nobody else watches (offline or solo): available episodes say Play
|
||||
@@ -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,9 +83,6 @@ class _SchedulePageState extends State<SchedulePage> {
|
||||
/// The sheet stays built while it slides out.
|
||||
bool _sheetBuilt = false;
|
||||
|
||||
/// Follow toggles not yet confirmed by a reload (series id -> followed).
|
||||
final _followOverride = <int, bool>{};
|
||||
|
||||
/// Requested in this run: "anilistId#episode" or "series:anilistId".
|
||||
final _requested = <String>{};
|
||||
|
||||
@@ -97,21 +102,31 @@ class _SchedulePageState extends State<SchedulePage> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ownsRepo = widget.repository == null;
|
||||
_repo =
|
||||
widget.repository ??
|
||||
ScheduleRepository(
|
||||
aniList: AniListClient(),
|
||||
animeFlow: AnimeFlowClient.fromAccounts(JellyfinAccounts.instance),
|
||||
);
|
||||
_repo = widget.repository ?? _ownRepo();
|
||||
_load();
|
||||
_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() {
|
||||
final flow = AnimeFlowClient.fromAccounts(_accounts);
|
||||
return ScheduleRepository(
|
||||
aniList: AniListClient(),
|
||||
animeFlow: flow,
|
||||
jellyfinEpisodes: (id) => jellyfinScheduleEpisodes(_accounts, flow, id),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_watchedSub?.cancel();
|
||||
_scroll.dispose();
|
||||
if (_ownsRepo) {
|
||||
_repo.aniList.close();
|
||||
@@ -186,11 +201,8 @@ class _SchedulePageState extends State<SchedulePage> {
|
||||
animate ? _scroll.animateTo(x, duration: easeTime, curve: ease) : _scroll.jumpTo(x);
|
||||
}
|
||||
|
||||
bool _followed(ScheduleEntry e) => (e.seriesId == null ? null : _followOverride[e.seriesId]) ?? e.followed;
|
||||
|
||||
bool _shows(ScheduleEntry e) => switch (_filter) {
|
||||
ScheduleFilter.jellyfin => e.tracked || e.onJellyfin,
|
||||
ScheduleFilter.following => _followed(e),
|
||||
ScheduleFilter.all => _niche || e.tracked || e.airing.popularity >= SchedulePage.nicheBelow,
|
||||
};
|
||||
|
||||
@@ -223,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;
|
||||
@@ -268,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;
|
||||
@@ -337,21 +376,6 @@ class _SchedulePageState extends State<SchedulePage> {
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> _toggleFollow(ScheduleEntry e) async {
|
||||
final c = _flow, id = e.seriesId;
|
||||
if (c == null || id == null) return;
|
||||
final next = !_followed(e);
|
||||
setState(() => _followOverride[id] = next);
|
||||
try {
|
||||
await c.setFollowed(id, next);
|
||||
await _load();
|
||||
} catch (err) {
|
||||
_toast(friendlyError(err));
|
||||
} finally {
|
||||
if (mounted) setState(() => _followOverride.remove(id));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _decide(AnimeFlowRequest r, {required bool approve}) async {
|
||||
final c = _flow;
|
||||
if (c == null || _reqBusy.contains(r.id)) return;
|
||||
@@ -389,11 +413,6 @@ class _SchedulePageState extends State<SchedulePage> {
|
||||
icon: Icon(Icons.video_library_outlined),
|
||||
label: Text('On Jellyfin'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ScheduleFilter.following,
|
||||
icon: Icon(Icons.bookmark_outline),
|
||||
label: Text('Following'),
|
||||
),
|
||||
ButtonSegment(value: ScheduleFilter.all, icon: Icon(Icons.public), label: Text('All airing')),
|
||||
],
|
||||
selected: {_filter},
|
||||
@@ -537,12 +556,13 @@ class _SchedulePageState extends State<SchedulePage> {
|
||||
entry: e,
|
||||
now: now,
|
||||
compact: _compact,
|
||||
followed: _followed(e),
|
||||
action: action,
|
||||
disabledHint: hint,
|
||||
busy: busy,
|
||||
onAction: () => _onAction(e, action),
|
||||
onFollow: e.tracked && e.seriesId != null && _flow != null ? () => _toggleFollow(e) : null,
|
||||
onMenu: widget.onAddToQueue != null && (action == CardAction.putUp || action == CardAction.play) && hint == null
|
||||
? (context) => _menu(context, e)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,44 +1,23 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +28,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 +37,85 @@ 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: 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;
|
||||
|
||||
|
||||
@@ -16,7 +16,10 @@ enum LinkRelay { direct, server, me }
|
||||
final class LinkMedia extends PickedMedia {
|
||||
final String url;
|
||||
final LinkRelay relay;
|
||||
const LinkMedia(this.url, {this.relay = LinkRelay.direct});
|
||||
/// Put it up even though this computer's probe found a problem
|
||||
/// (`LinkProbe.problem`): the server or a friend may still open it.
|
||||
final bool anyway;
|
||||
const LinkMedia(this.url, {this.relay = LinkRelay.direct, this.anyway = false});
|
||||
|
||||
@override
|
||||
String get label {
|
||||
@@ -119,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,6 +2,7 @@ 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
|
||||
@@ -36,9 +37,8 @@ class EpisodeSkipButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +70,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: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 12, 12),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 380),
|
||||
child: SizedBox(
|
||||
width: 400,
|
||||
child: Padding(
|
||||
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 +175,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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -525,10 +526,13 @@ class SkipForwardButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +544,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 +557,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),
|
||||
opacity: _hover ? 1 : 0.45,
|
||||
child: GestureDetector(
|
||||
key: widget.tapKey,
|
||||
onTap: widget.onPressed,
|
||||
child: AnimatedOpacity(
|
||||
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.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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(', ')}');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -379,7 +379,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);
|
||||
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 +420,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 +460,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 +475,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 +690,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';
|
||||
@@ -19,6 +20,12 @@ class AvatarInfo {
|
||||
final bool ready, buffering, relaying, stale, host, self;
|
||||
/// Sitting out: dimmed, with an "away" mark.
|
||||
final bool out;
|
||||
/// 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,
|
||||
@@ -31,6 +38,8 @@ class AvatarInfo {
|
||||
this.host = false,
|
||||
this.self = false,
|
||||
this.out = false,
|
||||
this.error = '',
|
||||
this.delivery = '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,7 +87,7 @@ ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) {
|
||||
}
|
||||
|
||||
/// Your own avatar from the profile (also offline).
|
||||
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false}) {
|
||||
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false, String error = ''}) {
|
||||
final p = Profile.instance;
|
||||
final png = p.picture.value;
|
||||
final name = p.name.value;
|
||||
@@ -90,6 +99,7 @@ AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = fal
|
||||
host: host,
|
||||
self: true,
|
||||
out: out,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,13 +108,14 @@ 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,
|
||||
color: self ? Profile.instance.color.value : colorForName(m.name),
|
||||
image: memberAvatarImage(m, coord),
|
||||
ready: m.ready,
|
||||
buffering: !m.stale && m.ready && playing && m.buffered < r!.bufferNeed,
|
||||
buffering: !m.stale && m.ready && m.error.isEmpty && playing && m.buffered < r!.bufferNeed,
|
||||
relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
|
||||
stale: m.stale,
|
||||
// The state marks the host (`members[].host`); your own app also
|
||||
@@ -112,6 +123,8 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
|
||||
host: m.host || (self && coord.hostFile != null),
|
||||
self: self,
|
||||
out: m.out,
|
||||
error: m.error,
|
||||
delivery: delivery == null ? '' : memberDeliveryOf(m, r!, delivery).detail,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,7 +151,7 @@ class PartyWatch extends StatelessWidget {
|
||||
/// Circle avatar with a status ring outside it and a gap in the background
|
||||
/// colour, so overlapping neighbours never hide the status. Crown = host,
|
||||
/// tower = passing the video on, spinner = buffering, dimmed = stale,
|
||||
/// dimmed with a moon = sitting out.
|
||||
/// dimmed with a moon = sitting out, red "!" = cannot play the media.
|
||||
class StatusAvatar extends StatelessWidget {
|
||||
final AvatarInfo info;
|
||||
final double size;
|
||||
@@ -222,6 +235,17 @@ class StatusAvatar extends StatelessWidget {
|
||||
child: Icon(Icons.bedtime, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.blueGrey.shade200),
|
||||
),
|
||||
),
|
||||
if (info.error.isNotEmpty)
|
||||
Positioned(
|
||||
key: const ValueKey('error-mark'),
|
||||
right: -3,
|
||||
top: -3,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(1),
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
||||
child: Icon(Icons.error, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.redAccent.shade200),
|
||||
),
|
||||
),
|
||||
if (badges && info.relaying)
|
||||
Positioned(
|
||||
right: -3,
|
||||
@@ -287,7 +311,12 @@ class _AvatarStackState extends State<AvatarStack> {
|
||||
left: i * step,
|
||||
top: 0,
|
||||
child: Tooltip(
|
||||
message: visible[i].name,
|
||||
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,34 +160,59 @@ class ActivityRow extends StatelessWidget {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final time = _clock(item.at);
|
||||
if (item.action == 'chat') {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
StatusAvatar(info: avatarForName(item.actor), size: 34, badges: false),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
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,
|
||||
children: [
|
||||
StatusAvatar(info: avatarForName(item.actor), size: 34, badges: false),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(item.actor,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700)),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(item.actor,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700)),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(time, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(time, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
|
||||
message,
|
||||
],
|
||||
),
|
||||
SelectableText(item.reason),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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, notAVideoText;
|
||||
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 =>
|
||||
@@ -107,7 +128,112 @@ class LinkPreview {
|
||||
/// "youtube.com · Channel", or the host.
|
||||
final String subtitle;
|
||||
final String? thumbnail;
|
||||
const LinkPreview({required this.title, required this.subtitle, this.thumbnail});
|
||||
/// Why this computer cannot open it, in plain words ([LinkProbe.problem]);
|
||||
/// 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]).
|
||||
@@ -125,24 +251,30 @@ String clockText(double seconds) {
|
||||
}
|
||||
|
||||
/// The preview of [url]: a web page shows yt-dlp's title, uploader,
|
||||
/// duration and thumbnail (or why it is not a video); a media file (by
|
||||
/// name or by probe) its file name and host. Null for text that is not an http(s) link.
|
||||
/// duration and thumbnail; a media file its file name and host; a link
|
||||
/// that does not open, why ([LinkPreview.problem]). Null for text that is
|
||||
/// not an http(s) link.
|
||||
Future<LinkPreview?> previewLink(String url, {LinkProber probe = defaultLinkProber}) async {
|
||||
final uri = Uri.tryParse(url.trim());
|
||||
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null;
|
||||
final host = _host(uri);
|
||||
// A media file name needs no lookup.
|
||||
if (hasMediaExtension(uri)) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
|
||||
// A Jellyfin item goes up as the item (see `mediaForLink`), not the page.
|
||||
if (parseJellyfinLink(url.trim()) != null) return LinkPreview(title: host, subtitle: '$host · Jellyfin');
|
||||
final file = hasMediaExtension(uri);
|
||||
final LinkProbe p;
|
||||
try {
|
||||
p = await probe(url.trim());
|
||||
} catch (e) {
|
||||
return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: '$host · $e');
|
||||
final problem = playErrorText('$e');
|
||||
return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: '$host · $problem', problem: problem);
|
||||
}
|
||||
final info = p.info;
|
||||
if (!p.isPage) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
|
||||
if (info == null) {
|
||||
return LinkPreview(title: p.title ?? host, subtitle: '$host · ${notAVideoText(p.ytDlpError)}');
|
||||
final problem = p.problem;
|
||||
if (info == null || problem != null) {
|
||||
// A media file name that did not answer as one keeps its name.
|
||||
final title = file ? LinkMedia(url.trim()).label : p.title ?? host;
|
||||
return LinkPreview(title: title, subtitle: '$host · ${problem ?? 'Not a video'}', problem: problem);
|
||||
}
|
||||
return LinkPreview(
|
||||
title: p.title ?? host,
|
||||
@@ -155,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;
|
||||
@@ -169,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,
|
||||
@@ -177,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) {
|
||||
@@ -199,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,
|
||||
@@ -221,6 +378,12 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
Timer? _debounce;
|
||||
bool _busy = false;
|
||||
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() {
|
||||
@@ -228,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
|
||||
@@ -241,21 +408,72 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
final url = _c.link.text.trim();
|
||||
if (url == _previewUrl) return;
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 350), () => _preview(url));
|
||||
if (mounted) setState(() => _error = null);
|
||||
// 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;
|
||||
_canForce = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -263,25 +481,43 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _go() async {
|
||||
Future<void> _go({bool anyway = false}) async {
|
||||
final url = _c.link.text.trim();
|
||||
if (url.isEmpty || _busy) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
_canForce = false;
|
||||
});
|
||||
String? error;
|
||||
try {
|
||||
error = await widget.onPutUp(LinkMedia(url, relay: widget.solo ? LinkRelay.direct : _c.linkRelay));
|
||||
} catch (e) {
|
||||
error = '$e';
|
||||
// What the probe found refuses it here, with "Put it up anyway": the
|
||||
// server or a friend (with cookies) may still open it.
|
||||
final problem = anyway ? null : (_previews[url] ?? await previewLink(url, probe: widget.probe))?.problem;
|
||||
String? error = problem;
|
||||
if (problem == null) {
|
||||
try {
|
||||
error = await widget.onPutUp(
|
||||
LinkMedia(url, relay: widget.solo ? LinkRelay.direct : _c.linkRelay, anyway: anyway));
|
||||
} catch (e) {
|
||||
error = '$e';
|
||||
}
|
||||
}
|
||||
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(),
|
||||
)));
|
||||
}
|
||||
@@ -289,9 +525,91 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = error;
|
||||
_canForce = canForce;
|
||||
});
|
||||
}
|
||||
|
||||
/// "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;
|
||||
@@ -326,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'),
|
||||
@@ -362,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),
|
||||
@@ -373,6 +713,14 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
textAlign: TextAlign.center,
|
||||
style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey),
|
||||
),
|
||||
if (_canForce)
|
||||
Center(
|
||||
child: TextButton(
|
||||
key: const ValueKey('link-put-up-anyway'),
|
||||
onPressed: _busy ? null : () => _go(anyway: true),
|
||||
child: Text(solo ? 'Play it anyway' : 'Put it up anyway'),
|
||||
),
|
||||
),
|
||||
],
|
||||
AnimatedSize(
|
||||
duration: easeTime,
|
||||
@@ -381,40 +729,75 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
? const SizedBox(width: double.infinity)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(top: 14),
|
||||
child: _PreviewCard(preview: preview, loading: loading),
|
||||
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'),
|
||||
),
|
||||
];
|
||||
if (box.maxWidth < 560) {
|
||||
return Column(children: [
|
||||
for (final (i, c) in cards.indexed) ...[if (i > 0) const SizedBox(height: 8), c],
|
||||
]);
|
||||
}
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final (i, c) in cards.indexed) ...[
|
||||
if (i > 0) const SizedBox(width: 10),
|
||||
Expanded(child: c),
|
||||
],
|
||||
],
|
||||
),
|
||||
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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final (i, c) in cards.indexed) ...[
|
||||
if (i > 0) const SizedBox(width: 10),
|
||||
Expanded(child: c),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 8),
|
||||
@@ -426,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),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -475,9 +859,9 @@ class _PreviewCard extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
p?.subtitle ?? 'Paste an http(s) address',
|
||||
maxLines: 1,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
style: TextStyle(color: p?.problem != null ? scheme.error : Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -509,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,
|
||||
@@ -517,6 +903,7 @@ class _ModeCard extends StatelessWidget {
|
||||
required this.sub,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -532,23 +919,31 @@ 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: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: Row(
|
||||
crossAxisAlignment: compact ? CrossAxisAlignment.start : CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: selected ? scheme.primary : scheme.onSurfaceVariant),
|
||||
const SizedBox(height: 8),
|
||||
Text(title, style: TextStyle(fontWeight: FontWeight.w600, color: selected ? scheme.primary : null)),
|
||||
const SizedBox(height: 2),
|
||||
Text(sub,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
Icon(icon, size: compact ? 20 : 24, color: selected ? scheme.primary : scheme.onSurfaceVariant),
|
||||
SizedBox(width: compact ? 10 : 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: TextStyle(fontWeight: FontWeight.w600, color: selected ? scheme.primary : null)),
|
||||
const SizedBox(height: 2),
|
||||
Text(sub,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -560,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) {
|
||||
@@ -585,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,
|
||||
@@ -614,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,21 +155,57 @@ 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(
|
||||
listenable: artChanges(),
|
||||
builder: (context, _) => SignalBuilder(
|
||||
builder: (context) => NowPlayingBlock(
|
||||
now: SessionState.instance.coordinator.nowPlaying.value,
|
||||
cover: cover,
|
||||
compact: compact,
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
onSecondaryTap: () => nowPlayingMenu(context),
|
||||
child: ListenableBuilder(
|
||||
listenable: artChanges(),
|
||||
builder: (context, _) => SignalBuilder(
|
||||
builder: (context) => NowPlayingBlock(
|
||||
now: SessionState.instance.coordinator.nowPlaying.value,
|
||||
cover: cover,
|
||||
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
|
||||
@@ -103,6 +102,7 @@ class PartyStrip extends StatelessWidget {
|
||||
info: selfAvatarInfo(
|
||||
ready: self?.ready ?? false,
|
||||
host: online && !solo && coord.hostFile != null,
|
||||
error: online ? coord.playerError.value : '',
|
||||
),
|
||||
size: stripAvatarSize,
|
||||
ring: online,
|
||||
@@ -125,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),
|
||||
),
|
||||
],
|
||||
@@ -365,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(
|
||||
@@ -414,6 +419,8 @@ class StripTab extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
);
|
||||
final tip = tooltip;
|
||||
return tip == null ? tab : Tooltip(message: tip, waitDuration: const Duration(milliseconds: 600), child: tab);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,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,
|
||||
@@ -492,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(
|
||||
@@ -514,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'),
|
||||
),
|
||||
@@ -538,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
|
||||
@@ -553,6 +564,8 @@ class _MemberTile extends StatelessWidget {
|
||||
? 'reconnecting…'
|
||||
: m.out
|
||||
? 'sitting out'
|
||||
: m.error.isNotEmpty
|
||||
? "can't play this: ${m.error}"
|
||||
: info.buffering
|
||||
? 'buffering ${m.buffered.toStringAsFixed(1)} s'
|
||||
: m.ready
|
||||
@@ -571,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':
|
||||
@@ -590,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'),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -629,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,15 +6,17 @@ 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';
|
||||
|
||||
/// What the player's status pill says, most pressing first.
|
||||
enum StatusKind { reconnecting, problem, connecting, finished, loading, you, waiting }
|
||||
enum StatusKind { reconnecting, problem, connecting, finished, loading, you, cannotPlay, waiting }
|
||||
|
||||
/// The pill's button: stop reconnecting, retry, or mark yourself ready
|
||||
/// (the whole pill).
|
||||
enum StatusAction { cancel, retry, ready }
|
||||
/// The pill's button: stop reconnecting, retry (reconnect), reload the
|
||||
/// video (your player could not open it), or mark yourself ready (the
|
||||
/// whole pill).
|
||||
enum StatusAction { cancel, retry, reload, ready }
|
||||
|
||||
/// One status line: [text], a muted [detail], a coloured [tone] for
|
||||
/// problems (warn, error), the members it is about ([who]; [self] for you)
|
||||
@@ -55,18 +56,24 @@ String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1
|
||||
|
||||
/// The player's status from the session's signals; null when there is
|
||||
/// nothing to say (playing, connected, everyone ready). [slowConnect]:
|
||||
/// connecting has taken over 2 s.
|
||||
/// connecting has taken over 2 s. [playerError]: why your player cannot
|
||||
/// open the media (the coordinator's `playerError`).
|
||||
StatusView? playerStatusFor({
|
||||
required HealthStatus health,
|
||||
bool reconnecting = false,
|
||||
StateBroadcast? state,
|
||||
MemberView? self,
|
||||
bool slowConnect = false,
|
||||
String playerError = '',
|
||||
}) {
|
||||
if (reconnecting) {
|
||||
return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
|
||||
tone: HealthSeverity.warn, action: StatusAction.cancel);
|
||||
}
|
||||
if (playerError.isNotEmpty && state?.media != null && !(self?.out ?? false)) {
|
||||
return StatusView(StatusKind.problem, "Can't play this: $playerError",
|
||||
tone: HealthSeverity.error, action: StatusAction.reload, short: "Can't play this");
|
||||
}
|
||||
if (health.severity == HealthSeverity.warn || health.severity == HealthSeverity.error) {
|
||||
return StatusView(StatusKind.problem, _sentence(health.message),
|
||||
tone: health.severity, action: StatusAction.retry);
|
||||
@@ -81,6 +88,12 @@ StatusView? playerStatusFor({
|
||||
if (r.transport == 'finished') {
|
||||
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
|
||||
}
|
||||
// Members whose player cannot open it: the server does not wait for
|
||||
// them; say why instead of "waiting for".
|
||||
final failed = [
|
||||
for (final m in r.members)
|
||||
if (m.error.isNotEmpty && !m.out && (self == null || m.id != self.id)) m,
|
||||
];
|
||||
// As the server holds playback: someone in the ready set short of the
|
||||
// minimum buffer.
|
||||
final need = r.bufferNeed;
|
||||
@@ -92,12 +105,22 @@ StatusView? playerStatusFor({
|
||||
who: [m], self: you, progress: p, detail: '${(p * 100).round()}%');
|
||||
}
|
||||
}
|
||||
if (failed.isNotEmpty) {
|
||||
final f = failed.first;
|
||||
final who = nameList([for (final m in failed) m.name]);
|
||||
final unready = self != null && !self.ready;
|
||||
return StatusView(StatusKind.cannotPlay, "$who can't play this: ${f.error}",
|
||||
who: failed,
|
||||
short: "$who can't play this",
|
||||
detail: unready ? "Press Space when you're ready" : null,
|
||||
action: unready ? StatusAction.ready : null);
|
||||
}
|
||||
if (self != null && !self.ready) {
|
||||
return StatusView(StatusKind.you, "Press Space when you're ready",
|
||||
who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready');
|
||||
}
|
||||
// Members sitting out are never waited for nor counted.
|
||||
final active = [for (final m in r.members) if (!m.stale && !m.out) m];
|
||||
final active = [for (final m in r.members) if (!m.stale && !m.out && m.error.isEmpty) m];
|
||||
final others = [
|
||||
for (final m in active)
|
||||
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
|
||||
@@ -117,6 +140,7 @@ StatusView? playerStatusNow({bool slowConnect = false}) {
|
||||
state: c.serverState.value,
|
||||
self: c.selfMember.value,
|
||||
slowConnect: slowConnect,
|
||||
playerError: c.playerError.value,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,6 +188,8 @@ class _PlayerStatusState extends State<PlayerStatus> {
|
||||
coord.cancelReconnect();
|
||||
case StatusAction.retry:
|
||||
coord.retry();
|
||||
case StatusAction.reload:
|
||||
unawaited(coord.reloadVideo());
|
||||
case StatusAction.ready:
|
||||
coord.toggleReady();
|
||||
}
|
||||
@@ -215,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(
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Widget build(BuildContext context) => ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: AppGlass.pill(child: child),
|
||||
);
|
||||
}
|
||||
|
||||
class _StatusLine extends StatelessWidget {
|
||||
@@ -266,6 +277,7 @@ class _StatusLine extends StatelessWidget {
|
||||
return null;
|
||||
case StatusKind.loading:
|
||||
case StatusKind.you:
|
||||
case StatusKind.cannotPlay:
|
||||
case StatusKind.waiting:
|
||||
final r = coord.serverState.value;
|
||||
final infos = [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
|
||||
@@ -13,7 +14,9 @@ import '../controls/player_settings.dart';
|
||||
import '../controls/uosc/buttons.dart';
|
||||
import '../controls/uosc/controls.dart';
|
||||
import '../controls/uosc/timeline.dart';
|
||||
import '../controls/uosc/volume_flash.dart';
|
||||
import '../controls/volume.dart';
|
||||
import '../queue/queue_ui.dart';
|
||||
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
|
||||
import '../screens/session/jellyfin_widgets.dart';
|
||||
import '../screens/session/player_controls.dart';
|
||||
@@ -23,10 +26,10 @@ import '../shaders/shader_presets.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../widgets/app_keys.dart';
|
||||
import '../widgets/shown_frame_probe.dart';
|
||||
import '../widgets/text_focus.dart';
|
||||
import '../widgets/window_buttons.dart';
|
||||
import 'avatar.dart';
|
||||
import 'mark_problem.dart';
|
||||
import 'motion.dart';
|
||||
import 'now_playing_title.dart';
|
||||
import 'player_status.dart';
|
||||
@@ -93,7 +96,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
// `Video` (it never swaps controllers in place).
|
||||
var _videoGen = -1;
|
||||
var _videoKey = GlobalKey<VideoState>();
|
||||
final _frameProbe = ShownFrameProbe();
|
||||
final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
|
||||
DateTime? _lastKeyAt;
|
||||
late final void Function() _stopShaderWatch;
|
||||
@@ -106,6 +108,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
// does (shown on mount and on mouse movement, hidden 3 s later or on
|
||||
// exit), the uosc ones reporting it.
|
||||
final _controlsShown = ValueNotifier(true);
|
||||
// The mouse on a floating element, or the Up next popover open, keeps
|
||||
// the controls up.
|
||||
final _hold = ControlsHold();
|
||||
// Ticks on each wheel / key volume step: flashes the volume readout.
|
||||
final _volumeFlash = ValueNotifier(0);
|
||||
late final void Function() _stopPopoverWatch;
|
||||
|
||||
SessionCoordinator get _coord => SessionState.instance.coordinator;
|
||||
bool get _full => widget.mode == PlayerMode.full;
|
||||
@@ -114,7 +122,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_coord.jellyfin.ensureLoaded();
|
||||
_coord.playback.shownFrame = _frameProbe.sample;
|
||||
HardwareKeyboard.instance.addHandler(_globalKey);
|
||||
// Tab (AppKeys) reaches the chat through here in fullscreen.
|
||||
AppKeys.addChatToggle(_toggleChat);
|
||||
@@ -126,17 +133,21 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_shaderNoticeSeen = n.id;
|
||||
_toast(n.text);
|
||||
});
|
||||
_stopPopoverWatch = effect(() {
|
||||
_hold.set(#upNextPopover, _coord.queue.popoverOpen.value);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
HardwareKeyboard.instance.removeHandler(_globalKey);
|
||||
AppKeys.removeChatToggle(_toggleChat);
|
||||
final playback = _coord.playback;
|
||||
if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null;
|
||||
_stopShaderWatch();
|
||||
_stopPopoverWatch();
|
||||
_noFocus.dispose();
|
||||
_controlsShown.dispose();
|
||||
_hold.dispose();
|
||||
_volumeFlash.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -156,6 +167,14 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
/// and applied live. A null quality keeps the saved one.
|
||||
void _pickShader(ShaderPreset preset, ShaderQuality? quality) => applyShaderChoice(preset, quality);
|
||||
|
||||
/// One volume step up (+1) or down (-1), from the wheel or ↑ / ↓;
|
||||
/// flashes the design's volume readout.
|
||||
void _stepVolume(int step) {
|
||||
final player = _coord.playback.player;
|
||||
player.setVolume(stepVolume(player.state.volume, step * volumeStep));
|
||||
_volumeFlash.value++;
|
||||
}
|
||||
|
||||
void _toggleChat() {
|
||||
_coord.markInput();
|
||||
widget.onToggleChat();
|
||||
@@ -205,6 +224,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
// mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes.
|
||||
final shift = HardwareKeyboard.instance.isShiftPressed;
|
||||
if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) {
|
||||
// Up next first, then the next episode.
|
||||
if (coord.queue.items.peek().isNotEmpty) {
|
||||
_lastKeyAt = now;
|
||||
coord.queue.skip();
|
||||
return true;
|
||||
}
|
||||
if (coord.nextEpisode.peek() == null) return false;
|
||||
_lastKeyAt = now;
|
||||
coord.playNextEpisode();
|
||||
@@ -227,8 +252,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
return true;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.arrowDown) {
|
||||
final player = coord.playback.player;
|
||||
player.setVolume(stepVolume(player.state.volume, key == LogicalKeyboardKey.arrowUp ? 5 : -5));
|
||||
_stepVolume(key == LogicalKeyboardKey.arrowUp ? 1 : -1);
|
||||
return true;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.arrowRight) {
|
||||
@@ -284,25 +308,28 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
}
|
||||
final VideoControlsBuilder? controls = _full ? _sessionControls : null;
|
||||
final video = widget.videoBuilder?.call(context, controls) ?? _video(controls);
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// The frozen-picture watchdog captures this boundary: the
|
||||
// video (and its controls), not the window around it.
|
||||
RepaintBoundary(
|
||||
child: Stack(fit: StackFit.expand, children: [video, _frameProbe.anchor()]),
|
||||
),
|
||||
if (_full) ...[
|
||||
_status(),
|
||||
_progressLine(),
|
||||
_jellyfinOverlay(),
|
||||
if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()],
|
||||
] else
|
||||
_miniOverlay(),
|
||||
// Sitting out: nothing is loaded; the card covers the
|
||||
// empty player (which stays mounted for joining back).
|
||||
if (_coord.selfOut.value) _sitOut(),
|
||||
],
|
||||
// The wheel anywhere on the full player steps the volume
|
||||
// (lists, menus and popovers under the mouse scroll instead).
|
||||
return VolumeWheel(
|
||||
enabled: _full,
|
||||
onStep: _stepVolume,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
video,
|
||||
if (_full) ...[
|
||||
_progressLine(),
|
||||
_blockedCard(),
|
||||
_actionArea(),
|
||||
if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()],
|
||||
_volumeReadout(),
|
||||
] else
|
||||
_miniOverlay(),
|
||||
// Sitting out: nothing is loaded; the card covers the
|
||||
// empty player (which stays mounted for joining back).
|
||||
if (_coord.selfOut.value) _sitOut(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
@@ -391,6 +418,11 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(child: NowPlayingTitle()),
|
||||
UpNextButton(
|
||||
queue: coord.queue,
|
||||
onPlayNextEpisode: coord.playNextEpisode,
|
||||
look: UpNextLook.standard,
|
||||
hold: _hold),
|
||||
MaterialDesktopCustomButton(
|
||||
icon: const Icon(Icons.video_library_outlined),
|
||||
onPressed: widget.onChangeVideo,
|
||||
@@ -523,6 +555,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
material: (_) => ControlsHoverProbe(
|
||||
shown: _controlsShown,
|
||||
hideAfter: _controlsHide,
|
||||
hold: _hold,
|
||||
child: DoubleClickListener(
|
||||
onDoubleClick: widget.onToggleFullscreen,
|
||||
// Clicks on the bars are button clicks, not the video.
|
||||
@@ -554,11 +587,15 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
});
|
||||
return UoscControls(
|
||||
shown: _controlsShown,
|
||||
holdBy: _hold,
|
||||
onDoubleTap: widget.onToggleFullscreen,
|
||||
topLeft: const NowPlayingTitle(),
|
||||
// Up next is one of the top bar's controls, before chat.
|
||||
top: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
UpNextButton(
|
||||
queue: coord.queue, onPlayNextEpisode: coord.playNextEpisode, look: UpNextLook.uosc, hold: _hold),
|
||||
UoscButton(
|
||||
key: const ValueKey('uosc-chat'),
|
||||
icon: widget.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded,
|
||||
@@ -591,6 +628,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
(title: 'Change video', hint: null, run: widget.onChangeVideo),
|
||||
(title: 'Chat & activity', hint: 'Tab', run: _toggleChat),
|
||||
(title: 'Reload video', hint: 'Ctrl+R', run: _reloadVideo),
|
||||
(title: 'Mark a problem', hint: 'Ctrl+Shift+B', run: () => showMarkProblem(context)),
|
||||
(title: 'Mark everyone ready', hint: null, run: coord.forcePlayAll),
|
||||
(title: 'Fullscreen', hint: 'F', run: widget.onToggleFullscreen),
|
||||
(title: 'Leave server', hint: 'Esc', run: widget.onLeave),
|
||||
@@ -671,77 +709,183 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
);
|
||||
}
|
||||
|
||||
/// The one status pill, bottom centre above the controls.
|
||||
Widget _status() =>
|
||||
const Positioned(left: 16, right: 16, bottom: 124, child: Center(child: PlayerStatus()));
|
||||
/// The side inset every floating element keeps (the bars' own), and
|
||||
/// the gap between them and the controls.
|
||||
static const _edge = 16.0;
|
||||
|
||||
/// Jellyfin: the blocked card, the end-of-episode card (countdown) and
|
||||
/// the "Next episode" pill; for any media the skip pill (Skip OP / ED /
|
||||
/// 1:30) above it ([SkipPillGate]). Outside the auto-hiding controls, so
|
||||
/// they stay up.
|
||||
Widget _jellyfinOverlay() {
|
||||
/// The bottom controls' height in the design in use.
|
||||
static double _bottomControlsHeight(bool uosc) => uosc
|
||||
? UoscControls.buttonRowHeight + UoscControls.rowBottomPadding + UoscTimeline.height
|
||||
: _barHeight + ChapterSeekBar.height;
|
||||
|
||||
/// The volume flash after a wheel or key step, in the design in use:
|
||||
/// uosc's vertical slider at the right edge (centred between the top
|
||||
/// buttons and the bottom controls, as uosc places it), else a pill
|
||||
/// under the top bar.
|
||||
Widget _volumeReadout() {
|
||||
final playback = _coord.playback;
|
||||
Widget withVolume(Widget Function(double v) build) => StreamBuilder<double>(
|
||||
stream: playback.player.stream.volume,
|
||||
initialData: playback.player.state.volume,
|
||||
builder: (context, v) => build(v.data ?? 100),
|
||||
);
|
||||
return SignalBuilder(builder: (context) {
|
||||
if (AppSettings.instance.mpvControls.value) {
|
||||
const top = 12 + UoscControls.buttonRowHeight;
|
||||
final bottom = _bottomControlsHeight(true);
|
||||
return Positioned(
|
||||
right: UoscVolumeSlider.width / 2,
|
||||
top: top,
|
||||
bottom: bottom,
|
||||
width: UoscVolumeSlider.width,
|
||||
child: LayoutBuilder(builder: (context, c) {
|
||||
final h = UoscVolumeSlider.heightFor(c.maxHeight);
|
||||
if (h <= UoscVolumeSlider.width) return const SizedBox.shrink();
|
||||
return Align(
|
||||
// Centred with the mute button's room below, like uosc.
|
||||
alignment: Alignment.center,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: UoscVolumeSlider.width),
|
||||
child: SizedBox(
|
||||
height: h,
|
||||
child: VolumeFlash(
|
||||
trigger: _volumeFlash,
|
||||
builder: (context) => withVolume((v) => UoscVolumeSlider(volume: v)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: _topBarHeight + 8,
|
||||
child: Center(
|
||||
child: VolumeFlash(
|
||||
trigger: _volumeFlash,
|
||||
builder: (context) => withVolume((v) => VolumeReadout(volume: v)),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// The action area: one band above the bottom controls for everything
|
||||
/// that floats there. The status pill (the ready prompt among it)
|
||||
/// centred; at the right edge the end cards, else the action pills
|
||||
/// (Skip OP / ED / 1:30 over Next episode). It rides on the controls:
|
||||
/// just above them while they are up, down near the edge while they are
|
||||
/// hidden. The mouse on any of it counts as the mouse on the controls
|
||||
/// ([ControlsHold]).
|
||||
Widget _actionArea() {
|
||||
return SignalBuilder(builder: (context) {
|
||||
final controls = _bottomControlsHeight(AppSettings.instance.mpvControls.value);
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: _controlsShown,
|
||||
builder: (context, shown, child) => AnimatedPositioned(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
left: _edge,
|
||||
right: _edge,
|
||||
top: 0,
|
||||
bottom: shown ? controls + _edge : _edge,
|
||||
child: child!,
|
||||
),
|
||||
child: CustomMultiChildLayout(
|
||||
delegate: _ActionAreaLayout(gap: _edge),
|
||||
children: [
|
||||
LayoutId(id: _ActionSlot.status, child: ControlsHoldRegion(hold: _hold, child: const PlayerStatus())),
|
||||
LayoutId(id: _ActionSlot.side, child: _actionSide()),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// The action area's right end: the end-of-media card (Up next, else the
|
||||
/// next episode; it stays up), else the pills, shown only with the
|
||||
/// controls ([PillGate]).
|
||||
Widget _actionSide() {
|
||||
final coord = _coord;
|
||||
return Positioned.fill(
|
||||
child: SignalBuilder(
|
||||
builder: (context) {
|
||||
final blocked = coord.jellyfinBlocked.value;
|
||||
final next = coord.nextEpisode.value;
|
||||
final countdown = coord.autoAdvance.value;
|
||||
final finished = coord.serverState.value?.transport == 'finished';
|
||||
final pill = coord.nextPromptVisible.value;
|
||||
final SkipOffer? skip = coord.skipOffer.value;
|
||||
final showNext = next != null && pill;
|
||||
return Stack(
|
||||
children: [
|
||||
if (blocked != null)
|
||||
Center(
|
||||
child: JellyfinBlockedCard(
|
||||
blocked: blocked,
|
||||
onLinkAccount: widget.onOpenIntegrations,
|
||||
onRetry: coord.retryJellyfin,
|
||||
),
|
||||
),
|
||||
if (next != null && (finished || countdown != null))
|
||||
Positioned(
|
||||
right: 24,
|
||||
bottom: 138,
|
||||
child: EndOfEpisodeCard(
|
||||
next: next,
|
||||
countdown: countdown,
|
||||
onPlayNow: coord.playNextEpisode,
|
||||
onCancel: coord.cancelAutoAdvance,
|
||||
),
|
||||
)
|
||||
else if (showNext || skip != null)
|
||||
Positioned(
|
||||
right: 24,
|
||||
bottom: 124,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
spacing: 10,
|
||||
children: [
|
||||
if (skip != null)
|
||||
SkipPillGate(
|
||||
key: const ValueKey('skip-gate'),
|
||||
offer: skip,
|
||||
controlsShown: _controlsShown,
|
||||
child: SkipPill(offer: skip, onPressed: () => coord.skip(skip)),
|
||||
),
|
||||
if (showNext) NextEpisodePill(episode: next, onPressed: coord.playNextEpisode),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
return SignalBuilder(
|
||||
builder: (context) {
|
||||
final next = coord.nextEpisode.value;
|
||||
final countdown = coord.autoAdvance.value;
|
||||
// Up next goes before the next episode.
|
||||
final upNext = coord.queue.countdown.value;
|
||||
final finished = coord.serverState.value?.transport == 'finished';
|
||||
final pill = coord.nextPromptVisible.value;
|
||||
final SkipOffer? skip = coord.skipOffer.value;
|
||||
final showNext = next != null && pill;
|
||||
if (upNext != null && finished) {
|
||||
return ControlsHoldRegion(hold: _hold, child: UpNextCard(queue: coord.queue, countdown: upNext));
|
||||
}
|
||||
// "Clear" in Up next hides the next episode for this media.
|
||||
if (next != null && (finished || countdown != null) && coord.queue.shownContinuation.value.isNotEmpty) {
|
||||
return ControlsHoldRegion(
|
||||
hold: _hold,
|
||||
child: EndOfEpisodeCard(
|
||||
next: next,
|
||||
countdown: countdown,
|
||||
onPlayNow: coord.playNextEpisode,
|
||||
onCancel: coord.cancelAutoAdvance,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
}
|
||||
if (!showNext && skip == null) return const SizedBox.shrink();
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (skip != null)
|
||||
PillGate(
|
||||
key: const ValueKey('skip-gate'),
|
||||
kind: skip.kind,
|
||||
// Skip ED: no announcing, only with the controls.
|
||||
announce: skip.kind != SkipKind.ending,
|
||||
controlsShown: _controlsShown,
|
||||
hold: _hold,
|
||||
child: SkipPill(offer: skip, onPressed: () => coord.skip(skip)),
|
||||
),
|
||||
if (showNext)
|
||||
PillGate(
|
||||
key: const ValueKey('next-gate'),
|
||||
kind: next,
|
||||
controlsShown: _controlsShown,
|
||||
hold: _hold,
|
||||
child: NextEpisodePill(episode: next, onPressed: coord.playNextEpisode),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Jellyfin refused this viewer: the card in the middle (it stays up).
|
||||
Widget _blockedCard() => Positioned.fill(
|
||||
child: SignalBuilder(builder: (context) {
|
||||
final blocked = _coord.jellyfinBlocked.value;
|
||||
if (blocked == null) return const SizedBox.shrink();
|
||||
return Center(
|
||||
child: ControlsHoldRegion(
|
||||
hold: _hold,
|
||||
child: JellyfinBlockedCard(
|
||||
blocked: blocked,
|
||||
onLinkAccount: widget.onOpenIntegrations,
|
||||
onRetry: _coord.retryJellyfin,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
/// Fullscreen hides the strip: while paused the party stays in a corner.
|
||||
Widget _pausedParty() => Positioned(
|
||||
right: 16,
|
||||
top: _barHeight + 8,
|
||||
right: _edge,
|
||||
top: _topBarHeight + 8,
|
||||
child: PartyWatch(builder: (context) {
|
||||
final r = _coord.serverState.value;
|
||||
final paused = r != null && r.transport != 'playing';
|
||||
@@ -755,15 +899,18 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
);
|
||||
|
||||
/// Fullscreen hides the strip and its chat badge: while there is unread
|
||||
/// chat and the controls are down, a small glass chip says so top right.
|
||||
/// chat, the controls are down and the video is not playing, a small
|
||||
/// glass chip says so top right (while it plays, only the controls' chat
|
||||
/// button shows the count: nothing sits on the picture).
|
||||
Widget _unreadChip() => Positioned(
|
||||
right: 16,
|
||||
top: 16,
|
||||
right: _edge,
|
||||
top: _edge,
|
||||
child: IgnorePointer(
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: _controlsShown,
|
||||
builder: (context, shown, _) {
|
||||
final on = widget.unread > 0 && !widget.chatOpen && !shown;
|
||||
builder: (context, shown, _) => SignalBuilder(builder: (context) {
|
||||
final playing = _coord.serverState.value?.transport == 'playing';
|
||||
final on = widget.unread > 0 && !widget.chatOpen && !shown && !playing;
|
||||
return AnimatedOpacity(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
@@ -783,7 +930,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -906,22 +1053,33 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The skip pill's visibility: up for [firstShown] after an offer
|
||||
/// appears (a new kind: OP, ED, 1:30), then only while the controls are
|
||||
/// up or the mouse is on it; gone with the offer (the parent drops it).
|
||||
class SkipPillGate extends StatefulWidget {
|
||||
/// A floating pill's visibility: only while the controls are up (the
|
||||
/// mouse moved) or the mouse is on it. With [announce] it is also up for
|
||||
/// [firstShown] whenever [kind] changes (a new offer: Skip OP, Skip 1:30).
|
||||
/// Gone with the offer (the parent drops it). The mouse on it holds
|
||||
/// [hold]: it counts as the mouse on the controls.
|
||||
class PillGate extends StatefulWidget {
|
||||
static const firstShown = Duration(seconds: 5);
|
||||
final SkipOffer offer;
|
||||
final Object kind;
|
||||
final bool announce;
|
||||
final ValueListenable<bool> controlsShown;
|
||||
final ControlsHold? hold;
|
||||
final Widget child;
|
||||
const SkipPillGate({super.key, required this.offer, required this.controlsShown, required this.child});
|
||||
const PillGate({
|
||||
super.key,
|
||||
required this.kind,
|
||||
this.announce = false,
|
||||
required this.controlsShown,
|
||||
this.hold,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SkipPillGate> createState() => _SkipPillGateState();
|
||||
State<PillGate> createState() => _PillGateState();
|
||||
}
|
||||
|
||||
class _SkipPillGateState extends State<SkipPillGate> {
|
||||
SkipKind? _kind;
|
||||
class _PillGateState extends State<PillGate> {
|
||||
Object? _kind;
|
||||
bool _fresh = false;
|
||||
bool _hover = false;
|
||||
Timer? _timer;
|
||||
@@ -933,9 +1091,9 @@ class _SkipPillGateState extends State<SkipPillGate> {
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SkipPillGate old) {
|
||||
void didUpdateWidget(PillGate old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (widget.offer.kind != _kind) _arm();
|
||||
if (widget.kind != _kind) _arm();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -945,18 +1103,19 @@ class _SkipPillGateState extends State<SkipPillGate> {
|
||||
}
|
||||
|
||||
void _arm() {
|
||||
_kind = widget.offer.kind;
|
||||
_fresh = true;
|
||||
_kind = widget.kind;
|
||||
_timer?.cancel();
|
||||
_timer = Timer(SkipPillGate.firstShown, () {
|
||||
_fresh = widget.announce;
|
||||
if (!_fresh) return;
|
||||
_timer = Timer(PillGate.firstShown, () {
|
||||
if (mounted) setState(() => _fresh = false);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// The pill covers the controls' hover area: on it, they may hide while
|
||||
// it stays.
|
||||
// The pill covers the controls' hover area: on it, [hold] keeps them
|
||||
// up (and it stays while the mouse is on it).
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: widget.controlsShown,
|
||||
builder: (context, shown, child) {
|
||||
@@ -970,11 +1129,34 @@ class _SkipPillGateState extends State<SkipPillGate> {
|
||||
),
|
||||
);
|
||||
},
|
||||
child: widget.child,
|
||||
child: ControlsHoldRegion(hold: widget.hold, child: widget.child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _ActionSlot { status, side }
|
||||
|
||||
/// The action area's two slots, at its bottom: [_ActionSlot.side] at the
|
||||
/// right end, [_ActionSlot.status] centred, moved left only as far as it
|
||||
/// must to keep [gap] from the side (and never wider than what is left).
|
||||
class _ActionAreaLayout extends MultiChildLayoutDelegate {
|
||||
final double gap;
|
||||
_ActionAreaLayout({required this.gap});
|
||||
|
||||
@override
|
||||
void performLayout(Size size) {
|
||||
final side = layoutChild(_ActionSlot.side, BoxConstraints.loose(size));
|
||||
positionChild(_ActionSlot.side, Offset(size.width - side.width, size.height - side.height));
|
||||
final room = side.width > 0 ? size.width - side.width - gap : size.width;
|
||||
final status = layoutChild(_ActionSlot.status, BoxConstraints.loose(Size(math.max(0, room), size.height)));
|
||||
final x = math.min((size.width - status.width) / 2, room - status.width);
|
||||
positionChild(_ActionSlot.status, Offset(math.max(0, x), size.height - status.height));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_ActionAreaLayout old) => old.gap != gap;
|
||||
}
|
||||
|
||||
const _shaderKeys = [
|
||||
LogicalKeyboardKey.digit0,
|
||||
LogicalKeyboardKey.digit1,
|
||||
|
||||
@@ -22,6 +22,7 @@ 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';
|
||||
|
||||
@@ -622,11 +623,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 +728,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 +751,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'),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -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';
|
||||
@@ -379,7 +380,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) {
|
||||
|
||||
@@ -27,9 +27,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();
|
||||
|
||||
@@ -52,6 +71,11 @@ class AppSettings {
|
||||
/// A 2 px progress line at the bottom of the video while the controls
|
||||
/// are hidden (both designs). 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.
|
||||
@@ -109,6 +133,10 @@ class AppSettings {
|
||||
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);
|
||||
@@ -181,6 +209,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({
|
||||
@@ -219,6 +224,7 @@ class SyncClient {
|
||||
double duration = 0,
|
||||
int mediaId = 0,
|
||||
Map<String, bool>? jf,
|
||||
String error = '',
|
||||
}) {
|
||||
final safeBuffered = buffered.isNaN || buffered < 0 ? 0.0 : buffered;
|
||||
_send(
|
||||
@@ -229,6 +235,7 @@ class SyncClient {
|
||||
duration: duration.isFinite && duration > 0 ? duration : 0,
|
||||
mediaId: mediaId,
|
||||
jf: jf,
|
||||
error: error,
|
||||
).toJson(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,503 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' show ImageFilter;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../controls/uosc/controls.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 uosc controls: keep them up while the menu is open.
|
||||
final controls = UoscControls.maybeOf(context);
|
||||
return controls == null ? open() : controls.holdDuring(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;
|
||||
}
|
||||
+93
-55
@@ -5,18 +5,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: android_file_picker
|
||||
sha256: "014c74ab48d452c3252465682375a7fe6ddf56abb908ec361f207a3c4ffb2444"
|
||||
sha256: "537bce9a43bc6fc08670cb68498251acc6eb9134b86a391b43e1f7c863588388"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
version: "2.0.1"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
|
||||
sha256: "3aa20c6b5617b632a1b7198dcca3d88065d37232485b9a6c6c14dc8f5357d279"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.0"
|
||||
version: "4.4.0"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -61,10 +61,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: cfd4f5f575a49c5f10ca856e9846073f1e6c3ee94912377eea5f6cefc5272941
|
||||
sha256: "828110d598123b5ea96c00c9f3c72105bf79f8ee36c20a39b26209ade421ec57"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
version: "2.1.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -97,14 +97,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
cupertino_ui:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cupertino_ui
|
||||
sha256: ea0b8170b40a86262b2fc21ac0625303f52df209dbf6fccc2b98b27eff44f6ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383
|
||||
sha256: f7db8504e715b835f5ebe5fe74604ccd961d504c73389a5d8e81bafc2e2f3612
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.15"
|
||||
version: "0.8.0"
|
||||
dio:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -130,7 +138,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
@@ -149,42 +157,42 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: "9be6aac79508dbcf8dac80a4fb20f27ce6f910837ca27e3670b9295995cc0110"
|
||||
sha256: "98c0b156b6380ba55bc767a14e2e6b3feb246e713ad40092424596fa79005bea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.3.0"
|
||||
version: "13.1.0"
|
||||
file_picker_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_picker_darwin
|
||||
sha256: "59fa5394cfa5b6dc8bf491630cb17077b77b844adbb840df371e7be5355d6cf6"
|
||||
sha256: "3df1dc8e881de9ffd3953c9e482ac5d6e1ffe1e7f7b231b5695543e251ab7918"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
version: "2.1.3"
|
||||
file_picker_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_picker_linux
|
||||
sha256: bd52ff1e0048f29df95f913c55ad2991c72791d93e6c42241912c4bdee946cb9
|
||||
sha256: c6562fc7d426db2fb85ea66fe2627953d2709932df5584ceaacf0aa5e39cf4da
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
version: "2.0.2"
|
||||
file_picker_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_picker_platform_interface
|
||||
sha256: "0355558fd9af6da499d18e333d6b3beb44b0bf19e00933fccd15d18eb8d0e9ca"
|
||||
sha256: bbdc085a6f168e63f147e9ce8988f79fa0800a58e7f25a48f762c60837107ba5
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.0"
|
||||
version: "4.0.0"
|
||||
file_picker_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_picker_web
|
||||
sha256: "935560a9d29fa6f006f2855addebb88e88d438e13627d4cc24187033c106f227"
|
||||
sha256: "32af8262aca907b02b8970b32328b0312db0b42ba6f930be6d27fde179d59b7d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
version: "4.1.0"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -210,10 +218,15 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
version: "6.0.0"
|
||||
flutter_localizations:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -228,27 +241,27 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "v0.1.8"
|
||||
resolved-ref: d2435a06b26d4f94c3c3777e6ef006ac84f4eef4
|
||||
ref: "v0.1.9"
|
||||
resolved-ref: ef96868b87e55935792f392dd5881d49bc672ce5
|
||||
url: "https://git.senkochan.moe/Foxyz/fox_utils.git"
|
||||
source: git
|
||||
version: "0.1.8"
|
||||
version: "0.1.9"
|
||||
hive_ce:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: hive_ce
|
||||
sha256: "8e9980e68643afb1e765d3af32b47996552a64e190d03faf622cea07c1294418"
|
||||
sha256: "85d43209e2b4903a95d65d054407ea9ad4989d17b4d47e71732070d41b427c87"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.19.3"
|
||||
version: "2.20.2"
|
||||
hive_ce_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: hive_ce_flutter
|
||||
sha256: "2677e95a333ff15af43ccd06af7eb7abbf1a4f154ea071997f3de4346cae913a"
|
||||
sha256: "231c5f025a44f4a555b4e7417d1468027cc32d9471b00543bd5bce72464a1009"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.4"
|
||||
version: "2.4.0"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -277,10 +290,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: image
|
||||
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
|
||||
sha256: "0be525eb917b032c7c9c980597c508a255814a858bd8d9abfc9c57e04f3ebf89"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.9.2"
|
||||
version: "4.10.2"
|
||||
intl:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: intl
|
||||
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.3"
|
||||
isolate_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -293,18 +314,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni
|
||||
sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3
|
||||
sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
version: "1.1.0"
|
||||
jni_flutter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni_flutter
|
||||
sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351
|
||||
sha256: "0dd33d67797560f66e961a6053882d4df54bd345bf063b8fe7ea133f7ad8b191"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
version: "1.0.4+1"
|
||||
jni_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -321,6 +342,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:
|
||||
@@ -349,10 +379,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
version: "6.1.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -377,6 +407,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
material_ui:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_ui
|
||||
sha256: "98ab9eda579ecae623e4b376c603035c5def78ce706f735f30bd632af5814695"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
media_kit:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -461,10 +499,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: ad56fd53a78ff6b1472fa59ff2a4e8b8ccabafc586fc263a1dfad0b99b5553e3
|
||||
sha256: "5c80c2ad6e2c0397de5db38c6d653e69616a3397e13aa7633bf39eb668f2041b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.6.0"
|
||||
version: "9.6.2"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -477,10 +515,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481"
|
||||
sha256: "73ef2cd5c11a7548f9b7f6fc2a4f6113de2e64aa2a70de63feede95548822908"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.2.1"
|
||||
version: "10.2.2"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -549,10 +587,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
sha256: "2f1564aadb14215e478563b5406cb80729d022158c01dc50c96013c2f7eb4790"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
version: "7.1.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -634,7 +672,7 @@ packages:
|
||||
source: hosted
|
||||
version: "2.0.6"
|
||||
screen_retriever:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: screen_retriever
|
||||
sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6
|
||||
@@ -748,14 +786,14 @@ packages:
|
||||
path: "../../packages/core"
|
||||
relative: true
|
||||
source: path
|
||||
version: "1.0.1"
|
||||
version: "1.1.2"
|
||||
sync_relay_server:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../packages/server"
|
||||
relative: true
|
||||
source: path
|
||||
version: "1.0.1"
|
||||
version: "1.1.2"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -816,10 +854,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
|
||||
sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
version: "2.4.3"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -832,10 +870,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: wakelock_plus
|
||||
sha256: "22b3e7e937721de70e63c85e7139f4ac781dc22863b9262431a53ac030eb074b"
|
||||
sha256: "9a91139ee876c24a9ebe1484e4b9b7923b07d368566a5e0b2301870b0f5029c8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.8.0"
|
||||
version: "1.8.1"
|
||||
wakelock_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -888,10 +926,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: windows_file_picker
|
||||
sha256: "62e6e6e115231d1d1d71c7b5883b4091ce3e0190e49548dfb0ff8cba8d91ad96"
|
||||
sha256: "18452aa293de3b6961c6d05b0e71b4be7bb90c67635abdd3aa5c4e75dd875072"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
version: "2.0.1"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -904,10 +942,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
|
||||
sha256: "0fe8cc98946def31cbcbc8734aa4067031e6f65ea3a78a508060bfed2200bfeb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
version: "7.1.0"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -917,5 +955,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
sdks:
|
||||
dart: ">=3.13.0-0 <4.0.0"
|
||||
flutter: ">=3.44.0"
|
||||
dart: ">=3.13.0 <4.0.0"
|
||||
flutter: ">=3.47.0"
|
||||
@@ -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.0
|
||||
version: 1.2.0
|
||||
environment:
|
||||
sdk: '>=3.13.0-0 <4.0.0'
|
||||
|
||||
@@ -18,21 +18,28 @@ dependencies:
|
||||
path: ../../packages/core
|
||||
sync_relay_server:
|
||||
path: ../../packages/server
|
||||
file_picker: ^12.3.0
|
||||
file_picker: ^13.1.0
|
||||
hive_ce: ^2.19.3
|
||||
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).
|
||||
fox_utils:
|
||||
git:
|
||||
url: https://git.senkochan.moe/Foxyz/fox_utils.git
|
||||
ref: v0.1.8
|
||||
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
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^5.0.0
|
||||
flutter_lints: ^6.0.0
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -189,6 +189,25 @@ void main() {
|
||||
expect(decodeRecentLinks(jsonEncode([{'url': ''}, {'url': 'https://a/b.mkv'}])).single.title, 'b.mkv');
|
||||
});
|
||||
|
||||
test('kept previews: playable ones only, display text, survive a reload', () async {
|
||||
String? disk;
|
||||
LinkPreviewStore store() => LinkPreviewStore(read: () async => disk, write: (v) async => disk = v);
|
||||
final a = store();
|
||||
await a.put('https://a', const LinkPreview(title: 'A', subtitle: 'a · Chan · 3:07', thumbnail: 'https://i/a.jpg'),
|
||||
at: DateTime.utc(2026, 1, 2));
|
||||
await a.put('https://b', const LinkPreview(title: 'B', subtitle: 'b', problem: 'Not found (404)'));
|
||||
expect(a.lookup('https://b'), isNull);
|
||||
final b = store();
|
||||
await b.ensureLoaded();
|
||||
final kept = b.lookup('https://a')!;
|
||||
expect((kept.preview.title, kept.preview.subtitle, kept.preview.thumbnail),
|
||||
('A', 'a · Chan · 3:07', 'https://i/a.jpg'));
|
||||
expect(kept.at.isAtSameMomentAs(DateTime.utc(2026, 1, 2)), isTrue);
|
||||
// A problem later forgets it.
|
||||
await b.put('https://a', const LinkPreview(title: 'A', subtitle: 'a', problem: 'Not allowed (403)'));
|
||||
expect(b.lookup('https://a'), isNull);
|
||||
});
|
||||
|
||||
test('preview: a page by yt-dlp, a file by its name, else why not', () async {
|
||||
final yt = await previewLink('https://www.youtube.com/watch?v=abc',
|
||||
probe: (url) async => LinkProbe.page(Uri.parse(url),
|
||||
@@ -202,8 +221,11 @@ void main() {
|
||||
|
||||
final page = await previewLink('https://example.com/a',
|
||||
probe: (url) async => LinkProbe.page(Uri.parse(url), pageTitle: 'Example', ytDlpError: 'Unsupported URL'));
|
||||
expect((page!.title, page.subtitle),
|
||||
('Example', "example.com · Not a video (and yt-dlp couldn't read it: Unsupported URL)"));
|
||||
expect((page!.title, page.subtitle, page.problem),
|
||||
('Example', 'example.com · Not a video page yt-dlp knows', 'Not a video page yt-dlp knows'));
|
||||
final refused = await previewLink('https://cdn.example/b.mp4',
|
||||
probe: (url) async => LinkProbe.page(Uri.parse(url), ytDlpError: 'odd', status: 403));
|
||||
expect((refused!.title, refused.problem), ('b.mp4', 'Not allowed (403)'));
|
||||
expect(await previewLink('not a link'), isNull);
|
||||
});
|
||||
|
||||
|
||||
@@ -110,7 +110,9 @@ Future<Harness> pumpLibrary(
|
||||
now: () => fixtureNow,
|
||||
probe: (url) async => url.endsWith('.mkv')
|
||||
? LinkProbe.file(Uri.parse(url), 1)
|
||||
: LinkProbe.page(Uri.parse(url), info: const YtDlpInfo(title: 'A video', uploader: 'Chan')),
|
||||
: url.contains('bilibili')
|
||||
? LinkProbe.page(Uri.parse(url), ytDlpError: 'HTTP Error 412: Precondition Failed')
|
||||
: LinkProbe.page(Uri.parse(url), info: const YtDlpInfo(title: 'A video', uploader: 'Chan')),
|
||||
filesBuilder: (context, {required solo, required relayed, required onPlay, nowPlayingPath}) =>
|
||||
FilesStub(solo: solo, relayed: relayed, onPlay: onPlay),
|
||||
onPutUp: (m) async {
|
||||
@@ -279,9 +281,18 @@ void main() {
|
||||
expect(find.descendant(of: find.byKey(const ValueKey('link-preview')), matching: find.text('A video')),
|
||||
findsOneWidget);
|
||||
expect(find.text('youtube.com · Chan'), findsOneWidget);
|
||||
// Through me by default; the other two behind "Other ways".
|
||||
expect(h.controller.linkRelay, LinkRelay.me);
|
||||
expect(find.byKey(const ValueKey('link-mode-direct')), findsNothing);
|
||||
await tester.tap(find.byKey(const ValueKey('link-other-ways')));
|
||||
await settle(tester);
|
||||
await tester.tap(find.byKey(const ValueKey('link-mode-server')));
|
||||
await settle(tester);
|
||||
expect(h.controller.linkRelay, LinkRelay.server);
|
||||
await tester.tap(find.byKey(const ValueKey('link-mode-me')));
|
||||
await settle(tester);
|
||||
expect(h.controller.linkRelay, LinkRelay.me);
|
||||
expect(find.byKey(const ValueKey('link-mode-direct')), findsOneWidget, reason: 'stays open until closed');
|
||||
expect(find.text(linkRelayHelp(LinkRelay.me)), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('link-put-up')));
|
||||
await settle(tester, 10);
|
||||
@@ -296,6 +307,18 @@ void main() {
|
||||
await settle(tester, 10);
|
||||
expect(h.controller.link.text, url);
|
||||
|
||||
// A link the probe cannot open: the reason, not put up; "anyway" is.
|
||||
const bili = 'https://www.bilibili.com/video/BV1';
|
||||
await tester.enterText(find.byKey(const ValueKey('link-field')), bili);
|
||||
await settle(tester, 10);
|
||||
await tester.tap(find.byKey(const ValueKey('link-put-up')));
|
||||
await settle(tester, 10);
|
||||
expect(find.text(needsCookiesText), findsOneWidget);
|
||||
expect(h.put, hasLength(1));
|
||||
await tester.tap(find.byKey(const ValueKey('link-put-up-anyway')));
|
||||
await settle(tester, 10);
|
||||
expect((h.put.last as LinkMedia).anyway, isTrue);
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
|
||||
@@ -42,4 +42,25 @@ void main() {
|
||||
const NowPlaying(title: 'Show', episode: 'S1 E2 · The Pilot', posterHash: 'aaaa', bannerHash: 'bbbb'));
|
||||
expect(nowPlayingOf({'name': 'Show - 12.mkv'}), const NowPlaying(title: 'Show', episode: 'E12'));
|
||||
});
|
||||
|
||||
test('web videos: name as is and wide from the start, before the thumbnail', () {
|
||||
const title = 'Frieren - 05 | reaction';
|
||||
// Everyone opens it, passed on by a member, fetched by the server.
|
||||
for (final media in [
|
||||
{'name': title, 'url': 'https://www.youtube.com/watch?v=x'},
|
||||
{'name': title, 'relay': {'source': 'm2', 'live': true}},
|
||||
{'name': title, 'relay': {'fetch': true}},
|
||||
]) {
|
||||
expect(nowPlayingOf(media), const NowPlaying(title: title, thumbnail: true));
|
||||
expect(nowPlayingOf({...media, 'art': {'poster': 'aa', 'banner': 'aa'}}),
|
||||
const NowPlaying(title: title, posterHash: 'aa', bannerHash: 'aa', thumbnail: true));
|
||||
}
|
||||
// Files (a link to one, a relayed one) and Jellyfin keep their parsing.
|
||||
expect(nowPlayingOf({'name': 'Show - 12.mkv', 'url': 'https://cdn.example/Show%20-%2012.mkv'}),
|
||||
const NowPlaying(title: 'Show', episode: 'E12'));
|
||||
expect(nowPlayingOf({'name': 'Show - 12.mkv', 'relay': {'source': 'm2'}}),
|
||||
const NowPlaying(title: 'Show', episode: 'E12'));
|
||||
expect(nowPlayingOf({'name': 'Show - 12', 'relay': {}, 'art': {'poster': 'p', 'banner': 'b'}}),
|
||||
const NowPlaying(title: 'Show', episode: 'E12', posterHash: 'p', bannerHash: 'b'));
|
||||
});
|
||||
}
|
||||
@@ -112,6 +112,19 @@ void main() {
|
||||
expect(w.who.map((m) => m.name), ['Bob', 'Mika']);
|
||||
});
|
||||
|
||||
test("can't play: yours with Retry (reload); others' instead of waiting", () {
|
||||
var v = playerStatusFor(health: ok, state: held([me]), self: me, playerError: 'Not allowed (403)')!;
|
||||
expect((v.kind, v.text, v.action), (StatusKind.problem, "Can't play this: Not allowed (403)", StatusAction.reload));
|
||||
final bob = MemberView(id: 'b', name: 'Bob', ready: false, inSet: false, buffered: 0, stale: false,
|
||||
error: 'Not allowed (403)');
|
||||
v = playerStatusFor(health: ok, state: held([me, bob, mv('m', 'Mika', ready: true)]), self: me)!;
|
||||
expect((v.kind, v.text, v.action), (StatusKind.cannotPlay, "Bob can't play this: Not allowed (403)", null));
|
||||
expect(v.who.single.id, 'b');
|
||||
final notMe = mv('me', 'Me');
|
||||
v = playerStatusFor(health: ok, state: held([notMe, bob]), self: notMe)!;
|
||||
expect((v.kind, v.action), (StatusKind.cannotPlay, StatusAction.ready));
|
||||
});
|
||||
|
||||
test('names read as a sentence', () {
|
||||
expect(nameList(['Bob']), 'Bob');
|
||||
expect(nameList(['Bob', 'Mika']), 'Bob and Mika');
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:watch_together/src/queue/queue_logic.dart';
|
||||
|
||||
MemberView member(String id,
|
||||
{bool stale = false, bool out = false, Map<String, bool> jf = const {}, String name = ''}) =>
|
||||
MemberView(
|
||||
id: id, name: name.isEmpty ? id : name, ready: false, inSet: true, buffered: 0, stale: stale, out: out, jf: jf);
|
||||
|
||||
QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => QueueItem(
|
||||
id: 'q1',
|
||||
media: how == QueueHow.jellyfin
|
||||
? const HelloMedia(name: 'ep', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: 'S', itemId: 'i'))
|
||||
: const HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://x/y'),
|
||||
how: how,
|
||||
title: 'x',
|
||||
by: by,
|
||||
byName: byName,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('queuePerformer', () {
|
||||
test('a link passed on or a file: only its adder; gone means skipped', () {
|
||||
for (final how in [QueueHow.through, QueueHow.file]) {
|
||||
expect(queuePerformer(item(how), [member('m1'), member('m2', out: true)]).id, 'm2');
|
||||
final gone = queuePerformer(item(how), [member('m1'), member('m3')]);
|
||||
expect(gone.id, isNull);
|
||||
expect(gone.why, 'Bob left');
|
||||
}
|
||||
});
|
||||
|
||||
test('Jellyfin: the adder with access, else the lowest member with access, else skipped', () {
|
||||
final access = {'S': true};
|
||||
expect(queuePerformer(item(QueueHow.jellyfin), [member('m1', jf: access), member('m2', jf: access)]).id, 'm2');
|
||||
expect(
|
||||
queuePerformer(item(QueueHow.jellyfin), [member('m10', jf: access), member('m3', jf: access), member('m2')])
|
||||
.id,
|
||||
'm3');
|
||||
expect(queuePerformer(item(QueueHow.jellyfin), [member('m3', jf: access, out: true), member('m2')]).why,
|
||||
isNotNull);
|
||||
});
|
||||
|
||||
test('a link everyone opens or the server fetches: the adder, else the lowest active member', () {
|
||||
for (final how in [QueueHow.direct, QueueHow.server]) {
|
||||
expect(queuePerformer(item(how), [member('m1'), member('m2')]).id, 'm2');
|
||||
expect(queuePerformer(item(how), [member('m4'), member('m2', stale: true), member('m3')]).id, 'm3');
|
||||
final nobody = queuePerformer(item(how), [member('m2', out: true)]);
|
||||
expect((nobody.id, nobody.why), (null, null), reason: 'nobody around is not a skip');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('the janitor is the lowest member that is not stale', () {
|
||||
expect(queueJanitor([member('m9'), member('m1', stale: true), member('m2')]), 'm2');
|
||||
});
|
||||
|
||||
test('labels', () {
|
||||
expect(queueHowLabel(item(QueueHow.through), selfId: 'm2'), 'through you');
|
||||
expect(queueHowLabel(item(QueueHow.through), selfId: 'm1'), 'through Bob');
|
||||
expect(queueHowLabel(item(QueueHow.file), selfId: 'm1'), "Bob's file");
|
||||
expect(queueClock(3725), '1:02:05');
|
||||
expect(queueClock(65), '1:05');
|
||||
expect(queueClock(0), '');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:watch_together/src/queue/queue_ui.dart';
|
||||
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||
import 'package:watch_together/src/shell/library_link.dart' show LinkPreview;
|
||||
import 'package:watch_together/src/widgets/text_focus.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Up next popover: holds the keys, Ctrl+V adds with a preview row, + Add link opens a field',
|
||||
(tester) async {
|
||||
final coord = SessionCoordinator();
|
||||
var clip = (files: const <String>[], text: 'look https://example.com/v' as String?);
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: ThemeData.dark(),
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: QueuePanel(
|
||||
queue: coord.queue,
|
||||
onClose: () {},
|
||||
readClipboard: () async => clip,
|
||||
preview: (url) async => const LinkPreview(title: 'A video', subtitle: 'example.com'),
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
expect(textInputFocused(), isTrue, reason: 'the player keys rest while it is open');
|
||||
expect(find.byKey(const ValueKey('queue-add-field')), findsNothing);
|
||||
|
||||
// Ctrl+V: the link goes in at once, as a row with its preview; not in
|
||||
// a server here, so it says why.
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyV);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.text('A video'), findsOneWidget);
|
||||
expect(find.text('not in a server'), findsOneWidget);
|
||||
|
||||
// Nothing to add on the clipboard.
|
||||
clip = (files: const <String>[], text: 'just words');
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyV);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey('queue-add-error')), findsOneWidget);
|
||||
|
||||
// + Add link: a small field with focus; Esc closes it, focus stays.
|
||||
await tester.tap(find.byKey(const ValueKey('queue-add-link')));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey('queue-add-field')), findsOneWidget);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey('queue-add-field')), findsNothing);
|
||||
expect(textInputFocused(), isTrue);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:watch_together/src/jellyfin/accounts.dart';
|
||||
import 'package:watch_together/src/jellyfin/session_media.dart';
|
||||
import 'package:watch_together/src/jellyfin/track_prefs.dart';
|
||||
import 'package:watch_together/src/playback/yt_dlp_app.dart';
|
||||
import 'package:watch_together/src/queue/queue_controller.dart' show WarmState;
|
||||
import 'package:watch_together/src/screens/home/picked_media.dart';
|
||||
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||
import 'package:watch_together/src/session/switch_trace.dart';
|
||||
|
||||
// How long an Up next switch takes, end to end, over real mpv and a real
|
||||
// sync server: two Jellyfin account holders and a guest (through the
|
||||
// server's /j login) watch an episode, then ⏭ puts up the next episode,
|
||||
// then a direct link, then a link passed on "through" alice, then one the
|
||||
// server fetches (both relayed: the guests preload them from the server's
|
||||
// warm relay of the item). The fake Jellyfin and the link's host answer with a
|
||||
// delay and a capped rate, like a server elsewhere. Prints each client's
|
||||
// phases (SwitchTrace) and the wall time from ⏭ to the server playing.
|
||||
//
|
||||
// WT_SWITCH_PRELOAD=0 turns the preload off (the cold path, for comparison);
|
||||
// WT_SWITCH_DELAY_MS and WT_SWITCH_RATE_KBS set the remote's delay and rate.
|
||||
|
||||
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
||||
const _sid = 'ab12ab12ab12ab12ab12ab12ab12ab12';
|
||||
const _series = '5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e';
|
||||
const _ep1 = 'e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1';
|
||||
const _ep2 = 'e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2';
|
||||
|
||||
/// Per API request, and before a stream's first byte (WT_SWITCH_DELAY_MS).
|
||||
final _delay = Duration(milliseconds: int.tryParse(Platform.environment['WT_SWITCH_DELAY_MS'] ?? '') ?? 120);
|
||||
/// Per stream connection, default 24 Mbit/s (WT_SWITCH_RATE_KBS).
|
||||
final _bytesPerSec = (int.tryParse(Platform.environment['WT_SWITCH_RATE_KBS'] ?? '') ?? 3072) * 1024;
|
||||
|
||||
/// 60 s of 720p at about 4 Mbit/s with sound (headless mpv needs audio).
|
||||
Future<String> _media() async {
|
||||
final path = '$_tmp/queue_switch_60s.mkv';
|
||||
if (await File(path).exists()) return path;
|
||||
final r = await Process.run('ffmpeg', [
|
||||
'-y', '-v', 'error',
|
||||
'-f', 'lavfi', '-i', 'testsrc2=size=1280x720:rate=24',
|
||||
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
|
||||
'-t', '60', '-c:v', 'libx264', '-preset', 'ultrafast', '-b:v', '4M', '-g', '48', '-pix_fmt', 'yuv420p',
|
||||
'-c:a', 'aac', path,
|
||||
]);
|
||||
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
|
||||
return path;
|
||||
}
|
||||
|
||||
/// Serves [file] with ranges, after [_delay], at most [_bytesPerSec].
|
||||
Future<void> _serveFile(HttpRequest req, String file) async {
|
||||
final f = File(file);
|
||||
final size = await f.length();
|
||||
final res = req.response;
|
||||
res.headers.set('Accept-Ranges', 'bytes');
|
||||
res.headers.contentType = ContentType('video', 'x-matroska');
|
||||
var start = 0, end = size - 1;
|
||||
final range = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(req.headers.value('range') ?? '');
|
||||
if (range != null) {
|
||||
start = int.tryParse(range.group(1)!) ?? 0;
|
||||
end = int.tryParse(range.group(2)!) ?? size - 1;
|
||||
if (end >= size) end = size - 1;
|
||||
res.statusCode = 206;
|
||||
res.headers.set('Content-Range', 'bytes $start-$end/$size');
|
||||
}
|
||||
res.contentLength = end - start + 1;
|
||||
await Future.delayed(_delay);
|
||||
try {
|
||||
final raf = await f.open();
|
||||
try {
|
||||
await raf.setPosition(start);
|
||||
var at = start;
|
||||
const chunk = 64 * 1024;
|
||||
final clock = Stopwatch()..start();
|
||||
var sent = 0;
|
||||
while (at <= end) {
|
||||
final n = (end - at + 1).clamp(0, chunk);
|
||||
res.add(await raf.read(n));
|
||||
await res.flush();
|
||||
at += n;
|
||||
sent += n;
|
||||
final due = Duration(microseconds: sent * 1000000 ~/ _bytesPerSec);
|
||||
if (due > clock.elapsed) await Future.delayed(due - clock.elapsed);
|
||||
}
|
||||
} finally {
|
||||
await raf.close();
|
||||
}
|
||||
await res.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
class _FakeJellyfin {
|
||||
final String file;
|
||||
final tokens = {'ATOKEN': 'ua', 'CTOKEN': 'uc', 'HOSTTOKEN': 'uh'};
|
||||
late HttpServer _server;
|
||||
_FakeJellyfin(this.file);
|
||||
|
||||
String get base => 'http://127.0.0.1:${_server.port}';
|
||||
|
||||
Future<void> start() async {
|
||||
_server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
_server.listen(_handle);
|
||||
}
|
||||
|
||||
Future<void> stop() => _server.close(force: true);
|
||||
|
||||
Map<String, Object?> _item(String id) => {
|
||||
'Id': id,
|
||||
'Name': id == _ep1 ? 'Pilot' : 'Second',
|
||||
'Type': 'Episode',
|
||||
'ServerId': _sid,
|
||||
'SeriesId': _series,
|
||||
'SeriesName': 'Fake Show',
|
||||
'ParentIndexNumber': 1,
|
||||
'IndexNumber': id == _ep1 ? 1 : 2,
|
||||
'RunTimeTicks': 60 * jellyfinTicksPerSecond,
|
||||
};
|
||||
|
||||
Future<void> _handle(HttpRequest req) async {
|
||||
final auth = req.headers.value('authorization') ?? '';
|
||||
await req.drain<void>();
|
||||
final res = req.response;
|
||||
final path = req.uri.path;
|
||||
if (RegExp(r'^/Videos/\w+/stream$').hasMatch(path)) return _serveFile(req, file);
|
||||
await Future.delayed(_delay);
|
||||
Future<void> json(Object v) async {
|
||||
res.headers.contentType = ContentType.json;
|
||||
res.write(jsonEncode(v));
|
||||
await res.close();
|
||||
}
|
||||
|
||||
if (path == '/System/Info/Public') return json({'Id': _sid, 'ServerName': 'Fake', 'Version': '10.11.11'});
|
||||
final token = RegExp(r'Token="([^"]*)"').firstMatch(auth)?.group(1);
|
||||
final user = tokens[token];
|
||||
if (user == null) {
|
||||
res.statusCode = 401;
|
||||
return res.close();
|
||||
}
|
||||
final m = RegExp(r'^/Items/(\w+)(/PlaybackInfo)?$').firstMatch(path);
|
||||
if (m != null && m.group(2) != null) {
|
||||
return json({
|
||||
'PlaySessionId': 'ps-$user-${m.group(1)}',
|
||||
'MediaSources': [
|
||||
{
|
||||
'Id': m.group(1),
|
||||
'SupportsDirectPlay': true,
|
||||
'MediaStreams': [
|
||||
{'Index': 0, 'Type': 'Video', 'Codec': 'h264'},
|
||||
{'Index': 1, 'Type': 'Audio', 'Language': 'jpn', 'Codec': 'aac'},
|
||||
],
|
||||
}
|
||||
],
|
||||
});
|
||||
}
|
||||
if (m != null) return json(_item(m.group(1)!));
|
||||
if (path == '/Shows/$_series/Episodes') {
|
||||
return json({'Items': [_item(_ep1), _item(_ep2)], 'TotalRecordCount': 2});
|
||||
}
|
||||
if (path.startsWith('/Sessions/Playing')) {
|
||||
res.statusCode = 204;
|
||||
return res.close();
|
||||
}
|
||||
if (path.startsWith('/UserPlayedItems/') || path.startsWith('/UserItems/')) {
|
||||
return json({'Played': false, 'PlaybackPositionTicks': 0});
|
||||
}
|
||||
res.statusCode = 404;
|
||||
await res.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _poll(FutureOr<bool> Function() cond, String what,
|
||||
{Duration timeout = const Duration(seconds: 40), Duration every = const Duration(milliseconds: 200)}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
if (await cond()) return;
|
||||
await Future.delayed(every);
|
||||
}
|
||||
fail('timed out waiting for $what');
|
||||
}
|
||||
|
||||
Future<JellyfinAccounts> _account(String base, String token, String userId, String device) async {
|
||||
final a = JellyfinAccounts(store: MemoryJellyfinStore(), deviceName: device);
|
||||
await a.ensureLoaded(validate: false);
|
||||
await a.link(
|
||||
baseUrl: base,
|
||||
server: const JellyfinServerInfo(id: _sid, name: 'Fake', version: '10.11.11'),
|
||||
auth: JellyfinAuthResult(accessToken: token, user: JellyfinUser(id: userId, name: userId), serverId: _sid),
|
||||
);
|
||||
return a;
|
||||
}
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'Up next: ⏭ to a Jellyfin episode, then to a direct link, timed per client',
|
||||
timeout: const Timeout(Duration(minutes: 4)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final preload = Platform.environment['WT_SWITCH_PRELOAD'] != '0';
|
||||
final file = await _media();
|
||||
final jelly = _FakeJellyfin(file);
|
||||
await jelly.start();
|
||||
final link = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
link.listen((req) => _serveFile(req, file));
|
||||
final linkUrl = 'http://127.0.0.1:${link.port}/clip.mkv';
|
||||
final server = SyncServer(ytDlp: YtDlp('$_tmp/queue_switch_server_ytdlp', enabled: false), AppConfig(
|
||||
port: 0,
|
||||
secret: 'q',
|
||||
name: 'q',
|
||||
jellyfin: JellyfinCredential(
|
||||
baseUrl: jelly.base,
|
||||
serverId: _sid,
|
||||
token: 'HOSTTOKEN',
|
||||
client: 'Watch Together',
|
||||
device: 'Host',
|
||||
deviceId: 'host-1',
|
||||
version: '9.9.9'),
|
||||
));
|
||||
// Relic logs errors writing a response (a relay response cut short
|
||||
// when its relay is dropped at a swap or stop) as it does outside
|
||||
// tests; anything else fails the test.
|
||||
final serverErrors = <Object>[];
|
||||
await runZonedGuarded(server.start, (e, _) {
|
||||
if ('$e'.contains('Content size below specified contentLength') || '$e'.contains('did not arrive')) return;
|
||||
serverErrors.add(e);
|
||||
});
|
||||
final ytDlp = AppYtDlp(YtDlp('$_tmp/queue_switch_ytdlp', enabled: false));
|
||||
final accA = await _account(jelly.base, 'ATOKEN', 'ua', 'A-PC');
|
||||
final accC = await _account(jelly.base, 'CTOKEN', 'uc', 'C-PC');
|
||||
final accB = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||
await accB.ensureLoaded(validate: false);
|
||||
SessionCoordinator coord(JellyfinAccounts acc) => SessionCoordinator(
|
||||
jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()), ytDlp: ytDlp)
|
||||
..playback.preloadEnabled = preload;
|
||||
final a = coord(accA), b = coord(accB), c = coord(accC);
|
||||
final all = {'alice': a, 'guest': b, 'carol': c};
|
||||
ConnectParams params(String name, [HelloMedia? media]) =>
|
||||
ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'q', name: name, media: media);
|
||||
final report = <String>[];
|
||||
|
||||
Future<void> switchOnce(String what) async {
|
||||
final before = a.serverState.peek()!.mediaId;
|
||||
// Time to get ready (Up next's first item, on every client).
|
||||
await Future.delayed(const Duration(seconds: 8));
|
||||
if (preload) {
|
||||
for (final x in all.values) {
|
||||
expect(x.queue.warm.peek()?.state, WarmState.ready, reason: 'ready on every client before ⏭');
|
||||
expect(x.playback.preloadUrl, isNotNull, reason: 'preloaded on every client');
|
||||
}
|
||||
}
|
||||
final clock = Stopwatch()..start();
|
||||
a.queue.skip();
|
||||
await _poll(() {
|
||||
final r = a.serverState.peek();
|
||||
return r != null && r.mediaId > before && r.transport == 'playing';
|
||||
}, '$what playing', every: const Duration(milliseconds: 10));
|
||||
final total = clock.elapsedMilliseconds;
|
||||
final id = a.serverState.peek()!.mediaId;
|
||||
report.add('$what (${preload ? 'preload' : 'cold'}): ⏭ to server playing $total ms');
|
||||
for (final e in all.entries) {
|
||||
report.add(' ${e.key}: ${e.value.switchTrace.of(id)}');
|
||||
}
|
||||
await _poll(() => all.values.every((x) => x.playback.player.state.playing), 'all play $what');
|
||||
// The promoted player is the session player: marks, tracks and
|
||||
// the volume carried over, the clock moves.
|
||||
for (final x in all.values) {
|
||||
expect(x.playback.durationSecs, greaterThan(59));
|
||||
expect(x.playback.player.state.volume, 0, reason: 'volume kept');
|
||||
await _poll(() => x.playback.tracks.peek().isNotEmpty, 'tracks listed');
|
||||
}
|
||||
final at = a.playback.positionSecs;
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
expect(a.playback.positionSecs, greaterThan(at + 0.5));
|
||||
}
|
||||
|
||||
try {
|
||||
await a.connect(params('alice', const HelloMedia(
|
||||
name: 'Pilot', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: _sid, itemId: _ep1, title: 'Pilot'))));
|
||||
await b.connect(params('guest'));
|
||||
await c.connect(params('carol'));
|
||||
for (final x in all.values) {
|
||||
await x.playback.player.setVolume(0);
|
||||
}
|
||||
await _poll(() => all.values.every((x) => x.playback.durationSecs > 59), 'all opened episode 1');
|
||||
expect(await a.queue.add(pickForRef(accA, const JellyfinRef(serverId: _sid, itemId: _ep2, title: 'Second'))!),
|
||||
isNull);
|
||||
expect(await a.queue.add(LinkMedia(linkUrl, relay: LinkRelay.direct)), isNull);
|
||||
await _poll(() => (a.serverState.peek()?.queue.length ?? 0) == 2, 'two queued');
|
||||
for (final x in all.values) {
|
||||
x.toggleReady();
|
||||
}
|
||||
await _poll(() => all.values.every((x) => x.playback.player.state.playing), 'all playing episode 1');
|
||||
|
||||
await switchOnce('episode 2');
|
||||
expect(refOfStateMedia(a.serverState.peek()!.media)?.itemId, _ep2);
|
||||
expect(b.jellyfinPlan.peek(), isA<RelayPlay>());
|
||||
if (preload) {
|
||||
for (final x in all.values) {
|
||||
expect(x.switchTrace.of(x.serverState.peek()!.mediaId), contains(SwitchTrace.preloaded));
|
||||
}
|
||||
}
|
||||
await switchOnce('direct link');
|
||||
expect(stateMediaUrl(a.serverState.peek()!), linkUrl);
|
||||
|
||||
// Relayed items: alice passes the link on, then the server fetches
|
||||
// it. Each is fed into the server's warm relay while it waits, so
|
||||
// bob and carol (and for the fetched one alice) preload from it,
|
||||
// and the swap promotes that relay with its buffer.
|
||||
for (final (what, mode) in [('link through alice', LinkRelay.me), ('server-fetched link', LinkRelay.server)]) {
|
||||
expect(await a.queue.add(LinkMedia(linkUrl, relay: mode)), isNull);
|
||||
await _poll(() => a.serverState.peek()?.queue.isNotEmpty ?? false, '$what queued');
|
||||
final id = a.serverState.peek()!.queue.first.id;
|
||||
await _poll(() => a.serverState.peek()?.queue.firstOrNull?.media.relay?.live ?? false, '$what warm relay live');
|
||||
await switchOnce(what);
|
||||
final relay = stateMediaRelay(a.serverState.peek()!);
|
||||
expect(relay?.queueId, id, reason: 'the warm relay became the media\'s');
|
||||
expect(relay?.fetch, mode == LinkRelay.server);
|
||||
if (preload) {
|
||||
for (final x in all.values) {
|
||||
expect(x.switchTrace.of(x.serverState.peek()!.mediaId), contains(SwitchTrace.preloaded),
|
||||
reason: '$what preloaded');
|
||||
}
|
||||
}
|
||||
}
|
||||
// ignore: avoid_print
|
||||
print(report.join('\n'));
|
||||
expect(serverErrors, isEmpty);
|
||||
|
||||
// Nothing queued: no preload. Queued and taken off again: dropped.
|
||||
if (preload) {
|
||||
await _poll(() => all.values.every((x) => x.playback.preloadUrl == null), 'nothing preloaded');
|
||||
expect(await a.queue.add(LinkMedia(linkUrl, relay: LinkRelay.direct)), isNull);
|
||||
await _poll(() => all.values.every((x) => x.playback.preloadUrl == linkUrl), 'preloaded again');
|
||||
a.queue.remove(a.serverState.peek()!.queue.first.id);
|
||||
await _poll(() => all.values.every((x) => x.playback.preloadUrl == null), 'dropped when taken off');
|
||||
}
|
||||
// Preloaded everywhere: every client starts from its preload.
|
||||
if (preload) {
|
||||
for (final x in all.values) {
|
||||
expect(x.switchTrace.of(x.serverState.peek()!.mediaId), contains(SwitchTrace.preloaded));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
for (final x in all.values) {
|
||||
await x.dispose();
|
||||
}
|
||||
await server.stop();
|
||||
await jelly.stop();
|
||||
await link.close(force: true);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -169,6 +170,46 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
test('a source that dials in after mpv would have given up (5 s): viewers wait for it, then play',
|
||||
timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||
final file = await _media();
|
||||
final alice = await member('alice');
|
||||
// Bob is a bare client: he puts a file up and starts passing it on
|
||||
// only 7 s later (a slow first upstream read, a slow own player).
|
||||
final ws = await WebSocket.connect('ws://127.0.0.1:${server.boundPort}/sync');
|
||||
final welcome = Completer<Map>();
|
||||
ws.listen((f) {
|
||||
final j = jsonDecode('$f') as Map;
|
||||
if (j['t'] == 'welcome' && !welcome.isCompleted) welcome.complete(j);
|
||||
}, onError: (_) {});
|
||||
ws.add(jsonEncode(const HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 'pw',
|
||||
user: HelloUser(name: 'bob'),
|
||||
media: HelloMedia(name: '', size: 0, duration: 0))
|
||||
.toJson()));
|
||||
final token = (await welcome.future.timeout(const Duration(seconds: 5)))['token'] as String;
|
||||
final size = await File(file).length();
|
||||
ws.add(jsonEncode({
|
||||
't': 'swap',
|
||||
'media': {'name': 'late.mkv', 'size': size, 'duration': 0, 'relay': <String, Object?>{}},
|
||||
}));
|
||||
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source.isNotEmpty ?? false, 'relay state');
|
||||
await Future.delayed(const Duration(seconds: 7));
|
||||
expect(alice.playerError.peek(), isEmpty, reason: 'no "could not be opened" while the source is on its way');
|
||||
expect(alice.health.peek().message, contains('waiting for bob'));
|
||||
final src = RelaySource(Uri.parse('ws://127.0.0.1:${server.boundPort}/r/default/$token/source'),
|
||||
RelayMedia.single(FileRelayBytes(File(file))));
|
||||
await src.start();
|
||||
try {
|
||||
await _poll(() => alice.playback.durationSecs > 19, 'alice opened it once the source was up');
|
||||
expect(alice.playerError.peek(), isEmpty);
|
||||
} finally {
|
||||
await src.close();
|
||||
await ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('the source drops uncleanly and resumes: it keeps its copy and keeps serving',
|
||||
timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||
final file = await _media();
|
||||
|
||||
@@ -29,7 +29,6 @@ final seriesJson = {
|
||||
'jellyfin_series_id': 'jf7',
|
||||
'state': 'released',
|
||||
'monitored': true,
|
||||
'followed': true,
|
||||
'episodes_total': 24,
|
||||
'episodes_available': 20,
|
||||
'next_air_date': null,
|
||||
@@ -110,7 +109,7 @@ void main() {
|
||||
'to': req.uri.queryParameters['to'],
|
||||
'items': [
|
||||
{...episodeJson, 'series_id': 7, 'title': 'Show', 'anidb_id': 70, 'tvdb_id': 700,
|
||||
'jellyfin_series_id': 'jf7', 'followed': true},
|
||||
'jellyfin_series_id': 'jf7'},
|
||||
],
|
||||
}, etag: 'W/"s1"');
|
||||
},
|
||||
@@ -126,7 +125,6 @@ void main() {
|
||||
expect(it.anidbId, 70);
|
||||
expect(it.tvdbId, 700);
|
||||
expect(it.jellyfinSeriesId, 'jf7');
|
||||
expect(it.followed, isTrue);
|
||||
final e = it.episode;
|
||||
expect((e.season, e.episode, e.absolute, e.title, e.anilistId), (2, 3, 15, 'Three', 222));
|
||||
expect(e.airDate, DateTime.utc(2026, 10, 8, 19, 36, 32));
|
||||
@@ -146,7 +144,7 @@ void main() {
|
||||
expect(server.seen, isEmpty);
|
||||
});
|
||||
|
||||
test('/series, /series/{id} with episodes, follow/unfollow', () async {
|
||||
test('/series, /series/{id} with episodes', () async {
|
||||
await serve({
|
||||
'GET /series': (req, res) async => json(res, {'items': [seriesJson]}, etag: 'W/"a"'),
|
||||
'GET /series/7': (req, res) async => json(res, {
|
||||
@@ -156,24 +154,18 @@ void main() {
|
||||
{'season': 1, 'episode': 1, 'status': 'failed', 'reason': 'No release'},
|
||||
],
|
||||
}),
|
||||
'PUT /series/7/follow': (req, res) async => res.statusCode = 204,
|
||||
'DELETE /series/7/follow': (req, res) async => res.statusCode = 204,
|
||||
});
|
||||
final c = client();
|
||||
final s = (await c.series()).single;
|
||||
expect(s.seriesId, 7);
|
||||
expect(s.anilistId, 111);
|
||||
expect(s.state, SeriesState.released);
|
||||
expect(s.monitored && s.followed, isTrue);
|
||||
expect(s.monitored, isTrue);
|
||||
expect((s.episodesTotal, s.episodesAvailable, s.nextAirDate), (24, 20, null));
|
||||
final d = await c.seriesDetail(7);
|
||||
expect(d.episodes.map((e) => e.season), [0, 1]);
|
||||
expect(d.episodes.last.status, EpisodeStatus.failed);
|
||||
expect(d.episodes.last.reason, 'No release');
|
||||
await c.setFollowed(7, true);
|
||||
await c.setFollowed(7, false);
|
||||
expect(server.seen.skip(2).map((r) => '${r.method} ${r.uri.path}'),
|
||||
['PUT /api/app/v1/series/7/follow', 'DELETE /api/app/v1/series/7/follow']);
|
||||
});
|
||||
|
||||
test('/search and the three request bodies', () async {
|
||||
|
||||
@@ -20,7 +20,7 @@ Airing airing(int id, int ep, DateTime local) => Airing(
|
||||
);
|
||||
|
||||
AnimeFlowScheduleItem item(int? anilistId, int ep, EpisodeStatus status,
|
||||
{int season = 1, int? absolute, DateTime? air, int seriesId = 7, bool followed = false,
|
||||
{int season = 1, int? absolute, DateTime? air, int seriesId = 7,
|
||||
double? progress, String? reason}) =>
|
||||
AnimeFlowScheduleItem(
|
||||
episode: AnimeFlowEpisode(
|
||||
@@ -36,7 +36,6 @@ AnimeFlowScheduleItem item(int? anilistId, int ep, EpisodeStatus status,
|
||||
seriesId: seriesId,
|
||||
title: 'Show',
|
||||
jellyfinSeriesId: 'jf$seriesId',
|
||||
followed: followed,
|
||||
);
|
||||
|
||||
void main() {
|
||||
@@ -80,6 +79,68 @@ void main() {
|
||||
expect(out.single.season, 6);
|
||||
});
|
||||
|
||||
test('confirmOnJellyfin: the episode (with the run offset) must be on Jellyfin', () {
|
||||
final lib = AnimeFlowLibraryItem.fromJson({
|
||||
'jellyfin_series_id': 'jfJ',
|
||||
'name': 'JoJo',
|
||||
'anilist_map': [
|
||||
{'season': 6, 'anilist_id': 210482, 'first_episode': 13, 'offset': 12},
|
||||
],
|
||||
});
|
||||
final matched = matchSchedule(
|
||||
[
|
||||
airing(210482, 1, DateTime(2026, 10, 1, 18)), // S6E13: there
|
||||
airing(210482, 2, DateTime(2026, 10, 8, 11)), // S6E14: not yet
|
||||
airing(172192, 3, DateTime(2026, 10, 6, 18)), // series not looked up
|
||||
],
|
||||
schedule: const [],
|
||||
library: [
|
||||
lib,
|
||||
const AnimeFlowLibraryItem(jellyfinSeriesId: 'jfR', name: 'Returner', anilistIds: {2: 172192}),
|
||||
],
|
||||
now: now,
|
||||
);
|
||||
expect(matched.map((e) => e.jellyfinEpisode), [13, 14, 3]);
|
||||
final out = confirmOnJellyfin(matched, {
|
||||
'jfJ': const [
|
||||
JellyfinEpisodeRef(6, 12),
|
||||
JellyfinEpisodeRef(6, 13, watch: EpisodeWatch(progress: 0.4)),
|
||||
JellyfinEpisodeRef(1, 14),
|
||||
],
|
||||
});
|
||||
expect(out.map((e) => e.status),
|
||||
[ScheduleStatus.inLibrary, ScheduleStatus.notOnJellyfin, ScheduleStatus.aired]);
|
||||
expect(out[1].jellyfinSeriesId, 'jfJ');
|
||||
expect(out.map((e) => e.watch), [const EpisodeWatch(progress: 0.4), null, null]);
|
||||
});
|
||||
|
||||
test("confirmOnJellyfin: AnimeFlow's available episodes get this user's watched state", () {
|
||||
final matched = matchSchedule(
|
||||
[
|
||||
airing(100, 3, DateTime(2026, 10, 1, 18)), // S1E3 by number
|
||||
airing(100, 4, DateTime.utc(2026, 10, 7, 14)), // numbering misses: premiere day
|
||||
airing(200, 1, DateTime(2026, 10, 1, 18)), // series not looked up
|
||||
],
|
||||
schedule: [
|
||||
item(100, 3, EpisodeStatus.available),
|
||||
item(100, 40, EpisodeStatus.available, absolute: 4),
|
||||
item(200, 1, EpisodeStatus.available),
|
||||
],
|
||||
now: now,
|
||||
);
|
||||
expect(matched.map((e) => e.jellyfinEpisode), [3, 40, 1]);
|
||||
final out = confirmOnJellyfin(matched, {
|
||||
'jf7': [
|
||||
const JellyfinEpisodeRef(1, 3, watch: EpisodeWatch(played: true)),
|
||||
// Midnight JST of Oct 7.
|
||||
JellyfinEpisodeRef(1, 4, premiere: DateTime.utc(2026, 10, 6, 15), watch: const EpisodeWatch(progress: 0.5)),
|
||||
],
|
||||
});
|
||||
expect(out.map((e) => e.status), everyElement(ScheduleStatus.available));
|
||||
expect(out.map((e) => e.watch),
|
||||
[const EpisodeWatch(played: true), const EpisodeWatch(progress: 0.5), null]);
|
||||
});
|
||||
|
||||
test("AnimeFlow's anilist_episode wins over TVDB numbering (split cour)", () {
|
||||
// S2E13 in TVDB is part 2 (AniList 300) episode 1.
|
||||
final out = matchSchedule(
|
||||
@@ -99,13 +160,28 @@ void main() {
|
||||
expect(out.single.season, 2);
|
||||
});
|
||||
|
||||
test('a TVDB number never claims another AniList episode (JoJo S6E4 = 2nd STAGE ep 3)', () {
|
||||
AnimeFlowScheduleItem row(int tvdbEp, int aniEp, EpisodeStatus st) => AnimeFlowScheduleItem(
|
||||
episode: AnimeFlowEpisode(season: 6, episode: tvdbEp, anilistId: 210482, anilistEpisode: aniEp, status: st),
|
||||
seriesId: 40,
|
||||
title: 'JoJo',
|
||||
jellyfinSeriesId: 'jfJ',
|
||||
);
|
||||
final out = matchSchedule(
|
||||
[airing(210482, 3, DateTime(2026, 10, 9, 11)), airing(210482, 4, DateTime(2026, 10, 16, 11))],
|
||||
schedule: [row(4, 3, EpisodeStatus.available), row(5, 4, EpisodeStatus.upcoming)],
|
||||
now: now,
|
||||
);
|
||||
expect(out.map((e) => e.status), [ScheduleStatus.available, ScheduleStatus.upcoming]);
|
||||
});
|
||||
|
||||
test('by AniList id + episode, per season ids', () {
|
||||
// One AnimeFlow series (7) spans two AniList entries: S1 = 100, S2 = 200.
|
||||
final out = matchSchedule(
|
||||
[airing(100, 3, DateTime(2026, 10, 1, 20)), airing(200, 3, DateTime(2026, 10, 9, 20))],
|
||||
schedule: [
|
||||
item(100, 3, EpisodeStatus.available, season: 1),
|
||||
item(200, 3, EpisodeStatus.downloading, season: 2, progress: 0.4, followed: true),
|
||||
item(200, 3, EpisodeStatus.downloading, season: 2, progress: 0.4),
|
||||
],
|
||||
series: [const AnimeFlowSeries(seriesId: 7, title: 'Show', anilistId: 100, jellyfinSeriesId: 'jf7')],
|
||||
now: now,
|
||||
@@ -113,7 +189,6 @@ void main() {
|
||||
expect(out.map((e) => e.status), [ScheduleStatus.available, ScheduleStatus.downloading]);
|
||||
expect(out.map((e) => e.season), [1, 2]);
|
||||
expect(out[1].progress, 0.4);
|
||||
expect(out[1].followed, isTrue);
|
||||
expect(out.every((e) => e.tracked && e.seriesId == 7 && e.jellyfinSeriesId == 'jf7'), isTrue);
|
||||
});
|
||||
|
||||
@@ -127,15 +202,22 @@ void main() {
|
||||
expect(out.single.tracked, isFalse);
|
||||
});
|
||||
|
||||
test('absolute number, then same local air day', () {
|
||||
test('absolute number, then the same Japan air day', () {
|
||||
final out = matchSchedule(
|
||||
[
|
||||
airing(300, 1100, DateTime(2026, 10, 5, 9)), // AniList counts absolute
|
||||
airing(400, 13, DateTime(2026, 10, 6, 23, 30)), // numbering differs
|
||||
// Numbering differs; Oct 6 23:30 JST, the evening before in
|
||||
// Europe and America.
|
||||
airing(400, 13, DateTime.utc(2026, 10, 6, 14, 30)),
|
||||
// Oct 8 01:30 JST, a late-night slot listed on Oct 7.
|
||||
airing(401, 13, DateTime.utc(2026, 10, 7, 16, 30)),
|
||||
],
|
||||
schedule: [
|
||||
item(300, 5, EpisodeStatus.available, season: 22, absolute: 1100),
|
||||
item(400, 1, EpisodeStatus.failed, season: 2, air: DateTime(2026, 10, 6, 0, 15), reason: 'No release'),
|
||||
// Air dates are midnight JST (Oct 6, 15:00 UTC the day before)...
|
||||
item(400, 1, EpisodeStatus.failed, season: 2, air: DateTime.utc(2026, 10, 5, 15), reason: 'No release'),
|
||||
// ... or midnight UTC from older servers.
|
||||
item(401, 1, EpisodeStatus.available, air: DateTime.utc(2026, 10, 7)),
|
||||
],
|
||||
now: now,
|
||||
);
|
||||
@@ -143,12 +225,14 @@ void main() {
|
||||
expect(out[0].season, 22);
|
||||
expect(out[1].status, ScheduleStatus.failed);
|
||||
expect(out[1].reason, 'No release');
|
||||
expect(out[2].status, ScheduleStatus.available);
|
||||
});
|
||||
|
||||
test('a different local day does not match', () {
|
||||
test('a different Japan day does not match', () {
|
||||
final out = matchSchedule(
|
||||
[airing(400, 13, DateTime(2026, 10, 7, 0, 30))],
|
||||
schedule: [item(400, 1, EpisodeStatus.available, air: DateTime(2026, 10, 6, 23, 50))],
|
||||
// Oct 7 20:00 JST; the air date is Oct 6 (midnight JST).
|
||||
[airing(400, 13, DateTime.utc(2026, 10, 7, 11))],
|
||||
schedule: [item(400, 1, EpisodeStatus.available, air: DateTime.utc(2026, 10, 5, 15))],
|
||||
now: now,
|
||||
);
|
||||
// Tracked (the id is in the schedule) but unmatched and aired.
|
||||
@@ -160,11 +244,11 @@ void main() {
|
||||
final out = matchSchedule(
|
||||
[airing(500, 2, DateTime(2026, 10, 7, 20)), airing(500, 3, DateTime(2026, 10, 14, 20))],
|
||||
schedule: const [],
|
||||
series: [const AnimeFlowSeries(seriesId: 9, title: 'S', anilistId: 500, followed: true)],
|
||||
series: [const AnimeFlowSeries(seriesId: 9, title: 'S', anilistId: 500)],
|
||||
now: now,
|
||||
);
|
||||
expect(out.map((e) => e.status), [ScheduleStatus.missing, ScheduleStatus.upcoming]);
|
||||
expect(out.every((e) => e.followed && e.seriesId == 9), isTrue);
|
||||
expect(out.every((e) => e.seriesId == 9), isTrue);
|
||||
});
|
||||
|
||||
test('progress only while downloading, reason only when failed', () {
|
||||
@@ -226,7 +310,7 @@ void main() {
|
||||
return json(res, {
|
||||
'items': [
|
||||
{'season': 1, 'episode': 3, 'anilist_id': 100, 'status': 'available', 'series_id': 7,
|
||||
'title': 'Show', 'followed': false},
|
||||
'title': 'Show'},
|
||||
],
|
||||
});
|
||||
case '/flow/series':
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:watch_together/src/jellyfin/accounts.dart';
|
||||
import 'package:watch_together/src/schedule/anilist.dart';
|
||||
import 'package:watch_together/src/schedule/animeflow.dart';
|
||||
import 'package:watch_together/src/schedule/schedule_model.dart';
|
||||
import 'package:watch_together/src/schedule/ui/schedule_actions.dart';
|
||||
import 'package:watch_together/src/schedule/ui/schedule_page.dart';
|
||||
import 'package:watch_together/src/screens/home/picked_media.dart';
|
||||
|
||||
@@ -49,6 +50,7 @@ class FakeJf implements JellyfinHttp {
|
||||
'SeriesName': 'Avail Show',
|
||||
'IndexNumber': 2,
|
||||
'ParentIndexNumber': 1,
|
||||
'UserData': {'Played': true},
|
||||
},
|
||||
{
|
||||
'Id': 'ep3',
|
||||
@@ -57,6 +59,8 @@ class FakeJf implements JellyfinHttp {
|
||||
'SeriesName': 'Avail Show',
|
||||
'IndexNumber': 3,
|
||||
'ParentIndexNumber': 1,
|
||||
'RunTimeTicks': 1000,
|
||||
'UserData': {'PlaybackPositionTicks': 250},
|
||||
},
|
||||
],
|
||||
'TotalRecordCount': 2,
|
||||
@@ -87,10 +91,10 @@ List<ScheduleEntry> entries() => [
|
||||
airing: airing(111, 'Avail Show', 3, day(0, 9)),
|
||||
status: ScheduleStatus.available,
|
||||
tracked: true,
|
||||
followed: true,
|
||||
seriesId: 7,
|
||||
season: 1,
|
||||
jellyfinSeriesId: 'jf7',
|
||||
watch: const EpisodeWatch(played: true),
|
||||
),
|
||||
ScheduleEntry(
|
||||
airing: airing(222, 'Missing Show', 5, day(0, 10)),
|
||||
@@ -99,9 +103,8 @@ List<ScheduleEntry> entries() => [
|
||||
seriesId: 8,
|
||||
),
|
||||
ScheduleEntry(
|
||||
airing: airing(333, 'Failed Show', 2, day(0, 11)),
|
||||
status: ScheduleStatus.failed,
|
||||
reason: 'no seeders',
|
||||
airing: airing(333, 'Unmonitored Show', 2, day(0, 11)),
|
||||
status: ScheduleStatus.unmonitored,
|
||||
tracked: true,
|
||||
seriesId: 9,
|
||||
season: 1,
|
||||
@@ -200,8 +203,6 @@ void main() {
|
||||
});
|
||||
case ('GET', '/search'):
|
||||
json(res, {'items': searchHits});
|
||||
case ('PUT' || 'DELETE', '/series/7/follow'):
|
||||
res.statusCode = 204;
|
||||
case ('POST', '/requests/1/approve' || '/requests/2/reject'):
|
||||
json(res, {});
|
||||
default:
|
||||
@@ -285,8 +286,13 @@ void main() {
|
||||
expect(find.byKey(ValueKey('day-${w.year}-${w.month}-${w.day}')), findsNothing);
|
||||
expect(card(111, 3), findsOneWidget);
|
||||
expect(find.text('on Jellyfin'), findsOneWidget);
|
||||
expect(find.text('missing'), findsOneWidget);
|
||||
expect(find.text('failed · no seeders'), findsOneWidget);
|
||||
// AnimeFlow's "missing": waiting for a release, nothing to request;
|
||||
// only the unmonitored one has Request.
|
||||
expect(find.text('waiting for release'), findsOneWidget);
|
||||
expect(action(222, 5), findsNothing);
|
||||
expect(action(333, 2), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('watched-111-3')), findsOneWidget);
|
||||
expect(find.text('not monitored'), findsOneWidget);
|
||||
expect(find.text('downloading 42%'), findsOneWidget);
|
||||
expect(find.text('Put it up'), findsOneWidget);
|
||||
// On Jellyfin: untracked entries are hidden.
|
||||
@@ -297,13 +303,9 @@ void main() {
|
||||
await done(tester);
|
||||
});
|
||||
|
||||
testWidgets('filters: following, all airing, niche too, compact', (tester) async {
|
||||
testWidgets('filters: all airing, niche too, compact', (tester) async {
|
||||
await pumpPage(tester);
|
||||
await tester.tap(find.text('Following'));
|
||||
await tester.pump();
|
||||
expect(card(111, 3), findsOneWidget);
|
||||
expect(card(222, 5), findsNothing);
|
||||
|
||||
expect(find.text('Following'), findsNothing);
|
||||
await tester.tap(find.text('All airing'));
|
||||
await tester.pump();
|
||||
expect(card(444, 1), findsOneWidget);
|
||||
@@ -322,7 +324,14 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('request on a tracked episode sends AnimeFlow numbering', (tester) async {
|
||||
await pumpPage(tester);
|
||||
await pumpPage(
|
||||
tester,
|
||||
snap: () => snapshot(
|
||||
list: [
|
||||
for (final e in entries()) e.airing.anilistId == 222 ? e.copyWith(status: ScheduleStatus.unmonitored) : e,
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.tap(action(222, 5));
|
||||
await io(tester);
|
||||
final post = server.seen.where((r) => r.method == 'POST' && r.uri.path.endsWith('/requests')).single;
|
||||
@@ -390,22 +399,13 @@ void main() {
|
||||
|
||||
testWidgets('without the request scope the button is disabled with a hint', (tester) async {
|
||||
await pumpPage(tester, snap: () => snapshot(me: viewer));
|
||||
final b = widgetOf<OutlinedButton>(tester, action(222, 5));
|
||||
final b = widgetOf<OutlinedButton>(tester, action(333, 2));
|
||||
expect(b.onPressed, isNull);
|
||||
expect(find.byWidgetPredicate((w) => w is Tooltip && (w.message ?? '').contains('owner')), findsWidgets);
|
||||
expect(find.text('My requests'), findsOneWidget);
|
||||
await done(tester);
|
||||
});
|
||||
|
||||
testWidgets('follow toggles through AnimeFlow', (tester) async {
|
||||
final repo = await pumpPage(tester);
|
||||
await tester.tap(find.byKey(const ValueKey('follow-111-3')));
|
||||
await io(tester);
|
||||
expect(server.seen.where((r) => r.method == 'DELETE' && r.uri.path.endsWith('/series/7/follow')), hasLength(1));
|
||||
expect(repo.forced.length, greaterThanOrEqualTo(2)); // reloaded
|
||||
await done(tester);
|
||||
});
|
||||
|
||||
testWidgets('put it up resolves the Jellyfin episode', (tester) async {
|
||||
final jf = FakeJf();
|
||||
final accounts = JellyfinAccounts(
|
||||
@@ -434,6 +434,52 @@ void main() {
|
||||
await done(tester);
|
||||
});
|
||||
|
||||
testWidgets('untracked on Jellyfin: episode lookup, aired-not-there has no Play', (tester) async {
|
||||
final jf = FakeJf();
|
||||
final accounts = JellyfinAccounts(
|
||||
store: MemoryJellyfinStore({
|
||||
'accounts': jsonEncode([
|
||||
{
|
||||
'serverId': 'srv',
|
||||
'serverName': 'Home',
|
||||
'urls': ['http://jf.test'],
|
||||
'userId': 'uid',
|
||||
'userName': 'me',
|
||||
'token': 'tok',
|
||||
},
|
||||
]),
|
||||
}),
|
||||
httpFactory: () => jf,
|
||||
deviceName: 'Test',
|
||||
);
|
||||
final eps = await tester.runAsync(() => jellyfinScheduleEpisodes(accounts, flow, 'jf7'));
|
||||
expect(eps!.map((e) => (e.season, e.episode)), [(1, 2), (1, 3)]);
|
||||
expect(eps.map((e) => e.watch), [const EpisodeWatch(played: true), const EpisodeWatch(progress: 0.25)]);
|
||||
await tester.runAsync(() => jellyfinScheduleEpisodes(accounts, flow, 'jf7'));
|
||||
expect(jf.seen.where((u) => u.path.endsWith('/Episodes')), hasLength(1), reason: 'memoised');
|
||||
final none = JellyfinAccounts(store: MemoryJellyfinStore(), deviceName: 'Test');
|
||||
expect(await tester.runAsync(() => jellyfinScheduleEpisodes(none, flow, 'jf7')), isNull);
|
||||
|
||||
await pumpPage(
|
||||
tester,
|
||||
snap: () => snapshot(
|
||||
list: [
|
||||
ScheduleEntry(
|
||||
airing: airing(777, 'Clover', 2, day(0, 9)),
|
||||
status: ScheduleStatus.notOnJellyfin,
|
||||
season: 1,
|
||||
jellyfinEpisode: 2,
|
||||
jellyfinSeriesId: 'jfC',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
expect(card(777, 2), findsOneWidget);
|
||||
expect(find.text('aired · not on Jellyfin yet'), findsOneWidget);
|
||||
expect(action(777, 2), findsNothing);
|
||||
await done(tester);
|
||||
});
|
||||
|
||||
testWidgets('solo: the button says Play and works', (tester) async {
|
||||
await pumpPage(tester, solo: true);
|
||||
expect(find.text('Put it up'), findsNothing);
|
||||
|
||||
@@ -374,19 +374,19 @@ void main() {
|
||||
final app = shellToastFor(ToastItem(id: 3, text: 'Bob joined', at: at), who);
|
||||
expect(app.who, isNull);
|
||||
expect(app.text, 'Bob joined');
|
||||
final froze = shellToastFor(ToastItem(id: 4, text: 'Video froze — reloaded', at: at), who);
|
||||
expect(froze.who, isNull);
|
||||
final notice = shellToastFor(ToastItem(id: 4, text: 'Shaders off', at: at), who);
|
||||
expect(notice.who, isNull);
|
||||
});
|
||||
|
||||
testWidgets('toasts slide in with an avatar and leave after their time', (tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: PartyToasts(life: Duration(seconds: 1)))));
|
||||
coord.toasts.value = [ToastItem(id: 1, text: 'Video froze — reloaded', at: DateTime.now())];
|
||||
coord.toasts.value = [ToastItem(id: 1, text: 'Shaders off', at: DateTime.now())];
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.text('Video froze — reloaded'), findsOneWidget);
|
||||
expect(find.text('Shaders off'), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Video froze — reloaded'), findsNothing);
|
||||
expect(find.text('Shaders off'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('status avatar fills its box at any size', (tester) async {
|
||||
|
||||
@@ -30,4 +30,16 @@ void main() {
|
||||
field.dispose();
|
||||
button.dispose();
|
||||
});
|
||||
|
||||
testWidgets('focus under a KeyboardClaim counts as typing', (tester) async {
|
||||
final claimed = FocusNode();
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: KeyboardClaim(child: Focus(focusNode: claimed, child: const SizedBox())),
|
||||
));
|
||||
expect(textInputFocused(), isFalse);
|
||||
claimed.requestFocus();
|
||||
await tester.pump();
|
||||
expect(textInputFocused(), isTrue);
|
||||
claimed.dispose();
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import 'package:watch_together/src/controls/uosc/controls.dart';
|
||||
import 'package:watch_together/src/controls/uosc/menu.dart';
|
||||
import 'package:watch_together/src/controls/uosc/style.dart';
|
||||
import 'package:watch_together/src/controls/uosc/timeline.dart';
|
||||
import 'package:watch_together/src/controls/volume.dart' show VolumeWheel;
|
||||
import 'package:watch_together/src/jellyfin/track_prefs.dart';
|
||||
import 'package:watch_together/src/playback/media_marks.dart';
|
||||
import 'package:watch_together/src/playback/playback_controller.dart' show RenderMode;
|
||||
@@ -16,6 +17,7 @@ import 'package:watch_together/src/shaders/shader_presets.dart';
|
||||
import 'package:watch_together/src/shell/settings_tab.dart';
|
||||
import 'package:watch_together/src/state/settings.dart';
|
||||
import 'package:watch_together/src/update/update_controller.dart';
|
||||
import 'package:watch_together/src/widgets/app_menu.dart';
|
||||
|
||||
// The experimental mpv-style (uosc) controls, piece by piece (never the
|
||||
// session screen or a media_kit `Video`).
|
||||
@@ -75,74 +77,76 @@ void main() {
|
||||
expect(formatUoscTime(const Duration(seconds: -4)), '00:00');
|
||||
});
|
||||
|
||||
group('cache hatching math', () {
|
||||
test('cached range runs from the position to the cache end', () {
|
||||
const size = Size(1000, 30);
|
||||
final r = cacheRect(size, position: m(6), buffered: m(9), duration: m(24))!;
|
||||
expect(r.left, 250);
|
||||
expect(r.right, 375);
|
||||
expect(r.top, 0);
|
||||
expect(r.bottom, 30);
|
||||
expect(cacheRect(size, position: m(6), buffered: m(6), duration: m(24)), isNull);
|
||||
expect(cacheRect(size, position: m(6), buffered: m(5), duration: m(24)), isNull, reason: 'behind');
|
||||
expect(cacheRect(size, position: m(6), buffered: m(9), duration: Duration.zero), isNull);
|
||||
// Clamped to the bar.
|
||||
expect(cacheRect(size, position: m(20), buffered: m(40), duration: m(24))!.right, 1000);
|
||||
group('timeline math (uosc)', () {
|
||||
test('uosc clock: trimmed by the duration, minus kept', () {
|
||||
expect(uoscClock(m(4, 22), const Duration(hours: 1, minutes: 52)), '00:04:22');
|
||||
expect(uoscClock(-const Duration(hours: 1, minutes: 47, seconds: 30), const Duration(hours: 1, minutes: 52)),
|
||||
'-01:47:30');
|
||||
expect(uoscClock(m(6), m(24)), '06:00');
|
||||
expect(uoscClock(-m(18), m(24)), '-18:00');
|
||||
expect(uoscClock(const Duration(seconds: 7), const Duration(seconds: 40)), '07');
|
||||
expect(uoscClock(Duration.zero, m(24)), '00:00');
|
||||
});
|
||||
|
||||
test('45° strokes, evenly spaced, covering the whole range', () {
|
||||
const r = Rect.fromLTRB(100, 0, 160, 30);
|
||||
final lines = hatchLines(r, spacing: 6);
|
||||
for (final (a, b) in lines) {
|
||||
expect(a.dy, r.bottom);
|
||||
expect(b.dy, r.top);
|
||||
expect(b.dx - a.dx, r.height, reason: '45°');
|
||||
// Every stroke crosses the range.
|
||||
expect(b.dx, greaterThan(r.left));
|
||||
expect(a.dx, lessThan(r.right));
|
||||
}
|
||||
for (var i = 1; i < lines.length; i++) {
|
||||
expect(lines[i].$1.dx - lines[i - 1].$1.dx, 6);
|
||||
}
|
||||
// No gap at either edge: the first stroke starts left of the range's
|
||||
// bottom-left corner diagonal, the last one within a step of the end.
|
||||
expect(lines.first.$1.dx, lessThanOrEqualTo(r.left - r.height + 6));
|
||||
expect(lines.last.$1.dx, greaterThan(r.right - 6));
|
||||
expect(hatchLines(Rect.zero), isEmpty);
|
||||
test('uncached part: from the cache end (or the position) to the end', () {
|
||||
expect(uncachedFrom(position: m(6), buffered: m(9), duration: m(24)), 9 / 24);
|
||||
expect(uncachedFrom(position: m(6), buffered: m(5), duration: m(24)), 6 / 24, reason: 'stale cache end');
|
||||
expect(uncachedFrom(position: m(6), buffered: Duration.zero, duration: m(24)), isNull, reason: 'unknown');
|
||||
expect(uncachedFrom(position: m(6), buffered: m(24), duration: m(24)), isNull, reason: 'cached to the end');
|
||||
expect(uncachedFrom(position: m(6), buffered: m(9), duration: Duration.zero), isNull);
|
||||
});
|
||||
|
||||
test('cache label: seconds ahead, under 60 s, not when cached to the end', () {
|
||||
expect(cacheLabel(position: m(6), buffered: m(6, 30), duration: m(24)), '30s');
|
||||
expect(cacheLabel(position: m(6), buffered: m(6), duration: m(24)), '0s');
|
||||
expect(cacheLabel(position: m(6), buffered: m(5), duration: m(24)), '0s');
|
||||
expect(cacheLabel(position: m(6), buffered: m(7), duration: m(24)), isNull, reason: 'a minute or more');
|
||||
expect(cacheLabel(position: m(23, 40), buffered: m(24), duration: m(24)), isNull, reason: 'eof cached');
|
||||
expect(cacheLabel(position: m(6), buffered: Duration.zero, duration: m(24)), isNull);
|
||||
});
|
||||
|
||||
test('geometry: times map around the position line', () {
|
||||
const g = UoscTimelineGeometry(width: 1000, size: 40, progress: 0.5);
|
||||
expect(g.lineLeft, 499);
|
||||
expect(g.lineRight, 501);
|
||||
expect(g.x(0), 0.5);
|
||||
expect(g.x(1), 1000 - 0.5);
|
||||
expect(g.x(0.75), greaterThan(g.lineRight), reason: 'past the line');
|
||||
expect(g.fractionAt(500), 0.5, reason: 'on the line: the position');
|
||||
expect(g.fractionAt(0), 0);
|
||||
expect(g.fractionAt(1000), 1);
|
||||
expect(g.fractionAt(g.x(0.25)), moreOrLessEquals(0.25, epsilon: 0.001));
|
||||
expect(g.fractionAt(g.x(0.75)), moreOrLessEquals(0.75, epsilon: 0.001));
|
||||
// Collapsing: the line widens to 20 px over the last eighth.
|
||||
expect(UoscTimelineGeometry.sizeAt(1), 40);
|
||||
expect(UoscTimelineGeometry.sizeAt(0), 2);
|
||||
expect(UoscTimelineGeometry.lineWidthAt(40), 2);
|
||||
expect(UoscTimelineGeometry.lineWidthAt(2), 20);
|
||||
});
|
||||
});
|
||||
|
||||
group('timeline', () {
|
||||
testWidgets('elapsed part filled, time boxes inside the bar', (tester) async {
|
||||
testWidgets('position line, elapsed left, remaining right, cache label', (tester) async {
|
||||
await tester.pumpWidget(host(UoscTimeline(
|
||||
position: m(6), duration: m(24), buffered: m(8), chapters: chapters, onSeek: (_) {})));
|
||||
position: m(6), duration: m(24), buffered: m(6, 30), chapters: chapters, onSeek: (_) {})));
|
||||
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
|
||||
expect(bar.width, 800, reason: 'the whole width (the test window)');
|
||||
expect(bar.height, UoscTimeline.height);
|
||||
final p = painter(tester);
|
||||
expect(p.played, 0.25);
|
||||
expect(p.hatched, isTrue);
|
||||
expect(p.cache(bar.size), cacheRect(bar.size, position: m(6), buffered: m(8), duration: m(24)));
|
||||
expect(boxText(tester, 'uosc-time'), '06:00');
|
||||
expect(boxText(tester, 'uosc-duration'), '24:00');
|
||||
final time = tester.getRect(find.byKey(const ValueKey('uosc-time')));
|
||||
expect(time.left, greaterThanOrEqualTo(bar.left));
|
||||
expect(time.top, greaterThanOrEqualTo(bar.top));
|
||||
expect(time.bottom, lessThanOrEqualTo(bar.bottom));
|
||||
expect(time.center.dx, lessThan(bar.left + 80), reason: 'at the left');
|
||||
expect(p.geometry.lineRight - p.geometry.lineLeft, UoscTimelineSpec.lineWidth, reason: 'a line, no fill');
|
||||
expect(p.visibility, 1);
|
||||
expect(p.uncached, 6.5 / 24);
|
||||
expect(p.elapsed, '06:00');
|
||||
expect(p.remaining, '-18:00');
|
||||
expect(p.cache, '30s');
|
||||
expect(p.textOpacity, 1);
|
||||
});
|
||||
|
||||
testWidgets('a diamond on the top edge at each chapter start but the first', (tester) async {
|
||||
testWidgets('a diamond on the top edge at each chapter start', (tester) async {
|
||||
await tester.pumpWidget(host(UoscTimeline(
|
||||
position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {})));
|
||||
expect(find.byKey(const ValueKey('uosc-chapter-0')), findsNothing, reason: 'starts at 0');
|
||||
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
|
||||
for (final c in chapters.skip(1)) {
|
||||
final mark = tester.getCenter(find.byKey(ValueKey('uosc-chapter-${c.index}')));
|
||||
expect(mark.dx, moreOrLessEquals(bar.left + bar.width * c.start.inSeconds / m(24).inSeconds, epsilon: 0.5),
|
||||
reason: c.title);
|
||||
expect(mark.dy, moreOrLessEquals(bar.top, epsilon: 0.5), reason: 'on the top edge');
|
||||
}
|
||||
expect(painter(tester).chapters, [for (final c in chapters) c.start.inSeconds / m(24).inSeconds]);
|
||||
});
|
||||
|
||||
testWidgets('hover: chapter title over the hover time, above the bar', (tester) async {
|
||||
@@ -150,8 +154,9 @@ void main() {
|
||||
position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {})));
|
||||
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
|
||||
final mouse = await mouseAt(tester, Offset.zero);
|
||||
final g = painter(tester).geometry;
|
||||
// 01:51 of 24:00: inside "Opening".
|
||||
await mouse.moveTo(Offset(bar.left + bar.width * (111 / (24 * 60)), bar.center.dy));
|
||||
await mouse.moveTo(Offset(bar.left + g.x(111 / (24 * 60)) + 0.2, bar.center.dy));
|
||||
await tester.pump();
|
||||
expect(boxText(tester, 'uosc-hover-chapter'), 'Opening');
|
||||
expect(boxText(tester, 'uosc-hover-time'), '01:51');
|
||||
@@ -168,21 +173,34 @@ void main() {
|
||||
expect(find.byKey(const ValueKey('uosc-hover-time')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('hidden controls: a thin line with chapter ticks, no boxes, no input', (tester) async {
|
||||
testWidgets('a press on a hovered diamond jumps to its chapter', (tester) async {
|
||||
final seeks = <Duration>[];
|
||||
await tester.pumpWidget(host(UoscTimeline(
|
||||
position: m(1), duration: m(24), chapters: chapters, onSeek: seeks.add)));
|
||||
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
|
||||
final at = Offset(bar.left + painter(tester).geometry.x(2 / 24) + 3, bar.top + 2);
|
||||
final mouse = await mouseAt(tester, at);
|
||||
await mouse.moveTo(at);
|
||||
await tester.pump();
|
||||
expect(painter(tester).hoveredChapter, 2);
|
||||
expect(boxText(tester, 'uosc-hover-time'), '02:00');
|
||||
await mouse.down(at);
|
||||
await mouse.up();
|
||||
await tester.pump();
|
||||
expect(seeks, [m(2)]);
|
||||
});
|
||||
|
||||
testWidgets('hidden controls: the progress line, no text, no input', (tester) async {
|
||||
final seeks = <Duration>[];
|
||||
await tester.pumpWidget(host(UoscTimeline(
|
||||
position: m(12), duration: m(24), buffered: m(14), chapters: chapters, expanded: false, onSeek: seeks.add)));
|
||||
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
|
||||
expect(bar.height, UoscTimeline.collapsedHeight);
|
||||
expect(painter(tester).played, 0.5);
|
||||
expect(painter(tester).hatched, isFalse);
|
||||
expect(find.byKey(const ValueKey('uosc-chapter-1')), findsNothing);
|
||||
for (final c in chapters.skip(1)) {
|
||||
final tick = tester.getRect(find.byKey(ValueKey('uosc-chapter-tick-${c.index}')));
|
||||
expect(tick.center.dx, moreOrLessEquals(bar.left + bar.width * c.start.inSeconds / m(24).inSeconds, epsilon: 0.5));
|
||||
expect(tick.width, lessThanOrEqualTo(2));
|
||||
}
|
||||
expect(find.byKey(const ValueKey('uosc-time')), findsNothing);
|
||||
final p = painter(tester);
|
||||
expect(p.played, 0.5);
|
||||
expect(p.visibility, 0);
|
||||
expect(p.geometry.lineWidth, UoscTimelineSpec.progressLineWidth);
|
||||
expect(p.textOpacity, 0);
|
||||
final mouse = await mouseAt(tester, Offset.zero);
|
||||
await mouse.moveTo(bar.center);
|
||||
await tester.pump();
|
||||
@@ -191,19 +209,26 @@ void main() {
|
||||
expect(seeks, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('drag seeks on every move and on release', (tester) async {
|
||||
testWidgets('press seeks, every drag move seeks, release holds the target', (tester) async {
|
||||
final seeks = <Duration>[];
|
||||
await tester.pumpWidget(host(UoscTimeline(
|
||||
position: m(1), duration: m(24), chapters: chapters, onSeek: seeks.add)));
|
||||
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
|
||||
final g = await tester.startGesture(Offset(bar.left + bar.width / 4, bar.center.dy), kind: PointerDeviceKind.mouse);
|
||||
expect(seeks, isEmpty, reason: 'press alone does not seek');
|
||||
await g.moveTo(Offset(bar.left + bar.width / 2, bar.center.dy));
|
||||
final g = painter(tester).geometry;
|
||||
final g0 = await tester.startGesture(Offset(bar.left + g.x(0.25), bar.center.dy), kind: PointerDeviceKind.mouse);
|
||||
expect(seeks, hasLength(1), reason: 'the press seeks');
|
||||
expect(seeks.single.inSeconds, closeTo(6 * 60, 2));
|
||||
await g0.moveTo(Offset(bar.left + bar.width / 2, bar.center.dy));
|
||||
await tester.pump();
|
||||
expect(boxText(tester, 'uosc-time'), '12:00', reason: 'follows the drag');
|
||||
await g.moveTo(Offset(bar.left + bar.width * 3 / 4, bar.center.dy));
|
||||
await g.up();
|
||||
expect(seeks, [m(12), m(18), m(18)]);
|
||||
expect(painter(tester).elapsed, uoscClock(seeks.last, m(24)), reason: 'follows the drag');
|
||||
await g0.moveTo(Offset(bar.left + bar.width * 3 / 4, bar.center.dy));
|
||||
await g0.up();
|
||||
await tester.pump();
|
||||
expect(seeks, hasLength(3), reason: 'release at the last move: no repeat');
|
||||
expect(painter(tester).played, moreOrLessEquals(seeks.last.inMilliseconds / m(24).inMilliseconds, epsilon: 0.01),
|
||||
reason: 'shows the target until the position catches up');
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
expect(painter(tester).played, 1 / 24, reason: 'never confirmed: back to the position');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -244,7 +269,9 @@ void main() {
|
||||
await mouse.moveTo(const Offset(420, 220));
|
||||
await tester.pump();
|
||||
expect(buttonsOpacity(tester), 1);
|
||||
expect(tester.getRect(find.byKey(const ValueKey('uosc-timeline'))).height, UoscTimeline.height);
|
||||
await tester.pump(UoscTimeline.animation);
|
||||
expect(tester.getRect(find.byKey(const ValueKey('uosc-timeline'))).height, UoscTimeline.height,
|
||||
reason: 'grown back (animated)');
|
||||
});
|
||||
|
||||
testWidgets('a long drag keeps the controls up', (tester) async {
|
||||
@@ -340,9 +367,52 @@ void main() {
|
||||
await tester.sendEventToBinding(PointerScrollEvent(
|
||||
kind: PointerDeviceKind.mouse,
|
||||
position: tester.getCenter(find.byKey(const ValueKey('uosc-volume'))),
|
||||
scrollDelta: const Offset(0, -20)));
|
||||
scrollDelta: const Offset(0, -60)));
|
||||
await tester.pump();
|
||||
expect(got, [0, 80, 85]);
|
||||
expect(got, [0, 80, 85], reason: 'a notch is one step');
|
||||
for (var i = 0; i < 3; i++) {
|
||||
await tester.sendEventToBinding(PointerScrollEvent(
|
||||
kind: PointerDeviceKind.mouse,
|
||||
position: tester.getCenter(find.byKey(const ValueKey('uosc-volume'))),
|
||||
scrollDelta: const Offset(0, 20)));
|
||||
}
|
||||
await tester.pump();
|
||||
expect(got, [0, 80, 85, 80], reason: 'smooth deltas add up to a step');
|
||||
});
|
||||
});
|
||||
|
||||
group('volume wheel', () {
|
||||
testWidgets('anywhere: vertical steps; lists scroll instead; sideways is nothing', (tester) async {
|
||||
final steps = <int>[];
|
||||
final list = ScrollController();
|
||||
addTearDown(list.dispose);
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: VolumeWheel(
|
||||
onStep: steps.add,
|
||||
child: Stack(children: [
|
||||
const Positioned.fill(child: ColoredBox(color: Colors.black)),
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 200,
|
||||
height: 200,
|
||||
child: ListView(controller: list, children: [for (var i = 0; i < 50; i++) Text('row $i')]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
));
|
||||
Future<void> wheel(Offset at, Offset delta) =>
|
||||
tester.sendEventToBinding(PointerScrollEvent(kind: PointerDeviceKind.mouse, position: at, scrollDelta: delta));
|
||||
await wheel(const Offset(100, 400), const Offset(0, -60));
|
||||
await wheel(const Offset(100, 400), const Offset(0, 60));
|
||||
expect(steps, [1, -1], reason: 'up louder, down quieter');
|
||||
await wheel(const Offset(100, 400), const Offset(60, 0));
|
||||
await wheel(const Offset(100, 400), const Offset(60, -10));
|
||||
expect(steps, [1, -1], reason: 'sideways');
|
||||
await wheel(const Offset(700, 100), const Offset(0, 60));
|
||||
await tester.pump();
|
||||
expect(steps, [1, -1], reason: 'the list took it');
|
||||
expect(list.offset, greaterThan(0));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -466,12 +536,12 @@ void main() {
|
||||
));
|
||||
final design = find.byKey(const ValueKey('player-design'));
|
||||
expect(find.text('Player design'), findsOneWidget);
|
||||
expect(tester.widget<DropdownButton<bool>>(design).value, isFalse);
|
||||
expect(tester.widget<AppSelect<bool>>(design).value, isFalse);
|
||||
await tester.tap(design);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('uosc').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(s.mpvControls.value, isTrue);
|
||||
expect(tester.widget<DropdownButton<bool>>(design).value, isTrue);
|
||||
expect(tester.widget<AppSelect<bool>>(design).value, isTrue);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:watch_together/src/playback/playback_controller.dart';
|
||||
|
||||
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
||||
const _referer = 'https://www.bilibili.com/';
|
||||
|
||||
/// Separate video-only and audio-only files, like Bilibili's DASH .m4s.
|
||||
Future<(String, String)> _media() async {
|
||||
final v = '$_tmp/ytdl_headers_v.mp4', a = '$_tmp/ytdl_headers_a.m4a';
|
||||
for (final (path, args) in [
|
||||
(v, ['-f', 'lavfi', '-i', 'testsrc2=size=320x180:rate=24', '-c:v', 'libx264', '-preset', 'ultrafast']),
|
||||
(a, ['-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000', '-c:a', 'aac']),
|
||||
]) {
|
||||
if (await File(path).exists()) continue;
|
||||
final r = await Process.run('ffmpeg', ['-y', '-v', 'error', ...args, '-t', '30', path]);
|
||||
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
|
||||
}
|
||||
return (v, a);
|
||||
}
|
||||
|
||||
/// A CDN like Bilibili's: 403 without the page's Referer.
|
||||
class _Cdn {
|
||||
_Cdn(this._server, this._files) {
|
||||
_server.listen(_serve);
|
||||
}
|
||||
final HttpServer _server;
|
||||
final Map<String, String> _files;
|
||||
int ok = 0, forbidden = 0;
|
||||
|
||||
String url(String name) => 'http://127.0.0.1:${_server.port}/$name';
|
||||
|
||||
Future<void> _serve(HttpRequest req) async {
|
||||
final res = req.response;
|
||||
final file = _files[req.uri.pathSegments.lastOrNull];
|
||||
if (file == null || req.headers.value('referer') != _referer) {
|
||||
forbidden++;
|
||||
res.statusCode = HttpStatus.forbidden;
|
||||
await res.close();
|
||||
return;
|
||||
}
|
||||
ok++;
|
||||
final bytes = await File(file).readAsBytes();
|
||||
var start = 0, end = bytes.length - 1;
|
||||
final range = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(req.headers.value('range') ?? '');
|
||||
res.headers.set('accept-ranges', 'bytes');
|
||||
if (range != null) {
|
||||
start = int.tryParse(range[1]!) ?? 0;
|
||||
end = int.tryParse(range[2]!) ?? end;
|
||||
res.statusCode = HttpStatus.partialContent;
|
||||
res.headers.set('content-range', 'bytes $start-$end/${bytes.length}');
|
||||
}
|
||||
res.contentLength = end - start + 1;
|
||||
res.add(bytes.sublist(start, end + 1));
|
||||
await res.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// A stand-in yt-dlp printing what yt-dlp gives for a split-track page:
|
||||
/// `requested_formats` with per-format `http_headers`, none at the top.
|
||||
Future<String> _fakeYtDlp(_Cdn cdn, {required bool headers}) async {
|
||||
final dir = await Directory('$_tmp/ytdl_headers_fake').create(recursive: true);
|
||||
final h = headers ? {'Referer': _referer, 'User-Agent': 'fake-agent'} : <String, String>{};
|
||||
final json = jsonEncode({
|
||||
'id': 'BV1', 'title': 'Fake page', 'duration': 30, 'extractor': 'fake',
|
||||
'format_id': 'v+a', 'ext': 'mp4',
|
||||
'requested_formats': [
|
||||
{'format_id': 'v', 'url': cdn.url('v.m4s'), 'protocol': 'https', 'ext': 'mp4',
|
||||
'vcodec': 'avc1.64001F', 'acodec': 'none', 'width': 320, 'height': 180, 'http_headers': h},
|
||||
{'format_id': 'a', 'url': cdn.url('a.m4s'), 'protocol': 'https', 'ext': 'm4a',
|
||||
'vcodec': 'none', 'acodec': 'mp4a.40.2', 'http_headers': h},
|
||||
],
|
||||
});
|
||||
final f = File('${dir.path}/yt-dlp-${headers ? 'h' : 'n'}');
|
||||
await f.writeAsString("#!/bin/sh\ncat <<'EOF'\n$json\nEOF\n");
|
||||
await Process.run('chmod', ['+x', f.path]);
|
||||
return f.path;
|
||||
}
|
||||
|
||||
void main() {
|
||||
late _Cdn cdn;
|
||||
late HttpServer server;
|
||||
|
||||
setUpAll(() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final (v, a) = await _media();
|
||||
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
cdn = _Cdn(server, {'v.m4s': v, 'a.m4s': a});
|
||||
});
|
||||
tearDownAll(() => server.close(force: true));
|
||||
|
||||
test(
|
||||
"mpv's yt-dlp hook sends each format's Referer to the CDN",
|
||||
timeout: const Timeout(Duration(minutes: 1)),
|
||||
() async {
|
||||
final p = PlaybackController();
|
||||
try {
|
||||
await p.init();
|
||||
expect(p.pinMismatches, isEmpty, reason: 'stream-lavf-o and the rest took effect');
|
||||
await p.useYtDlp(await _fakeYtDlp(cdn, headers: true));
|
||||
await p.openUrl('https://www.bilibili.com/video/BV1fake');
|
||||
final end = DateTime.now().add(const Duration(seconds: 20));
|
||||
while (DateTime.now().isBefore(end) && p.duration.peek() == Duration.zero) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
expect(p.duration.peek(), greaterThan(Duration.zero), reason: 'both tracks opened');
|
||||
expect(cdn.forbidden, 0, reason: 'every request had the Referer');
|
||||
expect(cdn.ok, greaterThanOrEqualTo(2));
|
||||
} finally {
|
||||
await p.dispose();
|
||||
}
|
||||
},
|
||||
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
|
||||
);
|
||||
|
||||
test(
|
||||
'a 403 is a load error, not a reconnect loop',
|
||||
timeout: const Timeout(Duration(minutes: 1)),
|
||||
() async {
|
||||
final p = PlaybackController();
|
||||
final errors = <String>[];
|
||||
p.onLoadError = errors.add;
|
||||
try {
|
||||
await p.init();
|
||||
var logs = 0;
|
||||
final sub = p.player.stream.log.listen((_) => logs++);
|
||||
await p.useYtDlp(await _fakeYtDlp(cdn, headers: false));
|
||||
final before = cdn.forbidden;
|
||||
await p.openUrl('https://www.bilibili.com/video/BV2fake');
|
||||
await Future.delayed(const Duration(seconds: 8));
|
||||
await sub.cancel();
|
||||
// ignore: avoid_print
|
||||
print('403s: ${cdn.forbidden - before}, log lines: $logs, errors: $errors');
|
||||
expect(cdn.forbidden - before, lessThan(10), reason: 'no retry loop on 403');
|
||||
expect(errors, hasLength(1));
|
||||
// The status only when ffmpeg logs through this mpv (see _onLog).
|
||||
expect(errors.single, anyOf(contains('403'), contains('could not be opened')));
|
||||
expect(logs, lessThan(30), reason: 'warnings and errors only');
|
||||
} finally {
|
||||
await p.dispose();
|
||||
}
|
||||
},
|
||||
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
|
||||
);
|
||||
}
|
||||
+688
-92
File diff suppressed because it is too large.
Load diff
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user