Compare commits
| 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 | ||
|
|
1fd4081461 | ||
|
|
2d39018a73 | ||
|
|
059b7dd306 | ||
|
|
0115d54ab8 | ||
|
|
fe5d6c3b3f | ||
|
|
05f9e794a9 | ||
|
|
75065f90cd | ||
|
|
e0db5cc655 | ||
|
|
8186fbb5d3 | ||
|
|
8b81da4069 | ||
|
|
dbfd351ee4 | ||
|
|
a4be24a2a5 | ||
|
|
b038706dff | ||
|
|
b2b77a6b3f | ||
|
|
d002ff6941 | ||
|
|
151ec95289 | ||
|
|
745717a4a0 | ||
|
|
9ddd85de90 | ||
|
|
24fc137e4f | ||
|
|
9a4b04672b | ||
|
|
ff51d52fbb | ||
|
|
f2272d7980 | ||
|
|
cf80bb2167 | ||
|
|
8effa573a0 | ||
|
|
5f22be6c20 | ||
|
|
740273745b | ||
|
|
27b4e1dc0c | ||
|
|
fce392e425 | ||
|
|
d8fd464e33 |
No files matched your search
@@ -1,6 +1,9 @@
|
|||||||
name: ci
|
name: ci
|
||||||
on:
|
on:
|
||||||
|
# Branches only: a release tag points at a commit CI already checked on
|
||||||
|
# main, and release.yml builds it.
|
||||||
push:
|
push:
|
||||||
|
branches: ['**']
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -55,5 +58,13 @@ jobs:
|
|||||||
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/setup_flutter_windows.ps1
|
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/setup_flutter_windows.ps1
|
||||||
- name: Updater tests
|
- name: Updater tests
|
||||||
shell: powershell
|
shell: powershell
|
||||||
working-directory: apps/desktop
|
working-directory: packages/core
|
||||||
run: flutter test test/updater_test.dart
|
run: |
|
||||||
|
dart pub get
|
||||||
|
if ($LASTEXITCODE) { exit 1 }
|
||||||
|
dart test test/updater_test.dart
|
||||||
|
# The release build, without publishing: a broken Windows build shows
|
||||||
|
# here, not only on a tag.
|
||||||
|
- name: Windows build
|
||||||
|
shell: powershell
|
||||||
|
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/package_windows.ps1
|
||||||
@@ -17,6 +17,7 @@ coverage/
|
|||||||
# Build artifacts that should never be committed
|
# Build artifacts that should never be committed
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
*.zip
|
*.zip
|
||||||
|
/dist/
|
||||||
app.*.symbols
|
app.*.symbols
|
||||||
app.*.map.json
|
app.*.map.json
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
# watch-together
|
<p align="center">
|
||||||
|
<img src="apps/desktop/assets/icon/icon_256.png" width="128" height="128" alt="Starsync icon">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1 align="center">Starsync</h1>
|
||||||
|
|
||||||
Watch a video with friends, in sync, with the quality of a real mpv player.
|
Watch a video with friends, in sync, with the quality of a real mpv player.
|
||||||
Pick from your Jellyfin library, paste any direct video link, or pick a file on your own disk;
|
Pick from your Jellyfin library, paste any direct video link, or pick a file on your own disk;
|
||||||
@@ -22,7 +26,8 @@ How it fits together: [docs/HOW-IT-WORKS.md](docs/HOW-IT-WORKS.md).
|
|||||||
From the Releases page of this repo:
|
From the Releases page of this repo:
|
||||||
|
|
||||||
- Linux: `watch-together-<version>-linux-x64.tar.gz`. Extract, run
|
- Linux: `watch-together-<version>-linux-x64.tar.gz`. Extract, run
|
||||||
`watch_together`. Needs mpv's library installed (`mpv` or `libmpv2`).
|
`watch_together`. On start it adds itself to your app launcher (and its
|
||||||
|
icon), so the window shows its own icon on Wayland. Needs mpv's library installed (`mpv` or `libmpv2`).
|
||||||
- Windows: `watch-together-<version>-windows-x64.zip`. Extract, run
|
- Windows: `watch-together-<version>-windows-x64.zip`. Extract, run
|
||||||
`watch_together.exe`. mpv is bundled. New and not yet tried on a real
|
`watch_together.exe`. mpv is bundled. New and not yet tried on a real
|
||||||
Windows desktop; reports welcome.
|
Windows desktop; reports welcome.
|
||||||
@@ -31,48 +36,88 @@ Update from the app: when a newer version exists, an update indicator
|
|||||||
appears in the strip at the top of the window and opens the update card. "Update and restart" downloads,
|
appears in the strip at the top of the window and opens the update card. "Update and restart" downloads,
|
||||||
verifies, applies in place and restarts; the install folder must be writable
|
verifies, applies in place and restarts; the install folder must be writable
|
||||||
by you. Settings has Check now and an auto-check toggle. From a terminal:
|
by you. Settings has Check now and an auto-check toggle. From a terminal:
|
||||||
`watch_together --upgrade`, or the shipped `update.sh` / `update.bat`. A
|
`./watch-together-server --upgrade` (no display needed, so it works on a
|
||||||
|
server box too), `watch_together --upgrade`, or the shipped `update.sh` /
|
||||||
|
`update.bat` (which run the first). Each updates the whole folder, app and
|
||||||
|
server; restart whichever is running to use the new version. A
|
||||||
release with a newer protocol is a required update: everyone on a server
|
release with a newer protocol is a required update: everyone on a server
|
||||||
must run the same protocol version, and a mismatch is refused with a message
|
must run the same protocol version, and a mismatch is refused with a message
|
||||||
telling you to update.
|
telling you to update.
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
Three ways in, all at the top of the Library when you are not in a server.
|
||||||
|
None of them needs Jellyfin or an account.
|
||||||
|
|
||||||
|
- **Join a friend** (most people): your friend sends you an address and,
|
||||||
|
maybe, a password. See [Join](#join).
|
||||||
|
- **Host**: you have the video and friends watch it with you. See
|
||||||
|
[Host from the app](#host-from-the-app).
|
||||||
|
- **Watch solo**: just play something from the Library. It plays for you
|
||||||
|
alone, with everything a server has (Jellyfin progress, next episode,
|
||||||
|
skip buttons); nothing is reachable from outside this computer. Invite
|
||||||
|
friends in the strip turns it into a hosted server (the Host card's port
|
||||||
|
and password) and the video carries on from where it was; Stop ends it.
|
||||||
|
|
||||||
## The window
|
## The window
|
||||||
|
|
||||||
One window. Along the top, the strip: your avatar (click it for your
|
One window. Along the top, the strip: your avatar (click it for your
|
||||||
profile: name, picture with a crop editor, colour, and links to the
|
profile: name, picture with a crop editor, colour, and links to the
|
||||||
settings), the Library / Schedule / Settings tabs, and the party. Offline
|
settings), the Library / Schedule / Settings tabs, and the party. Offline
|
||||||
the party side has Join and Host; in a server it shows everyone's avatar
|
the party side has Join a friend and Host; watching solo it says Solo,
|
||||||
|
with Invite friends and Stop; in a server it shows everyone's avatar
|
||||||
with a ring for their state (green ready, amber buffering, grey not ready,
|
with a ring for their state (green ready, amber buffering, grey not ready,
|
||||||
dimmed reconnecting; a crown on whoever hosts, a tower on whoever
|
dimmed reconnecting, dimmed with a moon sitting out; a crown on whoever
|
||||||
passes the video on). Click the avatars for the member list (force ready,
|
hosts, a tower on whoever passes the video on). Click the avatars for the
|
||||||
kick, "Copy invite address"; when you host it lists this computer's
|
member list (force ready, Nudge, Sit out, "Copy invite address"; when you host it lists this computer's
|
||||||
network addresses with the port, and friends outside your network need
|
network addresses with the port, and friends outside your network need
|
||||||
that port forwarded or a server they can all reach, like a VPS). Beside them the server and "ready n/m", the chat button with unread
|
that port forwarded or a server they can all reach, like a VPS). Beside them the server and "ready n/m", the chat button with unread
|
||||||
count, and Leave. Empty strip space drags the window.
|
count, and Leave. Empty strip space drags the window.
|
||||||
|
|
||||||
The player fills the window under the strip. Pick a tab and it shrinks to
|
The player fills the window under the strip. Pick a tab and it shrinks to
|
||||||
a mini player in a corner: drag it anywhere and it snaps to the nearest
|
a mini player in a corner: drag it anywhere and it snaps to the nearest
|
||||||
corner; double-click or ⤢ makes it big again, × stops watching (you stay in
|
corner; double-click or ⤢ makes it big again, × sits you out (below) and
|
||||||
the server, muted) and the strip's screen button brings it back. F or
|
× again closes it; the strip's screen button brings it back. F or
|
||||||
double-click on the picture goes fullscreen (the strip hides, the party
|
double-click on the picture goes fullscreen (the strip hides, the party
|
||||||
shows in a corner while paused, the window buttons sit in the player's top
|
shows in a corner while paused, the window buttons sit in the player's top
|
||||||
bar while its controls are up), Esc leaves it. The controls show the
|
bar while its controls are up), Esc leaves it. The controls show the
|
||||||
show's cover, title and episode top left (guests get the cover too); before
|
show's cover, title and episode top left (a web video: its thumbnail and
|
||||||
|
name; guests get the art too; right-click to copy the title, or the link
|
||||||
|
when you have it, or open it in the browser or Jellyfin); before
|
||||||
a video first plays, its banner fills the picture until playback starts.
|
a video first plays, its banner fills the picture until playback starts.
|
||||||
One pill above the controls says what playback waits for ("Press Space
|
One pill bottom centre, just above the controls, says what playback waits for ("Press Space
|
||||||
when you're ready", "Waiting for Bob and Mika · 2/3 ready", "Bob is
|
when you're ready", "Waiting for Bob and Mika · 2/3 ready", "Bob is
|
||||||
loading…") or what went wrong, with Retry or Cancel when it helps. Chat and activity open in a panel on the right (chat button or
|
loading…") or what went wrong, with Retry or Cancel when it helps. Chat and activity open in a panel on the right (chat button or
|
||||||
Tab); joins, leaves, chat and other news also pop up bottom left with the
|
Tab), ready to type; it shows messages (select text to copy it, or
|
||||||
person's avatar.
|
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
|
||||||
|
(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
|
Your name and picture change for everyone in the server right away; the
|
||||||
colour is yours only.
|
colour is yours only.
|
||||||
|
|
||||||
|
**Sitting out.** Step away without leaving: Sit out (your profile, your
|
||||||
|
row in the member list, or × on the mini player) unloads the video on your
|
||||||
|
end, and nobody waits for you any more; you stay in chat. Anyone can sit
|
||||||
|
anyone out, the host too ("Alice sat you out" tells you who). The player
|
||||||
|
then shows the cover, title and where the others are; Join back (or Space)
|
||||||
|
loads the video there and readies you. Settings → Party & network → "When idle,
|
||||||
|
sit out instead of just unready" does it after 10 idle minutes.
|
||||||
|
|
||||||
|
**Nudge.** Nudge in the member list pokes someone, sitting out or not:
|
||||||
|
only they hear it (a sound, a pop-up, and the taskbar entry asks for
|
||||||
|
attention when the window is in the background).
|
||||||
|
|
||||||
## Host from the app
|
## Host from the app
|
||||||
|
|
||||||
Host in the strip: set a port and an optional password (blank = anyone can
|
Host in the strip: set a port and an optional password (blank = anyone can
|
||||||
join), then Host. The app starts a server inside itself and joins it; you
|
join), then Host. The app starts a server inside itself and joins it; you
|
||||||
are the host and nobody can kick you. Nothing is up yet: pick what to
|
are the host (the crown). Nothing is up yet: pick what to
|
||||||
watch in the Library tab (below) and put it up. Leaving or
|
watch in the Library tab (below) and put it up. Leaving or
|
||||||
closing the app stops the server. Friends need to reach your machine: open
|
closing the app stops the server. Friends need to reach your machine: open
|
||||||
the TCP port on your router (or use a LAN or VPN address). Friends open
|
the TCP port on your router (or use a LAN or VPN address). Friends open
|
||||||
@@ -83,10 +128,13 @@ disk. Nothing is uploaded or stored anywhere.
|
|||||||
|
|
||||||
## Library
|
## Library
|
||||||
|
|
||||||
What to watch, from three sources (the switch at the top; Ctrl+V outside
|
What to watch, from three sources (the switch at the top). Drop a video,
|
||||||
a field pastes a link into Link):
|
a folder or a link anywhere on the window, or press Ctrl+V outside a
|
||||||
|
text field, and a small card asks how to open it (put it up, add it to
|
||||||
|
Up next, add the folder to Files, or fill Link).
|
||||||
|
|
||||||
- **Jellyfin**: your linked servers (see below). Library chips beside the
|
- **Jellyfin**: your linked servers (see below); with none linked it
|
||||||
|
points to Files and Link and offers Jellyfin as optional. Library chips beside the
|
||||||
switch, then one **Continue** row (what you were watching and what comes
|
switch, then one **Continue** row (what you were watching and what comes
|
||||||
next, one card per series, the one you watched most recently first;
|
next, one card per series, the one you watched most recently first;
|
||||||
series not touched for two months drop out) and **Latest in** each
|
series not touched for two months drop out) and **Latest in** each
|
||||||
@@ -98,15 +146,75 @@ a field pastes a link into Link):
|
|||||||
details open on the right with "Put it up · S1E3", mark watched /
|
details open on the right with "Put it up · S1E3", mark watched /
|
||||||
unwatched, remove from Continue and the episode list (click an episode
|
unwatched, remove from Continue and the episode list (click an episode
|
||||||
to choose it). Double-click a card or an episode to put it up at once.
|
to choose it). Double-click a card or an episode to put it up at once.
|
||||||
- **Link**: paste an http(s) link. The preview shows what it is (title and
|
Right-click a card or an episode to copy its title or open it in
|
||||||
thumbnail for YouTube, else the file name); in a server, pick how the
|
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
|
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
|
||||||
- **Files**: video folders you add on this computer, grouped by series,
|
or open it). A link seen before shows its preview at once, without
|
||||||
or any file through Browse. The others stream it from you.
|
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. Offline the library browses but nothing
|
Put it up and the player opens. Not in a server, the buttons say Play
|
||||||
goes up until you join or host ("Join or host to watch together").
|
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
|
## Dedicated server
|
||||||
|
|
||||||
@@ -125,14 +233,37 @@ then everyone (you included) uses Join.
|
|||||||
| `--name` | `server` | name reported by `/health` |
|
| `--name` | `server` | name reported by `/health` |
|
||||||
| `--media` | none | local file to serve as the starting video |
|
| `--media` | none | local file to serve as the starting video |
|
||||||
| `--advertise-host` | none | host put in file URLs instead of the one each viewer dialled |
|
| `--advertise-host` | none | host put in file URLs instead of the one each viewer dialled |
|
||||||
| `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`); replaces the flags |
|
| `--[no-]yt-dlp` | on | read web pages (YouTube, X, …) with yt-dlp when the server fetches a link |
|
||||||
|
| `--yt-dlp-dir` | next to the binary | writable folder for the server's yt-dlp (systemd: the unit's `StateDirectory`) |
|
||||||
|
| `--yt-dlp-cookies` | none | cookies.txt or cookie-extension JSON export for that yt-dlp (sites that refuse guests); copied into the yt-dlp folder at start, removed from it when not given. **An account login**: keep it private, use a throwaway account for YouTube |
|
||||||
|
| `--log-dir` | `logs` next to `--config`, else next to the binary (systemd: the unit's `StateDirectory`) | folder for the log files (`server-YYYY-MM-DD.log` and the session log `session-YYYY-MM-DD.log`, kept 7 days; passwords, tokens and cookies left out) |
|
||||||
|
| `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`, `ytDlp`, `ytDlpDir`, `ytDlpCookies`); replaces the flags |
|
||||||
|
| `--upgrade` | | update the install folder to the latest release ([Install and update](#install-and-update)), then exit |
|
||||||
|
| `--check-update` | | print whether a newer release exists, then exit |
|
||||||
|
|
||||||
It prints `READY port=... name=...` once listening and stops cleanly on
|
It prints `READY port=... name=...` once listening and stops cleanly on
|
||||||
Ctrl+C or SIGTERM. A dedicated server has no host: anyone can kick anyone.
|
Ctrl+C or SIGTERM. A dedicated server has no host.
|
||||||
To run it as a service, unpack the tarball to `/opt/watch-together` and use
|
To run it as a service, unpack the tarball to `/opt/watch-together` and use
|
||||||
[deploy/watch-together.service](deploy/watch-together.service) (copy it to
|
[deploy/watch-together.service](deploy/watch-together.service) (copy it to
|
||||||
`/etc/systemd/system/`, edit the flags, `systemctl enable --now watch-together`).
|
`/etc/systemd/system/`, edit the flags, `systemctl enable --now watch-together`).
|
||||||
|
|
||||||
|
Something not playing? Settings → About → **Copy diagnostics** puts the
|
||||||
|
app's recent log (passwords, tokens and cookies left out) on the
|
||||||
|
clipboard: paste it to whoever helps you, from both sides if you can.
|
||||||
|
|
||||||
|
While you are in a server, the app also sends its log to that server, which
|
||||||
|
merges everyone's lines and its own into one session log
|
||||||
|
(`session-YYYY-MM-DD.log` next to the server's logs; the hosting app keeps
|
||||||
|
it in its own logs folder), so a problem can be read afterwards from every
|
||||||
|
side. Chat is never logged anywhere, nor passwords, tokens and cookies.
|
||||||
|
When something goes wrong, press **Mark a problem** (Ctrl+Shift+B, also in
|
||||||
|
the player's ⚙ and Settings → About) and add a few words if you like: a
|
||||||
|
marker with your name goes in the session log at that moment. In a server,
|
||||||
|
Copy diagnostics can add the session log of the last 6 hours (any member
|
||||||
|
may read it). Don't want to share? Settings → About → **Share diagnostics
|
||||||
|
with the server** off: your log stays on your machine (the server only
|
||||||
|
notes that you don't share; a problem you mark still goes in).
|
||||||
|
|
||||||
## Join
|
## Join
|
||||||
|
|
||||||
Join in the strip. Servers you got into are listed first (up to 6), with
|
Join in the strip. Servers you got into are listed first (up to 6), with
|
||||||
@@ -149,9 +280,9 @@ of help: Profile; Party & network (the Host card's default port and
|
|||||||
password, recent servers with remove); Player (below); Jellyfin &
|
password, recent servers with remove); Player (below); Jellyfin &
|
||||||
integrations (linked Jellyfin servers, below, the AnimeFlow connection:
|
integrations (linked Jellyfin servers, below, the AnimeFlow connection:
|
||||||
"connected as <name>" with what you may do, or why not, and playing the
|
"connected as <name>" with what you may do, or why not, and playing the
|
||||||
next episode automatically); Notifications & sounds (what pops up; there
|
next episode automatically); Notifications & sounds (what pops up; the sound
|
||||||
is nothing to switch yet); Updates; Keyboard shortcuts (read-only); About
|
volume and sound set, and every sound with a preview, its own set and a switch); Updates; Keyboard shortcuts (read-only); About
|
||||||
(the version).
|
(the version; resting the mouse on the Settings tab shows it too).
|
||||||
|
|
||||||
Player settings live in the player: ⚙ at the right end of its controls
|
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
|
(both designs) opens them, and Settings → Player has the same ones (plus
|
||||||
@@ -162,10 +293,13 @@ hardware decoding (auto, off or custom; the env var `SYNC_RELAY_HWDEC`
|
|||||||
overrides it); in the player also the subtitle and audio track. Every
|
overrides it); in the player also the subtitle and audio track. Every
|
||||||
change applies right away.
|
change applies right away.
|
||||||
|
|
||||||
Keys while the player fills the window: Space = ready / unready, Tab =
|
Anywhere in a server (unless you are typing): Space = ready / unready,
|
||||||
chat & activity, Left/Right = seek 10 s, Shift+Right = skip 1:30,
|
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 =
|
PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Esc =
|
||||||
close the chat or fullscreen, else ask to leave, Ctrl+R = reload video,
|
close the chat, then fullscreen, then shrink the player to mini; from the
|
||||||
|
mini it asks to leave, Ctrl+R = reload video,
|
||||||
`<` / `>` or Shift+P / Shift+N = previous / next Jellyfin episode,
|
`<` / `>` or Shift+P / Shift+N = previous / next Jellyfin episode,
|
||||||
Ctrl+1..6 = Anime4K mode A, B, C, A+A, B+B, C+A, Ctrl+0 = shaders off.
|
Ctrl+1..6 = Anime4K mode A, B, C, A+A, B+B, C+A, Ctrl+0 = shaders off.
|
||||||
Every seek, skip and chapter jump moves everyone.
|
Every seek, skip and chapter jump moves everyone.
|
||||||
@@ -174,27 +308,37 @@ Controls: the seek bar marks chapter starts with a diamond; hovering it
|
|||||||
shows the time and the chapter title. The chapters button lists them
|
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
|
(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
|
Subtitles buttons (with the track count) list tracks as "Japanese · E-AC3
|
||||||
2.0 · title"; subtitles have Off.
|
2.0 · title"; subtitles have Off. Volume (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,
|
The uosc player design (⚙ → Player design, Standard by default,
|
||||||
switches right away): video controls modeled on mpv's uosc theme. A thick
|
switches right away): video controls modeled on mpv's uosc theme. A
|
||||||
timeline across the bottom shows the played part, the cached part ahead
|
translucent timeline across the bottom marks the position with a thin
|
||||||
(hatched), chapter starts as diamonds and the time; hovering it shows the
|
line, hatches the part not downloaded yet, shows chapter starts as
|
||||||
chapter title and the time there. With the controls hidden it stays as a
|
diamonds (click one to jump), the elapsed time left, the time remaining
|
||||||
thin line (unless "Progress line when hidden" is off). Above it flat buttons: ≡ menu, Subtitles and Audio (with the
|
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"),
|
track count), the shader badge (the active Anime4K mode, e.g. "A HQ"),
|
||||||
ready, chapters, 1:30, previous / next episode, change video, volume, ⚙
|
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.
|
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
|
jumps to the end of the opening), during the ending "Skip ED" (to the end
|
||||||
of the ending only; what comes after is yours to skip). The opening and
|
of the ending only; what comes after is yours to skip). "Skip OP" and
|
||||||
|
"Skip 1:30" show for 5 seconds when they come up, then only while the
|
||||||
|
controls are up (move the mouse); "Skip ED" and "Next episode" show only
|
||||||
|
while the controls are up. The opening and
|
||||||
ending come from Jellyfin's intro / outro segments when your Jellyfin has
|
ending come from Jellyfin's intro / outro segments when your Jellyfin has
|
||||||
them (needs a segment plugin, e.g. Intro Skipper), else from chapter names
|
them (needs a segment plugin, e.g. Intro Skipper), else from chapter names
|
||||||
(OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening, on
|
(OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening or
|
||||||
anything longer than 20 minutes, "Skip 1:30" floats there during the first
|
any chapters, on anything longer than 20 minutes, "Skip 1:30" floats there
|
||||||
6 minutes.
|
during the first 6 minutes.
|
||||||
|
|
||||||
## Ports
|
## Ports
|
||||||
|
|
||||||
@@ -236,11 +380,14 @@ Watching:
|
|||||||
directly with their own sign-in. Jellyfin tracks progress per account:
|
directly with their own sign-in. Jellyfin tracks progress per account:
|
||||||
your watched state, Continue watching and resume point follow what you
|
your watched state, Continue watching and resume point follow what you
|
||||||
saw (reported on start, every 10 s, on pause, resume, seek and track
|
saw (reported on start, every 10 s, on pause, resume, seek and track
|
||||||
change, and when it stops).
|
change, and when it stops). Watched means you actually played most of
|
||||||
|
it (or reached the ending after half), not where you stopped: opening
|
||||||
|
a watched episode to check a moment keeps it watched, and a quick peek
|
||||||
|
leaves no resume point.
|
||||||
- Friends without an account watch through the server: whoever put the
|
- Friends without an account watch through the server: whoever put the
|
||||||
item up passes it on automatically, with their own sign-in, which never
|
item up passes it on automatically, with their own sign-in, which never
|
||||||
leaves their computer (only the current item, only to people in the
|
leaves their computer (only the current item, only to people in the
|
||||||
session). They show "via host" in the member list (click the avatars). When the app hosts,
|
session). They show "via server" in the member list (click the avatars). When the app hosts,
|
||||||
or a dedicated server has its own login (`--jellyfin <file>`, see
|
or a dedicated server has its own login (`--jellyfin <file>`, see
|
||||||
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay), the server
|
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay), the server
|
||||||
fetches it itself instead.
|
fetches it itself instead.
|
||||||
@@ -251,7 +398,8 @@ Watching:
|
|||||||
Episodes: the ⏮ / ⏭ buttons next to the controls (or `<` / `>`,
|
Episodes: the ⏮ / ⏭ buttons next to the controls (or `<` / `>`,
|
||||||
Shift+P / Shift+N) put up the previous or next episode for everyone. From
|
Shift+P / Shift+N) put up the previous or next episode for everyone. From
|
||||||
the ending (when it is known, see Skipping above; else in the last two
|
the ending (when it is known, see Skipping above; else in the last two
|
||||||
minutes) a "Next episode" button floats in the corner. When an
|
minutes) a "Next episode" button floats in the corner while the controls
|
||||||
|
are up. When an
|
||||||
episode ends, a card shows what is next with Play next; with "Play the
|
episode ends, a card shows what is next with Play next; with "Play the
|
||||||
next episode automatically" (Settings, on by default) it counts down 10 s
|
next episode automatically" (Settings, on by default) it counts down 10 s
|
||||||
first, and Cancel stops it on your screen (seeking back stops it for
|
first, and Cancel stops it on your screen (seeking back stops it for
|
||||||
@@ -270,19 +418,46 @@ current season, else where you left it.
|
|||||||
Anyone can put up a file from their own computer, also on a dedicated
|
Anyone can put up a file from their own computer, also on a dedicated
|
||||||
server: it streams from your computer through the server to everyone (the
|
server: it streams from your computer through the server to everyone (the
|
||||||
server keeps a few minutes in memory, so seeks and skipping the opening
|
server keeps a few minutes in memory, so seeks and skipping the opening
|
||||||
are quick). Keep the app open while it plays. For a link you choose how
|
are quick). Keep the app open while it plays. A link goes up "Through
|
||||||
the others get it:
|
me": your computer opens it and passes it on, so it plays for everyone
|
||||||
- Everyone fetches it (default): each app plays the link itself.
|
whenever it plays for you (you can't tell ahead which sites want cookies,
|
||||||
|
and YouTube may start asking any day). Under "Other ways" there are two more:
|
||||||
|
- Everyone fetches it: each app plays the link itself.
|
||||||
- Server fetches it: the server downloads it once for everyone, for links
|
- Server fetches it: the server downloads it once for everyone, for links
|
||||||
that are slow or blocked for some. The server must be able to reach it,
|
that are slow or blocked for some. The server must be able to reach it,
|
||||||
and it must have a password (an open server refuses: anyone could make
|
and it must have a password (an open server refuses: anyone could make
|
||||||
it fetch addresses only it can reach).
|
it fetch addresses only it can reach).
|
||||||
- Through me: your computer downloads it and passes it on, for links only
|
|
||||||
you can open.
|
|
||||||
|
|
||||||
The last two need a link to a video file (not a page like YouTube) and
|
Web pages work in all three: the app and the dedicated server each
|
||||||
never show the link to anyone. Your upload has to beat the video's
|
download [yt-dlp](https://github.com/yt-dlp/yt-dlp) by themselves (checked
|
||||||
bitrate: a 25-minute 2 GB episode is about 11 Mbit/s.
|
against the release's checksums, updated once a day; Settings → Player
|
||||||
|
shows its version and has "Update now"), so there is nothing to install.
|
||||||
|
Sites that refuse guests (Bilibili answers "HTTP Error 412", YouTube may
|
||||||
|
ask to sign in) need cookies: Settings → Player → yt-dlp → Cookies reads
|
||||||
|
them from a browser, or from a cookies.txt or a cookie extension's JSON
|
||||||
|
export (picked or dropped; every site in it is kept). They stay on this
|
||||||
|
computer and are used by its yt-dlp only (your own playback, "through
|
||||||
|
me", and a server you host). Cookies log yt-dlp in as you on those
|
||||||
|
sites: keep the file private, and for YouTube consider a throwaway
|
||||||
|
account (Google may flag accounts used with yt-dlp).
|
||||||
|
YouTube works in all three too, and so do pages and links that only
|
||||||
|
offer streaming playlists (HLS `.m3u8`, DASH `.mpd`, the HLS of X videos).
|
||||||
|
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
|
## Video quality and shaders
|
||||||
|
|
||||||
@@ -306,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.
|
- "host offline - check address and port": wrong address, or the port is not open.
|
||||||
- "wrong server password": password mismatch.
|
- "wrong server password": password mismatch.
|
||||||
- "host runs a different version": update the app on both sides.
|
- "host runs a different version": update the app on both sides.
|
||||||
- Picture frozen but sound fine: Ctrl+R reloads the video; the app also does it automatically.
|
- Picture frozen but sound fine: Ctrl+R reloads the video.
|
||||||
- Everyone waits at "waiting for X": X has not buffered 4 seconds yet or has not pressed Space.
|
- Everyone waits at "waiting for X": X has not buffered 4 seconds yet or has not pressed Space.
|
||||||
- Windows: the build is new; if something is off there, say so.
|
- Windows: the build is new; if something is off there, say so.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||||
|
<defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#2d2547"/><stop offset="1" stop-color="#14111d"/></linearGradient><linearGradient id="t1" gradientUnits="userSpaceOnUse" x1="50.7" y1="148.7" x2="184.6" y2="71.4"><stop offset="0" stop-color="#f0795a" stop-opacity="0"/><stop offset="0.45" stop-color="#f0795a" stop-opacity="0.85"/><stop offset="1" stop-color="#ffc56b"/></linearGradient><linearGradient id="t2" gradientUnits="userSpaceOnUse" x1="205.3" y1="107.3" x2="71.4" y2="184.6"><stop offset="0" stop-color="#ffc56b" stop-opacity="0"/><stop offset="0.45" stop-color="#ffc56b" stop-opacity="0.85"/><stop offset="1" stop-color="#f0795a"/></linearGradient></defs>
|
||||||
|
<rect x="8" y="8" width="240" height="240" rx="56" fill="url(#bg)"/>
|
||||||
|
<circle cx="128" cy="128" r="80" fill="none" stroke="#ffffff" stroke-opacity="0.10" stroke-width="6"/>
|
||||||
|
<path d="M205.27 107.29 A80 80 0 0 1 71.43 184.57" fill="none" stroke="url(#t2)" stroke-width="7" stroke-linecap="butt"/><path d="M50.73 148.71 A80 80 0 0 1 184.57 71.43" fill="none" stroke="url(#t1)" stroke-width="7" stroke-linecap="butt"/>
|
||||||
|
<path d="M110 96 L110 160 L162 128 Z" fill="#fff1d6" stroke="#fff1d6" stroke-width="16" stroke-linejoin="round"/>
|
||||||
|
<path d="M71.4 161.6 Q74.7 181.3 94.4 184.6 Q74.7 187.8 71.4 207.6 Q68.2 187.8 48.4 184.6 Q68.2 181.3 71.4 161.6 Z" fill="#f0795a"/>
|
||||||
|
<path d="M184.6 40.4 Q188.9 67.1 215.6 71.4 Q188.9 75.8 184.6 102.4 Q180.2 75.8 153.6 71.4 Q180.2 67.1 184.6 40.4 Z" fill="#ffc56b"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 732 B |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'src/screens/home/ui.dart';
|
import 'src/screens/home/ui.dart';
|
||||||
import 'src/shell/app_shell.dart';
|
import 'src/shell/app_shell.dart';
|
||||||
|
import 'src/widgets/app_keys.dart';
|
||||||
|
import 'src/widgets/app_menu.dart';
|
||||||
|
|
||||||
class WatchTogetherApp extends StatelessWidget {
|
class WatchTogetherApp extends StatelessWidget {
|
||||||
const WatchTogetherApp({super.key});
|
const WatchTogetherApp({super.key});
|
||||||
@@ -10,8 +12,11 @@ class WatchTogetherApp extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: appName,
|
title: appName,
|
||||||
theme: ThemeData.dark(),
|
// Material's own menus (the player's track menus, sort) wear the
|
||||||
home: const AppShell(),
|
// app menu's surface.
|
||||||
|
theme: AppMenuStyle.theme(ThemeData.dark()),
|
||||||
|
shortcuts: appShortcuts,
|
||||||
|
home: const AppKeys(child: AppShell()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -7,10 +8,14 @@ import 'package:sync_relay_core/core.dart';
|
|||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
import 'app.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/screens/home/ui.dart';
|
||||||
import 'src/state/session_state.dart';
|
import 'src/state/session_state.dart';
|
||||||
import 'src/state/settings.dart';
|
import 'src/state/settings.dart';
|
||||||
import 'src/update/updater.dart';
|
import 'src/update/update_controller.dart' show launchArgs;
|
||||||
|
|
||||||
Future<void> main(List<String> args) async {
|
Future<void> main(List<String> args) async {
|
||||||
launchArgs = List.unmodifiable(args);
|
launchArgs = List.unmodifiable(args);
|
||||||
@@ -37,29 +42,36 @@ Future<void> main(List<String> args) async {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await startAppLogging();
|
||||||
|
await Hive.initFlutter();
|
||||||
|
await AppSettings.instance.ensureLoaded();
|
||||||
await windowManager.ensureInitialized();
|
await windowManager.ensureInitialized();
|
||||||
await windowManager.setPreventClose(true);
|
await windowManager.setPreventClose(true);
|
||||||
windowManager.addListener(_CloseGuard());
|
final memory = WindowMemory(Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null);
|
||||||
const windowOptions = WindowOptions(
|
windowManager.addListener(_CloseGuard(memory));
|
||||||
size: Size(1280, 720),
|
final windowOptions = WindowOptions(
|
||||||
minimumSize: Size(160, 90),
|
size: memory.size,
|
||||||
center: true,
|
minimumSize: const Size(160, 90),
|
||||||
backgroundColor: Color(0xFF000000),
|
center: !memory.hasPosition,
|
||||||
|
backgroundColor: const Color(0xFF000000),
|
||||||
titleBarStyle: TitleBarStyle.hidden,
|
titleBarStyle: TitleBarStyle.hidden,
|
||||||
title: appName,
|
title: appName,
|
||||||
);
|
);
|
||||||
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||||
|
await memory.restore();
|
||||||
await windowManager.show();
|
await windowManager.show();
|
||||||
await windowManager.focus();
|
await windowManager.focus();
|
||||||
});
|
});
|
||||||
await Hive.initFlutter();
|
|
||||||
await AppSettings.instance.ensureLoaded();
|
|
||||||
try {
|
try {
|
||||||
await SessionState.instance.loadForm();
|
await SessionState.instance.loadForm();
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('load connect form failed: $e\n$st');
|
debugPrint('load connect form failed: $e\n$st');
|
||||||
}
|
}
|
||||||
MediaKit.ensureInitialized();
|
MediaKit.ensureInitialized();
|
||||||
|
// yt-dlp for web page links: downloaded on first run, kept current.
|
||||||
|
AppYtDlp.instance.warmUp();
|
||||||
|
// Linux: the launcher entry and icon Wayland needs (release installs).
|
||||||
|
unawaited(installDesktopEntry());
|
||||||
runApp(const WatchTogetherApp());
|
runApp(const WatchTogetherApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,12 +79,15 @@ Future<void> main(List<String> args) async {
|
|||||||
/// hosted server shuts down), bounded so a hung socket can't keep the
|
/// hosted server shuts down), bounded so a hung socket can't keep the
|
||||||
/// window open.
|
/// window open.
|
||||||
class _CloseGuard with WindowListener {
|
class _CloseGuard with WindowListener {
|
||||||
|
final WindowMemory memory;
|
||||||
bool _closing = false;
|
bool _closing = false;
|
||||||
|
_CloseGuard(this.memory);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> onWindowClose() async {
|
Future<void> onWindowClose() async {
|
||||||
if (_closing) return;
|
if (_closing) return;
|
||||||
_closing = true;
|
_closing = true;
|
||||||
|
await memory.save();
|
||||||
try {
|
try {
|
||||||
await SessionState.instance.leave().timeout(const Duration(seconds: 2));
|
await SessionState.instance.leave().timeout(const Duration(seconds: 2));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -2,16 +2,92 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
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,
|
/// 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,
|
/// 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
|
/// down [hideAfter] later or when the mouse leaves. media_kit does not
|
||||||
/// expose its visibility; this lets the player draw a progress line while
|
/// 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 {
|
class ControlsHoverProbe extends StatefulWidget {
|
||||||
final ValueNotifier<bool> shown;
|
final ValueNotifier<bool> shown;
|
||||||
final Duration hideAfter;
|
final Duration hideAfter;
|
||||||
|
final ControlsHold? hold;
|
||||||
final Widget child;
|
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
|
@override
|
||||||
State<ControlsHoverProbe> createState() => _ControlsHoverProbeState();
|
State<ControlsHoverProbe> createState() => _ControlsHoverProbeState();
|
||||||
@@ -19,22 +95,67 @@ class ControlsHoverProbe extends StatefulWidget {
|
|||||||
|
|
||||||
class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
|
class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
|
// While held: media_kit's own countdown is restarted before it ends.
|
||||||
|
Timer? _keep;
|
||||||
|
|
||||||
|
bool get _held => widget.hold?.held ?? false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
widget.hold?.addListener(_holdChanged);
|
||||||
// Not during this build: the line listening to [shown] may be built.
|
// Not during this build: the line listening to [shown] may be built.
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) _wake();
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
widget.hold?.removeListener(_holdChanged);
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
|
_keep?.cancel();
|
||||||
super.dispose();
|
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() {
|
void _wake() {
|
||||||
widget.shown.value = true;
|
widget.shown.value = true;
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
@@ -43,7 +164,8 @@ class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
|
|||||||
|
|
||||||
void _hide() {
|
void _hide() {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
if (mounted) widget.shown.value = false;
|
if (!mounted || _held) return;
|
||||||
|
widget.shown.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -52,7 +174,9 @@ class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
|
|||||||
hitTestBehavior: HitTestBehavior.translucent,
|
hitTestBehavior: HitTestBehavior.translucent,
|
||||||
onEnter: (_) => _wake(),
|
onEnter: (_) => _wake(),
|
||||||
onHover: (_) => _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,
|
child: widget.child,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -8,8 +8,10 @@ import '../jellyfin/track_prefs.dart' show MpvTrack;
|
|||||||
import '../playback/playback_controller.dart' show PlaybackController, RenderMode;
|
import '../playback/playback_controller.dart' show PlaybackController, RenderMode;
|
||||||
import '../shaders/shader_presets.dart';
|
import '../shaders/shader_presets.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
|
import '../shell/mark_problem.dart';
|
||||||
import '../shell/motion.dart';
|
import '../shell/motion.dart';
|
||||||
import '../state/settings.dart';
|
import '../state/settings.dart';
|
||||||
|
import '../widgets/app_menu.dart';
|
||||||
import 'uosc/buttons.dart';
|
import 'uosc/buttons.dart';
|
||||||
import 'uosc/controls.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 Key key,
|
||||||
required T value,
|
required T value,
|
||||||
required List<(T, String)> items,
|
required List<(T, String)> items,
|
||||||
required ValueChanged<T> onChanged,
|
required ValueChanged<T> onChanged,
|
||||||
bool expanded = false,
|
bool expanded = false,
|
||||||
}) =>
|
}) =>
|
||||||
DropdownButton<T>(
|
AppSelect<T>(key: key, value: value, items: items, onChanged: onChanged, expanded: expanded);
|
||||||
key: key,
|
|
||||||
value: value,
|
|
||||||
isExpanded: expanded,
|
|
||||||
underline: const SizedBox.shrink(),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
items: [
|
|
||||||
for (final (v, label) in items)
|
|
||||||
DropdownMenuItem(value: v, child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis)),
|
|
||||||
],
|
|
||||||
onChanged: (v) {
|
|
||||||
if (v != null) onChanged(v);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/// Standard (our bars over media_kit's controls) or uosc. Live.
|
/// Standard (our bars over media_kit's controls) or uosc. Live.
|
||||||
class PlayerDesignSetting extends StatelessWidget {
|
class PlayerDesignSetting extends StatelessWidget {
|
||||||
@@ -324,19 +313,18 @@ class PlayerSettingsCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Material(
|
// The app's glass, like the Up next popover and the menus.
|
||||||
|
return AppGlass(
|
||||||
key: const ValueKey('player-settings-card'),
|
key: const ValueKey('player-settings-card'),
|
||||||
elevation: 12,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 420,
|
width: 420,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 10),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
const Text('Player', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 16)),
|
const Text('Player', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
...playerSettingRows(renderMode: renderMode),
|
...playerSettingRows(renderMode: renderMode),
|
||||||
if (tracks) ...[
|
if (tracks) ...[
|
||||||
@@ -345,10 +333,17 @@ class PlayerSettingsCard extends StatelessWidget {
|
|||||||
const TrackSetting(type: JellyfinStreamType.audio),
|
const TrackSetting(type: JellyfinStreamType.audio),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
'Custom shader files: Settings → Player',
|
'Custom shader files: Settings → Player',
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12),
|
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:flutter/material.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
||||||
@@ -10,6 +9,7 @@ import '../../screens/session/player_controls.dart' show TrackPick;
|
|||||||
import '../../shaders/shader_menu.dart';
|
import '../../shaders/shader_menu.dart';
|
||||||
import '../../shaders/shader_presets.dart';
|
import '../../shaders/shader_presets.dart';
|
||||||
import '../../state/settings.dart';
|
import '../../state/settings.dart';
|
||||||
|
import '../volume.dart';
|
||||||
import 'controls.dart';
|
import 'controls.dart';
|
||||||
import 'menu.dart';
|
import 'menu.dart';
|
||||||
import 'style.dart';
|
import 'style.dart';
|
||||||
@@ -166,9 +166,10 @@ class _UoscBadgeButtonState extends State<UoscBadgeButton> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Volume: click mutes / unmutes, the wheel steps 5 %.
|
/// Volume: click mutes / unmutes, hovering shows a [VolumeBar] up to
|
||||||
|
/// [maxVolume], the wheel steps [volumeStep].
|
||||||
class UoscVolumeButton extends StatefulWidget {
|
class UoscVolumeButton extends StatefulWidget {
|
||||||
/// 0..100.
|
/// 0..[maxVolume].
|
||||||
final double volume;
|
final double volume;
|
||||||
final ValueChanged<double> onChanged;
|
final ValueChanged<double> onChanged;
|
||||||
const UoscVolumeButton({super.key, required this.volume, required this.onChanged});
|
const UoscVolumeButton({super.key, required this.volume, required this.onChanged});
|
||||||
@@ -178,21 +179,42 @@ class UoscVolumeButton extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _UoscVolumeButtonState extends State<UoscVolumeButton> {
|
class _UoscVolumeButtonState extends State<UoscVolumeButton> {
|
||||||
|
final _steps = WheelSteps();
|
||||||
double _beforeMute = 100;
|
double _beforeMute = 100;
|
||||||
|
bool _hover = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final v = widget.volume;
|
final v = widget.volume;
|
||||||
final icon = v <= 0 ? Icons.volume_off_rounded : (v < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded);
|
final icon = v <= 0 ? Icons.volume_off_rounded : (v < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded);
|
||||||
return Listener(
|
return MouseRegion(
|
||||||
onPointerSignal: (e) {
|
onEnter: (_) => setState(() => _hover = true),
|
||||||
if (e is PointerScrollEvent && e.scrollDelta.dy != 0) {
|
onExit: (_) => setState(() => _hover = false),
|
||||||
widget.onChanged((v + (e.scrollDelta.dy < 0 ? 5 : -5)).clamp(0.0, 100.0));
|
child: Listener(
|
||||||
}
|
// Claimed here, so the player's own wheel handler skips it.
|
||||||
},
|
onPointerSignal: (e) => _steps.handle(e, (step) => widget.onChanged(stepVolume(v, step * volumeStep))),
|
||||||
child: UoscButton(
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
child: _hover
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 6, right: 2),
|
||||||
|
child: VolumeBar(
|
||||||
|
key: const ValueKey('uosc-volume-bar'),
|
||||||
|
volume: v,
|
||||||
|
onChanged: widget.onChanged,
|
||||||
|
color: UoscStyle.text,
|
||||||
|
trackColor: UoscStyle.dim.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
UoscButton(
|
||||||
key: const ValueKey('uosc-volume'),
|
key: const ValueKey('uosc-volume'),
|
||||||
icon: icon,
|
icon: icon,
|
||||||
|
color: v > 100 ? boostColor : null,
|
||||||
tooltip: 'Volume ${v.round()} % (scroll to change)',
|
tooltip: 'Volume ${v.round()} % (scroll to change)',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (v > 0) {
|
if (v > 0) {
|
||||||
@@ -203,6 +225,9 @@ class _UoscVolumeButtonState extends State<UoscVolumeButton> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
import '../../state/settings.dart';
|
import '../../state/settings.dart';
|
||||||
|
import '../controls_probe.dart' show ControlsHold;
|
||||||
|
|
||||||
// The mpv-style (uosc) controls frame: a full-width timeline at the
|
// 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
|
// bottom with a row of flat buttons above it, the title top left and a
|
||||||
// corner widget top right.
|
// corner widget top right.
|
||||||
// Mouse movement shows them; after [UoscControls.hideAfter] without any
|
// Mouse movement shows them; after [UoscControls.hideAfter] without any
|
||||||
// they fade out, the timeline shrinks to a 2 px line (gone when "Progress
|
// they fade out, the timeline shrinks to uosc's progress line (gone when
|
||||||
// line when hidden" is off) and the cursor hides. Our own frame: no
|
// "Progress line when hidden" is off) and the cursor hides. Our own frame: no
|
||||||
// media_kit controls state to reach into.
|
// media_kit controls state to reach into.
|
||||||
|
|
||||||
class UoscControls extends StatefulWidget {
|
class UoscControls extends StatefulWidget {
|
||||||
@@ -26,6 +27,10 @@ class UoscControls extends StatefulWidget {
|
|||||||
final Widget? topLeft;
|
final Widget? topLeft;
|
||||||
final VoidCallback? onDoubleTap;
|
final VoidCallback? onDoubleTap;
|
||||||
final Duration hideAfter;
|
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({
|
const UoscControls({
|
||||||
super.key,
|
super.key,
|
||||||
required this.timeline,
|
required this.timeline,
|
||||||
@@ -35,9 +40,13 @@ class UoscControls extends StatefulWidget {
|
|||||||
this.topLeft,
|
this.topLeft,
|
||||||
this.onDoubleTap,
|
this.onDoubleTap,
|
||||||
this.hideAfter = const Duration(seconds: 3),
|
this.hideAfter = const Duration(seconds: 3),
|
||||||
|
this.shown,
|
||||||
|
this.holdBy,
|
||||||
});
|
});
|
||||||
|
|
||||||
static const buttonRowHeight = 40.0;
|
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
|
/// The controls around [context], for widgets that need them kept up
|
||||||
/// (a timeline drag, an open menu).
|
/// (a timeline drag, an open menu).
|
||||||
@@ -51,6 +60,7 @@ class UoscControls extends StatefulWidget {
|
|||||||
class UoscControlsState extends State<UoscControls> {
|
class UoscControlsState extends State<UoscControls> {
|
||||||
bool _visible = true;
|
bool _visible = true;
|
||||||
int _holds = 0;
|
int _holds = 0;
|
||||||
|
bool _heldBy = false;
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
|
|
||||||
bool get visible => _visible;
|
bool get visible => _visible;
|
||||||
@@ -58,18 +68,44 @@ class UoscControlsState extends State<UoscControls> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
widget.holdBy?.addListener(_holdByChanged);
|
||||||
|
// Not during this build: an overlay listening to [shown] may be built.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) widget.shown?.value = true;
|
||||||
|
});
|
||||||
_hideLater();
|
_hideLater();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(UoscControls oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.holdBy != widget.holdBy) {
|
||||||
|
oldWidget.holdBy?.removeListener(_holdByChanged);
|
||||||
|
widget.holdBy?.addListener(_holdByChanged);
|
||||||
|
_holdByChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
widget.holdBy?.removeListener(_holdByChanged);
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
super.dispose();
|
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.
|
/// Show the controls and restart the hide countdown.
|
||||||
void wake() {
|
void wake() {
|
||||||
if (!_visible && mounted) setState(() => _visible = true);
|
if (!_visible && mounted) {
|
||||||
|
setState(() => _visible = true);
|
||||||
|
widget.shown?.value = true;
|
||||||
|
}
|
||||||
_hideLater();
|
_hideLater();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +134,7 @@ class UoscControlsState extends State<UoscControls> {
|
|||||||
void _hide() {
|
void _hide() {
|
||||||
if (!mounted || _holds > 0 || !_visible) return;
|
if (!mounted || _holds > 0 || !_visible) return;
|
||||||
setState(() => _visible = false);
|
setState(() => _visible = false);
|
||||||
|
widget.shown?.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -174,8 +211,9 @@ class UoscControlsState extends State<UoscControls> {
|
|||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
duration: fade,
|
duration: fade,
|
||||||
opacity: visible ? 1 : 0,
|
opacity: visible ? 1 : 0,
|
||||||
|
// The buttons centred on the title, like the standard
|
||||||
|
// design's top bar.
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -185,7 +223,8 @@ class UoscControlsState extends State<UoscControls> {
|
|||||||
: Align(alignment: AlignmentDirectional.centerStart, child: widget.topLeft),
|
: 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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -206,7 +245,7 @@ class UoscControlsState extends State<UoscControls> {
|
|||||||
duration: fade,
|
duration: fade,
|
||||||
opacity: visible ? 1 : 0,
|
opacity: visible ? 1 : 0,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(6, 0, 6, 4),
|
padding: const EdgeInsets.fromLTRB(6, 0, 6, UoscControls.rowBottomPadding),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: UoscControls.buttonRowHeight,
|
height: UoscControls.buttonRowHeight,
|
||||||
child: Row(children: [...widget.left, const Spacer(), ...widget.right]),
|
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.
|
// owner runs it: flat black panels, white icons, a muted blue fill.
|
||||||
|
|
||||||
abstract final class UoscStyle {
|
abstract final class UoscStyle {
|
||||||
/// Timeline background and the boxes on it.
|
/// The active menu row.
|
||||||
static const bar = Color(0xCC000000);
|
|
||||||
/// Played part of the timeline; also the active menu row.
|
|
||||||
static const fill = Color(0xFF4C6A8E);
|
static const fill = Color(0xFF4C6A8E);
|
||||||
/// Menus and tooltips.
|
/// Menus and tooltips.
|
||||||
static const panel = Color(0xF0101010);
|
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
|
/// A small dark box with one line of text (the timeline's hover time and
|
||||||
/// hover chapter title).
|
/// chapter title).
|
||||||
class UoscBox extends StatelessWidget {
|
class UoscBox extends StatelessWidget {
|
||||||
final String text;
|
final String text;
|
||||||
final Color color;
|
final Color color;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -6,6 +7,66 @@ import '../../playback/media_marks.dart';
|
|||||||
import 'controls.dart';
|
import 'controls.dart';
|
||||||
import 'style.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).
|
/// Fraction of [total] at [d], 0..1 (0 without a duration).
|
||||||
double timelineFraction(Duration d, Duration total) {
|
double timelineFraction(Duration d, Duration total) {
|
||||||
final t = total.inMicroseconds;
|
final t = total.inMicroseconds;
|
||||||
@@ -13,36 +74,104 @@ double timelineFraction(Duration d, Duration total) {
|
|||||||
return (d.inMicroseconds / t).clamp(0.0, 1.0);
|
return (d.inMicroseconds / t).clamp(0.0, 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The cached-ahead part of a timeline of [size]: from the playing
|
/// Where the uncached part starts (it runs to the end), as a fraction;
|
||||||
/// position to the end of mpv's cache. Null when nothing is cached ahead.
|
/// null when nothing known is uncached. We know only the end of mpv's
|
||||||
Rect? cacheRect(Size size, {required Duration position, required Duration buffered, required Duration duration}) {
|
/// cache ahead ([buffered]); without it (0) uosc draws no ranges either,
|
||||||
final from = timelineFraction(position, duration) * size.width;
|
/// and a cache that reaches the end (within uosc's 0.5 s) leaves none.
|
||||||
final to = timelineFraction(buffered, duration) * size.width;
|
double? uncachedFrom({required Duration position, required Duration buffered, required Duration duration}) {
|
||||||
if (to - from < 0.5) return null;
|
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
|
||||||
return Rect.fromLTRB(from, 0, to, size.height);
|
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,
|
/// The cache label next to the position ("12s"): the seconds buffered
|
||||||
/// [spacing] apart along x, across all of [r] (the painter clips them).
|
/// ahead, shown while under [UoscTimelineSpec.bufferedThreshold] and the
|
||||||
/// The first starts half a step in, so none just grazes the corner.
|
/// cache does not reach the end (uosc drops it at eof-cached).
|
||||||
List<(Offset, Offset)> hatchLines(Rect r, {double spacing = 6}) {
|
String? cacheLabel({required Duration position, required Duration buffered, required Duration duration}) {
|
||||||
if (r.isEmpty || spacing <= 0) return const [];
|
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
|
||||||
final h = r.height;
|
if (duration - buffered <= const Duration(milliseconds: 500)) return null;
|
||||||
return [
|
final ahead = buffered > position ? buffered - position : Duration.zero;
|
||||||
for (var x = r.left - h + spacing / 2; x < r.right; x += spacing) (Offset(x, r.bottom), Offset(x + h, r.top)),
|
if (ahead >= UoscTimelineSpec.bufferedThreshold) return null;
|
||||||
];
|
return '${(ahead.inMilliseconds / 1000).round()}s';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The uosc timeline: full width, elapsed part filled, the cached range
|
/// The band's geometry at one size, as uosc lays it out: the position
|
||||||
/// ahead hatched, chapter starts as white diamonds on the top edge, the
|
/// line takes [lineWidth] pixels, and times map around it (`t2x`) so no
|
||||||
/// current time in a box at the left and the duration at the right.
|
/// time lies inside the line.
|
||||||
/// Hover shows the chapter title over the hover time. When the controls
|
class UoscTimelineGeometry {
|
||||||
/// hide ([expanded] false) it shrinks to a thin line with chapter ticks.
|
final double width;
|
||||||
/// 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 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 {
|
class UoscTimeline extends StatefulWidget {
|
||||||
final Duration position;
|
final Duration position;
|
||||||
final Duration duration;
|
final Duration duration;
|
||||||
|
|
||||||
/// End of the cached range ([PlaybackController.buffered]).
|
/// End of the cached range ([PlaybackController.buffered]).
|
||||||
final Duration buffered;
|
final Duration buffered;
|
||||||
final List<MediaChapter> chapters;
|
final List<MediaChapter> chapters;
|
||||||
@@ -58,24 +187,32 @@ class UoscTimeline extends StatefulWidget {
|
|||||||
required this.onSeek,
|
required this.onSeek,
|
||||||
});
|
});
|
||||||
|
|
||||||
static const height = 30.0;
|
static const height = UoscTimelineSpec.size + UoscTimelineSpec.border;
|
||||||
static const collapsedHeight = 2.0;
|
static const collapsedHeight = UoscTimelineSpec.progressSize + UoscTimelineSpec.border;
|
||||||
|
|
||||||
|
/// uosc's `animation_duration`.
|
||||||
|
static const animation = Duration(milliseconds: 100);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<UoscTimeline> createState() => _UoscTimelineState();
|
State<UoscTimeline> createState() => _UoscTimelineState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UoscTimelineState extends State<UoscTimeline> {
|
class _UoscTimelineState extends State<UoscTimeline> {
|
||||||
double? _hoverX;
|
Offset? _hover;
|
||||||
bool _dragging = false;
|
bool _dragging = false;
|
||||||
double _dragFraction = 0;
|
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);
|
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
|
||||||
|
|
||||||
void _seekTo(double x, double width) {
|
void _send(Duration d) {
|
||||||
if (widget.duration <= Duration.zero || width <= 0) return;
|
if (d == _lastSent) return;
|
||||||
_dragFraction = (x / width).clamp(0.0, 1.0);
|
_lastSent = d;
|
||||||
widget.onSeek(_at(_dragFraction));
|
widget.onSeek(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drag(bool on) {
|
void _drag(bool on) {
|
||||||
@@ -84,204 +221,425 @@ class _UoscTimelineState extends State<UoscTimeline> {
|
|||||||
UoscControls.maybeOf(context)?.hold(on);
|
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
|
@override
|
||||||
void didUpdateWidget(UoscTimeline old) {
|
void didUpdateWidget(UoscTimeline old) {
|
||||||
super.didUpdateWidget(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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_pendingTimer?.cancel();
|
||||||
// A drag cut short by a rebuild must not keep the controls up.
|
// A drag cut short by a rebuild must not keep the controls up.
|
||||||
if (_dragging) UoscControls.maybeOf(context)?.hold(false);
|
if (_dragging) UoscControls.maybeOf(context)?.hold(false);
|
||||||
super.dispose();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return LayoutBuilder(builder: (context, constraints) {
|
final font = DefaultTextStyle.of(context).style.fontFamily ?? Theme.of(context).textTheme.bodyMedium?.fontFamily;
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
final width = constraints.maxWidth;
|
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 expanded = widget.expanded;
|
||||||
final h = expanded ? UoscTimeline.height : UoscTimeline.collapsedHeight;
|
final duration = widget.duration;
|
||||||
final played = _dragging ? _dragFraction : timelineFraction(widget.position, widget.duration);
|
final size = UoscTimelineGeometry.sizeAt(visibility);
|
||||||
final hoverX = expanded ? _hoverX : null;
|
final progress = _dragging ? _dragFraction : (_pending ?? timelineFraction(widget.position, duration));
|
||||||
final hoverAt = hoverX == null || width <= 0 ? null : _at(hoverX / width);
|
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 hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
|
||||||
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
|
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
|
||||||
final withHours = widget.duration >= const Duration(hours: 1);
|
// Text fades out just before it would overflow the shrinking band.
|
||||||
final marks = [
|
const hideBelow = UoscTimelineSpec.fontSize * 0.8;
|
||||||
for (final c in widget.chapters)
|
final textOpacity = (size - hideBelow).clamp(0.0, hideBelow / 2) / (hideBelow / 2);
|
||||||
if (c.start > Duration.zero && widget.duration > Duration.zero) c,
|
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(
|
final bar = SizedBox(
|
||||||
key: const ValueKey('uosc-timeline'),
|
key: const ValueKey('uosc-timeline'),
|
||||||
width: width,
|
width: width,
|
||||||
height: h,
|
height: geo.height,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: CustomPaint(
|
child: RepaintBoundary(child: CustomPaint(painter: painter)),
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
),
|
if (hoverAt != null && cursorX != null)
|
||||||
),
|
|
||||||
for (final c in marks)
|
|
||||||
if (expanded)
|
|
||||||
Positioned(
|
Positioned(
|
||||||
key: ValueKey('uosc-chapter-${c.index}'),
|
left: 0,
|
||||||
left: width * timelineFraction(c.start, widget.duration) - 4,
|
right: 0,
|
||||||
top: -4,
|
bottom: geo.height + timestampGap,
|
||||||
width: 8,
|
height: 160,
|
||||||
height: 8,
|
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
child: Transform.rotate(
|
child: CustomMultiChildLayout(
|
||||||
angle: math.pi / 4,
|
delegate: _TooltipLayout(x: cursorX),
|
||||||
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: [
|
children: [
|
||||||
if (hoverTitle.isNotEmpty) UoscBox(hoverTitle, key: const ValueKey('uosc-hover-chapter')),
|
LayoutId(
|
||||||
UoscBox(formatUoscTime(hoverAt, withHours: withHours),
|
id: #time,
|
||||||
key: const ValueKey('uosc-hover-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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (!expanded) return IgnorePointer(child: bar);
|
if (!expanded) return IgnorePointer(child: bar);
|
||||||
|
Offset clampTo(Offset p) => Offset(p.dx.clamp(0.0, width), p.dy);
|
||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
cursor: SystemMouseCursors.click,
|
cursor: SystemMouseCursors.click,
|
||||||
onEnter: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
|
onEnter: (e) => setState(() => _hover = clampTo(e.localPosition)),
|
||||||
onHover: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
|
onHover: (e) => setState(() => _hover = clampTo(e.localPosition)),
|
||||||
onExit: (_) => setState(() => _hoverX = null),
|
onExit: (_) {
|
||||||
|
if (!_dragging) setState(() => _hover = null);
|
||||||
|
},
|
||||||
child: Listener(
|
child: Listener(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onPointerDown: (e) {
|
onPointerDown: (e) {
|
||||||
_dragFraction = width <= 0 ? 0 : (e.localPosition.dx / width).clamp(0.0, 1.0);
|
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);
|
_drag(true);
|
||||||
|
_send(_at(_dragFraction));
|
||||||
},
|
},
|
||||||
onPointerMove: (e) {
|
onPointerMove: (e) {
|
||||||
setState(() => _hoverX = e.localPosition.dx.clamp(0, width));
|
if (!_dragging) return;
|
||||||
_seekTo(e.localPosition.dx, width);
|
final at = clampTo(e.localPosition);
|
||||||
|
setState(() {
|
||||||
|
_hover = at;
|
||||||
|
_dragFraction = geo.fractionAt(at.dx);
|
||||||
|
});
|
||||||
|
_send(_at(_dragFraction));
|
||||||
},
|
},
|
||||||
onPointerUp: (e) {
|
onPointerUp: (e) {
|
||||||
_seekTo(e.localPosition.dx, width);
|
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);
|
_drag(false);
|
||||||
},
|
},
|
||||||
onPointerCancel: (_) => _drag(false),
|
onPointerCancel: (_) => _drag(false),
|
||||||
child: bar,
|
child: bar,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
static const _tipStyle = TextStyle(color: UoscStyle.text, fontSize: UoscTimelineSpec.fontSize, height: 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Background, cached range (hatched, or a faint band when collapsed),
|
@override
|
||||||
/// the played part and the hover line.
|
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 {
|
class UoscTimelinePainter extends CustomPainter {
|
||||||
final double played;
|
final UoscTimelineGeometry geometry;
|
||||||
final Rect? Function(Size size) cache;
|
|
||||||
/// What [cache] depends on (repaint when it changes).
|
/// 0 hidden .. 1 shown (picks the hatching texture and its strength).
|
||||||
final Object cacheKey;
|
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 double? hoverX;
|
||||||
final bool hatched;
|
final double textOpacity;
|
||||||
|
final String? elapsed;
|
||||||
|
final String? remaining;
|
||||||
|
final String? cache;
|
||||||
|
final String? fontFamily;
|
||||||
const UoscTimelinePainter({
|
const UoscTimelinePainter({
|
||||||
required this.played,
|
required this.geometry,
|
||||||
required this.cache,
|
this.visibility = 1,
|
||||||
required this.cacheKey,
|
this.uncached,
|
||||||
|
this.chapters = const [],
|
||||||
|
this.hoveredChapter,
|
||||||
this.hoverX,
|
this.hoverX,
|
||||||
this.hatched = true,
|
this.textOpacity = 1,
|
||||||
|
this.elapsed,
|
||||||
|
this.remaining,
|
||||||
|
this.cache,
|
||||||
|
this.fontFamily,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
double get played => geometry.progress;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
final all = Offset.zero & size;
|
final g = geometry;
|
||||||
canvas.drawRect(all, Paint()..color = UoscStyle.bar);
|
final w = size.width, bottom = size.height, top = g.top;
|
||||||
final c = cache(size);
|
final line = Rect.fromLTRB(g.lineLeft, top, g.lineRight, bottom);
|
||||||
if (c != null) {
|
// Background (`opacity.timeline`) around the position line.
|
||||||
if (hatched) {
|
final bg = Path()
|
||||||
canvas.save();
|
..fillType = PathFillType.evenOdd
|
||||||
canvas.clipRect(c);
|
..addRect(Offset.zero & size)
|
||||||
final stroke = Paint()
|
..addRect(line);
|
||||||
..color = const Color(0x40FFFFFF)
|
canvas.drawPath(bg, Paint()..color = Colors.black.withValues(alpha: UoscTimelineSpec.opacity));
|
||||||
..strokeWidth = 1.5;
|
canvas.drawRect(line, Paint()..color = Colors.white);
|
||||||
for (final (a, b) in hatchLines(c)) {
|
_hatch(canvas, w, top, bottom);
|
||||||
canvas.drawLine(a, b, stroke);
|
_chapters(canvas);
|
||||||
}
|
if (textOpacity > 0) _times(canvas, w, top, bottom, line);
|
||||||
canvas.restore();
|
|
||||||
} else {
|
|
||||||
canvas.drawRect(c, Paint()..color = const Color(0x40FFFFFF));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
canvas.drawRect(Rect.fromLTWH(0, 0, size.width * played, size.height), Paint()..color = UoscStyle.fill);
|
|
||||||
final x = hoverX;
|
final x = hoverX;
|
||||||
if (x != null) {
|
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),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
/// uosc's texture "b" (shown) or "a" (hidden) at 80 px: "/" stripes
|
||||||
bool shouldRepaint(UoscTimelinePainter old) =>
|
/// 1.6 px thick, white then black shifted by size / 24 (or / 28).
|
||||||
old.played != played || old.cacheKey != cacheKey || old.hoverX != hoverX || old.hatched != hatched;
|
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) => 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
class VolumeBar extends StatelessWidget {
|
||||||
|
final double volume;
|
||||||
|
final ValueChanged<double> onChanged;
|
||||||
|
final double width;
|
||||||
|
final Color color;
|
||||||
|
final Color trackColor;
|
||||||
|
const VolumeBar({
|
||||||
|
super.key,
|
||||||
|
required this.volume,
|
||||||
|
required this.onChanged,
|
||||||
|
this.width = 64,
|
||||||
|
this.color = Colors.white,
|
||||||
|
this.trackColor = Colors.white30,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
void at(double x) => onChanged((x / width * maxVolume).clamp(0.0, maxVolume).roundToDouble());
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTapDown: (e) => at(e.localPosition.dx),
|
||||||
|
onHorizontalDragUpdate: (e) => at(e.localPosition.dx),
|
||||||
|
child: SizedBox(
|
||||||
|
width: width,
|
||||||
|
height: 24,
|
||||||
|
child: CustomPaint(painter: _VolumePainter(volume.clamp(0.0, maxVolume), color, trackColor)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VolumePainter extends CustomPainter {
|
||||||
|
final double volume;
|
||||||
|
final Color color, trackColor;
|
||||||
|
_VolumePainter(this.volume, this.color, this.trackColor);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final y = size.height / 2;
|
||||||
|
double x(double v) => size.width * v / maxVolume;
|
||||||
|
final line = Paint()
|
||||||
|
..strokeWidth = 3
|
||||||
|
..strokeCap = StrokeCap.round;
|
||||||
|
canvas.drawLine(Offset(0, y), Offset(size.width, y), line..color = trackColor);
|
||||||
|
final full = x(100);
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(full, y - 5),
|
||||||
|
Offset(full, y + 5),
|
||||||
|
Paint()
|
||||||
|
..color = trackColor
|
||||||
|
..strokeWidth = 1.5,
|
||||||
|
);
|
||||||
|
if (volume > 0) canvas.drawLine(Offset(0, y), Offset(x(volume.clamp(0, 100)), y), line..color = color);
|
||||||
|
if (volume > 100) canvas.drawLine(Offset(full, y), Offset(x(volume), y), line..color = boostColor);
|
||||||
|
canvas.drawCircle(Offset(x(volume), y), 5, Paint()..color = volume > 100 ? boostColor : color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_VolumePainter old) => old.volume != volume || old.color != color || old.trackColor != trackColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Volume for the standard bar (instead of media_kit's, which stops at
|
||||||
|
/// 100): the icon mutes / unmutes, hovering shows a [VolumeBar], the
|
||||||
|
/// wheel steps [volumeStep].
|
||||||
|
class VolumeButton extends StatefulWidget {
|
||||||
|
final Player player;
|
||||||
|
final FocusNode? focusNode;
|
||||||
|
const VolumeButton({super.key, required this.player, this.focusNode});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<VolumeButton> createState() => _VolumeButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VolumeButtonState extends State<VolumeButton> {
|
||||||
|
final _steps = WheelSteps();
|
||||||
|
bool _hover = false;
|
||||||
|
double _beforeMute = 100;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final player = widget.player;
|
||||||
|
return StreamBuilder<double>(
|
||||||
|
stream: player.stream.volume,
|
||||||
|
initialData: player.state.volume,
|
||||||
|
builder: (context, snap) {
|
||||||
|
final v = snap.data ?? 100;
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _hover = true),
|
||||||
|
onExit: (_) => setState(() => _hover = false),
|
||||||
|
child: Listener(
|
||||||
|
// 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: [
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
IconButton(
|
||||||
|
key: const ValueKey('volume-button'),
|
||||||
|
focusNode: widget.focusNode,
|
||||||
|
tooltip: 'Volume ${v.round()} %',
|
||||||
|
iconSize: 22,
|
||||||
|
color: Colors.white,
|
||||||
|
onPressed: () {
|
||||||
|
if (v > 0) {
|
||||||
|
_beforeMute = v;
|
||||||
|
player.setVolume(0);
|
||||||
|
} else {
|
||||||
|
player.setVolume(_beforeMute <= 0 ? 100 : _beforeMute);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: Icon(v <= 0 ? Icons.volume_off : (v < 50 ? Icons.volume_down : Icons.volume_up)),
|
||||||
|
),
|
||||||
|
AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
child: _hover
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 4, right: 12),
|
||||||
|
child: VolumeBar(volume: v, onChanged: player.setVolume),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,9 +10,6 @@ import 'thumb.dart';
|
|||||||
// The Files page's rows and cards: a series header, an episode row (also
|
// The Files page's rows and cards: a series header, an episode row (also
|
||||||
// used for Recent), a loose-video card, and the empty states.
|
// used for Recent), a loose-video card, and the empty states.
|
||||||
|
|
||||||
/// Shown on play controls while there is nobody to watch with.
|
|
||||||
const offlinePlayHint = 'Join or host to watch';
|
|
||||||
|
|
||||||
/// What a play control is doing right now.
|
/// What a play control is doing right now.
|
||||||
enum PlayState { idle, starting, playing }
|
enum PlayState { idle, starting, playing }
|
||||||
|
|
||||||
@@ -28,14 +25,14 @@ String parentName(String path) => baseName(File(path).parent.path);
|
|||||||
/// Cleaned title of a loose video: `Title (2019)`.
|
/// Cleaned title of a loose video: `Title (2019)`.
|
||||||
String cleanTitle(ParsedName p) => p.year == null ? p.series : '${p.series} (${p.year})';
|
String cleanTitle(ParsedName p) => p.year == null ? p.series : '${p.series} (${p.year})';
|
||||||
|
|
||||||
/// Disabled with [offlinePlayHint] when [onTap] is null because offline.
|
|
||||||
Widget _offlineTip(bool offline, Widget child) =>
|
|
||||||
offline ? Tooltip(message: offlinePlayHint, child: child) : child;
|
|
||||||
|
|
||||||
class SeriesHeader extends StatelessWidget {
|
class SeriesHeader extends StatelessWidget {
|
||||||
final SeriesGroup group;
|
final SeriesGroup group;
|
||||||
final ThumbCache thumbs;
|
final ThumbCache thumbs;
|
||||||
const SeriesHeader({super.key, required this.group, required this.thumbs});
|
/// Its episodes are listed below; [onTap] opens or closes them.
|
||||||
|
final bool expanded;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
const SeriesHeader(
|
||||||
|
{super.key, required this.group, required this.thumbs, this.expanded = true, this.onTap});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -49,7 +46,12 @@ class SeriesHeader extends StatelessWidget {
|
|||||||
?first.parsed.group,
|
?first.parsed.group,
|
||||||
].join(' · ');
|
].join(' · ');
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: 18, bottom: 4),
|
padding: const EdgeInsets.only(top: 14),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
@@ -76,8 +78,17 @@ class SeriesHeader extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (onTap != null)
|
||||||
|
AnimatedRotation(
|
||||||
|
turns: expanded ? 0.5 : 0,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
child: Icon(Icons.expand_more, color: scheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,9 +103,8 @@ class FileRow extends StatelessWidget {
|
|||||||
final String? size;
|
final String? size;
|
||||||
final PlayState state;
|
final PlayState state;
|
||||||
final ThumbCache thumbs;
|
final ThumbCache thumbs;
|
||||||
/// Null disables the row ([offline] adds the reason as a tooltip).
|
/// Null disables the row.
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final bool offline;
|
|
||||||
final bool indent;
|
final bool indent;
|
||||||
final Widget? trailing;
|
final Widget? trailing;
|
||||||
const FileRow({
|
const FileRow({
|
||||||
@@ -106,7 +116,6 @@ class FileRow extends StatelessWidget {
|
|||||||
required this.state,
|
required this.state,
|
||||||
required this.thumbs,
|
required this.thumbs,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
required this.offline,
|
|
||||||
this.indent = true,
|
this.indent = true,
|
||||||
this.trailing,
|
this.trailing,
|
||||||
});
|
});
|
||||||
@@ -130,9 +139,7 @@ class FileRow extends StatelessWidget {
|
|||||||
builder: (context, t, _) {
|
builder: (context, t, _) {
|
||||||
final duration = durationText(t);
|
final duration = durationText(t);
|
||||||
final meta = [?duration, ?size].join(' · ');
|
final meta = [?duration, ?size].join(' · ');
|
||||||
return _offlineTip(
|
return Material(
|
||||||
offline,
|
|
||||||
Material(
|
|
||||||
color: playing ? scheme.primary.withValues(alpha: 0.08) : Colors.transparent,
|
color: playing ? scheme.primary.withValues(alpha: 0.08) : Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
@@ -188,7 +195,6 @@ class FileRow extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -205,14 +211,12 @@ class VideoCard extends StatelessWidget {
|
|||||||
final PlayState state;
|
final PlayState state;
|
||||||
final ThumbCache thumbs;
|
final ThumbCache thumbs;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final bool offline;
|
|
||||||
const VideoCard({
|
const VideoCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.video,
|
required this.video,
|
||||||
required this.state,
|
required this.state,
|
||||||
required this.thumbs,
|
required this.thumbs,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
required this.offline,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -224,9 +228,7 @@ class VideoCard extends StatelessWidget {
|
|||||||
path: video.path,
|
path: video.path,
|
||||||
builder: (context, t, _) {
|
builder: (context, t, _) {
|
||||||
final duration = durationText(t);
|
final duration = durationText(t);
|
||||||
return _offlineTip(
|
return InkWell(
|
||||||
offline,
|
|
||||||
InkWell(
|
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -283,7 +285,6 @@ class VideoCard extends StatelessWidget {
|
|||||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
import '../../shell/motion.dart';
|
import '../../shell/motion.dart';
|
||||||
|
import '../../widgets/app_menu.dart';
|
||||||
import '../folder_store.dart';
|
import '../folder_store.dart';
|
||||||
import '../name_parser.dart';
|
import '../name_parser.dart';
|
||||||
import '../scanner.dart';
|
import '../scanner.dart';
|
||||||
@@ -17,7 +18,8 @@ export 'thumb.dart' show ThumbnailLoader;
|
|||||||
// The Files source: watched folders on the left (chips on top when
|
// The Files source: watched folders on the left (chips on top when
|
||||||
// narrow), the open one on the right grouped into series and loose
|
// narrow), the open one on the right grouped into series and loose
|
||||||
// videos, or Recent. A folder is scanned when first opened and on Rescan;
|
// videos, or Recent. A folder is scanned when first opened and on Rescan;
|
||||||
// scans are kept for the app session. Playing goes through [onPlay].
|
// scans are kept for the app session. Series start collapsed; the ones
|
||||||
|
// opened stay open for the app session. Playing goes through [onPlay].
|
||||||
|
|
||||||
/// Below this width the sidebar turns into chips above the list.
|
/// Below this width the sidebar turns into chips above the list.
|
||||||
const filesNarrowWidth = 700.0;
|
const filesNarrowWidth = 700.0;
|
||||||
@@ -26,22 +28,35 @@ const filesNarrowWidth = 700.0;
|
|||||||
final _scans = <String, FolderScan>{};
|
final _scans = <String, FolderScan>{};
|
||||||
final _running = <String, Future<FolderScan>>{};
|
final _running = <String, Future<FolderScan>>{};
|
||||||
String? _lastOpen;
|
String? _lastOpen;
|
||||||
|
/// Series opened this session, by `folder\nseries key`.
|
||||||
|
final _expanded = <String>{};
|
||||||
|
final _showRequests = ValueNotifier(0);
|
||||||
|
|
||||||
/// Forgets the session's scans and the open folder.
|
/// Opens [dir] (null: Recent) in the Files page: now if one is showing,
|
||||||
|
/// else when it next opens.
|
||||||
|
void showFilesFolder(String? dir) {
|
||||||
|
_lastOpen = dir;
|
||||||
|
_showRequests.value++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forgets the session's scans, open series and the open folder.
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
void resetFilesPageSession() {
|
void resetFilesPageSession() {
|
||||||
_scans.clear();
|
_scans.clear();
|
||||||
_running.clear();
|
_running.clear();
|
||||||
|
_expanded.clear();
|
||||||
_lastOpen = null;
|
_lastOpen = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
class FilesPage extends StatefulWidget {
|
class FilesPage extends StatefulWidget {
|
||||||
/// In a session: false disables playing (browsing still works).
|
/// Nobody else watches (offline or solo): no relay notice.
|
||||||
final bool online;
|
final bool solo;
|
||||||
/// The file will stream from this computer through the server.
|
/// The file will stream from this computer through the server.
|
||||||
final bool relayed;
|
final bool relayed;
|
||||||
/// Plays [path] in the session; returns an error text, or null.
|
/// Plays [path] in the session; returns an error text, or null.
|
||||||
final Future<String?> Function(String path) onPlay;
|
final Future<String?> Function(String path) onPlay;
|
||||||
|
/// Adds [path] to Up next (right-click on a video); null: no server.
|
||||||
|
final Future<String?> Function(String path, {bool next})? onAddToQueue;
|
||||||
final String? nowPlayingPath;
|
final String? nowPlayingPath;
|
||||||
/// Defaults to [thumbnailFor].
|
/// Defaults to [thumbnailFor].
|
||||||
final ThumbnailLoader? thumbnails;
|
final ThumbnailLoader? thumbnails;
|
||||||
@@ -55,9 +70,10 @@ class FilesPage extends StatefulWidget {
|
|||||||
|
|
||||||
const FilesPage({
|
const FilesPage({
|
||||||
super.key,
|
super.key,
|
||||||
required this.online,
|
this.solo = false,
|
||||||
required this.relayed,
|
required this.relayed,
|
||||||
required this.onPlay,
|
required this.onPlay,
|
||||||
|
this.onAddToQueue,
|
||||||
this.nowPlayingPath,
|
this.nowPlayingPath,
|
||||||
this.thumbnails,
|
this.thumbnails,
|
||||||
this.scan,
|
this.scan,
|
||||||
@@ -73,22 +89,21 @@ class FilesPage extends StatefulWidget {
|
|||||||
/// A scan grouped for display, made once per scan.
|
/// A scan grouped for display, made once per scan.
|
||||||
class _Library {
|
class _Library {
|
||||||
final GroupedVideos grouped;
|
final GroupedVideos grouped;
|
||||||
/// Series rows in order: a header (video null), then its episodes and
|
|
||||||
/// extras.
|
|
||||||
final List<(SeriesGroup, VideoFile?)> rows;
|
|
||||||
final int subtitleFiles;
|
final int subtitleFiles;
|
||||||
|
|
||||||
_Library(FolderScan scan)
|
_Library(FolderScan scan)
|
||||||
: grouped = groupBySeries(scan.videos),
|
: grouped = groupBySeries(scan.videos),
|
||||||
rows = [],
|
subtitleFiles = {for (final l in scan.subtitles.values) ...l}.length;
|
||||||
subtitleFiles = {for (final l in scan.subtitles.values) ...l}.length {
|
|
||||||
for (final g in grouped.series) {
|
/// Series rows in order: a header (video null), then, for the series
|
||||||
rows.add((g, null));
|
/// [open] says, its episodes and extras.
|
||||||
for (final v in [...g.episodes, ...g.extras]) {
|
List<(SeriesGroup, VideoFile?)> rows(bool Function(SeriesGroup g) open) => [
|
||||||
rows.add((g, v));
|
for (final g in grouped.series) ...[
|
||||||
}
|
(g, null),
|
||||||
}
|
if (open(g))
|
||||||
}
|
for (final v in [...g.episodes, ...g.extras]) (g, v),
|
||||||
|
],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
final _libraries = Expando<_Library>();
|
final _libraries = Expando<_Library>();
|
||||||
@@ -107,6 +122,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_showRequests.addListener(_shown);
|
||||||
_store.ensureLoaded().then((_) {
|
_store.ensureLoaded().then((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final open = _open;
|
final open = _open;
|
||||||
@@ -118,6 +134,14 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_showRequests.removeListener(_shown);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _shown() => _select(_lastOpen);
|
||||||
|
|
||||||
Future<FolderScan> _runScan(String dir) => (widget.scan ?? scanFolder)(dir);
|
Future<FolderScan> _runScan(String dir) => (widget.scan ?? scanFolder)(dir);
|
||||||
|
|
||||||
void _select(String? dir) {
|
void _select(String? dir) {
|
||||||
@@ -187,7 +211,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _play(String path) async {
|
Future<void> _play(String path) async {
|
||||||
if (!widget.online || _starting != null) return;
|
if (_starting != null) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_starting = path;
|
_starting = path;
|
||||||
_error = null;
|
_error = null;
|
||||||
@@ -216,7 +240,36 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
? PlayState.playing
|
? PlayState.playing
|
||||||
: PlayState.idle;
|
: PlayState.idle;
|
||||||
|
|
||||||
VoidCallback? _tapOf(String path) => widget.online ? () => _play(path) : null;
|
VoidCallback _tapOf(String path) => () => _play(path);
|
||||||
|
|
||||||
|
/// Right-click on a video ([context]: its tile): Put it up, Add to
|
||||||
|
/// queue, Play next.
|
||||||
|
Future<void> _menu(BuildContext context, String path) async {
|
||||||
|
final add = widget.onAddToQueue;
|
||||||
|
if (add == null) return;
|
||||||
|
final pick = await showAppMenu<int>(context, items: [
|
||||||
|
AppMenuItem(value: 0, label: widget.solo ? 'Play' : 'Put it up'),
|
||||||
|
const AppMenuItem(value: 1, label: 'Add to queue'),
|
||||||
|
const AppMenuItem(value: 2, label: 'Play next'),
|
||||||
|
]);
|
||||||
|
if (pick == null || !mounted) return;
|
||||||
|
if (pick == 0) return _play(path);
|
||||||
|
String? error;
|
||||||
|
try {
|
||||||
|
error = await add(path, next: pick == 2);
|
||||||
|
} catch (e) {
|
||||||
|
error = '$e';
|
||||||
|
}
|
||||||
|
if (error != null) _showError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [child] with the right-click menu (when there is a server).
|
||||||
|
Widget _menuable(String path, Widget child) => widget.onAddToQueue == null
|
||||||
|
? child
|
||||||
|
: KeyedSubtree(
|
||||||
|
key: ValueKey('menu:$path'),
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) => GestureDetector(onSecondaryTap: () => _menu(context, path), child: child)));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => LayoutBuilder(
|
Widget build(BuildContext context) => LayoutBuilder(
|
||||||
@@ -244,9 +297,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _browseButton(Widget Function(VoidCallback? onPressed) build) => widget.online
|
Widget _browseButton(Widget Function(VoidCallback? onPressed) build) => build(_browse);
|
||||||
? build(_browse)
|
|
||||||
: Tooltip(message: offlinePlayHint, child: build(null));
|
|
||||||
|
|
||||||
Widget _sidebar(BuildContext context, List<String> folders, String? open) {
|
Widget _sidebar(BuildContext context, List<String> folders, String? open) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
@@ -325,7 +376,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
final top = <Widget>[
|
final top = <Widget>[
|
||||||
_header(context, open),
|
_header(context, open),
|
||||||
if (narrow) _chips(folders, open),
|
if (narrow) _chips(folders, open),
|
||||||
if (widget.online && widget.relayed) const _RelayNotice(),
|
if (!widget.solo && widget.relayed) const _RelayNotice(),
|
||||||
AnimatedSize(
|
AnimatedSize(
|
||||||
duration: easeTime,
|
duration: easeTime,
|
||||||
curve: ease,
|
curve: ease,
|
||||||
@@ -450,15 +501,16 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
itemCount: recent.length,
|
itemCount: recent.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final path = recent[i];
|
final path = recent[i];
|
||||||
return _RecentRow(
|
return _menuable(
|
||||||
|
path,
|
||||||
|
_RecentRow(
|
||||||
key: ValueKey(path),
|
key: ValueKey(path),
|
||||||
path: path,
|
path: path,
|
||||||
state: _stateOf(path),
|
state: _stateOf(path),
|
||||||
thumbs: _thumbs,
|
thumbs: _thumbs,
|
||||||
onTap: _tapOf(path),
|
onTap: _tapOf(path),
|
||||||
offline: !widget.online,
|
|
||||||
onRemove: () => _store.removeRecent(path),
|
onRemove: () => _store.removeRecent(path),
|
||||||
);
|
));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -477,6 +529,8 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
if (scan.truncated) 'stopped after $maxScannedFiles files',
|
if (scan.truncated) 'stopped after $maxScannedFiles files',
|
||||||
].join(' · ');
|
].join(' · ');
|
||||||
final loose = lib.grouped.loose;
|
final loose = lib.grouped.loose;
|
||||||
|
String seriesId(SeriesGroup g) => '$dir\n${g.key}';
|
||||||
|
final rows = lib.rows((g) => _expanded.contains(seriesId(g)));
|
||||||
return [
|
return [
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
@@ -547,15 +601,25 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
'It may have been moved, renamed or be on a drive that is not connected.',
|
'It may have been moved, renamed or be on a drive that is not connected.',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Series: files whose names carry an episode number.
|
// Series: files whose names carry an episode number; a header
|
||||||
|
// click opens or closes its episodes.
|
||||||
SliverList.builder(
|
SliverList.builder(
|
||||||
itemCount: lib.rows.length,
|
itemCount: rows.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final (group, v) = lib.rows[i];
|
final (group, v) = rows[i];
|
||||||
if (v == null) {
|
if (v == null) {
|
||||||
return SeriesHeader(key: ValueKey('series:${group.key}'), group: group, thumbs: _thumbs);
|
final id = seriesId(group);
|
||||||
|
return SeriesHeader(
|
||||||
|
key: ValueKey('series:${group.key}'),
|
||||||
|
group: group,
|
||||||
|
thumbs: _thumbs,
|
||||||
|
expanded: _expanded.contains(id),
|
||||||
|
onTap: () => setState(() => _expanded.contains(id) ? _expanded.remove(id) : _expanded.add(id)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return FileRow(
|
return _menuable(
|
||||||
|
v.path,
|
||||||
|
FileRow(
|
||||||
key: ValueKey(v.path),
|
key: ValueKey(v.path),
|
||||||
path: v.path,
|
path: v.path,
|
||||||
title: v.parsed.label.isEmpty ? v.parsed.series : v.parsed.label,
|
title: v.parsed.label.isEmpty ? v.parsed.series : v.parsed.label,
|
||||||
@@ -564,8 +628,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
state: _stateOf(v.path),
|
state: _stateOf(v.path),
|
||||||
thumbs: _thumbs,
|
thumbs: _thumbs,
|
||||||
onTap: _tapOf(v.path),
|
onTap: _tapOf(v.path),
|
||||||
offline: !widget.online,
|
));
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// Everything else (movies, clips) as a thumbnail grid.
|
// Everything else (movies, clips) as a thumbnail grid.
|
||||||
@@ -594,14 +657,15 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
itemCount: loose.length,
|
itemCount: loose.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final v = loose[i];
|
final v = loose[i];
|
||||||
return VideoCard(
|
return _menuable(
|
||||||
|
v.path,
|
||||||
|
VideoCard(
|
||||||
key: ValueKey(v.path),
|
key: ValueKey(v.path),
|
||||||
video: v,
|
video: v,
|
||||||
state: _stateOf(v.path),
|
state: _stateOf(v.path),
|
||||||
thumbs: _thumbs,
|
thumbs: _thumbs,
|
||||||
onTap: _tapOf(v.path),
|
onTap: _tapOf(v.path),
|
||||||
offline: !widget.online,
|
));
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
@@ -617,7 +681,6 @@ class _RecentRow extends StatefulWidget {
|
|||||||
final PlayState state;
|
final PlayState state;
|
||||||
final ThumbCache thumbs;
|
final ThumbCache thumbs;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final bool offline;
|
|
||||||
final VoidCallback onRemove;
|
final VoidCallback onRemove;
|
||||||
const _RecentRow({
|
const _RecentRow({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -625,7 +688,6 @@ class _RecentRow extends StatefulWidget {
|
|||||||
required this.state,
|
required this.state,
|
||||||
required this.thumbs,
|
required this.thumbs,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
required this.offline,
|
|
||||||
required this.onRemove,
|
required this.onRemove,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -658,7 +720,6 @@ class _RecentRowState extends State<_RecentRow> {
|
|||||||
state: widget.state,
|
state: widget.state,
|
||||||
thumbs: widget.thumbs,
|
thumbs: widget.thumbs,
|
||||||
onTap: missing ? null : widget.onTap,
|
onTap: missing ? null : widget.onTap,
|
||||||
offline: widget.offline && !missing,
|
|
||||||
indent: false,
|
indent: false,
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
tooltip: 'Remove from recent',
|
tooltip: 'Remove from recent',
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import 'package:hive_ce/hive.dart';
|
|||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../screens/home/picked_media.dart';
|
import '../screens/home/picked_media.dart';
|
||||||
import '../screens/home/ui.dart' show appName;
|
|
||||||
import '../update/updater.dart' show appVersion;
|
|
||||||
|
|
||||||
// Linked Jellyfin servers, one account per ServerId, in their own Hive box.
|
// Linked Jellyfin servers, one account per ServerId, in their own Hive box.
|
||||||
//
|
//
|
||||||
@@ -196,6 +194,13 @@ class JellyfinAccounts extends ChangeNotifier {
|
|||||||
JellyfinAccount? account(String serverId) =>
|
JellyfinAccount? account(String serverId) =>
|
||||||
_accounts.where((a) => a.serverId == serverId).firstOrNull;
|
_accounts.where((a) => a.serverId == serverId).firstOrNull;
|
||||||
|
|
||||||
|
/// [itemId]'s page in the web UI of linked server [serverId] ("Open in
|
||||||
|
/// Jellyfin"); null when that server is not linked here.
|
||||||
|
String? webUrlOf(String serverId, String itemId) {
|
||||||
|
final a = account(serverId);
|
||||||
|
return a == null ? null : jellyfinWebUrl(a.baseUrl, itemId, serverId: serverId);
|
||||||
|
}
|
||||||
|
|
||||||
/// The chosen default, else the first linked server.
|
/// The chosen default, else the first linked server.
|
||||||
JellyfinAccount? get defaultAccount =>
|
JellyfinAccount? get defaultAccount =>
|
||||||
(_defaultServerId == null ? null : account(_defaultServerId!)) ?? _accounts.firstOrNull;
|
(_defaultServerId == null ? null : account(_defaultServerId!)) ?? _accounts.firstOrNull;
|
||||||
@@ -215,7 +220,8 @@ class JellyfinAccounts extends ChangeNotifier {
|
|||||||
String get deviceId => _deviceId;
|
String get deviceId => _deviceId;
|
||||||
|
|
||||||
JellyfinClientInfo get clientInfo => JellyfinClientInfo(
|
JellyfinClientInfo get clientInfo => JellyfinClientInfo(
|
||||||
client: appName,
|
// The old name on purpose: Jellyfin lists devices under it.
|
||||||
|
client: 'Watch Together',
|
||||||
device: deviceName ?? _hostname(),
|
device: deviceName ?? _hostname(),
|
||||||
deviceId: _deviceId.isEmpty ? 'unknown' : _deviceId,
|
deviceId: _deviceId.isEmpty ? 'unknown' : _deviceId,
|
||||||
version: appVersion,
|
version: appVersion,
|
||||||
@@ -544,6 +550,17 @@ class JellyfinAccounts extends ChangeNotifier {
|
|||||||
/// Drops cached browse results (refresh button, after a pick).
|
/// Drops cached browse results (refresh button, after a pick).
|
||||||
void invalidate(String serverId) => _clearMemo(serverId);
|
void invalidate(String serverId) => _clearMemo(serverId);
|
||||||
|
|
||||||
|
final _watched = StreamController<String>.broadcast();
|
||||||
|
|
||||||
|
/// Server ids whose watched state this app just changed (a stop report):
|
||||||
|
/// their cached lists are gone, and browse views reload.
|
||||||
|
Stream<String> get watched => _watched.stream;
|
||||||
|
|
||||||
|
void watchedChanged(String serverId) {
|
||||||
|
_clearMemo(serverId);
|
||||||
|
_watched.add(serverId);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import 'package:sync_relay_core/core.dart';
|
|||||||
|
|
||||||
import 'accounts.dart';
|
import 'accounts.dart';
|
||||||
import 'format.dart';
|
import 'format.dart';
|
||||||
|
import '../widgets/app_menu.dart';
|
||||||
|
import '../widgets/copy_open.dart';
|
||||||
|
|
||||||
/// One level of the library browser's back stack.
|
/// One level of the library browser's back stack.
|
||||||
sealed class BrowseRoute {
|
sealed class BrowseRoute {
|
||||||
@@ -156,7 +158,14 @@ class BrowseController extends ChangeNotifier {
|
|||||||
final JellyfinAccounts accounts;
|
final JellyfinAccounts accounts;
|
||||||
/// Clock for the Continue row's age cut-off (tests pin it).
|
/// Clock for the Continue row's age cut-off (tests pin it).
|
||||||
final DateTime Function() now;
|
final DateTime Function() now;
|
||||||
BrowseController(this.accounts, {this.now = DateTime.now});
|
BrowseController(this.accounts, {this.now = DateTime.now}) {
|
||||||
|
// Back from watching: the rows show what was just watched.
|
||||||
|
_watchedSub = accounts.watched.listen((id) {
|
||||||
|
if (!_disposed && id == serverId) dataChanged();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
late final StreamSubscription<String> _watchedSub;
|
||||||
|
|
||||||
String? _serverId;
|
String? _serverId;
|
||||||
List<BrowseRoute> _stack = const [HomeRoute()];
|
List<BrowseRoute> _stack = const [HomeRoute()];
|
||||||
@@ -258,10 +267,19 @@ class BrowseController extends ChangeNotifier {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
|
unawaited(_watchedSub.cancel());
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An item's title to copy: an episode with its series ("Show · Name").
|
||||||
|
String jellyfinCopyTitle(JellyfinItem item) {
|
||||||
|
final series = item.seriesName;
|
||||||
|
return item.kind == JellyfinItemKind.episode && series != null && series.isNotEmpty
|
||||||
|
? '$series · ${item.name}'
|
||||||
|
: item.name;
|
||||||
|
}
|
||||||
|
|
||||||
/// What every browser page needs, provided by [JellyfinBrowser].
|
/// What every browser page needs, provided by [JellyfinBrowser].
|
||||||
class BrowseScope extends InheritedWidget {
|
class BrowseScope extends InheritedWidget {
|
||||||
final JellyfinAccounts accounts;
|
final JellyfinAccounts accounts;
|
||||||
@@ -274,6 +292,9 @@ class BrowseScope extends InheritedWidget {
|
|||||||
final ValueChanged<JellyfinItem> onSelect;
|
final ValueChanged<JellyfinItem> onSelect;
|
||||||
/// Double-click: put it up (a series: its next episode).
|
/// Double-click: put it up (a series: its next episode).
|
||||||
final ValueChanged<JellyfinItem> onPutUp;
|
final ValueChanged<JellyfinItem> onPutUp;
|
||||||
|
/// Adds it to Up next (with next: right after what plays); null: no
|
||||||
|
/// server (the cards' menu then only copies and opens).
|
||||||
|
final void Function(JellyfinItem item, {bool next})? onAddToQueue;
|
||||||
/// Home shows the library chips itself (the Library tab has them in its
|
/// Home shows the library chips itself (the Library tab has them in its
|
||||||
/// header instead).
|
/// header instead).
|
||||||
final bool libraryStrip;
|
final bool libraryStrip;
|
||||||
@@ -287,6 +308,7 @@ class BrowseScope extends InheritedWidget {
|
|||||||
required this.selectedItemId,
|
required this.selectedItemId,
|
||||||
required this.onSelect,
|
required this.onSelect,
|
||||||
required this.onPutUp,
|
required this.onPutUp,
|
||||||
|
this.onAddToQueue,
|
||||||
this.libraryStrip = true,
|
this.libraryStrip = true,
|
||||||
required super.child,
|
required super.child,
|
||||||
});
|
});
|
||||||
@@ -296,6 +318,33 @@ class BrowseScope extends InheritedWidget {
|
|||||||
|
|
||||||
void onOpen(BrowseRoute route) => controller.open(route);
|
void onOpen(BrowseRoute route) => controller.open(route);
|
||||||
|
|
||||||
|
/// Right-click on a card ([context]: the card): Put it up, Add to queue,
|
||||||
|
/// Play next (playable, in a server), Copy title, Open in Jellyfin.
|
||||||
|
Future<void> cardMenu(BuildContext context, JellyfinItem item) async {
|
||||||
|
final queue = isSelectable(item) ? onAddToQueue : null;
|
||||||
|
final pick = await showAppMenu<int>(context, items: [
|
||||||
|
if (queue != null) ...const [
|
||||||
|
AppMenuItem(value: 0, label: 'Put it up'),
|
||||||
|
AppMenuItem(value: 1, label: 'Add to queue'),
|
||||||
|
AppMenuItem(value: 2, label: 'Play next'),
|
||||||
|
],
|
||||||
|
const AppMenuItem(value: 3, label: ReuseText.copyTitle),
|
||||||
|
const AppMenuItem(value: 4, label: ReuseText.openInJellyfin),
|
||||||
|
]);
|
||||||
|
switch (pick) {
|
||||||
|
case 0:
|
||||||
|
onPutUp(item);
|
||||||
|
case 1:
|
||||||
|
queue!(item);
|
||||||
|
case 2:
|
||||||
|
queue!(item, next: true);
|
||||||
|
case 3:
|
||||||
|
await copyToClipboard(jellyfinCopyTitle(item), done: 'Title copied');
|
||||||
|
case 4:
|
||||||
|
await openLink(jellyfinWebUrl(client.baseUrl, item.id, serverId: account.serverId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Cached browse call on this server.
|
/// Cached browse call on this server.
|
||||||
Future<T> load<T>(String key, Future<T> Function(JellyfinClient c) fn) =>
|
Future<T> load<T>(String key, Future<T> Function(JellyfinClient c) fn) =>
|
||||||
accounts.cached(account.serverId, key, () => fn(client));
|
accounts.cached(account.serverId, key, () => fn(client));
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ class JellyfinBrowser extends StatefulWidget {
|
|||||||
final JellyfinSelection? selected;
|
final JellyfinSelection? selected;
|
||||||
final ValueChanged<JellyfinSelection> onSelect;
|
final ValueChanged<JellyfinSelection> onSelect;
|
||||||
final ValueChanged<JellyfinSelection> onPutUp;
|
final ValueChanged<JellyfinSelection> onPutUp;
|
||||||
|
/// Right-click on a card: Add to queue / Play next; null: no server.
|
||||||
|
final void Function(JellyfinSelection item, {bool next})? onAddToQueue;
|
||||||
final VoidCallback? onOpenIntegrations;
|
final VoidCallback? onOpenIntegrations;
|
||||||
final Duration searchDebounce;
|
final Duration searchDebounce;
|
||||||
/// Opens here instead of the remembered location (the series of the
|
/// Opens here instead of the remembered location (the series of the
|
||||||
@@ -43,6 +45,7 @@ class JellyfinBrowser extends StatefulWidget {
|
|||||||
required this.accounts,
|
required this.accounts,
|
||||||
required this.onSelect,
|
required this.onSelect,
|
||||||
required this.onPutUp,
|
required this.onPutUp,
|
||||||
|
this.onAddToQueue,
|
||||||
this.controller,
|
this.controller,
|
||||||
this.selected,
|
this.selected,
|
||||||
this.onOpenIntegrations,
|
this.onOpenIntegrations,
|
||||||
@@ -221,6 +224,10 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
|
|||||||
selectedItemId: sel != null && sel.serverId == account.serverId ? sel.itemId : null,
|
selectedItemId: sel != null && sel.serverId == account.serverId ? sel.itemId : null,
|
||||||
onSelect: (item) => widget.onSelect(JellyfinSelection(account.serverId, item.id, item)),
|
onSelect: (item) => widget.onSelect(JellyfinSelection(account.serverId, item.id, item)),
|
||||||
onPutUp: (item) => widget.onPutUp(JellyfinSelection(account.serverId, item.id, item)),
|
onPutUp: (item) => widget.onPutUp(JellyfinSelection(account.serverId, item.id, item)),
|
||||||
|
onAddToQueue: widget.onAddToQueue == null
|
||||||
|
? null
|
||||||
|
: (item, {next = false}) =>
|
||||||
|
widget.onAddToQueue!(JellyfinSelection(account.serverId, item.id, item), next: next),
|
||||||
libraryStrip: widget.libraryStrip,
|
libraryStrip: widget.libraryStrip,
|
||||||
child: Focus(
|
child: Focus(
|
||||||
onKeyEvent: _onKey,
|
onKeyEvent: _onKey,
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ class _MediaCardState extends State<MediaCard> {
|
|||||||
focusColor: Colors.transparent,
|
focusColor: Colors.transparent,
|
||||||
hoverColor: Colors.transparent,
|
hoverColor: Colors.transparent,
|
||||||
onTap: () => _tap(scope),
|
onTap: () => _tap(scope),
|
||||||
|
onSecondaryTap: () => scope.cardMenu(context, item),
|
||||||
onHover: (h) => setState(() => _hover = h),
|
onHover: (h) => setState(() => _hover = h),
|
||||||
onFocusChange: (f) => setState(() => _focus = f),
|
onFocusChange: (f) => setState(() => _focus = f),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../shell/motion.dart';
|
import '../shell/motion.dart';
|
||||||
|
import '../widgets/app_menu.dart';
|
||||||
|
import '../widgets/copy_open.dart';
|
||||||
import 'accounts.dart';
|
import 'accounts.dart';
|
||||||
import 'browse_scope.dart';
|
import 'browse_scope.dart';
|
||||||
import 'cards.dart' show doubleClickTime, kindIcon;
|
import 'cards.dart' show doubleClickTime, kindIcon;
|
||||||
@@ -32,26 +34,30 @@ typedef _Data = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/// The selected card's details, at the right of the library: banner,
|
/// The selected card's details, at the right of the library: banner,
|
||||||
/// title, meta, "Put it up · S1E3" (a disabled Play with [offlineHint]
|
/// title, meta, "Put it up · S1E3" ("Play · S1E3" when watching
|
||||||
/// offline), mark watched / unwatched, remove from Continue, overview and,
|
/// [solo]), mark watched / unwatched, remove from Continue, overview and,
|
||||||
/// for a series or an episode, the episode list (click picks which
|
/// for a series or an episode, the episode list (click picks which
|
||||||
/// episode goes up, double-click puts it up). Reloads when the
|
/// episode goes up, double-click puts it up). Reloads when the
|
||||||
/// [controller]'s data version moves.
|
/// [controller]'s data version moves.
|
||||||
class JellyfinDetailsPanel extends StatefulWidget {
|
class JellyfinDetailsPanel extends StatefulWidget {
|
||||||
final BrowseController controller;
|
final BrowseController controller;
|
||||||
final JellyfinSelection selection;
|
final JellyfinSelection selection;
|
||||||
/// Null when online; else why nothing can go up.
|
/// Nobody else watches (offline or solo): the button says Play.
|
||||||
final String? offlineHint;
|
final bool solo;
|
||||||
/// Puts [item] (playable) up; returns an error text, or null when sent.
|
/// Puts [item] (playable) up; returns an error text, or null when sent.
|
||||||
final Future<String?> Function(JellyfinSelection item) onPutUp;
|
final Future<String?> Function(JellyfinSelection item) onPutUp;
|
||||||
|
/// Adds [item] to Up next (with next: right after what plays); null: no
|
||||||
|
/// server, no queue actions.
|
||||||
|
final Future<String?> Function(JellyfinSelection item, {bool next})? onAddToQueue;
|
||||||
final VoidCallback onClose;
|
final VoidCallback onClose;
|
||||||
const JellyfinDetailsPanel({
|
const JellyfinDetailsPanel({
|
||||||
super.key,
|
super.key,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.selection,
|
required this.selection,
|
||||||
required this.onPutUp,
|
required this.onPutUp,
|
||||||
|
this.onAddToQueue,
|
||||||
required this.onClose,
|
required this.onClose,
|
||||||
this.offlineHint,
|
this.solo = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -104,7 +110,7 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
|||||||
_cached('episodes:$seriesId:$seasonId', (c) => c.episodes(seriesId, seasonId: seasonId));
|
_cached('episodes:$seriesId:$seasonId', (c) => c.episodes(seriesId, seasonId: seasonId));
|
||||||
|
|
||||||
Future<void> _putUp(JellyfinItem target) async {
|
Future<void> _putUp(JellyfinItem target) async {
|
||||||
if (_busy || widget.offlineHint != null) return;
|
if (_busy) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_busy = true;
|
_busy = true;
|
||||||
_error = null;
|
_error = null;
|
||||||
@@ -122,6 +128,55 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _queue(JellyfinItem target, {bool next = false}) async {
|
||||||
|
final add = widget.onAddToQueue;
|
||||||
|
if (add == null || _busy) return;
|
||||||
|
setState(() {
|
||||||
|
_busy = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
String? error;
|
||||||
|
try {
|
||||||
|
error = await add(JellyfinSelection(_serverId, target.id, target), next: next);
|
||||||
|
} catch (e) {
|
||||||
|
error = '$e';
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_busy = false;
|
||||||
|
_error = error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Right-click on an episode ([context]: its row): Put it up, Add to
|
||||||
|
/// queue, Play next (in a server), Copy title, Open in Jellyfin.
|
||||||
|
Future<void> _episodeMenu(BuildContext context, JellyfinItem e) async {
|
||||||
|
final queue = widget.onAddToQueue != null;
|
||||||
|
if (queue) setState(() => _targetId = e.id);
|
||||||
|
final client = _client;
|
||||||
|
final pick = await showAppMenu<int>(context, items: [
|
||||||
|
if (queue) ...[
|
||||||
|
AppMenuItem(value: 0, label: widget.solo ? 'Play' : 'Put it up'),
|
||||||
|
const AppMenuItem(value: 1, label: 'Add to queue'),
|
||||||
|
const AppMenuItem(value: 2, label: 'Play next'),
|
||||||
|
],
|
||||||
|
const AppMenuItem(value: 3, label: ReuseText.copyTitle),
|
||||||
|
if (client != null) const AppMenuItem(value: 4, label: ReuseText.openInJellyfin),
|
||||||
|
]);
|
||||||
|
switch (pick) {
|
||||||
|
case 0:
|
||||||
|
await _putUp(e);
|
||||||
|
case 1:
|
||||||
|
await _queue(e);
|
||||||
|
case 2:
|
||||||
|
await _queue(e, next: true);
|
||||||
|
case 3:
|
||||||
|
await copyToClipboard(jellyfinCopyTitle(e), done: 'Title copied');
|
||||||
|
case 4:
|
||||||
|
await openLink(jellyfinWebUrl(client!.baseUrl, e.id, serverId: _serverId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Runs a watched-state change, then lets every row reload.
|
/// Runs a watched-state change, then lets every row reload.
|
||||||
Future<void> _change(Future<void> Function(JellyfinClient c) action) async {
|
Future<void> _change(Future<void> Function(JellyfinClient c) action) async {
|
||||||
final c = _client;
|
final c = _client;
|
||||||
@@ -253,14 +308,8 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
|||||||
if (shown.communityRating != null) '★ ${shown.communityRating!.toStringAsFixed(1)}',
|
if (shown.communityRating != null) '★ ${shown.communityRating!.toStringAsFixed(1)}',
|
||||||
if (!isSeries && shown.userData.played) 'Watched',
|
if (!isSeries && shown.userData.played) 'Watched',
|
||||||
].join(' · ');
|
].join(' · ');
|
||||||
final hint = widget.offlineHint;
|
final verb = widget.solo ? 'Play' : 'Put it up';
|
||||||
final label = playable == null
|
final label = playable != null && isSeries ? '$verb · ${playable.episodeLabel ?? playable.name}' : verb;
|
||||||
? (hint == null ? 'Put it up' : 'Play')
|
|
||||||
: hint != null
|
|
||||||
? 'Play'
|
|
||||||
: isSeries
|
|
||||||
? 'Put it up · ${playable.episodeLabel ?? playable.name}'
|
|
||||||
: 'Put it up';
|
|
||||||
final inContinue = playable != null &&
|
final inContinue = playable != null &&
|
||||||
(playable.userData.isResumable || _c.continueKeys.contains(continueKey(playable)));
|
(playable.userData.isResumable || _c.continueKeys.contains(continueKey(playable)));
|
||||||
final left = playable == null ? null : timeLeft(playable);
|
final left = playable == null ? null : timeLeft(playable);
|
||||||
@@ -307,12 +356,22 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
|||||||
children: [
|
children: [
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
key: const ValueKey('details-put-up'),
|
key: const ValueKey('details-put-up'),
|
||||||
onPressed: playable == null || hint != null || _busy ? null : () => _putUp(playable),
|
onPressed: playable == null || _busy ? null : () => _putUp(playable),
|
||||||
icon: _busy
|
icon: _busy
|
||||||
? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
: const Icon(Icons.play_arrow),
|
: const Icon(Icons.play_arrow),
|
||||||
label: Text(label, overflow: TextOverflow.ellipsis),
|
label: Text(label, overflow: TextOverflow.ellipsis),
|
||||||
),
|
),
|
||||||
|
if (playable != null && widget.onAddToQueue != null)
|
||||||
|
GestureDetector(
|
||||||
|
onSecondaryTap: _busy ? null : () => _queue(playable, next: true),
|
||||||
|
child: IconButton(
|
||||||
|
key: const ValueKey('details-add-queue'),
|
||||||
|
onPressed: _busy ? null : () => _queue(playable),
|
||||||
|
tooltip: 'Add to queue (right-click: play next)',
|
||||||
|
icon: const Icon(Icons.playlist_add_rounded),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (playable != null)
|
if (playable != null)
|
||||||
IconButton(
|
IconButton(
|
||||||
key: const ValueKey('details-watched'),
|
key: const ValueKey('details-watched'),
|
||||||
@@ -334,14 +393,21 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
|||||||
onPressed: () => _c.open(SeriesRoute(d.series!, seasonId: seasonId)),
|
onPressed: () => _c.open(SeriesRoute(d.series!, seasonId: seasonId)),
|
||||||
icon: const Icon(Icons.open_in_new),
|
icon: const Icon(Icons.open_in_new),
|
||||||
),
|
),
|
||||||
|
if (client != null)
|
||||||
|
IconButton(
|
||||||
|
key: const ValueKey('details-open-jellyfin'),
|
||||||
|
tooltip: ReuseText.openInJellyfin,
|
||||||
|
onPressed: () => openLink(jellyfinWebUrl(client.baseUrl, shown.id, serverId: _serverId)),
|
||||||
|
icon: const Icon(Icons.open_in_browser_rounded),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (hint != null || _error != null) ...[
|
if (_error != null) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
_error ?? hint!,
|
_error!,
|
||||||
key: const ValueKey('details-hint'),
|
key: const ValueKey('details-hint'),
|
||||||
style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey),
|
style: t.bodySmall?.copyWith(color: scheme.error),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (isSeries && playable != null) ...[
|
if (isSeries && playable != null) ...[
|
||||||
@@ -398,18 +464,14 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
|||||||
children: [
|
children: [
|
||||||
const Expanded(child: Text('Episodes', style: TextStyle(fontWeight: FontWeight.w600))),
|
const Expanded(child: Text('Episodes', style: TextStyle(fontWeight: FontWeight.w600))),
|
||||||
if (seasons.length > 1)
|
if (seasons.length > 1)
|
||||||
DropdownButton<String>(
|
Flexible(
|
||||||
|
child: AppSelect<String?>(
|
||||||
key: const ValueKey('details-season'),
|
key: const ValueKey('details-season'),
|
||||||
value: seasonId,
|
value: seasonId,
|
||||||
isDense: true,
|
items: [for (final s in seasons) (s.id, s.name)],
|
||||||
underline: const SizedBox.shrink(),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
items: [
|
|
||||||
for (final s in seasons)
|
|
||||||
DropdownMenuItem(value: s.id, child: Text(s.name, overflow: TextOverflow.ellipsis)),
|
|
||||||
],
|
|
||||||
onChanged: (id) => setState(() => _seasonId = id),
|
onChanged: (id) => setState(() => _seasonId = id),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -422,10 +484,12 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
|||||||
return Material(
|
return Material(
|
||||||
color: isTarget ? scheme.primary.withValues(alpha: 0.10) : Colors.transparent,
|
color: isTarget ? scheme.primary.withValues(alpha: 0.10) : Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: InkWell(
|
// The row's own context: its menu opens at the row.
|
||||||
|
child: Builder(builder: (context) => InkWell(
|
||||||
key: ValueKey('details-episode-${e.id}'),
|
key: ValueKey('details-episode-${e.id}'),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
onTap: () => _tapEpisode(e),
|
onTap: () => _tapEpisode(e),
|
||||||
|
onSecondaryTap: () => _episodeMenu(context, e),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 4),
|
padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -470,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 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../state/settings.dart';
|
import '../state/settings.dart';
|
||||||
|
import '../widgets/app_menu.dart';
|
||||||
import 'browse_scope.dart';
|
import 'browse_scope.dart';
|
||||||
import 'cards.dart';
|
import 'cards.dart';
|
||||||
import 'home_rows.dart';
|
import 'home_rows.dart';
|
||||||
@@ -184,18 +185,18 @@ class _LatestRow extends StatelessWidget {
|
|||||||
onPressed: () => scope.onOpen(LibraryRoute(view, sort: 'added')),
|
onPressed: () => scope.onOpen(LibraryRoute(view, sort: 'added')),
|
||||||
child: const Text('Show all'),
|
child: const Text('Show all'),
|
||||||
),
|
),
|
||||||
PopupMenuButton<void>(
|
AppMenuButton<bool>(
|
||||||
key: ValueKey('latest-menu-${view.id}'),
|
key: ValueKey('latest-menu-${view.id}'),
|
||||||
tooltip: 'Row options',
|
tooltip: 'Row options',
|
||||||
icon: const Icon(Icons.more_vert, size: 18),
|
items: () => [
|
||||||
itemBuilder: (context) => [
|
AppMenuItem(
|
||||||
CheckedPopupMenuItem(
|
|
||||||
key: const ValueKey('hide-rereleases'),
|
key: const ValueKey('hide-rereleases'),
|
||||||
|
value: !hide,
|
||||||
checked: hide,
|
checked: hide,
|
||||||
onTap: () => AppSettings.instance.setHideReReleases(!hide),
|
label: 'Hide re-releases in Latest',
|
||||||
child: const Text('Hide re-releases in Latest'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
onSelected: AppSettings.instance.setHideReReleases,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,26 +7,31 @@ import 'browser.dart';
|
|||||||
import 'sign_in_panel.dart';
|
import 'sign_in_panel.dart';
|
||||||
|
|
||||||
/// The Library's Jellyfin source: the library browser once a server is
|
/// The Library's Jellyfin source: the library browser once a server is
|
||||||
/// linked, else an inline add-server panel.
|
/// linked, else [empty] (default: an inline add-server panel).
|
||||||
class JellyfinPickerSource extends StatefulWidget {
|
class JellyfinPickerSource extends StatefulWidget {
|
||||||
final JellyfinAccounts accounts;
|
final JellyfinAccounts accounts;
|
||||||
final BrowseController? controller;
|
final BrowseController? controller;
|
||||||
final JellyfinSelection? selected;
|
final JellyfinSelection? selected;
|
||||||
final ValueChanged<JellyfinSelection> onSelect;
|
final ValueChanged<JellyfinSelection> onSelect;
|
||||||
final ValueChanged<JellyfinSelection> onPutUp;
|
final ValueChanged<JellyfinSelection> onPutUp;
|
||||||
|
final void Function(JellyfinSelection item, {bool next})? onAddToQueue;
|
||||||
final VoidCallback? onOpenIntegrations;
|
final VoidCallback? onOpenIntegrations;
|
||||||
final BrowseStart? start;
|
final BrowseStart? start;
|
||||||
final bool libraryStrip;
|
final bool libraryStrip;
|
||||||
|
/// Shown while no server is linked.
|
||||||
|
final WidgetBuilder? empty;
|
||||||
const JellyfinPickerSource({
|
const JellyfinPickerSource({
|
||||||
super.key,
|
super.key,
|
||||||
this.start,
|
this.start,
|
||||||
required this.accounts,
|
required this.accounts,
|
||||||
required this.onSelect,
|
required this.onSelect,
|
||||||
required this.onPutUp,
|
required this.onPutUp,
|
||||||
|
this.onAddToQueue,
|
||||||
this.controller,
|
this.controller,
|
||||||
this.selected,
|
this.selected,
|
||||||
this.onOpenIntegrations,
|
this.onOpenIntegrations,
|
||||||
this.libraryStrip = true,
|
this.libraryStrip = true,
|
||||||
|
this.empty,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -49,6 +54,7 @@ class _JellyfinPickerSourceState extends State<JellyfinPickerSource> {
|
|||||||
if (!a.loaded) {
|
if (!a.loaded) {
|
||||||
return const Center(child: CircularProgressIndicator(strokeWidth: 2));
|
return const Center(child: CircularProgressIndicator(strokeWidth: 2));
|
||||||
}
|
}
|
||||||
|
if (a.isEmpty && widget.empty != null) return widget.empty!(context);
|
||||||
if (a.isEmpty) return _LinkFirst(accounts: a, onOpenIntegrations: widget.onOpenIntegrations);
|
if (a.isEmpty) return _LinkFirst(accounts: a, onOpenIntegrations: widget.onOpenIntegrations);
|
||||||
return JellyfinBrowser(
|
return JellyfinBrowser(
|
||||||
key: const ValueKey('media-jellyfin'),
|
key: const ValueKey('media-jellyfin'),
|
||||||
@@ -57,6 +63,7 @@ class _JellyfinPickerSourceState extends State<JellyfinPickerSource> {
|
|||||||
selected: widget.selected,
|
selected: widget.selected,
|
||||||
onSelect: widget.onSelect,
|
onSelect: widget.onSelect,
|
||||||
onPutUp: widget.onPutUp,
|
onPutUp: widget.onPutUp,
|
||||||
|
onAddToQueue: widget.onAddToQueue,
|
||||||
onOpenIntegrations: widget.onOpenIntegrations,
|
onOpenIntegrations: widget.onOpenIntegrations,
|
||||||
start: widget.start,
|
start: widget.start,
|
||||||
libraryStrip: widget.libraryStrip,
|
libraryStrip: widget.libraryStrip,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
@@ -24,6 +25,14 @@ class PlayerSnapshot {
|
|||||||
/// go out one after another; a failure is logged and handed to [onError]
|
/// go out one after another; a failure is logged and handed to [onError]
|
||||||
/// (the session shows one quiet toast) and never touches playback. A 401
|
/// (the session shows one quiet toast) and never touches playback. A 401
|
||||||
/// calls [onUnauthorized] and ends reporting.
|
/// calls [onUnauthorized] and ends reporting.
|
||||||
|
///
|
||||||
|
/// Watched or not is [WatchSession]'s rule, not Jellyfin's position
|
||||||
|
/// percentages (see ARCHITECTURE.md "Jellyfin in a session"): an item
|
||||||
|
/// played before ([playedBefore]) stays played (re-marked after Playing,
|
||||||
|
/// Progress without a position, Stopped without one, then marked played
|
||||||
|
/// again); otherwise Progress leaves the position out past
|
||||||
|
/// [_noPositionFrom] of the runtime so Jellyfin cannot mark it played on
|
||||||
|
/// its own, and Stopped carries what the rule says.
|
||||||
class JellyfinProgressReporter {
|
class JellyfinProgressReporter {
|
||||||
final JellyfinClient client;
|
final JellyfinClient client;
|
||||||
final String itemId;
|
final String itemId;
|
||||||
@@ -34,20 +43,41 @@ class JellyfinProgressReporter {
|
|||||||
final void Function(Object error)? onError;
|
final void Function(Object error)? onError;
|
||||||
final VoidCallback? onUnauthorized;
|
final VoidCallback? onUnauthorized;
|
||||||
|
|
||||||
|
/// What this viewer watched of the item while it was open here.
|
||||||
|
final WatchSession watch;
|
||||||
|
|
||||||
JellyfinProgressReporter({
|
JellyfinProgressReporter({
|
||||||
required this.client,
|
required this.client,
|
||||||
required this.itemId,
|
required this.itemId,
|
||||||
required this.source,
|
required this.source,
|
||||||
required this.playSessionId,
|
required this.playSessionId,
|
||||||
|
bool playedBefore = false,
|
||||||
|
int resumeTicksBefore = 0,
|
||||||
this.interval = const Duration(seconds: 10),
|
this.interval = const Duration(seconds: 10),
|
||||||
this.onError,
|
this.onError,
|
||||||
this.onUnauthorized,
|
this.onUnauthorized,
|
||||||
|
this.onStopped,
|
||||||
DateTime Function()? clock,
|
DateTime Function()? clock,
|
||||||
}) : _clock = clock ?? DateTime.now;
|
}) : _clock = clock ?? DateTime.now,
|
||||||
|
_keepPlayed = playedBefore,
|
||||||
|
_runtime = (source.runTimeTicks ?? 0) / jellyfinTicksPerSecond,
|
||||||
|
watch = WatchSession(
|
||||||
|
playedBefore: playedBefore, resumeBefore: resumeTicksBefore / jellyfinTicksPerSecond);
|
||||||
|
|
||||||
|
/// Past this share of the runtime Progress carries no position: Jellyfin
|
||||||
|
/// marks an item played past its "max resume" share (90 % by default).
|
||||||
|
static const _noPositionFrom = 0.85;
|
||||||
|
|
||||||
|
/// After Stopped (and a mark played) went out: the item's watched
|
||||||
|
/// state on the server may differ from what lists show.
|
||||||
|
final void Function()? onStopped;
|
||||||
|
|
||||||
Future<void> _chain = Future.value();
|
Future<void> _chain = Future.value();
|
||||||
bool _playing = false;
|
bool _playing = false;
|
||||||
bool _dead = false;
|
bool _dead = false;
|
||||||
|
bool _keepPlayed;
|
||||||
|
double _runtime;
|
||||||
|
double? _endingStart;
|
||||||
PlayerSnapshot? _last;
|
PlayerSnapshot? _last;
|
||||||
DateTime? _lastAt;
|
DateTime? _lastAt;
|
||||||
DateTime? _lastSentAt;
|
DateTime? _lastSentAt;
|
||||||
@@ -55,13 +85,23 @@ class JellyfinProgressReporter {
|
|||||||
/// Between [start] and [stop].
|
/// Between [start] and [stop].
|
||||||
bool get active => _playing && !_dead;
|
bool get active => _playing && !_dead;
|
||||||
|
|
||||||
|
/// The item counts as played here (before, or by the rule at a stop).
|
||||||
|
bool get keepsPlayed => _keepPlayed;
|
||||||
|
|
||||||
|
/// The player's duration (used when Jellyfin gave no runtime) and where
|
||||||
|
/// the ending starts (Outro segment / ED chapter), seconds.
|
||||||
|
void timeline({double duration = 0, double? endingStart}) {
|
||||||
|
if ((source.runTimeTicks ?? 0) <= 0 && duration > 0) _runtime = duration;
|
||||||
|
_endingStart = endingStart;
|
||||||
|
}
|
||||||
|
|
||||||
/// The report body for [s]: ticks, pause, and mpv's aid/sid mapped to
|
/// The report body for [s]: ticks, pause, and mpv's aid/sid mapped to
|
||||||
/// Jellyfin stream indices (-1 = subtitles off).
|
/// Jellyfin stream indices (-1 = subtitles off).
|
||||||
JellyfinPlaybackReport reportFor(PlayerSnapshot s) => JellyfinPlaybackReport(
|
JellyfinPlaybackReport reportFor(PlayerSnapshot s, {bool withPosition = true}) => JellyfinPlaybackReport(
|
||||||
itemId: itemId,
|
itemId: itemId,
|
||||||
mediaSourceId: source.id,
|
mediaSourceId: source.id,
|
||||||
playSessionId: playSessionId,
|
playSessionId: playSessionId,
|
||||||
positionTicks: (s.position * jellyfinTicksPerSecond).round(),
|
positionTicks: withPosition ? _ticks(s.position) : null,
|
||||||
isPaused: s.paused,
|
isPaused: s.paused,
|
||||||
audioStreamIndex: s.aid == null
|
audioStreamIndex: s.aid == null
|
||||||
? null
|
? null
|
||||||
@@ -71,13 +111,24 @@ class JellyfinProgressReporter {
|
|||||||
: source.jellyfinIndexForMpvTrack(JellyfinStreamType.subtitle, s.sid!),
|
: source.jellyfinIndexForMpvTrack(JellyfinStreamType.subtitle, s.sid!),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
static int _ticks(double secs) => (secs * jellyfinTicksPerSecond).round();
|
||||||
|
|
||||||
|
/// Progress may carry the position: not for a played item, not without
|
||||||
|
/// a runtime and not near the end (either would let Jellyfin decide).
|
||||||
|
bool _progressPosition(double position) =>
|
||||||
|
!_keepPlayed && _runtime > 0 && position < _noPositionFrom * _runtime;
|
||||||
|
|
||||||
/// Playing: once the media is open. Again after [stop] (played to the
|
/// Playing: once the media is open. Again after [stop] (played to the
|
||||||
/// end, then someone seeked back) starts reporting anew.
|
/// end, then someone seeked back) starts reporting anew. A played item
|
||||||
|
/// is marked played again right after (Jellyfin up to 10.11 clears it
|
||||||
|
/// on Playing).
|
||||||
void start(PlayerSnapshot s) {
|
void start(PlayerSnapshot s) {
|
||||||
if (_playing || _dead) return;
|
if (_playing || _dead) return;
|
||||||
_playing = true;
|
_playing = true;
|
||||||
_mark(s);
|
_mark(s);
|
||||||
|
watch.sample(s.position, playing: !s.paused, at: _clock());
|
||||||
_send('Playing', () => client.reportPlaybackStart(reportFor(s)));
|
_send('Playing', () => client.reportPlaybackStart(reportFor(s)));
|
||||||
|
if (_keepPlayed) _send('played', () => client.markPlayed(itemId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Progress when due (see the class doc).
|
/// Progress when due (see the class doc).
|
||||||
@@ -85,6 +136,7 @@ class JellyfinProgressReporter {
|
|||||||
if (!_playing || _dead) return;
|
if (!_playing || _dead) return;
|
||||||
final last = _last, lastAt = _lastAt, sentAt = _lastSentAt;
|
final last = _last, lastAt = _lastAt, sentAt = _lastSentAt;
|
||||||
final now = _clock();
|
final now = _clock();
|
||||||
|
watch.sample(s.position, playing: !s.paused, at: now);
|
||||||
var due = force || last == null || lastAt == null || sentAt == null;
|
var due = force || last == null || lastAt == null || sentAt == null;
|
||||||
if (!due) {
|
if (!due) {
|
||||||
final elapsed = now.difference(lastAt).inMilliseconds / 1000;
|
final elapsed = now.difference(lastAt).inMilliseconds / 1000;
|
||||||
@@ -99,16 +151,44 @@ class JellyfinProgressReporter {
|
|||||||
_lastAt = now;
|
_lastAt = now;
|
||||||
if (!due) return;
|
if (!due) return;
|
||||||
_lastSentAt = now;
|
_lastSentAt = now;
|
||||||
_send('Progress', () => client.reportPlaybackProgress(reportFor(s)));
|
final report = reportFor(s, withPosition: _progressPosition(s.position));
|
||||||
|
_send('Progress', () => client.reportPlaybackProgress(report));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stopped (swap, leave, kick, window close, played to the end).
|
/// Stopped (swap, leave, kick, window close, played to the end), as
|
||||||
|
/// [WatchSession.verdict] says: played (or kept played) -> no position,
|
||||||
|
/// then marked played; a peek -> the resume point from before; part way
|
||||||
|
/// -> the position, held under [_noPositionFrom] of the runtime.
|
||||||
/// Completes when it and everything before it went out.
|
/// Completes when it and everything before it went out.
|
||||||
Future<void> stop(PlayerSnapshot s) {
|
Future<void> stop(PlayerSnapshot s) {
|
||||||
if (!_playing || _dead) return _chain;
|
if (!_playing || _dead) return _chain;
|
||||||
_playing = false;
|
_playing = false;
|
||||||
_send('Stopped', () => client.reportPlaybackStopped(reportFor(s)));
|
watch.sample(s.position, playing: !s.paused, at: _clock());
|
||||||
return _chain;
|
final v = _keepPlayed
|
||||||
|
? const WatchVerdict(WatchOutcome.keepPlayed)
|
||||||
|
: watch.verdict(runtime: _runtime, endingStart: _endingStart);
|
||||||
|
debugPrint('jellyfin $itemId: $v (covered ${watch.covered.round()} s of ${_runtime.round()} s)');
|
||||||
|
final int? ticks;
|
||||||
|
switch (v.outcome) {
|
||||||
|
case WatchOutcome.played || WatchOutcome.keepPlayed:
|
||||||
|
_keepPlayed = true;
|
||||||
|
ticks = null;
|
||||||
|
case WatchOutcome.peek:
|
||||||
|
ticks = _ticks(watch.resumeBefore);
|
||||||
|
case WatchOutcome.partial:
|
||||||
|
final at = v.resumeAt ?? s.position;
|
||||||
|
ticks = _ticks(_runtime > 0 ? math.min(at, _noPositionFrom * _runtime) : at);
|
||||||
|
}
|
||||||
|
final report = JellyfinPlaybackReport(
|
||||||
|
itemId: itemId, mediaSourceId: source.id, playSessionId: playSessionId, positionTicks: ticks);
|
||||||
|
_send('Stopped', () => client.reportPlaybackStopped(report));
|
||||||
|
if (ticks == null) {
|
||||||
|
_send('played', () => client.markPlayed(itemId));
|
||||||
|
// Marking played keeps a resume point Progress left (Continue would
|
||||||
|
// still list it, "3 min left"): clear it.
|
||||||
|
_send('resume cleared', () => client.clearResumePosition(itemId));
|
||||||
|
}
|
||||||
|
return _chain = _chain.then((_) => onStopped?.call());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _mark(PlayerSnapshot s) {
|
void _mark(PlayerSnapshot s) {
|
||||||
|
|||||||
@@ -375,6 +375,7 @@ class _EpisodeTileState extends State<EpisodeTile> {
|
|||||||
key: ValueKey('episode-${e.id}'),
|
key: ValueKey('episode-${e.id}'),
|
||||||
focusColor: Colors.transparent,
|
focusColor: Colors.transparent,
|
||||||
onTap: () => _tap(scope),
|
onTap: () => _tap(scope),
|
||||||
|
onSecondaryTap: () => scope.cardMenu(context, widget.episode),
|
||||||
onHover: (h) => setState(() => _hover = h),
|
onHover: (h) => setState(() => _hover = h),
|
||||||
onFocusChange: (f) => setState(() => _focus = f),
|
onFocusChange: (f) => setState(() => _focus = f),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|||||||
@@ -318,10 +318,11 @@ Future<({EpisodeLink? previous, EpisodeLink? next})> findAdjacentEpisodes(OwnPla
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// When the floating "Next episode" pill appears: at [endingStart] (the
|
/// Where the "Next episode" pill's window starts: at [endingStart] (the
|
||||||
/// ED: Jellyfin Outro segment or ED-titled chapter, see `endingRange`)
|
/// ED: Jellyfin Outro segment or ED-titled chapter, see `endingRange`)
|
||||||
/// when one is known inside [runtime], else the last two minutes of
|
/// when one is known inside [runtime], else the last two minutes of
|
||||||
/// [runtime] (all of it for shorter media).
|
/// [runtime] (all of it for shorter media). The pill shows only while the
|
||||||
|
/// controls are up (`PillGate`), so a guessed window costs nothing.
|
||||||
Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? endingStart}) {
|
Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? endingStart}) {
|
||||||
if (endingStart != null && endingStart > Duration.zero && endingStart < runtime) {
|
if (endingStart != null && endingStart > Duration.zero && endingStart < runtime) {
|
||||||
return endingStart;
|
return endingStart;
|
||||||
@@ -332,13 +333,13 @@ Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? ending
|
|||||||
|
|
||||||
/// Who performs the auto-advance swap, so several counting clients do not
|
/// Who performs the auto-advance swap, so several counting clients do not
|
||||||
/// swap twice: the lowest member id among members that play the item with
|
/// swap twice: the lowest member id among members that play the item with
|
||||||
/// their own account (`jf[serverId] == true`, not stale). Only what every
|
/// their own account (`jf[serverId] == true`, not stale, not out). Only what every
|
||||||
/// client sees in `state` decides (who put the item up is not in it: a
|
/// client sees in `state` decides (who put the item up is not in it: a
|
||||||
/// late joiner never saw that swap), so all clients agree.
|
/// late joiner never saw that swap), so all clients agree.
|
||||||
String? autoAdvanceSwapper(List<MemberView> members, String serverId) {
|
String? autoAdvanceSwapper(List<MemberView> members, String serverId) {
|
||||||
final eligible = [
|
final eligible = [
|
||||||
for (final m in members)
|
for (final m in members)
|
||||||
if (!m.stale && m.jf[serverId] == true) m
|
if (!m.stale && !m.out && m.jf[serverId] == true) m
|
||||||
];
|
];
|
||||||
if (eligible.isEmpty) return null;
|
if (eligible.isEmpty) return null;
|
||||||
eligible.sort((a, b) => compareMemberIds(a.id, b.id));
|
eligible.sort((a, b) => compareMemberIds(a.id, b.id));
|
||||||
|
|||||||
@@ -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,19 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// The runner's window channel (linux/runner/my_application.cc,
|
||||||
|
/// windows/runner/flutter_window.cpp).
|
||||||
|
const _window = MethodChannel('starsync/window');
|
||||||
|
|
||||||
|
/// Asks the desktop to point at the app's window (taskbar flash, urgency
|
||||||
|
/// hint) when it is not focused; nothing when it is. The runner clears it
|
||||||
|
/// once the window gets focus. Quietly nothing where the runner lacks it.
|
||||||
|
Future<void> requestWindowAttention() async {
|
||||||
|
try {
|
||||||
|
await _window.invokeMethod<void>('attention');
|
||||||
|
} on MissingPluginException {
|
||||||
|
// Tests, other platforms.
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('window attention: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart' show findInstallRoot;
|
||||||
|
|
||||||
|
/// The app's id: GTK's application id, so the Wayland `app_id` the
|
||||||
|
/// compositor matches to [desktopFileName] for the window's icon.
|
||||||
|
const appId = 'moe.senkochan.watch_together';
|
||||||
|
const desktopFileName = '$appId.desktop';
|
||||||
|
|
||||||
|
/// Linux release installs (an unpacked tarball) carry no launcher entry, so
|
||||||
|
/// Wayland shows a generic icon. On start: write the `.desktop` file into
|
||||||
|
/// `~/.local/share/applications` (Exec = this binary) and the icon into
|
||||||
|
/// the hicolor theme, only when something changed (a moved install, a new
|
||||||
|
/// icon). Never in debug runs; failures only log.
|
||||||
|
Future<void> installDesktopEntry({String? executable, String? dataHome, String? home}) async {
|
||||||
|
if (!Platform.isLinux || kDebugMode) return;
|
||||||
|
try {
|
||||||
|
final exe = executable ?? Platform.resolvedExecutable;
|
||||||
|
final root = findInstallRoot(exe, windows: false);
|
||||||
|
if (root == null) return;
|
||||||
|
final share = dataHome ?? Platform.environment['XDG_DATA_HOME'] ??
|
||||||
|
'${home ?? Platform.environment['HOME']}/.local/share';
|
||||||
|
final icons = '${root.path}/data/flutter_assets/assets/icon';
|
||||||
|
for (final size in const [16, 32, 48, 64, 128, 256, 512]) {
|
||||||
|
final src = File('$icons/icon_$size.png');
|
||||||
|
if (!src.existsSync()) continue;
|
||||||
|
final dst = File('$share/icons/hicolor/${size}x$size/apps/$appId.png');
|
||||||
|
if (dst.existsSync() && dst.lengthSync() == src.lengthSync()) continue;
|
||||||
|
await dst.parent.create(recursive: true);
|
||||||
|
await src.copy(dst.path);
|
||||||
|
}
|
||||||
|
final entry = desktopEntryText(exe);
|
||||||
|
final file = File('$share/applications/$desktopFileName');
|
||||||
|
if (file.existsSync() && file.readAsStringSync() == entry) return;
|
||||||
|
await file.parent.create(recursive: true);
|
||||||
|
await file.writeAsString(entry);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('desktop entry: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
String desktopEntryText(String exe) => '''[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Starsync
|
||||||
|
Comment=Watch videos in sync with friends
|
||||||
|
Exec="$exe"
|
||||||
|
Icon=$appId
|
||||||
|
StartupWMClass=$appId
|
||||||
|
Categories=AudioVideo;Video;Player;Network;
|
||||||
|
Terminal=false
|
||||||
|
''';
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -243,18 +243,20 @@ const _skipTail = Duration(seconds: 1);
|
|||||||
/// The skip pill at [position]: inside the opening "Skip OP" (to its end),
|
/// The skip pill at [position]: inside the opening "Skip OP" (to its end),
|
||||||
/// inside the ending "Skip ED" (to its end only: what follows, preview or
|
/// inside the ending "Skip ED" (to its end only: what follows, preview or
|
||||||
/// more episode, is the viewer's call). Without any opening info on media
|
/// more episode, is the viewer's call). Without any opening info on media
|
||||||
/// longer than [fallbackMinDuration], "Skip 1:30" during the first
|
/// longer than [fallbackMinDuration] and without chapters ([chaptered]: a
|
||||||
/// [fallbackWindow]. Null otherwise.
|
/// chaptered file without an OP chapter has none on purpose), "Skip 1:30"
|
||||||
|
/// during the first [fallbackWindow]. Null otherwise.
|
||||||
SkipOffer? skipOfferAt({
|
SkipOffer? skipOfferAt({
|
||||||
required Duration position,
|
required Duration position,
|
||||||
required Duration duration,
|
required Duration duration,
|
||||||
required SkipRange? opening,
|
required SkipRange? opening,
|
||||||
required SkipRange? ending,
|
required SkipRange? ending,
|
||||||
|
bool chaptered = false,
|
||||||
}) {
|
}) {
|
||||||
bool inside(SkipRange r) => position >= r.start && position < r.end - _skipTail;
|
bool inside(SkipRange r) => position >= r.start && position < r.end - _skipTail;
|
||||||
if (opening != null && inside(opening)) return SkipOffer(SkipKind.opening, opening.end);
|
if (opening != null && inside(opening)) return SkipOffer(SkipKind.opening, opening.end);
|
||||||
if (ending != null && inside(ending)) return SkipOffer(SkipKind.ending, ending.end);
|
if (ending != null && inside(ending)) return SkipOffer(SkipKind.ending, ending.end);
|
||||||
if (opening == null && duration > fallbackMinDuration && position < fallbackWindow) {
|
if (opening == null && !chaptered && duration > fallbackMinDuration && position < fallbackWindow) {
|
||||||
final to = position + fallbackSkip;
|
final to = position + fallbackSkip;
|
||||||
return SkipOffer(SkipKind.fixed, to < duration ? to : duration);
|
return SkipOffer(SkipKind.fixed, to < duration ? to : duration);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:ffi' show Int8;
|
||||||
import 'dart:io';
|
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:flutter/widgets.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
import 'package:media_kit_video/media_kit_video.dart';
|
import 'package:media_kit_video/media_kit_video.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
import 'package:sync_relay_core/core.dart' show JellyfinStreamType, LogThrottle, createLogger;
|
||||||
|
|
||||||
import '../jellyfin/track_prefs.dart' show MpvTrack;
|
import '../jellyfin/track_prefs.dart' show MpvTrack;
|
||||||
import '../shaders/shader_assets.dart';
|
import '../shaders/shader_assets.dart';
|
||||||
@@ -14,13 +16,14 @@ import '../shaders/shader_presets.dart';
|
|||||||
import '../state/settings.dart';
|
import '../state/settings.dart';
|
||||||
import '../sync/state_diff.dart';
|
import '../sync/state_diff.dart';
|
||||||
import '../sync/user_seek.dart';
|
import '../sync/user_seek.dart';
|
||||||
import 'frame_watchdog.dart';
|
|
||||||
import 'media_marks.dart';
|
import 'media_marks.dart';
|
||||||
|
import 'yt_dlp_app.dart' show AppYtDlp, ytdlFormat;
|
||||||
|
|
||||||
/// Owns one mpv [Player] (and its [VideoController]) per session: created
|
/// Owns the session's mpv [Player] (and its [VideoController]): created
|
||||||
/// by [init] on join, released by [dispose] on leave, swapped by
|
/// by [init] on join, released by [dispose] on leave, swapped by
|
||||||
/// [recreate] for "Reload video". Never reuse a player across sessions —
|
/// [recreate] for "Reload video" and by [openUrl] for every new media (a
|
||||||
/// a GPU-side freeze sticks to the player, not to the widget.
|
/// promoted preload or a fresh player). Never reuse a player for other
|
||||||
|
/// media — a GPU-side freeze sticks to the player, not to the widget.
|
||||||
class PlaybackController {
|
class PlaybackController {
|
||||||
/// Read-ahead target: about two minutes rides out short outages and
|
/// Read-ahead target: about two minutes rides out short outages and
|
||||||
/// makes skipping an intro instant.
|
/// makes skipping an intro instant.
|
||||||
@@ -32,14 +35,14 @@ class PlaybackController {
|
|||||||
/// Back buffer (media_kit sets it to bufferSize too): enough for a short
|
/// Back buffer (media_kit sets it to bufferSize too): enough for a short
|
||||||
/// seek back without holding another 256 MiB.
|
/// seek back without holding another 256 MiB.
|
||||||
static const maxBackBytes = 64 * 1024 * 1024;
|
static const maxBackBytes = 64 * 1024 * 1024;
|
||||||
|
/// mpv's `volume-max` (its default is 130): the volume controls go
|
||||||
|
/// this far, past 100 % amplified.
|
||||||
|
static const maxVolume = 150;
|
||||||
|
|
||||||
SyncPlayer? _player;
|
SyncPlayer? _player;
|
||||||
VideoController? _video;
|
VideoController? _video;
|
||||||
Timer? _watchTimer;
|
|
||||||
bool _probing = false;
|
|
||||||
final FrameWatchdog _watchdog = FrameWatchdog();
|
|
||||||
StreamSubscription<PlayerLog>? _logSub;
|
StreamSubscription<PlayerLog>? _logSub;
|
||||||
final List<StreamSubscription<Duration>> _timeSubs = [];
|
final List<StreamSubscription<Object?>> _timeSubs = [];
|
||||||
String _chapterJson = '';
|
String _chapterJson = '';
|
||||||
// `glsl-shaders` as last set on the current player ('' = none).
|
// `glsl-shaders` as last set on the current player ('' = none).
|
||||||
String _shaders = '';
|
String _shaders = '';
|
||||||
@@ -54,12 +57,53 @@ class PlaybackController {
|
|||||||
/// otherwise, so no texture outlives its player.
|
/// otherwise, so no texture outlives its player.
|
||||||
final alive = signal(false);
|
final alive = signal(false);
|
||||||
|
|
||||||
/// Called once when the watchdog sees a frozen picture.
|
/// mpv's yt-dlp hook could not open the current link (its error text).
|
||||||
void Function()? onFrozen;
|
void Function(String error)? onYtdlError;
|
||||||
|
|
||||||
/// Fingerprint of the picture on screen, installed by the video view
|
/// The current media failed to load (the HTTP error when there was one,
|
||||||
/// (`ShownFrameProbe.sample`). Without it the watchdog stays idle.
|
/// e.g. a CDN's 403), at most once per [openUrl].
|
||||||
Future<int?> Function()? shownFrame;
|
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.
|
||||||
|
Future<void> useYtDlp(String path) async {
|
||||||
|
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');
|
||||||
|
await platform?.setProperty('ytdl-format', ytdlFormat);
|
||||||
|
_ytdlSetOn = p;
|
||||||
|
_ytdlRaw = '';
|
||||||
|
}
|
||||||
|
final raw = await AppYtDlp.instance.mpvRawOptions();
|
||||||
|
if (raw != _ytdlRaw && identical(p, _player)) {
|
||||||
|
await platform?.setProperty('ytdl-raw-options', raw);
|
||||||
|
_ytdlRaw = raw;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('yt-dlp path not set: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Player? _ytdlSetOn;
|
||||||
|
String _ytdlRaw = '';
|
||||||
|
String? _ytdlPath;
|
||||||
|
|
||||||
Player get player => _player!;
|
Player get player => _player!;
|
||||||
|
|
||||||
@@ -128,6 +172,7 @@ class PlaybackController {
|
|||||||
final existing = _video;
|
final existing = _video;
|
||||||
if (existing != null) return existing;
|
if (existing != null) return existing;
|
||||||
final hwdec = _hwdecOverride;
|
final hwdec = _hwdecOverride;
|
||||||
|
_requestLog(player, 'v'); // back to [logLevel] by _detectRenderMode
|
||||||
// media_kit's video output sets hwdec itself on attach ('auto' unless
|
// media_kit's video output sets hwdec itself on attach ('auto' unless
|
||||||
// configured), which used to override SYNC_RELAY_HWDEC.
|
// configured), which used to override SYNC_RELAY_HWDEC.
|
||||||
final video = _video = VideoController(
|
final video = _video = VideoController(
|
||||||
@@ -146,26 +191,27 @@ class PlaybackController {
|
|||||||
return AppSettings.instance.hwdec.peek();
|
return AppSettings.instance.hwdec.peek();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> init() async {
|
SyncPlayer _newPlayer({required int bufferSize, required MPVLogLevel logLevel}) => SyncPlayer(
|
||||||
if (_player != null) return;
|
|
||||||
_player = SyncPlayer(
|
|
||||||
configuration: PlayerConfiguration(
|
configuration: PlayerConfiguration(
|
||||||
bufferSize: maxBytes,
|
bufferSize: bufferSize,
|
||||||
libass: true,
|
libass: true,
|
||||||
vo: Platform.environment['SYNC_RELAY_VO'],
|
vo: Platform.environment['SYNC_RELAY_VO'],
|
||||||
// Verbose: the render-mode probe needs mpv's GL init lines.
|
logLevel: logLevel,
|
||||||
logLevel: MPVLogLevel.v,
|
|
||||||
))
|
))
|
||||||
..onUserSeek = (d) => onUserSeek?.call(d);
|
..onUserSeek = (d) => onUserSeek?.call(d);
|
||||||
|
|
||||||
|
Future<void> init() async {
|
||||||
|
if (_player != null) return;
|
||||||
|
_player = _newPlayer(bufferSize: maxBytes, logLevel: MPVLogLevel.warn); // see [logLevel]
|
||||||
|
_held = false;
|
||||||
_shaders = '';
|
_shaders = '';
|
||||||
renderMode.value = RenderMode.unknown;
|
renderMode.value = RenderMode.unknown;
|
||||||
shaderNotice.value = null;
|
shaderNotice.value = null;
|
||||||
_logSub = _player!.stream.log.listen(_onLog);
|
_logSub = _player!.stream.log.listen(_onLog);
|
||||||
_watchMarks(_player!);
|
_watchMarks(_player!);
|
||||||
await _pinBufferProps();
|
await _pinBufferProps();
|
||||||
|
await _pin('volume-max', '$maxVolume');
|
||||||
await applyShaders();
|
await applyShaders();
|
||||||
_watchdog.reset();
|
|
||||||
_watchTimer = Timer.periodic(const Duration(seconds: 1), (_) => _watch());
|
|
||||||
generation.value++;
|
generation.value++;
|
||||||
alive.value = true;
|
alive.value = true;
|
||||||
}
|
}
|
||||||
@@ -183,9 +229,9 @@ class PlaybackController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pin(String property, String value) async {
|
Future<void> _pin(String property, String value, [Player? on]) async {
|
||||||
try {
|
try {
|
||||||
final dynamic platform = player.platform;
|
final dynamic platform = (on ?? player).platform;
|
||||||
await platform?.setProperty(property, value);
|
await platform?.setProperty(property, value);
|
||||||
final got = '${await platform?.getProperty(property)}';
|
final got = '${await platform?.getProperty(property)}';
|
||||||
if (!pinMatches(value, got)) {
|
if (!pinMatches(value, got)) {
|
||||||
@@ -198,18 +244,39 @@ class PlaybackController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pinBufferProps() async {
|
Future<void> _pinBufferProps([Player? on, int ahead = maxBytes, int back = maxBackBytes]) async {
|
||||||
pinMismatches.clear();
|
if (on == null) pinMismatches.clear();
|
||||||
await _pin('demuxer-readahead-secs', '$readAheadSecs');
|
await _pin('demuxer-readahead-secs', '$readAheadSecs', on);
|
||||||
await _pin('cache-secs', '$readAheadSecs');
|
await _pin('cache-secs', '$readAheadSecs', on);
|
||||||
await _pin('demuxer-max-bytes', '$maxBytes');
|
await _pin('demuxer-max-bytes', '$ahead', on);
|
||||||
await _pin('demuxer-max-back-bytes', '$maxBackBytes');
|
await _pin('demuxer-max-back-bytes', '$back', on);
|
||||||
final hwdec = _hwdecOverride;
|
final hwdec = _hwdecOverride;
|
||||||
if (hwdec != null) {
|
if (hwdec != null) {
|
||||||
await _pin('hwdec', hwdec);
|
await _pin('hwdec', hwdec, on);
|
||||||
|
}
|
||||||
|
await _pin('stream-lavf-o', streamLavfOptions, 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
|
/// Puts the saved shader choice (Settings, Ctrl+0..6, shader button) on
|
||||||
@@ -294,6 +361,31 @@ class PlaybackController {
|
|||||||
// an error there while shaders are on is a shader that failed to load
|
// an error there while shaders are on is a shader that failed to load
|
||||||
// or compile (mpv then renders without it).
|
// or compile (mpv then renders without it).
|
||||||
void _onLog(PlayerLog log) {
|
void _onLog(PlayerLog log) {
|
||||||
|
_toAppLog(log);
|
||||||
|
if (log.prefix == 'ytdl_hook' && (log.level == 'error' || log.level == 'fatal')) {
|
||||||
|
final text = log.text.trim().replaceFirst(RegExp(r'^ERROR:\s*'), '');
|
||||||
|
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;
|
if (log.prefix != 'libmpv_render') return;
|
||||||
if (log.text.contains('suspected software renderer')) {
|
if (log.text.contains('suspected software renderer')) {
|
||||||
_setRenderMode(RenderMode.softwareGl);
|
_setRenderMode(RenderMode.softwareGl);
|
||||||
@@ -313,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) {
|
void _setRenderMode(RenderMode mode) {
|
||||||
renderMode.value = mode;
|
renderMode.value = mode;
|
||||||
lastRenderMode.value = mode;
|
lastRenderMode.value = mode;
|
||||||
@@ -324,10 +442,12 @@ class PlaybackController {
|
|||||||
Future<void> _detectRenderMode(Player p, VideoController video) async {
|
Future<void> _detectRenderMode(Player p, VideoController video) async {
|
||||||
try {
|
try {
|
||||||
await video.platform.future;
|
await video.platform.future;
|
||||||
|
await Future.delayed(const Duration(milliseconds: 1500));
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return;
|
return;
|
||||||
|
} finally {
|
||||||
|
if (identical(p, _player)) _requestLog(p, logLevel);
|
||||||
}
|
}
|
||||||
await Future.delayed(const Duration(milliseconds: 1500));
|
|
||||||
if (!identical(p, _player) || renderMode.peek() != RenderMode.unknown) {
|
if (!identical(p, _player) || renderMode.peek() != RenderMode.unknown) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -338,84 +458,46 @@ class PlaybackController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fingerprint of the decoder's current frame (mpv `screenshot-raw`,
|
/// `mpv-version` and `ffmpeg-version` of the session player, for
|
||||||
/// off-thread); null without video.
|
/// diagnostics; null when there is no player.
|
||||||
Future<int?> _sourceFrame(Player p) async {
|
Future<String?> mpvVersion() async {
|
||||||
|
final p = _player;
|
||||||
|
if (p == null) return null;
|
||||||
try {
|
try {
|
||||||
final raw = await p.screenshot(format: null);
|
final dynamic platform = p.platform;
|
||||||
return raw == null || raw.isEmpty ? null : hashSpread(raw);
|
return '${await platform?.getProperty('mpv-version')}, ffmpeg ${await platform?.getProperty('ffmpeg-version')}';
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A suspected freeze is real only if mpv's own frames still move: a
|
|
||||||
/// still scene looks frozen on screen too.
|
|
||||||
Future<bool> _sourceMoving(Player p) async {
|
|
||||||
final a = await _sourceFrame(p);
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
|
||||||
if (!identical(p, _player) || !p.state.playing) return false;
|
|
||||||
final b = await _sourceFrame(p);
|
|
||||||
return a != null && b != null && a != b;
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool get _windowVisible {
|
|
||||||
try {
|
|
||||||
final s = WidgetsBinding.instance.lifecycleState;
|
|
||||||
return s == null ||
|
|
||||||
s == AppLifecycleState.resumed ||
|
|
||||||
s == AppLifecycleState.inactive;
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _watch() async {
|
|
||||||
final p = _player;
|
|
||||||
final probe = shownFrame;
|
|
||||||
// Only meaningful once the video view is up.
|
|
||||||
if (p == null || _video == null || probe == null || _probing) return;
|
|
||||||
_probing = true;
|
|
||||||
try {
|
|
||||||
final frames = await probe();
|
|
||||||
if (!identical(p, _player)) return;
|
|
||||||
final s = p.state;
|
|
||||||
final eligible = s.playing &&
|
|
||||||
!s.buffering &&
|
|
||||||
!s.completed &&
|
|
||||||
s.rate > 0 &&
|
|
||||||
(s.width ?? 0) > 0 &&
|
|
||||||
_windowVisible;
|
|
||||||
final fired = _watchdog.feed(FrameSample(
|
|
||||||
at: DateTime.now(),
|
|
||||||
position: positionSecs,
|
|
||||||
frames: frames,
|
|
||||||
eligible: eligible,
|
|
||||||
));
|
|
||||||
if (fired) {
|
|
||||||
if (await _sourceMoving(p) && identical(p, _player)) {
|
|
||||||
debugPrint('video watchdog: picture stuck while mpv frames move');
|
|
||||||
onFrozen?.call();
|
|
||||||
} else {
|
|
||||||
_watchdog.dismiss();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
_probing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Opens [url] paused. [headers] go with every request for it (mpv
|
/// Opens [url] paused. [headers] go with every request for it (mpv
|
||||||
/// `http-header-fields`): a Jellyfin token travels there, not in the URL.
|
/// `http-header-fields`): a Jellyfin token travels there, not in the URL.
|
||||||
Future<void> openUrl(String url, {Map<String, String>? headers}) async {
|
/// Each media gets its own player: when [preload] holds the same [url]
|
||||||
if (!_ready) return;
|
/// and [headers], that player becomes this one (true: promoted, its
|
||||||
|
/// buffer comes along); else a player that already held media is
|
||||||
|
/// replaced by a fresh one first ([onFreshPlayer] once it is up), unless
|
||||||
|
/// [keepPlayer] (the same media again, e.g. after a reconnect).
|
||||||
|
Future<bool> openUrl(String url,
|
||||||
|
{Map<String, String>? headers, bool keepPlayer = false, void Function()? onFreshPlayer}) async {
|
||||||
|
if (!_ready) return false;
|
||||||
|
final pre = _pre;
|
||||||
|
if (pre != null && pre.matches(url, headers)) {
|
||||||
|
if (await _promote(pre)) return true;
|
||||||
|
}
|
||||||
|
if (_held && !keepPlayer && await _freshPlayer()) onFreshPlayer?.call();
|
||||||
|
if (!_ready) return false;
|
||||||
try {
|
try {
|
||||||
|
_held = true;
|
||||||
_chapterJson = '';
|
_chapterJson = '';
|
||||||
|
_httpError = null;
|
||||||
|
_loadErrorSent = false;
|
||||||
chapters.value = const [];
|
chapters.value = const [];
|
||||||
tracks.value = const [];
|
tracks.value = const [];
|
||||||
buffered.value = Duration.zero;
|
buffered.value = Duration.zero;
|
||||||
// An empty map, not null: media_kit caches headers per URI.
|
// An empty map, not null: media_kit caches headers per URI.
|
||||||
await player.open(Media(url, httpHeaders: headers ?? const {}), play: false);
|
await player.open(Media(url, httpHeaders: headers ?? const {}), play: false);
|
||||||
|
return false;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('open ${Uri.tryParse(url)?.replace(query: '') ?? 'media'} failed: $e\n$st');
|
debugPrint('open ${Uri.tryParse(url)?.replace(query: '') ?? 'media'} failed: $e\n$st');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -426,9 +508,18 @@ class PlaybackController {
|
|||||||
// track-list observed in mpv (read back as JSON).
|
// track-list observed in mpv (read back as JSON).
|
||||||
void _watchMarks(SyncPlayer p) {
|
void _watchMarks(SyncPlayer p) {
|
||||||
_timeSubs.add(p.stream.position.listen((d) {
|
_timeSubs.add(p.stream.position.listen((d) {
|
||||||
|
// At the end the bar shows full: mpv's last position is the last
|
||||||
|
// frame's start (and the 250 ms steps round down), short of duration.
|
||||||
|
if (p.state.completed && duration.value > Duration.zero) {
|
||||||
|
position.value = duration.value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
final ms = d.inMilliseconds;
|
final ms = d.inMilliseconds;
|
||||||
position.value = Duration(milliseconds: ms - ms % 250);
|
position.value = Duration(milliseconds: ms - ms % 250);
|
||||||
}));
|
}));
|
||||||
|
_timeSubs.add(p.stream.completed.listen((done) {
|
||||||
|
if (done && duration.value > Duration.zero) position.value = duration.value;
|
||||||
|
}));
|
||||||
_timeSubs.add(p.stream.buffer.listen((d) {
|
_timeSubs.add(p.stream.buffer.listen((d) {
|
||||||
final ms = d.inMilliseconds;
|
final ms = d.inMilliseconds;
|
||||||
buffered.value = Duration(milliseconds: ms - ms % 250);
|
buffered.value = Duration(milliseconds: ms - ms % 250);
|
||||||
@@ -575,10 +666,9 @@ class PlaybackController {
|
|||||||
/// Release the player. The view drops its texture first ([alive] goes
|
/// Release the player. The view drops its texture first ([alive] goes
|
||||||
/// false, one frame passes), then mpv is torn down.
|
/// false, one frame passes), then mpv is torn down.
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
|
await dropPreload();
|
||||||
final p = _player;
|
final p = _player;
|
||||||
if (p == null) return;
|
if (p == null) return;
|
||||||
_watchTimer?.cancel();
|
|
||||||
_watchTimer = null;
|
|
||||||
await _logSub?.cancel();
|
await _logSub?.cancel();
|
||||||
_logSub = null;
|
_logSub = null;
|
||||||
for (final sub in _timeSubs) {
|
for (final sub in _timeSubs) {
|
||||||
@@ -587,6 +677,7 @@ class PlaybackController {
|
|||||||
_timeSubs.clear();
|
_timeSubs.clear();
|
||||||
_player = null;
|
_player = null;
|
||||||
_video = null;
|
_video = null;
|
||||||
|
_held = false;
|
||||||
alive.value = false;
|
alive.value = false;
|
||||||
_clearMarks();
|
_clearMarks();
|
||||||
await _settleUi();
|
await _settleUi();
|
||||||
@@ -609,6 +700,246 @@ class PlaybackController {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ preload
|
||||||
|
//
|
||||||
|
// A second, hidden player holding what plays next (Up next's first
|
||||||
|
// item), open paused at its start and muted, with a small cache: its
|
||||||
|
// first seconds are in before anyone asks. [openUrl] for the same URL
|
||||||
|
// promotes it to the session player (see ARCHITECTURE.md "Getting
|
||||||
|
// ready"). One at a time.
|
||||||
|
|
||||||
|
/// Preloading on (env `SYNC_RELAY_PRELOAD=0` turns it off; tests set it).
|
||||||
|
bool preloadEnabled = Platform.environment['SYNC_RELAY_PRELOAD'] != '0';
|
||||||
|
|
||||||
|
/// The preload's forward cache: its first seconds and the container's
|
||||||
|
/// index, at little memory. Raised to [maxBytes] when promoted.
|
||||||
|
static const preloadBytes = 48 * 1024 * 1024;
|
||||||
|
static const _preloadBackBytes = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
_Preload? _pre;
|
||||||
|
|
||||||
|
/// The URL preloaded now (null: none).
|
||||||
|
String? get preloadUrl => _pre?.url;
|
||||||
|
|
||||||
|
/// Opens [url] in the hidden preload player (replacing any other), and
|
||||||
|
/// completes once [need] seconds are buffered or the whole media is:
|
||||||
|
/// true, false when it failed (or was dropped or replaced meanwhile).
|
||||||
|
/// The same [url] and [headers] again is the same preload unless
|
||||||
|
/// [fresh]. [ytdlPath]: mpv's yt-dlp hook for web pages.
|
||||||
|
Future<bool> preload(String url,
|
||||||
|
{Map<String, String>? headers, String? ytdlPath, double need = 4, bool fresh = false}) async {
|
||||||
|
if (!preloadEnabled || !_ready) return false;
|
||||||
|
final had = _pre;
|
||||||
|
if (had != null && had.matches(url, headers) && !had.failed && !fresh) return had.ready.future;
|
||||||
|
// Set before any await: a second call for the same URL finds it.
|
||||||
|
_pre = null;
|
||||||
|
if (had != null) unawaited(_disposePreload(had));
|
||||||
|
final p = _newPlayer(bufferSize: preloadBytes, logLevel: MPVLogLevel.error);
|
||||||
|
final pre = _pre = _Preload(p, url, headers ?? const {});
|
||||||
|
pre.logSub = p.stream.log.listen((log) {
|
||||||
|
// ffmpeg logs through one mpv per process: the first one, and once
|
||||||
|
// that is gone (a promotion disposes it) the next one created, which
|
||||||
|
// can be this preload. Its HTTP errors still name the session's.
|
||||||
|
if (log.prefix == 'ffmpeg') return _onLog(log);
|
||||||
|
_toAppLog(log, player: 'preload ');
|
||||||
|
if (_loadFailed(log, p.state.duration)) pre.fail();
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await _pinBufferProps(p, preloadBytes, _preloadBackBytes);
|
||||||
|
await _pin('volume-max', '$maxVolume', p);
|
||||||
|
await _pin('mute', 'yes', p);
|
||||||
|
final path = ytdlPath ?? _ytdlPath;
|
||||||
|
if (path != null) {
|
||||||
|
pre.ytdlRaw = await _setYtdl(p, path);
|
||||||
|
pre.ytdl = true;
|
||||||
|
}
|
||||||
|
// With a view up, its own video output: mpv decodes (media_kit
|
||||||
|
// starts players with vid=no) and the first frame is there.
|
||||||
|
if (_video != null) {
|
||||||
|
pre.video = VideoController(p, configuration: VideoControllerConfiguration(hwdec: _hwdecOverride));
|
||||||
|
await pre.video!.platform.future;
|
||||||
|
}
|
||||||
|
if (!identical(_pre, pre)) {
|
||||||
|
pre.fail();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await p.open(Media(url, httpHeaders: headers ?? const {}), play: false);
|
||||||
|
if (!pre.opened.isCompleted) pre.opened.complete();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('preload failed: $e');
|
||||||
|
pre.fail();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
unawaited(_watchPreload(pre, need));
|
||||||
|
return pre.ready.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _watchPreload(_Preload pre, double need) async {
|
||||||
|
final end = DateTime.now().add(const Duration(seconds: 60));
|
||||||
|
while (identical(_pre, pre) && !pre.failed && DateTime.now().isBefore(end)) {
|
||||||
|
final st = pre.player.state;
|
||||||
|
final secs = st.duration.inMilliseconds / 1000;
|
||||||
|
final buffered = (st.buffer - st.position).inMilliseconds / 1000;
|
||||||
|
if (secs > 0 && (buffered >= need || st.buffer.inMilliseconds / 1000 >= secs - 0.5)) {
|
||||||
|
if (!pre.ready.isCompleted) pre.ready.complete(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
|
}
|
||||||
|
if (!pre.ready.isCompleted) pre.ready.complete(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops the preload (nothing to get ready, or something else is).
|
||||||
|
Future<void> dropPreload() async {
|
||||||
|
final pre = _pre;
|
||||||
|
if (pre == null) return;
|
||||||
|
_pre = null;
|
||||||
|
await _disposePreload(pre);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _disposePreload(_Preload pre) async {
|
||||||
|
pre.fail();
|
||||||
|
await pre.logSub?.cancel();
|
||||||
|
try {
|
||||||
|
await pre.player.dispose();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [pre] becomes the session player ([_adopt]); the full cache pins
|
||||||
|
/// replace its small ones.
|
||||||
|
Future<bool> _promote(_Preload pre) async {
|
||||||
|
await pre.opened.future;
|
||||||
|
final old = _player;
|
||||||
|
if (!identical(_pre, pre) || old == null) return false;
|
||||||
|
_pre = null;
|
||||||
|
if (pre.failed) {
|
||||||
|
unawaited(_disposePreload(pre));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await pre.logSub?.cancel();
|
||||||
|
if (!pre.ready.isCompleted) pre.ready.complete(true);
|
||||||
|
final p = pre.player;
|
||||||
|
await _adopt(p, pre.video, ytdl: pre.ytdl, ytdlRaw: pre.ytdlRaw);
|
||||||
|
try {
|
||||||
|
await _pin('mute', 'no', p);
|
||||||
|
await _pinBufferProps(p);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('promoted player settings: $e');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The current player has had media opened in it (one fresh from [init]
|
||||||
|
// has not, so the first open after join or Reload video uses it).
|
||||||
|
bool _held = false;
|
||||||
|
|
||||||
|
// The last replaced player until it is disposed: a fresh player waits
|
||||||
|
// for it, so at most one old player lingers.
|
||||||
|
Future<void> _retiring = Future.value();
|
||||||
|
|
||||||
|
/// Replaces the current player with a fresh one set up like [init]'s
|
||||||
|
/// (pins, hwdec, volume, yt-dlp hook, shaders, observers), its video
|
||||||
|
/// output ready before the view swaps to it when the view is up. True
|
||||||
|
/// once it is the session player.
|
||||||
|
Future<bool> _freshPlayer() async {
|
||||||
|
await _retiring;
|
||||||
|
final old = _player;
|
||||||
|
if (old == null) return false;
|
||||||
|
final p = _newPlayer(bufferSize: maxBytes, logLevel: MPVLogLevel.warn); // see [logLevel]
|
||||||
|
VideoController? video;
|
||||||
|
var ytdl = false, ytdlRaw = '';
|
||||||
|
try {
|
||||||
|
pinMismatches.clear();
|
||||||
|
await _pinBufferProps(p);
|
||||||
|
await _pin('volume-max', '$maxVolume', p);
|
||||||
|
final path = _ytdlPath;
|
||||||
|
if (path != null) {
|
||||||
|
ytdlRaw = await _setYtdl(p, path);
|
||||||
|
ytdl = true;
|
||||||
|
}
|
||||||
|
// The view is up: the new output is created (and its texture
|
||||||
|
// registered) before the view swaps, so the old picture stays until
|
||||||
|
// then. Created by the view instead while the render mode is not
|
||||||
|
// known yet, so the probe hears its GL init ([video]).
|
||||||
|
if (_video != null && renderMode.peek() != RenderMode.unknown) {
|
||||||
|
video = VideoController(p, configuration: VideoControllerConfiguration(hwdec: _hwdecOverride));
|
||||||
|
await video.platform.future;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('fresh player setup: $e');
|
||||||
|
}
|
||||||
|
// Disposed or reloaded meanwhile: that player stays.
|
||||||
|
if (!identical(old, _player)) {
|
||||||
|
try {
|
||||||
|
await p.dispose();
|
||||||
|
} catch (_) {}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await _adopt(p, video, ytdl: ytdl, ytdlRaw: ytdlRaw);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mpv's yt-dlp hook on [p] (see [useYtDlp]); returns the raw options set.
|
||||||
|
static Future<String> _setYtdl(Player p, String path) async {
|
||||||
|
final dynamic platform = p.platform;
|
||||||
|
await platform?.setProperty('script-opts', 'ytdl_hook-ytdl_path=$path');
|
||||||
|
await platform?.setProperty('ytdl-format', ytdlFormat);
|
||||||
|
final raw = await AppYtDlp.instance.mpvRawOptions();
|
||||||
|
await platform?.setProperty('ytdl-raw-options', raw);
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [p] becomes the session player (a promoted preload or a fresh
|
||||||
|
/// player): log, marks and track observers move over, the view swaps to
|
||||||
|
/// its video output ([generation]), volume and shaders carry over, and
|
||||||
|
/// the old player is paused and disposed once the view let go of its
|
||||||
|
/// texture (as in [dispose]).
|
||||||
|
Future<void> _adopt(SyncPlayer p, VideoController? video, {required bool ytdl, String ytdlRaw = ''}) async {
|
||||||
|
final old = _player!;
|
||||||
|
final volume = old.state.volume;
|
||||||
|
await _logSub?.cancel();
|
||||||
|
for (final sub in _timeSubs) {
|
||||||
|
await sub.cancel();
|
||||||
|
}
|
||||||
|
_timeSubs.clear();
|
||||||
|
_player = p;
|
||||||
|
_video = video;
|
||||||
|
_held = true;
|
||||||
|
_ytdlSetOn = ytdl ? p : null;
|
||||||
|
_ytdlRaw = ytdlRaw;
|
||||||
|
_chapterJson = '';
|
||||||
|
_httpError = null;
|
||||||
|
_loadErrorSent = false;
|
||||||
|
_shaders = '';
|
||||||
|
final at = p.state.position.inMilliseconds;
|
||||||
|
position.value = Duration(milliseconds: at - at % 250);
|
||||||
|
duration.value = p.state.duration;
|
||||||
|
final ms = p.state.buffer.inMilliseconds;
|
||||||
|
buffered.value = Duration(milliseconds: ms - ms % 250);
|
||||||
|
chapters.value = const [];
|
||||||
|
tracks.value = const [];
|
||||||
|
_logSub = p.stream.log.listen(_onLog);
|
||||||
|
_requestLog(p, logLevel);
|
||||||
|
_watchMarks(p);
|
||||||
|
generation.value++;
|
||||||
|
// Normally stopped already (the coordinator leaves the previous
|
||||||
|
// media first): never two sounds at once.
|
||||||
|
unawaited(old.pause().catchError((_) {}));
|
||||||
|
try {
|
||||||
|
await p.setVolume(volume);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('new player volume: $e');
|
||||||
|
}
|
||||||
|
unawaited(applyShaders());
|
||||||
|
unawaited(_refreshTracks());
|
||||||
|
_retiring = () async {
|
||||||
|
await _settleUi();
|
||||||
|
try {
|
||||||
|
await old.dispose();
|
||||||
|
} catch (_) {}
|
||||||
|
}();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
if (!_ready) return;
|
if (!_ready) return;
|
||||||
try {
|
try {
|
||||||
@@ -641,3 +972,27 @@ bool pinMatches(String wanted, String got) {
|
|||||||
final a = double.tryParse(wanted), b = double.tryParse(got);
|
final a = double.tryParse(wanted), b = double.tryParse(got);
|
||||||
return a != null && b != null && a == b;
|
return a != null && b != null && a == b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _Preload {
|
||||||
|
final SyncPlayer player;
|
||||||
|
final String url;
|
||||||
|
final Map<String, String> headers;
|
||||||
|
VideoController? video;
|
||||||
|
StreamSubscription<PlayerLog>? logSub;
|
||||||
|
/// Completes once the media is handed to mpv (or the preload failed).
|
||||||
|
final opened = Completer<void>();
|
||||||
|
bool ytdl = false;
|
||||||
|
String ytdlRaw = '';
|
||||||
|
bool failed = false;
|
||||||
|
final ready = Completer<bool>();
|
||||||
|
_Preload(this.player, this.url, this.headers);
|
||||||
|
|
||||||
|
bool matches(String url, Map<String, String>? headers) =>
|
||||||
|
url == this.url && mapEquals(headers ?? const {}, this.headers);
|
||||||
|
|
||||||
|
void fail() {
|
||||||
|
failed = true;
|
||||||
|
if (!ready.isCompleted) ready.complete(false);
|
||||||
|
if (!opened.isCompleted) opened.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
/// mpv's `ytdl-format` for links every player opens itself.
|
||||||
|
const ytdlFormat = 'bv*[height<=1080]+ba/b';
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
class AppYtDlp {
|
||||||
|
static final instance = AppYtDlp();
|
||||||
|
|
||||||
|
/// [ytDlp]: a given one (tests); default in the app-support folder.
|
||||||
|
AppYtDlp([YtDlp? ytDlp]) : _ytDlp = ytDlp == null ? null : Future.value(ytDlp);
|
||||||
|
|
||||||
|
Future<YtDlp>? _ytDlp;
|
||||||
|
Future<YtDlp> get ytDlp => _ytDlp ??= () async {
|
||||||
|
final base = await getApplicationSupportDirectory();
|
||||||
|
return YtDlp(dir(base.path));
|
||||||
|
}();
|
||||||
|
|
||||||
|
static String dir(String appSupport) => '$appSupport${Platform.pathSeparator}yt-dlp';
|
||||||
|
|
||||||
|
/// Gets yt-dlp ready in the background (download on first run, a daily
|
||||||
|
/// update check afterwards).
|
||||||
|
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
|
||||||
|
|
||||||
|
/// mpv's `ytdl-raw-options`: [ytdlSubLangs] and the cookies in the
|
||||||
|
/// yt-dlp folder (see [YtDlpCookies]).
|
||||||
|
Future<String> mpvRawOptions() async {
|
||||||
|
const subs = 'sub-langs=%${ytdlSubLangs.length}%$ytdlSubLangs';
|
||||||
|
try {
|
||||||
|
final cookies = (await ytDlp).cookies.mpvRawOptions();
|
||||||
|
return cookies.isEmpty ? subs : '$subs,$cookies';
|
||||||
|
} catch (_) {
|
||||||
|
return subs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final _probes = <String, ({DateTime at, DateTime until, Future<LinkProbe> f})>{};
|
||||||
|
static const _probeTtl = Duration(minutes: 10);
|
||||||
|
|
||||||
|
/// What [url] is, from memory while it is kept (10 minutes from when it
|
||||||
|
/// was probed; [keep] keeps it longer, for Up next's first item, which
|
||||||
|
/// is probed again before that runs out). [fresh] probes again: the
|
||||||
|
/// remembered result stays until the new one is in, and stays when the
|
||||||
|
/// new one fails.
|
||||||
|
Future<LinkProbe> probe(String url, {Duration keep = _probeTtl, bool fresh = false}) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
_probes.removeWhere((_, v) => now.isAfter(v.until));
|
||||||
|
final hit = _probes[url];
|
||||||
|
if (hit != null && !fresh) {
|
||||||
|
final longer = hit.at.add(keep);
|
||||||
|
if (longer.isAfter(hit.until)) _probes[url] = (at: hit.at, until: longer, f: hit.f);
|
||||||
|
return hit.f;
|
||||||
|
}
|
||||||
|
final f = ytDlp.then((y) => probeLink(Uri.parse(url), ytDlp: y));
|
||||||
|
final until = now.add(keep);
|
||||||
|
if (hit == null) {
|
||||||
|
_probes[url] = (at: now, until: until, f: f);
|
||||||
|
f.catchError((Object _) {
|
||||||
|
if (_probes[url]?.f == f) _probes.remove(url);
|
||||||
|
return LinkProbe.file(Uri(), -1);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
f.then<void>((_) {
|
||||||
|
_probes[url] = (at: now, until: until, f: f);
|
||||||
|
}, onError: (Object _) {});
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [url] no longer needs keeping longer: back to the usual 10 minutes.
|
||||||
|
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)}';
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import 'dart:ui' show Color;
|
|||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../update/updater.dart' show appVersion;
|
|
||||||
|
|
||||||
// AniList airing schedule (public GraphQL, no login): every episode airing
|
// AniList airing schedule (public GraphQL, no login): every episode airing
|
||||||
// in a time range, with covers, banners and popularity; and a title search
|
// in a time range, with covers, banners and popularity; and a title search
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import 'dart:io';
|
|||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../jellyfin/accounts.dart';
|
import '../jellyfin/accounts.dart';
|
||||||
import '../update/updater.dart' show appVersion;
|
|
||||||
|
|
||||||
// AnimeFlow app API: what the media server tracks, has, downloads and was
|
// AnimeFlow app API: what the media server tracks, has, downloads and was
|
||||||
// asked for. Authenticated with the user's own Jellyfin access token
|
// asked for. Authenticated with the user's own Jellyfin access token
|
||||||
@@ -148,13 +147,17 @@ class AnimeFlowLibraryItem {
|
|||||||
final String name;
|
final String name;
|
||||||
/// Set when AnimeFlow tracks it.
|
/// Set when AnimeFlow tracks it.
|
||||||
final int? seriesId;
|
final int? seriesId;
|
||||||
/// AniList entry per TVDB season.
|
/// AniList entry per TVDB season (the first, when a season spans more).
|
||||||
final Map<int, int> anilistIds;
|
final Map<int, int> anilistIds;
|
||||||
|
/// Every AniList entry per season with its episode run (`anilist_map`):
|
||||||
|
/// a season AniList splits (one cour as two entries) has several rows.
|
||||||
|
final List<AniListRun> anilistMap;
|
||||||
const AnimeFlowLibraryItem({
|
const AnimeFlowLibraryItem({
|
||||||
required this.jellyfinSeriesId,
|
required this.jellyfinSeriesId,
|
||||||
required this.name,
|
required this.name,
|
||||||
this.seriesId,
|
this.seriesId,
|
||||||
this.anilistIds = const {},
|
this.anilistIds = const {},
|
||||||
|
this.anilistMap = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
static AnimeFlowLibraryItem fromJson(Map j) => AnimeFlowLibraryItem(
|
static AnimeFlowLibraryItem fromJson(Map j) => AnimeFlowLibraryItem(
|
||||||
@@ -165,15 +168,38 @@ class AnimeFlowLibraryItem {
|
|||||||
for (final e in ((j['anilist_ids'] as Map?) ?? const {}).entries)
|
for (final e in ((j['anilist_ids'] as Map?) ?? const {}).entries)
|
||||||
if (int.tryParse('${e.key}') case final season? when _int(e.value) != null) season: _int(e.value)!,
|
if (int.tryParse('${e.key}') case final season? when _int(e.value) != null) season: _int(e.value)!,
|
||||||
},
|
},
|
||||||
|
anilistMap: [
|
||||||
|
for (final r in (j['anilist_map'] as List?) ?? const [])
|
||||||
|
if (r is Map && _int(r['season']) != null && _int(r['anilist_id']) != null)
|
||||||
|
AniListRun(
|
||||||
|
season: _int(r['season'])!,
|
||||||
|
anilistId: _int(r['anilist_id'])!,
|
||||||
|
firstEpisode: _int(r['first_episode']) ?? 1,
|
||||||
|
offset: _int(r['offset']) ?? 0,
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One AniList entry's run within a TVDB season: TVDB episode e (from
|
||||||
|
/// [firstEpisode] to the next run of that season) is AniList episode
|
||||||
|
/// e - [offset].
|
||||||
|
class AniListRun {
|
||||||
|
final int season;
|
||||||
|
final int anilistId;
|
||||||
|
final int firstEpisode;
|
||||||
|
final int offset;
|
||||||
|
const AniListRun({required this.season, required this.anilistId, this.firstEpisode = 1, this.offset = 0});
|
||||||
|
}
|
||||||
|
|
||||||
class AnimeFlowEpisode {
|
class AnimeFlowEpisode {
|
||||||
/// 0 = specials.
|
/// 0 = specials.
|
||||||
final int season;
|
final int season;
|
||||||
final int episode;
|
final int episode;
|
||||||
final int? absolute;
|
final int? absolute;
|
||||||
final String? title;
|
final String? title;
|
||||||
|
/// Date-only: midnight Japan time of the broadcast day. Compare it as a
|
||||||
|
/// Japan day (`sameAirDay` in schedule_model.dart), never a local one.
|
||||||
final DateTime? airDate;
|
final DateTime? airDate;
|
||||||
/// The AniList entry of this episode's season (one AnimeFlow series may
|
/// The AniList entry of this episode's season (one AnimeFlow series may
|
||||||
/// span several AniList entries).
|
/// span several AniList entries).
|
||||||
@@ -184,8 +210,11 @@ class AnimeFlowEpisode {
|
|||||||
final EpisodeStatus status;
|
final EpisodeStatus status;
|
||||||
/// 0..1, only while downloading.
|
/// 0..1, only while downloading.
|
||||||
final double? progress;
|
final double? progress;
|
||||||
/// Only when failed.
|
/// Why it failed; for a missing one with rejected releases, a
|
||||||
|
/// ready-to-show sentence ("2 release(s) found, rejected: …").
|
||||||
final String? reason;
|
final String? reason;
|
||||||
|
/// Last search for a monitored, not yet downloaded episode.
|
||||||
|
final DateTime? lastSearchedAt;
|
||||||
|
|
||||||
const AnimeFlowEpisode({
|
const AnimeFlowEpisode({
|
||||||
required this.season,
|
required this.season,
|
||||||
@@ -198,6 +227,7 @@ class AnimeFlowEpisode {
|
|||||||
required this.status,
|
required this.status,
|
||||||
this.progress,
|
this.progress,
|
||||||
this.reason,
|
this.reason,
|
||||||
|
this.lastSearchedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
static AnimeFlowEpisode fromJson(Map j) => AnimeFlowEpisode(
|
static AnimeFlowEpisode fromJson(Map j) => AnimeFlowEpisode(
|
||||||
@@ -206,11 +236,14 @@ class AnimeFlowEpisode {
|
|||||||
absolute: _int(j['absolute']),
|
absolute: _int(j['absolute']),
|
||||||
title: _str(j['episode_title']),
|
title: _str(j['episode_title']),
|
||||||
airDate: _time(j['air_date']),
|
airDate: _time(j['air_date']),
|
||||||
anilistId: _int(j['anilist_id']),
|
// The episode's own entry (a split season rolls into its sequel),
|
||||||
|
// else the series' one.
|
||||||
|
anilistId: _int(j['episode_anilist_id']) ?? _int(j['anilist_id']),
|
||||||
anilistEpisode: _int(j['anilist_episode']),
|
anilistEpisode: _int(j['anilist_episode']),
|
||||||
status: EpisodeStatus.parse(j['status']),
|
status: EpisodeStatus.parse(j['status']),
|
||||||
progress: (j['progress'] as num?)?.toDouble(),
|
progress: (j['progress'] as num?)?.toDouble(),
|
||||||
reason: _str(j['reason']),
|
reason: _str(j['reason']),
|
||||||
|
lastSearchedAt: _time(j['last_searched_at']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +255,6 @@ class AnimeFlowScheduleItem {
|
|||||||
final int? anidbId;
|
final int? anidbId;
|
||||||
final int? tvdbId;
|
final int? tvdbId;
|
||||||
final String? jellyfinSeriesId;
|
final String? jellyfinSeriesId;
|
||||||
final bool followed;
|
|
||||||
|
|
||||||
const AnimeFlowScheduleItem({
|
const AnimeFlowScheduleItem({
|
||||||
required this.episode,
|
required this.episode,
|
||||||
@@ -231,7 +263,6 @@ class AnimeFlowScheduleItem {
|
|||||||
this.anidbId,
|
this.anidbId,
|
||||||
this.tvdbId,
|
this.tvdbId,
|
||||||
this.jellyfinSeriesId,
|
this.jellyfinSeriesId,
|
||||||
this.followed = false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
static AnimeFlowScheduleItem fromJson(Map j) => AnimeFlowScheduleItem(
|
static AnimeFlowScheduleItem fromJson(Map j) => AnimeFlowScheduleItem(
|
||||||
@@ -241,7 +272,6 @@ class AnimeFlowScheduleItem {
|
|||||||
anidbId: _int(j['anidb_id']),
|
anidbId: _int(j['anidb_id']),
|
||||||
tvdbId: _int(j['tvdb_id']),
|
tvdbId: _int(j['tvdb_id']),
|
||||||
jellyfinSeriesId: _str(j['jellyfin_series_id']),
|
jellyfinSeriesId: _str(j['jellyfin_series_id']),
|
||||||
followed: j['followed'] == true,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +300,6 @@ class AnimeFlowSeries {
|
|||||||
final String? jellyfinSeriesId;
|
final String? jellyfinSeriesId;
|
||||||
final SeriesState state;
|
final SeriesState state;
|
||||||
final bool monitored;
|
final bool monitored;
|
||||||
final bool followed;
|
|
||||||
final int episodesTotal;
|
final int episodesTotal;
|
||||||
final int episodesAvailable;
|
final int episodesAvailable;
|
||||||
final DateTime? nextAirDate;
|
final DateTime? nextAirDate;
|
||||||
@@ -286,7 +315,6 @@ class AnimeFlowSeries {
|
|||||||
this.jellyfinSeriesId,
|
this.jellyfinSeriesId,
|
||||||
this.state = SeriesState.releasing,
|
this.state = SeriesState.releasing,
|
||||||
this.monitored = false,
|
this.monitored = false,
|
||||||
this.followed = false,
|
|
||||||
this.episodesTotal = 0,
|
this.episodesTotal = 0,
|
||||||
this.episodesAvailable = 0,
|
this.episodesAvailable = 0,
|
||||||
this.nextAirDate,
|
this.nextAirDate,
|
||||||
@@ -302,7 +330,6 @@ class AnimeFlowSeries {
|
|||||||
jellyfinSeriesId: _str(j['jellyfin_series_id']),
|
jellyfinSeriesId: _str(j['jellyfin_series_id']),
|
||||||
state: SeriesState.parse(j['state']),
|
state: SeriesState.parse(j['state']),
|
||||||
monitored: j['monitored'] == true,
|
monitored: j['monitored'] == true,
|
||||||
followed: j['followed'] == true,
|
|
||||||
episodesTotal: _int(j['episodes_total']) ?? 0,
|
episodesTotal: _int(j['episodes_total']) ?? 0,
|
||||||
episodesAvailable: _int(j['episodes_available']) ?? 0,
|
episodesAvailable: _int(j['episodes_available']) ?? 0,
|
||||||
nextAirDate: _time(j['next_air_date']),
|
nextAirDate: _time(j['next_air_date']),
|
||||||
@@ -658,10 +685,6 @@ class AnimeFlowClient {
|
|||||||
return AnimeFlowSeries.fromJson(j is Map ? j : const {});
|
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].
|
/// Titles that can be requested [request].
|
||||||
Future<List<AnimeFlowSearchHit>> search(String q) async => [
|
Future<List<AnimeFlowSearchHit>> search(String q) async => [
|
||||||
for (final h in _items(await _call('GET', '/search', query: {'q': q}))) AnimeFlowSearchHit.fromJson(h),
|
for (final h in _items(await _call('GET', '/search', query: {'q': q}))) AnimeFlowSearchHit.fromJson(h),
|
||||||
|
|||||||
@@ -12,14 +12,25 @@ enum ScheduleStatus {
|
|||||||
available,
|
available,
|
||||||
downloading,
|
downloading,
|
||||||
upcoming,
|
upcoming,
|
||||||
|
/// AnimeFlow's `missing`: aired, monitored, no release grabbed yet;
|
||||||
|
/// AnimeFlow grabs it by itself once one appears (nothing to request).
|
||||||
|
/// Also an aired episode of a tracked series AnimeFlow has no row for.
|
||||||
missing,
|
missing,
|
||||||
failed,
|
failed,
|
||||||
|
/// AnimeFlow does not monitor it: the only status worth a Request.
|
||||||
unmonitored,
|
unmonitored,
|
||||||
/// AnimeFlow does not track this series (or is unreachable).
|
/// AnimeFlow does not track this series (or is unreachable).
|
||||||
notTracked,
|
notTracked,
|
||||||
/// Not tracked by AnimeFlow, but the series is on Jellyfin (`/library`):
|
/// Not tracked by AnimeFlow, the series is on Jellyfin (`/library`) and
|
||||||
/// the episode may be there; Put it up finds out.
|
/// so is the episode ([confirmOnJellyfin]; [matchSchedule] alone sets
|
||||||
inLibrary;
|
/// 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) {
|
static ScheduleStatus of(EpisodeStatus s) => switch (s) {
|
||||||
EpisodeStatus.available => available,
|
EpisodeStatus.available => available,
|
||||||
@@ -37,17 +48,26 @@ class ScheduleEntry {
|
|||||||
final ScheduleStatus status;
|
final ScheduleStatus status;
|
||||||
/// 0..1 while downloading.
|
/// 0..1 while downloading.
|
||||||
final double? progress;
|
final double? progress;
|
||||||
/// Why it failed.
|
/// Why it failed, or (missing) why the releases found were rejected.
|
||||||
final String? reason;
|
final String? reason;
|
||||||
|
/// AnimeFlow's last search for it (missing).
|
||||||
|
final DateTime? lastSearchedAt;
|
||||||
/// AnimeFlow tracks the series.
|
/// AnimeFlow tracks the series.
|
||||||
final bool tracked;
|
final bool tracked;
|
||||||
final bool followed;
|
/// AnimeFlow's series id (requests).
|
||||||
/// AnimeFlow's series id (follow, requests).
|
|
||||||
final int? seriesId;
|
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;
|
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.
|
/// Play target once available.
|
||||||
final String? jellyfinSeriesId;
|
final String? jellyfinSeriesId;
|
||||||
|
/// This user's progress on the Jellyfin episode ([confirmOnJellyfin]);
|
||||||
|
/// null when it is not on Jellyfin or was not looked up.
|
||||||
|
final EpisodeWatch? watch;
|
||||||
/// The series is on Jellyfin (tracked or not).
|
/// The series is on Jellyfin (tracked or not).
|
||||||
bool get onJellyfin => jellyfinSeriesId != null;
|
bool get onJellyfin => jellyfinSeriesId != null;
|
||||||
|
|
||||||
@@ -56,13 +76,29 @@ class ScheduleEntry {
|
|||||||
required this.status,
|
required this.status,
|
||||||
this.progress,
|
this.progress,
|
||||||
this.reason,
|
this.reason,
|
||||||
|
this.lastSearchedAt,
|
||||||
this.tracked = false,
|
this.tracked = false,
|
||||||
this.followed = false,
|
|
||||||
this.seriesId,
|
this.seriesId,
|
||||||
this.season,
|
this.season,
|
||||||
|
this.jellyfinEpisode,
|
||||||
this.jellyfinSeriesId,
|
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
|
@override
|
||||||
String toString() => 'ScheduleEntry(${airing.anilistId}#${airing.episode} $status)';
|
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,
|
status: a.airingAt.isAfter(now) ? ScheduleStatus.upcoming : ScheduleStatus.notTracked,
|
||||||
);
|
);
|
||||||
|
|
||||||
String _day(DateTime d) {
|
/// This user's state of a Jellyfin episode (its `UserData`).
|
||||||
final l = d.toLocal();
|
class EpisodeWatch {
|
||||||
return '${l.year}-${l.month}-${l.day}';
|
final bool played;
|
||||||
|
/// 0..1: the resume point of a partly watched episode (0 = none).
|
||||||
|
final double progress;
|
||||||
|
const EpisodeWatch({this.played = false, this.progress = 0});
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => other is EpisodeWatch && other.played == played && other.progress == progress;
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(played, progress);
|
||||||
|
@override
|
||||||
|
String toString() => 'EpisodeWatch(${played ? 'played' : '$progress'})';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One episode Jellyfin has of a series, as the schedule needs it.
|
||||||
|
class JellyfinEpisodeRef {
|
||||||
|
final int season;
|
||||||
|
final int episode;
|
||||||
|
/// Jellyfin's date-only premiere date.
|
||||||
|
final DateTime? premiere;
|
||||||
|
final EpisodeWatch watch;
|
||||||
|
const JellyfinEpisodeRef(this.season, this.episode, {this.premiere, this.watch = const EpisodeWatch()});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Air dates (AnimeFlow's `air_date`, Jellyfin's premiere date) are
|
||||||
|
// date-only: the Japanese broadcast day, sent as midnight Japan time (or
|
||||||
|
// midnight UTC by older servers). Both land on the right day read as a
|
||||||
|
// Japan (UTC+9, no DST) calendar day, never as a local one: midnight JST
|
||||||
|
// is the evening before in Europe and America.
|
||||||
|
const _jst = Duration(hours: 9);
|
||||||
|
|
||||||
|
/// The Japan calendar day of [t].
|
||||||
|
DateTime _jstDay(DateTime t) {
|
||||||
|
final j = t.toUtc().add(_jst);
|
||||||
|
return DateTime.utc(j.year, j.month, j.day);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _dayKey(DateTime d) => '${d.year}-${d.month}-${d.day}';
|
||||||
|
|
||||||
|
/// The Japan days whose air date fits a broadcast at [airingAt]: its own
|
||||||
|
/// and, for a late-night slot (before 05:00 JST, "25:30"), the evening
|
||||||
|
/// before, which listings often give.
|
||||||
|
List<DateTime> _airDays(DateTime airingAt) {
|
||||||
|
final j = airingAt.toUtc().add(_jst);
|
||||||
|
final d = DateTime.utc(j.year, j.month, j.day);
|
||||||
|
return [d, if (j.hour < 5) DateTime.utc(j.year, j.month, j.day - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The date-only [airDate] is the broadcast day of [airingAt] (see
|
||||||
|
/// [_airDays]).
|
||||||
|
bool sameAirDay(DateTime airDate, DateTime airingAt) => _airDays(airingAt).contains(_jstDay(airDate));
|
||||||
|
|
||||||
class _SeriesRef {
|
class _SeriesRef {
|
||||||
final int seriesId;
|
final int seriesId;
|
||||||
final String? jellyfinSeriesId;
|
final String? jellyfinSeriesId;
|
||||||
bool followed;
|
_SeriesRef(this.seriesId, this.jellyfinSeriesId);
|
||||||
_SeriesRef(this.seriesId, this.jellyfinSeriesId, this.followed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Merges AniList [airings] with AnimeFlow's [schedule] rows and tracked
|
/// Merges AniList [airings] with AnimeFlow's [schedule] rows and tracked
|
||||||
@@ -98,51 +181,66 @@ List<ScheduleEntry> matchSchedule(
|
|||||||
DateTime? now,
|
DateTime? now,
|
||||||
}) {
|
}) {
|
||||||
final at = now ?? DateTime.now();
|
final at = now ?? DateTime.now();
|
||||||
// AniList id -> (Jellyfin series, its TVDB season) from /library.
|
// AniList id -> (Jellyfin series, its TVDB season, TVDB - AniList
|
||||||
final lib = <int, (String, int)>{};
|
// episode offset) from /library.
|
||||||
|
final lib = <int, (String, int, int)>{};
|
||||||
for (final item in library) {
|
for (final item in library) {
|
||||||
if (item.jellyfinSeriesId.isEmpty) continue;
|
if (item.jellyfinSeriesId.isEmpty) continue;
|
||||||
|
for (final r in item.anilistMap) {
|
||||||
|
lib[r.anilistId] ??= (item.jellyfinSeriesId, r.season, r.offset);
|
||||||
|
}
|
||||||
for (final e in item.anilistIds.entries) {
|
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>{};
|
final refs = <int, _SeriesRef>{};
|
||||||
for (final s in series) {
|
for (final s in series) {
|
||||||
final id = s.anilistId;
|
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 byEp = <String, AnimeFlowScheduleItem>{};
|
||||||
final byAbs = <String, AnimeFlowScheduleItem>{};
|
final byAbs = <String, AnimeFlowScheduleItem>{};
|
||||||
final byDay = <String, AnimeFlowScheduleItem>{};
|
final byDay = <String, AnimeFlowScheduleItem>{};
|
||||||
|
final numbered = <int>{};
|
||||||
for (final it in schedule) {
|
for (final it in schedule) {
|
||||||
final e = it.episode, id = e.anilistId;
|
final e = it.episode, id = e.anilistId;
|
||||||
if (id == null) continue;
|
if (id == null) continue;
|
||||||
final ref = refs[id] ??= _SeriesRef(it.seriesId, it.jellyfinSeriesId, it.followed);
|
refs[id] ??= _SeriesRef(it.seriesId, it.jellyfinSeriesId);
|
||||||
if (it.followed) ref.followed = true;
|
if (e.anilistEpisode != null) {
|
||||||
// AnimeFlow's AniList episode number first: TVDB numbering can run on
|
numbered.add(id);
|
||||||
// across split cours.
|
byAni.putIfAbsent('$id#${e.anilistEpisode}', () => it);
|
||||||
if (e.anilistEpisode != null) byEp.putIfAbsent('$id#${e.anilistEpisode}', () => it);
|
}
|
||||||
byEp.putIfAbsent('$id#${e.episode}', () => it);
|
byEp.putIfAbsent('$id#${e.episode}', () => it);
|
||||||
if (e.absolute != null) byAbs.putIfAbsent('$id#${e.absolute}', () => it);
|
if (e.absolute != null) byAbs.putIfAbsent('$id#${e.absolute}', () => it);
|
||||||
if (e.airDate != null) byDay.putIfAbsent('$id@${_day(e.airDate!)}', () => it);
|
if (e.airDate != null) byDay.putIfAbsent('$id@${_dayKey(_jstDay(e.airDate!))}', () => it);
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
for (final a in airings)
|
for (final a in airings)
|
||||||
() {
|
() {
|
||||||
final id = a.anilistId;
|
final id = a.anilistId;
|
||||||
final ref = refs[id];
|
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) {
|
if (it != null) {
|
||||||
final e = it.episode;
|
final e = it.episode;
|
||||||
return ScheduleEntry(
|
return ScheduleEntry(
|
||||||
airing: a,
|
airing: a,
|
||||||
status: ScheduleStatus.of(e.status),
|
status: ScheduleStatus.of(e.status),
|
||||||
progress: e.status == EpisodeStatus.downloading ? e.progress : null,
|
progress: e.status == EpisodeStatus.downloading ? e.progress : null,
|
||||||
reason: e.status == EpisodeStatus.failed ? e.reason : null,
|
reason: e.status == EpisodeStatus.failed || e.status == EpisodeStatus.missing ? e.reason : null,
|
||||||
|
lastSearchedAt: e.lastSearchedAt,
|
||||||
tracked: true,
|
tracked: true,
|
||||||
followed: it.followed || (ref?.followed ?? false),
|
|
||||||
seriesId: it.seriesId,
|
seriesId: it.seriesId,
|
||||||
season: e.season,
|
season: e.season,
|
||||||
|
jellyfinEpisode: e.episode,
|
||||||
jellyfinSeriesId: it.jellyfinSeriesId ?? ref?.jellyfinSeriesId ?? lib[id]?.$1,
|
jellyfinSeriesId: it.jellyfinSeriesId ?? ref?.jellyfinSeriesId ?? lib[id]?.$1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -153,6 +251,7 @@ List<ScheduleEntry> matchSchedule(
|
|||||||
airing: a,
|
airing: a,
|
||||||
status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.inLibrary,
|
status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.inLibrary,
|
||||||
season: onJf.$2,
|
season: onJf.$2,
|
||||||
|
jellyfinEpisode: a.episode + onJf.$3,
|
||||||
jellyfinSeriesId: onJf.$1,
|
jellyfinSeriesId: onJf.$1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -160,7 +259,6 @@ List<ScheduleEntry> matchSchedule(
|
|||||||
airing: a,
|
airing: a,
|
||||||
status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.missing,
|
status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.missing,
|
||||||
tracked: true,
|
tracked: true,
|
||||||
followed: ref.followed,
|
|
||||||
seriesId: ref.seriesId,
|
seriesId: ref.seriesId,
|
||||||
jellyfinSeriesId: ref.jellyfinSeriesId ?? lib[id]?.$1,
|
jellyfinSeriesId: ref.jellyfinSeriesId ?? lib[id]?.$1,
|
||||||
);
|
);
|
||||||
@@ -168,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
|
/// One loaded window. Errors are display text; [entries] is empty only
|
||||||
/// when AniList failed with nothing cached.
|
/// when AniList failed with nothing cached.
|
||||||
class ScheduleSnapshot {
|
class ScheduleSnapshot {
|
||||||
@@ -203,12 +341,16 @@ class ScheduleSnapshot {
|
|||||||
|
|
||||||
/// Loads AniList and AnimeFlow for a window and merges them.
|
/// Loads AniList and AnimeFlow for a window and merges them.
|
||||||
class ScheduleRepository {
|
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;
|
: _now = now ?? DateTime.now;
|
||||||
|
|
||||||
final AniListClient aniList;
|
final AniListClient aniList;
|
||||||
/// Null = not configured (AniList only).
|
/// Null = not configured (AniList only).
|
||||||
final AnimeFlowClient? animeFlow;
|
final AnimeFlowClient? animeFlow;
|
||||||
|
/// Confirms "on Jellyfin" 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;
|
final DateTime Function() _now;
|
||||||
|
|
||||||
/// Last week's Monday 00:00 local .. 4 weeks later.
|
/// Last week's Monday 00:00 local .. 4 weeks later.
|
||||||
@@ -233,7 +375,8 @@ class ScheduleRepository {
|
|||||||
final flow = await flowF;
|
final flow = await flowF;
|
||||||
final airings = [...ani.airings]..sort((a, b) => a.airingAt.compareTo(b.airingAt));
|
final airings = [...ani.airings]..sort((a, b) => a.airingAt.compareTo(b.airingAt));
|
||||||
final entries = flow.error == null
|
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)];
|
: [for (final a in airings) untrackedEntry(a, now)];
|
||||||
return ScheduleSnapshot(
|
return ScheduleSnapshot(
|
||||||
from: start,
|
from: start,
|
||||||
@@ -246,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})>
|
Future<({AnimeFlowMe? me, List<AnimeFlowScheduleItem> schedule, List<AnimeFlowSeries> series, List<AnimeFlowLibraryItem> library, Object? error})>
|
||||||
_animeFlow(DateTime from, DateTime to, bool force) async {
|
_animeFlow(DateTime from, DateTime to, bool force) async {
|
||||||
final c = animeFlow;
|
final c = animeFlow;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../../jellyfin/accounts.dart';
|
import '../../jellyfin/accounts.dart';
|
||||||
import '../../jellyfin/format.dart' show pickFor;
|
import '../../jellyfin/format.dart' show pickFor, progressOf;
|
||||||
import '../../screens/home/picked_media.dart';
|
import '../../screens/home/picked_media.dart';
|
||||||
import '../anilist.dart';
|
import '../anilist.dart';
|
||||||
import '../animeflow.dart';
|
import '../animeflow.dart';
|
||||||
@@ -47,14 +47,9 @@ String friendlyError(Object e) => switch (e) {
|
|||||||
_ => '$e',
|
_ => '$e',
|
||||||
};
|
};
|
||||||
|
|
||||||
bool _sameDay(DateTime a, DateTime b) {
|
|
||||||
final x = a.toLocal(), y = b.toLocal();
|
|
||||||
return x.year == y.year && x.month == y.month && x.day == y.day;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// AnimeFlow's episode for [e] (its season and numbering), from the
|
/// AnimeFlow's episode for [e] (its season and numbering), from the
|
||||||
/// series detail: same AniList id and episode, else absolute number, else
|
/// series detail: same AniList id and episode, else absolute number, else
|
||||||
/// the same air day. Null when the entry is not tracked or nothing fits.
|
/// the same air day ([sameAirDay]). Null when the entry is not tracked or nothing fits.
|
||||||
Future<AnimeFlowEpisode?> flowEpisodeFor(AnimeFlowClient client, ScheduleEntry e) async {
|
Future<AnimeFlowEpisode?> flowEpisodeFor(AnimeFlowClient client, ScheduleEntry e) async {
|
||||||
final id = e.seriesId;
|
final id = e.seriesId;
|
||||||
if (id == null) return null;
|
if (id == null) return null;
|
||||||
@@ -63,7 +58,7 @@ Future<AnimeFlowEpisode?> flowEpisodeFor(AnimeFlowClient client, ScheduleEntry e
|
|||||||
final mine = eps.where((x) => x.anilistId == null || x.anilistId == a.anilistId).toList();
|
final mine = eps.where((x) => x.anilistId == null || x.anilistId == a.anilistId).toList();
|
||||||
return mine.where((x) => x.anilistId == a.anilistId && x.episode == a.episode).firstOrNull ??
|
return mine.where((x) => x.anilistId == a.anilistId && x.episode == a.episode).firstOrNull ??
|
||||||
mine.where((x) => x.absolute == a.episode).firstOrNull ??
|
mine.where((x) => x.absolute == a.episode).firstOrNull ??
|
||||||
mine.where((x) => x.airDate != null && _sameDay(x.airDate!, a.airingAt)).firstOrNull;
|
mine.where((x) => x.airDate != null && sameAirDay(x.airDate!, a.airingAt)).firstOrNull;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The (season, episode) AnimeFlow uses for [e]; falls back to the
|
/// The (season, episode) AnimeFlow uses for [e]; falls back to the
|
||||||
@@ -78,7 +73,7 @@ Future<({int season, int episode})> flowNumbering(AnimeFlowClient? client, Sched
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (ep != null) return (season: ep.season, episode: ep.episode);
|
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:
|
/// The AniDB id of an untracked AniList entry, from AnimeFlow's search:
|
||||||
@@ -112,6 +107,37 @@ JellyfinAccount? jellyfinAccountFor(JellyfinAccounts accounts, AnimeFlowClient?
|
|||||||
return accounts.defaultAccount;
|
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
|
/// The Jellyfin episode of an available [e] as a media pick. Throws
|
||||||
/// [ScheduleActionException] (or a client error) when it cannot be found.
|
/// [ScheduleActionException] (or a client error) when it cannot be found.
|
||||||
Future<JellyfinMedia> resolveJellyfinEpisode(JellyfinAccounts accounts, AnimeFlowClient? flow, ScheduleEntry e) async {
|
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 a = e.airing;
|
||||||
final item =
|
final item =
|
||||||
eps.where((x) => x.parentIndexNumber == n.season && x.indexNumber == n.episode).firstOrNull ??
|
eps.where((x) => x.parentIndexNumber == n.season && x.indexNumber == n.episode).firstOrNull ??
|
||||||
eps.where((x) => x.premiereDate != null && _sameDay(x.premiereDate!, a.airingAt)).singleOrNull ??
|
eps.where((x) => x.premiereDate != null && sameAirDay(x.premiereDate!, a.airingAt)).singleOrNull ??
|
||||||
eps.where((x) => x.indexNumber == a.episode).singleOrNull;
|
eps.where((x) => x.indexNumber == a.episode).singleOrNull;
|
||||||
if (item == null) {
|
if (item == null) {
|
||||||
throw ScheduleActionException('Episode ${a.episode} of ${a.title} is not on Jellyfin yet');
|
throw ScheduleActionException('Episode ${a.episode} of ${a.title} is not on Jellyfin yet');
|
||||||
|
|||||||
@@ -4,17 +4,17 @@ import 'package:flutter/material.dart';
|
|||||||
import '../schedule_model.dart';
|
import '../schedule_model.dart';
|
||||||
|
|
||||||
// One day of the Schedule board and its airing cards: cover poster on the
|
// One day of the Schedule board and its airing cards: cover poster on the
|
||||||
// left (air time on it, download progress under it), title, episode and
|
// left (air time on it, download progress or this user's watch progress
|
||||||
// status on the right, one full-width action below. Compact mode is a
|
// under it, a check once watched), title, episode and status on the
|
||||||
// slim row.
|
// right, one full-width action below. Compact mode is a slim row.
|
||||||
|
|
||||||
/// What a card's action does, decided by the page.
|
/// What a card's action does, decided by the page.
|
||||||
enum CardAction {
|
enum CardAction {
|
||||||
none,
|
none,
|
||||||
putUp,
|
putUp,
|
||||||
|
|
||||||
/// Available, but not in a server: shown disabled with a hint.
|
/// Available, nobody else watching (offline or solo): plays it here.
|
||||||
playOffline,
|
play,
|
||||||
request,
|
request,
|
||||||
requestSeries,
|
requestSeries,
|
||||||
requested,
|
requested,
|
||||||
@@ -35,8 +35,29 @@ String countdownText(DateTime at, DateTime now) {
|
|||||||
return 'in ${d.inMinutes}m';
|
return 'in ${d.inMinutes}m';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Aired, tracked, no release yet: AnimeFlow downloads it by itself.
|
||||||
|
bool _waiting(ScheduleEntry e, DateTime now) =>
|
||||||
|
e.status == ScheduleStatus.missing ||
|
||||||
|
(e.status == ScheduleStatus.upcoming && e.tracked && !e.airing.airingAt.isAfter(now));
|
||||||
|
|
||||||
|
const waitingHint = 'Aired in Japan; downloads automatically when a release appears';
|
||||||
|
|
||||||
|
String _ago(DateTime t, DateTime now) {
|
||||||
|
final m = now.difference(t).inMinutes;
|
||||||
|
return m < 1 ? 'just now' : m < 60 ? '$m min ago' : '${m ~/ 60} h ago';
|
||||||
|
}
|
||||||
|
|
||||||
/// Status line: text, colour, icon.
|
/// Status line: text, colour, icon.
|
||||||
(String, Color, IconData) statusBadge(ScheduleEntry e, DateTime now) => switch (e.status) {
|
(String, Color, IconData) statusBadge(ScheduleEntry e, DateTime now) => _waiting(e, now)
|
||||||
|
? e.reason != null
|
||||||
|
// Releases exist but AnimeFlow rejected them (the hint says why).
|
||||||
|
? ('release rejected', Colors.orangeAccent, Icons.block)
|
||||||
|
: (
|
||||||
|
e.lastSearchedAt == null ? 'waiting for release' : 'waiting · searched ${_ago(e.lastSearchedAt!, now)}',
|
||||||
|
Colors.amber.shade200,
|
||||||
|
Icons.hourglass_top,
|
||||||
|
)
|
||||||
|
: switch (e.status) {
|
||||||
ScheduleStatus.available => ('on Jellyfin', Colors.greenAccent, Icons.check_circle),
|
ScheduleStatus.available => ('on Jellyfin', Colors.greenAccent, Icons.check_circle),
|
||||||
ScheduleStatus.downloading => (
|
ScheduleStatus.downloading => (
|
||||||
e.progress == null ? 'downloading' : 'downloading ${(e.progress! * 100).round()}%',
|
e.progress == null ? 'downloading' : 'downloading ${(e.progress! * 100).round()}%',
|
||||||
@@ -44,13 +65,26 @@ String countdownText(DateTime at, DateTime now) {
|
|||||||
Icons.downloading,
|
Icons.downloading,
|
||||||
),
|
),
|
||||||
ScheduleStatus.upcoming => (countdownText(e.airing.airingAt, now), Colors.amber, Icons.schedule),
|
ScheduleStatus.upcoming => (countdownText(e.airing.airingAt, now), Colors.amber, Icons.schedule),
|
||||||
ScheduleStatus.missing => ('missing', Colors.orangeAccent, Icons.report_gmailerrorred),
|
ScheduleStatus.missing => ('waiting for release', Colors.amber.shade200, Icons.hourglass_top),
|
||||||
ScheduleStatus.failed => ('failed${e.reason == null ? '' : ' · ${e.reason}'}', Colors.redAccent, Icons.error_outline),
|
ScheduleStatus.failed => (
|
||||||
|
'failed${e.reason == null ? '' : ' · ${e.reason}'}',
|
||||||
|
Colors.redAccent,
|
||||||
|
Icons.error_outline,
|
||||||
|
),
|
||||||
ScheduleStatus.unmonitored => ('not monitored', Colors.grey, Icons.visibility_off_outlined),
|
ScheduleStatus.unmonitored => ('not monitored', Colors.grey, Icons.visibility_off_outlined),
|
||||||
ScheduleStatus.notTracked => ('not in AnimeFlow', Colors.grey, Icons.public),
|
ScheduleStatus.notTracked => ('not in AnimeFlow', Colors.grey, Icons.public),
|
||||||
ScheduleStatus.inLibrary => ('on Jellyfin', Colors.greenAccent, Icons.video_library_outlined),
|
ScheduleStatus.inLibrary => ('on Jellyfin', Colors.greenAccent, Icons.video_library_outlined),
|
||||||
|
ScheduleStatus.notOnJellyfin => ('aired · not on Jellyfin yet', Colors.grey, Icons.hourglass_empty),
|
||||||
|
ScheduleStatus.aired => ('aired', Colors.grey, Icons.done),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// What the status line means, when its text alone does not say.
|
||||||
|
String? statusHint(ScheduleEntry e, DateTime now) => !_waiting(e, now)
|
||||||
|
? null
|
||||||
|
: e.reason != null
|
||||||
|
? '${e.reason}${e.lastSearchedAt == null ? '' : ' · searched ${_ago(e.lastSearchedAt!, now)}'}'
|
||||||
|
: waitingHint;
|
||||||
|
|
||||||
class DayColumn extends StatelessWidget {
|
class DayColumn extends StatelessWidget {
|
||||||
final DateTime day;
|
final DateTime day;
|
||||||
final bool today, past;
|
final bool today, past;
|
||||||
@@ -125,7 +159,6 @@ class AiringCard extends StatelessWidget {
|
|||||||
final ScheduleEntry entry;
|
final ScheduleEntry entry;
|
||||||
final DateTime now;
|
final DateTime now;
|
||||||
final bool compact;
|
final bool compact;
|
||||||
final bool followed;
|
|
||||||
final CardAction action;
|
final CardAction action;
|
||||||
|
|
||||||
/// Why [action] is disabled (no request scope, not on Jellyfin, ...);
|
/// Why [action] is disabled (no request scope, not on Jellyfin, ...);
|
||||||
@@ -136,20 +169,19 @@ class AiringCard extends StatelessWidget {
|
|||||||
final bool busy;
|
final bool busy;
|
||||||
final VoidCallback onAction;
|
final VoidCallback onAction;
|
||||||
|
|
||||||
/// Null hides the bookmark (untracked series).
|
/// Right-click ([context]: the card): the page's menu; null: none.
|
||||||
final VoidCallback? onFollow;
|
final void Function(BuildContext context)? onMenu;
|
||||||
|
|
||||||
const AiringCard({
|
const AiringCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.entry,
|
required this.entry,
|
||||||
required this.now,
|
required this.now,
|
||||||
required this.compact,
|
required this.compact,
|
||||||
required this.followed,
|
|
||||||
required this.action,
|
required this.action,
|
||||||
required this.onAction,
|
required this.onAction,
|
||||||
this.disabledHint,
|
this.disabledHint,
|
||||||
this.busy = false,
|
this.busy = false,
|
||||||
this.onFollow,
|
this.onMenu,
|
||||||
});
|
});
|
||||||
|
|
||||||
String get _id => '${entry.airing.anilistId}-${entry.airing.episode}';
|
String get _id => '${entry.airing.anilistId}-${entry.airing.episode}';
|
||||||
@@ -157,20 +189,65 @@ class AiringCard extends StatelessWidget {
|
|||||||
Widget _image(String? url, Widget fallback) =>
|
Widget _image(String? url, Widget fallback) =>
|
||||||
url == null ? fallback : Image.network(url, fit: BoxFit.cover, errorBuilder: (_, _, _) => fallback);
|
url == null ? fallback : Image.network(url, fit: BoxFit.cover, errorBuilder: (_, _, _) => fallback);
|
||||||
|
|
||||||
|
static Widget _hinted(String? hint, Widget child) => hint == null ? child : Tooltip(message: hint, child: child);
|
||||||
|
|
||||||
|
bool get _played => entry.watch?.played ?? false;
|
||||||
|
|
||||||
|
/// A watched episode's cover is a little dimmer.
|
||||||
|
Widget _dimmed(Widget cover) => _played ? Opacity(opacity: 0.6, child: cover) : cover;
|
||||||
|
|
||||||
|
/// This user's state of the episode on Jellyfin: the library cards'
|
||||||
|
/// green check once watched, else a thin bar to the resume point.
|
||||||
|
List<Widget> _watchMarks({bool small = false}) {
|
||||||
|
final w = entry.watch;
|
||||||
|
if (w == null) return const [];
|
||||||
|
if (w.played) {
|
||||||
|
final size = small ? 16.0 : 22.0;
|
||||||
|
return [
|
||||||
|
Positioned(
|
||||||
|
top: small ? 2 : 4,
|
||||||
|
right: small ? 2 : 4,
|
||||||
|
child: Tooltip(
|
||||||
|
message: 'Watched',
|
||||||
|
child: Container(
|
||||||
|
key: ValueKey('watched-$_id'),
|
||||||
|
constraints: BoxConstraints(minWidth: size, minHeight: size),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(color: const Color(0xCC1B5E20), borderRadius: BorderRadius.circular(size / 2)),
|
||||||
|
child: Icon(Icons.check, size: small ? 11 : 14, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (w.progress <= 0) return const [];
|
||||||
|
return [
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Tooltip(
|
||||||
|
message: '${(w.progress * 100).round()}% watched',
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
key: ValueKey('watch-progress-$_id'),
|
||||||
|
value: w.progress,
|
||||||
|
minHeight: small ? 2 : 3,
|
||||||
|
backgroundColor: Colors.black54,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
Widget? _button(BuildContext context) {
|
Widget? _button(BuildContext context) {
|
||||||
final enabled = disabledHint == null && !busy;
|
final enabled = disabledHint == null && !busy;
|
||||||
final spinner = const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2));
|
final spinner = const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2));
|
||||||
Widget? b = switch (action) {
|
Widget? b = switch (action) {
|
||||||
CardAction.none => null,
|
CardAction.none => null,
|
||||||
CardAction.putUp => FilledButton.tonalIcon(
|
CardAction.putUp || CardAction.play => FilledButton.tonalIcon(
|
||||||
onPressed: enabled ? onAction : null,
|
onPressed: enabled ? onAction : null,
|
||||||
icon: busy ? spinner : const Icon(Icons.play_arrow, size: 18),
|
icon: busy ? spinner : const Icon(Icons.play_arrow, size: 18),
|
||||||
label: const Text('Put it up'),
|
label: Text(action == CardAction.play ? 'Play' : 'Put it up'),
|
||||||
),
|
|
||||||
CardAction.playOffline => FilledButton.tonalIcon(
|
|
||||||
onPressed: null,
|
|
||||||
icon: const Icon(Icons.play_arrow, size: 18),
|
|
||||||
label: const Text('Play'),
|
|
||||||
),
|
),
|
||||||
CardAction.request || CardAction.requestSeries => OutlinedButton.icon(
|
CardAction.request || CardAction.requestSeries => OutlinedButton.icon(
|
||||||
onPressed: enabled ? onAction : null,
|
onPressed: enabled ? onAction : null,
|
||||||
@@ -184,27 +261,12 @@ class AiringCard extends StatelessWidget {
|
|||||||
return disabledHint == null ? b : Tooltip(message: disabledHint!, child: b);
|
return disabledHint == null ? b : Tooltip(message: disabledHint!, child: b);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget? _follow(BuildContext context, double size) {
|
|
||||||
final f = onFollow;
|
|
||||||
if (f == null) return null;
|
|
||||||
return IconButton(
|
|
||||||
key: ValueKey('follow-$_id'),
|
|
||||||
onPressed: f,
|
|
||||||
tooltip: followed ? 'Stop following' : 'Follow',
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
constraints: BoxConstraints.tightFor(width: size + 10, height: size + 10),
|
|
||||||
iconSize: size,
|
|
||||||
color: followed ? Theme.of(context).colorScheme.primary : null,
|
|
||||||
icon: Icon(followed ? Icons.bookmark : Icons.bookmark_outline),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final a = entry.airing;
|
final a = entry.airing;
|
||||||
final (label, color, icon) = statusBadge(entry, now);
|
final (label, color, icon) = statusBadge(entry, now);
|
||||||
|
final hint = statusHint(entry, now);
|
||||||
final time = clockText(a.airingAt);
|
final time = clockText(a.airingAt);
|
||||||
final tintColor = a.color ?? Colors.blueGrey;
|
final tintColor = a.color ?? Colors.blueGrey;
|
||||||
final tint = DecoratedBox(
|
final tint = DecoratedBox(
|
||||||
@@ -216,7 +278,11 @@ class AiringCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final tap = action == CardAction.putUp && disabledHint == null && !busy ? onAction : null;
|
final tap = (action == CardAction.putUp || action == CardAction.play) && disabledHint == null && !busy
|
||||||
|
? onAction
|
||||||
|
: null;
|
||||||
|
final onMenu = this.onMenu;
|
||||||
|
final menu = onMenu == null || busy ? null : () => onMenu(context);
|
||||||
if (compact) {
|
if (compact) {
|
||||||
final requestable = action == CardAction.request || action == CardAction.requestSeries;
|
final requestable = action == CardAction.request || action == CardAction.requestSeries;
|
||||||
return Material(
|
return Material(
|
||||||
@@ -226,13 +292,21 @@ class AiringCard extends StatelessWidget {
|
|||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
onTap: tap,
|
onTap: tap,
|
||||||
|
onSecondaryTap: menu,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(6),
|
padding: const EdgeInsets.all(6),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
child: SizedBox(width: 34, height: 48, child: _image(a.cover, tint)),
|
child: SizedBox(
|
||||||
|
width: 34,
|
||||||
|
height: 48,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [_dimmed(_image(a.cover, tint)), ..._watchMarks(small: true)],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -249,7 +323,6 @@ class AiringCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (followed && onFollow == null) Icon(Icons.bookmark, size: 14, color: scheme.primary),
|
|
||||||
if (requestable)
|
if (requestable)
|
||||||
Tooltip(
|
Tooltip(
|
||||||
message: disabledHint ?? (action == CardAction.request ? 'Request' : 'Request series'),
|
message: disabledHint ?? (action == CardAction.request ? 'Request' : 'Request series'),
|
||||||
@@ -263,7 +336,7 @@ class AiringCard extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
Tooltip(
|
Tooltip(
|
||||||
message: label,
|
message: hint == null ? label : '$label: $hint',
|
||||||
child: Icon(icon, size: 16, color: color),
|
child: Icon(icon, size: 16, color: color),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -273,7 +346,6 @@ class AiringCard extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
final button = _button(context);
|
final button = _button(context);
|
||||||
final follow = _follow(context, 16);
|
|
||||||
// The cover poster leads: it is what people recognise at a glance.
|
// The cover poster leads: it is what people recognise at a glance.
|
||||||
return Material(
|
return Material(
|
||||||
key: ValueKey('card-$_id'),
|
key: ValueKey('card-$_id'),
|
||||||
@@ -282,6 +354,7 @@ class AiringCard extends StatelessWidget {
|
|||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: tap,
|
onTap: tap,
|
||||||
|
onSecondaryTap: menu,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -298,7 +371,7 @@ class AiringCard extends StatelessWidget {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
_image(a.cover, tint),
|
_dimmed(_image(a.cover, tint)),
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 4,
|
left: 4,
|
||||||
top: 4,
|
top: 4,
|
||||||
@@ -314,6 +387,7 @@ class AiringCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
..._watchMarks(),
|
||||||
if (entry.status == ScheduleStatus.downloading)
|
if (entry.status == ScheduleStatus.downloading)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
@@ -343,27 +417,15 @@ class AiringCard extends StatelessWidget {
|
|||||||
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.25),
|
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.25),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Row(
|
Text(
|
||||||
children: [
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
'Episode ${a.episode}',
|
'Episode ${a.episode}',
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(width: 2),
|
|
||||||
if (follow != null)
|
|
||||||
follow
|
|
||||||
else if (followed)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 4),
|
|
||||||
child: Icon(Icons.bookmark, size: 14, color: scheme.primary),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
|
_hinted(
|
||||||
|
hint,
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 14, color: color),
|
Icon(icon, size: 14, color: color),
|
||||||
@@ -378,6 +440,7 @@ class AiringCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../../jellyfin/accounts.dart';
|
|||||||
import '../../screens/home/picked_media.dart';
|
import '../../screens/home/picked_media.dart';
|
||||||
import '../../shell/avatar.dart';
|
import '../../shell/avatar.dart';
|
||||||
import '../../shell/motion.dart';
|
import '../../shell/motion.dart';
|
||||||
|
import '../../widgets/app_menu.dart';
|
||||||
import '../anilist.dart';
|
import '../anilist.dart';
|
||||||
import '../animeflow.dart';
|
import '../animeflow.dart';
|
||||||
import '../schedule_model.dart';
|
import '../schedule_model.dart';
|
||||||
@@ -15,17 +16,23 @@ import 'schedule_card.dart';
|
|||||||
|
|
||||||
// The Schedule tab: one column per day from last week's Monday to four
|
// The Schedule tab: one column per day from last week's Monday to four
|
||||||
// weeks out (AniList), each airing with what AnimeFlow has of it, and the
|
// weeks out (AniList), each airing with what AnimeFlow has of it, and the
|
||||||
// one thing to do about it (put it up, request it). Refreshes every
|
// one thing to do about it (put it up, request it; right-click an
|
||||||
// minute while visible; ETags keep that cheap.
|
// available one to queue it). Refreshes every minute while visible; ETags
|
||||||
|
// keep that cheap. Episodes on Jellyfin show this user's watched state,
|
||||||
|
// reloaded after a playback stops (JellyfinAccounts.watched).
|
||||||
|
|
||||||
enum ScheduleFilter { jellyfin, following, all }
|
enum ScheduleFilter { jellyfin, all }
|
||||||
|
|
||||||
class SchedulePage extends StatefulWidget {
|
class SchedulePage extends StatefulWidget {
|
||||||
/// In a server: available episodes can be put up.
|
/// Nobody else watches (offline or solo): available episodes say Play
|
||||||
final bool online;
|
/// (offline that starts a solo session).
|
||||||
|
final bool solo;
|
||||||
|
|
||||||
/// Puts a Jellyfin episode up in the server; returns an error to show.
|
/// Puts a Jellyfin episode up in the server; returns an error to show.
|
||||||
final Future<String?> Function(JellyfinMedia pick) onPutUp;
|
final Future<String?> Function(JellyfinMedia pick) onPutUp;
|
||||||
|
|
||||||
|
/// Adds an episode to Up next; null: no server, the cards have no menu.
|
||||||
|
final AddToQueue? onAddToQueue;
|
||||||
final void Function(String message) onToast;
|
final void Function(String message) onToast;
|
||||||
|
|
||||||
/// Null = the page makes (and closes) its own.
|
/// Null = the page makes (and closes) its own.
|
||||||
@@ -36,8 +43,9 @@ class SchedulePage extends StatefulWidget {
|
|||||||
|
|
||||||
const SchedulePage({
|
const SchedulePage({
|
||||||
super.key,
|
super.key,
|
||||||
required this.online,
|
this.solo = false,
|
||||||
required this.onPutUp,
|
required this.onPutUp,
|
||||||
|
this.onAddToQueue,
|
||||||
required this.onToast,
|
required this.onToast,
|
||||||
this.repository,
|
this.repository,
|
||||||
this.accounts,
|
this.accounts,
|
||||||
@@ -63,6 +71,7 @@ class _SchedulePageState extends State<SchedulePage> {
|
|||||||
bool _refreshing = false;
|
bool _refreshing = false;
|
||||||
int _gen = 0;
|
int _gen = 0;
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
|
StreamSubscription<String>? _watchedSub;
|
||||||
final _scroll = ScrollController();
|
final _scroll = ScrollController();
|
||||||
bool _jumped = false;
|
bool _jumped = false;
|
||||||
|
|
||||||
@@ -74,9 +83,6 @@ class _SchedulePageState extends State<SchedulePage> {
|
|||||||
/// The sheet stays built while it slides out.
|
/// The sheet stays built while it slides out.
|
||||||
bool _sheetBuilt = false;
|
bool _sheetBuilt = false;
|
||||||
|
|
||||||
/// Follow toggles not yet confirmed by a reload (series id -> followed).
|
|
||||||
final _followOverride = <int, bool>{};
|
|
||||||
|
|
||||||
/// Requested in this run: "anilistId#episode" or "series:anilistId".
|
/// Requested in this run: "anilistId#episode" or "series:anilistId".
|
||||||
final _requested = <String>{};
|
final _requested = <String>{};
|
||||||
|
|
||||||
@@ -96,21 +102,31 @@ class _SchedulePageState extends State<SchedulePage> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_ownsRepo = widget.repository == null;
|
_ownsRepo = widget.repository == null;
|
||||||
_repo =
|
_repo = widget.repository ?? _ownRepo();
|
||||||
widget.repository ??
|
|
||||||
ScheduleRepository(
|
|
||||||
aniList: AniListClient(),
|
|
||||||
animeFlow: AnimeFlowClient.fromAccounts(JellyfinAccounts.instance),
|
|
||||||
);
|
|
||||||
_load();
|
_load();
|
||||||
_timer = Timer.periodic(SchedulePage.refreshEvery, (_) {
|
_timer = Timer.periodic(SchedulePage.refreshEvery, (_) {
|
||||||
if (mounted && !_refreshing && TickerMode.valuesOf(context).enabled) _load();
|
if (mounted && !_refreshing && TickerMode.valuesOf(context).enabled) _load();
|
||||||
});
|
});
|
||||||
|
// Back from watching: the cards' watched marks follow (the stop report
|
||||||
|
// already dropped the cached episode lists).
|
||||||
|
_watchedSub = _accounts.watched.listen((_) {
|
||||||
|
if (mounted) _load();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ScheduleRepository _ownRepo() {
|
||||||
|
final flow = AnimeFlowClient.fromAccounts(_accounts);
|
||||||
|
return ScheduleRepository(
|
||||||
|
aniList: AniListClient(),
|
||||||
|
animeFlow: flow,
|
||||||
|
jellyfinEpisodes: (id) => jellyfinScheduleEpisodes(_accounts, flow, id),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
|
_watchedSub?.cancel();
|
||||||
_scroll.dispose();
|
_scroll.dispose();
|
||||||
if (_ownsRepo) {
|
if (_ownsRepo) {
|
||||||
_repo.aniList.close();
|
_repo.aniList.close();
|
||||||
@@ -185,11 +201,8 @@ class _SchedulePageState extends State<SchedulePage> {
|
|||||||
animate ? _scroll.animateTo(x, duration: easeTime, curve: ease) : _scroll.jumpTo(x);
|
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) {
|
bool _shows(ScheduleEntry e) => switch (_filter) {
|
||||||
ScheduleFilter.jellyfin => e.tracked || e.onJellyfin,
|
ScheduleFilter.jellyfin => e.tracked || e.onJellyfin,
|
||||||
ScheduleFilter.following => _followed(e),
|
|
||||||
ScheduleFilter.all => _niche || e.tracked || e.airing.popularity >= SchedulePage.nicheBelow,
|
ScheduleFilter.all => _niche || e.tracked || e.airing.popularity >= SchedulePage.nicheBelow,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -215,16 +228,17 @@ class _SchedulePageState extends State<SchedulePage> {
|
|||||||
|
|
||||||
(CardAction, String?) _actionFor(ScheduleEntry e) {
|
(CardAction, String?) _actionFor(ScheduleEntry e) {
|
||||||
if (e.status == ScheduleStatus.available || e.status == ScheduleStatus.inLibrary) {
|
if (e.status == ScheduleStatus.available || e.status == ScheduleStatus.inLibrary) {
|
||||||
if (!widget.online) return (CardAction.playOffline, 'Join or host a server to put it up');
|
final put = widget.solo ? CardAction.play : CardAction.putUp;
|
||||||
if (e.jellyfinSeriesId == null) return (CardAction.putUp, 'Not linked to a Jellyfin series yet');
|
if (e.jellyfinSeriesId == null) return (put, 'Not linked to a Jellyfin series yet');
|
||||||
return (CardAction.putUp, null);
|
return (put, null);
|
||||||
}
|
}
|
||||||
final me = _me;
|
final me = _me;
|
||||||
final CardAction kind;
|
final CardAction kind;
|
||||||
if (e.tracked) {
|
if (e.tracked) {
|
||||||
kind = e.status == ScheduleStatus.missing || e.status == ScheduleStatus.failed
|
// Only an unmonitored episode is worth asking for: AnimeFlow grabs
|
||||||
? CardAction.request
|
// a missing one (no release yet) by itself once a release appears,
|
||||||
: CardAction.none;
|
// and retries a failed one.
|
||||||
|
kind = e.status == ScheduleStatus.unmonitored ? CardAction.request : CardAction.none;
|
||||||
} else {
|
} else {
|
||||||
// On Jellyfin already (owner added it by hand): nothing to request.
|
// On Jellyfin already (owner added it by hand): nothing to request.
|
||||||
kind = me != null && !e.onJellyfin ? CardAction.requestSeries : CardAction.none;
|
kind = me != null && !e.onJellyfin ? CardAction.requestSeries : CardAction.none;
|
||||||
@@ -242,6 +256,7 @@ class _SchedulePageState extends State<SchedulePage> {
|
|||||||
Future<void> _onAction(ScheduleEntry e, CardAction action) async {
|
Future<void> _onAction(ScheduleEntry e, CardAction action) async {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case CardAction.putUp:
|
case CardAction.putUp:
|
||||||
|
case CardAction.play:
|
||||||
return _putUp(e);
|
return _putUp(e);
|
||||||
case CardAction.request:
|
case CardAction.request:
|
||||||
case CardAction.requestSeries:
|
case CardAction.requestSeries:
|
||||||
@@ -266,6 +281,32 @@ class _SchedulePageState extends State<SchedulePage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Right-click on an available episode ([context]: its card): Put it
|
||||||
|
/// up, Add to queue, Play next.
|
||||||
|
Future<void> _menu(BuildContext context, ScheduleEntry e) async {
|
||||||
|
final add = widget.onAddToQueue;
|
||||||
|
if (add == null) return;
|
||||||
|
final pick = await showAppMenu<int>(context, items: [
|
||||||
|
AppMenuItem(value: 0, label: widget.solo ? 'Play' : 'Put it up'),
|
||||||
|
const AppMenuItem(value: 1, label: 'Add to queue'),
|
||||||
|
const AppMenuItem(value: 2, label: 'Play next'),
|
||||||
|
]);
|
||||||
|
if (pick == null || !mounted) return;
|
||||||
|
if (pick == 0) return _putUp(e);
|
||||||
|
final key = 'play:${_key(e)}';
|
||||||
|
if (_busy.contains(key)) return;
|
||||||
|
setState(() => _busy.add(key));
|
||||||
|
try {
|
||||||
|
final media = await resolveJellyfinEpisode(_accounts, _flow, e);
|
||||||
|
final err = await add(media, next: pick == 2);
|
||||||
|
if (err != null) _toast(err);
|
||||||
|
} catch (err) {
|
||||||
|
_toast(friendlyError(err));
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busy.remove(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _request(ScheduleEntry e) async {
|
Future<void> _request(ScheduleEntry e) async {
|
||||||
final c = _flow;
|
final c = _flow;
|
||||||
if (c == null) return;
|
if (c == null) return;
|
||||||
@@ -335,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 {
|
Future<void> _decide(AnimeFlowRequest r, {required bool approve}) async {
|
||||||
final c = _flow;
|
final c = _flow;
|
||||||
if (c == null || _reqBusy.contains(r.id)) return;
|
if (c == null || _reqBusy.contains(r.id)) return;
|
||||||
@@ -387,11 +413,6 @@ class _SchedulePageState extends State<SchedulePage> {
|
|||||||
icon: Icon(Icons.video_library_outlined),
|
icon: Icon(Icons.video_library_outlined),
|
||||||
label: Text('On Jellyfin'),
|
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')),
|
ButtonSegment(value: ScheduleFilter.all, icon: Icon(Icons.public), label: Text('All airing')),
|
||||||
],
|
],
|
||||||
selected: {_filter},
|
selected: {_filter},
|
||||||
@@ -501,7 +522,6 @@ class _SchedulePageState extends State<SchedulePage> {
|
|||||||
(byDay[DateTime(l.year, l.month, l.day)] ??= []).add(e);
|
(byDay[DateTime(l.year, l.month, l.day)] ??= []).add(e);
|
||||||
}
|
}
|
||||||
return HScroll(
|
return HScroll(
|
||||||
wheel: false,
|
|
||||||
controller: _scroll,
|
controller: _scroll,
|
||||||
builder: (c) => ListView.separated(
|
builder: (c) => ListView.separated(
|
||||||
key: const ValueKey('schedule-board'),
|
key: const ValueKey('schedule-board'),
|
||||||
@@ -531,17 +551,18 @@ class _SchedulePageState extends State<SchedulePage> {
|
|||||||
|
|
||||||
Widget _card(ScheduleEntry e, DateTime now) {
|
Widget _card(ScheduleEntry e, DateTime now) {
|
||||||
final (action, hint) = _actionFor(e);
|
final (action, hint) = _actionFor(e);
|
||||||
final busy = _busy.contains(action == CardAction.putUp ? 'play:${_key(e)}' : _key(e));
|
final busy = _busy.contains(action == CardAction.putUp || action == CardAction.play ? 'play:${_key(e)}' : _key(e));
|
||||||
return AiringCard(
|
return AiringCard(
|
||||||
entry: e,
|
entry: e,
|
||||||
now: now,
|
now: now,
|
||||||
compact: _compact,
|
compact: _compact,
|
||||||
followed: _followed(e),
|
|
||||||
action: action,
|
action: action,
|
||||||
disabledHint: hint,
|
disabledHint: hint,
|
||||||
busy: busy,
|
busy: busy,
|
||||||
onAction: () => _onAction(e, action),
|
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,920 +0,0 @@
|
|||||||
part of 'shell_demo.dart';
|
|
||||||
|
|
||||||
// DEBUG ONLY: the Files source for real. Folders you add are scanned for
|
|
||||||
// videos (everything else is counted and hidden), grouped into series by
|
|
||||||
// a small anitomy-style name parser; a click plays the file in the demo
|
|
||||||
// player. Kept in memory for the demo session.
|
|
||||||
|
|
||||||
const _videoExt = {
|
|
||||||
'mkv',
|
|
||||||
'mp4',
|
|
||||||
'webm',
|
|
||||||
'avi',
|
|
||||||
'mov',
|
|
||||||
'm4v',
|
|
||||||
'ts',
|
|
||||||
'm2ts',
|
|
||||||
'wmv',
|
|
||||||
'flv',
|
|
||||||
};
|
|
||||||
const _subExt = {'ass', 'ssa', 'srt', 'vtt', 'sup'};
|
|
||||||
|
|
||||||
class _VideoFile {
|
|
||||||
final String path;
|
|
||||||
final int size;
|
|
||||||
final _ParsedName parsed;
|
|
||||||
const _VideoFile(this.path, this.size, this.parsed);
|
|
||||||
String get name => _baseName(path);
|
|
||||||
String get folder => _baseName(File(path).parent.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _FolderScan {
|
|
||||||
final List<_VideoFile> videos;
|
|
||||||
final int hidden;
|
|
||||||
final int subtitles;
|
|
||||||
final List<String> hiddenExamples;
|
|
||||||
final bool truncated;
|
|
||||||
const _FolderScan(
|
|
||||||
this.videos,
|
|
||||||
this.hidden,
|
|
||||||
this.subtitles,
|
|
||||||
this.hiddenExamples,
|
|
||||||
this.truncated,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final _folders = ValueNotifier<List<String>>([]);
|
|
||||||
final _scans = <String, _FolderScan>{};
|
|
||||||
final _recentFiles = ValueNotifier<List<_VideoFile>>([]);
|
|
||||||
|
|
||||||
/// What a release file name says: series, season, episode, group.
|
|
||||||
class _ParsedName {
|
|
||||||
final String series;
|
|
||||||
final int? season;
|
|
||||||
final int? episode;
|
|
||||||
final String? group;
|
|
||||||
const _ParsedName(this.series, {this.season, this.episode, this.group});
|
|
||||||
|
|
||||||
String get label {
|
|
||||||
if (episode == null) return series;
|
|
||||||
final ep = 'E${episode.toString().padLeft(2, '0')}';
|
|
||||||
return season == null ? ep : 'S${season.toString().padLeft(2, '0')}$ep';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_ParsedName _parseName(String file) {
|
|
||||||
var s = file;
|
|
||||||
final dot = s.lastIndexOf('.');
|
|
||||||
if (dot > 0) s = s.substring(0, dot);
|
|
||||||
final group = RegExp(r'^\s*\[([^\]]+)\]').firstMatch(s)?.group(1);
|
|
||||||
// Brackets/parens hold group, resolution, codec, CRC.
|
|
||||||
s = s.replaceAll(RegExp(r'\[[^\]]*\]|\([^)]*\)'), ' ');
|
|
||||||
// Dots/underscores as separators (scene style), unless it has spaces.
|
|
||||||
if (!s.contains(' ') || RegExp(r'\w\.\w').allMatches(s).length > 2) {
|
|
||||||
s = s.replaceAll(RegExp(r'[._]'), ' ');
|
|
||||||
}
|
|
||||||
int? season, episode;
|
|
||||||
var cut = s.length;
|
|
||||||
final se = RegExp(
|
|
||||||
r'\bS(\d{1,2})\s?E(\d{1,4})\b',
|
|
||||||
caseSensitive: false,
|
|
||||||
).firstMatch(s);
|
|
||||||
if (se != null) {
|
|
||||||
season = int.parse(se.group(1)!);
|
|
||||||
episode = int.parse(se.group(2)!);
|
|
||||||
cut = se.start;
|
|
||||||
} else {
|
|
||||||
final dash = RegExp(r'\s-\s(\d{1,4})(?:v\d)?\b').firstMatch(s);
|
|
||||||
final ep = RegExp(
|
|
||||||
r'\b(?:E|EP|Episode)\s?(\d{1,4})\b',
|
|
||||||
caseSensitive: false,
|
|
||||||
).firstMatch(s);
|
|
||||||
final m = dash ?? ep;
|
|
||||||
if (m != null) {
|
|
||||||
episode = int.parse(m.group(1)!);
|
|
||||||
cut = m.start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var series = s.substring(0, cut);
|
|
||||||
// Season markers inside the title ("Title S2", "Title 2nd Season").
|
|
||||||
final sInTitle = RegExp(
|
|
||||||
r'\s(?:S(\d{1,2})|(\d)(?:st|nd|rd|th) Season)\s*$',
|
|
||||||
caseSensitive: false,
|
|
||||||
).firstMatch(series);
|
|
||||||
if (sInTitle != null && season == null) {
|
|
||||||
season = int.parse(sInTitle.group(1) ?? sInTitle.group(2)!);
|
|
||||||
}
|
|
||||||
// Release junk after the title.
|
|
||||||
series = series
|
|
||||||
.replaceAll(
|
|
||||||
RegExp(
|
|
||||||
r'\b(1080p|720p|2160p|480p|x26[45]|HEVC|WEB(-?DL|Rip)?|BD(Rip)?|BluRay|AAC|FLAC|10bit)\b.*',
|
|
||||||
caseSensitive: false,
|
|
||||||
),
|
|
||||||
'',
|
|
||||||
)
|
|
||||||
.replaceAll(RegExp(r'\s+'), ' ')
|
|
||||||
.replaceAll(RegExp(r'[\s\-–:]+$'), '')
|
|
||||||
.trim();
|
|
||||||
if (series.isEmpty) series = file;
|
|
||||||
return _ParsedName(series, season: season, episode: episode, group: group);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<_FolderScan> _scanFolder(String dir) async {
|
|
||||||
final videos = <_VideoFile>[];
|
|
||||||
var hidden = 0, subs = 0;
|
|
||||||
final examples = <String>[];
|
|
||||||
var seen = 0;
|
|
||||||
var truncated = false;
|
|
||||||
try {
|
|
||||||
await for (final e in Directory(
|
|
||||||
dir,
|
|
||||||
).list(recursive: true, followLinks: false)) {
|
|
||||||
if (e is! File) continue;
|
|
||||||
if (++seen > 20000) {
|
|
||||||
truncated = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
final name = e.path.split(Platform.pathSeparator).last;
|
|
||||||
if (name.startsWith('.')) continue;
|
|
||||||
final ext = name.contains('.') ? name.split('.').last.toLowerCase() : '';
|
|
||||||
if (_subExt.contains(ext)) {
|
|
||||||
subs++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
final isVideo = _videoExt.contains(ext);
|
|
||||||
final lower = name.toLowerCase();
|
|
||||||
int size = 0;
|
|
||||||
try {
|
|
||||||
size = await e.length();
|
|
||||||
} catch (_) {}
|
|
||||||
// Samples and trailers next to the real thing.
|
|
||||||
final junk =
|
|
||||||
isVideo &&
|
|
||||||
(lower.contains('sample') || lower.contains('trailer')) &&
|
|
||||||
size < 300 << 20;
|
|
||||||
if (!isVideo || junk) {
|
|
||||||
hidden++;
|
|
||||||
if (examples.length < 6) examples.add(name);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
videos.add(_VideoFile(e.path, size, _parseName(name)));
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// Unreadable subfolders: show what was found.
|
|
||||||
}
|
|
||||||
return _FolderScan(videos, hidden, subs, examples, truncated);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _size(int b) => b >= 1 << 30
|
|
||||||
? '${(b / (1 << 30)).toStringAsFixed(1)} GB'
|
|
||||||
: '${(b / (1 << 20)).toStringAsFixed(0)} MB';
|
|
||||||
|
|
||||||
class _RealFilesTab extends StatefulWidget {
|
|
||||||
final bool online;
|
|
||||||
final VoidCallback onPlay;
|
|
||||||
const _RealFilesTab({required this.online, required this.onPlay});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_RealFilesTab> createState() => _RealFilesTabState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RealFilesTabState extends State<_RealFilesTab> {
|
|
||||||
String? _open; // null = Recent
|
|
||||||
bool _scanning = false;
|
|
||||||
bool _showOther = false;
|
|
||||||
|
|
||||||
Future<void> _addFolder() async {
|
|
||||||
String? dir;
|
|
||||||
try {
|
|
||||||
dir = await FilePicker.getDirectoryPath(
|
|
||||||
dialogTitle: 'Add a folder with videos',
|
|
||||||
);
|
|
||||||
} catch (_) {}
|
|
||||||
if (dir == null || _folders.value.contains(dir)) return;
|
|
||||||
_folders.value = [..._folders.value, dir];
|
|
||||||
await _select(dir, rescan: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _select(String? dir, {bool rescan = false}) async {
|
|
||||||
setState(() {
|
|
||||||
_open = dir;
|
|
||||||
_showOther = false;
|
|
||||||
});
|
|
||||||
if (dir == null || (_scans.containsKey(dir) && !rescan)) return;
|
|
||||||
setState(() => _scanning = true);
|
|
||||||
final scan = await _scanFolder(dir);
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_scans[dir] = scan;
|
|
||||||
_scanning = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _browse() async {
|
|
||||||
List<PlatformFile> files = const [];
|
|
||||||
try {
|
|
||||||
files = await FilePicker.pickFiles(
|
|
||||||
dialogTitle: 'Pick a video',
|
|
||||||
type: FileType.custom,
|
|
||||||
allowedExtensions: _videoExt.toList(),
|
|
||||||
);
|
|
||||||
} catch (_) {}
|
|
||||||
final path = files.isEmpty ? null : files.first.path;
|
|
||||||
if (path == null) return;
|
|
||||||
final f = File(path);
|
|
||||||
await _play(
|
|
||||||
_VideoFile(path, await f.length(), _parseName(f.uri.pathSegments.last)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _play(_VideoFile v) async {
|
|
||||||
_recentFiles.value = [
|
|
||||||
v,
|
|
||||||
..._recentFiles.value.where((x) => x.path != v.path),
|
|
||||||
].take(30).toList();
|
|
||||||
// Structured names read as "Series · S01E03"; anything else as the
|
|
||||||
// cleaned name and the folder it is in.
|
|
||||||
await _playMedia(
|
|
||||||
_DemoMedia(
|
|
||||||
v.parsed.series,
|
|
||||||
v.parsed.episode == null ? v.folder : v.parsed.label,
|
|
||||||
v.path,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
widget.onPlay();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, box) {
|
|
||||||
final narrow = box.maxWidth < 700;
|
|
||||||
final list = _list(context, narrow);
|
|
||||||
if (narrow) return list;
|
|
||||||
return Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
SizedBox(width: 260, child: _sidebar(context)),
|
|
||||||
Expanded(child: list),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _sidebar(BuildContext context) {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
return ValueListenableBuilder(
|
|
||||||
valueListenable: _folders,
|
|
||||||
builder: (context, folders, _) => ListView(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 20, 8, 20),
|
|
||||||
children: [
|
|
||||||
const Text('Folders', style: TextStyle(fontWeight: FontWeight.w600)),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
ListTile(
|
|
||||||
dense: true,
|
|
||||||
selected: _open == null,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
leading: const Icon(Icons.history, size: 18),
|
|
||||||
title: const Text('Recent'),
|
|
||||||
onTap: () => _select(null),
|
|
||||||
),
|
|
||||||
for (final f in folders)
|
|
||||||
ListTile(
|
|
||||||
dense: true,
|
|
||||||
selected: _open == f,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
leading: const Icon(Icons.folder_outlined, size: 18),
|
|
||||||
title: Text(
|
|
||||||
_baseName(f),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
_scans[f] == null
|
|
||||||
? 'scanning…'
|
|
||||||
: '${_scans[f]!.videos.length} videos',
|
|
||||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11),
|
|
||||||
),
|
|
||||||
trailing: IconButton(
|
|
||||||
tooltip: 'Remove folder',
|
|
||||||
icon: const Icon(Icons.close, size: 16),
|
|
||||||
onPressed: () {
|
|
||||||
_folders.value = [...folders]..remove(f);
|
|
||||||
_scans.remove(f);
|
|
||||||
if (_open == f) _select(null);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
onTap: () => _select(f),
|
|
||||||
),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: _addFolder,
|
|
||||||
icon: const Icon(Icons.add, size: 18),
|
|
||||||
label: const Text('Add folder'),
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: _browse,
|
|
||||||
icon: const Icon(Icons.folder_open, size: 18),
|
|
||||||
label: const Text('Browse…'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
const Text(
|
|
||||||
'The system file picker, for anything outside your folders.',
|
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 11),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _list(BuildContext context, bool narrow) {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
final open = _open;
|
|
||||||
final scan = open == null ? null : _scans[open];
|
|
||||||
final children = <Widget>[
|
|
||||||
// Where you are: Recent, or the folder and its path.
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 14),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
open == null ? Icons.history : Icons.folder,
|
|
||||||
color: scheme.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
open == null ? 'Recent' : _baseName(open),
|
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
|
||||||
),
|
|
||||||
if (open != null)
|
|
||||||
Text(
|
|
||||||
open,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(
|
|
||||||
color: scheme.onSurfaceVariant,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (narrow)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 14),
|
|
||||||
child: ValueListenableBuilder(
|
|
||||||
valueListenable: _folders,
|
|
||||||
builder: (context, folders, _) => Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: [
|
|
||||||
ChoiceChip(
|
|
||||||
label: const Text('Recent'),
|
|
||||||
selected: open == null,
|
|
||||||
onSelected: (_) => _select(null),
|
|
||||||
),
|
|
||||||
for (final f in folders)
|
|
||||||
ChoiceChip(
|
|
||||||
label: Text(_baseName(f)),
|
|
||||||
selected: open == f,
|
|
||||||
onSelected: (_) => _select(f),
|
|
||||||
),
|
|
||||||
ActionChip(
|
|
||||||
avatar: const Icon(Icons.add, size: 16),
|
|
||||||
label: const Text('Folder'),
|
|
||||||
onPressed: _addFolder,
|
|
||||||
),
|
|
||||||
ActionChip(
|
|
||||||
avatar: const Icon(Icons.folder_open, size: 16),
|
|
||||||
label: const Text('Browse…'),
|
|
||||||
onPressed: _browse,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.online)
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(10),
|
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.lightBlueAccent.withValues(alpha: 0.08),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
child: const Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.cell_tower, size: 18, color: Colors.lightBlueAccent),
|
|
||||||
SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
'Files stream from this computer through the server. Keep the app open while it plays.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
if (open == null) {
|
|
||||||
children.add(
|
|
||||||
ValueListenableBuilder(
|
|
||||||
valueListenable: _recentFiles,
|
|
||||||
builder: (context, recent, _) => recent.isEmpty
|
|
||||||
? _empty(
|
|
||||||
context,
|
|
||||||
Icons.video_library_outlined,
|
|
||||||
'Nothing played yet',
|
|
||||||
'Add a folder with your videos, or Browse for a single file.',
|
|
||||||
action: FilledButton.icon(
|
|
||||||
onPressed: _addFolder,
|
|
||||||
icon: const Icon(Icons.create_new_folder_outlined),
|
|
||||||
label: const Text('Add folder'),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Column(
|
|
||||||
children: [
|
|
||||||
for (final v in recent)
|
|
||||||
_episodeTile(context, v, showSeries: true),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (_scanning || scan == null) {
|
|
||||||
children.add(
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.only(top: 60),
|
|
||||||
child: Center(child: CircularProgressIndicator()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
final groups = <String, List<_VideoFile>>{};
|
|
||||||
for (final v in scan.videos) {
|
|
||||||
groups.putIfAbsent(v.parsed.series, () => []).add(v);
|
|
||||||
}
|
|
||||||
final names = groups.keys.toList()
|
|
||||||
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
|
|
||||||
for (final g in groups.values) {
|
|
||||||
g.sort((a, b) {
|
|
||||||
final s = (a.parsed.season ?? 0).compareTo(b.parsed.season ?? 0);
|
|
||||||
if (s != 0) return s;
|
|
||||||
final e = (a.parsed.episode ?? 0).compareTo(b.parsed.episode ?? 0);
|
|
||||||
return e != 0 ? e : a.name.compareTo(b.name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
children.add(
|
|
||||||
Wrap(
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.filter_alt_outlined,
|
|
||||||
size: 16,
|
|
||||||
color: scheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
'${scan.videos.length} videos in ${groups.length} series'
|
|
||||||
'${scan.hidden > 0 ? ' · ${scan.hidden} other files hidden' : ''}'
|
|
||||||
'${scan.subtitles > 0 ? ' · ${scan.subtitles} subtitle files' : ''}'
|
|
||||||
'${scan.truncated ? ' · stopped after 20 000 files' : ''}',
|
|
||||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
|
||||||
),
|
|
||||||
if (scan.hidden > 0) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => setState(() => _showOther = !_showOther),
|
|
||||||
child: Text(_showOther ? 'Hide them' : 'Show some'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () => _select(open, rescan: true),
|
|
||||||
icon: const Icon(Icons.refresh, size: 16),
|
|
||||||
label: const Text('Rescan'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
children.add(
|
|
||||||
AnimatedSize(
|
|
||||||
duration: _easeTime,
|
|
||||||
curve: _ease,
|
|
||||||
alignment: Alignment.topLeft,
|
|
||||||
child: !_showOther
|
|
||||||
? const SizedBox(width: double.infinity)
|
|
||||||
: Column(
|
|
||||||
children: [
|
|
||||||
for (final f in scan.hiddenExamples)
|
|
||||||
ListTile(
|
|
||||||
dense: true,
|
|
||||||
enabled: false,
|
|
||||||
leading: const Icon(
|
|
||||||
Icons.insert_drive_file_outlined,
|
|
||||||
size: 18,
|
|
||||||
),
|
|
||||||
title: Text(f),
|
|
||||||
trailing: const Text(
|
|
||||||
'not a video',
|
|
||||||
style: TextStyle(fontSize: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (scan.videos.isEmpty) {
|
|
||||||
children.add(
|
|
||||||
_empty(
|
|
||||||
context,
|
|
||||||
Icons.search_off,
|
|
||||||
'No videos here',
|
|
||||||
'Only video files are listed (mkv, mp4, webm…).',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Series: files whose names carry an episode number. Everything
|
|
||||||
// else is a loose video, shown as a thumbnail grid.
|
|
||||||
final loose = <_VideoFile>[];
|
|
||||||
for (final name in names) {
|
|
||||||
final eps = groups[name]!;
|
|
||||||
if (eps.every((e) => e.parsed.episode == null)) {
|
|
||||||
loose.addAll(eps);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
children.add(
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 18, bottom: 4),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: _Thumb(eps.first.path, width: 96, height: 54),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
name,
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'${eps.length} ${eps.length == 1 ? 'episode' : 'episodes'}'
|
|
||||||
'${eps.first.parsed.group == null ? '' : ' · ${eps.first.parsed.group}'}',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.grey,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
for (final v in eps) {
|
|
||||||
children.add(_episodeTile(context, v));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (loose.isNotEmpty) {
|
|
||||||
children.add(
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 22, bottom: 10),
|
|
||||||
child: Text(
|
|
||||||
names.length == loose.length ? 'Videos' : 'Other videos',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
children.add(
|
|
||||||
LayoutBuilder(
|
|
||||||
builder: (context, box) {
|
|
||||||
const gap = 12.0;
|
|
||||||
final cols = (box.maxWidth / 240).floor().clamp(2, 8);
|
|
||||||
final w = (box.maxWidth - gap * (cols - 1)) / cols;
|
|
||||||
return Wrap(
|
|
||||||
spacing: gap,
|
|
||||||
runSpacing: gap,
|
|
||||||
children: [
|
|
||||||
for (final v in loose)
|
|
||||||
SizedBox(width: w, child: _videoCard(context, v)),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ListView(
|
|
||||||
padding: EdgeInsets.fromLTRB(narrow ? 20 : 12, 20, 20, 20),
|
|
||||||
children: children,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _episodeTile(
|
|
||||||
BuildContext context,
|
|
||||||
_VideoFile v, {
|
|
||||||
bool showSeries = false,
|
|
||||||
}) {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
return ValueListenableBuilder(
|
|
||||||
valueListenable: _nowPlaying,
|
|
||||||
builder: (context, now, _) {
|
|
||||||
final playing = now?.path == v.path;
|
|
||||||
return ListTile(
|
|
||||||
dense: true,
|
|
||||||
contentPadding: EdgeInsets.only(left: showSeries ? 0 : 20),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
selected: playing,
|
|
||||||
leading: SizedBox(
|
|
||||||
width: 112,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
child: _Thumb(v.path, width: 80, height: 45),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Icon(
|
|
||||||
playing ? Icons.graphic_eq : Icons.play_circle_outline,
|
|
||||||
size: 20,
|
|
||||||
color: scheme.primary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
minLeadingWidth: 112,
|
|
||||||
title: Text(
|
|
||||||
showSeries
|
|
||||||
? '${v.parsed.series} · ${v.parsed.label}'
|
|
||||||
: v.parsed.label,
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
showSeries && v.parsed.episode == null ? v.folder : v.name,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
trailing: Text(
|
|
||||||
_size(v.size),
|
|
||||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
|
||||||
),
|
|
||||||
onTap: () => _play(v),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _videoCard(BuildContext context, _VideoFile v) {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
return ValueListenableBuilder(
|
|
||||||
valueListenable: _nowPlaying,
|
|
||||||
builder: (context, now, _) {
|
|
||||||
final playing = now?.path == v.path;
|
|
||||||
return InkWell(
|
|
||||||
onTap: () => _play(v),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
AspectRatio(
|
|
||||||
aspectRatio: 16 / 9,
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(
|
|
||||||
color: playing ? scheme.primary : Colors.transparent,
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
child: Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
|
||||||
_Thumb(v.path),
|
|
||||||
Positioned(
|
|
||||||
right: 6,
|
|
||||||
bottom: 6,
|
|
||||||
child: _DurationBadge(v.path),
|
|
||||||
),
|
|
||||||
if (playing)
|
|
||||||
const Center(
|
|
||||||
child: Icon(
|
|
||||||
Icons.graphic_eq,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 32,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
|
||||||
v.parsed.series,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: playing ? scheme.primary : null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
_size(v.size),
|
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _empty(
|
|
||||||
BuildContext context,
|
|
||||||
IconData icon,
|
|
||||||
String title,
|
|
||||||
String sub, {
|
|
||||||
Widget? action,
|
|
||||||
}) => Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 60),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
size: 44,
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(sub, style: const TextStyle(color: Colors.grey)),
|
|
||||||
if (action != null) ...[const SizedBox(height: 14), action],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _baseName(String path) {
|
|
||||||
final parts = path
|
|
||||||
.split(RegExp(r'[/\\]'))
|
|
||||||
.where((p) => p.isNotEmpty)
|
|
||||||
.toList();
|
|
||||||
return parts.isEmpty ? path : parts.last;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------ thumbnails
|
|
||||||
//
|
|
||||||
// Demo: ffmpeg/ffprobe when installed (a frame at 25%, cached in the temp
|
|
||||||
// dir). The real app would ask mpv for the frame instead (no extra tool).
|
|
||||||
|
|
||||||
class _ThumbInfo {
|
|
||||||
final File? image;
|
|
||||||
final Duration? duration;
|
|
||||||
const _ThumbInfo(this.image, this.duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
final _thumbs = <String, Future<_ThumbInfo>>{};
|
|
||||||
var _thumbRunning = 0;
|
|
||||||
final _thumbQueue = <Completer<void>>[];
|
|
||||||
|
|
||||||
Future<_ThumbInfo> _thumbFor(String path) =>
|
|
||||||
_thumbs.putIfAbsent(path, () async {
|
|
||||||
// At most 3 ffmpeg runs at a time.
|
|
||||||
if (_thumbRunning >= 3) {
|
|
||||||
final c = Completer<void>();
|
|
||||||
_thumbQueue.add(c);
|
|
||||||
await c.future;
|
|
||||||
}
|
|
||||||
_thumbRunning++;
|
|
||||||
try {
|
|
||||||
return await _makeThumb(path);
|
|
||||||
} finally {
|
|
||||||
_thumbRunning--;
|
|
||||||
if (_thumbQueue.isNotEmpty) _thumbQueue.removeAt(0).complete();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Future<_ThumbInfo> _makeThumb(String path) async {
|
|
||||||
Duration? dur;
|
|
||||||
try {
|
|
||||||
final r = await Process.run('ffprobe', [
|
|
||||||
'-v',
|
|
||||||
'error',
|
|
||||||
'-show_entries',
|
|
||||||
'format=duration',
|
|
||||||
'-of',
|
|
||||||
'csv=p=0',
|
|
||||||
path,
|
|
||||||
]);
|
|
||||||
final secs = double.tryParse('${r.stdout}'.trim());
|
|
||||||
if (secs != null) dur = Duration(milliseconds: (secs * 1000).round());
|
|
||||||
} catch (_) {
|
|
||||||
return const _ThumbInfo(null, null); // no ffmpeg on this machine
|
|
||||||
}
|
|
||||||
final dir = Directory('${Directory.systemTemp.path}/wt-demo-thumbs')
|
|
||||||
..createSync(recursive: true);
|
|
||||||
final key = path.hashCode.toUnsigned(32).toRadixString(16);
|
|
||||||
final out = File('${dir.path}/$key.jpg');
|
|
||||||
if (out.existsSync() && out.lengthSync() > 0) return _ThumbInfo(out, dur);
|
|
||||||
final at = dur == null ? 5.0 : dur.inMilliseconds / 1000 * 0.25;
|
|
||||||
try {
|
|
||||||
final r = await Process.run('ffmpeg', [
|
|
||||||
'-v',
|
|
||||||
'error',
|
|
||||||
'-y',
|
|
||||||
'-ss',
|
|
||||||
at.toStringAsFixed(2),
|
|
||||||
'-i',
|
|
||||||
path,
|
|
||||||
'-frames:v',
|
|
||||||
'1',
|
|
||||||
'-vf',
|
|
||||||
'scale=480:-2',
|
|
||||||
'-q:v',
|
|
||||||
'4',
|
|
||||||
out.path,
|
|
||||||
]);
|
|
||||||
if (r.exitCode == 0 && out.existsSync()) return _ThumbInfo(out, dur);
|
|
||||||
} catch (_) {}
|
|
||||||
return _ThumbInfo(null, dur);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _Thumb extends StatelessWidget {
|
|
||||||
final String path;
|
|
||||||
final double? width, height;
|
|
||||||
const _Thumb(this.path, {this.width, this.height});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
final placeholder = Container(
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
color: scheme.surfaceContainerHighest,
|
|
||||||
child: Icon(
|
|
||||||
Icons.movie_outlined,
|
|
||||||
color: scheme.onSurfaceVariant,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return FutureBuilder<_ThumbInfo>(
|
|
||||||
future: _thumbFor(path),
|
|
||||||
builder: (context, snap) {
|
|
||||||
final img = snap.data?.image;
|
|
||||||
return AnimatedSwitcher(
|
|
||||||
duration: _easeTime,
|
|
||||||
child: img == null
|
|
||||||
? placeholder
|
|
||||||
: Image.file(
|
|
||||||
img,
|
|
||||||
key: ValueKey(img.path),
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DurationBadge extends StatelessWidget {
|
|
||||||
final String path;
|
|
||||||
const _DurationBadge(this.path);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => FutureBuilder<_ThumbInfo>(
|
|
||||||
future: _thumbFor(path),
|
|
||||||
builder: (context, snap) {
|
|
||||||
final d = snap.data?.duration;
|
|
||||||
if (d == null) return const SizedBox.shrink();
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black87,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
_fmt(d),
|
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 11),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,577 +0,0 @@
|
|||||||
part of 'shell_demo.dart';
|
|
||||||
|
|
||||||
// DEBUG ONLY: a real media_kit player for the layout demo. One Player and
|
|
||||||
// VideoController, shown by whichever view is active (mini, full or
|
|
||||||
// fullscreen); only one Video widget is mounted at a time.
|
|
||||||
|
|
||||||
class _DemoMedia {
|
|
||||||
final String title;
|
|
||||||
final String subtitle;
|
|
||||||
final String? path;
|
|
||||||
const _DemoMedia(this.title, this.subtitle, [this.path]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What is playing; null = nothing.
|
|
||||||
final _nowPlaying = ValueNotifier<_DemoMedia?>(null);
|
|
||||||
Player? _demoPlayer;
|
|
||||||
VideoController? _demoVideo;
|
|
||||||
|
|
||||||
Future<void> _playMedia(_DemoMedia m) async {
|
|
||||||
_nowPlaying.value = m;
|
|
||||||
if (m.path == null) return;
|
|
||||||
final p = _demoPlayer ??= Player();
|
|
||||||
_demoVideo ??= VideoController(p);
|
|
||||||
await p.open(Media(m.path!));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _disposeDemoPlayer() async {
|
|
||||||
final p = _demoPlayer;
|
|
||||||
_demoPlayer = null;
|
|
||||||
_demoVideo = null;
|
|
||||||
_nowPlaying.value = null;
|
|
||||||
await p?.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
String _fmt(Duration d) {
|
|
||||||
final h = d.inHours, m = d.inMinutes % 60, s = d.inSeconds % 60;
|
|
||||||
String two(int n) => n.toString().padLeft(2, '0');
|
|
||||||
return h > 0 ? '$h:${two(m)}:${two(s)}' : '${two(m)}:${two(s)}';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The video itself, or a placeholder when nothing real is open.
|
|
||||||
class _VideoSurface extends StatelessWidget {
|
|
||||||
final double placeholderSize;
|
|
||||||
const _VideoSurface({this.placeholderSize = 32});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final v = _demoVideo;
|
|
||||||
if (v == null || _nowPlaying.value?.path == null) {
|
|
||||||
return Container(
|
|
||||||
color: Colors.black,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Text(
|
|
||||||
'video · play something from Files',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white24,
|
|
||||||
fontSize: placeholderSize * 0.6,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Video(controller: v, controls: NoVideoControls, fill: Colors.black);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Position bar + play/pause + time, bound to the real player (inert
|
|
||||||
/// when nothing is open).
|
|
||||||
class _Transport extends StatelessWidget {
|
|
||||||
final bool compact;
|
|
||||||
final List<Widget> trailing;
|
|
||||||
const _Transport({this.compact = false, this.trailing = const []});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final p = _demoPlayer;
|
|
||||||
if (p == null) {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
const IconButton(
|
|
||||||
onPressed: null,
|
|
||||||
icon: Icon(Icons.play_arrow, color: Colors.white38),
|
|
||||||
),
|
|
||||||
const Text('--:-- / --:--', style: TextStyle(color: Colors.white38)),
|
|
||||||
const Spacer(),
|
|
||||||
...trailing,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return StreamBuilder<Duration>(
|
|
||||||
stream: p.stream.position,
|
|
||||||
builder: (context, _) {
|
|
||||||
final pos = p.state.position, dur = p.state.duration;
|
|
||||||
final frac = dur.inMilliseconds == 0
|
|
||||||
? 0.0
|
|
||||||
: pos.inMilliseconds / dur.inMilliseconds;
|
|
||||||
final bar = SliderTheme(
|
|
||||||
data: SliderTheme.of(context).copyWith(
|
|
||||||
trackHeight: compact ? 3 : 4,
|
|
||||||
thumbShape: RoundSliderThumbShape(
|
|
||||||
enabledThumbRadius: compact ? 0 : 7,
|
|
||||||
),
|
|
||||||
overlayShape: SliderComponentShape.noOverlay,
|
|
||||||
),
|
|
||||||
child: Slider(
|
|
||||||
value: frac.clamp(0.0, 1.0),
|
|
||||||
onChanged: dur == Duration.zero ? null : (v) => p.seek(dur * v),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final row = Row(
|
|
||||||
children: [
|
|
||||||
StreamBuilder<bool>(
|
|
||||||
stream: p.stream.playing,
|
|
||||||
builder: (context, _) => IconButton(
|
|
||||||
onPressed: p.playOrPause,
|
|
||||||
icon: Icon(
|
|
||||||
p.state.playing ? Icons.pause : Icons.play_arrow,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'${_fmt(pos)} / ${_fmt(dur)}',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white70,
|
|
||||||
fontSize: compact ? 12 : 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
...trailing,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [if (!compact) bar, row, if (compact) bar],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Full and fullscreen player. Controls fade out after 2.5 s without the
|
|
||||||
/// mouse moving; in fullscreen the party sits top-right and toasts stay.
|
|
||||||
class _RealFullPlayer extends StatefulWidget {
|
|
||||||
final bool fullscreen;
|
|
||||||
final VoidCallback onCollapse, onToggleFullscreen;
|
|
||||||
final Widget? partyOverlay;
|
|
||||||
const _RealFullPlayer({
|
|
||||||
required this.fullscreen,
|
|
||||||
required this.onCollapse,
|
|
||||||
required this.onToggleFullscreen,
|
|
||||||
this.partyOverlay,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_RealFullPlayer> createState() => _RealFullPlayerState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RealFullPlayerState extends State<_RealFullPlayer> {
|
|
||||||
bool _shown = true;
|
|
||||||
Timer? _hide;
|
|
||||||
|
|
||||||
void _poke() {
|
|
||||||
if (!_shown) setState(() => _shown = true);
|
|
||||||
_hide?.cancel();
|
|
||||||
_hide = Timer(const Duration(milliseconds: 2500), () {
|
|
||||||
if (mounted) setState(() => _shown = false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_poke();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_hide?.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final m = _nowPlaying.value;
|
|
||||||
final controls = AnimatedOpacity(
|
|
||||||
duration: _easeTime,
|
|
||||||
curve: _ease,
|
|
||||||
opacity: _shown ? 1 : 0,
|
|
||||||
child: IgnorePointer(
|
|
||||||
ignoring: !_shown,
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
Positioned(
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 28),
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [Colors.black87, Colors.transparent],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 34,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white12,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
m?.title ?? 'Nothing playing',
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
m?.subtitle ?? '',
|
|
||||||
style: const TextStyle(color: Colors.white60),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.partyOverlay != null) ...[
|
|
||||||
widget.partyOverlay!,
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
],
|
|
||||||
if (!widget.fullscreen)
|
|
||||||
IconButton(
|
|
||||||
onPressed: widget.onCollapse,
|
|
||||||
tooltip: 'Back to the library (keeps playing)',
|
|
||||||
icon: const Icon(
|
|
||||||
Icons.close_fullscreen,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 32, 16, 10),
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.bottomCenter,
|
|
||||||
end: Alignment.topCenter,
|
|
||||||
colors: [Colors.black87, Colors.transparent],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: _Transport(
|
|
||||||
trailing: [
|
|
||||||
Builder(
|
|
||||||
builder: (context) => IconButton(
|
|
||||||
tooltip: 'Player settings',
|
|
||||||
onPressed: () => _popover(
|
|
||||||
context,
|
|
||||||
at: const Alignment(0.97, 0.78),
|
|
||||||
child: const _PlayerSettingsCard(),
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.tune, color: Colors.white),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: widget.fullscreen
|
|
||||||
? 'Exit fullscreen (Esc)'
|
|
||||||
: 'Fullscreen (F)',
|
|
||||||
onPressed: widget.onToggleFullscreen,
|
|
||||||
icon: Icon(
|
|
||||||
widget.fullscreen
|
|
||||||
? Icons.fullscreen_exit
|
|
||||||
: Icons.fullscreen,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return CallbackShortcuts(
|
|
||||||
bindings: {
|
|
||||||
const SingleActivator(LogicalKeyboardKey.keyF):
|
|
||||||
widget.onToggleFullscreen,
|
|
||||||
const SingleActivator(LogicalKeyboardKey.escape): () {
|
|
||||||
if (widget.fullscreen) widget.onToggleFullscreen();
|
|
||||||
},
|
|
||||||
const SingleActivator(LogicalKeyboardKey.space): () =>
|
|
||||||
_demoPlayer?.playOrPause(),
|
|
||||||
},
|
|
||||||
child: Focus(
|
|
||||||
autofocus: true,
|
|
||||||
child: MouseRegion(
|
|
||||||
onHover: (_) => _poke(),
|
|
||||||
cursor: _shown ? MouseCursor.defer : SystemMouseCursors.none,
|
|
||||||
child: GestureDetector(
|
|
||||||
onDoubleTap: widget.onToggleFullscreen,
|
|
||||||
onTap: () => _demoPlayer?.playOrPause(),
|
|
||||||
child: Container(
|
|
||||||
color: Colors.black,
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
const Positioned.fill(child: _VideoSurface()),
|
|
||||||
Positioned.fill(child: controls),
|
|
||||||
// A thin progress line stays when the controls hide.
|
|
||||||
if (!_shown && _demoPlayer != null)
|
|
||||||
const Positioned(
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
child: _ThinProgress(),
|
|
||||||
),
|
|
||||||
// Paused: who everyone is waiting for, always visible.
|
|
||||||
Positioned(
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: _shown ? 96 : 28,
|
|
||||||
child: const Center(child: _WaitingPill()),
|
|
||||||
),
|
|
||||||
// Fullscreen with the controls hidden: the party stays
|
|
||||||
// in the corner while paused.
|
|
||||||
if (widget.fullscreen &&
|
|
||||||
!_shown &&
|
|
||||||
widget.partyOverlay != null)
|
|
||||||
Positioned(
|
|
||||||
right: 16,
|
|
||||||
top: 14,
|
|
||||||
child: _PausedOnly(child: widget.partyOverlay!),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ThinProgress extends StatelessWidget {
|
|
||||||
const _ThinProgress();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final p = _demoPlayer!;
|
|
||||||
return StreamBuilder<Duration>(
|
|
||||||
stream: p.stream.position,
|
|
||||||
builder: (context, _) {
|
|
||||||
final d = p.state.duration.inMilliseconds;
|
|
||||||
return LinearProgressIndicator(
|
|
||||||
value: d == 0 ? 0 : p.state.position.inMilliseconds / d,
|
|
||||||
minHeight: 2,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The gear in the player: everything about the player lives here.
|
|
||||||
class _PlayerSettingsCard extends StatefulWidget {
|
|
||||||
const _PlayerSettingsCard();
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_PlayerSettingsCard> createState() => _PlayerSettingsCardState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _PlayerSettingsCardState extends State<_PlayerSettingsCard> {
|
|
||||||
String _design = 'Standard';
|
|
||||||
bool _line = true;
|
|
||||||
String _shader = 'Off';
|
|
||||||
String _hwdec = 'Auto';
|
|
||||||
double _subSize = 1;
|
|
||||||
|
|
||||||
Widget _row(String label, Widget control, {String? help}) => Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(label),
|
|
||||||
if (help != null)
|
|
||||||
Text(
|
|
||||||
help,
|
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 11),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
control,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _drop(String value, List<String> items, ValueChanged<String> on) =>
|
|
||||||
DropdownButton<String>(
|
|
||||||
value: value,
|
|
||||||
underline: const SizedBox.shrink(),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
items: [
|
|
||||||
for (final i in items) DropdownMenuItem(value: i, child: Text(i)),
|
|
||||||
],
|
|
||||||
onChanged: (v) => setState(() => on(v!)),
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Material(
|
|
||||||
elevation: 12,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: SizedBox(
|
|
||||||
width: 380,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 10),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Player',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 16),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
_row(
|
|
||||||
'Player design',
|
|
||||||
_drop(_design, const ['Standard', 'uosc'], (v) => _design = v),
|
|
||||||
),
|
|
||||||
_row(
|
|
||||||
'Progress line when hidden',
|
|
||||||
Switch(
|
|
||||||
value: _line,
|
|
||||||
onChanged: (v) => setState(() => _line = v),
|
|
||||||
),
|
|
||||||
help: 'A thin bar at the bottom while the controls are hidden',
|
|
||||||
),
|
|
||||||
_row(
|
|
||||||
'Upscaling shader',
|
|
||||||
_drop(_shader, const [
|
|
||||||
'Off',
|
|
||||||
'Anime4K A (HQ)',
|
|
||||||
'Anime4K B (HQ)',
|
|
||||||
'Anime4K A (Fast)',
|
|
||||||
], (v) => _shader = v),
|
|
||||||
help: 'Runs on your GPU; only you see it',
|
|
||||||
),
|
|
||||||
_row(
|
|
||||||
'Hardware decoding',
|
|
||||||
_drop(_hwdec, const ['Auto', 'On', 'Off'], (v) => _hwdec = v),
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
_row(
|
|
||||||
'Subtitles',
|
|
||||||
_drop('English · ASS', const [
|
|
||||||
'Off',
|
|
||||||
'English · ASS',
|
|
||||||
'Signs only',
|
|
||||||
], (_) {}),
|
|
||||||
),
|
|
||||||
_row(
|
|
||||||
'Subtitle size',
|
|
||||||
SizedBox(
|
|
||||||
width: 140,
|
|
||||||
child: Slider(
|
|
||||||
value: _subSize,
|
|
||||||
min: 0.6,
|
|
||||||
max: 1.6,
|
|
||||||
onChanged: (v) => setState(() => _subSize = v),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
_row(
|
|
||||||
'Audio',
|
|
||||||
_drop('Japanese', const ['Japanese', 'English'], (_) {}),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
const Text(
|
|
||||||
'Track choices carry to the next episode.',
|
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 11),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shows [child] only while the demo player is paused (or has nothing).
|
|
||||||
class _PausedOnly extends StatelessWidget {
|
|
||||||
final Widget child;
|
|
||||||
const _PausedOnly({required this.child});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final p = _demoPlayer;
|
|
||||||
return StreamBuilder<bool>(
|
|
||||||
stream: p?.stream.playing,
|
|
||||||
builder: (context, _) {
|
|
||||||
final paused = p == null || !p.state.playing;
|
|
||||||
return AnimatedOpacity(
|
|
||||||
duration: _easeTime,
|
|
||||||
curve: _ease,
|
|
||||||
opacity: paused ? 1 : 0,
|
|
||||||
child: IgnorePointer(ignoring: !paused, child: child),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// "Waiting for Alex, Mika" or "Waiting for you · press Space", with
|
|
||||||
/// their avatars; only while paused.
|
|
||||||
class _WaitingPill extends StatelessWidget {
|
|
||||||
const _WaitingPill();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final notReady = _members.where((m) => !m.ready && !m.stale).toList();
|
|
||||||
final meWaiting = !_me.value.ready;
|
|
||||||
final text = meWaiting
|
|
||||||
? 'Waiting for you · press Space when ready'
|
|
||||||
: notReady.isEmpty
|
|
||||||
? 'Paused'
|
|
||||||
: 'Waiting for ${notReady.map((m) => m.name).join(', ')}';
|
|
||||||
return _PausedOnly(
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.fromLTRB(8, 6, 16, 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black.withValues(alpha: 0.72),
|
|
||||||
borderRadius: BorderRadius.circular(30),
|
|
||||||
border: Border.all(color: Colors.white12),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
for (final m in (meWaiting ? [_me.value] : notReady).take(4))
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 4),
|
|
||||||
child: _Avatar(member: m, size: 28),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
text,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
part of 'shell_demo.dart';
|
|
||||||
|
|
||||||
// DEBUG ONLY: the Settings tab. App-wide things only; everything about
|
|
||||||
// the player lives behind the gear in the player (pointer card at the top
|
|
||||||
// of every page). Controls are placeholders that hold their own state.
|
|
||||||
|
|
||||||
class _SettingsTab extends StatefulWidget {
|
|
||||||
final VoidCallback onOpenPlayerSettings;
|
|
||||||
const _SettingsTab({required this.onOpenPlayerSettings});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_SettingsTab> createState() => _SettingsTabState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SettingsTabState extends State<_SettingsTab> {
|
|
||||||
static const _sections = [
|
|
||||||
(Icons.person_outline, 'Profile'),
|
|
||||||
(Icons.hub_outlined, 'Party & network'),
|
|
||||||
(Icons.extension_outlined, 'Jellyfin & integrations'),
|
|
||||||
(Icons.notifications_none, 'Notifications & sounds'),
|
|
||||||
(Icons.palette_outlined, 'Appearance'),
|
|
||||||
(Icons.system_update_alt, 'Updates'),
|
|
||||||
(Icons.info_outline, 'About'),
|
|
||||||
];
|
|
||||||
int _at = 0;
|
|
||||||
final _bools = <String, bool>{
|
|
||||||
'toast-chat': true,
|
|
||||||
'toast-ready': true,
|
|
||||||
'toast-seek': false,
|
|
||||||
'unfocused': true,
|
|
||||||
'sounds': true,
|
|
||||||
'reduce-motion': false,
|
|
||||||
'auto-update': true,
|
|
||||||
'auto-next': true,
|
|
||||||
'remember-folders': true,
|
|
||||||
};
|
|
||||||
|
|
||||||
Widget _switch(String key, String title, [String? help]) => SwitchListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: Text(title),
|
|
||||||
subtitle: help == null ? null : Text(help),
|
|
||||||
value: _bools[key]!,
|
|
||||||
onChanged: (v) => setState(() => _bools[key] = v),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Material, not a coloured box: list tiles paint their ripple on it.
|
|
||||||
Widget _card(String title, List<Widget> children) => Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
|
||||||
child: Material(
|
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 10),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(title, style: const TextStyle(fontWeight: FontWeight.w700)),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
...children,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
List<Widget> _page() {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
switch (_at) {
|
|
||||||
case 0:
|
|
||||||
return [
|
|
||||||
_card('You', [
|
|
||||||
ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
leading: _Avatar(member: _me.value, size: 48, badges: false),
|
|
||||||
title: Text(_me.value.name),
|
|
||||||
subtitle: const Text(
|
|
||||||
'Name, picture and colour: click your avatar in the top left',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
case 1:
|
|
||||||
return [
|
|
||||||
_card('Hosting', [
|
|
||||||
const ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: Text('Default port'),
|
|
||||||
trailing: Text('8080'),
|
|
||||||
),
|
|
||||||
const ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: Text('Password'),
|
|
||||||
subtitle: Text(
|
|
||||||
'Asked when you host; blank = anyone who reaches the port can join',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: Text('Relay buffer'),
|
|
||||||
subtitle: Text('Memory the server keeps for relayed videos'),
|
|
||||||
trailing: Text('256 MB ahead · 64 MB back'),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
_card('Recent servers', [
|
|
||||||
for (final s in [
|
|
||||||
'VPS · vps.example:8080',
|
|
||||||
'Bob · 192.168.1.20:8080',
|
|
||||||
])
|
|
||||||
ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
leading: const Icon(Icons.dns_outlined),
|
|
||||||
title: Text(s),
|
|
||||||
trailing: IconButton(
|
|
||||||
onPressed: () {},
|
|
||||||
icon: const Icon(Icons.close, size: 18),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
case 2:
|
|
||||||
return [
|
|
||||||
_card('Jellyfin', [
|
|
||||||
for (final (name, user) in [
|
|
||||||
('Senko Jellyfin', 'foxyz'),
|
|
||||||
('Bob\'s box', 'guest'),
|
|
||||||
])
|
|
||||||
ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
leading: const Icon(Icons.video_library_outlined),
|
|
||||||
title: Text(name),
|
|
||||||
subtitle: Text('signed in as $user'),
|
|
||||||
trailing: TextButton(
|
|
||||||
onPressed: () {},
|
|
||||||
child: const Text('Sign out'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {},
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: const Text('Add a Jellyfin server'),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
_card('AnimeFlow', [
|
|
||||||
ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
leading: const Icon(Icons.downloading_outlined),
|
|
||||||
title: const Text('anime.senkochan.moe'),
|
|
||||||
subtitle: const Text(
|
|
||||||
'Uses your Senko Jellyfin sign-in · can view, request, approve',
|
|
||||||
),
|
|
||||||
trailing: _Pill('connected', Colors.greenAccent.shade400),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'Powers the Schedule tab: what is downloaded, downloading or missing, and requests.',
|
|
||||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
_card('Local files', [
|
|
||||||
_switch(
|
|
||||||
'remember-folders',
|
|
||||||
'Remember folders',
|
|
||||||
'Folders added in Files stay between launches',
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
case 3:
|
|
||||||
return [
|
|
||||||
_card('Pop-ups', [
|
|
||||||
_switch('toast-chat', 'Chat messages'),
|
|
||||||
_switch('toast-ready', 'Ready / joined / left'),
|
|
||||||
_switch(
|
|
||||||
'toast-seek',
|
|
||||||
'Seeks and skips',
|
|
||||||
'Always in the activity log',
|
|
||||||
),
|
|
||||||
_switch(
|
|
||||||
'unfocused',
|
|
||||||
'Desktop notification when the app is in the background',
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
_card('Sounds', [
|
|
||||||
_switch(
|
|
||||||
'sounds',
|
|
||||||
'Play sounds',
|
|
||||||
'Soft cues for ready, join and chat; seeks stay silent',
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
case 4:
|
|
||||||
return [
|
|
||||||
_card('Look', [
|
|
||||||
const ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: Text('Theme'),
|
|
||||||
trailing: Text('Dark'),
|
|
||||||
),
|
|
||||||
_switch(
|
|
||||||
'reduce-motion',
|
|
||||||
'Reduce motion',
|
|
||||||
'Fades instead of slides, no spreading avatars',
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
_card('Playback', [
|
|
||||||
_switch(
|
|
||||||
'auto-next',
|
|
||||||
'Play the next episode automatically',
|
|
||||||
'10 s countdown at the end',
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
case 5:
|
|
||||||
return [
|
|
||||||
_card('Updates', [
|
|
||||||
ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: const Text('Watch together 0.2.2'),
|
|
||||||
subtitle: const Text('0.3.0 is available'),
|
|
||||||
trailing: FilledButton(
|
|
||||||
onPressed: () {},
|
|
||||||
child: const Text('Update and restart'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
_switch('auto-update', 'Check for updates on launch'),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
default:
|
|
||||||
return [
|
|
||||||
_card('About', [
|
|
||||||
const Text(
|
|
||||||
'Watch together for friends: synced playback, Jellyfin, relay.',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'mpv · media_kit · Flutter',
|
|
||||||
style: TextStyle(color: scheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
final pointer = Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: scheme.primary.withValues(alpha: 0.08),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.tune, color: scheme.primary),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
const Expanded(
|
|
||||||
child: Text(
|
|
||||||
'Player design, shaders, subtitles and audio live in the player: the ⚙ in its controls.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: widget.onOpenPlayerSettings,
|
|
||||||
child: const Text('Open player settings'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, box) {
|
|
||||||
final narrow = box.maxWidth < 760;
|
|
||||||
final nav = narrow
|
|
||||||
? SizedBox(
|
|
||||||
height: 52,
|
|
||||||
child: _HScroll(
|
|
||||||
builder: (c) => ListView(
|
|
||||||
controller: c,
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
|
|
||||||
children: [
|
|
||||||
for (var i = 0; i < _sections.length; i++)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 8),
|
|
||||||
child: ChoiceChip(
|
|
||||||
label: Text(_sections[i].$2),
|
|
||||||
selected: _at == i,
|
|
||||||
onSelected: (_) => setState(() => _at = i),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: SizedBox(
|
|
||||||
width: 250,
|
|
||||||
child: ListView(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 20, 8, 20),
|
|
||||||
children: [
|
|
||||||
for (var i = 0; i < _sections.length; i++)
|
|
||||||
ListTile(
|
|
||||||
selected: _at == i,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
leading: Icon(_sections[i].$1, size: 20),
|
|
||||||
title: Text(_sections[i].$2),
|
|
||||||
onTap: () => setState(() => _at = i),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final page = _fadeSwitch(
|
|
||||||
KeyedSubtree(
|
|
||||||
key: ValueKey(_at),
|
|
||||||
child: ListView(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 20, 24, 24),
|
|
||||||
children: [
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.topLeft,
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 760),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
_sections[_at].$2,
|
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
pointer,
|
|
||||||
..._page(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return narrow
|
|
||||||
? Column(
|
|
||||||
children: [
|
|
||||||
nav,
|
|
||||||
Expanded(child: page),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
: Row(
|
|
||||||
children: [
|
|
||||||
nav,
|
|
||||||
Expanded(child: page),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +1,23 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
import '../../platform/app_log.dart';
|
||||||
|
import '../../shell/mark_problem.dart';
|
||||||
|
import '../../state/session_state.dart';
|
||||||
|
import '../../state/settings.dart';
|
||||||
import '../../update/update_controller.dart';
|
import '../../update/update_controller.dart';
|
||||||
import '../../update/updater.dart';
|
import '../../widgets/app_menu.dart';
|
||||||
|
import '../../widgets/copy_open.dart';
|
||||||
import 'settings_page.dart';
|
import 'settings_page.dart';
|
||||||
import 'ui.dart';
|
import 'ui.dart';
|
||||||
import 'update_indicator.dart';
|
import 'update_indicator.dart';
|
||||||
|
|
||||||
/// Opens [url] in the default browser (no url_launcher dependency).
|
/// Name, version, update state, the source link and the logs (Settings →
|
||||||
Future<bool> openInBrowser(String url) async {
|
/// About).
|
||||||
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).
|
|
||||||
class AboutPanel extends StatelessWidget {
|
class AboutPanel extends StatelessWidget {
|
||||||
final UpdateController? updates;
|
final UpdateController? updates;
|
||||||
const AboutPanel({super.key, this.updates});
|
const AboutPanel({super.key, this.updates});
|
||||||
|
|
||||||
Future<void> _open(BuildContext context) async {
|
|
||||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
|
||||||
if (await openInBrowser(repoUrl)) return;
|
|
||||||
await Clipboard.setData(const ClipboardData(text: repoUrl));
|
|
||||||
messenger?.showSnackBar(
|
|
||||||
const SnackBar(content: Text('Could not open a browser — link copied')));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = Theme.of(context).textTheme;
|
final t = Theme.of(context).textTheme;
|
||||||
@@ -49,7 +28,7 @@ class AboutPanel extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(appName, style: t.titleLarge),
|
Text(appName, style: t.titleLarge),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
SelectableText('Version $appVersion',
|
SelectableText('Version ${displayVersion()}',
|
||||||
style: t.bodyMedium?.copyWith(color: Colors.grey)),
|
style: t.bodyMedium?.copyWith(color: Colors.grey)),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
UpdateStatusLine(controller: updates),
|
UpdateStatusLine(controller: updates),
|
||||||
@@ -58,20 +37,85 @@ class AboutPanel extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(child: SelectableText(repoUrl, style: t.bodySmall)),
|
Expanded(child: SelectableText(repoUrl, style: t.bodySmall)),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Copy link',
|
tooltip: ReuseText.copyLink,
|
||||||
icon: const Icon(Icons.copy, size: 18),
|
icon: const Icon(Icons.copy, size: 18),
|
||||||
onPressed: () =>
|
onPressed: () => copyToClipboard(repoUrl, done: 'Link copied'),
|
||||||
Clipboard.setData(const ClipboardData(text: repoUrl)),
|
|
||||||
),
|
),
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: () => _open(context),
|
onPressed: () => openLink(repoUrl),
|
||||||
icon: const Icon(Icons.open_in_new, size: 18),
|
icon: const Icon(Icons.open_in_new, size: 18),
|
||||||
label: const Text('Source'),
|
label: const Text('Source'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -23,10 +23,6 @@ class ConnectFlow extends ChangeNotifier {
|
|||||||
int _attempt = 0;
|
int _attempt = 0;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
|
||||||
/// Set when an attempt ends in a kick, so the shell shows the removal
|
|
||||||
/// dialog.
|
|
||||||
void Function(String reason)? onKicked;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
@@ -101,6 +97,7 @@ class ConnectFlow extends ChangeNotifier {
|
|||||||
_notify();
|
_notify();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final fromSolo = mode == ConnectMode.host && _session.solo.value;
|
||||||
try {
|
try {
|
||||||
if (mode == ConnectMode.host) {
|
if (mode == ConnectMode.host) {
|
||||||
await _session.host(params, form);
|
await _session.host(params, form);
|
||||||
@@ -110,20 +107,19 @@ class ConnectFlow extends ChangeNotifier {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (myAttempt != _attempt) return;
|
if (myAttempt != _attempt) return;
|
||||||
busy = null;
|
busy = null;
|
||||||
final kick = _session.coordinator.kickReason.peek();
|
|
||||||
if (kick != null && kick.isNotEmpty) {
|
|
||||||
onKicked?.call(kick);
|
|
||||||
} else {
|
|
||||||
errors[mode] = CardError('Could not connect', '$e');
|
errors[mode] = CardError('Could not connect', '$e');
|
||||||
}
|
|
||||||
_notify();
|
_notify();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (myAttempt != _attempt) {
|
if (myAttempt != _attempt) {
|
||||||
// Cancelled after it got through: back out.
|
// Cancelled after it got through: back out (an Invite: back to solo).
|
||||||
try {
|
try {
|
||||||
|
if (fromSolo) {
|
||||||
|
await _session.cancelInvite();
|
||||||
|
} else {
|
||||||
await _session.leave();
|
await _session.leave();
|
||||||
await _session.stopServer();
|
await _session.stopServer();
|
||||||
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ class MediaPickerController extends ChangeNotifier {
|
|||||||
final TextEditingController link;
|
final TextEditingController link;
|
||||||
final TextEditingController file;
|
final TextEditingController file;
|
||||||
MediaKind _source;
|
MediaKind _source;
|
||||||
LinkRelay _linkRelay = LinkRelay.direct;
|
// Through me unless picked otherwise (Library → Link).
|
||||||
|
LinkRelay _linkRelay = LinkRelay.me;
|
||||||
JellyfinMedia? _jellyfin;
|
JellyfinMedia? _jellyfin;
|
||||||
JellyfinSelection? _selected;
|
JellyfinSelection? _selected;
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ enum LinkRelay { direct, server, me }
|
|||||||
final class LinkMedia extends PickedMedia {
|
final class LinkMedia extends PickedMedia {
|
||||||
final String url;
|
final String url;
|
||||||
final LinkRelay relay;
|
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
|
@override
|
||||||
String get label {
|
String get label {
|
||||||
@@ -119,3 +122,7 @@ final class JellyfinMedia extends PickedMedia {
|
|||||||
@override
|
@override
|
||||||
String toString() => 'JellyfinMedia($serverId/$itemId "$title")';
|
String toString() => 'JellyfinMedia($serverId/$itemId "$title")';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adds a pick to Up next (with [next]: right after what plays); returns
|
||||||
|
/// an error text, or null when sent. Null where there is no server.
|
||||||
|
typedef AddToQueue = Future<String?> Function(PickedMedia media, {bool next});
|
||||||
@@ -7,11 +7,12 @@ import '../../update/update_controller.dart';
|
|||||||
import 'ui.dart';
|
import 'ui.dart';
|
||||||
import 'update_indicator.dart';
|
import 'update_indicator.dart';
|
||||||
|
|
||||||
/// Player keys, as handled by the session player's key handler (while
|
/// Session keys: Space and Tab anywhere in a server (`AppKeys`), the
|
||||||
/// the player fills the window).
|
/// rest by the session player's key handler (while it fills the window).
|
||||||
const sessionShortcuts = [
|
const sessionShortcuts = [
|
||||||
('Space', 'Toggle ready'),
|
('Space', 'Toggle ready'),
|
||||||
('← / →', 'Seek back / forward 10 s'),
|
('← / →', 'Seek back / forward 10 s'),
|
||||||
|
('↑ / ↓, wheel', 'Volume up / down (up to 150 %)'),
|
||||||
('Shift+→', 'Skip 1:30 (for everyone)'),
|
('Shift+→', 'Skip 1:30 (for everyone)'),
|
||||||
('PgUp / PgDn', 'Previous / next chapter'),
|
('PgUp / PgDn', 'Previous / next chapter'),
|
||||||
('F', 'Toggle fullscreen'),
|
('F', 'Toggle fullscreen'),
|
||||||
@@ -21,7 +22,8 @@ const sessionShortcuts = [
|
|||||||
('< or Shift+P', 'Put up the previous Jellyfin episode'),
|
('< or Shift+P', 'Put up the previous Jellyfin episode'),
|
||||||
('Ctrl+1…6', 'Shaders: Anime4K mode A, B, C, A+A, B+B, C+A'),
|
('Ctrl+1…6', 'Shaders: Anime4K mode A, B, C, A+A, B+B, C+A'),
|
||||||
('Ctrl+0', 'Shaders off'),
|
('Ctrl+0', 'Shaders off'),
|
||||||
('Esc', 'Close the panel or fullscreen, else ask to leave'),
|
('Esc', 'Close the panel or fullscreen, shrink the player, then ask to leave'),
|
||||||
|
('Ctrl+Shift+B', 'Mark a problem in the session log (anywhere)'),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// One titled card of the Settings tab (rounded Material, so list tiles
|
/// One titled card of the Settings tab (rounded Material, so list tiles
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart' show appVersion, isDevBuild;
|
||||||
|
|
||||||
/// Product name (working title). Change it here only.
|
/// Product name (working title). Change it here only.
|
||||||
const appName = 'Watch Together';
|
const appName = 'Starsync';
|
||||||
|
|
||||||
const repoUrl = 'https://git.senkochan.moe/Foxyz/sync_replay_project';
|
const repoUrl = 'https://git.senkochan.moe/Foxyz/sync_replay_project';
|
||||||
|
|
||||||
|
/// The pubspec's version: the Flutter tool defines it for every build and
|
||||||
|
/// run (`flutter run` too).
|
||||||
|
const _pubspecVersion = String.fromEnvironment('FLUTTER_BUILD_NAME');
|
||||||
|
|
||||||
|
/// The short commit a packaged build was made from (`tool/package_linux.sh`;
|
||||||
|
/// `-dirty` with uncommitted changes); empty otherwise.
|
||||||
|
const _gitCommit = String.fromEnvironment('GIT_COMMIT');
|
||||||
|
|
||||||
|
/// The version to show: a release's own ("1.1.2"), a test build's with its
|
||||||
|
/// commit ("1.1.3-dev.12 · 1a2b3c4"), and for a dev run (no `APP_VERSION`)
|
||||||
|
/// the pubspec's with "dev" ("1.1.2 · dev run").
|
||||||
|
String displayVersion({String version = appVersion, String pubspec = _pubspecVersion, String commit = _gitCommit}) {
|
||||||
|
if (version == '0.0.0-dev') return pubspec.isEmpty ? 'dev run' : '$pubspec · dev run';
|
||||||
|
return isDevBuild(version) && commit.isNotEmpty ? '$version · $commit' : version;
|
||||||
|
}
|
||||||
|
|
||||||
const kRadius = 12.0;
|
const kRadius = 12.0;
|
||||||
const kGap = 16.0;
|
const kGap = 16.0;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../../jellyfin/session_media.dart';
|
import '../../jellyfin/session_media.dart';
|
||||||
import '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs;
|
import '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs;
|
||||||
|
import '../../widgets/app_menu.dart' show AppGlass;
|
||||||
import 'player_controls.dart' show FloatingPill;
|
import 'player_controls.dart' show FloatingPill;
|
||||||
|
|
||||||
// Jellyfin pieces of the session player. Plain widgets fed by the
|
// Jellyfin pieces of the session player. Plain widgets fed by the
|
||||||
@@ -36,9 +37,8 @@ class EpisodeSkipButton extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Floating "Next episode ›" from the ED (or the last two minutes):
|
/// Floating "Next episode ›" from the ED (or the last two minutes), while
|
||||||
/// faint at rest, lit on hover. Lives outside the auto-hiding controls, so
|
/// the controls are up (`PillGate`): faint at rest, lit on hover.
|
||||||
/// it shows in fullscreen and with the controls hidden.
|
|
||||||
class NextEpisodePill extends StatelessWidget {
|
class NextEpisodePill extends StatelessWidget {
|
||||||
final EpisodeLink episode;
|
final EpisodeLink episode;
|
||||||
final VoidCallback onPressed;
|
final VoidCallback onPressed;
|
||||||
@@ -70,27 +70,36 @@ class EndOfEpisodeCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = Theme.of(context).textTheme;
|
|
||||||
final c = countdown;
|
final c = countdown;
|
||||||
return Card(
|
// The same card as Up next's (`UpNextCard`): the app's glass, 400 wide.
|
||||||
|
return AppGlass(
|
||||||
key: const ValueKey('end-of-episode'),
|
key: const ValueKey('end-of-episode'),
|
||||||
color: Colors.black.withValues(alpha: 0.85),
|
child: SizedBox(
|
||||||
|
width: 400,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 12, 12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 380),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text(c == null ? 'Up next' : 'Next episode in ${c.secondsLeft} s',
|
Text(c == null ? 'Up next' : 'Next episode in ${c.secondsLeft} s',
|
||||||
style: t.labelLarge?.copyWith(color: Colors.white70)),
|
style: const TextStyle(color: Colors.white60, fontSize: 12)),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 2),
|
||||||
Text('Next: ${next.label}',
|
Text('Next: ${next.label}',
|
||||||
maxLines: 2, overflow: TextOverflow.ellipsis, style: t.titleMedium),
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600, height: 1.25)),
|
||||||
if (c != null) ...[
|
if (c != null) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 10),
|
||||||
LinearProgressIndicator(value: c.secondsLeft / autoAdvanceSecs),
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
child: TweenAnimationBuilder<double>(
|
||||||
|
tween: Tween(end: c.secondsLeft / autoAdvanceSecs),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
builder: (context, v, _) =>
|
||||||
|
LinearProgressIndicator(value: v, minHeight: 3, backgroundColor: const Color(0x1FFFFFFF)),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Row(
|
Row(
|
||||||
@@ -166,25 +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 '../../jellyfin/track_prefs.dart' show MpvTrack;
|
||||||
import '../../playback/media_marks.dart';
|
import '../../playback/media_marks.dart';
|
||||||
|
import '../../widgets/app_menu.dart' show AppGlass;
|
||||||
|
|
||||||
// Session control-bar pieces built on the playback signals (chapters,
|
// Session control-bar pieces built on the playback signals (chapters,
|
||||||
// tracks, skip offers): the seek bar with chapter marks, the chapter and
|
// 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):
|
/// The player's action pill (Next episode, Skip OP / ED / 1:30): the
|
||||||
/// faint at rest, lit on hover. Lives outside the auto-hiding controls, so
|
/// status pill's glass ([AppGlass.pill]) at [height], faint at rest, lit
|
||||||
/// it shows in fullscreen and with the controls hidden.
|
/// 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 {
|
class FloatingPill extends StatefulWidget {
|
||||||
|
/// Every action pill's height: the status pill's without avatars.
|
||||||
|
static const height = 44.0;
|
||||||
final String label;
|
final String label;
|
||||||
final String tooltip;
|
final String tooltip;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
@@ -540,7 +544,7 @@ class FloatingPill extends StatefulWidget {
|
|||||||
required this.label,
|
required this.label,
|
||||||
required this.tooltip,
|
required this.tooltip,
|
||||||
required this.onPressed,
|
required this.onPressed,
|
||||||
this.icon = Icons.chevron_right,
|
this.icon = Icons.chevron_right_rounded,
|
||||||
this.tapKey,
|
this.tapKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -553,38 +557,31 @@ class _FloatingPillState extends State<FloatingPill> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final primary = Theme.of(context).colorScheme.primary;
|
const fade = Duration(milliseconds: 150);
|
||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
cursor: SystemMouseCursors.click,
|
cursor: SystemMouseCursors.click,
|
||||||
onEnter: (_) => setState(() => _hover = true),
|
onEnter: (_) => setState(() => _hover = true),
|
||||||
onExit: (_) => setState(() => _hover = false),
|
onExit: (_) => setState(() => _hover = false),
|
||||||
child: Tooltip(
|
child: Tooltip(
|
||||||
message: widget.tooltip,
|
message: widget.tooltip,
|
||||||
child: AnimatedScale(
|
|
||||||
duration: const Duration(milliseconds: 150),
|
|
||||||
scale: _hover ? 1.06 : 1,
|
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
duration: const Duration(milliseconds: 150),
|
duration: fade,
|
||||||
opacity: _hover ? 1 : 0.45,
|
opacity: _hover ? 1 : 0.45,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
key: widget.tapKey,
|
key: widget.tapKey,
|
||||||
onTap: widget.onPressed,
|
onTap: widget.onPressed,
|
||||||
|
child: AppGlass.pill(
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 150),
|
duration: fade,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
|
height: FloatingPill.height,
|
||||||
decoration: BoxDecoration(
|
padding: const EdgeInsets.only(left: 16, right: 12),
|
||||||
color: Colors.black.withValues(alpha: 0.75),
|
color: _hover ? Colors.white12 : Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
border: Border.all(color: _hover ? primary : Colors.white24),
|
|
||||||
boxShadow: _hover
|
|
||||||
? [BoxShadow(color: primary.withValues(alpha: 0.45), blurRadius: 16)]
|
|
||||||
: const [],
|
|
||||||
),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(widget.label, style: const TextStyle(color: Colors.white, fontSize: 13)),
|
Text(widget.label,
|
||||||
const SizedBox(width: 4),
|
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(width: 6),
|
||||||
Icon(widget.icon, color: Colors.white, size: 18),
|
Icon(widget.icon, color: Colors.white, size: 18),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:isolate';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../files/name_parser.dart';
|
import '../files/name_parser.dart';
|
||||||
import '../schedule/anilist.dart';
|
import '../schedule/anilist.dart';
|
||||||
|
|
||||||
// Art for media this client swapped in (`art`, see PROTOCOL.md "Media
|
// Art for media this client swapped in (`art`, see PROTOCOL.md "Media
|
||||||
// art"): Jellyfin images with this install's account, else an AniList
|
// art"): Jellyfin images with this install's account, a web link's
|
||||||
// title search. Everything here fails quietly to "no art".
|
// thumbnail, else an AniList title search. Everything here fails quietly
|
||||||
|
// to "no art".
|
||||||
|
|
||||||
/// Poster and banner bytes; either may be null.
|
/// Poster and banner bytes; either may be null.
|
||||||
typedef ArtBytes = ({Uint8List? poster, Uint8List? banner});
|
typedef ArtBytes = ({Uint8List? poster, Uint8List? banner});
|
||||||
@@ -51,7 +54,8 @@ Future<ArtBytes> jellyfinArt(JellyfinClient client, String itemId,
|
|||||||
Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
|
Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
|
||||||
{Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async {
|
{Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async {
|
||||||
final series = parseReleaseName(name).series.trim();
|
final series = parseReleaseName(name).series.trim();
|
||||||
if (series.isEmpty) return null;
|
// "1", "2026-10-09": nothing a show could be called.
|
||||||
|
if (!RegExp(r'\p{L}', unicode: true).hasMatch(series)) return null;
|
||||||
final match = await aniList.searchMedia(series);
|
final match = await aniList.searchMedia(series);
|
||||||
if (match == null) return null;
|
if (match == null) return null;
|
||||||
Future<Uint8List?> get(String? url, int maxBytes) async {
|
Future<Uint8List?> get(String? url, int maxBytes) async {
|
||||||
@@ -67,6 +71,31 @@ Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
|
|||||||
return (poster: poster, banner: banner);
|
return (poster: poster, banner: banner);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A web video's thumbnail at [url] as both cover and banner (the same
|
||||||
|
/// bytes, so the same hash: what tells clients it is a thumbnail, see
|
||||||
|
/// `NowPlaying.thumbnail`); shrunk to a JPEG under the cover cap when
|
||||||
|
/// larger; null when it cannot be had.
|
||||||
|
Future<ArtBytes?> thumbnailArt(Uri url,
|
||||||
|
{Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async {
|
||||||
|
var bytes = await fetch(url, const {}, 8 * 1024 * 1024);
|
||||||
|
if (bytes != null && bytes.length > RelayConstants.maxPosterBytes) bytes = await _shrinkApart(bytes);
|
||||||
|
return bytes == null ? null : (poster: bytes, banner: bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Its own function, so the isolate's closure holds nothing but [raw].
|
||||||
|
Future<Uint8List?> _shrinkApart(Uint8List raw) => Isolate.run<Uint8List?>(() => _shrinkThumbnail(raw));
|
||||||
|
|
||||||
|
Uint8List? _shrinkThumbnail(Uint8List raw) {
|
||||||
|
final decoded = img.decodeImage(raw);
|
||||||
|
if (decoded == null) return null;
|
||||||
|
for (final width in const [1280, 960, 640]) {
|
||||||
|
final small = decoded.width > width ? img.copyResize(decoded, width: width, interpolation: img.Interpolation.average) : decoded;
|
||||||
|
final jpg = img.encodeJpg(small, quality: 82);
|
||||||
|
if (jpg.length <= RelayConstants.maxPosterBytes) return jpg;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// GET [url]: the bytes when 200, at most [maxBytes] and a known image
|
/// GET [url]: the bytes when 200, at most [maxBytes] and a known image
|
||||||
/// type (see `avatarImageType`); else null.
|
/// type (see `avatarImageType`); else null.
|
||||||
Future<Uint8List?> fetchImage(Uri url, Map<String, String> headers, int maxBytes) async {
|
Future<Uint8List?> fetchImage(Uri url, Map<String, String> headers, int maxBytes) async {
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ class NowPlaying {
|
|||||||
/// Portrait cover (2:3) and wide banner; '' when none.
|
/// Portrait cover (2:3) and wide banner; '' when none.
|
||||||
final String posterHash;
|
final String posterHash;
|
||||||
final String bannerHash;
|
final String bannerHash;
|
||||||
const NowPlaying({required this.title, this.episode, this.posterHash = '', this.bannerHash = ''});
|
/// A web video: its art is its thumbnail (the same picture as cover and
|
||||||
|
/// banner, or none yet), shown wide, under the video's own name.
|
||||||
|
final bool thumbnail;
|
||||||
|
const NowPlaying(
|
||||||
|
{required this.title, this.episode, this.posterHash = '', this.bannerHash = '', this.thumbnail = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
@@ -17,8 +21,9 @@ class NowPlaying {
|
|||||||
other.title == title &&
|
other.title == title &&
|
||||||
other.episode == episode &&
|
other.episode == episode &&
|
||||||
other.posterHash == posterHash &&
|
other.posterHash == posterHash &&
|
||||||
other.bannerHash == bannerHash;
|
other.bannerHash == bannerHash &&
|
||||||
|
other.thumbnail == thumbnail;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(title, episode, posterHash, bannerHash);
|
int get hashCode => Object.hash(title, episode, posterHash, bannerHash, thumbnail);
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'package:sync_relay_core/core.dart' show hasMediaExtension;
|
||||||
|
|
||||||
import '../files/name_parser.dart';
|
import '../files/name_parser.dart';
|
||||||
import '../jellyfin/session_media.dart';
|
import '../jellyfin/session_media.dart';
|
||||||
import 'now_playing.dart';
|
import 'now_playing.dart';
|
||||||
@@ -17,12 +19,37 @@ NowPlaying? nowPlayingOf(Map<String, Object?>? media) {
|
|||||||
final poster = art['poster'] is String ? art['poster'] as String : '';
|
final poster = art['poster'] is String ? art['poster'] as String : '';
|
||||||
final banner = art['banner'] is String ? art['banner'] as String : '';
|
final banner = art['banner'] is String ? art['banner'] as String : '';
|
||||||
final ref = refOfStateMedia(media);
|
final ref = refOfStateMedia(media);
|
||||||
|
// A web video: its name as is, no episode guessing, its thumbnail as
|
||||||
|
// the art. Told by the art once it is up (the same picture as cover and
|
||||||
|
// banner), before that by the media itself, so it never changes layout.
|
||||||
|
final web = poster.isNotEmpty || banner.isNotEmpty ? poster == banner : ref == null && isWebVideo(media);
|
||||||
|
if (web) {
|
||||||
|
return NowPlaying(title: name, posterHash: poster, bannerHash: banner, thumbnail: true);
|
||||||
|
}
|
||||||
final (title, episode) = ref != null
|
final (title, episode) = ref != null
|
||||||
? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle)
|
? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle)
|
||||||
: fileTitle(name);
|
: fileTitle(name);
|
||||||
return NowPlaying(title: title, episode: episode, posterHash: poster, bannerHash: banner);
|
return NowPlaying(title: title, episode: episode, posterHash: poster, bannerHash: banner);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [media] (`state.media`, not Jellyfin) is a web page's video, whose art
|
||||||
|
/// will be its thumbnail: a shared http(s) link that is no media file (the
|
||||||
|
/// everyone-opens-it mode), or a relayed one (passed on by a member or
|
||||||
|
/// fetched by the server: the URL stays private) named by the page's title,
|
||||||
|
/// not a file name. A relayed file keeps its file name's extension.
|
||||||
|
bool isWebVideo(Map<String, Object?> media) {
|
||||||
|
final url = media['url'];
|
||||||
|
if (url is String && url.isNotEmpty) {
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
return uri != null &&
|
||||||
|
(uri.isScheme('http') || uri.isScheme('https')) &&
|
||||||
|
uri.host.isNotEmpty &&
|
||||||
|
!hasMediaExtension(uri);
|
||||||
|
}
|
||||||
|
final name = media['name'] is String ? media['name'] as String : '';
|
||||||
|
return media['relay'] is Map && name.isNotEmpty && !_ext.hasMatch(name);
|
||||||
|
}
|
||||||
|
|
||||||
/// Jellyfin: [name] is the item's, [subtitle] "Series · S1E6" (or
|
/// Jellyfin: [name] is the item's, [subtitle] "Series · S1E6" (or
|
||||||
/// "Series - S01E02") for episodes, the year for movies. Episodes give the
|
/// "Series - S01E02") for episodes, the year for movies. Episodes give the
|
||||||
/// series and "S1 E6 · Name"; anything else the name alone.
|
/// series and "S1 E6 · Name"; anything else the name alone.
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// Timings of one media change on this client, from the `state` naming
|
||||||
|
/// the new media to the server playing it (see ARCHITECTURE.md "Getting
|
||||||
|
/// ready"). Printed when [enabled] (env `SYNC_RELAY_TRACE_SWITCH=1`);
|
||||||
|
/// marks are always kept for the last few media, tests read them.
|
||||||
|
class SwitchTrace {
|
||||||
|
/// Phases, in the order they usually happen.
|
||||||
|
static const state = 'state';
|
||||||
|
static const resolved = 'resolved';
|
||||||
|
static const open = 'open';
|
||||||
|
/// A fresh player is up for it (no preload held it).
|
||||||
|
static const player = 'player';
|
||||||
|
static const opened = 'opened';
|
||||||
|
static const preloaded = 'preloaded';
|
||||||
|
static const buffered = 'buffered';
|
||||||
|
static const playing = 'playing';
|
||||||
|
|
||||||
|
static bool enabled = (Platform.environment['SYNC_RELAY_TRACE_SWITCH'] ?? '').isNotEmpty;
|
||||||
|
|
||||||
|
final String who;
|
||||||
|
SwitchTrace([this.who = '']);
|
||||||
|
|
||||||
|
final Map<int, Map<String, int>> _marks = {};
|
||||||
|
final Map<int, Stopwatch> _clocks = {};
|
||||||
|
|
||||||
|
/// Milliseconds from [state] to each phase of media [mediaId].
|
||||||
|
Map<String, int> of(int mediaId) => Map.unmodifiable(_marks[mediaId] ?? const {});
|
||||||
|
|
||||||
|
/// Starts the clock for [mediaId] (its first `state`).
|
||||||
|
void start(int mediaId) {
|
||||||
|
if (_clocks.containsKey(mediaId)) return;
|
||||||
|
_clocks[mediaId] = Stopwatch()..start();
|
||||||
|
_marks[mediaId] = {state: 0};
|
||||||
|
while (_clocks.length > 4) {
|
||||||
|
final oldest = _clocks.keys.first;
|
||||||
|
_clocks.remove(oldest);
|
||||||
|
_marks.remove(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records [phase] once for [mediaId]; [playing] ends the trace.
|
||||||
|
void mark(int mediaId, String phase) {
|
||||||
|
final clock = _clocks[mediaId];
|
||||||
|
final marks = _marks[mediaId];
|
||||||
|
if (clock == null || marks == null || marks.containsKey(phase)) return;
|
||||||
|
marks[phase] = clock.elapsedMilliseconds;
|
||||||
|
if (phase == playing && enabled) {
|
||||||
|
debugPrint('switch${who.isEmpty ? '' : ' $who'} m$mediaId: '
|
||||||
|
'${[for (final e in marks.entries) '${e.key} +${e.value} ms'].join(', ')}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,25 @@
|
|||||||
|
/// The app's sounds. Only events that carry news get one: no sound for
|
||||||
|
/// what the video itself makes obvious (start, pause, end, seeks).
|
||||||
enum SfxKind {
|
enum SfxKind {
|
||||||
|
/// Someone readies up; pitch climbs with [SfxCue.readyIndex].
|
||||||
ready,
|
ready,
|
||||||
unready,
|
unready,
|
||||||
|
/// Someone marks everyone ready.
|
||||||
forceReady,
|
forceReady,
|
||||||
forceUnready,
|
|
||||||
forcePlay,
|
|
||||||
forceHold,
|
|
||||||
kick,
|
|
||||||
join,
|
join,
|
||||||
leave,
|
leave,
|
||||||
stale,
|
/// Someone sat you out (played by the session, not from a feed action).
|
||||||
|
kick,
|
||||||
|
/// A new video or episode is put up.
|
||||||
swap,
|
swap,
|
||||||
seek,
|
|
||||||
pause,
|
|
||||||
resume,
|
|
||||||
chat,
|
chat,
|
||||||
|
/// Idle warning: auto-unready soon.
|
||||||
warn,
|
warn,
|
||||||
|
/// The connection to the server dropped / came back.
|
||||||
|
lost,
|
||||||
|
back,
|
||||||
|
/// Someone pokes you for attention; plays only on your end.
|
||||||
|
nudge,
|
||||||
}
|
}
|
||||||
|
|
||||||
class SfxCue {
|
class SfxCue {
|
||||||
@@ -23,42 +28,16 @@ class SfxCue {
|
|||||||
const SfxCue(this.kind, {this.readyIndex = 0});
|
const SfxCue(this.kind, {this.readyIndex = 0});
|
||||||
}
|
}
|
||||||
|
|
||||||
SfxKind? sfxForFeedAction(String action) {
|
SfxKind? sfxForFeedAction(String action) => switch (action) {
|
||||||
switch (action) {
|
'ready' => SfxKind.ready,
|
||||||
case 'ready':
|
'unready' => SfxKind.unready,
|
||||||
return SfxKind.ready;
|
'force-ready' => SfxKind.forceReady,
|
||||||
case 'unready':
|
'join' => SfxKind.join,
|
||||||
return SfxKind.unready;
|
'leave' => SfxKind.leave,
|
||||||
case 'force-ready':
|
'swap' => SfxKind.swap,
|
||||||
return SfxKind.forceReady;
|
'chat' => SfxKind.chat,
|
||||||
case 'force-unready':
|
_ => null,
|
||||||
return SfxKind.forceUnready;
|
};
|
||||||
case 'force-play':
|
|
||||||
return SfxKind.forcePlay;
|
|
||||||
case 'force-hold':
|
|
||||||
return SfxKind.forceHold;
|
|
||||||
case 'kick':
|
|
||||||
return SfxKind.kick;
|
|
||||||
case 'join':
|
|
||||||
return SfxKind.join;
|
|
||||||
case 'leave':
|
|
||||||
return SfxKind.leave;
|
|
||||||
case 'stale':
|
|
||||||
return SfxKind.stale;
|
|
||||||
case 'swap':
|
|
||||||
return SfxKind.swap;
|
|
||||||
case 'seek':
|
|
||||||
return SfxKind.seek;
|
|
||||||
case 'pause':
|
|
||||||
return SfxKind.pause;
|
|
||||||
case 'resume':
|
|
||||||
return SfxKind.resume;
|
|
||||||
case 'chat':
|
|
||||||
return SfxKind.chat;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract class SfxService {
|
abstract class SfxService {
|
||||||
Future<void> play(SfxCue cue);
|
Future<void> play(SfxCue cue);
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import 'sfx.dart';
|
||||||
|
import 'synth.dart';
|
||||||
|
|
||||||
|
// The app's sounds come in presets; each action can take its sound from any
|
||||||
|
// of them. Every preset is quiet, short and on D, so sounds from different
|
||||||
|
// presets still agree when they overlap.
|
||||||
|
// - Cosmic: star bells, stardust, bubbles, soft pads, a space tail.
|
||||||
|
// - Abyss: deep space and its dread. Far beacons, nebula washes, slow
|
||||||
|
// beating clusters and tritone sags, on D phrygian, with a vast tail.
|
||||||
|
// - Wood & glass: plucked strings, glass bells, wood knocks, a short room.
|
||||||
|
|
||||||
|
enum SoundPreset { cosmic, abyss, woodAndGlass }
|
||||||
|
|
||||||
|
/// Shown in Settings: name and feel.
|
||||||
|
const presetInfo = {
|
||||||
|
SoundPreset.cosmic: ('Cosmic', 'Star bells, stardust and a soft space tail'),
|
||||||
|
SoundPreset.abyss: ('Abyss', 'Deep space: far beacons, nebula, a vast dark tail'),
|
||||||
|
SoundPreset.woodAndGlass: ('Wood & glass', 'Plucked strings, glass bells and wood, close and dry'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The preset used when nothing else is chosen.
|
||||||
|
const defaultPreset = SoundPreset.abyss;
|
||||||
|
|
||||||
|
const _d3 = -19, _ab3 = -13;
|
||||||
|
const _d4 = -7, _eb4 = -6, _ab4 = -1, _a4 = 0, _b4 = 2;
|
||||||
|
const _d5 = 5, _eb5 = 6, _e5 = 7, _f5 = 8, _fs5 = 9, _g5 = 10, _a5 = 12, _bb5 = 13, _b5 = 14;
|
||||||
|
const _c6 = 15, _cs6 = 16, _d6 = 17, _e6 = 19, _f6 = 20, _fs6 = 21, _g6 = 22, _a6 = 24, _b6 = 26;
|
||||||
|
|
||||||
|
double _p(int s) => semi(s);
|
||||||
|
|
||||||
|
/// The ready sound's answer climbs with each person ready.
|
||||||
|
const _cosmicLadder = [_a5, _b5, _d6, _e6, _fs6, _a6];
|
||||||
|
const _abyssLadder = [_f5, _g5, _a5, _c6, _d6, _f6];
|
||||||
|
const _woodLadder = [_g5, _a5, _b5, _d6, _e6, _g6];
|
||||||
|
|
||||||
|
SoundSpec readySound(int readyIndex, {SoundPreset preset = defaultPreset}) =>
|
||||||
|
soundFor(SfxKind.ready, readyIndex: readyIndex, preset: preset);
|
||||||
|
|
||||||
|
SoundSpec soundFor(SfxKind kind, {int readyIndex = 1, SoundPreset preset = defaultPreset}) {
|
||||||
|
final i = (readyIndex - 1).clamp(0, 5);
|
||||||
|
return switch (preset) {
|
||||||
|
SoundPreset.cosmic => _cosmic(kind, i),
|
||||||
|
SoundPreset.abyss => _abyss(kind, i),
|
||||||
|
SoundPreset.woodAndGlass => _wood(kind, i),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
SoundSpec _cosmic(SfxKind kind, int i) => switch (kind) {
|
||||||
|
SfxKind.ready => SoundSpec([
|
||||||
|
Note(0, _p(_d4), 1.0, voice: Voice.pad, gain: 0.3),
|
||||||
|
Note(0, _p(_d5), 1.2, voice: Voice.star, gain: 0.8, bright: 0.4),
|
||||||
|
Note(0.09, _p(_cosmicLadder[i]), 1.5, voice: Voice.star, bright: 0.5),
|
||||||
|
], peakDb: -17, room: 0.3),
|
||||||
|
// The same bells, dimmed and stepping down.
|
||||||
|
SfxKind.unready => SoundSpec([
|
||||||
|
Note(0, _p(_fs5), 0.6, voice: Voice.star, gain: 0.8, bright: 0.15),
|
||||||
|
Note(0.1, _p(_d5), 0.8, voice: Voice.star, bright: 0.1),
|
||||||
|
], cutoff: 3500, peakDb: -20, room: 0.25),
|
||||||
|
// Just the bell, left hanging in space: playback is the rest of the cue.
|
||||||
|
SfxKind.forceReady => SoundSpec([
|
||||||
|
Note(0, _p(_d6), 1.8, voice: Voice.star, bright: 0.7),
|
||||||
|
], cutoff: 7000, peakDb: -18, room: 0.35, tail: 1.6),
|
||||||
|
// A star arriving: dust rising into a bell.
|
||||||
|
SfxKind.join => SoundSpec([
|
||||||
|
Note(0, _p(_d6), 1.0, voice: Voice.dust, to: _p(_a6), gain: 0.5),
|
||||||
|
Note(0.15, _p(_a5), 1.4, voice: Voice.star, bright: 0.35, gain: 0.6),
|
||||||
|
], cutoff: 7000, peakDb: -19, room: 0.3),
|
||||||
|
// Dust drifting down, a low bell going out.
|
||||||
|
SfxKind.leave => SoundSpec([
|
||||||
|
Note(0, _p(_a6), 0.9, voice: Voice.dust, to: _p(_d6), gain: 0.5),
|
||||||
|
Note(0.1, _p(_d5), 1.3, voice: Voice.star, bright: 0.2, gain: 0.5),
|
||||||
|
], cutoff: 5000, peakDb: -22, room: 0.3),
|
||||||
|
// Falling into the dark: a deep tone sinking an octave under a sagging pad.
|
||||||
|
SfxKind.kick => SoundSpec([
|
||||||
|
Note(0, _p(_d4), 1.0, voice: Voice.sub, to: _p(_d3)),
|
||||||
|
Note(0, _p(_a4), 0.9, voice: Voice.pad, to: _p(_d4), gain: 0.4),
|
||||||
|
], cutoff: 3000, peakDb: -19, room: 0.25),
|
||||||
|
// Something new opens up: a rush of air, then a wide chord blooms.
|
||||||
|
SfxKind.swap => SoundSpec([
|
||||||
|
Note(0, 300, 0.6, voice: Voice.slide, to: 2400, gain: 0.35),
|
||||||
|
Note(0.35, _p(_d4), 1.4, voice: Voice.pad, gain: 0.5, bright: 0.3),
|
||||||
|
Note(0.35, _p(_a4), 1.4, voice: Voice.pad, gain: 0.5, bright: 0.3),
|
||||||
|
Note(0.35, _p(_e5), 1.4, voice: Voice.pad, gain: 0.4, bright: 0.3),
|
||||||
|
Note(0.38, _p(_fs6), 1.2, voice: Voice.star, bright: 0.3, gain: 0.3),
|
||||||
|
], peakDb: -18, room: 0.3),
|
||||||
|
// Two bubbles rising: bloop-bloop, the second higher.
|
||||||
|
SfxKind.chat => SoundSpec([
|
||||||
|
Note(0, _p(_a5), 0.18, voice: Voice.drop, gain: 0.8),
|
||||||
|
Note(0.08, _p(_e6), 0.22, voice: Voice.drop),
|
||||||
|
], peakDb: -19, room: 0.15, tail: 1.0),
|
||||||
|
// Something huge drifting past: a slow, deep swell.
|
||||||
|
SfxKind.warn => SoundSpec([
|
||||||
|
Note(0, _p(_d4), 2.0, voice: Voice.pad),
|
||||||
|
Note(0, _p(_a4), 2.0, voice: Voice.pad, gain: 0.5),
|
||||||
|
Note(0.3, _p(_d3), 1.7, voice: Voice.sub, gain: 0.4),
|
||||||
|
], cutoff: 3000, peakDb: -20, room: 0.2, tail: 1.0),
|
||||||
|
// The signal fading: a chord sagging down and dimming.
|
||||||
|
SfxKind.lost => SoundSpec([
|
||||||
|
Note(0, _p(_a4), 1.2, voice: Voice.pad, to: _p(_d4), gain: 0.7),
|
||||||
|
Note(0, _p(_e5), 1.2, voice: Voice.pad, to: _p(_a4), gain: 0.4),
|
||||||
|
], cutoff: 2500, peakDb: -19, room: 0.3, tail: 1.2),
|
||||||
|
// Re-acquired: a bell gliding up into tune, landing with dust.
|
||||||
|
SfxKind.back => SoundSpec([
|
||||||
|
Note(0, _p(_a4), 0.6, voice: Voice.star, to: _p(_d5), bright: 0.3, gain: 0.5),
|
||||||
|
Note(0.22, _p(_a5), 1.2, voice: Voice.star, bright: 0.5),
|
||||||
|
Note(0.25, _p(_d6), 0.6, voice: Voice.dust, to: _p(_a6), gain: 0.3),
|
||||||
|
], peakDb: -18, room: 0.25),
|
||||||
|
// A poke: two quick bright bubbles flicking up, a glint on the second.
|
||||||
|
// High and dry so it cuts through a video and stays a patter when spammed.
|
||||||
|
SfxKind.nudge => SoundSpec([
|
||||||
|
Note(0, _p(_d6), 0.12, voice: Voice.drop, to: _p(_fs6), gain: 0.8),
|
||||||
|
Note(0.09, _p(_a6), 0.16, voice: Voice.drop),
|
||||||
|
Note(0.09, _p(_cs6) * 2, 0.3, voice: Voice.star, bright: 0.6, gain: 0.2),
|
||||||
|
], cutoff: 7000, peakDb: -18, room: 0.05, tail: 0.6),
|
||||||
|
};
|
||||||
|
|
||||||
|
SoundSpec _abyss(SfxKind kind, int i) => switch (kind) {
|
||||||
|
// A beacon pinging far out, the void humming under it.
|
||||||
|
SfxKind.ready => SoundSpec([
|
||||||
|
Note(0, _p(_d3), 1.2, voice: Voice.pad, gain: 0.35),
|
||||||
|
Note(0, _p(_d5), 1.4, voice: Voice.star, gain: 0.8, bright: 0.25),
|
||||||
|
Note(0.12, _p(_abyssLadder[i]), 1.6, voice: Voice.star, bright: 0.3),
|
||||||
|
], cutoff: 5000, peakDb: -18, room: 0.4, tail: 1.8),
|
||||||
|
// The beacon answering itself a tritone down, dimmer.
|
||||||
|
SfxKind.unready => SoundSpec([
|
||||||
|
Note(0, _p(_a4), 0.7, voice: Voice.star, gain: 0.8, bright: 0.1),
|
||||||
|
Note(0.12, _p(_eb4), 0.9, voice: Voice.star, bright: 0.1),
|
||||||
|
], cutoff: 2500, peakDb: -21, room: 0.35, tail: 1.6),
|
||||||
|
// One bell, huge and very far away.
|
||||||
|
SfxKind.forceReady => SoundSpec([
|
||||||
|
Note(0, _p(_d5), 1.7, voice: Voice.star, bright: 0.5),
|
||||||
|
], cutoff: 5000, peakDb: -18, room: 0.45, tail: 1.8),
|
||||||
|
// Something emerging from the nebula: a dark wash brightening into a bell.
|
||||||
|
SfxKind.join => SoundSpec([
|
||||||
|
Note(0, 150, 1.2, voice: Voice.slide, to: 900, gain: 0.4),
|
||||||
|
Note(0.05, _p(_d4), 1.2, voice: Voice.pad, gain: 0.3),
|
||||||
|
Note(0.3, _p(_a5), 1.3, voice: Voice.star, bright: 0.3, gain: 0.5),
|
||||||
|
], cutoff: 4500, peakDb: -19, room: 0.4, tail: 1.6),
|
||||||
|
// Swallowed back into it: the wash darkening over a fading bell.
|
||||||
|
SfxKind.leave => SoundSpec([
|
||||||
|
Note(0, 900, 1.1, voice: Voice.slide, to: 150, gain: 0.4),
|
||||||
|
Note(0.1, _p(_d5), 1.2, voice: Voice.star, bright: 0.15, gain: 0.4),
|
||||||
|
], cutoff: 3500, peakDb: -22, room: 0.4, tail: 1.6),
|
||||||
|
// Pulled under: a deep fall with a tritone sagging over it.
|
||||||
|
SfxKind.kick => SoundSpec([
|
||||||
|
Note(0, _p(_d4), 1.3, voice: Voice.sub, to: _p(_d3)),
|
||||||
|
Note(0, _p(_ab4), 1.2, voice: Voice.pad, to: _p(_d4), gain: 0.35),
|
||||||
|
], cutoff: 2000, peakDb: -19, room: 0.35, tail: 1.6),
|
||||||
|
// A door into the dark: low air, then an unresolved chord opens.
|
||||||
|
SfxKind.swap => SoundSpec([
|
||||||
|
Note(0, 120, 0.8, voice: Voice.slide, to: 1200, gain: 0.35),
|
||||||
|
Note(0.45, _p(_d4), 1.3, voice: Voice.pad, gain: 0.5, bright: 0.2),
|
||||||
|
Note(0.45, _p(_a4), 1.3, voice: Voice.pad, gain: 0.45, bright: 0.2),
|
||||||
|
Note(0.45, _p(_eb5), 1.3, voice: Voice.pad, gain: 0.3, bright: 0.2),
|
||||||
|
Note(0.5, _p(_bb5), 1.1, voice: Voice.star, bright: 0.2, gain: 0.25),
|
||||||
|
], cutoff: 4000, peakDb: -19, room: 0.35, tail: 1.4),
|
||||||
|
// Bubbles rising from deep water.
|
||||||
|
SfxKind.chat => SoundSpec([
|
||||||
|
Note(0, _p(_d5), 0.25, voice: Voice.drop, gain: 0.8),
|
||||||
|
Note(0.1, _p(_a5), 0.3, voice: Voice.drop),
|
||||||
|
], cutoff: 4500, peakDb: -19, room: 0.3, tail: 1.4),
|
||||||
|
// Something vast drawing near: a slow swell of two notes rubbing.
|
||||||
|
SfxKind.warn => SoundSpec([
|
||||||
|
Note(0, _p(_d4), 2.0, voice: Voice.pad),
|
||||||
|
Note(0, _p(_eb4), 2.0, voice: Voice.pad, gain: 0.35),
|
||||||
|
Note(0.3, _p(_d3), 1.7, voice: Voice.sub, gain: 0.45),
|
||||||
|
], cutoff: 2200, peakDb: -20, room: 0.25, tail: 1.0),
|
||||||
|
// The signal drowning: a chord sagging by a tritone into static.
|
||||||
|
SfxKind.lost => SoundSpec([
|
||||||
|
Note(0, _p(_a4), 1.3, voice: Voice.pad, to: _p(_eb4), gain: 0.7),
|
||||||
|
Note(0, _p(_d4), 1.3, voice: Voice.pad, to: _p(_ab3), gain: 0.5),
|
||||||
|
Note(0.2, 600, 1.0, voice: Voice.slide, to: 200, gain: 0.2),
|
||||||
|
], cutoff: 2000, peakDb: -19, room: 0.35, tail: 1.4),
|
||||||
|
// Found again: a bell climbing out of the tritone into tune.
|
||||||
|
SfxKind.back => SoundSpec([
|
||||||
|
Note(0, _p(_ab4), 0.6, voice: Voice.star, to: _p(_d5), bright: 0.25, gain: 0.5),
|
||||||
|
Note(0.25, _p(_a5), 1.3, voice: Voice.star, bright: 0.4),
|
||||||
|
Note(0.28, _p(_d5), 0.6, voice: Voice.dust, to: _p(_a5), gain: 0.25),
|
||||||
|
], cutoff: 5000, peakDb: -18, room: 0.35, tail: 1.6),
|
||||||
|
// A poke from the dark: two quick bubbles flicking up, a pale glint.
|
||||||
|
// Still high and fairly dry so it cuts through a video when spammed.
|
||||||
|
SfxKind.nudge => SoundSpec([
|
||||||
|
Note(0, _p(_a5), 0.13, voice: Voice.drop, to: _p(_c6), gain: 0.8),
|
||||||
|
Note(0.1, _p(_d6), 0.18, voice: Voice.drop),
|
||||||
|
Note(0.1, _p(_a6), 0.35, voice: Voice.star, bright: 0.5, gain: 0.2),
|
||||||
|
], cutoff: 6000, peakDb: -18, room: 0.1, tail: 1.0),
|
||||||
|
};
|
||||||
|
|
||||||
|
SoundSpec _wood(SfxKind kind, int i) => switch (kind) {
|
||||||
|
SfxKind.ready => SoundSpec([
|
||||||
|
Note(0, _p(_d5), 0.9, gain: 0.75, bright: 0.55),
|
||||||
|
Note(0.085, _p(_woodLadder[i]), 1.1, bright: 0.65),
|
||||||
|
], peakDb: -17, room: 0.14, tail: 0.5),
|
||||||
|
// The same strings, muted and stepping down.
|
||||||
|
SfxKind.unready => SoundSpec([
|
||||||
|
Note(0, _p(_a5), 0.35, gain: 0.8, bright: 0.2),
|
||||||
|
Note(0.09, _p(_g5), 0.45, bright: 0.15),
|
||||||
|
], cutoff: 3800, peakDb: -20, room: 0.1, tail: 0.5),
|
||||||
|
// Just the bell: playback starting is the rest of the cue.
|
||||||
|
SfxKind.forceReady => SoundSpec([
|
||||||
|
Note(0, _p(_d6) * 2, 1.6, voice: Voice.chime),
|
||||||
|
], cutoff: 7000, peakDb: -18, room: 0.18, tail: 0.5),
|
||||||
|
// Wind through glass: three uneven tinkles.
|
||||||
|
SfxKind.join => SoundSpec([
|
||||||
|
Note(0, _p(_a6), 1.3, voice: Voice.chime, gain: 0.8),
|
||||||
|
Note(0.11, _p(_d6) * 2, 1.2, voice: Voice.chime, gain: 0.55),
|
||||||
|
Note(0.26, _p(_e6) * 2, 1.4, voice: Voice.chime, gain: 0.45),
|
||||||
|
], cutoff: 7000, peakDb: -19, room: 0.18, tail: 0.5),
|
||||||
|
// The wind settling: two softer, lower tinkles.
|
||||||
|
SfxKind.leave => SoundSpec([
|
||||||
|
Note(0, _p(_b6), 1.0, voice: Voice.chime, gain: 0.6),
|
||||||
|
Note(0.17, _p(_e6), 1.3, voice: Voice.chime, gain: 0.5),
|
||||||
|
], cutoff: 5000, peakDb: -22, room: 0.18, tail: 0.5),
|
||||||
|
// Two hardwood clacks, then a low muted string.
|
||||||
|
SfxKind.kick => SoundSpec([
|
||||||
|
Note(0, 1900, 0.09, voice: Voice.clack),
|
||||||
|
Note(0.16, 1750, 0.11, voice: Voice.clack, gain: 0.9),
|
||||||
|
Note(0.3, _p(_d4), 0.7, bright: 0.15, gain: 0.7),
|
||||||
|
], cutoff: 5000, peakDb: -19, room: 0.12, tail: 0.5),
|
||||||
|
// The door slides open on something new: air, then a fifth.
|
||||||
|
SfxKind.swap => SoundSpec([
|
||||||
|
Note(0, 500, 0.42, voice: Voice.slide, to: 1700, gain: 0.5),
|
||||||
|
Note(0.33, _p(_d5), 1.1, bright: 0.6, gain: 0.8),
|
||||||
|
Note(0.36, _p(_a5), 1.1, bright: 0.6, gain: 0.7),
|
||||||
|
], peakDb: -18, tail: 0.5),
|
||||||
|
// A hollow wooden tok-tok, the second higher.
|
||||||
|
SfxKind.chat => SoundSpec([
|
||||||
|
Note(0, _p(_g5), 0.13, voice: Voice.bamboo, gain: 0.85),
|
||||||
|
Note(0.075, _p(_d6), 0.16, voice: Voice.bamboo),
|
||||||
|
], cutoff: 5500, peakDb: -19, room: 0.1, tail: 0.5),
|
||||||
|
// A bowl: one soft strike that hums.
|
||||||
|
SfxKind.warn => SoundSpec([
|
||||||
|
Note(0, _p(_d5), 2.6, voice: Voice.rin),
|
||||||
|
], cutoff: 4500, peakDb: -20, room: 0.1, tail: 0.5),
|
||||||
|
// A string going slack: plucked, its pitch sagging away.
|
||||||
|
SfxKind.lost => SoundSpec([
|
||||||
|
Note(0, _p(_a5), 1.1, to: _p(_d5), bright: 0.4),
|
||||||
|
Note(0.05, _p(_d5), 1.0, to: _p(_a4), bright: 0.3, gain: 0.6),
|
||||||
|
], cutoff: 3500, peakDb: -19, tail: 0.5),
|
||||||
|
// Re-tuned: a quick bend up that lands, and a tinkle.
|
||||||
|
SfxKind.back => SoundSpec([
|
||||||
|
Note(0, _p(_b4), 0.9, to: _p(_d5), bright: 0.5),
|
||||||
|
Note(0.2, _p(_a5), 1.0, bright: 0.6, gain: 0.7),
|
||||||
|
Note(0.3, _p(_d6) * 2, 1.0, voice: Voice.chime, gain: 0.2),
|
||||||
|
], peakDb: -18, tail: 0.5),
|
||||||
|
// Two quick wooden taps, the second flicking up, with a glint.
|
||||||
|
SfxKind.nudge => SoundSpec([
|
||||||
|
Note(0, _p(_a5), 0.12, voice: Voice.bamboo, gain: 0.8),
|
||||||
|
Note(0.09, _p(_e6), 0.16, voice: Voice.bamboo),
|
||||||
|
Note(0.09, _p(_b6), 0.35, voice: Voice.chime, gain: 0.3),
|
||||||
|
], cutoff: 6500, peakDb: -18, room: 0.04, tail: 0.5),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Shown in Settings → Notifications & sounds: name and when it plays.
|
||||||
|
const soundInfo = {
|
||||||
|
SfxKind.ready: ('Ready', 'Someone presses Space; climbs with each ready person'),
|
||||||
|
SfxKind.unready: ('Not ready', 'Someone takes their ready back'),
|
||||||
|
SfxKind.forceReady: ('Everyone ready', 'Someone marks everyone ready'),
|
||||||
|
SfxKind.join: ('Joined', 'Someone joins the server'),
|
||||||
|
SfxKind.leave: ('Left', 'Someone leaves'),
|
||||||
|
SfxKind.kick: ('Sat out', 'Someone sits you out'),
|
||||||
|
SfxKind.swap: ('New video', 'A different video or episode is put up'),
|
||||||
|
SfxKind.chat: ('Message', 'A chat message from someone else'),
|
||||||
|
SfxKind.warn: ('Idle warning', 'Auto-unready soon unless you press Space'),
|
||||||
|
SfxKind.lost: ('Connection lost', 'The server stopped answering; reconnecting'),
|
||||||
|
SfxKind.back: ('Reconnected', 'Back in the server after a drop'),
|
||||||
|
SfxKind.nudge: ('Nudge', 'Someone nudges you; always plays (volume still applies)'),
|
||||||
|
};
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
// A tiny offline synthesizer for UI sounds. The current palette is
|
||||||
|
// spacious: FM star bells, bubble drops, slow pads, a sub that falls away,
|
||||||
|
// stardust and a long dark space tail. Older voices (plucked string, glass
|
||||||
|
// and bowl bells, wood) stay available. Renders to 16-bit mono WAV bytes.
|
||||||
|
// Pure Dart.
|
||||||
|
|
||||||
|
const sampleRate = 44100;
|
||||||
|
|
||||||
|
enum Voice {
|
||||||
|
/// Plucked string; [Note.to] bends it (the delay line stretches).
|
||||||
|
koto,
|
||||||
|
/// Small glass bell: inharmonic partials, a slow shimmer.
|
||||||
|
chime,
|
||||||
|
/// Singing bowl: a soft strike and a long beating hum.
|
||||||
|
rin,
|
||||||
|
/// Hollow bamboo knock, pitched.
|
||||||
|
bamboo,
|
||||||
|
/// Two hardwood blocks struck together.
|
||||||
|
clack,
|
||||||
|
/// Breathy air sweeping up (a sliding door); [Note.hz]→[Note.to] is the
|
||||||
|
/// sweep of its band.
|
||||||
|
slide,
|
||||||
|
/// FM bell: strikes glassy, settles to a pure hanging tone; [Note.bright]
|
||||||
|
/// scales the strike, [Note.to] glides it.
|
||||||
|
star,
|
||||||
|
/// A bubble: the pitch rises into the note fast; [Note.to] flicks it on.
|
||||||
|
drop,
|
||||||
|
/// Slow detuned swell (in over a third of its length, then out);
|
||||||
|
/// [Note.bright] adds overtones, [Note.to] glides it.
|
||||||
|
pad,
|
||||||
|
/// Deep round tone that falls from [Note.hz] to [Note.to].
|
||||||
|
sub,
|
||||||
|
/// A scatter of tiny pings on the scale above [Note.hz], drifting
|
||||||
|
/// towards [Note.to].
|
||||||
|
dust,
|
||||||
|
}
|
||||||
|
|
||||||
|
class Note {
|
||||||
|
final double at;
|
||||||
|
final double hz;
|
||||||
|
final double? to;
|
||||||
|
/// Length (s) to about -40 dB.
|
||||||
|
final double len;
|
||||||
|
final double gain;
|
||||||
|
final Voice voice;
|
||||||
|
/// Koto only: 0 = dark/muted pluck, 1 = bright.
|
||||||
|
final double bright;
|
||||||
|
const Note(this.at, this.hz, this.len,
|
||||||
|
{this.to, this.gain = 1, this.voice = Voice.koto, this.bright = 0.6});
|
||||||
|
}
|
||||||
|
|
||||||
|
class SoundSpec {
|
||||||
|
final List<Note> notes;
|
||||||
|
/// Gentle low-pass cutoff (Hz) over the mix.
|
||||||
|
final double cutoff;
|
||||||
|
/// Peak level after normalising, dBFS (quiet by design).
|
||||||
|
final double peakDb;
|
||||||
|
/// Space tail mix (0..1).
|
||||||
|
final double room;
|
||||||
|
/// Space tail decay to -60 dB (s); the sound keeps about half of it.
|
||||||
|
final double tail;
|
||||||
|
const SoundSpec(this.notes,
|
||||||
|
{this.cutoff = 6000, this.peakDb = -17, this.room = 0.14, this.tail = 1.4});
|
||||||
|
|
||||||
|
double get length =>
|
||||||
|
notes.fold<double>(0, (m, n) => math.max(m, n.at + n.len)) + (room > 0 ? tail * 0.55 : 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Semitones from A4 (440 Hz) to Hz.
|
||||||
|
double semi(num n) => 440 * math.pow(2, n / 12).toDouble();
|
||||||
|
|
||||||
|
Float64List render(SoundSpec spec, {int seed = 7}) {
|
||||||
|
final total = (spec.length * sampleRate).ceil();
|
||||||
|
final out = Float64List(total);
|
||||||
|
final rnd = math.Random(seed);
|
||||||
|
for (final n in spec.notes) {
|
||||||
|
final start = (n.at * sampleRate).round();
|
||||||
|
if (start >= total) continue;
|
||||||
|
final s = switch (n.voice) {
|
||||||
|
Voice.koto => _koto(n, rnd),
|
||||||
|
Voice.chime => _bell(n, const [(1.0, 1.0, 1.0), (2.76, 0.45, 1.9), (5.40, 0.2, 3.2), (8.93, 0.08, 4.5)], 1.6, 0.003),
|
||||||
|
Voice.rin => _bell(n, const [(1.0, 1.0, 1.0), (2.71, 0.35, 1.5), (5.15, 0.12, 2.4)], 2.4, 0.012),
|
||||||
|
Voice.bamboo => _bamboo(n, rnd),
|
||||||
|
Voice.clack => _clack(n, rnd),
|
||||||
|
Voice.slide => _slide(n, rnd),
|
||||||
|
Voice.star => _star(n),
|
||||||
|
Voice.drop => _drop(n),
|
||||||
|
Voice.pad => _pad(n, rnd),
|
||||||
|
Voice.sub => _sub(n),
|
||||||
|
Voice.dust => _dust(n, rnd),
|
||||||
|
};
|
||||||
|
for (var i = 0; i < s.length && start + i < total; i++) {
|
||||||
|
out[start + i] += n.gain * s[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_lowpass(out, spec.cutoff);
|
||||||
|
if (spec.room > 0) _space(out, spec.room, spec.tail);
|
||||||
|
// 1 ms in, 10 ms out: no clicks at either end.
|
||||||
|
final fadeIn = (0.001 * sampleRate).round();
|
||||||
|
for (var i = 0; i < fadeIn && i < total; i++) {
|
||||||
|
out[i] *= i / fadeIn;
|
||||||
|
}
|
||||||
|
final fade = (0.01 * sampleRate).round();
|
||||||
|
for (var i = 0; i < fade && i < total; i++) {
|
||||||
|
out[total - 1 - i] *= i / fade;
|
||||||
|
}
|
||||||
|
var peak = 0.0;
|
||||||
|
for (final v in out) {
|
||||||
|
peak = math.max(peak, v.abs());
|
||||||
|
}
|
||||||
|
if (peak > 0) {
|
||||||
|
final g = math.pow(10, spec.peakDb / 20) / peak;
|
||||||
|
for (var i = 0; i < total; i++) {
|
||||||
|
out[i] *= g;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _n(double secs) => (secs * sampleRate).round();
|
||||||
|
double _smooth(double t) => t * t * (3 - 2 * t);
|
||||||
|
|
||||||
|
/// Karplus-Strong with a fractional, movable delay: a noise burst in a
|
||||||
|
/// loop that loses a little of its highs each pass.
|
||||||
|
Float64List _koto(Note n, math.Random rnd) {
|
||||||
|
final len = _n(n.len);
|
||||||
|
final out = Float64List(len);
|
||||||
|
final maxPeriod = sampleRate / math.min(n.hz, n.to ?? n.hz) + 4;
|
||||||
|
final buf = Float64List(maxPeriod.ceil() + 4);
|
||||||
|
final size = buf.length;
|
||||||
|
// Excitation: noise, smoothed more for a darker pluck.
|
||||||
|
final period0 = sampleRate / n.hz;
|
||||||
|
var prev = 0.0;
|
||||||
|
final smooth = 1 - (0.25 + 0.7 * n.bright);
|
||||||
|
for (var i = 0; i < period0.ceil(); i++) {
|
||||||
|
final x = rnd.nextDouble() * 2 - 1;
|
||||||
|
prev = smooth * prev + (1 - smooth) * x;
|
||||||
|
buf[i % size] = prev;
|
||||||
|
}
|
||||||
|
// Loop gain so the note reaches -40 dB at len.
|
||||||
|
final loss = math.pow(0.01, 1 / (n.len * n.hz)).toDouble();
|
||||||
|
var w = period0.ceil();
|
||||||
|
var last = 0.0;
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
final t = i / len;
|
||||||
|
final hz = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(math.min(1, t * 1.6)));
|
||||||
|
// The two-point average below delays half a sample: take it off.
|
||||||
|
final period = sampleRate / hz - 0.5;
|
||||||
|
final r = w - period;
|
||||||
|
final ri = r.floor();
|
||||||
|
final f = r - ri;
|
||||||
|
final a = buf[(ri % size + size) % size];
|
||||||
|
final b = buf[((ri + 1) % size + size) % size];
|
||||||
|
final y = a + (b - a) * f;
|
||||||
|
// Two-point average = the string's damping; the 0.5 mix keeps it bright.
|
||||||
|
final v = loss * (0.5 * y + 0.5 * last);
|
||||||
|
last = y;
|
||||||
|
buf[w % size] = v;
|
||||||
|
w++;
|
||||||
|
// A 3 ms attack takes the click off the pluck.
|
||||||
|
out[i] = v * (i < 132 ? i / 132 : 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inharmonic partials (ratio, amplitude, decay multiplier), each doubled
|
||||||
|
/// with a slightly detuned twin so the tail shimmers.
|
||||||
|
Float64List _bell(Note n, List<(double, double, double)> partials, double beatHz, double attackSecs) {
|
||||||
|
final len = _n(n.len);
|
||||||
|
final out = Float64List(len);
|
||||||
|
final k = math.log(100) / len;
|
||||||
|
final attack = _n(attackSecs);
|
||||||
|
for (final (ratio, amp, decay) in partials) {
|
||||||
|
final f1 = n.hz * ratio, f2 = f1 + beatHz * ratio;
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
final env = math.exp(-k * decay * i) * (i < attack ? _smooth(i / attack) : 1);
|
||||||
|
final ph = 2 * math.pi * i / sampleRate;
|
||||||
|
out[i] += amp * env * 0.5 * (math.sin(ph * f1) + math.sin(ph * f2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A hollow tube: a pitched thump that drops into its note, an overtone
|
||||||
|
/// that dies fast, and a short click of air.
|
||||||
|
Float64List _bamboo(Note n, math.Random rnd) {
|
||||||
|
final len = _n(n.len);
|
||||||
|
final out = Float64List(len);
|
||||||
|
final k = math.log(100) / len;
|
||||||
|
var ph = 0.0, ph2 = 0.0;
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
final drop = math.exp(-i / _n(0.012));
|
||||||
|
final hz = n.hz * (1 + 0.5 * drop);
|
||||||
|
ph += 2 * math.pi * hz / sampleRate;
|
||||||
|
ph2 += 2 * math.pi * hz * 2.31 / sampleRate;
|
||||||
|
out[i] = math.exp(-k * i) * (math.sin(ph) + 0.35 * math.sin(ph2) * math.exp(-k * 3 * i));
|
||||||
|
}
|
||||||
|
final click = _bandNoise(_n(0.006), n.hz * 3, 2.0, rnd);
|
||||||
|
for (var i = 0; i < click.length && i < len; i++) {
|
||||||
|
out[i] += 0.5 * click[i] * (1 - i / click.length);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hardwood: two resonances excited by a very short noise burst.
|
||||||
|
Float64List _clack(Note n, math.Random rnd) {
|
||||||
|
final len = _n(n.len);
|
||||||
|
final out = Float64List(len);
|
||||||
|
final burst = Float64List(len);
|
||||||
|
for (var i = 0; i < _n(0.002) && i < len; i++) {
|
||||||
|
burst[i] = rnd.nextDouble() * 2 - 1;
|
||||||
|
}
|
||||||
|
for (final (f, q, g) in [(n.hz, 18.0, 1.0), (n.hz * 1.47, 14.0, 0.6), (n.hz * 2.6, 10.0, 0.25)]) {
|
||||||
|
final r = _biquadBand(burst, f, q);
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
out[i] += g * r[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final k = math.log(100) / len;
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
out[i] *= math.exp(-k * i);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Air through a band that sweeps from [Note.hz] to [Note.to], swelling in
|
||||||
|
/// and out (a paper door sliding along its track).
|
||||||
|
Float64List _slide(Note n, math.Random rnd) {
|
||||||
|
final len = _n(n.len);
|
||||||
|
final noise = Float64List(len);
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
noise[i] = rnd.nextDouble() * 2 - 1;
|
||||||
|
}
|
||||||
|
final out = Float64List(len);
|
||||||
|
// Sweep in short blocks (a fixed band per block is plenty).
|
||||||
|
const block = 256;
|
||||||
|
var z1 = 0.0, z2 = 0.0;
|
||||||
|
for (var b0 = 0; b0 < len; b0 += block) {
|
||||||
|
final t = b0 / len;
|
||||||
|
final f = n.hz * math.pow((n.to ?? n.hz) / n.hz, t);
|
||||||
|
final c = _bandCoeffs(f.toDouble(), 2.5);
|
||||||
|
for (var i = b0; i < math.min(len, b0 + block); i++) {
|
||||||
|
final y = c.$1 * noise[i] + z1;
|
||||||
|
z1 = c.$2 * noise[i] - c.$4 * y + z2;
|
||||||
|
z2 = c.$3 * noise[i] - c.$5 * y;
|
||||||
|
final tt = i / len;
|
||||||
|
// Rises, holds, eases off: sin^2 swell with a tail.
|
||||||
|
final env = math.pow(math.sin(math.pi * math.min(1, tt * 1.25)), 2).toDouble();
|
||||||
|
// The track's grain: a slow flutter.
|
||||||
|
final grain = 1 + 0.25 * math.sin(2 * math.pi * 23 * i / sampleRate);
|
||||||
|
out[i] = y * env * grain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
Float64List _bandNoise(int len, double f, double q, math.Random rnd) {
|
||||||
|
final x = Float64List(len);
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
x[i] = rnd.nextDouble() * 2 - 1;
|
||||||
|
}
|
||||||
|
return _biquadBand(x, f, q);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RBJ band-pass (constant 0 dB peak): (b0, b1, b2, a1, a2), b1 = 0.
|
||||||
|
(double, double, double, double, double) _bandCoeffs(double f, double q) {
|
||||||
|
final w = 2 * math.pi * math.min(f, sampleRate * 0.45) / sampleRate;
|
||||||
|
final alpha = math.sin(w) / (2 * q);
|
||||||
|
final a0 = 1 + alpha;
|
||||||
|
return (alpha / a0, 0, -alpha / a0, -2 * math.cos(w) / a0, (1 - alpha) / a0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Float64List _biquadBand(Float64List x, double f, double q) {
|
||||||
|
final c = _bandCoeffs(f, q);
|
||||||
|
final y = Float64List(x.length);
|
||||||
|
var z1 = 0.0, z2 = 0.0;
|
||||||
|
for (var i = 0; i < x.length; i++) {
|
||||||
|
final v = c.$1 * x[i] + z1;
|
||||||
|
z1 = c.$2 * x[i] - c.$4 * v + z2;
|
||||||
|
z2 = c.$3 * x[i] - c.$5 * v;
|
||||||
|
y[i] = v;
|
||||||
|
}
|
||||||
|
return y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One pole, twice: a soft 12 dB/oct roll-off.
|
||||||
|
void _lowpass(Float64List out, double cutoff) {
|
||||||
|
final a = math.exp(-2 * math.pi * cutoff / sampleRate);
|
||||||
|
for (var pass = 0; pass < 2; pass++) {
|
||||||
|
var y = 0.0;
|
||||||
|
for (var i = 0; i < out.length; i++) {
|
||||||
|
y = (1 - a) * out[i] + a * y;
|
||||||
|
out[i] = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Space: four damped feedback combs and two allpasses (a dark, smooth
|
||||||
|
/// tail decaying to -60 dB in [tail] s) after a short pre-delay.
|
||||||
|
void _space(Float64List out, double mix, double tail) {
|
||||||
|
const combs = [0.0297, 0.0371, 0.0411, 0.0437];
|
||||||
|
const damp = 0.35;
|
||||||
|
final len = out.length;
|
||||||
|
final pre = _n(0.018);
|
||||||
|
final dry = Float64List(len);
|
||||||
|
for (var i = pre; i < len; i++) {
|
||||||
|
dry[i] = out[i - pre];
|
||||||
|
}
|
||||||
|
final wet = Float64List(len);
|
||||||
|
for (final d in combs) {
|
||||||
|
final delay = _n(d);
|
||||||
|
final g = math.pow(0.001, d / tail).toDouble();
|
||||||
|
final y = Float64List(len);
|
||||||
|
var lp = 0.0;
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
final back = i >= delay ? y[i - delay] : 0.0;
|
||||||
|
lp = back * (1 - damp) + lp * damp;
|
||||||
|
y[i] = dry[i] + g * lp;
|
||||||
|
wet[i] += y[i] / combs.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final d in const [0.005, 0.0017]) {
|
||||||
|
final delay = _n(d);
|
||||||
|
final x = Float64List.fromList(wet);
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
final xd = i >= delay ? x[i - delay] : 0.0;
|
||||||
|
final yd = i >= delay ? wet[i - delay] : 0.0;
|
||||||
|
wet[i] = -0.5 * x[i] + xd + 0.5 * yd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
out[i] += mix * wet[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [Note.hz] gliding to [Note.to] over the first ~60% of the note.
|
||||||
|
double _glide(Note n, double t) =>
|
||||||
|
n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(math.min(1, t * 1.6))).toDouble();
|
||||||
|
|
||||||
|
Float64List _star(Note n) {
|
||||||
|
final len = _n(n.len);
|
||||||
|
final out = Float64List(len);
|
||||||
|
final k = math.log(100) / len;
|
||||||
|
final attack = _n(0.004);
|
||||||
|
final index0 = 0.3 + 3.0 * n.bright;
|
||||||
|
final idxTau = _n(0.09 + 0.12 * n.bright);
|
||||||
|
const ratio = 3.5;
|
||||||
|
var c1 = 0.0, c2 = 0.0, m1 = 0.0, m2 = 0.0;
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
final hz = _glide(n, i / len);
|
||||||
|
final w = 2 * math.pi * hz / sampleRate;
|
||||||
|
c1 += w;
|
||||||
|
c2 += w * 1.0017;
|
||||||
|
m1 += w * ratio;
|
||||||
|
m2 += w * ratio * 1.0017;
|
||||||
|
final idx = index0 * math.exp(-i / idxTau);
|
||||||
|
final env = math.exp(-k * i) * (i < attack ? _smooth(i / attack) : 1);
|
||||||
|
out[i] = env * 0.5 * (math.sin(c1 + idx * math.sin(m1)) + math.sin(c2 + idx * math.sin(m2)));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
Float64List _drop(Note n) {
|
||||||
|
final len = _n(n.len);
|
||||||
|
final out = Float64List(len);
|
||||||
|
final k = math.log(100) / len;
|
||||||
|
final attack = _n(0.002);
|
||||||
|
final riseTau = _n(0.016);
|
||||||
|
var ph = 0.0;
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
final t = i / len;
|
||||||
|
final base = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(math.min(1, t * 2.5)));
|
||||||
|
final hz = base * (0.62 + 0.38 * (1 - math.exp(-i / riseTau)));
|
||||||
|
ph += 2 * math.pi * hz / sampleRate;
|
||||||
|
final env = math.exp(-k * i) * (i < attack ? _smooth(i / attack) : 1);
|
||||||
|
out[i] = env * (math.sin(ph) + 0.15 * math.sin(2 * ph));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
Float64List _pad(Note n, math.Random rnd) {
|
||||||
|
final len = _n(n.len);
|
||||||
|
final out = Float64List(len);
|
||||||
|
const detune = [-9.0, -3.0, 4.0, 10.0];
|
||||||
|
final ph = [for (var j = 0; j < detune.length + 1; j++) rnd.nextDouble() * 2 * math.pi];
|
||||||
|
final ratios = [for (final c in detune) math.pow(2, c / 1200).toDouble(), 0.5];
|
||||||
|
final amps = [for (final _ in detune) 1.0, 0.45];
|
||||||
|
const a = 0.33;
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
final t = i / len;
|
||||||
|
final env = t < a ? _smooth(t / a) : math.pow(1 - _smooth((t - a) / (1 - a)), 2).toDouble();
|
||||||
|
final vib = 1 + 0.002 * math.sin(2 * math.pi * 0.3 * i / sampleRate);
|
||||||
|
final hz = _glide(n, t) * vib;
|
||||||
|
var v = 0.0;
|
||||||
|
for (var j = 0; j < ratios.length; j++) {
|
||||||
|
ph[j] += 2 * math.pi * hz * ratios[j] / sampleRate;
|
||||||
|
v += amps[j] * (math.sin(ph[j]) + n.bright * (0.25 * math.sin(2 * ph[j]) + 0.1 * math.sin(3 * ph[j])));
|
||||||
|
}
|
||||||
|
out[i] = env * v / ratios.length;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
Float64List _sub(Note n) {
|
||||||
|
final len = _n(n.len);
|
||||||
|
final out = Float64List(len);
|
||||||
|
final k = math.log(100) / len;
|
||||||
|
final attack = _n(0.025);
|
||||||
|
var ph = 0.0;
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
final t = i / len;
|
||||||
|
final hz = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(t));
|
||||||
|
ph += 2 * math.pi * hz / sampleRate;
|
||||||
|
final env = math.exp(-k * i) * (i < attack ? _smooth(i / attack) : 1);
|
||||||
|
// Overtones so it still reads on small speakers.
|
||||||
|
out[i] = env * (math.sin(ph) + 0.35 * math.sin(2 * ph) + 0.12 * math.sin(3 * ph));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
Float64List _dust(Note n, math.Random rnd) {
|
||||||
|
final len = _n(n.len);
|
||||||
|
final out = Float64List(len);
|
||||||
|
const steps = [0, 2, 4, 7, 9, 12, 14];
|
||||||
|
final count = math.max(4, (n.len * 14).round());
|
||||||
|
for (var g = 0; g < count; g++) {
|
||||||
|
final tg = (g + rnd.nextDouble() * 0.6) / count * 0.75;
|
||||||
|
final start = _n(tg * n.len);
|
||||||
|
final centre = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, tg);
|
||||||
|
final f = centre * math.pow(2, steps[rnd.nextInt(steps.length)] / 12);
|
||||||
|
final glen = math.min(_n(0.1 + 0.15 * rnd.nextDouble()), len - start);
|
||||||
|
if (glen <= 0) continue;
|
||||||
|
final amp = (1 - tg) * (0.5 + 0.5 * rnd.nextDouble());
|
||||||
|
final k = math.log(100) / glen;
|
||||||
|
final w = 2 * math.pi * f / sampleRate;
|
||||||
|
for (var i = 0; i < glen; i++) {
|
||||||
|
out[start + i] += amp * math.exp(-k * i) * (i < 88 ? i / 88 : 1) * math.sin(w * i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 16-bit PCM mono WAV.
|
||||||
|
Uint8List wav(Float64List samples) {
|
||||||
|
final data = samples.length * 2;
|
||||||
|
final b = ByteData(44 + data);
|
||||||
|
void str(int o, String s) {
|
||||||
|
for (var i = 0; i < s.length; i++) {
|
||||||
|
b.setUint8(o + i, s.codeUnitAt(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
str(0, 'RIFF');
|
||||||
|
b.setUint32(4, 36 + data, Endian.little);
|
||||||
|
str(8, 'WAVE');
|
||||||
|
str(12, 'fmt ');
|
||||||
|
b.setUint32(16, 16, Endian.little);
|
||||||
|
b.setUint16(20, 1, Endian.little);
|
||||||
|
b.setUint16(22, 1, Endian.little);
|
||||||
|
b.setUint32(24, sampleRate, Endian.little);
|
||||||
|
b.setUint32(28, sampleRate * 2, Endian.little);
|
||||||
|
b.setUint16(32, 2, Endian.little);
|
||||||
|
b.setUint16(34, 16, Endian.little);
|
||||||
|
str(36, 'data');
|
||||||
|
b.setUint32(40, data, Endian.little);
|
||||||
|
for (var i = 0; i < samples.length; i++) {
|
||||||
|
b.setInt16(44 + i * 2, (samples[i].clamp(-1.0, 1.0) * 32767).round(), Endian.little);
|
||||||
|
}
|
||||||
|
return b.buffer.asUint8List();
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:media_kit/media_kit.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import 'sfx.dart';
|
||||||
|
import 'sound_bank.dart';
|
||||||
|
import 'synth.dart';
|
||||||
|
|
||||||
|
/// Plays [SoundSpec]s: each is rendered once to a WAV in the temp dir and
|
||||||
|
/// played on a small pool of audio-only mpv players (so two sounds can
|
||||||
|
/// overlap). [volume] (0..1) is read per play and applied on a square
|
||||||
|
/// curve (ears are log); [enabled] filters cues.
|
||||||
|
class SynthSfx implements SfxService {
|
||||||
|
final double Function() volume;
|
||||||
|
final bool Function(SfxKind kind) enabled;
|
||||||
|
/// Which sound set each sound plays from.
|
||||||
|
final SoundPreset Function(SfxKind kind) presetFor;
|
||||||
|
SynthSfx({required this.volume, bool Function(SfxKind kind)? enabled, SoundPreset Function(SfxKind kind)? presetFor})
|
||||||
|
: enabled = enabled ?? ((_) => true),
|
||||||
|
presetFor = presetFor ?? ((_) => defaultPreset);
|
||||||
|
|
||||||
|
static const _poolSize = 3;
|
||||||
|
final List<Player> _pool = [];
|
||||||
|
var _next = 0;
|
||||||
|
final Map<String, Future<String>> _files = {};
|
||||||
|
Directory? _dir;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> play(SfxCue cue) async {
|
||||||
|
if (!enabled(cue.kind)) return;
|
||||||
|
final preset = presetFor(cue.kind);
|
||||||
|
await playSpec('${preset.name}-${cue.kind.name}-${cue.readyIndex}',
|
||||||
|
soundFor(cue.kind, readyIndex: cue.readyIndex, preset: preset));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plays [kind] even when it is switched off (Settings' preview), always
|
||||||
|
/// freshly rendered: an edited spec plays after a hot reload.
|
||||||
|
Future<void> preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) {
|
||||||
|
final p = preset ?? presetFor(kind);
|
||||||
|
final key = '${p.name}-${kind.name}-$readyIndex';
|
||||||
|
_files.remove(key);
|
||||||
|
return playSpec(key, soundFor(kind, readyIndex: readyIndex, preset: p));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plays [spec], cached under [key] (same key = same sound).
|
||||||
|
Future<void> playSpec(String key, SoundSpec spec) async {
|
||||||
|
final v = volume();
|
||||||
|
if (v <= 0) return;
|
||||||
|
try {
|
||||||
|
final path = await (_files[key] ??= _write(key, spec));
|
||||||
|
final player = await _player();
|
||||||
|
await player.setVolume(100 * v * v);
|
||||||
|
await player.open(Media(path));
|
||||||
|
} catch (e) {
|
||||||
|
_files.remove(key);
|
||||||
|
debugPrint('sfx $key: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _write(String key, SoundSpec spec) async {
|
||||||
|
_dir ??= await Directory('${(await getTemporaryDirectory()).path}/watch_together_sfx').create(recursive: true);
|
||||||
|
final f = File('${_dir!.path}/$key.wav');
|
||||||
|
await f.writeAsBytes(wav(render(spec)));
|
||||||
|
return f.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Player> _player() async {
|
||||||
|
if (_pool.length < _poolSize) {
|
||||||
|
final p = Player(
|
||||||
|
configuration: const PlayerConfiguration(vo: 'null', osc: false, libass: false, title: 'sfx'),
|
||||||
|
);
|
||||||
|
await (p.platform as NativePlayer).setProperty('audio-display', 'no');
|
||||||
|
_pool.add(p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
return _pool[_next++ % _poolSize];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> dispose() async {
|
||||||
|
for (final p in _pool) {
|
||||||
|
await p.dispose();
|
||||||
|
}
|
||||||
|
_pool.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io' show File;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget;
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
|
import '../files/folder_store.dart';
|
||||||
|
import '../files/ui/files_page.dart' show showFilesFolder;
|
||||||
import '../jellyfin/accounts.dart';
|
import '../jellyfin/accounts.dart';
|
||||||
import '../jellyfin/browse_scope.dart' show BrowseStart;
|
import '../jellyfin/browse_scope.dart' show BrowseStart;
|
||||||
import '../jellyfin/session_media.dart';
|
import '../jellyfin/session_media.dart';
|
||||||
import '../screens/home/connect_flow.dart';
|
import '../screens/home/connect_flow.dart';
|
||||||
|
import '../session/session_coordinator.dart' show ConnectParams;
|
||||||
import '../screens/home/health_probe.dart';
|
import '../screens/home/health_probe.dart';
|
||||||
import '../screens/home/media_picker.dart';
|
import '../screens/home/media_picker.dart';
|
||||||
import '../screens/home/profile.dart';
|
import '../screens/home/profile.dart';
|
||||||
@@ -18,6 +23,7 @@ import '../state/settings.dart';
|
|||||||
import '../update/update_controller.dart';
|
import '../update/update_controller.dart';
|
||||||
import 'chat_panel.dart';
|
import 'chat_panel.dart';
|
||||||
import 'connect_cards.dart';
|
import 'connect_cards.dart';
|
||||||
|
import 'handoff.dart';
|
||||||
import 'library_tab.dart';
|
import 'library_tab.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
import 'party_strip.dart';
|
import 'party_strip.dart';
|
||||||
@@ -26,6 +32,7 @@ import '../schedule/ui/schedule_page.dart';
|
|||||||
import 'session_player.dart';
|
import 'session_player.dart';
|
||||||
import 'settings_tab.dart';
|
import 'settings_tab.dart';
|
||||||
import 'toasts.dart';
|
import 'toasts.dart';
|
||||||
|
import 'welcome.dart';
|
||||||
|
|
||||||
/// The one window: the party strip over a body that fades through between
|
/// The one window: the party strip over a body that fades through between
|
||||||
/// the tabs and the full player, the floating mini player, the chat panel
|
/// the tabs and the full player, the floating mini player, the chat panel
|
||||||
@@ -36,6 +43,9 @@ import 'toasts.dart';
|
|||||||
/// hidden), mini it is a 384x216 box snapped to a corner. A mode change
|
/// hidden), mini it is a 384x216 box snapped to a corner. A mode change
|
||||||
/// fades the layer out, moves it while invisible and fades it back in, so
|
/// fades the layer out, moves it while invisible and fades it back in, so
|
||||||
/// the video is never mounted twice nor remounted.
|
/// the video is never mounted twice nor remounted.
|
||||||
|
///
|
||||||
|
/// A drop anywhere on the window or Ctrl+V outside a text field is a
|
||||||
|
/// [Handoff]: a card near the pointer asks how to open it.
|
||||||
class AppShell extends StatefulWidget {
|
class AppShell extends StatefulWidget {
|
||||||
final UpdateController? updates;
|
final UpdateController? updates;
|
||||||
final bool autoUpdateCheck;
|
final bool autoUpdateCheck;
|
||||||
@@ -88,6 +98,11 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
Offset? _drag; // mini top-left while dragging
|
Offset? _drag; // mini top-left while dragging
|
||||||
bool _snapping = false;
|
bool _snapping = false;
|
||||||
|
|
||||||
|
/// Last pointer position in the window (where a paste's card opens).
|
||||||
|
Offset? _pointer;
|
||||||
|
bool _dragging = false;
|
||||||
|
bool _handoffOpen = false;
|
||||||
|
|
||||||
final _flow = ConnectFlow();
|
final _flow = ConnectFlow();
|
||||||
late final _join = JoinFields(SessionState.instance.lastForm);
|
late final _join = JoinFields(SessionState.instance.lastForm);
|
||||||
late final _host = HostFields(SessionState.instance.lastForm);
|
late final _host = HostFields(SessionState.instance.lastForm);
|
||||||
@@ -99,12 +114,15 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
/// The next media to come up opens full (just joined, or put up here).
|
/// The next media to come up opens full (just joined, or put up here).
|
||||||
bool _autoFull = false;
|
bool _autoFull = false;
|
||||||
int _lastMediaId = -1;
|
int _lastMediaId = -1;
|
||||||
double? _mutedVolume;
|
|
||||||
bool _dialogOpen = false;
|
bool _dialogOpen = false;
|
||||||
String? _lastKickShown;
|
|
||||||
final _stops = <void Function()>[];
|
final _stops = <void Function()>[];
|
||||||
// Rebuild the shell on these flips, not on every state broadcast.
|
// Rebuild the shell on these flips, not on every state broadcast.
|
||||||
final _online = computed(() => SessionState.instance.route.value == AppRoute.session);
|
final _online = computed(() => SessionState.instance.route.value == AppRoute.session);
|
||||||
|
/// Nobody else watches: offline, solo, or solo becoming a server.
|
||||||
|
final _alone = computed(() {
|
||||||
|
final s = SessionState.instance;
|
||||||
|
return s.route.value != AppRoute.session || s.solo.value || s.switching.value;
|
||||||
|
});
|
||||||
final _hasMedia = computed(() =>
|
final _hasMedia = computed(() =>
|
||||||
SessionState.instance.route.value == AppRoute.session &&
|
SessionState.instance.route.value == AppRoute.session &&
|
||||||
SessionState.instance.coordinator.serverState.value?.media != null);
|
SessionState.instance.coordinator.serverState.value?.media != null);
|
||||||
@@ -127,7 +145,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
// Reads linked servers and checks their tokens in the background.
|
// Reads linked servers and checks their tokens in the background.
|
||||||
_jellyfin.ensureLoaded();
|
_jellyfin.ensureLoaded();
|
||||||
if (widget.autoUpdateCheck) _autoCheck();
|
if (widget.autoUpdateCheck) _autoCheck();
|
||||||
_flow.onKicked = _showKicked;
|
HardwareKeyboard.instance.addHandler(_onKey);
|
||||||
final session = SessionState.instance;
|
final session = SessionState.instance;
|
||||||
final coord = session.coordinator;
|
final coord = session.coordinator;
|
||||||
_feedSeen = coord.feedLog.peek().length;
|
_feedSeen = coord.feedLog.peek().length;
|
||||||
@@ -167,17 +185,28 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
if (!_chatOpen) setState(() => _unread += chats);
|
if (!_chatOpen) setState(() => _unread += chats);
|
||||||
}));
|
}));
|
||||||
}));
|
}));
|
||||||
// Kicked: back offline, and say so.
|
// Back from sitting out with the player closed: show it (the media
|
||||||
|
// plays again).
|
||||||
|
var wasOut = false;
|
||||||
_stops.add(effect(() {
|
_stops.add(effect(() {
|
||||||
final kick = coord.kickReason.value;
|
final out = coord.selfOut.value;
|
||||||
if (kick == null) {
|
if (wasOut && !out && session.route.peek() == AppRoute.session) {
|
||||||
_lastKickShown = null;
|
_later(() {
|
||||||
return;
|
if (_mode == PlayerMode.none) _show(PlayerMode.mini);
|
||||||
}
|
});
|
||||||
untracked(() {
|
}
|
||||||
if (session.route.value == AppRoute.session) session.route.value = AppRoute.home;
|
wasOut = out;
|
||||||
|
}));
|
||||||
|
// Solo becoming a hosted server: the player closes while the media
|
||||||
|
// moves over and comes back the way it was once it is up again.
|
||||||
|
_stops.add(effect(() {
|
||||||
|
if (!session.switching.value) return;
|
||||||
|
_later(() {
|
||||||
|
_lastMediaId = -1;
|
||||||
|
_autoFull = _mode == PlayerMode.full;
|
||||||
|
if (_fullscreen) _toggleFullscreen();
|
||||||
|
_show(PlayerMode.none);
|
||||||
});
|
});
|
||||||
if (kick.isNotEmpty) _later(() => _showKicked(kick));
|
|
||||||
}));
|
}));
|
||||||
// The hosted server died mid-session (SessionState leaves): say so.
|
// The hosted server died mid-session (SessionState leaves): say so.
|
||||||
_stops.add(effect(() {
|
_stops.add(effect(() {
|
||||||
@@ -195,8 +224,10 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
for (final stop in _stops) {
|
for (final stop in _stops) {
|
||||||
stop();
|
stop();
|
||||||
}
|
}
|
||||||
|
HardwareKeyboard.instance.removeHandler(_onKey);
|
||||||
_fade.dispose();
|
_fade.dispose();
|
||||||
_online.dispose();
|
_online.dispose();
|
||||||
|
_alone.dispose();
|
||||||
_hasMedia.dispose();
|
_hasMedia.dispose();
|
||||||
_flow.dispose();
|
_flow.dispose();
|
||||||
_join.dispose();
|
_join.dispose();
|
||||||
@@ -227,7 +258,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _wentOffline() {
|
void _wentOffline() {
|
||||||
_mutedVolume = null;
|
|
||||||
if (_fullscreen) _toggleFullscreen();
|
if (_fullscreen) _toggleFullscreen();
|
||||||
setState(() {
|
setState(() {
|
||||||
_chatOpen = false;
|
_chatOpen = false;
|
||||||
@@ -243,7 +273,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
Future<void> _show(PlayerMode m) async {
|
Future<void> _show(PlayerMode m) async {
|
||||||
if (m == _mode) return;
|
if (m == _mode) return;
|
||||||
final seq = ++_modeSeq;
|
final seq = ++_modeSeq;
|
||||||
if (m != PlayerMode.none) _unmute();
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_from = _shown;
|
_from = _shown;
|
||||||
_mode = m;
|
_mode = m;
|
||||||
@@ -267,27 +296,15 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
_show(PlayerMode.mini);
|
_show(PlayerMode.mini);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mini's ×: stop watching but stay in the server (muted until shown).
|
/// The player's ×: sit out (stay in the server, nothing loaded; the
|
||||||
|
/// player shows the join-back card). Once out, × closes the player.
|
||||||
void _closePlayer() {
|
void _closePlayer() {
|
||||||
final playback = SessionState.instance.coordinator.playback;
|
final coord = SessionState.instance.coordinator;
|
||||||
if (playback.alive.peek()) {
|
if (coord.selfOut.peek()) {
|
||||||
try {
|
|
||||||
_mutedVolume = playback.player.state.volume;
|
|
||||||
playback.player.setVolume(0);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
_show(PlayerMode.none);
|
_show(PlayerMode.none);
|
||||||
|
} else {
|
||||||
|
coord.sitOut();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _unmute() {
|
|
||||||
final v = _mutedVolume;
|
|
||||||
if (v == null) return;
|
|
||||||
_mutedVolume = null;
|
|
||||||
final playback = SessionState.instance.coordinator.playback;
|
|
||||||
if (!playback.alive.peek()) return;
|
|
||||||
try {
|
|
||||||
playback.player.setVolume(v);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _setWindowFullscreen(bool on) async {
|
Future<void> _setWindowFullscreen(bool on) async {
|
||||||
@@ -345,7 +362,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
_selectTab(ShellTab.library);
|
_selectTab(ShellTab.library);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Library → "Put it up for everyone"; the player opens full.
|
/// Library → "Put it up for everyone"; the player opens full. Offline
|
||||||
|
/// it starts a solo session first (just you, on this computer).
|
||||||
Future<String?> _putUp(PickedMedia media) async {
|
Future<String?> _putUp(PickedMedia media) async {
|
||||||
final session = SessionState.instance;
|
final session = SessionState.instance;
|
||||||
if (media is JellyfinMedia) {
|
if (media is JellyfinMedia) {
|
||||||
@@ -356,6 +374,14 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
}
|
}
|
||||||
unawaited(_jellyfin.rememberPick(media));
|
unawaited(_jellyfin.rememberPick(media));
|
||||||
}
|
}
|
||||||
|
if (!_online.peek()) {
|
||||||
|
final error = await _startSolo();
|
||||||
|
if (error != null || !mounted) return error;
|
||||||
|
}
|
||||||
|
// Alone, a link plays direct: there is nobody to pass it on to.
|
||||||
|
if (media is LinkMedia && session.solo.peek()) {
|
||||||
|
media = LinkMedia(media.url, relay: LinkRelay.direct, anyway: media.anyway);
|
||||||
|
}
|
||||||
final error = await session.coordinator.swapPicked(media);
|
final error = await session.coordinator.swapPicked(media);
|
||||||
if (error != null || !mounted) return error;
|
if (error != null || !mounted) return error;
|
||||||
final f = session.lastForm;
|
final f = session.lastForm;
|
||||||
@@ -368,9 +394,168 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Starts a solo session; returns an error text, or null.
|
||||||
|
Future<String?> _startSolo() async {
|
||||||
|
final profile = Profile.instance;
|
||||||
|
try {
|
||||||
|
await profile.ensureLoaded();
|
||||||
|
final name = profile.name.peek().trim();
|
||||||
|
await SessionState.instance.startSolo(ConnectParams(
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 0,
|
||||||
|
secret: '',
|
||||||
|
name: name.isEmpty ? 'Me' : name,
|
||||||
|
avatar: profile.avatarBase64(),
|
||||||
|
));
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
return "couldn't start playing: $e";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Library's "Watch solo": somewhere to pick from (Jellyfin when linked,
|
||||||
|
/// else Files).
|
||||||
|
void _watchSolo() => _openLibrary(_jellyfin.isEmpty ? MediaKind.file : MediaKind.jellyfin);
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- handoff
|
||||||
|
|
||||||
|
/// Nothing (a dialog, a popover) is over the shell.
|
||||||
|
bool get _onTop => !_handoffOpen && handoffHolds == 0 && (ModalRoute.of(context)?.isCurrent ?? true);
|
||||||
|
|
||||||
|
/// Ctrl+V outside a text field (a focused field pastes on its own).
|
||||||
|
bool _onKey(KeyEvent e) {
|
||||||
|
if (e is! KeyDownEvent || e.logicalKey != LogicalKeyboardKey.keyV) return false;
|
||||||
|
final keys = HardwareKeyboard.instance;
|
||||||
|
if (!(keys.isControlPressed || keys.isMetaPressed) || keys.isShiftPressed || keys.isAltPressed) return false;
|
||||||
|
if (!mounted || !_onTop) return false;
|
||||||
|
final inField =
|
||||||
|
FocusManager.instance.primaryFocus?.context?.findAncestorStateOfType<EditableTextState>() != null;
|
||||||
|
if (inField) return false;
|
||||||
|
unawaited(_paste());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clipboard files first, then text (a link or absolute paths). A pasted
|
||||||
|
/// image has no taker yet, so the clipboard's image data is not read.
|
||||||
|
Future<void> _paste() async {
|
||||||
|
var files = const <String>[];
|
||||||
|
String? text;
|
||||||
|
try {
|
||||||
|
files = await ClipboardX.readFiles();
|
||||||
|
} catch (_) {}
|
||||||
|
if (files.isEmpty) {
|
||||||
|
try {
|
||||||
|
text = await ClipboardX.readText();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
await _handOff(classifyHandoff(paths: files, text: text), _pointer);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handOff(Handoff item, Offset? at) async {
|
||||||
|
if (!mounted || !_onTop || item is HandoffNothing || item is HandoffImage) return;
|
||||||
|
final size = MediaQuery.sizeOf(context);
|
||||||
|
final align = at == null || size.isEmpty
|
||||||
|
? Alignment.center
|
||||||
|
: Alignment((at.dx / size.width * 2 - 1).clamp(-1, 1), (at.dy / size.height * 2 - 1).clamp(-1, 1));
|
||||||
|
_handoffOpen = true;
|
||||||
|
final HandoffAction? action;
|
||||||
|
try {
|
||||||
|
action = await showPopover<HandoffAction>(context,
|
||||||
|
at: align, child: HandoffCard(item: item, solo: _alone.peek(), queue: _online.peek()));
|
||||||
|
} finally {
|
||||||
|
_handoffOpen = false;
|
||||||
|
}
|
||||||
|
if (action == null || !mounted) return;
|
||||||
|
switch ((item, action)) {
|
||||||
|
case (HandoffVideos(:final paths), HandoffAction.putUp):
|
||||||
|
final error = await _putUp(FileMedia(paths.first));
|
||||||
|
if (error != null) {
|
||||||
|
SessionState.instance.coordinator.showToast(error);
|
||||||
|
} else {
|
||||||
|
await FolderStore.instance.ensureLoaded();
|
||||||
|
FolderStore.instance.addRecent(paths.first);
|
||||||
|
}
|
||||||
|
case (HandoffVideos(:final paths), HandoffAction.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), _):
|
||||||
|
await _addFolder(path);
|
||||||
|
case (HandoffLink(:final url), _):
|
||||||
|
_picker.link.text = url;
|
||||||
|
_openLibrary(MediaKind.link);
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addFolder(String dir) async {
|
||||||
|
await FolderStore.instance.ensureLoaded();
|
||||||
|
FolderStore.instance.addFolder(dir);
|
||||||
|
_openFiles(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Library → Files on [dir] (null: Recent).
|
||||||
|
void _openFiles(String? dir) {
|
||||||
|
showFilesFolder(dir);
|
||||||
|
_openLibrary(MediaKind.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openLibrary(MediaKind source) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_picker.source = source;
|
||||||
|
_selectTab(ShellTab.library);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Off on Settings: its own drop targets (cookies) take the files there.
|
||||||
|
Widget _dropTarget(Widget child) => DropTarget(
|
||||||
|
enable: _tab != ShellTab.settings,
|
||||||
|
onDragEntered: (_) => setState(() => _dragging = _onTop),
|
||||||
|
onDragExited: (_) => setState(() => _dragging = false),
|
||||||
|
onDragDone: (d) {
|
||||||
|
setState(() => _dragging = false);
|
||||||
|
_handOff(classifyHandoff(paths: DesktopDropX.pathsOf(d.files)), d.localPosition);
|
||||||
|
},
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
child,
|
||||||
|
Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration: easeTime,
|
||||||
|
curve: ease,
|
||||||
|
opacity: _dragging ? 1 : 0,
|
||||||
|
child: const _DropOverlay(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// --------------------------------------------------------------- dialogs
|
// --------------------------------------------------------------- dialogs
|
||||||
|
|
||||||
|
/// Leave asks first; a solo session just stops.
|
||||||
Future<void> _confirmLeave() async {
|
Future<void> _confirmLeave() async {
|
||||||
|
final session = SessionState.instance;
|
||||||
|
if (session.solo.peek()) {
|
||||||
|
await session.leave();
|
||||||
|
return;
|
||||||
|
}
|
||||||
final ok = await showDialog<bool>(
|
final ok = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
@@ -389,12 +574,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
if (ok == true) await SessionState.instance.leave();
|
if (ok == true) await SessionState.instance.leave();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showKicked(String reason) async {
|
|
||||||
if (reason == _lastKickShown) return;
|
|
||||||
_lastKickShown = reason;
|
|
||||||
await _alert('You were removed', reason.isEmpty ? 'The host removed you from the server.' : reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _alert(String title, String text) async {
|
Future<void> _alert(String title, String text) async {
|
||||||
if (!mounted || _dialogOpen) return;
|
if (!mounted || _dialogOpen) return;
|
||||||
_dialogOpen = true;
|
_dialogOpen = true;
|
||||||
@@ -426,7 +605,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return CallbackShortcuts(
|
final shell = CallbackShortcuts(
|
||||||
bindings: {
|
bindings: {
|
||||||
const SingleActivator(LogicalKeyboardKey.comma, control: true): () => _selectTab(ShellTab.settings),
|
const SingleActivator(LogicalKeyboardKey.comma, control: true): () => _selectTab(ShellTab.settings),
|
||||||
},
|
},
|
||||||
@@ -434,6 +613,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
backgroundColor: _fullscreen ? Colors.black : scheme.surface,
|
backgroundColor: _fullscreen ? Colors.black : scheme.surface,
|
||||||
body: SignalBuilder(builder: (context) {
|
body: SignalBuilder(builder: (context) {
|
||||||
final online = _online.value;
|
final online = _online.value;
|
||||||
|
final alone = _alone.value;
|
||||||
final hasMedia = _hasMedia.value;
|
final hasMedia = _hasMedia.value;
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -448,6 +628,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
onJoin: () => _connect(ConnectMode.join),
|
onJoin: () => _connect(ConnectMode.join),
|
||||||
onHost: () => _connect(ConnectMode.host),
|
onHost: () => _connect(ConnectMode.host),
|
||||||
onLeave: _confirmLeave,
|
onLeave: _confirmLeave,
|
||||||
|
onInvite: () => _connect(ConnectMode.host),
|
||||||
onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
|
onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
|
||||||
onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null,
|
onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null,
|
||||||
updates: widget.updates,
|
updates: widget.updates,
|
||||||
@@ -457,7 +638,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
key: const ValueKey('body'),
|
key: const ValueKey('body'),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: _body(online)),
|
Expanded(child: _body(online, alone)),
|
||||||
AnimatedSize(
|
AnimatedSize(
|
||||||
duration: easeTime,
|
duration: easeTime,
|
||||||
curve: ease,
|
curve: ease,
|
||||||
@@ -474,24 +655,49 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
return Listener(
|
||||||
|
onPointerHover: (e) => _pointer = e.localPosition,
|
||||||
|
onPointerDown: (e) => _pointer = e.localPosition,
|
||||||
|
child: _dropTarget(shell),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _body(bool online) {
|
/// No Jellyfin account, no server joined or hosted yet, band not
|
||||||
|
/// dismissed: the welcome band explains the three ways in.
|
||||||
|
bool get _newcomer =>
|
||||||
|
!AppSettings.instance.welcomeDismissed.value &&
|
||||||
|
_jellyfin.isEmpty &&
|
||||||
|
SessionState.instance.recents.value.isEmpty;
|
||||||
|
|
||||||
|
Widget _body(bool online, bool alone) {
|
||||||
final coord = SessionState.instance.coordinator;
|
final coord = SessionState.instance.coordinator;
|
||||||
final tabBody = switch (_tab) {
|
final tabBody = switch (_tab) {
|
||||||
ShellTab.library => LibraryTab(
|
ShellTab.library => LibraryTab(
|
||||||
key: ValueKey('library-$_libraryGen'),
|
key: ValueKey('library-$_libraryGen'),
|
||||||
controller: _picker,
|
controller: _picker,
|
||||||
accounts: _jellyfin,
|
accounts: _jellyfin,
|
||||||
online: online,
|
solo: alone,
|
||||||
isHost: coord.hostFile != null,
|
// Offline, a put-up starts a solo session, which hosts.
|
||||||
|
isHost: !online || coord.hostFile != null,
|
||||||
|
// Only for newcomers: the strip has Join and Host for everyone else.
|
||||||
|
banner: online || !_newcomer
|
||||||
|
? null
|
||||||
|
: WelcomeBand(
|
||||||
|
onJoin: () => _connect(ConnectMode.join),
|
||||||
|
onHost: () => _connect(ConnectMode.host),
|
||||||
|
onSolo: _watchSolo,
|
||||||
|
onDismiss: () => setState(AppSettings.instance.dismissWelcome),
|
||||||
|
),
|
||||||
jellyfinStart: _libraryStart,
|
jellyfinStart: _libraryStart,
|
||||||
onPutUp: _putUp,
|
onPutUp: _putUp,
|
||||||
|
// Up next needs a server (a solo session has one).
|
||||||
|
onAddToQueue: online ? coord.queue.add : null,
|
||||||
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
|
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
|
||||||
),
|
),
|
||||||
ShellTab.schedule => SchedulePage(
|
ShellTab.schedule => SchedulePage(
|
||||||
online: online,
|
solo: alone,
|
||||||
onPutUp: _putUp,
|
onPutUp: _putUp,
|
||||||
|
onAddToQueue: online ? coord.queue.add : null,
|
||||||
onToast: coord.showToast,
|
onToast: coord.showToast,
|
||||||
),
|
),
|
||||||
ShellTab.settings => SettingsTab(
|
ShellTab.settings => SettingsTab(
|
||||||
@@ -533,7 +739,11 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (online && _shown != PlayerMode.none) _playerLayer(area),
|
if (online && _shown != PlayerMode.none) _playerLayer(area),
|
||||||
Positioned(left: 16, bottom: _fullscreen ? 90 : 16, child: const PartyToasts()),
|
Positioned(left: 16, bottom: _fullscreen ? 90 : 16, child: PartyToasts(
|
||||||
|
onOpenChat: () {
|
||||||
|
if (!_chatOpen) _toggleChat();
|
||||||
|
},
|
||||||
|
)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -619,3 +829,39 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Over the window while something is dragged over it.
|
||||||
|
class _DropOverlay extends StatelessWidget {
|
||||||
|
const _DropOverlay();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return Container(
|
||||||
|
key: const ValueKey('drop-overlay'),
|
||||||
|
margin: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: scheme.surface.withValues(alpha: 0.55),
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
border: Border.all(color: scheme.primary.withValues(alpha: 0.7), width: 2),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Material(
|
||||||
|
color: scheme.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
elevation: 8,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.file_download_outlined, color: scheme.primary),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text('Drop to open', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import 'package:signals/signals_flutter.dart';
|
|||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../screens/home/profile.dart';
|
import '../screens/home/profile.dart';
|
||||||
|
import '../session/delivery.dart';
|
||||||
import '../session/session_coordinator.dart';
|
import '../session/session_coordinator.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
@@ -17,6 +18,14 @@ class AvatarInfo {
|
|||||||
final Color color;
|
final Color color;
|
||||||
final ImageProvider? image;
|
final ImageProvider? image;
|
||||||
final bool ready, buffering, relaying, stale, host, self;
|
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({
|
const AvatarInfo({
|
||||||
this.id = '',
|
this.id = '',
|
||||||
required this.name,
|
required this.name,
|
||||||
@@ -28,6 +37,9 @@ class AvatarInfo {
|
|||||||
this.stale = false,
|
this.stale = false,
|
||||||
this.host = false,
|
this.host = false,
|
||||||
this.self = false,
|
this.self = false,
|
||||||
|
this.out = false,
|
||||||
|
this.error = '',
|
||||||
|
this.delivery = '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +87,7 @@ ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Your own avatar from the profile (also offline).
|
/// Your own avatar from the profile (also offline).
|
||||||
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false}) {
|
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false, String error = ''}) {
|
||||||
final p = Profile.instance;
|
final p = Profile.instance;
|
||||||
final png = p.picture.value;
|
final png = p.picture.value;
|
||||||
final name = p.name.value;
|
final name = p.name.value;
|
||||||
@@ -86,6 +98,8 @@ AvatarInfo selfAvatarInfo({bool ready = false, bool host = false}) {
|
|||||||
ready: ready,
|
ready: ready,
|
||||||
host: host,
|
host: host,
|
||||||
self: true,
|
self: true,
|
||||||
|
out: out,
|
||||||
|
error: error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,19 +108,23 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
|
|||||||
final self = m.id.isNotEmpty && m.id == coord.sync.memberId;
|
final self = m.id.isNotEmpty && m.id == coord.sync.memberId;
|
||||||
final relay = r == null ? null : stateMediaRelay(r);
|
final relay = r == null ? null : stateMediaRelay(r);
|
||||||
final playing = r?.media != null;
|
final playing = r?.media != null;
|
||||||
|
final delivery = r == null ? null : deliveryOf(r, selfId: coord.sync.memberId, names: coord.memberNames);
|
||||||
return AvatarInfo(
|
return AvatarInfo(
|
||||||
id: m.id,
|
id: m.id,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
color: self ? Profile.instance.color.value : colorForName(m.name),
|
color: self ? Profile.instance.color.value : colorForName(m.name),
|
||||||
image: memberAvatarImage(m, coord),
|
image: memberAvatarImage(m, coord),
|
||||||
ready: m.ready,
|
ready: m.ready,
|
||||||
buffering: !m.stale && m.ready && playing && m.buffered < RelayConstants.minBufferSecs,
|
buffering: !m.stale && m.ready && m.error.isEmpty && playing && m.buffered < r!.bufferNeed,
|
||||||
relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
|
relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
|
||||||
stale: m.stale,
|
stale: m.stale,
|
||||||
// The state marks the host (`members[].host`); your own app also
|
// The state marks the host (`members[].host`); your own app also
|
||||||
// knows before the first state.
|
// knows before the first state.
|
||||||
host: m.host || (self && coord.hostFile != null),
|
host: m.host || (self && coord.hostFile != null),
|
||||||
self: self,
|
self: self,
|
||||||
|
out: m.out,
|
||||||
|
error: m.error,
|
||||||
|
delivery: delivery == null ? '' : memberDeliveryOf(m, r!, delivery).detail,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +150,8 @@ class PartyWatch extends StatelessWidget {
|
|||||||
|
|
||||||
/// Circle avatar with a status ring outside it and a gap in the background
|
/// Circle avatar with a status ring outside it and a gap in the background
|
||||||
/// colour, so overlapping neighbours never hide the status. Crown = host,
|
/// colour, so overlapping neighbours never hide the status. Crown = host,
|
||||||
/// tower = passing the video on, spinner = buffering, dimmed = stale.
|
/// tower = passing the video on, spinner = buffering, dimmed = stale,
|
||||||
|
/// dimmed with a moon = sitting out, red "!" = cannot play the media.
|
||||||
class StatusAvatar extends StatelessWidget {
|
class StatusAvatar extends StatelessWidget {
|
||||||
final AvatarInfo info;
|
final AvatarInfo info;
|
||||||
final double size;
|
final double size;
|
||||||
@@ -176,7 +195,7 @@ class StatusAvatar extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
||||||
padding: EdgeInsets.all(stroke),
|
padding: EdgeInsets.all(stroke),
|
||||||
child: Opacity(
|
child: Opacity(
|
||||||
opacity: info.stale ? 0.4 : 1,
|
opacity: info.stale || info.out ? 0.4 : 1,
|
||||||
child: CircleAvatar(
|
child: CircleAvatar(
|
||||||
radius: size / 2,
|
radius: size / 2,
|
||||||
backgroundColor: info.color,
|
backgroundColor: info.color,
|
||||||
@@ -205,6 +224,28 @@ class StatusAvatar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (info.out)
|
||||||
|
Positioned(
|
||||||
|
key: const ValueKey('away-mark'),
|
||||||
|
left: -3,
|
||||||
|
bottom: -3,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
||||||
|
child: Icon(Icons.bedtime, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.blueGrey.shade200),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (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)
|
if (badges && info.relaying)
|
||||||
Positioned(
|
Positioned(
|
||||||
right: -3,
|
right: -3,
|
||||||
@@ -240,7 +281,7 @@ class _AvatarStackState extends State<AvatarStack> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final size = widget.size;
|
final size = widget.size;
|
||||||
int rank(AvatarInfo m) => m.host ? 0 : (m.ready ? 1 : 2);
|
int rank(AvatarInfo m) => m.host ? 0 : (m.out ? 3 : m.ready ? 1 : 2);
|
||||||
final sorted = [...widget.members]..sort((a, b) => rank(a) - rank(b));
|
final sorted = [...widget.members]..sort((a, b) => rank(a) - rank(b));
|
||||||
final visible = sorted.take(widget.shown).toList();
|
final visible = sorted.take(widget.shown).toList();
|
||||||
final extra = sorted.length - visible.length;
|
final extra = sorted.length - visible.length;
|
||||||
@@ -270,7 +311,12 @@ class _AvatarStackState extends State<AvatarStack> {
|
|||||||
left: i * step,
|
left: i * step,
|
||||||
top: 0,
|
top: 0,
|
||||||
child: Tooltip(
|
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),
|
child: StatusAvatar(info: visible[i], size: size),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,22 +1,72 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
|
import '../widgets/app_menu.dart';
|
||||||
|
import '../widgets/copy_open.dart';
|
||||||
|
import '../widgets/text_focus.dart';
|
||||||
import 'avatar.dart';
|
import 'avatar.dart';
|
||||||
|
|
||||||
/// Side panel: chat with avatars, other activity as quiet lines, and the
|
/// Activity lines (joins, ready, seeks...) between the messages: off by
|
||||||
/// message field. Width is fixed; the shell slides it in and out.
|
/// default, kept for the app's run.
|
||||||
class ChatPanel extends StatelessWidget {
|
final showActivity = ValueNotifier(false);
|
||||||
|
|
||||||
|
/// Side panel: chat with avatars, other activity as quiet lines (behind
|
||||||
|
/// the "Activity" toggle), and the message field. Width is fixed; the
|
||||||
|
/// shell slides it in and out. The field takes focus on open; Tab and Esc
|
||||||
|
/// in it close the panel.
|
||||||
|
class ChatPanel extends StatefulWidget {
|
||||||
static const width = 340.0;
|
static const width = 340.0;
|
||||||
final TextEditingController chat;
|
final TextEditingController chat;
|
||||||
final FocusNode chatFocus;
|
final FocusNode chatFocus;
|
||||||
final VoidCallback onClose;
|
final VoidCallback onClose;
|
||||||
const ChatPanel({super.key, required this.chat, required this.chatFocus, required this.onClose});
|
const ChatPanel({super.key, required this.chat, required this.chatFocus, required this.onClose});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChatPanel> createState() => _ChatPanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChatPanelState extends State<ChatPanel> {
|
||||||
|
// Where focus was before the field took it; it goes back there on close
|
||||||
|
// (else a field behind could take it, and Space with it).
|
||||||
|
FocusNode? _before;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final before = FocusManager.instance.primaryFocus;
|
||||||
|
if (before != widget.chatFocus) _before = before;
|
||||||
|
widget.chatFocus.requestFocus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
final before = _before;
|
||||||
|
if (before != null && before.context != null && before.canRequestFocus && !textInputFocused(before)) {
|
||||||
|
before.requestFocus();
|
||||||
|
} else if (textInputFocused()) {
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
void _send() {
|
void _send() {
|
||||||
SessionState.instance.coordinator.sendChat(chat.text);
|
SessionState.instance.coordinator.sendChat(widget.chat.text);
|
||||||
chat.clear();
|
widget.chat.clear();
|
||||||
chatFocus.requestFocus();
|
widget.chatFocus.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEventResult _fieldKey(FocusNode node, KeyEvent e) {
|
||||||
|
if (e is! KeyDownEvent) return KeyEventResult.ignored;
|
||||||
|
if (e.logicalKey != LogicalKeyboardKey.tab && e.logicalKey != LogicalKeyboardKey.escape) {
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
widget.onClose();
|
||||||
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -24,7 +74,7 @@ class ChatPanel extends StatelessWidget {
|
|||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return Container(
|
return Container(
|
||||||
key: const ValueKey('chat-panel'),
|
key: const ValueKey('chat-panel'),
|
||||||
width: width,
|
width: ChatPanel.width,
|
||||||
color: scheme.surfaceContainerLow,
|
color: scheme.surfaceContainerLow,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -32,18 +82,35 @@ class ChatPanel extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.fromLTRB(16, 8, 4, 0),
|
padding: const EdgeInsets.fromLTRB(16, 8, 4, 0),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Text('Chat & activity', style: TextStyle(fontWeight: FontWeight.w600)),
|
const Text('Chat', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
IconButton(tooltip: 'Close (Tab)', onPressed: onClose, icon: const Icon(Icons.close, size: 18)),
|
ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: showActivity,
|
||||||
|
builder: (context, on, _) => FilterChip(
|
||||||
|
key: const ValueKey('chat-activity'),
|
||||||
|
label: const Text('Activity', style: TextStyle(fontSize: 12)),
|
||||||
|
tooltip: 'Show joins, ready, seeks and other activity',
|
||||||
|
selected: on,
|
||||||
|
showCheckmark: false,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
onSelected: (v) => showActivity.value = v,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(tooltip: 'Close (Tab)', onPressed: widget.onClose, icon: const Icon(Icons.close, size: 18)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: PartyWatch(builder: (context) {
|
child: ValueListenableBuilder<bool>(
|
||||||
final log = SessionState.instance.coordinator.feedLog.value;
|
valueListenable: showActivity,
|
||||||
|
builder: (context, activity, _) => PartyWatch(builder: (context) {
|
||||||
|
final all = SessionState.instance.coordinator.feedLog.value;
|
||||||
|
final log = activity ? all : [for (final f in all) if (f.action == 'chat') f];
|
||||||
if (log.isEmpty) {
|
if (log.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text('Nothing yet', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
child: Text(activity ? 'Nothing yet' : 'No messages yet',
|
||||||
|
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
@@ -54,12 +121,17 @@ class ChatPanel extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Focus(
|
||||||
|
canRequestFocus: false,
|
||||||
|
skipTraversal: true,
|
||||||
|
onKeyEvent: _fieldKey,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
key: const ValueKey('chat-field'),
|
key: const ValueKey('chat-field'),
|
||||||
controller: chat,
|
controller: widget.chat,
|
||||||
focusNode: chatFocus,
|
focusNode: widget.chatFocus,
|
||||||
onSubmitted: (_) => _send(),
|
onSubmitted: (_) => _send(),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Message (Enter)',
|
hintText: 'Message (Enter)',
|
||||||
@@ -70,6 +142,7 @@ class ChatPanel extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -87,7 +160,30 @@ class ActivityRow extends StatelessWidget {
|
|||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final time = _clock(item.at);
|
final time = _clock(item.at);
|
||||||
if (item.action == 'chat') {
|
if (item.action == 'chat') {
|
||||||
return Padding(
|
final link = firstLinkIn(item.reason);
|
||||||
|
// Right-click on the text: its selection menu plus the whole message
|
||||||
|
// and its link; beside it, the same as the app's menu.
|
||||||
|
final message = SelectableText(
|
||||||
|
item.reason,
|
||||||
|
contextMenuBuilder: (context, state) => AdaptiveTextSelectionToolbar.buttonItems(
|
||||||
|
anchors: state.contextMenuAnchors,
|
||||||
|
buttonItems: [
|
||||||
|
...state.contextMenuButtonItems,
|
||||||
|
for (final (label, action) in _messageActions(item.reason, link))
|
||||||
|
ContextMenuButtonItem(
|
||||||
|
label: label,
|
||||||
|
onPressed: () {
|
||||||
|
state.hideToolbar();
|
||||||
|
action();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return Builder(
|
||||||
|
builder: (context) => GestureDetector(
|
||||||
|
onSecondaryTap: () => _messageMenu(context, item.reason, link),
|
||||||
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -110,12 +206,14 @@ class ActivityRow extends StatelessWidget {
|
|||||||
Text(time, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
|
Text(time, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
SelectableText(item.reason),
|
message,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final (icon, color, text) = item.actor == 'server' ? serverFeedStyle(item) : feedStyle(item, scheme);
|
final (icon, color, text) = item.actor == 'server' ? serverFeedStyle(item) : feedStyle(item, scheme);
|
||||||
@@ -135,6 +233,28 @@ class ActivityRow extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The first http(s) link in [text], if any.
|
||||||
|
String? firstLinkIn(String text) => RegExp(r'https?://[^\s<>"]+', caseSensitive: false).firstMatch(text)?.group(0);
|
||||||
|
|
||||||
|
/// What a chat message offers: Copy message, and its link's Copy link and
|
||||||
|
/// Open in browser.
|
||||||
|
List<(String, VoidCallback)> _messageActions(String text, String? link) => [
|
||||||
|
(ReuseText.copyMessage, () => copyToClipboard(text, done: 'Message copied')),
|
||||||
|
if (link != null) ...[
|
||||||
|
(ReuseText.copyLink, () => copyToClipboard(link, done: 'Link copied')),
|
||||||
|
(ReuseText.openInBrowser, () => openLink(link)),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Right-click beside a chat message: [_messageActions] in the app's menu.
|
||||||
|
Future<void> _messageMenu(BuildContext context, String text, String? link) async {
|
||||||
|
final actions = _messageActions(text, link);
|
||||||
|
final pick = await showAppMenu<int>(context, items: [
|
||||||
|
for (final (i, (label, _)) in actions.indexed) AppMenuItem(value: i, label: label),
|
||||||
|
]);
|
||||||
|
if (pick != null) actions[pick].$2();
|
||||||
|
}
|
||||||
|
|
||||||
/// Icon, colour and wording of a member's feed action.
|
/// Icon, colour and wording of a member's feed action.
|
||||||
(IconData, Color, String) feedStyle(FeedMessage f, ColorScheme scheme) {
|
(IconData, Color, String) feedStyle(FeedMessage f, ColorScheme scheme) {
|
||||||
switch (f.action) {
|
switch (f.action) {
|
||||||
@@ -161,9 +281,20 @@ class ActivityRow extends StatelessWidget {
|
|||||||
return (Icons.swap_horiz, scheme.primary, 'changed the media');
|
return (Icons.swap_horiz, scheme.primary, 'changed the media');
|
||||||
case 'stale':
|
case 'stale':
|
||||||
return (Icons.cloud_off, Colors.orange, 'lost signal');
|
return (Icons.cloud_off, Colors.orange, 'lost signal');
|
||||||
case 'kick':
|
case 'out':
|
||||||
final reason = f.reason.isEmpty ? '' : ' · ${f.reason}';
|
final who = f.target;
|
||||||
return (Icons.gavel, Colors.red, 'kicked ${f.target ?? ''}$reason');
|
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:
|
default:
|
||||||
final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.target}';
|
final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.target}';
|
||||||
return (Icons.info_outline, Colors.grey, '${f.action}$target');
|
return (Icons.info_outline, Colors.grey, '${f.action}$target');
|
||||||
|
|||||||
@@ -41,8 +41,9 @@ class HostFields {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The strip's Join / Host buttons: a card over the window. It closes
|
/// The strip's Join / Host buttons (and solo's Invite friends, the Host
|
||||||
/// itself once the attempt got in; a failed one shows its error inside.
|
/// card): a card over the window. It closes itself once the attempt got
|
||||||
|
/// in; a failed one shows its error inside.
|
||||||
Future<void> showConnectCard(
|
Future<void> showConnectCard(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required ConnectMode mode,
|
required ConnectMode mode,
|
||||||
@@ -77,7 +78,9 @@ class _CloseWhenOnlineState extends State<_CloseWhenOnline> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_stop = effect(() {
|
_stop = effect(() {
|
||||||
if (SessionState.instance.route.value != AppRoute.session) return;
|
final s = SessionState.instance;
|
||||||
|
// Solo is in a session too: wait until it is a hosted server.
|
||||||
|
if (s.route.value != AppRoute.session || s.solo.value || s.switching.value) return;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) Navigator.of(context).maybePop();
|
if (mounted) Navigator.of(context).maybePop();
|
||||||
});
|
});
|
||||||
@@ -153,7 +156,7 @@ class JoinCard extends StatelessWidget {
|
|||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final busy = flow.busy == ConnectMode.join;
|
final busy = flow.busy == ConnectMode.join;
|
||||||
return _Card(
|
return _Card(
|
||||||
title: 'Join a server',
|
title: 'Join a friend',
|
||||||
children: [
|
children: [
|
||||||
SignalBuilder(builder: (context) {
|
SignalBuilder(builder: (context) {
|
||||||
final list = [
|
final list = [
|
||||||
@@ -221,6 +224,13 @@ class JoinCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ConnectButton(flow: flow, mode: ConnectMode.join, label: 'Join', onSubmit: _join, expand: true),
|
ConnectButton(flow: flow, mode: ConnectMode.join, label: 'Join', onSubmit: _join, expand: true),
|
||||||
ConnectFeedback(flow: flow, mode: ConnectMode.join),
|
ConnectFeedback(flow: flow, mode: ConnectMode.join),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 8),
|
||||||
|
child: Text(
|
||||||
|
'Your friend gives you the address and password. No account or Jellyfin needed.',
|
||||||
|
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -243,10 +253,13 @@ class HostCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// From a solo session this is "Invite friends": same server settings,
|
||||||
|
// and what plays carries over.
|
||||||
|
final solo = SessionState.instance.solo.peek();
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: flow,
|
listenable: flow,
|
||||||
builder: (context, _) => _Card(
|
builder: (context, _) => _Card(
|
||||||
title: 'Host a server',
|
title: solo ? 'Invite friends' : 'Host a server',
|
||||||
children: [
|
children: [
|
||||||
AbsorbPointer(
|
AbsorbPointer(
|
||||||
absorbing: flow.busy == ConnectMode.host,
|
absorbing: flow.busy == ConnectMode.host,
|
||||||
@@ -281,13 +294,16 @@ class HostCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ConnectButton(flow: flow, mode: ConnectMode.host, label: 'Host', onSubmit: _host, expand: true),
|
ConnectButton(
|
||||||
|
flow: flow, mode: ConnectMode.host, label: solo ? 'Start hosting' : 'Host', onSubmit: _host, expand: true),
|
||||||
ConnectFeedback(flow: flow, mode: ConnectMode.host),
|
ConnectFeedback(flow: flow, mode: ConnectMode.host),
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(top: 8),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'You pick what to watch afterwards, in the library.',
|
solo
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
? 'Your video keeps playing from where it is; friends join with your address.'
|
||||||
|
: 'You pick what to watch afterwards, in the library.',
|
||||||
|
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../files/scanner.dart' show FileKind, kindOf;
|
||||||
|
import '../files/ui/file_tiles.dart' show baseName;
|
||||||
|
|
||||||
|
// Something handed to the app, by a drop or by Ctrl+V outside a text
|
||||||
|
// field: what it is ([classifyHandoff], pure) and the small card that asks
|
||||||
|
// how to open it ([HandoffCard]). The shell wires both.
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Video files (by the Files page's extensions), in the order given.
|
||||||
|
final class HandoffVideos extends Handoff {
|
||||||
|
final List<String> paths;
|
||||||
|
const HandoffVideos(this.paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class HandoffFolder extends Handoff {
|
||||||
|
final String path;
|
||||||
|
const HandoffFolder(this.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An http(s) link.
|
||||||
|
final class HandoffLink extends Handoff {
|
||||||
|
final String url;
|
||||||
|
const HandoffLink(this.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An image file. Nothing takes one yet; the shell ignores it.
|
||||||
|
final class HandoffImage extends Handoff {
|
||||||
|
final String path;
|
||||||
|
const HandoffImage(this.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class HandoffNothing extends Handoff {
|
||||||
|
const HandoffNothing();
|
||||||
|
}
|
||||||
|
|
||||||
|
const _imageExtensions = {'png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'};
|
||||||
|
|
||||||
|
final _link = RegExp(r'^https?://\S+$', caseSensitive: false);
|
||||||
|
final _windowsAbsolute = RegExp(r'^([A-Za-z]:[\\/]|\\\\)');
|
||||||
|
|
||||||
|
bool _absolute(String p) => p.startsWith('/') || _windowsAbsolute.hasMatch(p);
|
||||||
|
|
||||||
|
/// A `file://` URI as a path; anything else as is.
|
||||||
|
String _pathOf(String p) {
|
||||||
|
if (!p.startsWith('file://')) return p;
|
||||||
|
try {
|
||||||
|
return Uri.parse(p).toFilePath();
|
||||||
|
} catch (_) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classifies dropped or pasted [paths] (files first) or, when there are
|
||||||
|
/// none, pasted [text]: a link, or one absolute path (or `file://` URI) per
|
||||||
|
/// line. Videos win over folders, folders over images; paths that do not
|
||||||
|
/// exist ([typeOf], defaults to the file system) count for nothing.
|
||||||
|
Handoff classifyHandoff({
|
||||||
|
List<String> paths = const [],
|
||||||
|
String? text,
|
||||||
|
FileSystemEntityType Function(String path)? typeOf,
|
||||||
|
}) {
|
||||||
|
final type = typeOf ?? (p) => FileSystemEntity.typeSync(p);
|
||||||
|
var list = [for (final p in paths) if (p.trim().isNotEmpty) _pathOf(p.trim())];
|
||||||
|
if (list.isEmpty && text != null) {
|
||||||
|
final t = text.trim();
|
||||||
|
if (_link.hasMatch(t)) return HandoffLink(t);
|
||||||
|
final lines = [for (final l in t.split(RegExp(r'[\r\n]+'))) if (l.trim().isNotEmpty) _pathOf(l.trim())];
|
||||||
|
if (lines.isEmpty || !lines.every(_absolute)) return const HandoffNothing();
|
||||||
|
list = lines;
|
||||||
|
}
|
||||||
|
final types = {for (final p in list) p: type(p)};
|
||||||
|
bool file(String p) => types[p] == FileSystemEntityType.file;
|
||||||
|
final videos = [for (final p in list) if (file(p) && kindOf(p) == FileKind.video) p];
|
||||||
|
if (videos.isNotEmpty) return HandoffVideos(videos);
|
||||||
|
for (final p in list) {
|
||||||
|
if (types[p] == FileSystemEntityType.directory) return HandoffFolder(p);
|
||||||
|
}
|
||||||
|
for (final p in list) {
|
||||||
|
final dot = p.lastIndexOf('.');
|
||||||
|
if (file(p) && dot >= 0 && _imageExtensions.contains(p.substring(dot + 1).toLowerCase())) {
|
||||||
|
return HandoffImage(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return const HandoffNothing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the card's buttons ask the shell to do.
|
||||||
|
enum HandoffAction {
|
||||||
|
/// Put the (first) video up (offline: watch it solo).
|
||||||
|
putUp,
|
||||||
|
/// Add the video's folder (or the folder) to Files and open it.
|
||||||
|
addFolder,
|
||||||
|
/// Open Library → Link with the URL filled.
|
||||||
|
openLink,
|
||||||
|
/// 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);
|
||||||
|
/// pops the [HandoffAction] chosen.
|
||||||
|
class HandoffCard extends StatelessWidget {
|
||||||
|
final Handoff item;
|
||||||
|
/// Nobody else watches (offline or solo): videos play, no relay talk.
|
||||||
|
final bool solo;
|
||||||
|
/// 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) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
void pick(HandoffAction a) => Navigator.of(context).pop(a);
|
||||||
|
Widget button(HandoffAction a, IconData icon, String label, {bool primary = false}) => primary
|
||||||
|
? FilledButton.icon(
|
||||||
|
key: ValueKey('handoff-${a.name}'), onPressed: () => pick(a), icon: Icon(icon, size: 18), label: Text(label))
|
||||||
|
: OutlinedButton.icon(
|
||||||
|
key: ValueKey('handoff-${a.name}'), onPressed: () => pick(a), icon: Icon(icon, size: 18), label: Text(label));
|
||||||
|
final 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) => (
|
||||||
|
Icons.movie_outlined,
|
||||||
|
baseName(paths.first),
|
||||||
|
paths.length > 1 ? 'and ${paths.length - 1} more · the first one goes up' : 'A video on this computer',
|
||||||
|
[
|
||||||
|
button(HandoffAction.putUp, Icons.play_arrow, solo ? 'Play' : 'Put it up', primary: true),
|
||||||
|
...queueButtons,
|
||||||
|
button(HandoffAction.addFolder, Icons.create_new_folder_outlined, 'Add its folder to Files'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
HandoffFolder(:final path) => (
|
||||||
|
Icons.folder_outlined,
|
||||||
|
baseName(path),
|
||||||
|
path,
|
||||||
|
[button(HandoffAction.addFolder, Icons.create_new_folder_outlined, 'Add to Files', primary: true)],
|
||||||
|
),
|
||||||
|
HandoffLink(:final url) => (
|
||||||
|
Icons.link,
|
||||||
|
url,
|
||||||
|
solo ? 'Opens it on the Link page' : 'Pick how the others get it on the Link page',
|
||||||
|
[
|
||||||
|
button(HandoffAction.openLink, Icons.link, solo ? 'Open in Link' : 'Put it up as a link…',
|
||||||
|
primary: true),
|
||||||
|
...queueButtons,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
HandoffImage() || HandoffNothing() => (Icons.help_outline, '', '', const <Widget>[]),
|
||||||
|
};
|
||||||
|
return Material(
|
||||||
|
key: const ValueKey('handoff-card'),
|
||||||
|
elevation: 12,
|
||||||
|
color: scheme.surfaceContainer,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 380),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: scheme.primary),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall),
|
||||||
|
Text(detail,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Wrap(spacing: 8, runSpacing: 8, children: buttons),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart'
|
||||||
|
show LinkProbe, hasMediaExtension, parseJellyfinLink, playErrorText, unreachableText;
|
||||||
|
|
||||||
|
import '../playback/yt_dlp_app.dart';
|
||||||
import '../screens/home/media_picker.dart';
|
import '../screens/home/media_picker.dart';
|
||||||
|
import '../widgets/app_menu.dart';
|
||||||
|
import '../widgets/copy_open.dart';
|
||||||
import '../state/connect_prefs.dart';
|
import '../state/connect_prefs.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
|
|
||||||
@@ -20,20 +24,33 @@ class RecentLink {
|
|||||||
final String url;
|
final String url;
|
||||||
final String title;
|
final String title;
|
||||||
final String? thumbnail;
|
final String? thumbnail;
|
||||||
|
/// The preview's second line ("youtube.com · Channel · 3:07"); null for
|
||||||
|
/// links kept before it was.
|
||||||
|
final String? subtitle;
|
||||||
final DateTime at;
|
final DateTime at;
|
||||||
const RecentLink({required this.url, required this.title, this.thumbnail, required this.at});
|
const RecentLink({required this.url, required this.title, this.thumbnail, this.subtitle, required this.at});
|
||||||
|
|
||||||
Map<String, Object?> toJson() =>
|
/// What it showed when put up (the host for a second line, when older).
|
||||||
{'url': url, 'title': title, 'thumb': ?thumbnail, 'at': at.millisecondsSinceEpoch};
|
LinkPreview get preview =>
|
||||||
|
LinkPreview(title: title, subtitle: subtitle ?? _host(Uri.tryParse(url) ?? Uri()), thumbnail: thumbnail);
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'url': url,
|
||||||
|
'title': title,
|
||||||
|
'thumb': ?thumbnail,
|
||||||
|
'sub': ?subtitle,
|
||||||
|
'at': at.millisecondsSinceEpoch,
|
||||||
|
};
|
||||||
|
|
||||||
static RecentLink? fromJson(Object? j) {
|
static RecentLink? fromJson(Object? j) {
|
||||||
if (j is! Map) return null;
|
if (j is! Map) return null;
|
||||||
final url = j['url'], title = j['title'], at = j['at'], thumb = j['thumb'];
|
final url = j['url'], title = j['title'], at = j['at'], thumb = j['thumb'], sub = j['sub'];
|
||||||
if (url is! String || url.isEmpty) return null;
|
if (url is! String || url.isEmpty) return null;
|
||||||
return RecentLink(
|
return RecentLink(
|
||||||
url: url,
|
url: url,
|
||||||
title: title is String && title.isNotEmpty ? title : LinkMedia(url).label,
|
title: title is String && title.isNotEmpty ? title : LinkMedia(url).label,
|
||||||
thumbnail: thumb is String && thumb.isNotEmpty ? thumb : null,
|
thumbnail: thumb is String && thumb.isNotEmpty ? thumb : null,
|
||||||
|
subtitle: sub is String && sub.isNotEmpty ? sub : null,
|
||||||
at: DateTime.fromMillisecondsSinceEpoch(at is int ? at : 0),
|
at: DateTime.fromMillisecondsSinceEpoch(at is int ? at : 0),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -59,13 +76,18 @@ List<RecentLink> decodeRecentLinks(Object? raw) {
|
|||||||
class RecentLinks {
|
class RecentLinks {
|
||||||
static final RecentLinks instance = RecentLinks();
|
static final RecentLinks instance = RecentLinks();
|
||||||
|
|
||||||
RecentLinks({Future<Object?> Function()? read, Future<void> Function(String value)? write})
|
/// [read] / [write] given (tests): [previews] defaults to one kept in
|
||||||
|
/// memory only.
|
||||||
|
RecentLinks({Future<Object?> Function()? read, Future<void> Function(String value)? write, LinkPreviewStore? previews})
|
||||||
: _read = read ?? _hiveRead,
|
: _read = read ?? _hiveRead,
|
||||||
_write = write ?? _hiveWrite;
|
_write = write ?? _hiveWrite,
|
||||||
|
previews = previews ?? (read == null && write == null ? LinkPreviewStore() : LinkPreviewStore.memory());
|
||||||
|
|
||||||
final Future<Object?> Function() _read;
|
final Future<Object?> Function() _read;
|
||||||
final Future<void> Function(String value) _write;
|
final Future<void> Function(String value) _write;
|
||||||
final links = ValueNotifier<List<RecentLink>>(const []);
|
final links = ValueNotifier<List<RecentLink>>(const []);
|
||||||
|
/// What links pasted or put up before turned out to be.
|
||||||
|
final LinkPreviewStore previews;
|
||||||
Future<void>? _load;
|
Future<void>? _load;
|
||||||
|
|
||||||
static Future<Object?> _hiveRead() async =>
|
static Future<Object?> _hiveRead() async =>
|
||||||
@@ -106,52 +128,176 @@ class LinkPreview {
|
|||||||
/// "youtube.com · Channel", or the host.
|
/// "youtube.com · Channel", or the host.
|
||||||
final String subtitle;
|
final String subtitle;
|
||||||
final String? thumbnail;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET [uri] and return the body (oEmbed).
|
/// A [LinkPreview] kept, and when it was found.
|
||||||
typedef OEmbedGetter = Future<String> Function(Uri uri);
|
typedef StoredLinkPreview = ({LinkPreview preview, DateTime at});
|
||||||
|
|
||||||
Future<String> defaultOEmbedGet(Uri uri) async {
|
/// How long a kept preview is shown as is; older, it is shown and
|
||||||
final client = HttpClient()..connectionTimeout = const Duration(seconds: 6);
|
/// 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 {
|
try {
|
||||||
final req = await client.getUrl(uri);
|
final raw = await _read();
|
||||||
final res = await req.close().timeout(const Duration(seconds: 8));
|
final j = raw is String ? jsonDecode(raw) : raw;
|
||||||
if (res.statusCode != 200) throw HttpException('status ${res.statusCode}', uri: uri);
|
if (j is! Map) return;
|
||||||
return await res.transform(utf8.decoder).join().timeout(const Duration(seconds: 8));
|
final read = <String, StoredLinkPreview>{};
|
||||||
} finally {
|
for (final e in j.entries) {
|
||||||
client.close(force: true);
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isYouTubeLink(Uri uri) {
|
/// What a link is (see [AppYtDlp.probe]).
|
||||||
final h = uri.host.toLowerCase();
|
typedef LinkProber = Future<LinkProbe> Function(String url);
|
||||||
return h == 'youtu.be' || h == 'youtube.com' || h.endsWith('.youtube.com');
|
|
||||||
}
|
Future<LinkProbe> defaultLinkProber(String url) => AppYtDlp.instance.probe(url);
|
||||||
|
|
||||||
String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host;
|
String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host;
|
||||||
|
|
||||||
/// The preview of [url]: YouTube links ask YouTube's oEmbed for the title,
|
/// "3:07", "1:02:03".
|
||||||
/// channel and thumbnail; anything else (or a failed lookup) shows the
|
String clockText(double seconds) {
|
||||||
/// file name and host. Null for text that is not an http(s) link.
|
final s = seconds.round();
|
||||||
Future<LinkPreview?> previewLink(String url, {OEmbedGetter get = defaultOEmbedGet}) async {
|
final h = s ~/ 3600, m = s % 3600 ~/ 60, sec = (s % 60).toString().padLeft(2, '0');
|
||||||
|
return h > 0 ? '$h:${m.toString().padLeft(2, '0')}:$sec' : '$m:$sec';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The preview of [url]: a web page shows yt-dlp's title, uploader,
|
||||||
|
/// duration and thumbnail; 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());
|
final uri = Uri.tryParse(url.trim());
|
||||||
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null;
|
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null;
|
||||||
final plain = LinkPreview(title: LinkMedia(url.trim()).label, subtitle: _host(uri));
|
final host = _host(uri);
|
||||||
if (!isYouTubeLink(uri)) return plain;
|
// 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 {
|
try {
|
||||||
final body = await get(Uri.https('www.youtube.com', '/oembed', {'url': url.trim(), 'format': 'json'}));
|
p = await probe(url.trim());
|
||||||
final j = jsonDecode(body);
|
} catch (e) {
|
||||||
if (j is! Map || j['title'] is! String) return plain;
|
final problem = playErrorText('$e');
|
||||||
final author = j['author_name'];
|
return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: '$host · $problem', problem: problem);
|
||||||
return LinkPreview(
|
|
||||||
title: j['title'] as String,
|
|
||||||
subtitle: [_host(uri), if (author is String && author.isNotEmpty) author].join(' · '),
|
|
||||||
thumbnail: j['thumbnail_url'] is String ? j['thumbnail_url'] as String : null,
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
return plain;
|
|
||||||
}
|
}
|
||||||
|
final info = p.info;
|
||||||
|
if (!p.isPage) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
|
||||||
|
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,
|
||||||
|
subtitle: [
|
||||||
|
host,
|
||||||
|
if (info.uploader.isNotEmpty) info.uploader,
|
||||||
|
if (info.duration case final d? when d > 0) clockText(d),
|
||||||
|
].join(' · '),
|
||||||
|
thumbnail: info.thumbnail,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [url]'s preview at once when it was kept before ([LinkPreviewStore]),
|
||||||
|
/// else probed and kept (the Up next popover's paste; the Link page has
|
||||||
|
/// its own refresh rules).
|
||||||
|
Future<LinkPreview?> cachedLinkPreview(String url, {RecentLinks? recents, LinkProber probe = defaultLinkProber}) async {
|
||||||
|
final store = (recents ?? RecentLinks.instance).previews;
|
||||||
|
await store.ensureLoaded();
|
||||||
|
final kept = store.lookup(url);
|
||||||
|
if (kept != null) return kept.preview;
|
||||||
|
final p = await previewLink(url, probe: probe);
|
||||||
|
if (p != null && parseJellyfinLink(url) == null) unawaited(store.put(url, p));
|
||||||
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "today", "yesterday", "3 days ago", "last week", "5 weeks ago", ...
|
/// "today", "yesterday", "3 days ago", "last week", "5 weeks ago", ...
|
||||||
@@ -168,7 +314,17 @@ String agoText(DateTime at, DateTime now) {
|
|||||||
|
|
||||||
// -------------------------------------------------------------- widget
|
// -------------------------------------------------------------- widget
|
||||||
|
|
||||||
const _modes = [
|
// Through me is the default and the main choice: it plays whatever this
|
||||||
|
// computer can open, and nobody can tell ahead which sites will want
|
||||||
|
// sign-in cookies (YouTube may start asking any day). The other two sit
|
||||||
|
// behind "Other ways".
|
||||||
|
const _main = (
|
||||||
|
LinkRelay.me,
|
||||||
|
Icons.cell_tower,
|
||||||
|
'Through me',
|
||||||
|
'Your computer opens it and passes it on, so it plays for everyone whenever it plays for you.'
|
||||||
|
);
|
||||||
|
const _others = [
|
||||||
(LinkRelay.direct, Icons.groups_outlined, 'Everyone fetches it', 'Each app plays the link itself.'),
|
(LinkRelay.direct, Icons.groups_outlined, 'Everyone fetches it', 'Each app plays the link itself.'),
|
||||||
(
|
(
|
||||||
LinkRelay.server,
|
LinkRelay.server,
|
||||||
@@ -176,7 +332,6 @@ const _modes = [
|
|||||||
'Server fetches it',
|
'Server fetches it',
|
||||||
'Downloaded once by the server. For links slow or blocked for some.'
|
'Downloaded once by the server. For links slow or blocked for some.'
|
||||||
),
|
),
|
||||||
(LinkRelay.me, Icons.cell_tower, 'Through me', 'Your computer passes it on. For links only you can open.'),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
String linkRelayHelp(LinkRelay r) => switch (r) {
|
String linkRelayHelp(LinkRelay r) => switch (r) {
|
||||||
@@ -188,21 +343,28 @@ String linkRelayHelp(LinkRelay r) => switch (r) {
|
|||||||
'while it plays; the link is not shown to anyone.',
|
'while it plays; the link is not shown to anyone.',
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The Link source. [offlineHint] set: the button is off and says why.
|
/// Under Play when nobody else watches.
|
||||||
|
const soloPlayHint = 'Plays just for you. Invite friends any time; it keeps playing.';
|
||||||
|
|
||||||
|
/// The Link source. [solo]: nobody else watches (offline or solo), so the
|
||||||
|
/// button says Play and there are no relay modes (the link plays direct).
|
||||||
class LibraryLinkSource extends StatefulWidget {
|
class LibraryLinkSource extends StatefulWidget {
|
||||||
final MediaPickerController controller;
|
final MediaPickerController controller;
|
||||||
final String? offlineHint;
|
final bool solo;
|
||||||
/// Puts the link up; returns an error text, or null when sent.
|
/// Puts the link up; returns an error text, or null when sent.
|
||||||
final Future<String?> Function(LinkMedia media) onPutUp;
|
final Future<String?> Function(LinkMedia media) onPutUp;
|
||||||
|
/// Adds the link to Up next in the chosen mode; null: no server.
|
||||||
|
final AddToQueue? onAddToQueue;
|
||||||
final RecentLinks recents;
|
final RecentLinks recents;
|
||||||
final OEmbedGetter oembed;
|
final LinkProber probe;
|
||||||
const LibraryLinkSource({
|
const LibraryLinkSource({
|
||||||
super.key,
|
super.key,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.onPutUp,
|
required this.onPutUp,
|
||||||
|
this.onAddToQueue,
|
||||||
required this.recents,
|
required this.recents,
|
||||||
this.offlineHint,
|
this.solo = false,
|
||||||
this.oembed = defaultOEmbedGet,
|
this.probe = defaultLinkProber,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -216,6 +378,12 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
Timer? _debounce;
|
Timer? _debounce;
|
||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
String? _error;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -223,6 +391,10 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
widget.recents.ensureLoaded();
|
widget.recents.ensureLoaded();
|
||||||
_c.link.addListener(_typed);
|
_c.link.addListener(_typed);
|
||||||
_typed();
|
_typed();
|
||||||
|
// A link already in the field may have been seen before.
|
||||||
|
_store.ensureLoaded().then((_) {
|
||||||
|
if (mounted && _c.link.text.trim() == _previewUrl) _preview(_previewUrl);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -236,19 +408,70 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
final url = _c.link.text.trim();
|
final url = _c.link.text.trim();
|
||||||
if (url == _previewUrl) return;
|
if (url == _previewUrl) return;
|
||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
|
// Seen before (picked from Recent, pasted again): at once.
|
||||||
|
if (_previews.containsKey(url) || _known(url) != null) {
|
||||||
|
_preview(url);
|
||||||
|
} else {
|
||||||
_debounce = Timer(const Duration(milliseconds: 350), () => _preview(url));
|
_debounce = Timer(const Duration(milliseconds: 350), () => _preview(url));
|
||||||
if (mounted) setState(() => _error = null);
|
}
|
||||||
|
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 {
|
Future<void> _preview(String url) async {
|
||||||
_previewUrl = url;
|
_previewUrl = url;
|
||||||
if (url.isEmpty || _previews.containsKey(url)) {
|
if (url.isEmpty || _previews.containsKey(url)) {
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final p = await previewLink(url, get: widget.oembed);
|
final known = _known(url);
|
||||||
_previews[url] = p;
|
if (known != null) {
|
||||||
|
_previews[url] = known.preview;
|
||||||
if (mounted) setState(() {});
|
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 {
|
Future<void> _paste() async {
|
||||||
@@ -258,40 +481,140 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _go() async {
|
Future<void> _go({bool anyway = false}) async {
|
||||||
final url = _c.link.text.trim();
|
final url = _c.link.text.trim();
|
||||||
if (url.isEmpty || _busy || widget.offlineHint != null) return;
|
if (url.isEmpty || _busy) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_busy = true;
|
_busy = true;
|
||||||
_error = null;
|
_error = null;
|
||||||
|
_canForce = false;
|
||||||
});
|
});
|
||||||
String? error;
|
// 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 {
|
try {
|
||||||
error = await widget.onPutUp(LinkMedia(url, relay: _c.linkRelay));
|
error = await widget.onPutUp(
|
||||||
|
LinkMedia(url, relay: widget.solo ? LinkRelay.direct : _c.linkRelay, anyway: anyway));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = '$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) {
|
if (error == null) {
|
||||||
final p = _previews[url] ?? await previewLink(url, get: widget.oembed);
|
var p = _previews[url];
|
||||||
|
if (p == null) {
|
||||||
|
p = _previews[url] = await previewLink(url, probe: widget.probe);
|
||||||
|
_keep(url, p);
|
||||||
|
}
|
||||||
unawaited(widget.recents.add(RecentLink(
|
unawaited(widget.recents.add(RecentLink(
|
||||||
url: url,
|
url: url,
|
||||||
title: p?.title ?? LinkMedia(url).label,
|
title: p?.title ?? LinkMedia(url).label,
|
||||||
thumbnail: p?.thumbnail,
|
thumbnail: p?.thumbnail,
|
||||||
|
subtitle: p?.problem == null ? p?.subtitle : null,
|
||||||
at: DateTime.now(),
|
at: DateTime.now(),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
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(() {
|
setState(() {
|
||||||
_busy = false;
|
_busy = false;
|
||||||
_error = error;
|
_error = error;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Right-click on a recent link: put it up or queue it, copy or open it.
|
||||||
|
Future<void> _recentMenu(BuildContext context, RecentLink l) async {
|
||||||
|
final pick = await showAppMenu<String>(context, items: [
|
||||||
|
AppMenuItem(value: 'up', label: widget.solo ? 'Play' : 'Put it up'),
|
||||||
|
if (widget.onAddToQueue != null) ...const [
|
||||||
|
AppMenuItem(value: 'queue', label: 'Add to queue'),
|
||||||
|
AppMenuItem(value: 'next', label: 'Play next'),
|
||||||
|
],
|
||||||
|
const AppMenuItem(value: 'copy', label: ReuseText.copyLink),
|
||||||
|
const AppMenuItem(value: 'title', label: ReuseText.copyTitle),
|
||||||
|
const AppMenuItem(value: 'open', label: ReuseText.openInBrowser),
|
||||||
|
]);
|
||||||
|
if (pick == null || !mounted) return;
|
||||||
|
switch (pick) {
|
||||||
|
case 'copy':
|
||||||
|
return copyToClipboard(l.url, done: 'Link copied');
|
||||||
|
case 'title':
|
||||||
|
return copyToClipboard(l.title, done: 'Title copied');
|
||||||
|
case 'open':
|
||||||
|
return openLink(l.url);
|
||||||
|
}
|
||||||
|
_c.link.text = l.url;
|
||||||
|
switch (pick) {
|
||||||
|
case 'up':
|
||||||
|
await _go();
|
||||||
|
case 'queue' || 'next':
|
||||||
|
await _queue(url: l.url, next: pick == 'next');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Right-click on the preview: copy or open the link.
|
||||||
|
Future<void> _previewMenu(BuildContext context, String url, LinkPreview? p) async {
|
||||||
|
final pick = await showAppMenu<String>(context, items: [
|
||||||
|
const AppMenuItem(value: 'copy', label: ReuseText.copyLink),
|
||||||
|
if (p != null) const AppMenuItem(value: 'title', label: ReuseText.copyTitle),
|
||||||
|
const AppMenuItem(value: 'open', label: ReuseText.openInBrowser),
|
||||||
|
]);
|
||||||
|
switch (pick) {
|
||||||
|
case 'copy':
|
||||||
|
await copyToClipboard(url, done: 'Link copied');
|
||||||
|
case 'title':
|
||||||
|
await copyToClipboard(p!.title, done: 'Title copied');
|
||||||
|
case 'open':
|
||||||
|
await openLink(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _modeCard((LinkRelay, IconData, String, String) m, {bool compact = false}) => _ModeCard(
|
||||||
|
key: ValueKey('link-mode-${m.$1.name}'),
|
||||||
|
icon: m.$2,
|
||||||
|
title: m.$3,
|
||||||
|
sub: m.$4,
|
||||||
|
compact: compact,
|
||||||
|
selected: _c.linkRelay == m.$1,
|
||||||
|
onTap: () => setState(() => _c.linkRelay = m.$1),
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final t = Theme.of(context).textTheme;
|
final t = Theme.of(context).textTheme;
|
||||||
final hint = widget.offlineHint;
|
final solo = widget.solo;
|
||||||
final url = _c.link.text.trim();
|
final url = _c.link.text.trim();
|
||||||
final preview = url.isEmpty ? null : _previews[url];
|
final preview = url.isEmpty ? null : _previews[url];
|
||||||
final loading = url.isNotEmpty && !_previews.containsKey(url);
|
final loading = url.isNotEmpty && !_previews.containsKey(url);
|
||||||
@@ -311,16 +634,32 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
);
|
);
|
||||||
final button = FilledButton.icon(
|
final button = FilledButton.icon(
|
||||||
key: const ValueKey('link-put-up'),
|
key: const ValueKey('link-put-up'),
|
||||||
onPressed: url.isEmpty || _busy || hint != null ? null : _go,
|
onPressed: url.isEmpty || _busy ? null : _go,
|
||||||
icon: _busy
|
icon: _busy
|
||||||
? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
: const Icon(Icons.play_arrow),
|
: const Icon(Icons.play_arrow),
|
||||||
label: Text(hint == null ? 'Put it up' : 'Play'),
|
label: Text(solo ? 'Play' : 'Put it up'),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
minimumSize: const Size(0, 56),
|
minimumSize: const Size(0, 56),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
final queue = widget.onAddToQueue == null
|
||||||
|
? null
|
||||||
|
: Tooltip(
|
||||||
|
message: 'Plays after what is on now (right-click a recent link for Play next)',
|
||||||
|
waitDuration: const Duration(milliseconds: 600),
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
key: const ValueKey('link-add-queue'),
|
||||||
|
onPressed: url.isEmpty || _busy ? null : () => _queue(),
|
||||||
|
icon: const Icon(Icons.playlist_add_rounded),
|
||||||
|
label: const Text('Add to queue'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
minimumSize: const Size(0, 56),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
key: const ValueKey('media-link'),
|
key: const ValueKey('media-link'),
|
||||||
@@ -346,7 +685,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
Text('Watch from a link', textAlign: TextAlign.center, style: t.headlineSmall),
|
Text('Watch from a link', textAlign: TextAlign.center, style: t.headlineSmall),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
'A video file, YouTube, a Jellyfin link… Ctrl+V anywhere pastes it here.',
|
'A video file, a page (YouTube, X, …) or a Jellyfin link. Ctrl+V or drop it anywhere to bring it here.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(color: scheme.onSurfaceVariant),
|
style: TextStyle(color: scheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
@@ -357,17 +696,31 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
field,
|
field,
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
button,
|
button,
|
||||||
|
if (queue != null) ...[const SizedBox(height: 8), queue],
|
||||||
])
|
])
|
||||||
: Row(children: [Expanded(child: field), const SizedBox(width: 12), button]),
|
: Row(children: [
|
||||||
|
Expanded(child: field),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
button,
|
||||||
|
if (queue != null) ...[const SizedBox(width: 8), queue],
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
if (hint != null || _error != null) ...[
|
if (solo || _error != null) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
_error ?? hint!,
|
_error ?? soloPlayHint,
|
||||||
key: const ValueKey('link-hint'),
|
key: const ValueKey('link-hint'),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey),
|
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(
|
AnimatedSize(
|
||||||
duration: easeTime,
|
duration: easeTime,
|
||||||
@@ -376,28 +729,58 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
? const SizedBox(width: double.infinity)
|
? const SizedBox(width: double.infinity)
|
||||||
: Padding(
|
: Padding(
|
||||||
padding: const EdgeInsets.only(top: 14),
|
padding: const EdgeInsets.only(top: 14),
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) => GestureDetector(
|
||||||
|
onSecondaryTap: () => _previewMenu(context, url, preview),
|
||||||
child: _PreviewCard(preview: preview, loading: loading),
|
child: _PreviewCard(preview: preview, loading: loading),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (hint == null) ...[
|
),
|
||||||
|
),
|
||||||
|
if (!solo) ...[
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
Text('How the others get it', style: t.titleSmall),
|
Text('How the others get it', style: t.titleSmall),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
LayoutBuilder(builder: (context, box) {
|
_modeCard(_main),
|
||||||
final cards = [
|
const SizedBox(height: 6),
|
||||||
for (final m in _modes)
|
Builder(builder: (context) {
|
||||||
_ModeCard(
|
final open = _otherWays || _c.linkRelay != LinkRelay.me;
|
||||||
key: ValueKey('link-mode-${m.$1.name}'),
|
return Column(
|
||||||
icon: m.$2,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
title: m.$3,
|
children: [
|
||||||
sub: m.$4,
|
TextButton.icon(
|
||||||
selected: _c.linkRelay == m.$1,
|
key: const ValueKey('link-other-ways'),
|
||||||
onTap: () => setState(() => _c.linkRelay = m.$1),
|
// Closing while one of them is picked goes back to Through me.
|
||||||
|
onPressed: () => setState(() {
|
||||||
|
_otherWays = !open;
|
||||||
|
if (open) _c.linkRelay = LinkRelay.me;
|
||||||
|
}),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: scheme.onSurfaceVariant),
|
||||||
|
iconAlignment: IconAlignment.end,
|
||||||
|
icon: AnimatedRotation(
|
||||||
|
turns: open ? 0.5 : 0,
|
||||||
|
duration: easeTime,
|
||||||
|
curve: ease,
|
||||||
|
child: const Icon(Icons.keyboard_arrow_down_rounded, size: 18),
|
||||||
),
|
),
|
||||||
];
|
label: const Text('Other ways'),
|
||||||
|
),
|
||||||
|
AnimatedSize(
|
||||||
|
duration: easeTime,
|
||||||
|
curve: ease,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: !open
|
||||||
|
? const SizedBox(width: double.infinity)
|
||||||
|
: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: LayoutBuilder(builder: (context, box) {
|
||||||
|
final cards = [for (final m in _others) _modeCard(m, compact: true)];
|
||||||
if (box.maxWidth < 560) {
|
if (box.maxWidth < 560) {
|
||||||
return Column(children: [
|
return Column(children: [
|
||||||
for (final (i, c) in cards.indexed) ...[if (i > 0) const SizedBox(height: 8), c],
|
for (final (i, c) in cards.indexed) ...[
|
||||||
|
if (i > 0) const SizedBox(height: 8),
|
||||||
|
c,
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
return IntrinsicHeight(
|
return IntrinsicHeight(
|
||||||
@@ -412,6 +795,11 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(linkRelayHelp(_c.linkRelay), style: t.bodySmall?.copyWith(color: Colors.grey)),
|
Text(linkRelayHelp(_c.linkRelay), style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||||
],
|
],
|
||||||
@@ -421,7 +809,8 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
? const SizedBox.shrink()
|
? const SizedBox.shrink()
|
||||||
: Padding(
|
: Padding(
|
||||||
padding: const EdgeInsets.only(top: 32),
|
padding: const EdgeInsets.only(top: 32),
|
||||||
child: _RecentGrid(recents: recents, onPick: (l) => _c.link.text = l.url),
|
child: _RecentGrid(
|
||||||
|
recents: recents, onPick: (l) => _c.link.text = l.url, onMenu: _recentMenu),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -470,9 +859,9 @@ class _PreviewCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
p?.subtitle ?? 'Paste an http(s) address',
|
p?.subtitle ?? 'Paste an http(s) address',
|
||||||
maxLines: 1,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(color: Colors.grey),
|
style: TextStyle(color: p?.problem != null ? scheme.error : Colors.grey),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -504,6 +893,8 @@ class _ModeCard extends StatelessWidget {
|
|||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String title, sub;
|
final String title, sub;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
|
/// A secondary choice: icon beside the title, a quieter card.
|
||||||
|
final bool compact;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
const _ModeCard({
|
const _ModeCard({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -512,6 +903,7 @@ class _ModeCard extends StatelessWidget {
|
|||||||
required this.sub,
|
required this.sub,
|
||||||
required this.selected,
|
required this.selected,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
|
this.compact = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -527,18 +919,23 @@ class _ModeCard extends StatelessWidget {
|
|||||||
duration: easeTime,
|
duration: easeTime,
|
||||||
curve: ease,
|
curve: ease,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(12),
|
padding: EdgeInsets.all(compact ? 10 : 14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: selected ? scheme.primary.withValues(alpha: 0.10) : scheme.surfaceContainerLow,
|
color: selected ? scheme.primary.withValues(alpha: 0.10) : scheme.surfaceContainerLow,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(color: selected ? scheme.primary : Colors.transparent, width: 2),
|
border: Border.all(color: selected ? scheme.primary : Colors.transparent, width: 2),
|
||||||
),
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: compact ? CrossAxisAlignment.start : CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: compact ? 20 : 24, color: selected ? scheme.primary : scheme.onSurfaceVariant),
|
||||||
|
SizedBox(width: compact ? 10 : 14),
|
||||||
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, color: selected ? scheme.primary : scheme.onSurfaceVariant),
|
Text(title,
|
||||||
const SizedBox(height: 8),
|
style: TextStyle(fontWeight: FontWeight.w600, color: selected ? scheme.primary : null)),
|
||||||
Text(title, style: TextStyle(fontWeight: FontWeight.w600, color: selected ? scheme.primary : null)),
|
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(sub,
|
Text(sub,
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
@@ -547,6 +944,9 @@ class _ModeCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -555,7 +955,9 @@ class _ModeCard extends StatelessWidget {
|
|||||||
class _RecentGrid extends StatelessWidget {
|
class _RecentGrid extends StatelessWidget {
|
||||||
final List<RecentLink> recents;
|
final List<RecentLink> recents;
|
||||||
final ValueChanged<RecentLink> onPick;
|
final ValueChanged<RecentLink> onPick;
|
||||||
const _RecentGrid({required this.recents, required this.onPick});
|
/// Right-click: Put it up / Add to queue / Play next, copy or open it.
|
||||||
|
final void Function(BuildContext context, RecentLink link) onMenu;
|
||||||
|
const _RecentGrid({required this.recents, required this.onPick, required this.onMenu});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -580,9 +982,11 @@ class _RecentGrid extends StatelessWidget {
|
|||||||
child: Tooltip(
|
child: Tooltip(
|
||||||
message: l.url,
|
message: l.url,
|
||||||
waitDuration: const Duration(milliseconds: 600),
|
waitDuration: const Duration(milliseconds: 600),
|
||||||
child: InkWell(
|
// The card's own context: the menu opens at the card.
|
||||||
|
child: Builder(builder: (context) => InkWell(
|
||||||
key: ValueKey('recent-link-${l.url}'),
|
key: ValueKey('recent-link-${l.url}'),
|
||||||
onTap: () => onPick(l),
|
onTap: () => onPick(l),
|
||||||
|
onSecondaryTap: () => onMenu(context, l),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -609,7 +1013,7 @@ class _RecentGrid extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
import 'package:sync_relay_core/core.dart' show HelloMedia, JellyfinException, JellyfinItem;
|
import 'package:sync_relay_core/core.dart' show HelloMedia, JellyfinException, JellyfinItem;
|
||||||
|
|
||||||
@@ -17,6 +16,7 @@ import '../screens/home/media_picker.dart';
|
|||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import 'library_link.dart';
|
import 'library_link.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
|
import 'welcome.dart';
|
||||||
|
|
||||||
/// What putting up a Jellyfin pick sends: its ref (no URL, no token).
|
/// What putting up a Jellyfin pick sends: its ref (no URL, no token).
|
||||||
/// Throws [FormatException] with a user-facing message when the server is
|
/// Throws [FormatException] with a user-facing message when the server is
|
||||||
@@ -59,9 +59,6 @@ String? nowPlayingFile(Map<String, Object?>? media, String lastPath) {
|
|||||||
return name == media['name'] ? lastPath : null;
|
return name == media['name'] ? lastPath : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Offline, nothing goes up; this says why.
|
|
||||||
const offlinePutUpHint = 'Join or host to watch together';
|
|
||||||
|
|
||||||
/// Window width under which the details panel floats over the library.
|
/// Window width under which the details panel floats over the library.
|
||||||
const detailsDockFrom = 1000.0;
|
const detailsDockFrom = 1000.0;
|
||||||
|
|
||||||
@@ -69,41 +66,48 @@ const detailsDockFrom = 1000.0;
|
|||||||
/// through), the Jellyfin library chips beside it, and the source below.
|
/// through), the Jellyfin library chips beside it, and the source below.
|
||||||
/// Jellyfin cards select on click and open their details in a panel at
|
/// Jellyfin cards select on click and open their details in a panel at
|
||||||
/// the right (docked from [detailsDockFrom], floating over the library
|
/// the right (docked from [detailsDockFrom], floating over the library
|
||||||
/// below it); a double-click or the panel's button puts it up. Offline
|
/// below it); a double-click or the panel's button puts it up. With
|
||||||
/// everything browses and the play buttons say [offlinePutUpHint].
|
/// nobody else watching ([solo]: offline or a solo session) the buttons
|
||||||
|
/// say Play and putting something up starts (or uses) a solo session.
|
||||||
class LibraryTab extends StatefulWidget {
|
class LibraryTab extends StatefulWidget {
|
||||||
final MediaPickerController controller;
|
final MediaPickerController controller;
|
||||||
final JellyfinAccounts accounts;
|
final JellyfinAccounts accounts;
|
||||||
final bool online;
|
final bool solo;
|
||||||
/// This app hosts the server: its files stream from here directly.
|
/// This app hosts the server: its files stream from here directly.
|
||||||
final bool isHost;
|
final bool isHost;
|
||||||
final BrowseStart? jellyfinStart;
|
final BrowseStart? jellyfinStart;
|
||||||
/// Puts the pick up; returns an error text, or null when sent.
|
/// Puts the pick up; returns an error text, or null when sent.
|
||||||
final Future<String?> Function(PickedMedia media) onPutUp;
|
final Future<String?> Function(PickedMedia media) onPutUp;
|
||||||
|
/// Adds a pick to Up next; null without a server (no queue actions).
|
||||||
|
final AddToQueue? onAddToQueue;
|
||||||
final VoidCallback? onPutUpDone;
|
final VoidCallback? onPutUpDone;
|
||||||
final VoidCallback? onOpenIntegrations;
|
final VoidCallback? onOpenIntegrations;
|
||||||
/// Defaults to [RecentLinks.instance].
|
/// Defaults to [RecentLinks.instance].
|
||||||
final RecentLinks? recentLinks;
|
final RecentLinks? recentLinks;
|
||||||
final OEmbedGetter oembed;
|
final LinkProber probe;
|
||||||
/// The Files source (tests inject one); defaults to [FilesPage].
|
/// The Files source (tests inject one); defaults to [FilesPage].
|
||||||
final Widget Function(BuildContext context, {required bool online, required bool relayed,
|
final Widget Function(BuildContext context, {required bool solo, required bool relayed,
|
||||||
required Future<String?> Function(String path) onPlay, String? nowPlayingPath})? filesBuilder;
|
required Future<String?> Function(String path) onPlay, String? nowPlayingPath})? filesBuilder;
|
||||||
/// Clock for the Jellyfin Continue row (tests pin it).
|
/// Clock for the Jellyfin Continue row (tests pin it).
|
||||||
final DateTime Function() now;
|
final DateTime Function() now;
|
||||||
|
/// Over the source switch (the shell's welcome band while offline).
|
||||||
|
final Widget? banner;
|
||||||
const LibraryTab({
|
const LibraryTab({
|
||||||
super.key,
|
super.key,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.accounts,
|
required this.accounts,
|
||||||
required this.online,
|
required this.solo,
|
||||||
required this.isHost,
|
required this.isHost,
|
||||||
required this.onPutUp,
|
required this.onPutUp,
|
||||||
|
this.onAddToQueue,
|
||||||
this.onPutUpDone,
|
this.onPutUpDone,
|
||||||
this.jellyfinStart,
|
this.jellyfinStart,
|
||||||
this.onOpenIntegrations,
|
this.onOpenIntegrations,
|
||||||
this.recentLinks,
|
this.recentLinks,
|
||||||
this.oembed = defaultOEmbedGet,
|
this.probe = defaultLinkProber,
|
||||||
this.filesBuilder,
|
this.filesBuilder,
|
||||||
this.now = DateTime.now,
|
this.now = DateTime.now,
|
||||||
|
this.banner,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -116,7 +120,6 @@ class _LibraryTabState extends State<LibraryTab> {
|
|||||||
JellyfinSelection? _shown;
|
JellyfinSelection? _shown;
|
||||||
|
|
||||||
MediaPickerController get _c => widget.controller;
|
MediaPickerController get _c => widget.controller;
|
||||||
String? get _hint => widget.online ? null : offlinePutUpHint;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -164,7 +167,13 @@ class _LibraryTabState extends State<LibraryTab> {
|
|||||||
|
|
||||||
/// A Jellyfin card or episode: a series goes up as its next episode.
|
/// A Jellyfin card or episode: a series goes up as its next episode.
|
||||||
Future<String?> _putUpJellyfin(JellyfinSelection sel) async {
|
Future<String?> _putUpJellyfin(JellyfinSelection sel) async {
|
||||||
if (!widget.online) return offlinePutUpHint;
|
final pick = await _jellyfinPick(sel);
|
||||||
|
return pick is JellyfinMedia ? _put(pick) : pick as String;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a Jellyfin card stands for (a series: its next episode), or an
|
||||||
|
/// error text.
|
||||||
|
Future<Object> _jellyfinPick(JellyfinSelection sel) async {
|
||||||
final account = widget.accounts.account(sel.serverId);
|
final account = widget.accounts.account(sel.serverId);
|
||||||
final client = widget.accounts.clientFor(sel.serverId);
|
final client = widget.accounts.clientFor(sel.serverId);
|
||||||
if (account == null || client == null) return 'that Jellyfin server is not linked any more';
|
if (account == null || client == null) return 'that Jellyfin server is not linked any more';
|
||||||
@@ -176,43 +185,32 @@ class _LibraryTabState extends State<LibraryTab> {
|
|||||||
return e.message;
|
return e.message;
|
||||||
}
|
}
|
||||||
if (item == null) return 'nothing to play in there';
|
if (item == null) return 'nothing to play in there';
|
||||||
return _put(pickFor(account, item));
|
return pickFor(account, item);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Double-click on a card: put it up, errors as a snack bar.
|
/// "Add to queue" on a Jellyfin card, episode or the details panel.
|
||||||
Future<void> _doublePutUp(JellyfinSelection sel) async {
|
Future<String?> _queueJellyfin(JellyfinSelection sel, {bool next = false}) async {
|
||||||
_c.select(sel);
|
final add = widget.onAddToQueue;
|
||||||
if (!widget.online) return;
|
if (add == null) return null;
|
||||||
final error = await _putUpJellyfin(sel);
|
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) {
|
if (error != null && mounted) {
|
||||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error)));
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------- paste
|
/// Double-click on a card: put it up, errors as a snack bar.
|
||||||
|
Future<void> _doublePutUp(JellyfinSelection sel) async {
|
||||||
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
|
_c.select(sel);
|
||||||
final ctrl = HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed;
|
final error = await _putUpJellyfin(sel);
|
||||||
if (e is! KeyDownEvent || !ctrl || e.logicalKey != LogicalKeyboardKey.keyV) {
|
if (error != null && mounted) {
|
||||||
return KeyEventResult.ignored;
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error)));
|
||||||
}
|
}
|
||||||
// A focused text field handles its own paste.
|
|
||||||
final inField =
|
|
||||||
FocusManager.instance.primaryFocus?.context?.findAncestorStateOfType<EditableTextState>() != null;
|
|
||||||
if (inField) return KeyEventResult.ignored;
|
|
||||||
unawaited(_pasteLink());
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ctrl+V outside a field: an http(s) link goes to the Link source.
|
|
||||||
Future<void> _pasteLink() async {
|
|
||||||
String? text;
|
|
||||||
try {
|
|
||||||
text = (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim();
|
|
||||||
} catch (_) {}
|
|
||||||
if (text == null || !(text.startsWith('http://') || text.startsWith('https://'))) return;
|
|
||||||
_c.link.text = text;
|
|
||||||
_c.source = MediaKind.link;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------- build
|
// -------------------------------------------------------------- build
|
||||||
@@ -263,23 +261,39 @@ class _LibraryTabState extends State<LibraryTab> {
|
|||||||
selected: _c.selected,
|
selected: _c.selected,
|
||||||
onSelect: _c.select,
|
onSelect: _c.select,
|
||||||
onPutUp: _doublePutUp,
|
onPutUp: _doublePutUp,
|
||||||
|
onAddToQueue: widget.onAddToQueue == null ? null : _cardQueue,
|
||||||
onOpenIntegrations: widget.onOpenIntegrations,
|
onOpenIntegrations: widget.onOpenIntegrations,
|
||||||
start: widget.jellyfinStart,
|
start: widget.jellyfinStart,
|
||||||
libraryStrip: false,
|
libraryStrip: false,
|
||||||
|
empty: (context) => NoJellyfinPanel(
|
||||||
|
accounts: widget.accounts,
|
||||||
|
onFiles: () => _c.source = MediaKind.file,
|
||||||
|
onLink: () => _c.source = MediaKind.link,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
MediaKind.link => LibraryLinkSource(
|
MediaKind.link => LibraryLinkSource(
|
||||||
controller: _c,
|
controller: _c,
|
||||||
offlineHint: _hint,
|
solo: widget.solo,
|
||||||
recents: widget.recentLinks ?? RecentLinks.instance,
|
recents: widget.recentLinks ?? RecentLinks.instance,
|
||||||
oembed: widget.oembed,
|
probe: widget.probe,
|
||||||
onPutUp: _put,
|
onPutUp: _put,
|
||||||
|
onAddToQueue: widget.onAddToQueue,
|
||||||
),
|
),
|
||||||
MediaKind.file => _files(context),
|
MediaKind.file => _files(context),
|
||||||
};
|
};
|
||||||
|
|
||||||
final main = Column(
|
final main = Column(
|
||||||
children: [
|
children: [
|
||||||
|
AnimatedSize(
|
||||||
|
duration: easeTime,
|
||||||
|
curve: ease,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: easeTime,
|
||||||
|
child: widget.banner ?? const SizedBox(width: double.infinity),
|
||||||
|
),
|
||||||
|
),
|
||||||
header,
|
header,
|
||||||
Expanded(child: fadeThrough(KeyedSubtree(key: ValueKey(source), child: content))),
|
Expanded(child: fadeThrough(KeyedSubtree(key: ValueKey(source), child: content))),
|
||||||
],
|
],
|
||||||
@@ -294,8 +308,9 @@ class _LibraryTabState extends State<LibraryTab> {
|
|||||||
child: JellyfinDetailsPanel(
|
child: JellyfinDetailsPanel(
|
||||||
controller: _browse,
|
controller: _browse,
|
||||||
selection: shown,
|
selection: shown,
|
||||||
offlineHint: _hint,
|
solo: widget.solo,
|
||||||
onPutUp: _putUpJellyfin,
|
onPutUp: _putUpJellyfin,
|
||||||
|
onAddToQueue: widget.onAddToQueue == null ? null : _queueJellyfin,
|
||||||
onClose: () => _c.select(null),
|
onClose: () => _c.select(null),
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
@@ -306,10 +321,10 @@ class _LibraryTabState extends State<LibraryTab> {
|
|||||||
child: AnimatedOpacity(duration: easeTime, curve: ease, opacity: open ? 1 : 0, child: child),
|
child: AnimatedOpacity(duration: easeTime, curve: ease, opacity: open ? 1 : 0, child: child),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Holds focus so the shell's shortcuts (Ctrl+,) work.
|
||||||
return Focus(
|
return Focus(
|
||||||
key: const ValueKey('library-tab'),
|
key: const ValueKey('library-tab'),
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
onKeyEvent: _onKey,
|
|
||||||
child: LayoutBuilder(builder: (context, box) {
|
child: LayoutBuilder(builder: (context, box) {
|
||||||
if (box.maxWidth >= detailsDockFrom) {
|
if (box.maxWidth >= detailsDockFrom) {
|
||||||
return Row(
|
return Row(
|
||||||
@@ -355,14 +370,20 @@ class _LibraryTabState extends State<LibraryTab> {
|
|||||||
Future<String?> play(String path) => _put(FileMedia(path));
|
Future<String?> play(String path) => _put(FileMedia(path));
|
||||||
return SignalBuilder(builder: (context) {
|
return SignalBuilder(builder: (context) {
|
||||||
final session = SessionState.instance;
|
final session = SessionState.instance;
|
||||||
final now = widget.online
|
final now = session.route.value == AppRoute.session
|
||||||
? nowPlayingFile(session.coordinator.serverState.value?.media, session.lastForm.mediaPath)
|
? nowPlayingFile(session.coordinator.serverState.value?.media, session.lastForm.mediaPath)
|
||||||
: null;
|
: null;
|
||||||
final b = widget.filesBuilder;
|
final b = widget.filesBuilder;
|
||||||
if (b != null) {
|
if (b != null) {
|
||||||
return b(context, online: widget.online, relayed: relayed, onPlay: play, nowPlayingPath: now);
|
return b(context, solo: widget.solo, relayed: relayed, onPlay: play, nowPlayingPath: now);
|
||||||
}
|
}
|
||||||
return FilesPage(online: widget.online, 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,17 +90,17 @@ Future<T?> showPopover<T>(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Horizontal lists for mouse users: drag with the mouse, and the plain
|
/// Horizontal lists for mouse users: drag with the mouse or Shift+wheel;
|
||||||
/// (vertical) wheel moves them sideways. At either end the wheel is left
|
/// the plain wheel stays vertical (the page scrolls under the cursor, not
|
||||||
/// to the page, so a row never traps vertical scrolling.
|
/// the row).
|
||||||
class HScroll extends StatefulWidget {
|
class HScroll extends StatefulWidget {
|
||||||
final ScrollController? controller;
|
final ScrollController? controller;
|
||||||
final Widget Function(ScrollController controller) builder;
|
final Widget Function(ScrollController controller) builder;
|
||||||
/// Map the plain (vertical) wheel to sideways scrolling. Off where the
|
/// Also map the plain (vertical) wheel to sideways scrolling (at either
|
||||||
/// children scroll vertically themselves (the schedule's day columns):
|
/// end the wheel goes to the page). Off by default: one wheel doing two
|
||||||
/// there only mouse drag and Shift+wheel move sideways.
|
/// things was confusing.
|
||||||
final bool wheel;
|
final bool wheel;
|
||||||
const HScroll({super.key, this.controller, required this.builder, this.wheel = true});
|
const HScroll({super.key, this.controller, required this.builder, this.wheel = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<HScroll> createState() => _HScrollState();
|
State<HScroll> createState() => _HScrollState();
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import 'package:signals/signals_flutter.dart';
|
|||||||
|
|
||||||
import '../session/now_playing.dart';
|
import '../session/now_playing.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
|
import '../widgets/app_menu.dart';
|
||||||
|
import '../widgets/copy_open.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
|
|
||||||
/// Tests: art bytes by hash instead of the session's cache.
|
/// Tests: art bytes by hash instead of the session's cache.
|
||||||
@@ -47,18 +49,20 @@ Widget fadeInImage(ImageProvider image, {BoxFit fit = BoxFit.cover}) => Image(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// The portrait cover (2:3) of the current media, a quiet tile with a
|
/// The portrait cover (2:3) of the current media, a quiet tile with a
|
||||||
/// film icon until (or unless) its art arrives.
|
/// film icon until (or unless) its art arrives; [wide] (a video
|
||||||
|
/// thumbnail): 16:9 at the same height.
|
||||||
class ArtCover extends StatelessWidget {
|
class ArtCover extends StatelessWidget {
|
||||||
final String hash;
|
final String hash;
|
||||||
final double width;
|
final double width;
|
||||||
const ArtCover({super.key, required this.hash, this.width = 40});
|
final bool wide;
|
||||||
|
const ArtCover({super.key, required this.hash, this.width = 40, this.wide = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final image = artImage(hash);
|
final image = artImage(hash);
|
||||||
final r = BorderRadius.circular(width >= 80 ? 10 : 6);
|
final r = BorderRadius.circular(width >= 80 ? 10 : 6);
|
||||||
return Container(
|
return Container(
|
||||||
width: width,
|
width: wide ? width * 1.5 * 16 / 9 : width,
|
||||||
height: width * 1.5,
|
height: width * 1.5,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: r,
|
borderRadius: r,
|
||||||
@@ -116,7 +120,7 @@ class NowPlayingBlock extends StatelessWidget {
|
|||||||
key: ValueKey('now-playing:${n.title}\n${episode ?? ''}'),
|
key: ValueKey('now-playing:${n.title}\n${episode ?? ''}'),
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (cover) ...[ArtCover(hash: n.posterHash), const SizedBox(width: 12)],
|
if (cover) ...[ArtCover(hash: n.posterHash, wide: n.thumbnail), const SizedBox(width: 12)],
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -151,14 +155,17 @@ class NowPlayingBlock extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [NowPlayingBlock] of the session's current media.
|
/// [NowPlayingBlock] of the session's current media; right-click copies
|
||||||
|
/// its title or link, or opens it ([nowPlayingMenu]).
|
||||||
class NowPlayingTitle extends StatelessWidget {
|
class NowPlayingTitle extends StatelessWidget {
|
||||||
final bool cover;
|
final bool cover;
|
||||||
final bool compact;
|
final bool compact;
|
||||||
const NowPlayingTitle({super.key, this.cover = true, this.compact = false});
|
const NowPlayingTitle({super.key, this.cover = true, this.compact = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => ListenableBuilder(
|
Widget build(BuildContext context) => GestureDetector(
|
||||||
|
onSecondaryTap: () => nowPlayingMenu(context),
|
||||||
|
child: ListenableBuilder(
|
||||||
listenable: artChanges(),
|
listenable: artChanges(),
|
||||||
builder: (context, _) => SignalBuilder(
|
builder: (context, _) => SignalBuilder(
|
||||||
builder: (context) => NowPlayingBlock(
|
builder: (context) => NowPlayingBlock(
|
||||||
@@ -167,5 +174,38 @@ class NowPlayingTitle extends StatelessWidget {
|
|||||||
compact: compact,
|
compact: compact,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Copy title; with the link known here ([SessionCoordinator.currentLink])
|
||||||
|
/// Copy link and Open in browser; a Jellyfin item on a server linked here,
|
||||||
|
/// Open in Jellyfin. [context]: where the menu opens.
|
||||||
|
Future<void> nowPlayingMenu(BuildContext context) async {
|
||||||
|
final coord = SessionState.instance.coordinator;
|
||||||
|
final now = coord.nowPlaying.peek();
|
||||||
|
if (now == null) return;
|
||||||
|
final link = coord.currentLink();
|
||||||
|
final ref = coord.currentRef.peek();
|
||||||
|
final page = ref == null ? null : coord.jellyfin.webUrlOf(ref.serverId, ref.itemId);
|
||||||
|
final episode = now.episode;
|
||||||
|
final title = episode == null || episode.isEmpty ? now.title : '${now.title} · $episode';
|
||||||
|
final pick = await showAppMenu<String>(context, items: [
|
||||||
|
const AppMenuItem(value: 'title', label: ReuseText.copyTitle),
|
||||||
|
if (link != null) ...const [
|
||||||
|
AppMenuItem(value: 'link', label: ReuseText.copyLink),
|
||||||
|
AppMenuItem(value: 'open', label: ReuseText.openInBrowser),
|
||||||
|
],
|
||||||
|
if (page != null) const AppMenuItem(value: 'jellyfin', label: ReuseText.openInJellyfin),
|
||||||
|
]);
|
||||||
|
switch (pick) {
|
||||||
|
case 'title':
|
||||||
|
await copyToClipboard(title, done: 'Title copied');
|
||||||
|
case 'link':
|
||||||
|
await copyToClipboard(link!, done: 'Link copied');
|
||||||
|
case 'open':
|
||||||
|
await openLink(link!);
|
||||||
|
case 'jellyfin':
|
||||||
|
await openLink(page!);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,21 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
import '../screens/demo/shell_demo.dart';
|
import '../screens/home/ui.dart' show appName, displayVersion;
|
||||||
import '../screens/home/update_indicator.dart';
|
import '../screens/home/update_indicator.dart';
|
||||||
import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge;
|
import '../session/delivery.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import '../update/update_controller.dart';
|
import '../update/update_controller.dart';
|
||||||
import '../widgets/dialogs.dart';
|
import '../widgets/app_keys.dart';
|
||||||
|
import '../widgets/app_menu.dart';
|
||||||
|
import '../widgets/copy_open.dart';
|
||||||
import '../widgets/window_buttons.dart';
|
import '../widgets/window_buttons.dart';
|
||||||
import 'avatar.dart';
|
import 'avatar.dart';
|
||||||
|
import 'delivery_line.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
|
|
||||||
/// The shell's tabs, in strip order.
|
/// The shell's tabs, in strip order.
|
||||||
@@ -29,17 +30,17 @@ enum ShellTab {
|
|||||||
|
|
||||||
const partyStripHeight = 60.0;
|
const partyStripHeight = 60.0;
|
||||||
|
|
||||||
|
/// Every avatar in the strip, yours and the party's.
|
||||||
|
const stripAvatarSize = 42.0;
|
||||||
|
|
||||||
/// Below this window width the strip shows 3 avatars and no server text,
|
/// Below this window width the strip shows 3 avatars and no server text,
|
||||||
/// so the tabs keep their room.
|
/// so the tabs keep their room.
|
||||||
const stripCompactBelow = 1100.0;
|
const stripCompactBelow = 1100.0;
|
||||||
|
|
||||||
/// The member plays the current Jellyfin item through the host's relay.
|
|
||||||
bool viaHostOf(MemberView m, JellyfinRef? ref) =>
|
|
||||||
ref != null && m.jf[ref.serverId] == false;
|
|
||||||
|
|
||||||
/// The 60 px strip over everything: your avatar (profile), the tabs, and
|
/// The 60 px strip over everything: your avatar (profile), the tabs, and
|
||||||
/// the party: offline Join / Host, online the avatars (members card),
|
/// the party: offline Join a friend / Host, solo a "Solo" chip, Invite
|
||||||
/// server and readiness, chat and Leave. Empty space drags the window.
|
/// friends and Stop, online the avatars (members card), server and
|
||||||
|
/// readiness, chat and Leave. Empty space drags the window.
|
||||||
class PartyStrip extends StatelessWidget {
|
class PartyStrip extends StatelessWidget {
|
||||||
final ShellTab tab;
|
final ShellTab tab;
|
||||||
final ValueChanged<ShellTab> onTab;
|
final ValueChanged<ShellTab> onTab;
|
||||||
@@ -50,6 +51,8 @@ class PartyStrip extends StatelessWidget {
|
|||||||
final VoidCallback onHost;
|
final VoidCallback onHost;
|
||||||
final VoidCallback onLeave;
|
final VoidCallback onLeave;
|
||||||
final VoidCallback onProfile;
|
final VoidCallback onProfile;
|
||||||
|
/// Solo: host from here, keeping what plays.
|
||||||
|
final VoidCallback? onInvite;
|
||||||
|
|
||||||
/// Online with something up but the player closed: brings it back.
|
/// Online with something up but the player closed: brings it back.
|
||||||
final VoidCallback? onShowPlayer;
|
final VoidCallback? onShowPlayer;
|
||||||
@@ -68,6 +71,7 @@ class PartyStrip extends StatelessWidget {
|
|||||||
required this.onHost,
|
required this.onHost,
|
||||||
required this.onLeave,
|
required this.onLeave,
|
||||||
required this.onProfile,
|
required this.onProfile,
|
||||||
|
this.onInvite,
|
||||||
this.onShowPlayer,
|
this.onShowPlayer,
|
||||||
this.updates,
|
this.updates,
|
||||||
this.windowControls = true,
|
this.windowControls = true,
|
||||||
@@ -82,6 +86,7 @@ class PartyStrip extends StatelessWidget {
|
|||||||
final session = SessionState.instance;
|
final session = SessionState.instance;
|
||||||
final coord = session.coordinator;
|
final coord = session.coordinator;
|
||||||
final online = session.route.value == AppRoute.session;
|
final online = session.route.value == AppRoute.session;
|
||||||
|
final solo = online && (session.solo.value || session.switching.value);
|
||||||
final self = online ? coord.selfMember.value : null;
|
final self = online ? coord.selfMember.value : null;
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -90,14 +95,16 @@ class PartyStrip extends StatelessWidget {
|
|||||||
message: 'Your profile',
|
message: 'Your profile',
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
key: const ValueKey('strip-profile'),
|
key: const ValueKey('strip-profile'),
|
||||||
|
canRequestFocus: false,
|
||||||
customBorder: const CircleBorder(),
|
customBorder: const CircleBorder(),
|
||||||
onTap: onProfile,
|
onTap: onProfile,
|
||||||
child: StatusAvatar(
|
child: StatusAvatar(
|
||||||
info: selfAvatarInfo(
|
info: selfAvatarInfo(
|
||||||
ready: self?.ready ?? false,
|
ready: self?.ready ?? false,
|
||||||
host: online && coord.hostFile != null,
|
host: online && !solo && coord.hostFile != null,
|
||||||
|
error: online ? coord.playerError.value : '',
|
||||||
),
|
),
|
||||||
size: 32,
|
size: stripAvatarSize,
|
||||||
ring: online,
|
ring: online,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -118,6 +125,8 @@ class PartyStrip extends StatelessWidget {
|
|||||||
label: t.label,
|
label: t.label,
|
||||||
selected: t == tab,
|
selected: t == tab,
|
||||||
compact: compact,
|
compact: compact,
|
||||||
|
// The version, a rest of the mouse away.
|
||||||
|
tooltip: t == ShellTab.settings ? '$appName ${displayVersion()}' : null,
|
||||||
onTap: () => onTab(t),
|
onTap: () => onTab(t),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -126,45 +135,59 @@ class PartyStrip extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (kDebugMode)
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Layout demo (debug builds only)',
|
|
||||||
icon: const Icon(
|
|
||||||
Icons.bug_report_outlined,
|
|
||||||
size: 20,
|
|
||||||
color: Colors.amber,
|
|
||||||
),
|
|
||||||
onPressed: () => Navigator.of(context).push(
|
|
||||||
MaterialPageRoute<void>(builder: (_) => const ShellDemo()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
UpdateIndicator(controller: updates),
|
UpdateIndicator(controller: updates),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
if (online) ...[
|
if (online && onShowPlayer != null)
|
||||||
if (onShowPlayer != null)
|
|
||||||
IconButton(
|
IconButton(
|
||||||
key: const ValueKey('show-player'),
|
key: const ValueKey('show-player'),
|
||||||
tooltip: 'Show the player',
|
tooltip: 'Show the player',
|
||||||
onPressed: onShowPlayer,
|
onPressed: onShowPlayer,
|
||||||
icon: const Icon(Icons.smart_display_outlined, size: 20),
|
icon: const Icon(Icons.smart_display_outlined, size: 20),
|
||||||
),
|
),
|
||||||
|
if (solo) ...[
|
||||||
|
_SoloChip(compact: compact, busy: session.switching.value),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
if (compact)
|
||||||
|
FilledButton(
|
||||||
|
key: const ValueKey('strip-invite'),
|
||||||
|
onPressed: session.switching.value ? null : onInvite,
|
||||||
|
child: const Text('Invite'),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
FilledButton.icon(
|
||||||
|
key: const ValueKey('strip-invite'),
|
||||||
|
onPressed: session.switching.value ? null : onInvite,
|
||||||
|
icon: const Icon(Icons.group_add_outlined, size: 18),
|
||||||
|
label: const Text('Invite friends'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
OutlinedButton(
|
||||||
|
key: const ValueKey('strip-leave'),
|
||||||
|
onPressed: session.switching.value ? null : onLeave,
|
||||||
|
child: const Text('Stop'),
|
||||||
|
),
|
||||||
|
] else if (online) ...[
|
||||||
PartyWatch(
|
PartyWatch(
|
||||||
builder: (context) => AvatarStack(
|
builder: (context) => AvatarStack(
|
||||||
key: const ValueKey('party-avatars'),
|
key: const ValueKey('party-avatars'),
|
||||||
members: partyAvatars(coord),
|
members: partyAvatars(coord),
|
||||||
shown: compact ? 3 : 5,
|
shown: compact ? 3 : 5,
|
||||||
|
size: stripAvatarSize,
|
||||||
onTap: () => showMembersCard(context),
|
onTap: () => showMembersCard(context),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
if (!compact) _ServerLabel(scheme: scheme),
|
if (!compact) _ServerLabel(scheme: scheme),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Badge(
|
// Tab toggles the chat while the strip is shown.
|
||||||
|
ChatKey(
|
||||||
|
onToggle: onChat,
|
||||||
|
child: Badge(
|
||||||
isLabelVisible: unread > 0,
|
isLabelVisible: unread > 0,
|
||||||
label: Text('$unread'),
|
label: Text('$unread'),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
key: const ValueKey('strip-chat'),
|
key: const ValueKey('strip-chat'),
|
||||||
tooltip: 'Chat & activity',
|
tooltip: 'Chat & activity (Tab)',
|
||||||
isSelected: chatOpen,
|
isSelected: chatOpen,
|
||||||
onPressed: onChat,
|
onPressed: onChat,
|
||||||
icon: const Icon(Icons.chat_bubble_outline, size: 20),
|
icon: const Icon(Icons.chat_bubble_outline, size: 20),
|
||||||
@@ -175,6 +198,7 @@ class PartyStrip extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
key: const ValueKey('strip-leave'),
|
key: const ValueKey('strip-leave'),
|
||||||
@@ -182,24 +206,36 @@ class PartyStrip extends StatelessWidget {
|
|||||||
child: const Text('Leave'),
|
child: const Text('Leave'),
|
||||||
),
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
if (!compact)
|
if (!compact) ...[
|
||||||
Text(
|
Container(
|
||||||
'Offline',
|
width: 7,
|
||||||
style: TextStyle(color: scheme.onSurfaceVariant),
|
height: 7,
|
||||||
|
decoration: BoxDecoration(color: scheme.outline, shape: BoxShape.circle),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text('Offline', style: TextStyle(color: scheme.onSurfaceVariant)),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
FilledButton.tonal(
|
],
|
||||||
|
if (compact) ...[
|
||||||
|
FilledButton(key: const ValueKey('strip-join'), onPressed: onJoin, child: const Text('Join')),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton.tonal(key: const ValueKey('strip-host'), onPressed: onHost, child: const Text('Host')),
|
||||||
|
] else ...[
|
||||||
|
FilledButton.icon(
|
||||||
key: const ValueKey('strip-join'),
|
key: const ValueKey('strip-join'),
|
||||||
onPressed: onJoin,
|
onPressed: onJoin,
|
||||||
child: const Text('Join'),
|
icon: const Icon(Icons.login_rounded, size: 18),
|
||||||
|
label: const Text('Join a friend'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
FilledButton(
|
FilledButton.tonalIcon(
|
||||||
key: const ValueKey('strip-host'),
|
key: const ValueKey('strip-host'),
|
||||||
onPressed: onHost,
|
onPressed: onHost,
|
||||||
child: const Text('Host'),
|
icon: const Icon(Icons.podcasts_rounded, size: 18),
|
||||||
|
label: const Text('Host'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
if (windowControls) ...[
|
if (windowControls) ...[
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -275,11 +311,54 @@ class _ServerLabel extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "Your server" when hosting, else the address joined.
|
/// "Solo" alone, "Your server" when hosting, else the joined server's
|
||||||
|
/// name (its `--name`), or its address while unknown.
|
||||||
String serverName() {
|
String serverName() {
|
||||||
final session = SessionState.instance;
|
final session = SessionState.instance;
|
||||||
|
if (session.solo.value) return 'Solo';
|
||||||
if (session.coordinator.hostFile != null) return 'Your server';
|
if (session.coordinator.hostFile != null) return 'Your server';
|
||||||
return session.lastForm.host;
|
final label = session.serverLabel.value;
|
||||||
|
return label.isNotEmpty ? label : session.lastForm.host;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Solo: just you, on your own; a spinner while it becomes a server.
|
||||||
|
class _SoloChip extends StatelessWidget {
|
||||||
|
final bool compact;
|
||||||
|
final bool busy;
|
||||||
|
const _SoloChip({required this.compact, required this.busy});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return Tooltip(
|
||||||
|
message: busy
|
||||||
|
? 'Starting your server…'
|
||||||
|
: 'Watching on your own. Nobody can join until you invite friends.',
|
||||||
|
child: Container(
|
||||||
|
key: const ValueKey('solo-chip'),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: scheme.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (busy)
|
||||||
|
const SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
else
|
||||||
|
Icon(Icons.person_outline_rounded, size: 16, color: scheme.primary),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(busy ? 'Inviting…' : 'Solo', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
if (!compact && !busy) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text('only you', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One strip tab: label and an underline that grows in when selected.
|
/// One strip tab: label and an underline that grows in when selected.
|
||||||
@@ -288,18 +367,21 @@ class StripTab extends StatelessWidget {
|
|||||||
final bool selected;
|
final bool selected;
|
||||||
final bool compact;
|
final bool compact;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
/// Shown after resting on the tab.
|
||||||
|
final String? tooltip;
|
||||||
const StripTab({
|
const StripTab({
|
||||||
super.key,
|
super.key,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.selected,
|
required this.selected,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
this.compact = false,
|
this.compact = false,
|
||||||
|
this.tooltip,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return Semantics(
|
final tab = Semantics(
|
||||||
selected: selected,
|
selected: selected,
|
||||||
button: true,
|
button: true,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
@@ -337,11 +419,14 @@ class StripTab extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
final tip = tooltip;
|
||||||
|
return tip == null ? tab : Tooltip(message: tip, waitDuration: const Duration(milliseconds: 600), child: tab);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Who is in the server, from the avatars: status, host / relay pills,
|
/// Who is in the server, from the avatars: status, host / relay pills,
|
||||||
/// per member force ready and kick, and the address to invite friends.
|
/// per member force ready, nudge and sit out, and the address to invite
|
||||||
|
/// friends.
|
||||||
Future<void> showMembersCard(BuildContext context) => showPopover<void>(
|
Future<void> showMembersCard(BuildContext context) => showPopover<void>(
|
||||||
context,
|
context,
|
||||||
at: const Alignment(0.7, -0.84),
|
at: const Alignment(0.7, -0.84),
|
||||||
@@ -403,6 +488,9 @@ class MembersCard extends StatelessWidget {
|
|||||||
final address = hosting
|
final address = hosting
|
||||||
? null
|
? null
|
||||||
: '${session.lastForm.host}:${session.lastForm.port}';
|
: '${session.lastForm.host}:${session.lastForm.port}';
|
||||||
|
final delivery = r == null
|
||||||
|
? null
|
||||||
|
: deliveryOf(r, selfId: coord.sync.memberId, names: coord.memberNames);
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@@ -414,11 +502,12 @@ class MembersCard extends StatelessWidget {
|
|||||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (delivery != null) SourceLine(delivery: delivery),
|
||||||
for (final m in r?.members ?? const <MemberView>[])
|
for (final m in r?.members ?? const <MemberView>[])
|
||||||
_MemberTile(
|
_MemberTile(
|
||||||
member: m,
|
member: m,
|
||||||
info: memberAvatarInfo(m, r, coord),
|
info: memberAvatarInfo(m, r, coord),
|
||||||
viaHost: viaHostOf(m, coord.currentRef.value),
|
delivery: delivery == null ? null : memberDeliveryOf(m, r!, delivery),
|
||||||
),
|
),
|
||||||
if (r == null)
|
if (r == null)
|
||||||
Padding(
|
Padding(
|
||||||
@@ -436,8 +525,7 @@ class MembersCard extends StatelessWidget {
|
|||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: TextButton.icon(
|
child: TextButton.icon(
|
||||||
key: const ValueKey('copy-invite'),
|
key: const ValueKey('copy-invite'),
|
||||||
onPressed: () =>
|
onPressed: () => copyToClipboard(address, done: 'Invite address copied'),
|
||||||
Clipboard.setData(ClipboardData(text: address)),
|
|
||||||
icon: const Icon(Icons.copy, size: 16),
|
icon: const Icon(Icons.copy, size: 16),
|
||||||
label: const Text('Copy invite address'),
|
label: const Text('Copy invite address'),
|
||||||
),
|
),
|
||||||
@@ -460,11 +548,12 @@ class MembersCard extends StatelessWidget {
|
|||||||
class _MemberTile extends StatelessWidget {
|
class _MemberTile extends StatelessWidget {
|
||||||
final MemberView member;
|
final MemberView member;
|
||||||
final AvatarInfo info;
|
final AvatarInfo info;
|
||||||
final bool viaHost;
|
/// How it gets the media and its buffer; null with nothing up or alone.
|
||||||
|
final MemberDelivery? delivery;
|
||||||
const _MemberTile({
|
const _MemberTile({
|
||||||
required this.member,
|
required this.member,
|
||||||
required this.info,
|
required this.info,
|
||||||
required this.viaHost,
|
this.delivery,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -473,6 +562,10 @@ class _MemberTile extends StatelessWidget {
|
|||||||
final m = member;
|
final m = member;
|
||||||
final status = m.stale
|
final status = m.stale
|
||||||
? 'reconnecting…'
|
? 'reconnecting…'
|
||||||
|
: m.out
|
||||||
|
? 'sitting out'
|
||||||
|
: m.error.isNotEmpty
|
||||||
|
? "can't play this: ${m.error}"
|
||||||
: info.buffering
|
: info.buffering
|
||||||
? 'buffering ${m.buffered.toStringAsFixed(1)} s'
|
? 'buffering ${m.buffered.toStringAsFixed(1)} s'
|
||||||
: m.ready
|
: m.ready
|
||||||
@@ -491,34 +584,30 @@ class _MemberTile extends StatelessWidget {
|
|||||||
if (info.host) const Pill('host', Colors.amber),
|
if (info.host) const Pill('host', Colors.amber),
|
||||||
if (info.relaying)
|
if (info.relaying)
|
||||||
const Pill('passing the video on', Colors.lightBlueAccent),
|
const Pill('passing the video on', Colors.lightBlueAccent),
|
||||||
if (viaHost) const ViaHostBadge(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
subtitle: Text(status),
|
subtitle: DeliveryStatus(status: status, delivery: delivery),
|
||||||
trailing: PopupMenuButton<String>(
|
trailing: AppMenuButton<String>(
|
||||||
tooltip: 'More',
|
tooltip: 'More',
|
||||||
icon: const Icon(Icons.more_vert, size: 18),
|
onSelected: (v) {
|
||||||
onSelected: (v) async {
|
switch (v) {
|
||||||
if (v == 'force') {
|
case 'force':
|
||||||
coord.forceTarget(m.id, !m.ready);
|
coord.forceTarget(m.id, !m.ready);
|
||||||
} else if (v == 'kick') {
|
case 'nudge':
|
||||||
final ok = await showKickConfirmDialog(context, m.name);
|
coord.nudgeMember(m.id);
|
||||||
if (ok == true) coord.kick(m.id);
|
case 'out':
|
||||||
|
coord.sitOut(m.id);
|
||||||
|
case 'back':
|
||||||
|
coord.joinBack();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (_) => [
|
items: () => [
|
||||||
PopupMenuItem(
|
if (!m.out) AppMenuItem(value: 'force', label: m.ready ? 'Force unready' : 'Force ready'),
|
||||||
value: 'force',
|
if (!info.self) const AppMenuItem(value: 'nudge', label: 'Nudge'),
|
||||||
child: Text(m.ready ? 'Force unready' : 'Force ready'),
|
if (info.self && m.out)
|
||||||
),
|
const AppMenuItem(value: 'back', label: 'Join back')
|
||||||
if (!info.self && !info.host)
|
else if (!m.out)
|
||||||
PopupMenuItem(
|
const AppMenuItem(value: 'out', label: 'Sit out'),
|
||||||
value: 'kick',
|
|
||||||
child: Text(
|
|
||||||
'Kick',
|
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -546,7 +635,7 @@ class _HostInviteState extends State<_HostInvite> {
|
|||||||
future: _addresses,
|
future: _addresses,
|
||||||
builder: (context, snap) {
|
builder: (context, snap) {
|
||||||
final list = snap.data ?? const <String>[];
|
final list = snap.data ?? const <String>[];
|
||||||
void copy(String a) => Clipboard.setData(ClipboardData(text: a));
|
void copy(String a) => copyToClipboard(a, done: 'Invite address copied');
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:ui' show ImageFilter;
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
@@ -7,15 +6,17 @@ import 'package:sync_relay_core/core.dart';
|
|||||||
import '../session/health.dart';
|
import '../session/health.dart';
|
||||||
import '../session/session_coordinator.dart';
|
import '../session/session_coordinator.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
|
import '../widgets/app_menu.dart' show AppGlass;
|
||||||
import 'avatar.dart';
|
import 'avatar.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
|
|
||||||
/// What the player's status pill says, most pressing first.
|
/// What the player's status pill says, most pressing first.
|
||||||
enum StatusKind { kicked, 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 pill's button: stop reconnecting, retry (reconnect), reload the
|
||||||
/// (the whole pill).
|
/// video (your player could not open it), or mark yourself ready (the
|
||||||
enum StatusAction { cancel, retry, ready }
|
/// whole pill).
|
||||||
|
enum StatusAction { cancel, retry, reload, ready }
|
||||||
|
|
||||||
/// One status line: [text], a muted [detail], a coloured [tone] for
|
/// One status line: [text], a muted [detail], a coloured [tone] for
|
||||||
/// problems (warn, error), the members it is about ([who]; [self] for you)
|
/// problems (warn, error), the members it is about ([who]; [self] for you)
|
||||||
@@ -55,24 +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
|
/// The player's status from the session's signals; null when there is
|
||||||
/// nothing to say (playing, connected, everyone ready). [slowConnect]:
|
/// 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({
|
StatusView? playerStatusFor({
|
||||||
required HealthStatus health,
|
required HealthStatus health,
|
||||||
bool reconnecting = false,
|
bool reconnecting = false,
|
||||||
String? kickReason,
|
|
||||||
StateBroadcast? state,
|
StateBroadcast? state,
|
||||||
MemberView? self,
|
MemberView? self,
|
||||||
bool slowConnect = false,
|
bool slowConnect = false,
|
||||||
|
String playerError = '',
|
||||||
}) {
|
}) {
|
||||||
if (kickReason != null) {
|
|
||||||
return StatusView(StatusKind.kicked,
|
|
||||||
kickReason.isEmpty ? 'You were removed from the server' : 'You were removed: $kickReason',
|
|
||||||
tone: HealthSeverity.error, short: 'Removed from the server');
|
|
||||||
}
|
|
||||||
if (reconnecting) {
|
if (reconnecting) {
|
||||||
return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
|
return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
|
||||||
tone: HealthSeverity.warn, action: StatusAction.cancel);
|
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) {
|
if (health.severity == HealthSeverity.warn || health.severity == HealthSeverity.error) {
|
||||||
return StatusView(StatusKind.problem, _sentence(health.message),
|
return StatusView(StatusKind.problem, _sentence(health.message),
|
||||||
tone: health.severity, action: StatusAction.retry);
|
tone: health.severity, action: StatusAction.retry);
|
||||||
@@ -82,23 +83,44 @@ StatusView? playerStatusFor({
|
|||||||
}
|
}
|
||||||
final r = state;
|
final r = state;
|
||||||
if (r == null || r.media == null || r.transport == 'playing') return null;
|
if (r == null || r.media == null || r.transport == 'playing') return null;
|
||||||
|
// Sitting out: the join-back card says it; nobody waits for you.
|
||||||
|
if (self != null && self.out) return null;
|
||||||
if (r.transport == 'finished') {
|
if (r.transport == 'finished') {
|
||||||
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: '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
|
// As the server holds playback: someone in the ready set short of the
|
||||||
// minimum buffer.
|
// minimum buffer.
|
||||||
|
final need = r.bufferNeed;
|
||||||
for (final m in r.members) {
|
for (final m in r.members) {
|
||||||
if (m.inSet && m.buffered < RelayConstants.minBufferSecs) {
|
if (m.inSet && m.buffered < need) {
|
||||||
final you = self != null && m.id == self.id;
|
final you = self != null && m.id == self.id;
|
||||||
|
final p = (m.buffered / need).clamp(0.0, 1.0);
|
||||||
return StatusView(StatusKind.loading, you ? 'Loading the video…' : '${m.name} is loading…',
|
return StatusView(StatusKind.loading, you ? 'Loading the video…' : '${m.name} is loading…',
|
||||||
who: [m], self: you, progress: (m.buffered / RelayConstants.minBufferSecs).clamp(0.0, 1.0));
|
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) {
|
if (self != null && !self.ready) {
|
||||||
return StatusView(StatusKind.you, "Press Space when you're ready",
|
return StatusView(StatusKind.you, "Press Space when you're ready",
|
||||||
who: [self], self: true, action: StatusAction.ready, short: 'Press Space when ready');
|
who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready');
|
||||||
}
|
}
|
||||||
final active = [for (final m in r.members) if (!m.stale) m];
|
// Members sitting out are never waited for nor counted.
|
||||||
|
final active = [for (final m in r.members) if (!m.stale && !m.out && m.error.isEmpty) m];
|
||||||
final others = [
|
final others = [
|
||||||
for (final m in active)
|
for (final m in active)
|
||||||
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
|
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
|
||||||
@@ -115,10 +137,10 @@ StatusView? playerStatusNow({bool slowConnect = false}) {
|
|||||||
return playerStatusFor(
|
return playerStatusFor(
|
||||||
health: c.health.value,
|
health: c.health.value,
|
||||||
reconnecting: c.reconnecting.value,
|
reconnecting: c.reconnecting.value,
|
||||||
kickReason: c.kickReason.value,
|
|
||||||
state: c.serverState.value,
|
state: c.serverState.value,
|
||||||
self: c.selfMember.value,
|
self: c.selfMember.value,
|
||||||
slowConnect: slowConnect,
|
slowConnect: slowConnect,
|
||||||
|
playerError: c.playerError.value,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +188,8 @@ class _PlayerStatusState extends State<PlayerStatus> {
|
|||||||
coord.cancelReconnect();
|
coord.cancelReconnect();
|
||||||
case StatusAction.retry:
|
case StatusAction.retry:
|
||||||
coord.retry();
|
coord.retry();
|
||||||
|
case StatusAction.reload:
|
||||||
|
unawaited(coord.reloadVideo());
|
||||||
case StatusAction.ready:
|
case StatusAction.ready:
|
||||||
coord.toggleReady();
|
coord.toggleReady();
|
||||||
}
|
}
|
||||||
@@ -193,7 +217,7 @@ class _PlayerStatusState extends State<PlayerStatus> {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
key: const ValueKey('player-status'),
|
key: const ValueKey('player-status'),
|
||||||
onTap: ready ? coord.toggleReady : null,
|
onTap: ready ? coord.toggleReady : null,
|
||||||
child: _Glass(
|
child: PlayerGlass(
|
||||||
child: AnimatedSize(
|
child: AnimatedSize(
|
||||||
duration: easeTime,
|
duration: easeTime,
|
||||||
curve: ease,
|
curve: ease,
|
||||||
@@ -217,32 +241,18 @@ class _PlayerStatusState extends State<PlayerStatus> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Glass extends StatelessWidget {
|
/// 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;
|
final Widget child;
|
||||||
const _Glass({required this.child});
|
const PlayerGlass({super.key, required this.child});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) => ConstrainedBox(
|
||||||
final r = BorderRadius.circular(999);
|
|
||||||
return ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 560),
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
child: ClipRRect(
|
child: AppGlass.pill(child: child),
|
||||||
borderRadius: r,
|
|
||||||
child: BackdropFilter(
|
|
||||||
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
|
|
||||||
child: DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black.withValues(alpha: 0.55),
|
|
||||||
borderRadius: r,
|
|
||||||
border: Border.all(color: Colors.white12, width: 0.5),
|
|
||||||
),
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
class _StatusLine extends StatelessWidget {
|
class _StatusLine extends StatelessWidget {
|
||||||
final StatusView view;
|
final StatusView view;
|
||||||
@@ -250,16 +260,15 @@ class _StatusLine extends StatelessWidget {
|
|||||||
final ValueChanged<StatusAction> onAction;
|
final ValueChanged<StatusAction> onAction;
|
||||||
const _StatusLine({super.key, required this.view, required this.coord, required this.onAction});
|
const _StatusLine({super.key, required this.view, required this.coord, required this.onAction});
|
||||||
|
|
||||||
static const _avatar = 24.0;
|
static const _avatar = 34.0;
|
||||||
|
|
||||||
Widget? _lead() {
|
Widget? _lead() {
|
||||||
final v = view;
|
final v = view;
|
||||||
switch (v.kind) {
|
switch (v.kind) {
|
||||||
case StatusKind.kicked:
|
|
||||||
case StatusKind.problem:
|
case StatusKind.problem:
|
||||||
final error = v.tone == HealthSeverity.error;
|
final error = v.tone == HealthSeverity.error;
|
||||||
return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded,
|
return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded,
|
||||||
size: 18, color: error ? Colors.redAccent.shade100 : Colors.amber);
|
size: 20, color: error ? Colors.redAccent.shade100 : Colors.amber);
|
||||||
case StatusKind.reconnecting:
|
case StatusKind.reconnecting:
|
||||||
return const _Spinner(color: Colors.amber);
|
return const _Spinner(color: Colors.amber);
|
||||||
case StatusKind.connecting:
|
case StatusKind.connecting:
|
||||||
@@ -268,6 +277,7 @@ class _StatusLine extends StatelessWidget {
|
|||||||
return null;
|
return null;
|
||||||
case StatusKind.loading:
|
case StatusKind.loading:
|
||||||
case StatusKind.you:
|
case StatusKind.you:
|
||||||
|
case StatusKind.cannotPlay:
|
||||||
case StatusKind.waiting:
|
case StatusKind.waiting:
|
||||||
final r = coord.serverState.value;
|
final r = coord.serverState.value;
|
||||||
final infos = [
|
final infos = [
|
||||||
@@ -275,9 +285,26 @@ class _StatusLine extends StatelessWidget {
|
|||||||
v.self ? selfAvatarInfo(ready: m.ready, host: m.host) : memberAvatarInfo(m, r, coord),
|
v.self ? selfAvatarInfo(ready: m.ready, host: m.host) : memberAvatarInfo(m, r, coord),
|
||||||
];
|
];
|
||||||
if (infos.isEmpty) return null;
|
if (infos.isEmpty) return null;
|
||||||
return infos.length == 1
|
final avatar = infos.length == 1
|
||||||
? StatusAvatar(info: infos.first, size: _avatar, badges: false)
|
? StatusAvatar(info: infos.first, size: _avatar, badges: false)
|
||||||
: AvatarStack(members: infos, shown: 4, size: _avatar);
|
: AvatarStack(members: infos, shown: 4, size: _avatar);
|
||||||
|
if (v.progress == null) return avatar;
|
||||||
|
// Loading: the progress runs round the avatar.
|
||||||
|
return Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
avatar,
|
||||||
|
SizedBox.square(
|
||||||
|
dimension: _avatar + 6,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
value: v.progress,
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
color: Colors.white,
|
||||||
|
backgroundColor: Colors.white24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,43 +312,45 @@ class _StatusLine extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final v = view;
|
final v = view;
|
||||||
final lead = _lead();
|
final lead = _lead();
|
||||||
|
final avatars = v.who.isNotEmpty;
|
||||||
final action = v.action == StatusAction.ready ? null : v.action;
|
final action = v.action == StatusAction.ready ? null : v.action;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.fromLTRB(lead == null ? 16 : 6, 6, action == null ? 16 : 6, 6),
|
padding: EdgeInsets.fromLTRB(lead == null ? 18 : (avatars ? 5 : 14), 5, action == null ? 18 : 6, 5),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (lead != null) ...[
|
if (lead != null) ...[
|
||||||
SizedBox(height: _avatar, child: Center(child: lead)),
|
SizedBox(height: _avatar + 6, child: Center(child: lead)),
|
||||||
const SizedBox(width: 8),
|
SizedBox(width: avatars ? 10 : 8),
|
||||||
],
|
],
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(minHeight: _avatar),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
v.text,
|
v.text,
|
||||||
key: const ValueKey('player-status-text'),
|
key: const ValueKey('player-status-text'),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600),
|
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600, height: 1.2),
|
||||||
),
|
|
||||||
),
|
|
||||||
if (v.progress != null) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
SizedBox.square(
|
|
||||||
dimension: 14,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
value: v.progress,
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: Colors.white,
|
|
||||||
backgroundColor: Colors.white24,
|
|
||||||
),
|
),
|
||||||
|
if (v.detail != null)
|
||||||
|
Text(
|
||||||
|
v.detail!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(color: Colors.white60, fontSize: 12, fontWeight: FontWeight.w500, height: 1.2),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (v.detail != null) ...[
|
),
|
||||||
const SizedBox(width: 8),
|
),
|
||||||
Text(v.detail!, style: const TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.w500)),
|
),
|
||||||
],
|
|
||||||
if (action != null) ...[
|
if (action != null) ...[
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 12),
|
||||||
_PillButton(
|
_PillButton(
|
||||||
label: action == StatusAction.cancel ? 'Cancel' : 'Retry',
|
label: action == StatusAction.cancel ? 'Cancel' : 'Retry',
|
||||||
onPressed: () => onAction(action),
|
onPressed: () => onAction(action),
|
||||||
|
|||||||
@@ -78,10 +78,11 @@ class _ProfileCardState extends State<ProfileCard> {
|
|||||||
final coord = session.coordinator;
|
final coord = session.coordinator;
|
||||||
final online = session.route.value == AppRoute.session;
|
final online = session.route.value == AppRoute.session;
|
||||||
final self = online ? coord.selfMember.value : null;
|
final self = online ? coord.selfMember.value : null;
|
||||||
final hosting = online && coord.hostFile != null;
|
final hosting = online && !session.solo.value && coord.hostFile != null;
|
||||||
final color = _p.color.value;
|
final color = _p.color.value;
|
||||||
final png = _p.picture.value;
|
final png = _p.picture.value;
|
||||||
final me = selfAvatarInfo(ready: self?.ready ?? false, host: hosting);
|
final out = self?.out ?? false;
|
||||||
|
final me = selfAvatarInfo(ready: self?.ready ?? false, host: hosting, out: out);
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@@ -166,7 +167,8 @@ class _ProfileCardState extends State<ProfileCard> {
|
|||||||
Pill(online ? 'in a server' : 'offline',
|
Pill(online ? 'in a server' : 'offline',
|
||||||
online ? Colors.greenAccent : Colors.grey),
|
online ? Colors.greenAccent : Colors.grey),
|
||||||
if (online)
|
if (online)
|
||||||
Pill((self?.ready ?? false) ? 'ready' : 'not ready', Colors.lightBlueAccent),
|
Pill(out ? 'sitting out' : (self?.ready ?? false) ? 'ready' : 'not ready',
|
||||||
|
Colors.lightBlueAccent),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -237,6 +239,16 @@ class _ProfileCardState extends State<ProfileCard> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
child: Column(children: [
|
child: Column(children: [
|
||||||
|
if (self != null)
|
||||||
|
_link(
|
||||||
|
out ? Icons.login_rounded : Icons.event_seat_outlined,
|
||||||
|
out ? 'Join back' : 'Sit out',
|
||||||
|
trailing: Text(out ? 'Space' : 'stay in the server', style: small),
|
||||||
|
onTap: () {
|
||||||
|
out ? coord.joinBack() : coord.sitOut();
|
||||||
|
Navigator.of(context).maybePop();
|
||||||
|
},
|
||||||
|
),
|
||||||
_link(
|
_link(
|
||||||
Icons.extension_outlined,
|
Icons.extension_outlined,
|
||||||
'Jellyfin & integrations',
|
'Jellyfin & integrations',
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||||
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
|
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -10,7 +14,10 @@ import '../controls/player_settings.dart';
|
|||||||
import '../controls/uosc/buttons.dart';
|
import '../controls/uosc/buttons.dart';
|
||||||
import '../controls/uosc/controls.dart';
|
import '../controls/uosc/controls.dart';
|
||||||
import '../controls/uosc/timeline.dart';
|
import '../controls/uosc/timeline.dart';
|
||||||
import '../playback/media_marks.dart' show SkipOffer, formatClock;
|
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/jellyfin_widgets.dart';
|
||||||
import '../screens/session/player_controls.dart';
|
import '../screens/session/player_controls.dart';
|
||||||
import '../session/session_coordinator.dart';
|
import '../session/session_coordinator.dart';
|
||||||
@@ -18,13 +25,15 @@ import '../shaders/shader_menu.dart';
|
|||||||
import '../shaders/shader_presets.dart';
|
import '../shaders/shader_presets.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import '../state/settings.dart';
|
import '../state/settings.dart';
|
||||||
import '../widgets/shown_frame_probe.dart';
|
import '../widgets/app_keys.dart';
|
||||||
import '../widgets/text_focus.dart';
|
import '../widgets/text_focus.dart';
|
||||||
import '../widgets/window_buttons.dart';
|
import '../widgets/window_buttons.dart';
|
||||||
import 'avatar.dart';
|
import 'avatar.dart';
|
||||||
|
import 'mark_problem.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
import 'now_playing_title.dart';
|
import 'now_playing_title.dart';
|
||||||
import 'player_status.dart';
|
import 'player_status.dart';
|
||||||
|
import 'sit_out_card.dart';
|
||||||
import 'start_banner.dart';
|
import 'start_banner.dart';
|
||||||
|
|
||||||
/// Where the session's video is shown: nowhere, the floating mini player,
|
/// Where the session's video is shown: nowhere, the floating mini player,
|
||||||
@@ -87,7 +96,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
// `Video` (it never swaps controllers in place).
|
// `Video` (it never swaps controllers in place).
|
||||||
var _videoGen = -1;
|
var _videoGen = -1;
|
||||||
var _videoKey = GlobalKey<VideoState>();
|
var _videoKey = GlobalKey<VideoState>();
|
||||||
final _frameProbe = ShownFrameProbe();
|
|
||||||
final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
|
final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
|
||||||
DateTime? _lastKeyAt;
|
DateTime? _lastKeyAt;
|
||||||
late final void Function() _stopShaderWatch;
|
late final void Function() _stopShaderWatch;
|
||||||
@@ -96,9 +104,16 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
int _toastSeq = 0;
|
int _toastSeq = 0;
|
||||||
int _shaderNoticeSeen = 0;
|
int _shaderNoticeSeen = 0;
|
||||||
bool _miniHover = false;
|
bool _miniHover = false;
|
||||||
// Whether the standard controls are up (tracked like media_kit does:
|
// Whether the controls are up: the standard ones tracked like media_kit
|
||||||
// shown on mount and on mouse movement, hidden 3 s later or on exit).
|
// does (shown on mount and on mouse movement, hidden 3 s later or on
|
||||||
final _standardShown = ValueNotifier(true);
|
// 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;
|
SessionCoordinator get _coord => SessionState.instance.coordinator;
|
||||||
bool get _full => widget.mode == PlayerMode.full;
|
bool get _full => widget.mode == PlayerMode.full;
|
||||||
@@ -107,8 +122,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_coord.jellyfin.ensureLoaded();
|
_coord.jellyfin.ensureLoaded();
|
||||||
_coord.playback.shownFrame = _frameProbe.sample;
|
|
||||||
HardwareKeyboard.instance.addHandler(_globalKey);
|
HardwareKeyboard.instance.addHandler(_globalKey);
|
||||||
|
// Tab (AppKeys) reaches the chat through here in fullscreen.
|
||||||
|
AppKeys.addChatToggle(_toggleChat);
|
||||||
// Shader messages (preset changes, load failures) become toasts.
|
// Shader messages (preset changes, load failures) become toasts.
|
||||||
_shaderNoticeSeen = _coord.playback.shaderNotice.peek()?.id ?? 0;
|
_shaderNoticeSeen = _coord.playback.shaderNotice.peek()?.id ?? 0;
|
||||||
_stopShaderWatch = effect(() {
|
_stopShaderWatch = effect(() {
|
||||||
@@ -117,16 +133,21 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
_shaderNoticeSeen = n.id;
|
_shaderNoticeSeen = n.id;
|
||||||
_toast(n.text);
|
_toast(n.text);
|
||||||
});
|
});
|
||||||
|
_stopPopoverWatch = effect(() {
|
||||||
|
_hold.set(#upNextPopover, _coord.queue.popoverOpen.value);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
HardwareKeyboard.instance.removeHandler(_globalKey);
|
HardwareKeyboard.instance.removeHandler(_globalKey);
|
||||||
final playback = _coord.playback;
|
AppKeys.removeChatToggle(_toggleChat);
|
||||||
if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null;
|
|
||||||
_stopShaderWatch();
|
_stopShaderWatch();
|
||||||
|
_stopPopoverWatch();
|
||||||
_noFocus.dispose();
|
_noFocus.dispose();
|
||||||
_standardShown.dispose();
|
_controlsShown.dispose();
|
||||||
|
_hold.dispose();
|
||||||
|
_volumeFlash.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,16 +167,32 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
/// and applied live. A null quality keeps the saved one.
|
/// and applied live. A null quality keeps the saved one.
|
||||||
void _pickShader(ShaderPreset preset, ShaderQuality? quality) => applyShaderChoice(preset, quality);
|
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() {
|
void _toggleChat() {
|
||||||
_coord.markInput();
|
_coord.markInput();
|
||||||
widget.onToggleChat();
|
widget.onToggleChat();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The player keys, while the player fills the body and nothing (a
|
/// The player keys, while the player fills the body and nothing (a
|
||||||
/// dialog, a card, a text field) is in front of it.
|
/// dialog, a card, a text field) is in front of it. Space and Tab are
|
||||||
|
/// app-wide ([AppKeys]).
|
||||||
bool _globalKey(KeyEvent event) {
|
bool _globalKey(KeyEvent event) {
|
||||||
if (event is! KeyDownEvent || !mounted || !_full) return false;
|
if (event is! KeyDownEvent || !mounted) return false;
|
||||||
if (SessionState.instance.route.peek() != AppRoute.session) return false;
|
if (SessionState.instance.route.peek() != AppRoute.session) return false;
|
||||||
|
// Mini: Esc is the last step out, asking to leave the server.
|
||||||
|
if (!_full) {
|
||||||
|
if (event.logicalKey != LogicalKeyboardKey.escape) return false;
|
||||||
|
if (ModalRoute.of(context)?.isCurrent == false || textInputFocused()) return false;
|
||||||
|
widget.onLeave();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
final route = ModalRoute.of(context);
|
final route = ModalRoute.of(context);
|
||||||
if (route != null && !route.isCurrent) {
|
if (route != null && !route.isCurrent) {
|
||||||
// A menu of ours is open: Esc closes it.
|
// A menu of ours is open: Esc closes it.
|
||||||
@@ -184,14 +221,15 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
_reloadVideo();
|
_reloadVideo();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (key == LogicalKeyboardKey.space) {
|
|
||||||
_lastKeyAt = now;
|
|
||||||
coord.toggleReady();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes.
|
// mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes.
|
||||||
final shift = HardwareKeyboard.instance.isShiftPressed;
|
final shift = HardwareKeyboard.instance.isShiftPressed;
|
||||||
if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) {
|
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;
|
if (coord.nextEpisode.peek() == null) return false;
|
||||||
_lastKeyAt = now;
|
_lastKeyAt = now;
|
||||||
coord.playNextEpisode();
|
coord.playNextEpisode();
|
||||||
@@ -213,9 +251,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
coord.skipForward();
|
coord.skipForward();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (key == LogicalKeyboardKey.tab) {
|
if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.arrowDown) {
|
||||||
_lastKeyAt = now;
|
_stepVolume(key == LogicalKeyboardKey.arrowUp ? 1 : -1);
|
||||||
_toggleChat();
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (key == LogicalKeyboardKey.arrowRight) {
|
if (key == LogicalKeyboardKey.arrowRight) {
|
||||||
@@ -235,7 +272,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
} else if (widget.fullscreen) {
|
} else if (widget.fullscreen) {
|
||||||
widget.onToggleFullscreen();
|
widget.onToggleFullscreen();
|
||||||
} else {
|
} else {
|
||||||
widget.onLeave();
|
// One step at a time: big player → mini, then (from mini) leave.
|
||||||
|
widget.onCollapse();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -270,22 +308,28 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
}
|
}
|
||||||
final VideoControlsBuilder? controls = _full ? _sessionControls : null;
|
final VideoControlsBuilder? controls = _full ? _sessionControls : null;
|
||||||
final video = widget.videoBuilder?.call(context, controls) ?? _video(controls);
|
final video = widget.videoBuilder?.call(context, controls) ?? _video(controls);
|
||||||
return Stack(
|
// 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,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
// The frozen-picture watchdog captures this boundary: the
|
video,
|
||||||
// video (and its controls), not the window around it.
|
|
||||||
RepaintBoundary(
|
|
||||||
child: Stack(fit: StackFit.expand, children: [video, _frameProbe.anchor()]),
|
|
||||||
),
|
|
||||||
if (_full) ...[
|
if (_full) ...[
|
||||||
_status(),
|
|
||||||
_progressLine(),
|
_progressLine(),
|
||||||
_jellyfinOverlay(),
|
_blockedCard(),
|
||||||
if (widget.fullscreen) _pausedParty(),
|
_actionArea(),
|
||||||
|
if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()],
|
||||||
|
_volumeReadout(),
|
||||||
] else
|
] else
|
||||||
_miniOverlay(),
|
_miniOverlay(),
|
||||||
|
// Sitting out: nothing is loaded; the card covers the
|
||||||
|
// empty player (which stays mounted for joining back).
|
||||||
|
if (_coord.selfOut.value) _sitOut(),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -340,7 +384,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
final s = AppSettings.instance;
|
final s = AppSettings.instance;
|
||||||
if (s.mpvControls.value || !s.progressLine.value) return const SizedBox.shrink();
|
if (s.mpvControls.value || !s.progressLine.value) return const SizedBox.shrink();
|
||||||
return ValueListenableBuilder<bool>(
|
return ValueListenableBuilder<bool>(
|
||||||
valueListenable: _standardShown,
|
valueListenable: _controlsShown,
|
||||||
builder: (context, shown, _) => shown
|
builder: (context, shown, _) => shown
|
||||||
? const SizedBox.shrink()
|
? const SizedBox.shrink()
|
||||||
: const _MiniSeekBar(key: ValueKey('progress-line-standard'), thin: true),
|
: const _MiniSeekBar(key: ValueKey('progress-line-standard'), thin: true),
|
||||||
@@ -374,6 +418,11 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Expanded(child: NowPlayingTitle()),
|
const Expanded(child: NowPlayingTitle()),
|
||||||
|
UpNextButton(
|
||||||
|
queue: coord.queue,
|
||||||
|
onPlayNextEpisode: coord.playNextEpisode,
|
||||||
|
look: UpNextLook.standard,
|
||||||
|
hold: _hold),
|
||||||
MaterialDesktopCustomButton(
|
MaterialDesktopCustomButton(
|
||||||
icon: const Icon(Icons.video_library_outlined),
|
icon: const Icon(Icons.video_library_outlined),
|
||||||
onPressed: widget.onChangeVideo,
|
onPressed: widget.onChangeVideo,
|
||||||
@@ -437,7 +486,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
onPressed: coord.playNextEpisode,
|
onPressed: coord.playNextEpisode,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const MaterialDesktopVolumeButton(),
|
VolumeButton(player: playback.player, focusNode: _noFocus),
|
||||||
_ReadyControlButton(focusNode: _noFocus),
|
_ReadyControlButton(focusNode: _noFocus),
|
||||||
_BarTextButton(
|
_BarTextButton(
|
||||||
focusNode: _noFocus,
|
focusNode: _noFocus,
|
||||||
@@ -504,8 +553,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
Widget _controlsSwitch(VideoState state) {
|
Widget _controlsSwitch(VideoState state) {
|
||||||
return SessionControlsSwitch(
|
return SessionControlsSwitch(
|
||||||
material: (_) => ControlsHoverProbe(
|
material: (_) => ControlsHoverProbe(
|
||||||
shown: _standardShown,
|
shown: _controlsShown,
|
||||||
hideAfter: _controlsHide,
|
hideAfter: _controlsHide,
|
||||||
|
hold: _hold,
|
||||||
child: DoubleClickListener(
|
child: DoubleClickListener(
|
||||||
onDoubleClick: widget.onToggleFullscreen,
|
onDoubleClick: widget.onToggleFullscreen,
|
||||||
// Clicks on the bars are button clicks, not the video.
|
// Clicks on the bars are button clicks, not the video.
|
||||||
@@ -536,12 +586,23 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
return UoscControls(
|
return UoscControls(
|
||||||
|
shown: _controlsShown,
|
||||||
|
holdBy: _hold,
|
||||||
onDoubleTap: widget.onToggleFullscreen,
|
onDoubleTap: widget.onToggleFullscreen,
|
||||||
topLeft: const NowPlayingTitle(),
|
topLeft: const NowPlayingTitle(),
|
||||||
|
// Up next is one of the top bar's controls, before chat.
|
||||||
top: Row(
|
top: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
UoscButton(icon: Icons.chat_bubble_outline_rounded, tooltip: 'Chat & activity (Tab)', onPressed: _toggleChat),
|
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,
|
||||||
|
tooltip: 'Chat & activity (Tab)',
|
||||||
|
badge: widget.unread,
|
||||||
|
onPressed: _toggleChat,
|
||||||
|
),
|
||||||
UoscButton(
|
UoscButton(
|
||||||
icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
|
icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
|
||||||
tooltip: widget.fullscreen ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
|
tooltip: widget.fullscreen ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
|
||||||
@@ -567,6 +628,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
(title: 'Change video', hint: null, run: widget.onChangeVideo),
|
(title: 'Change video', hint: null, run: widget.onChangeVideo),
|
||||||
(title: 'Chat & activity', hint: 'Tab', run: _toggleChat),
|
(title: 'Chat & activity', hint: 'Tab', run: _toggleChat),
|
||||||
(title: 'Reload video', hint: 'Ctrl+R', run: _reloadVideo),
|
(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: 'Mark everyone ready', hint: null, run: coord.forcePlayAll),
|
||||||
(title: 'Fullscreen', hint: 'F', run: widget.onToggleFullscreen),
|
(title: 'Fullscreen', hint: 'F', run: widget.onToggleFullscreen),
|
||||||
(title: 'Leave server', hint: 'Esc', run: widget.onLeave),
|
(title: 'Leave server', hint: 'Esc', run: widget.onLeave),
|
||||||
@@ -647,70 +709,183 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The one status pill, bottom centre above the controls.
|
/// The side inset every floating element keeps (the bars' own), and
|
||||||
Widget _status() =>
|
/// the gap between them and the controls.
|
||||||
const Positioned(left: 16, right: 16, bottom: 124, child: Center(child: PlayerStatus()));
|
static const _edge = 16.0;
|
||||||
|
|
||||||
/// Jellyfin: the blocked card, the end-of-episode card (countdown) and
|
/// The bottom controls' height in the design in use.
|
||||||
/// the "Next episode" pill; for any media the skip pill (Skip OP / ED /
|
static double _bottomControlsHeight(bool uosc) => uosc
|
||||||
/// 1:30) above it. Outside the auto-hiding controls, so they stay up.
|
? UoscControls.buttonRowHeight + UoscControls.rowBottomPadding + UoscTimeline.height
|
||||||
Widget _jellyfinOverlay() {
|
: _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;
|
final coord = _coord;
|
||||||
return Positioned.fill(
|
return SignalBuilder(
|
||||||
child: SignalBuilder(
|
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final blocked = coord.jellyfinBlocked.value;
|
|
||||||
final next = coord.nextEpisode.value;
|
final next = coord.nextEpisode.value;
|
||||||
final countdown = coord.autoAdvance.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 finished = coord.serverState.value?.transport == 'finished';
|
||||||
final pill = coord.nextPromptVisible.value;
|
final pill = coord.nextPromptVisible.value;
|
||||||
final SkipOffer? skip = coord.skipOffer.value;
|
final SkipOffer? skip = coord.skipOffer.value;
|
||||||
final showNext = next != null && pill;
|
final showNext = next != null && pill;
|
||||||
return Stack(
|
if (upNext != null && finished) {
|
||||||
children: [
|
return ControlsHoldRegion(hold: _hold, child: UpNextCard(queue: coord.queue, countdown: upNext));
|
||||||
if (blocked != null)
|
}
|
||||||
Center(
|
// "Clear" in Up next hides the next episode for this media.
|
||||||
child: JellyfinBlockedCard(
|
if (next != null && (finished || countdown != null) && coord.queue.shownContinuation.value.isNotEmpty) {
|
||||||
blocked: blocked,
|
return ControlsHoldRegion(
|
||||||
onLinkAccount: widget.onOpenIntegrations,
|
hold: _hold,
|
||||||
onRetry: coord.retryJellyfin,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (next != null && (finished || countdown != null))
|
|
||||||
Positioned(
|
|
||||||
right: 24,
|
|
||||||
bottom: 138,
|
|
||||||
child: EndOfEpisodeCard(
|
child: EndOfEpisodeCard(
|
||||||
next: next,
|
next: next,
|
||||||
countdown: countdown,
|
countdown: countdown,
|
||||||
onPlayNow: coord.playNextEpisode,
|
onPlayNow: coord.playNextEpisode,
|
||||||
onCancel: coord.cancelAutoAdvance,
|
onCancel: coord.cancelAutoAdvance,
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
else if (showNext || skip != null)
|
}
|
||||||
Positioned(
|
if (!showNext && skip == null) return const SizedBox.shrink();
|
||||||
right: 24,
|
return Column(
|
||||||
bottom: 124,
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
spacing: 10,
|
spacing: 8,
|
||||||
children: [
|
children: [
|
||||||
if (skip != null) SkipPill(offer: skip, onPressed: () => coord.skip(skip)),
|
if (skip != null)
|
||||||
if (showNext) NextEpisodePill(episode: next, onPressed: coord.playNextEpisode),
|
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.
|
/// Fullscreen hides the strip: while paused the party stays in a corner.
|
||||||
Widget _pausedParty() => Positioned(
|
Widget _pausedParty() => Positioned(
|
||||||
right: 16,
|
right: _edge,
|
||||||
top: _barHeight + 8,
|
top: _topBarHeight + 8,
|
||||||
child: PartyWatch(builder: (context) {
|
child: PartyWatch(builder: (context) {
|
||||||
final r = _coord.serverState.value;
|
final r = _coord.serverState.value;
|
||||||
final paused = r != null && r.transport != 'playing';
|
final paused = r != null && r.transport != 'playing';
|
||||||
@@ -723,6 +898,75 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Fullscreen hides the strip and its chat badge: while there is unread
|
||||||
|
/// 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: _edge,
|
||||||
|
top: _edge,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: _controlsShown,
|
||||||
|
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,
|
||||||
|
opacity: on ? 1 : 0,
|
||||||
|
child: PlayerGlass(
|
||||||
|
key: const ValueKey('unread-chip'),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.chat_bubble_rounded, size: 14, color: Colors.white70),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text('${widget.unread}', style: const TextStyle(color: Colors.white, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// [SitOutCard] with the mini's (or the full player's) expand /
|
||||||
|
/// collapse and close.
|
||||||
|
Widget _sitOut() => Positioned.fill(
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
SitOutCard(key: const ValueKey('sit-out-card'), compact: !_full),
|
||||||
|
Positioned(
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
focusNode: _noFocus,
|
||||||
|
onPressed: _full ? widget.onCollapse : widget.onExpand,
|
||||||
|
tooltip: _full ? 'Small player' : 'Expand',
|
||||||
|
icon: Icon(_full ? Icons.close_fullscreen : Icons.open_in_full, color: Colors.white, size: 18),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
focusNode: _noFocus,
|
||||||
|
onPressed: widget.onClose,
|
||||||
|
tooltip: 'Close the player',
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white, size: 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
/// Mini: title, expand and close on hover; ready, time and a seekable
|
/// Mini: title, expand and close on hover; ready, time and a seekable
|
||||||
/// bar at the bottom. A thin progress line stays while not hovered.
|
/// bar at the bottom. A thin progress line stays while not hovered.
|
||||||
Widget _miniOverlay() {
|
Widget _miniOverlay() {
|
||||||
@@ -762,7 +1006,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
IconButton(
|
IconButton(
|
||||||
focusNode: _noFocus,
|
focusNode: _noFocus,
|
||||||
onPressed: widget.onClose,
|
onPressed: widget.onClose,
|
||||||
tooltip: 'Stop watching (stay in the server)',
|
tooltip: 'Sit out (stay in the server)',
|
||||||
icon: const Icon(Icons.close, color: Colors.white, size: 18),
|
icon: const Icon(Icons.close, color: Colors.white, size: 18),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -809,6 +1053,110 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 Object kind;
|
||||||
|
final bool announce;
|
||||||
|
final ValueListenable<bool> controlsShown;
|
||||||
|
final ControlsHold? hold;
|
||||||
|
final Widget child;
|
||||||
|
const PillGate({
|
||||||
|
super.key,
|
||||||
|
required this.kind,
|
||||||
|
this.announce = false,
|
||||||
|
required this.controlsShown,
|
||||||
|
this.hold,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PillGate> createState() => _PillGateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PillGateState extends State<PillGate> {
|
||||||
|
Object? _kind;
|
||||||
|
bool _fresh = false;
|
||||||
|
bool _hover = false;
|
||||||
|
Timer? _timer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_arm();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(PillGate old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (widget.kind != _kind) _arm();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_timer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _arm() {
|
||||||
|
_kind = widget.kind;
|
||||||
|
_timer?.cancel();
|
||||||
|
_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, [hold] keeps them
|
||||||
|
// up (and it stays while the mouse is on it).
|
||||||
|
return ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: widget.controlsShown,
|
||||||
|
builder: (context, shown, child) {
|
||||||
|
final on = _fresh || shown || _hover;
|
||||||
|
return IgnorePointer(
|
||||||
|
ignoring: !on,
|
||||||
|
child: MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _hover = true),
|
||||||
|
onExit: (_) => setState(() => _hover = false),
|
||||||
|
child: AnimatedOpacity(duration: easeTime, curve: ease, opacity: on ? 1 : 0, child: 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 = [
|
const _shaderKeys = [
|
||||||
LogicalKeyboardKey.digit0,
|
LogicalKeyboardKey.digit0,
|
||||||
LogicalKeyboardKey.digit1,
|
LogicalKeyboardKey.digit1,
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:fox_utils/fox_utils.dart' show DesktopDropX, DropTarget;
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart'
|
||||||
|
show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, parseCookies;
|
||||||
|
|
||||||
import '../controls/player_settings.dart';
|
import '../controls/player_settings.dart';
|
||||||
import '../jellyfin/accounts.dart';
|
import '../jellyfin/accounts.dart';
|
||||||
import '../jellyfin/accounts_panel.dart';
|
import '../jellyfin/accounts_panel.dart';
|
||||||
|
import '../playback/yt_dlp_app.dart';
|
||||||
import '../schedule/animeflow.dart';
|
import '../schedule/animeflow.dart';
|
||||||
import '../screens/home/about_page.dart';
|
import '../screens/home/about_page.dart';
|
||||||
import '../screens/home/settings_page.dart';
|
import '../screens/home/settings_page.dart';
|
||||||
|
import '../sfx/sfx.dart';
|
||||||
|
import '../sfx/sound_bank.dart';
|
||||||
|
import '../sfx/synth_sfx.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
|
import '../state/settings.dart';
|
||||||
import '../update/update_controller.dart';
|
import '../update/update_controller.dart';
|
||||||
|
import '../widgets/app_menu.dart';
|
||||||
import 'avatar.dart';
|
import 'avatar.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
|
|
||||||
@@ -73,7 +87,36 @@ class SettingsTab extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
case SettingsSection.network:
|
case SettingsSection.network:
|
||||||
return const [HostDefaultsPanel(), RecentServersPanel()];
|
return [
|
||||||
|
const HostDefaultsPanel(),
|
||||||
|
const RecentServersPanel(),
|
||||||
|
SettingsPanel(
|
||||||
|
title: 'Watching',
|
||||||
|
child: SignalBuilder(
|
||||||
|
builder: (context) => SwitchListTile(
|
||||||
|
key: const ValueKey('unready-on-swap'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Unready when the video changes'),
|
||||||
|
subtitle: const Text('Otherwise you stay ready and the next video starts once everyone ready has buffered'),
|
||||||
|
value: AppSettings.instance.unreadyOnSwap.value,
|
||||||
|
onChanged: AppSettings.instance.setUnreadyOnSwap,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SettingsPanel(
|
||||||
|
title: 'When idle',
|
||||||
|
child: SignalBuilder(
|
||||||
|
builder: (context) => SwitchListTile(
|
||||||
|
key: const ValueKey('idle-sits-out'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('When idle, sit out instead of just unready'),
|
||||||
|
subtitle: const Text('After 10 min away while ready and held, your player unloads the video until you join back (Space)'),
|
||||||
|
value: AppSettings.instance.idleSitsOut.value,
|
||||||
|
onChanged: AppSettings.instance.setIdleSitsOut,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
case SettingsSection.player:
|
case SettingsSection.player:
|
||||||
return [
|
return [
|
||||||
const SettingsHintCard(
|
const SettingsHintCard(
|
||||||
@@ -85,6 +128,7 @@ class SettingsTab extends StatelessWidget {
|
|||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()),
|
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()),
|
||||||
),
|
),
|
||||||
const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()),
|
const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()),
|
||||||
|
const SettingsPanel(title: 'yt-dlp', child: YtDlpSettings()),
|
||||||
];
|
];
|
||||||
case SettingsSection.integrations:
|
case SettingsSection.integrations:
|
||||||
final accounts = jellyfin ?? JellyfinAccounts.instance;
|
final accounts = jellyfin ?? JellyfinAccounts.instance;
|
||||||
@@ -330,6 +374,289 @@ class RecentServersPanel extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The app's yt-dlp (web page links): its version, "Update now", and the
|
||||||
|
/// last error.
|
||||||
|
class YtDlpSettings extends StatefulWidget {
|
||||||
|
/// Defaults to [AppYtDlp.instance]'s.
|
||||||
|
final Future<YtDlp>? ytDlp;
|
||||||
|
const YtDlpSettings({super.key, this.ytDlp});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<YtDlpSettings> createState() => _YtDlpSettingsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _YtDlpSettingsState extends State<YtDlpSettings> {
|
||||||
|
late final Future<YtDlp> _y = widget.ytDlp ?? AppYtDlp.instance.ytDlp;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return FutureBuilder<YtDlp>(
|
||||||
|
future: _y,
|
||||||
|
builder: (context, snap) {
|
||||||
|
final y = snap.data;
|
||||||
|
if (y == null) return const SizedBox.shrink();
|
||||||
|
return StreamBuilder<void>(
|
||||||
|
stream: y.changes,
|
||||||
|
builder: (context, _) {
|
||||||
|
final err = y.lastError;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.travel_explore),
|
||||||
|
title: Text(y.busy
|
||||||
|
? 'Downloading…'
|
||||||
|
: y.version == null
|
||||||
|
? 'Not downloaded yet'
|
||||||
|
: 'Version ${y.version}'),
|
||||||
|
subtitle: err == null
|
||||||
|
? null
|
||||||
|
: Text(err, key: const ValueKey('ytdlp-error'), style: TextStyle(color: scheme.error)),
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: y.busy
|
||||||
|
? null
|
||||||
|
: () => y.path == null ? y.ensure() : y.update(force: true),
|
||||||
|
child: const Text('Update now'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Plays links to web pages (YouTube, X, …) in all three link modes. '
|
||||||
|
'Downloaded and kept up to date by the app; checked once a day.',
|
||||||
|
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
YtDlpCookieSettings(cookies: y.cookies),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// yt-dlp's cookies (sites that refuse guests): from a browser, or a
|
||||||
|
/// cookies.txt / JSON export picked or dropped, filtered by site into the
|
||||||
|
/// private copy in the yt-dlp folder (see [YtDlpCookies]).
|
||||||
|
class YtDlpCookieSettings extends StatefulWidget {
|
||||||
|
final YtDlpCookies cookies;
|
||||||
|
const YtDlpCookieSettings({super.key, required this.cookies});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<YtDlpCookieSettings> createState() => _YtDlpCookieSettingsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
|
||||||
|
late var _source = widget.cookies.read();
|
||||||
|
String? _error;
|
||||||
|
bool _dragging = false;
|
||||||
|
|
||||||
|
void _reload([String? error]) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_source = widget.cookies.read();
|
||||||
|
_error = error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _summary => switch (_source) {
|
||||||
|
CookieFile f => '${f.count} cookie${f.count == 1 ? '' : 's'} for '
|
||||||
|
'${f.sites.length <= 3 ? f.sites.join(', ') : '${f.sites.length} sites'}',
|
||||||
|
CookieBrowser b => 'From ${b.browser}${b.profile.isEmpty ? '' : ' (profile ${b.profile})'}',
|
||||||
|
_ => 'No cookies: yt-dlp visits sites as a guest',
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<void> _pickFile() async {
|
||||||
|
final List<PlatformFile> files;
|
||||||
|
try {
|
||||||
|
files = await FilePicker.pickFiles(
|
||||||
|
dialogTitle: 'Pick a cookies.txt or a JSON cookie export',
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: const ['txt', 'json'],
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
_reload('file dialog unavailable — try again');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final path = files.firstOrNull?.path;
|
||||||
|
if (path != null) await _importFile(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _importFile(String path) async {
|
||||||
|
final List<NetscapeCookie> all;
|
||||||
|
try {
|
||||||
|
all = parseCookies(await File(path).readAsString());
|
||||||
|
} on FormatException catch (e) {
|
||||||
|
_reload("Can't read that file: ${e.message}");
|
||||||
|
return;
|
||||||
|
} on FileSystemException catch (e) {
|
||||||
|
_reload("Can't read that file: ${e.message}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
// Every site in the file: an export is "my logins", not a pick list.
|
||||||
|
try {
|
||||||
|
await widget.cookies.saveFile(all);
|
||||||
|
_reload();
|
||||||
|
} catch (e) {
|
||||||
|
_reload('Could not keep the cookies: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickBrowser() async {
|
||||||
|
final current = _source;
|
||||||
|
final picked = await showDialog<CookieBrowser>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => _CookieBrowserDialog(initial: current is CookieBrowser ? current : null),
|
||||||
|
);
|
||||||
|
if (picked == null) return;
|
||||||
|
try {
|
||||||
|
await widget.cookies.saveBrowser(picked.browser, picked.profile);
|
||||||
|
_reload();
|
||||||
|
} catch (e) {
|
||||||
|
_reload('Could not save: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _remove() async {
|
||||||
|
try {
|
||||||
|
await widget.cookies.remove();
|
||||||
|
_reload();
|
||||||
|
} catch (e) {
|
||||||
|
_reload('Could not remove the cookies: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return DropTarget(
|
||||||
|
onDragEntered: (_) => setState(() => _dragging = true),
|
||||||
|
onDragExited: (_) => setState(() => _dragging = false),
|
||||||
|
onDragDone: (d) {
|
||||||
|
setState(() => _dragging = false);
|
||||||
|
final path = DesktopDropX.pathsOf(d.files).firstOrNull;
|
||||||
|
if (path != null) _importFile(path);
|
||||||
|
},
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: _dragging ? scheme.primary : scheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.cookie_outlined),
|
||||||
|
title: const Text('Cookies'),
|
||||||
|
subtitle: Text(_summary, key: const ValueKey('ytdlp-cookies-summary')),
|
||||||
|
),
|
||||||
|
if (_error != null)
|
||||||
|
Text(_error!, key: const ValueKey('ytdlp-cookies-error'), style: TextStyle(color: scheme.error)),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _pickBrowser,
|
||||||
|
icon: const Icon(Icons.public),
|
||||||
|
label: const Text('From a browser…'),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _pickFile,
|
||||||
|
icon: const Icon(Icons.file_open_outlined),
|
||||||
|
label: const Text('From a file…'),
|
||||||
|
),
|
||||||
|
if (_source != null)
|
||||||
|
TextButton(
|
||||||
|
key: const ValueKey('ytdlp-cookies-remove'),
|
||||||
|
onPressed: _remove,
|
||||||
|
child: const Text('Remove cookies'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'For sites that refuse guests (Bilibili, YouTube asking to sign in). Drop a cookies.txt or a '
|
||||||
|
'cookie-extension JSON export here, or pick one; all of it is kept, on this computer only. '
|
||||||
|
'Cookies log yt-dlp in as you on those sites. Keep the file private; for YouTube consider a '
|
||||||
|
'throwaway account (Google may flag accounts used with yt-dlp).',
|
||||||
|
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CookieBrowserDialog extends StatefulWidget {
|
||||||
|
final CookieBrowser? initial;
|
||||||
|
const _CookieBrowserDialog({this.initial});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CookieBrowserDialog> createState() => _CookieBrowserDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CookieBrowserDialogState extends State<_CookieBrowserDialog> {
|
||||||
|
late var _browser = widget.initial?.browser ?? cookieBrowsers.first;
|
||||||
|
late final _profile = TextEditingController(text: widget.initial?.profile ?? '');
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_profile.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Cookies from a browser'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 380,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
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(
|
||||||
|
controller: _profile,
|
||||||
|
decoration: const InputDecoration(labelText: 'Profile (optional)', hintText: 'the default profile'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'yt-dlp reads them each time it opens a link (the browser may need to be closed on Windows).',
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context, CookieBrowser(_browser, _profile.text.trim())),
|
||||||
|
child: const Text('Use'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// AnimeFlow (the Schedule tab's source): connected as whom, with which
|
/// AnimeFlow (the Schedule tab's source): connected as whom, with which
|
||||||
/// rights, or why not.
|
/// rights, or why not.
|
||||||
class AnimeFlowCard extends StatefulWidget {
|
class AnimeFlowCard extends StatefulWidget {
|
||||||
@@ -401,8 +728,8 @@ class _AnimeFlowCardState extends State<AnimeFlowCard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What pops up and what makes a sound. No switches: there is nothing to
|
/// What pops up (chat pop-ups: [AppSettings.chatPopups]) and what makes
|
||||||
/// choose yet.
|
/// a sound.
|
||||||
class NotificationsInfo extends StatelessWidget {
|
class NotificationsInfo extends StatelessWidget {
|
||||||
const NotificationsInfo({super.key});
|
const NotificationsInfo({super.key});
|
||||||
|
|
||||||
@@ -424,18 +751,149 @@ class NotificationsInfo extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
line('Joins, leaves, kicks, video changes and chat',
|
line('Joins, leaves and video changes',
|
||||||
'Pop up bottom left for 5 s, with the person\'s picture'),
|
'Pop up bottom left for 5 s, with the person\'s picture'),
|
||||||
line('Ready, seeks and pauses', 'Only in Chat & activity (Tab), never as a pop-up'),
|
Padding(
|
||||||
line('App notices', 'Shader changes, a frozen video reloaded, the idle warning'),
|
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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SettingsPanel(
|
SignalBuilder(
|
||||||
title: 'Sounds',
|
builder: (context) => AppSelect<ChatPopups>(
|
||||||
child: line('No sounds yet', 'The app is silent; only the video makes noise'),
|
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, the idle warning'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SettingsPanel(title: 'Sounds', child: SoundSettings()),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Volume and the sound set, then every sound with a preview button, its
|
||||||
|
/// own set (or the overall one) and its own switch.
|
||||||
|
class SoundSettings extends StatelessWidget {
|
||||||
|
const SoundSettings({super.key});
|
||||||
|
|
||||||
|
void _preview(SfxKind kind) {
|
||||||
|
final sfx = SessionState.instance.sfx;
|
||||||
|
if (sfx is SynthSfx) unawaited(sfx.preview(kind, readyIndex: 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = AppSettings.instance;
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final muted = TextStyle(color: scheme.onSurfaceVariant, fontSize: 12);
|
||||||
|
return SignalBuilder(builder: (context) {
|
||||||
|
final volume = s.soundVolume.value;
|
||||||
|
final off = s.soundsOff.value;
|
||||||
|
final overall = s.soundPreset.value;
|
||||||
|
final per = s.soundPresetFor.value;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(volume == 0 ? Icons.volume_off_outlined : Icons.volume_down_outlined,
|
||||||
|
color: scheme.onSurfaceVariant),
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
key: const ValueKey('sound-volume'),
|
||||||
|
value: volume,
|
||||||
|
onChanged: s.setSoundVolume,
|
||||||
|
onChangeEnd: (_) => _preview(SfxKind.ready),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 44, child: Text('${(volume * 100).round()}%', textAlign: TextAlign.end)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Text('Soft cues for people and readiness; the video itself covers play, pause and the end. '
|
||||||
|
'Letting go of the slider plays the ready sound.', style: muted),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Expanded(child: Text('Sound set')),
|
||||||
|
AppSelect<String>(
|
||||||
|
key: const ValueKey('sound-preset'),
|
||||||
|
value: overall,
|
||||||
|
onChanged: (v) {
|
||||||
|
s.setSoundPreset(v);
|
||||||
|
_preview(SfxKind.ready);
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries) (p.name, name),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Text(presetInfo.entries.where((e) => e.key.name == overall).firstOrNull?.value.$2 ?? '', style: muted),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
for (final MapEntry(key: kind, value: (name, help)) in soundInfo.entries)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
key: ValueKey('sound-play-${kind.name}'),
|
||||||
|
tooltip: 'Play',
|
||||||
|
onPressed: volume == 0 ? null : () => _preview(kind),
|
||||||
|
icon: const Icon(Icons.play_arrow_rounded),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [Text(name), Text(help, style: muted)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AppSelect<String?>(
|
||||||
|
key: ValueKey('sound-preset-${kind.name}'),
|
||||||
|
value: per[kind.name],
|
||||||
|
style: muted,
|
||||||
|
onChanged: (v) {
|
||||||
|
s.setSoundPresetFor(kind.name, v);
|
||||||
|
if (volume > 0) _preview(kind);
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
(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,
|
||||||
|
// its place kept so the rows line up.
|
||||||
|
Visibility.maintain(
|
||||||
|
visible: kind != SfxKind.nudge,
|
||||||
|
child: Switch(
|
||||||
|
key: ValueKey('sound-on-${kind.name}'),
|
||||||
|
value: !off.contains(kind.name),
|
||||||
|
onChanged: volume == 0 || kind == SfxKind.nudge ? null : (on) => s.setSoundOn(kind.name, on),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
import '../playback/media_marks.dart' show formatClock;
|
||||||
|
import '../state/session_state.dart';
|
||||||
|
import '../sync/state_diff.dart' show serverPositionAt;
|
||||||
|
import 'now_playing_title.dart';
|
||||||
|
import 'start_banner.dart';
|
||||||
|
|
||||||
|
/// In place of the video while self sits out: the media's start card
|
||||||
|
/// (cover, title, episode), where the others are ("12:34 / 24:00",
|
||||||
|
/// playing or paused) and "Join back" (or Space). [compact]: the mini
|
||||||
|
/// player.
|
||||||
|
class SitOutCard extends StatefulWidget {
|
||||||
|
final bool compact;
|
||||||
|
const SitOutCard({super.key, this.compact = false});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SitOutCard> createState() => _SitOutCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SitOutCardState extends State<SitOutCard> {
|
||||||
|
// The position moves on while playing: redraw each second.
|
||||||
|
late final Timer _tick;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tick = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tick.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final coord = SessionState.instance.coordinator;
|
||||||
|
final small = widget.compact;
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: artChanges(),
|
||||||
|
builder: (context, _) => SignalBuilder(builder: (context) {
|
||||||
|
final r = coord.serverState.value;
|
||||||
|
final now = coord.nowPlaying.value;
|
||||||
|
final playing = r?.transport == 'playing';
|
||||||
|
final duration = (r?.media?['duration'] as num?)?.toDouble() ?? 0;
|
||||||
|
final at = r == null ? 0.0 : serverPositionAt(r, coord.sync.serverNow());
|
||||||
|
Duration secs(double s) => Duration(milliseconds: (s * 1000).round());
|
||||||
|
final where = r?.media == null
|
||||||
|
? 'Nothing is up'
|
||||||
|
: duration > 0
|
||||||
|
? '${formatClock(secs(at.clamp(0, duration)))} / ${formatClock(secs(duration))}'
|
||||||
|
: formatClock(secs(at));
|
||||||
|
final text = TextStyle(color: Colors.white, fontSize: small ? 12 : 15);
|
||||||
|
final panel = Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text("You're sitting out", style: text.copyWith(fontWeight: FontWeight.w700, fontSize: small ? 13 : 20)),
|
||||||
|
SizedBox(height: small ? 4 : 8),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (r?.media != null) ...[
|
||||||
|
Icon(playing ? Icons.play_arrow_rounded : Icons.pause_rounded,
|
||||||
|
color: Colors.white70, size: small ? 14 : 18),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
],
|
||||||
|
Text(where, key: const ValueKey('sit-out-position'), style: text.copyWith(color: Colors.white70)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(height: small ? 8 : 16),
|
||||||
|
FilledButton.icon(
|
||||||
|
key: const ValueKey('join-back'),
|
||||||
|
onPressed: coord.joinBack,
|
||||||
|
icon: Icon(Icons.login_rounded, size: small ? 16 : 20),
|
||||||
|
label: Text(small ? 'Join back' : 'Join back (Space)'),
|
||||||
|
style: small
|
||||||
|
? FilledButton.styleFrom(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
if (now != null) StartCard(now: now) else const ColoredBox(color: Color(0xFF0C0D11)),
|
||||||
|
// Small: the card's title sits at the bottom, so the panel goes up top.
|
||||||
|
Align(
|
||||||
|
alignment: small ? const Alignment(0, -0.35) : Alignment.center,
|
||||||
|
child: panel,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,9 +26,11 @@ class StartCard extends StatelessWidget {
|
|||||||
final episode = now.episode;
|
final episode = now.episode;
|
||||||
return LayoutBuilder(builder: (context, box) {
|
return LayoutBuilder(builder: (context, box) {
|
||||||
final h = box.maxHeight;
|
final h = box.maxHeight;
|
||||||
|
// Small (the mini player): no cover, a smaller title near the bottom.
|
||||||
|
final small = h < 320;
|
||||||
final coverWidth = (h * 0.26).clamp(84.0, 240.0) / 1.5;
|
final coverWidth = (h * 0.26).clamp(84.0, 240.0) / 1.5;
|
||||||
final titleSize = (h * 0.05).clamp(22.0, 44.0);
|
final titleSize = small ? (h * 0.08).clamp(13.0, 18.0) : (h * 0.05).clamp(22.0, 44.0);
|
||||||
final side = (box.maxWidth * 0.05).clamp(20.0, 72.0);
|
final side = small ? 12.0 : (box.maxWidth * 0.05).clamp(20.0, 72.0);
|
||||||
return Stack(
|
return Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
@@ -76,12 +78,15 @@ class StartCard extends StatelessWidget {
|
|||||||
Positioned(
|
Positioned(
|
||||||
left: side,
|
left: side,
|
||||||
right: side,
|
right: side,
|
||||||
bottom: startCardBottom,
|
bottom: small ? 12 : startCardBottom,
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
|
// A thumbnail already fills the card as the banner.
|
||||||
|
if (!small && !now.thumbnail) ...[
|
||||||
ArtCover(hash: now.posterHash, width: coverWidth),
|
ArtCover(hash: now.posterHash, width: coverWidth),
|
||||||
const SizedBox(width: 24),
|
const SizedBox(width: 24),
|
||||||
|
],
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:signals/signals_flutter.dart';
|
|||||||
|
|
||||||
import '../session/session_coordinator.dart' show ToastItem;
|
import '../session/session_coordinator.dart' show ToastItem;
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
|
import '../state/settings.dart';
|
||||||
import 'avatar.dart';
|
import 'avatar.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
|
|
||||||
@@ -34,8 +35,10 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
|
|||||||
? Icons.login
|
? Icons.login
|
||||||
: rest.startsWith('left')
|
: rest.startsWith('left')
|
||||||
? Icons.logout
|
? Icons.logout
|
||||||
: rest.startsWith('kicked')
|
: rest.startsWith('sat you out')
|
||||||
? Icons.gavel
|
? Icons.bedtime
|
||||||
|
: rest.startsWith('nudged')
|
||||||
|
? Icons.notifications_active_outlined
|
||||||
: rest.startsWith('changed')
|
: rest.startsWith('changed')
|
||||||
? Icons.swap_horiz
|
? Icons.swap_horiz
|
||||||
: Icons.info_outline;
|
: Icons.info_outline;
|
||||||
@@ -43,10 +46,13 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The coordinator's toasts as cards with avatars, bottom-left: each
|
/// The coordinator's toasts as cards with avatars, bottom-left: each
|
||||||
/// slides in, stays [life], then slides out and collapses its gap.
|
/// 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. Chat toasts follow [AppSettings.chatPopups].
|
||||||
class PartyToasts extends StatefulWidget {
|
class PartyToasts extends StatefulWidget {
|
||||||
final Duration life;
|
final Duration life;
|
||||||
const PartyToasts({super.key, this.life = const Duration(seconds: 5)});
|
final VoidCallback? onOpenChat;
|
||||||
|
const PartyToasts({super.key, this.life = const Duration(seconds: 5), this.onOpenChat});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PartyToasts> createState() => _PartyToastsState();
|
State<PartyToasts> createState() => _PartyToastsState();
|
||||||
@@ -71,8 +77,13 @@ class _PartyToastsState extends State<PartyToasts> {
|
|||||||
if (fresh.isEmpty) return;
|
if (fresh.isEmpty) return;
|
||||||
// Only new toasts re-run this, not the party they mention.
|
// Only new toasts re-run this, not the party they mention.
|
||||||
untracked(() {
|
untracked(() {
|
||||||
|
final playing = coord.serverState.value?.transport == 'playing';
|
||||||
|
final popups = AppSettings.instance.chatPopups.value;
|
||||||
for (final t in fresh) {
|
for (final t in fresh) {
|
||||||
_add(shellToastFor(t, (n) => avatarForName(n, coord)));
|
final toast = shellToastFor(t, (n) => avatarForName(n, coord));
|
||||||
|
// Chat per Settings → Notifications (it is in the chat either way).
|
||||||
|
if (toast.chat && !popups.shows(playing: playing)) continue;
|
||||||
|
_add(toast);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -101,13 +112,37 @@ class _PartyToastsState extends State<PartyToasts> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _open(ShellToast t) {
|
||||||
|
widget.onOpenChat?.call();
|
||||||
|
setState(() => t.leaving = true);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => IgnorePointer(child: ToastColumn(toasts: _shown.reversed.take(6).toList().reversed.toList()));
|
Widget build(BuildContext context) => ToastColumn(
|
||||||
|
toasts: _shown.reversed.take(6).toList().reversed.toList(),
|
||||||
|
onTap: widget.onOpenChat == null ? null : _open,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class ToastColumn extends StatelessWidget {
|
class ToastColumn extends StatelessWidget {
|
||||||
final List<ShellToast> toasts;
|
final List<ShellToast> toasts;
|
||||||
const ToastColumn({super.key, required this.toasts});
|
/// Clicks on chat toasts; null (or any other toast): clicks pass through.
|
||||||
|
final ValueChanged<ShellToast>? onTap;
|
||||||
|
const ToastColumn({super.key, required this.toasts, this.onTap});
|
||||||
|
|
||||||
|
Widget _card(ShellToast t) {
|
||||||
|
final card = ToastCard(toast: t);
|
||||||
|
final tap = onTap;
|
||||||
|
if (!t.chat || tap == null || t.leaving) return IgnorePointer(child: card);
|
||||||
|
return MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
child: GestureDetector(
|
||||||
|
key: ValueKey('toast-open-chat-${t.id}'),
|
||||||
|
onTap: () => tap(t),
|
||||||
|
child: card,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -139,7 +174,7 @@ class ToastColumn extends StatelessWidget {
|
|||||||
duration: easeTime,
|
duration: easeTime,
|
||||||
curve: ease,
|
curve: ease,
|
||||||
opacity: t.leaving ? 0 : 1,
|
opacity: t.leaving ? 0 : 1,
|
||||||
child: Padding(padding: const EdgeInsets.only(top: 8), child: ToastCard(toast: t)),
|
child: Padding(padding: const EdgeInsets.only(top: 8), child: _card(t)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../jellyfin/accounts.dart';
|
||||||
|
import '../jellyfin/sign_in_panel.dart';
|
||||||
|
import '../screens/home/ui.dart' show appName;
|
||||||
|
import 'motion.dart';
|
||||||
|
|
||||||
|
/// One line under the app name.
|
||||||
|
const appTagline = 'Watch together, in sync';
|
||||||
|
|
||||||
|
/// Window width from which the welcome band puts the three ways beside
|
||||||
|
/// the app name (else under it).
|
||||||
|
const welcomeWideFrom = 980.0;
|
||||||
|
|
||||||
|
/// Top of the Library while not in a server: the app's name and the three
|
||||||
|
/// ways in, Join a friend first (what most people came for), then Host and
|
||||||
|
/// Watch solo. None of them needs Jellyfin.
|
||||||
|
class WelcomeBand extends StatelessWidget {
|
||||||
|
final VoidCallback onJoin;
|
||||||
|
final VoidCallback onHost;
|
||||||
|
final VoidCallback onSolo;
|
||||||
|
final VoidCallback? onDismiss;
|
||||||
|
const WelcomeBand({super.key, required this.onJoin, required this.onHost, required this.onSolo, this.onDismiss});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final t = Theme.of(context).textTheme;
|
||||||
|
final hero = Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: Image.asset('assets/icon/icon_256.png', width: 52, height: 52, filterQuality: FilterQuality.medium),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(appName, style: t.titleLarge?.copyWith(fontWeight: FontWeight.w700, height: 1.1)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(appTagline, style: TextStyle(color: scheme.onSurfaceVariant)),
|
||||||
|
if (onDismiss != null)
|
||||||
|
TextButton(
|
||||||
|
key: const ValueKey('welcome-dismiss'),
|
||||||
|
onPressed: onDismiss,
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
minimumSize: const Size(0, 24),
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
foregroundColor: scheme.onSurfaceVariant,
|
||||||
|
textStyle: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
child: const Text("Don't show again"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final ways = [
|
||||||
|
WayCard(
|
||||||
|
key: const ValueKey('way-join'),
|
||||||
|
icon: Icons.login_rounded,
|
||||||
|
title: 'Join a friend',
|
||||||
|
caption: 'Got an address from a friend? Join with it. Nothing to set up.',
|
||||||
|
primary: true,
|
||||||
|
onTap: onJoin,
|
||||||
|
),
|
||||||
|
WayCard(
|
||||||
|
key: const ValueKey('way-host'),
|
||||||
|
icon: Icons.podcasts_rounded,
|
||||||
|
title: 'Host',
|
||||||
|
caption: 'You have the video. Friends join you and watch it with you.',
|
||||||
|
onTap: onHost,
|
||||||
|
),
|
||||||
|
WayCard(
|
||||||
|
key: const ValueKey('way-solo'),
|
||||||
|
icon: Icons.person_outline_rounded,
|
||||||
|
title: 'Watch solo',
|
||||||
|
caption: 'Just you: play anything below. Invite friends any time.',
|
||||||
|
onTap: onSolo,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
Widget row(List<Widget> cards) => IntrinsicHeight(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
for (final (i, c) in cards.indexed) ...[if (i > 0) const SizedBox(width: 10), Expanded(child: c)],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final band = Container(
|
||||||
|
key: const ValueKey('welcome-band'),
|
||||||
|
margin: const EdgeInsets.fromLTRB(20, 16, 20, 0),
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [scheme.primary.withValues(alpha: 0.16), scheme.primary.withValues(alpha: 0.03)],
|
||||||
|
),
|
||||||
|
border: Border.all(color: scheme.outlineVariant.withValues(alpha: 0.4)),
|
||||||
|
),
|
||||||
|
child: LayoutBuilder(builder: (context, box) {
|
||||||
|
if (box.maxWidth >= welcomeWideFrom - 40) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Padding(padding: const EdgeInsets.only(left: 4, right: 20), child: hero),
|
||||||
|
Expanded(child: row(ways)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(padding: const EdgeInsets.only(left: 4, bottom: 12), child: hero),
|
||||||
|
if (box.maxWidth >= 520)
|
||||||
|
row(ways)
|
||||||
|
else
|
||||||
|
for (final (i, c) in ways.indexed) ...[if (i > 0) const SizedBox(height: 8), c],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return band;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One way in: a tinted icon, a title and a one-line caption.
|
||||||
|
class WayCard extends StatefulWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String caption;
|
||||||
|
final bool primary;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
const WayCard({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.caption,
|
||||||
|
required this.onTap,
|
||||||
|
this.primary = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WayCard> createState() => _WayCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WayCardState extends State<WayCard> {
|
||||||
|
bool _hover = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final primary = widget.primary;
|
||||||
|
final bg = primary ? scheme.primaryContainer : scheme.surfaceContainerHigh;
|
||||||
|
final fg = primary ? scheme.onPrimaryContainer : scheme.onSurface;
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _hover = true),
|
||||||
|
onExit: (_) => setState(() => _hover = false),
|
||||||
|
child: AnimatedScale(
|
||||||
|
duration: easeTime,
|
||||||
|
curve: ease,
|
||||||
|
scale: _hover ? 1.015 : 1,
|
||||||
|
child: Material(
|
||||||
|
color: bg,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: widget.onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 12, 14, 12),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: primary ? scheme.primary : scheme.primary.withValues(alpha: 0.14),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Icon(widget.icon, size: 20, color: primary ? scheme.onPrimary : scheme.primary),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(widget.title, style: TextStyle(fontWeight: FontWeight.w700, color: fg)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(widget.caption,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(fontSize: 12, height: 1.3, color: fg.withValues(alpha: 0.72))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Library's Jellyfin source with no server linked: what plays with
|
||||||
|
/// nothing set up (Files, Link), then Jellyfin as an optional extra that
|
||||||
|
/// guests never need. The sign-in opens in place.
|
||||||
|
class NoJellyfinPanel extends StatefulWidget {
|
||||||
|
final JellyfinAccounts accounts;
|
||||||
|
final VoidCallback onFiles;
|
||||||
|
final VoidCallback onLink;
|
||||||
|
const NoJellyfinPanel({super.key, required this.accounts, required this.onFiles, required this.onLink});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NoJellyfinPanel> createState() => _NoJellyfinPanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NoJellyfinPanelState extends State<NoJellyfinPanel> {
|
||||||
|
bool _signIn = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final t = Theme.of(context).textTheme;
|
||||||
|
final muted = TextStyle(color: scheme.onSurfaceVariant);
|
||||||
|
final sources = [
|
||||||
|
WayCard(
|
||||||
|
key: const ValueKey('source-files'),
|
||||||
|
icon: Icons.folder_outlined,
|
||||||
|
title: 'Files',
|
||||||
|
caption: 'Videos on this computer. Drop a file or folder anywhere.',
|
||||||
|
onTap: widget.onFiles,
|
||||||
|
),
|
||||||
|
WayCard(
|
||||||
|
key: const ValueKey('source-link'),
|
||||||
|
icon: Icons.link,
|
||||||
|
title: 'Link',
|
||||||
|
caption: 'YouTube, X, or any video address. Ctrl+V anywhere.',
|
||||||
|
onTap: widget.onLink,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return SingleChildScrollView(
|
||||||
|
key: const ValueKey('media-jellyfin'),
|
||||||
|
padding: const EdgeInsets.only(bottom: 24),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 640),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text('Pick something to watch', style: t.titleMedium),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('No account needed: these work for everyone.', style: muted),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
LayoutBuilder(
|
||||||
|
builder: (context, box) => box.maxWidth < 460
|
||||||
|
? Column(children: [sources[0], const SizedBox(height: 8), sources[1]])
|
||||||
|
: IntrinsicHeight(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(child: sources[0]),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(child: sources[1]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Material(
|
||||||
|
key: const ValueKey('jellyfin-optional'),
|
||||||
|
color: scheme.surfaceContainer,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: AnimatedSize(
|
||||||
|
duration: easeTime,
|
||||||
|
curve: ease,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.video_library_outlined, color: scheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
|
||||||
|
const Text('Jellyfin', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: scheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text('optional', style: TextStyle(fontSize: 11, color: scheme.onSurfaceVariant)),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
Text(
|
||||||
|
'Only if you have a Jellyfin server: browse it here, '
|
||||||
|
'with progress and next episodes. Joining a friend never needs it.',
|
||||||
|
style: muted.copyWith(fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (!_signIn)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: TextButton(
|
||||||
|
key: const ValueKey('jellyfin-link'),
|
||||||
|
onPressed: () => setState(() => _signIn = true),
|
||||||
|
child: const Text('Link a server'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_signIn) ...[
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
JellyfinSignInPanel(accounts: widget.accounts, onCancel: () => setState(() => _signIn = false)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,8 +33,12 @@ class ServerIsolate {
|
|||||||
|
|
||||||
/// Spawns the server and waits until it listens. Throws a
|
/// Spawns the server and waits until it listens. Throws a
|
||||||
/// [StateError] with the server's message if it cannot start.
|
/// [StateError] with the server's message if it cannot start.
|
||||||
|
/// [loopbackOnly]: 127.0.0.1 only (the solo session). [logDir]: where
|
||||||
|
/// the server writes its session log (none when null).
|
||||||
static Future<ServerIsolate> start(
|
static Future<ServerIsolate> start(
|
||||||
AppConfig config, {
|
AppConfig config, {
|
||||||
|
bool loopbackOnly = false,
|
||||||
|
String? logDir,
|
||||||
required void Function(String line) onLog,
|
required void Function(String line) onLog,
|
||||||
required void Function(String error) onCrash,
|
required void Function(String error) onCrash,
|
||||||
Duration timeout = const Duration(seconds: 15),
|
Duration timeout = const Duration(seconds: 15),
|
||||||
@@ -55,9 +59,9 @@ class ServerIsolate {
|
|||||||
if (msg is! Map) return;
|
if (msg is! Map) return;
|
||||||
switch (msg['t']) {
|
switch (msg['t']) {
|
||||||
case 'log':
|
case 'log':
|
||||||
final err = msg['error'];
|
final err = msg['error'], stack = msg['stack'];
|
||||||
onLog('${msg['level']} ${msg['logger']}: ${msg['message']}'
|
onLog('${msg['level']} ${msg['logger']}: ${msg['message']}'
|
||||||
'${err == null ? '' : ' ($err)'}');
|
'${err == null ? '' : ' ($err)'}${stack == null ? '' : '\n$stack'}');
|
||||||
case 'ready':
|
case 'ready':
|
||||||
if (!startup.isCompleted) {
|
if (!startup.isCompleted) {
|
||||||
startup.complete((msg['port'] as int, msg['stop'] as SendPort));
|
startup.complete((msg['port'] as int, msg['stop'] as SendPort));
|
||||||
@@ -83,7 +87,7 @@ class ServerIsolate {
|
|||||||
final Isolate isolate;
|
final Isolate isolate;
|
||||||
try {
|
try {
|
||||||
isolate = await Isolate.spawn(
|
isolate = await Isolate.spawn(
|
||||||
_serverMain, (config.toMap(), events.sendPort),
|
_serverMain, ({...config.toMap(), if (loopbackOnly) 'loopbackOnly': true, 'logDir': ?logDir}, events.sendPort),
|
||||||
errorsAreFatal: false,
|
errorsAreFatal: false,
|
||||||
onError: errors.sendPort,
|
onError: errors.sendPort,
|
||||||
onExit: exit.sendPort,
|
onExit: exit.sendPort,
|
||||||
|
|||||||
@@ -8,12 +8,19 @@ import 'package:sync_relay_core/core.dart';
|
|||||||
|
|
||||||
import '../jellyfin/accounts.dart';
|
import '../jellyfin/accounts.dart';
|
||||||
import '../jellyfin/session_media.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/sfx.dart';
|
||||||
|
import '../sfx/sound_bank.dart';
|
||||||
|
import '../sfx/synth_sfx.dart';
|
||||||
import '../session/health.dart';
|
import '../session/health.dart';
|
||||||
import '../session/session_coordinator.dart';
|
import '../session/session_coordinator.dart';
|
||||||
import '../widgets/avatar_pipeline.dart';
|
import '../widgets/avatar_pipeline.dart';
|
||||||
import 'connect_prefs.dart';
|
import 'connect_prefs.dart';
|
||||||
import 'server_isolate.dart';
|
import 'server_isolate.dart';
|
||||||
|
import 'settings.dart';
|
||||||
|
|
||||||
enum AppRoute {
|
enum AppRoute {
|
||||||
home,
|
home,
|
||||||
@@ -140,7 +147,7 @@ class ServerCrashed implements Exception {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A fresh per-hosting key: the app's own client sends it in hello to be
|
/// A fresh per-hosting key: the app's own client sends it in hello to be
|
||||||
/// recognised as the (unkickable) host.
|
/// recognised as the host (`members[].host`).
|
||||||
String newHostKey() {
|
String newHostKey() {
|
||||||
final r = Random.secure();
|
final r = Random.secure();
|
||||||
return base64Url.encode([for (var i = 0; i < 24; i++) r.nextInt(256)]);
|
return base64Url.encode([for (var i = 0; i < 24; i++) r.nextInt(256)]);
|
||||||
@@ -153,19 +160,43 @@ class SessionState {
|
|||||||
final route = signal(AppRoute.home);
|
final route = signal(AppRoute.home);
|
||||||
final recents = signal<List<RecentServer>>(const []);
|
final recents = signal<List<RecentServer>>(const []);
|
||||||
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
||||||
final SfxService sfx = const NoopSfxService();
|
final SfxService sfx = SynthSfx(
|
||||||
|
volume: () => AppSettings.instance.soundVolume.peek(),
|
||||||
|
// A nudge is an explicit poke: it ignores its switch (not the volume).
|
||||||
|
enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name),
|
||||||
|
presetFor: soundPresetFor,
|
||||||
|
);
|
||||||
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
|
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
|
||||||
final lastForm = ConnectForm();
|
final lastForm = ConnectForm();
|
||||||
JellyfinAccounts get jellyfin => JellyfinAccounts.instance;
|
JellyfinAccounts get jellyfin => JellyfinAccounts.instance;
|
||||||
/// Set when the hosted server died mid-session; Home shows it on the
|
/// Set when the hosted server died mid-session; Home shows it on the
|
||||||
/// Host card and clears it.
|
/// Host card and clears it.
|
||||||
final serverCrash = signal<String?>(null);
|
final serverCrash = signal<String?>(null);
|
||||||
|
/// In a solo session: this app's own server on 127.0.0.1, only you in
|
||||||
|
/// it. Hosting from it ([host]) keeps what plays and where.
|
||||||
|
final solo = signal(false);
|
||||||
|
/// The joined server's own name (`/health`), '' until known or when it
|
||||||
|
/// only has the default name.
|
||||||
|
final serverLabel = signal('');
|
||||||
|
/// A solo session is becoming a hosted one: the player has nothing open
|
||||||
|
/// until the media is up again on the new server.
|
||||||
|
final switching = signal(false);
|
||||||
|
ConnectParams? _soloParams;
|
||||||
|
|
||||||
|
/// The app's yt-dlp folder for the hosted server; '' (the server's own
|
||||||
|
/// default) when the app-support folder is not available.
|
||||||
|
static Future<String> _sharedYtDlpDir() async {
|
||||||
|
try {
|
||||||
|
return (await AppYtDlp.instance.ytDlp).dir;
|
||||||
|
} catch (_) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
ServerIsolate? _server;
|
ServerIsolate? _server;
|
||||||
String? _setupCrash;
|
String? _setupCrash;
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
ServerIsolate? get hostedServer => _server;
|
ServerIsolate? get hostedServer => _server;
|
||||||
void Function()? _stopKickWatch;
|
|
||||||
// Bumped by host/join/cancelPending: a cancelled setup notices at its
|
// Bumped by host/join/cancelPending: a cancelled setup notices at its
|
||||||
// next step and unwinds instead of connecting.
|
// next step and unwinds instead of connecting.
|
||||||
int _op = 0;
|
int _op = 0;
|
||||||
@@ -225,9 +256,102 @@ class SessionState {
|
|||||||
await _saveFormQuietly();
|
await _saveFormQuietly();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hosts a server friends can join. From a solo session it keeps the
|
||||||
|
/// media and position: the solo server goes, the new one gets the same
|
||||||
|
/// pick, seeked to where it was; if hosting fails, solo comes back.
|
||||||
Future<void> host(ConnectParams params, ConnectForm form) async {
|
Future<void> host(ConnectParams params, ConnectForm form) async {
|
||||||
|
if (!solo.peek()) return _host(params, form: form);
|
||||||
|
final carry = _invited = _carry();
|
||||||
|
switching.value = true;
|
||||||
|
try {
|
||||||
|
await coordinator.leave();
|
||||||
|
await stopServer();
|
||||||
|
solo.value = false;
|
||||||
|
coordinator.solo = false;
|
||||||
|
try {
|
||||||
|
await _host(params, form: form);
|
||||||
|
} catch (_) {
|
||||||
|
// Not hosted (failed or cancelled): back to solo, same media.
|
||||||
|
try {
|
||||||
|
await _host(_soloParams ?? params, soloSession: true);
|
||||||
|
if (carry != null) await _resume(carry);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('back to solo failed: $e');
|
||||||
|
await leave();
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (carry != null) await _resume(carry);
|
||||||
|
} catch (e) {
|
||||||
|
coordinator.showToast("couldn't put it up again: $e");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
switching.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What an Invite from solo carried over, for [cancelInvite].
|
||||||
|
({PickedMedia pick, double position})? _invited;
|
||||||
|
|
||||||
|
/// Invite cancelled after the hosted server already came up: back to
|
||||||
|
/// solo with the same media, not out of the session.
|
||||||
|
Future<void> cancelInvite() async {
|
||||||
|
final carry = _invited;
|
||||||
|
switching.value = true;
|
||||||
|
try {
|
||||||
|
await coordinator.leave();
|
||||||
|
await stopServer();
|
||||||
|
await _host(_soloParams!, soloSession: true);
|
||||||
|
if (carry != null) await _resume(carry);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('back to solo failed: $e');
|
||||||
|
await leave();
|
||||||
|
} finally {
|
||||||
|
switching.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watch alone: the same hosted path, on 127.0.0.1 and a free port, no
|
||||||
|
/// password, nothing put up yet. Nobody else can reach it.
|
||||||
|
Future<void> startSolo(ConnectParams params) async {
|
||||||
|
if (route.peek() == AppRoute.session) return;
|
||||||
|
_soloParams = params;
|
||||||
|
await _host(params, soloSession: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
({PickedMedia pick, double position})? _carry() {
|
||||||
|
final pick = coordinator.lastPick;
|
||||||
|
if (pick == null || coordinator.serverState.peek()?.media == null) return null;
|
||||||
|
final pos = coordinator.playback.positionSecs;
|
||||||
|
return (pick: pick, position: pos.isFinite && pos > 0 ? pos : 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts [carry] up on the server just joined and seeks to its position
|
||||||
|
/// once the swap shows in a state.
|
||||||
|
Future<void> _resume(({PickedMedia pick, double position}) carry) async {
|
||||||
|
final before = coordinator.serverState.peek()?.mediaId ?? 0;
|
||||||
|
final error = await coordinator.swapPicked(carry.pick);
|
||||||
|
if (error != null) throw StateError(error);
|
||||||
|
if (carry.position < 1) return;
|
||||||
|
final landed = Completer<void>();
|
||||||
|
final stop = effect(() {
|
||||||
|
final r = coordinator.serverState.value;
|
||||||
|
if (r != null && r.media != null && r.mediaId > before && !landed.isCompleted) landed.complete();
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await landed.future.timeout(const Duration(seconds: 20));
|
||||||
|
coordinator.sync.sendSeek(carry.position);
|
||||||
|
} on TimeoutException {
|
||||||
|
debugPrint('resume: the media did not come up; not seeking');
|
||||||
|
} finally {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _host(ConnectParams params, {ConnectForm? form, bool soloSession = false}) async {
|
||||||
final op = ++_op;
|
final op = ++_op;
|
||||||
_rememberForm(form);
|
if (form != null) _rememberForm(form);
|
||||||
try {
|
try {
|
||||||
status.value =
|
status.value =
|
||||||
const HealthStatus(HealthSeverity.info, 'starting server…');
|
const HealthStatus(HealthSeverity.info, 'starting server…');
|
||||||
@@ -247,14 +371,20 @@ class SessionState {
|
|||||||
try {
|
try {
|
||||||
server = await ServerIsolate.start(
|
server = await ServerIsolate.start(
|
||||||
AppConfig(
|
AppConfig(
|
||||||
port: params.port,
|
port: soloSession ? 0 : params.port,
|
||||||
secret: params.secret,
|
secret: soloSession ? '' : params.secret,
|
||||||
name: 'server',
|
name: 'server',
|
||||||
mediaPath: params.mediaPath,
|
mediaPath: params.mediaPath,
|
||||||
hostKey: hostKey,
|
hostKey: hostKey,
|
||||||
jellyfin: relay,
|
jellyfin: relay,
|
||||||
|
ytDlpDir: await _sharedYtDlpDir(),
|
||||||
),
|
),
|
||||||
onLog: (line) => debugPrint('[server] $line'),
|
loopbackOnly: soloSession,
|
||||||
|
// Its session log (every member's shared diagnostics and its own
|
||||||
|
// records) goes next to the app's; not for a solo session.
|
||||||
|
logDir: soloSession ? null : appLogDir,
|
||||||
|
// Its records join the app's log as `server.<logger>`.
|
||||||
|
onLog: (line) => relogLine('server', line),
|
||||||
onCrash: (cause) => _onServerCrash(server, cause),
|
onCrash: (cause) => _onServerCrash(server, cause),
|
||||||
);
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
@@ -270,13 +400,13 @@ class SessionState {
|
|||||||
_server = server;
|
_server = server;
|
||||||
coordinator.hostFile = (path) async =>
|
coordinator.hostFile = (path) async =>
|
||||||
identical(_server, server) ? server.setFile(path) : null;
|
identical(_server, server) ? server.setFile(path) : null;
|
||||||
status.value =
|
status.value = HealthStatus(
|
||||||
HealthStatus(HealthSeverity.ok, 'hosting on port ${server.port}');
|
HealthSeverity.ok, soloSession ? 'watching solo' : 'hosting on port ${server.port}');
|
||||||
try {
|
try {
|
||||||
await coordinator.connect(ConnectParams(
|
await coordinator.connect(ConnectParams(
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: server.port,
|
port: server.port,
|
||||||
secret: params.secret,
|
secret: soloSession ? '' : params.secret,
|
||||||
name: params.name,
|
name: params.name,
|
||||||
avatar: params.avatar,
|
avatar: params.avatar,
|
||||||
hostKey: hostKey,
|
hostKey: hostKey,
|
||||||
@@ -290,10 +420,11 @@ class SessionState {
|
|||||||
if (crash != null && e is! ConnectCancelled) throw ServerCrashed(crash);
|
if (crash != null && e is! ConnectCancelled) throw ServerCrashed(crash);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
_watchKick();
|
solo.value = soloSession;
|
||||||
|
coordinator.solo = soloSession;
|
||||||
route.value = AppRoute.session;
|
route.value = AppRoute.session;
|
||||||
} finally {
|
} finally {
|
||||||
await _saveFormQuietly();
|
if (form != null) await _saveFormQuietly();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,6 +450,11 @@ class SessionState {
|
|||||||
await coordinator.connect(params);
|
await coordinator.connect(params);
|
||||||
if (op != _op) throw const ConnectCancelled();
|
if (op != _op) throw const ConnectCancelled();
|
||||||
route.value = AppRoute.session;
|
route.value = AppRoute.session;
|
||||||
|
serverLabel.value = '';
|
||||||
|
unawaited(probeHealth(params.host, '${params.port}', defaultHealthGet).then((h) {
|
||||||
|
// 'server' is the dedicated server's default --name: not a name.
|
||||||
|
if (op == _op && h.online && h.name.isNotEmpty && h.name != 'server') serverLabel.value = h.name;
|
||||||
|
}).catchError((Object _) {}));
|
||||||
} finally {
|
} finally {
|
||||||
await _saveFormQuietly();
|
await _saveFormQuietly();
|
||||||
}
|
}
|
||||||
@@ -331,16 +467,9 @@ class SessionState {
|
|||||||
try {
|
try {
|
||||||
await coordinator.cancelConnect();
|
await coordinator.cancelConnect();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
await stopServer();
|
// Solo becoming hosted: [host] stops what it started and goes back
|
||||||
}
|
// to solo itself.
|
||||||
|
if (!switching.peek()) await stopServer();
|
||||||
/// A kicked host must not keep serving.
|
|
||||||
void _watchKick() {
|
|
||||||
_stopKickWatch?.call();
|
|
||||||
_stopKickWatch = effect(() {
|
|
||||||
// Microtask: stopServer disposes this effect, not from inside it.
|
|
||||||
if (coordinator.kickReason.value != null) scheduleMicrotask(stopServer);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saved whatever the outcome: a failed join is exactly when the user
|
/// Saved whatever the outcome: a failed join is exactly when the user
|
||||||
@@ -368,8 +497,6 @@ class SessionState {
|
|||||||
|
|
||||||
Future<void> stopServer() async {
|
Future<void> stopServer() async {
|
||||||
coordinator.hostFile = null;
|
coordinator.hostFile = null;
|
||||||
_stopKickWatch?.call();
|
|
||||||
_stopKickWatch = null;
|
|
||||||
final server = _server;
|
final server = _server;
|
||||||
_server = null;
|
_server = null;
|
||||||
await server?.stop();
|
await server?.stop();
|
||||||
@@ -378,7 +505,18 @@ class SessionState {
|
|||||||
Future<void> leave() async {
|
Future<void> leave() async {
|
||||||
await coordinator.leave();
|
await coordinator.leave();
|
||||||
await stopServer();
|
await stopServer();
|
||||||
|
solo.value = false;
|
||||||
|
coordinator.solo = false;
|
||||||
|
serverLabel.value = '';
|
||||||
status.value = const HealthStatus(HealthSeverity.info, 'idle');
|
status.value = const HealthStatus(HealthSeverity.info, 'idle');
|
||||||
route.value = AppRoute.home;
|
route.value = AppRoute.home;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The sound set [kind] plays from: its own pick in Settings, else the
|
||||||
|
/// overall one.
|
||||||
|
SoundPreset soundPresetFor(SfxKind kind) {
|
||||||
|
final s = AppSettings.instance;
|
||||||
|
final name = s.soundPresetFor.peek()[kind.name] ?? s.soundPreset.peek();
|
||||||
|
return SoundPreset.values.where((p) => p.name == name).firstOrNull ?? defaultPreset;
|
||||||
|
}
|
||||||
@@ -20,9 +20,35 @@ const _keyAutoAdvance = 'autoAdvance';
|
|||||||
const _keyMpvControls = 'mpvControls';
|
const _keyMpvControls = 'mpvControls';
|
||||||
const _keyHideReReleases = 'hideReReleases';
|
const _keyHideReReleases = 'hideReReleases';
|
||||||
const _keyProgressLine = 'progressLine';
|
const _keyProgressLine = 'progressLine';
|
||||||
|
const _keySoundVolume = 'soundVolume';
|
||||||
|
const _keySoundsOff = 'soundsOff';
|
||||||
|
const _keySoundPreset = 'soundPreset';
|
||||||
|
const _keyWelcomeDismissed = 'welcomeDismissed';
|
||||||
|
const _keySoundPresetFor = 'soundPresetFor';
|
||||||
|
const _keyUnreadyOnSwap = 'unreadyOnSwap';
|
||||||
|
const _keyIdleSitsOut = 'idleSitsOut';
|
||||||
|
const _keyChatPopups = 'chatPopups';
|
||||||
|
const _keyShareDiagnostics = 'shareDiagnostics';
|
||||||
|
|
||||||
enum HwdecMode { auto, off, custom }
|
enum HwdecMode { auto, off, custom }
|
||||||
|
|
||||||
|
/// When chat messages pop up as toasts (they always reach the chat).
|
||||||
|
enum ChatPopups {
|
||||||
|
all('All'),
|
||||||
|
paused('Only when paused'),
|
||||||
|
off('Off');
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
const ChatPopups(this.label);
|
||||||
|
|
||||||
|
/// Whether a chat message pops up now ([playing]: the video plays).
|
||||||
|
bool shows({required bool playing}) => switch (this) {
|
||||||
|
ChatPopups.all => true,
|
||||||
|
ChatPopups.paused => !playing,
|
||||||
|
ChatPopups.off => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
class AppSettings {
|
class AppSettings {
|
||||||
static final AppSettings instance = AppSettings();
|
static final AppSettings instance = AppSettings();
|
||||||
|
|
||||||
@@ -31,6 +57,11 @@ class AppSettings {
|
|||||||
final autoCheckUpdates = signal(true);
|
final autoCheckUpdates = signal(true);
|
||||||
/// Jellyfin episodes: at the end, count down 10 s and put up the next.
|
/// Jellyfin episodes: at the end, count down 10 s and put up the next.
|
||||||
final autoAdvance = signal(true);
|
final autoAdvance = signal(true);
|
||||||
|
/// Session: send ready=false when the video changes (a swap keeps the
|
||||||
|
/// server's ready flags).
|
||||||
|
final unreadyOnSwap = signal(false);
|
||||||
|
/// Session: the idle auto-unready sits this member out instead.
|
||||||
|
final idleSitsOut = signal(false);
|
||||||
/// Player design: the uosc controls (`controls/uosc/`) instead of the
|
/// Player design: the uosc controls (`controls/uosc/`) instead of the
|
||||||
/// standard ones (media_kit's with our bars). Applies live.
|
/// standard ones (media_kit's with our bars). Applies live.
|
||||||
final mpvControls = signal(false);
|
final mpvControls = signal(false);
|
||||||
@@ -40,6 +71,22 @@ class AppSettings {
|
|||||||
/// A 2 px progress line at the bottom of the video while the controls
|
/// A 2 px progress line at the bottom of the video while the controls
|
||||||
/// are hidden (both designs). Applies live.
|
/// are hidden (both designs). Applies live.
|
||||||
final progressLine = signal(true);
|
final progressLine = signal(true);
|
||||||
|
/// Send this app's log lines to the server's session log while in a
|
||||||
|
/// session (never chat). Off: nothing is sent but a note that it is off.
|
||||||
|
final shareDiagnostics = signal(true);
|
||||||
|
/// Chat message toasts (default: only when paused). Applies live.
|
||||||
|
final chatPopups = signal(ChatPopups.paused);
|
||||||
|
/// App sounds (`sfx/`): 0..1, 0 = silent.
|
||||||
|
final soundVolume = signal(1.0);
|
||||||
|
/// `SfxKind` names switched off one by one.
|
||||||
|
final soundsOff = signal<Set<String>>(const {});
|
||||||
|
/// The sound set (`SoundPreset` name) every sound uses unless
|
||||||
|
/// [soundPresetFor] picks another one for it.
|
||||||
|
final soundPreset = signal('cosmic');
|
||||||
|
/// The offline welcome band's × was pressed: never show it again.
|
||||||
|
final welcomeDismissed = signal(false);
|
||||||
|
/// `SfxKind` name -> `SoundPreset` name, per-sound overrides.
|
||||||
|
final soundPresetFor = signal<Map<String, String>>(const {});
|
||||||
|
|
||||||
/// Video shaders: one choice, used on join and changed both here and
|
/// Video shaders: one choice, used on join and changed both here and
|
||||||
/// in the session (Ctrl+0..6, the shader button).
|
/// in the session (Ctrl+0..6, the shader button).
|
||||||
@@ -76,12 +123,35 @@ class AppSettings {
|
|||||||
if (auto is bool) autoCheckUpdates.value = auto;
|
if (auto is bool) autoCheckUpdates.value = auto;
|
||||||
final advance = box.get(_keyAutoAdvance);
|
final advance = box.get(_keyAutoAdvance);
|
||||||
if (advance is bool) autoAdvance.value = advance;
|
if (advance is bool) autoAdvance.value = advance;
|
||||||
|
final unready = box.get(_keyUnreadyOnSwap);
|
||||||
|
if (unready is bool) unreadyOnSwap.value = unready;
|
||||||
|
final idleOut = box.get(_keyIdleSitsOut);
|
||||||
|
if (idleOut is bool) idleSitsOut.value = idleOut;
|
||||||
final mpv = box.get(_keyMpvControls);
|
final mpv = box.get(_keyMpvControls);
|
||||||
if (mpv is bool) mpvControls.value = mpv;
|
if (mpv is bool) mpvControls.value = mpv;
|
||||||
final hideRe = box.get(_keyHideReReleases);
|
final hideRe = box.get(_keyHideReReleases);
|
||||||
if (hideRe is bool) hideReReleases.value = hideRe;
|
if (hideRe is bool) hideReReleases.value = hideRe;
|
||||||
final line = box.get(_keyProgressLine);
|
final line = box.get(_keyProgressLine);
|
||||||
if (line is bool) progressLine.value = line;
|
if (line is bool) progressLine.value = line;
|
||||||
|
final share = box.get(_keyShareDiagnostics);
|
||||||
|
if (share is bool) shareDiagnostics.value = share;
|
||||||
|
final popups = box.get(_keyChatPopups);
|
||||||
|
chatPopups.value = ChatPopups.values.where((m) => m.name == popups).firstOrNull ?? ChatPopups.paused;
|
||||||
|
final vol = box.get(_keySoundVolume);
|
||||||
|
if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1);
|
||||||
|
final off = box.get(_keySoundsOff);
|
||||||
|
if (off is List) soundsOff.value = Set.unmodifiable(off.whereType<String>());
|
||||||
|
final welcomeOff = box.get(_keyWelcomeDismissed);
|
||||||
|
if (welcomeOff is bool) welcomeDismissed.value = welcomeOff;
|
||||||
|
final soundSet = box.get(_keySoundPreset);
|
||||||
|
if (soundSet is String) soundPreset.value = soundSet;
|
||||||
|
final per = box.get(_keySoundPresetFor);
|
||||||
|
if (per is Map) {
|
||||||
|
soundPresetFor.value = Map.unmodifiable({
|
||||||
|
for (final e in per.entries)
|
||||||
|
if (e.key is String && e.value is String) e.key as String: e.value as String,
|
||||||
|
});
|
||||||
|
}
|
||||||
final preset = box.get(_keyShaderPreset);
|
final preset = box.get(_keyShaderPreset);
|
||||||
shaderPreset.value =
|
shaderPreset.value =
|
||||||
ShaderPreset.values.where((p) => p.name == preset).firstOrNull ??
|
ShaderPreset.values.where((p) => p.name == preset).firstOrNull ??
|
||||||
@@ -119,6 +189,16 @@ class AppSettings {
|
|||||||
_put(_keyAutoAdvance, on);
|
_put(_keyAutoAdvance, on);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setUnreadyOnSwap(bool on) {
|
||||||
|
unreadyOnSwap.value = on;
|
||||||
|
_put(_keyUnreadyOnSwap, on);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setIdleSitsOut(bool on) {
|
||||||
|
idleSitsOut.value = on;
|
||||||
|
_put(_keyIdleSitsOut, on);
|
||||||
|
}
|
||||||
|
|
||||||
void setMpvControls(bool on) {
|
void setMpvControls(bool on) {
|
||||||
mpvControls.value = on;
|
mpvControls.value = on;
|
||||||
_put(_keyMpvControls, on);
|
_put(_keyMpvControls, on);
|
||||||
@@ -129,6 +209,44 @@ class AppSettings {
|
|||||||
_put(_keyProgressLine, on);
|
_put(_keyProgressLine, on);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setShareDiagnostics(bool on) {
|
||||||
|
shareDiagnostics.value = on;
|
||||||
|
_put(_keyShareDiagnostics, on);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setChatPopups(ChatPopups mode) {
|
||||||
|
chatPopups.value = mode;
|
||||||
|
_put(_keyChatPopups, mode.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSoundVolume(double v) {
|
||||||
|
soundVolume.value = v.clamp(0, 1);
|
||||||
|
_put(_keySoundVolume, soundVolume.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSoundOn(String kind, bool on) {
|
||||||
|
soundsOff.value = Set.unmodifiable(on ? ({...soundsOff.value}..remove(kind)) : {...soundsOff.value, kind});
|
||||||
|
_put(_keySoundsOff, soundsOff.value.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
void dismissWelcome() {
|
||||||
|
welcomeDismissed.value = true;
|
||||||
|
_put(_keyWelcomeDismissed, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSoundPreset(String preset) {
|
||||||
|
soundPreset.value = preset;
|
||||||
|
_put(_keySoundPreset, preset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [preset] null: the sound follows [soundPreset] again.
|
||||||
|
void setSoundPresetFor(String kind, String? preset) {
|
||||||
|
final m = {...soundPresetFor.value};
|
||||||
|
preset == null ? m.remove(kind) : m[kind] = preset;
|
||||||
|
soundPresetFor.value = Map.unmodifiable(m);
|
||||||
|
_put(_keySoundPresetFor, m);
|
||||||
|
}
|
||||||
|
|
||||||
void setHideReReleases(bool on) {
|
void setHideReReleases(bool on) {
|
||||||
hideReReleases.value = on;
|
hideReReleases.value = on;
|
||||||
_put(_keyHideReReleases, on);
|
_put(_keyHideReReleases, on);
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ bool localPlayAllowed(StateBroadcast state, String memberId) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// At `finished`, a player at most this far behind the end keeps playing
|
||||||
|
/// to it.
|
||||||
|
const finishSlackSecs = 3.0;
|
||||||
|
|
||||||
class StateDiff {
|
class StateDiff {
|
||||||
static const debounceWindow = Duration(seconds: 2);
|
static const debounceWindow = Duration(seconds: 2);
|
||||||
|
|
||||||
@@ -73,7 +77,9 @@ class StateDiff {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// End of media: everyone stops where they are (at or just before the
|
// End of media: everyone stops where they are (at or just before the
|
||||||
// end). No seek to the very end and no drift correction.
|
// end). No seek to the very end and no drift correction. A player a
|
||||||
|
// little behind the server's clock plays its last moments out (mpv
|
||||||
|
// stops at the end by itself) instead of being cut short.
|
||||||
if (state.transport == 'finished') {
|
if (state.transport == 'finished') {
|
||||||
final first = _lastEventId == -1;
|
final first = _lastEventId == -1;
|
||||||
final changed = first ||
|
final changed = first ||
|
||||||
@@ -87,7 +93,9 @@ class StateDiff {
|
|||||||
if (!changed) return out;
|
if (!changed) return out;
|
||||||
out.add(const RateAction(1.0));
|
out.add(const RateAction(1.0));
|
||||||
if (first) out.add(SeekAction(serverPos > 1 ? serverPos - 1 : 0));
|
if (first) out.add(SeekAction(serverPos > 1 ? serverPos - 1 : 0));
|
||||||
|
if (first || state.position - ownPosition > finishSlackSecs) {
|
||||||
out.add(const TransportAction(false));
|
out.add(const TransportAction(false));
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'dart:convert';
|
|||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
|
||||||
enum SyncStatus { idle, connecting, open, kicked, closed, error }
|
enum SyncStatus { idle, connecting, open, closed, error }
|
||||||
|
|
||||||
class SyncClient {
|
class SyncClient {
|
||||||
/// Local clock; injectable so tests can run a client whose clock is off.
|
/// Local clock; injectable so tests can run a client whose clock is off.
|
||||||
@@ -23,13 +23,14 @@ class SyncClient {
|
|||||||
StreamSubscription? _sub;
|
StreamSubscription? _sub;
|
||||||
final StreamController<StateBroadcast> _states = StreamController.broadcast();
|
final StreamController<StateBroadcast> _states = StreamController.broadcast();
|
||||||
final StreamController<FeedMessage> _feeds = StreamController.broadcast();
|
final StreamController<FeedMessage> _feeds = StreamController.broadcast();
|
||||||
final StreamController<String> _kicked = StreamController.broadcast();
|
final StreamController<NudgeNotice> _nudges = StreamController.broadcast();
|
||||||
final StreamController<String> _errors = StreamController.broadcast();
|
final StreamController<String> _errors = StreamController.broadcast();
|
||||||
final StreamController<SyncStatus> _status = StreamController.broadcast();
|
final StreamController<SyncStatus> _status = StreamController.broadcast();
|
||||||
|
|
||||||
Stream<StateBroadcast> get states => _states.stream;
|
Stream<StateBroadcast> get states => _states.stream;
|
||||||
Stream<FeedMessage> get feeds => _feeds.stream;
|
Stream<FeedMessage> get feeds => _feeds.stream;
|
||||||
Stream<String> get kicked => _kicked.stream;
|
/// Someone poked this member (`nudge`).
|
||||||
|
Stream<NudgeNotice> get nudges => _nudges.stream;
|
||||||
Stream<String> get errors => _errors.stream;
|
Stream<String> get errors => _errors.stream;
|
||||||
Stream<SyncStatus> get status => _status.stream;
|
Stream<SyncStatus> get status => _status.stream;
|
||||||
|
|
||||||
@@ -153,14 +154,10 @@ class SyncClient {
|
|||||||
// one toast, one sfx.
|
// one toast, one sfx.
|
||||||
if (f.action != 'chat') _feeds.add(f);
|
if (f.action != 'chat') _feeds.add(f);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
case 'kicked':
|
case 'nudge':
|
||||||
try {
|
try {
|
||||||
final msg = KickedMessage.fromJson(decoded);
|
_nudges.add(NudgeNotice.fromJson(decoded));
|
||||||
_kicked.add(msg.reason);
|
} catch (_) {}
|
||||||
_status.add(SyncStatus.kicked);
|
|
||||||
} catch (_) {
|
|
||||||
_kicked.add('');
|
|
||||||
}
|
|
||||||
case 'pong':
|
case 'pong':
|
||||||
try {
|
try {
|
||||||
final pong = PongMessage.fromJson(decoded);
|
final pong = PongMessage.fromJson(decoded);
|
||||||
@@ -211,8 +208,13 @@ class SyncClient {
|
|||||||
_send(PingMessage(clientMs: _clock().millisecondsSinceEpoch).toJson());
|
_send(PingMessage(clientMs: _clock().millisecondsSinceEpoch).toJson());
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendSwap(HelloMedia media) {
|
void sendSwap(HelloMedia media, {String? queueId}) {
|
||||||
_send(SwapIntent(media: media).toJson());
|
_send(SwapIntent(media: media, queueId: queueId).toJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One change to Up next (see [QueueIntent]).
|
||||||
|
void sendQueue(QueueIntent intent) {
|
||||||
|
_send(intent.toJson());
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendReport({
|
void sendReport({
|
||||||
@@ -222,6 +224,7 @@ class SyncClient {
|
|||||||
double duration = 0,
|
double duration = 0,
|
||||||
int mediaId = 0,
|
int mediaId = 0,
|
||||||
Map<String, bool>? jf,
|
Map<String, bool>? jf,
|
||||||
|
String error = '',
|
||||||
}) {
|
}) {
|
||||||
final safeBuffered = buffered.isNaN || buffered < 0 ? 0.0 : buffered;
|
final safeBuffered = buffered.isNaN || buffered < 0 ? 0.0 : buffered;
|
||||||
_send(
|
_send(
|
||||||
@@ -232,6 +235,7 @@ class SyncClient {
|
|||||||
duration: duration.isFinite && duration > 0 ? duration : 0,
|
duration: duration.isFinite && duration > 0 ? duration : 0,
|
||||||
mediaId: mediaId,
|
mediaId: mediaId,
|
||||||
jf: jf,
|
jf: jf,
|
||||||
|
error: error,
|
||||||
).toJson(),
|
).toJson(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -255,8 +259,14 @@ class SyncClient {
|
|||||||
_send(ArtMessage(mediaId: mediaId, poster: poster, banner: banner).toJson());
|
_send(ArtMessage(mediaId: mediaId, poster: poster, banner: banner).toJson());
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendKick(String target, {String reason = ''}) {
|
/// Sits the member [target] out, or with [out] false brings this
|
||||||
_send(KickRequest(target: target, reason: reason).toJson());
|
/// member back (only oneself can come back).
|
||||||
|
void sendOut(String target, bool out) {
|
||||||
|
_send(OutIntent(target: target, out: out).toJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendNudge(String target) {
|
||||||
|
_send(NudgeIntent(target: target).toJson());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
@@ -283,7 +293,7 @@ class SyncClient {
|
|||||||
await disconnect();
|
await disconnect();
|
||||||
await _states.close();
|
await _states.close();
|
||||||
await _feeds.close();
|
await _feeds.close();
|
||||||
await _kicked.close();
|
await _nudges.close();
|
||||||
await _errors.close();
|
await _errors.close();
|
||||||
await _status.close();
|
await _status.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@echo off
|
@echo off
|
||||||
rem Update this install in place (same as clicking Update in the app).
|
rem Update this install in place (same as clicking Update in the app).
|
||||||
"%~dp0watch_together.exe" --upgrade %*
|
"%~dp0watch-together-server.exe" --upgrade %*
|
||||||
pause
|
pause
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Update this install in place (same as clicking Update in the app).
|
# Update this install in place (same as clicking Update in the app).
|
||||||
cd "$(dirname "$0")" && exec ./watch_together --upgrade "$@"
|
# The server binary runs the updater: it needs no GTK/display (VPS).
|
||||||
|
cd "$(dirname "$0")" && exec ./watch-together-server --upgrade "$@"
|
||||||