Compare commits
18
Commits
v1.2.0
..
9f2586b7ff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f2586b7ff | ||
|
|
51c1c5ab78 | ||
|
|
f4ca02c3cf | ||
|
|
eac45746ce | ||
|
|
a52133b313 | ||
|
|
a1b270fd4e | ||
|
|
7e98ebd1db | ||
|
|
0b0c67acb7 | ||
|
|
622df58295 | ||
|
|
df6f6ef893 | ||
|
|
ea607c1a52 | ||
|
|
e537f9d5f8 | ||
|
|
cf59251449 | ||
|
|
ef562342e2 | ||
|
|
3d81342725 | ||
|
|
b949951ef9 | ||
|
|
fd8064611a | ||
|
|
959eed38a4 |
No files matched your search
@@ -35,14 +35,17 @@ jobs:
|
||||
- run: dart test
|
||||
working-directory: packages/server
|
||||
# Compiling the dedicated server proves it stays Flutter-free.
|
||||
- run: mkdir -p build && dart compile exe bin/server.dart -o build/watch-together-server
|
||||
- run: mkdir -p build && dart compile exe bin/server.dart -o build/starsync-server
|
||||
working-directory: packages/server
|
||||
- run: build/watch-together-server --help
|
||||
- run: build/starsync-server --help
|
||||
working-directory: packages/server
|
||||
|
||||
# The updater's Windows apply path (running exe parked as .old) only
|
||||
# means something on real NTFS. Runner notes: see release.yml.
|
||||
updater-windows:
|
||||
# Not for a "Release v…" commit: release.yml builds it on the same
|
||||
# (only) Windows runner, which this job would hold up.
|
||||
if: ${{ !startsWith(github.event.head_commit.message, 'Release v') }}
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
VER="${GITHUB_REF_NAME#v}"
|
||||
dart tool/release_publish.dart "$GITHUB_REF_NAME" "dist/watch-together-$VER-linux-x64.tar.gz"
|
||||
dart tool/release_publish.dart "$GITHUB_REF_NAME" "dist/starsync-$VER-linux-x64.tar.gz"
|
||||
|
||||
windows:
|
||||
# yuuka-windows: host-mode runner (no containers), runs as SYSTEM, has
|
||||
@@ -73,5 +73,5 @@ jobs:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
$Ver = $env:GITHUB_REF_NAME.TrimStart('v')
|
||||
dart tool/release_publish.dart $env:GITHUB_REF_NAME "dist/watch-together-$Ver-windows-x64.zip"
|
||||
dart tool/release_publish.dart $env:GITHUB_REF_NAME "dist/starsync-$Ver-windows-x64.zip"
|
||||
if ($LASTEXITCODE) { exit 1 }
|
||||
@@ -11,9 +11,9 @@ everyone's play, pause and seek stay together.
|
||||
Why not Jellyfin SyncPlay: it runs in the browser and its sync is poor.
|
||||
This is a desktop app that drives mpv.
|
||||
|
||||
Name: `watch-together` is a placeholder and the final name is undecided
|
||||
(`sync-relay` is the working name). It appears in file names and the
|
||||
update manifest until that is settled.
|
||||
Up to 1.2.x the app was called `watch_together` (its files
|
||||
`watch-together-*`); the repository and the update manifest keep their old
|
||||
addresses for now.
|
||||
|
||||
- Desktop only (Linux and Windows), always mpv. No web clients.
|
||||
- No transcoding. Everyone plays the original file.
|
||||
@@ -25,19 +25,33 @@ How it fits together: [docs/HOW-IT-WORKS.md](docs/HOW-IT-WORKS.md).
|
||||
|
||||
From the Releases page of this repo:
|
||||
|
||||
- Linux: `watch-together-<version>-linux-x64.tar.gz`. Extract, run
|
||||
`watch_together`. On start it adds itself to your app launcher (and its
|
||||
- Linux: `starsync-<version>-linux-x64.tar.gz`. Extract, run
|
||||
`starsync`. On start it adds itself to your app launcher (and its
|
||||
icon), so the window shows its own icon on Wayland. Needs mpv's library installed (`mpv` or `libmpv2`).
|
||||
- Windows: `watch-together-<version>-windows-x64.zip`. Extract, run
|
||||
`watch_together.exe`. mpv is bundled. New and not yet tried on a real
|
||||
- Windows: `starsync-<version>-windows-x64.zip`. Extract, run
|
||||
`starsync.exe`. mpv is bundled. New and not yet tried on a real
|
||||
Windows desktop; reports welcome.
|
||||
|
||||
Update from the app: when a newer version exists, an update indicator
|
||||
appears in the strip at the top of the window and opens the update card. "Update and restart" downloads,
|
||||
verifies, applies in place and restarts; the install folder must be writable
|
||||
by you. Settings has Check now and an auto-check toggle. From a terminal:
|
||||
`./watch-together-server --upgrade` (no display needed, so it works on a
|
||||
server box too), `watch_together --upgrade`, or the shipped `update.sh` /
|
||||
The folder also holds `watch_together(.exe)` and
|
||||
`watch-together-server(.exe)`, the names up to 1.2.x: they start the new
|
||||
programs, so an update from 1.2.x restarts fine and old shortcuts and
|
||||
service files keep working.
|
||||
|
||||
Your data (settings, sign-ins, folders, logs, yt-dlp and its cookies) lives
|
||||
in `~/.local/share/dev.foxyz.starsync` on Linux and
|
||||
`%APPDATA%\foxyz\Starsync` on Windows. The first start after 1.2.x copies
|
||||
it over from the old places (`~/.local/share/moe.senkochan.watch_together`
|
||||
or `%APPDATA%\com.example\watch_together`, and the app's files in your
|
||||
Documents folder) and leaves the originals where they were.
|
||||
|
||||
Update from the app: the app looks for a new version at start and every
|
||||
few hours, and when one exists a band across the top of the window says so
|
||||
(never over a playing video). "Update and restart" downloads,
|
||||
verifies, applies in place and restarts; "Later" hides it until the next
|
||||
check; the install folder must be writable by you. The version you run is
|
||||
under the Settings tab. Settings has Check now and an auto-check toggle. From a terminal:
|
||||
`./starsync-server --upgrade` (no display needed, so it works on a
|
||||
server box too), `./starsync --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
|
||||
@@ -67,11 +81,13 @@ settings), the Library / Schedule / Settings tabs, and the party. Offline
|
||||
the party side has Join a friend and Host; watching solo it says Solo,
|
||||
with Invite friends and Stop; in a server it shows everyone's avatar
|
||||
with a ring for their state (green ready, amber buffering, grey not ready,
|
||||
dimmed reconnecting, dimmed with a moon sitting out; a crown on whoever
|
||||
a little faded joining, dimmed reconnecting, dimmed with a moon sitting
|
||||
out; a crown on whoever
|
||||
hosts, a tower on whoever passes the video on). Click the avatars for the
|
||||
member list (force ready, Nudge, Sit out, "Copy invite address"; when you host it lists this computer's
|
||||
network addresses with the port, and friends outside your network need
|
||||
that port forwarded or a server they can all reach, like a VPS). Beside them the server and "ready n/m", the chat button with unread
|
||||
that port forwarded or a server they can all reach, like a VPS). Beside them the server and "ready n/m" (who is sitting out or
|
||||
reconnecting does not count), the chat button with unread
|
||||
count, and Leave. Empty strip space drags the window.
|
||||
|
||||
The player fills the window under the strip. Pick a tab and it shrinks to
|
||||
@@ -93,8 +109,8 @@ 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
|
||||
(Settings → Notifications → Chat messages: All by default, or Only when
|
||||
paused, or Off; messages always reach the chat, with their sound). 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.
|
||||
|
||||
@@ -106,8 +122,25 @@ 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.
|
||||
loads the video there and readies you, in the background while the others
|
||||
play on (nobody is paused for you).
|
||||
|
||||
**Auto sit-out.** Idle for 5 minutes while nothing plays, or the window
|
||||
minimised for 10 minutes (playing or not), sits you out by itself, so
|
||||
nobody waits for you; you are back as soon as you return (a key, the
|
||||
mouse, the window's focus). It never happens when you are alone, and an
|
||||
away you chose (or someone else's) stays until you join back. Settings →
|
||||
Party & network → "Auto sit-out" turns it off.
|
||||
|
||||
**Reconnects.** A bad connection does not throw you out: the video keeps
|
||||
playing, the pill says "Connection unstable…" and then "Reconnecting…",
|
||||
and the app comes back on its own as the same member (ready, sitting out,
|
||||
your Up next items and the video you pass on all kept) for up to 10
|
||||
minutes; the others see you dimmed, "reconnecting…". Only a drop of a
|
||||
few seconds or more makes the lost / back sounds. Leave and closing the
|
||||
app tell the others at once; Update and restart keeps your place for the
|
||||
restarted app. Opening Starsync a second time (another window with the
|
||||
same profile) takes your place over, and the first one says so and stops.
|
||||
|
||||
**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
|
||||
@@ -182,7 +215,7 @@ 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
|
||||
In the player, Up next sits in the top bar, right of the title: the
|
||||
next item's picture and title (or the next episode's). Rest the mouse on
|
||||
it for a larger preview (who added it, its length, whether it is ready
|
||||
to start); ⏭ plays it now, a click opens the queue: drag rows to reorder, hover a
|
||||
@@ -219,11 +252,11 @@ cover; one you started shows how far you got.
|
||||
## Dedicated server
|
||||
|
||||
For an always-on box (no display needed): the release tarball also contains
|
||||
`watch-together-server`, a small standalone server without the app. Run it,
|
||||
`starsync-server`, a small standalone server without the app. Run it,
|
||||
then everyone (you included) uses Join.
|
||||
|
||||
```
|
||||
./watch-together-server --port 8080 --secret <password>
|
||||
./starsync-server --port 8080 --secret <password>
|
||||
```
|
||||
|
||||
| Flag | Default | Meaning |
|
||||
@@ -243,9 +276,11 @@ then everyone (you included) uses Join.
|
||||
|
||||
It prints `READY port=... name=...` once listening and stops cleanly on
|
||||
Ctrl+C or SIGTERM. A dedicated server has no host.
|
||||
To run it as a service, unpack the tarball to `/opt/watch-together` and use
|
||||
[deploy/watch-together.service](deploy/watch-together.service) (copy it to
|
||||
`/etc/systemd/system/`, edit the flags, `systemctl enable --now watch-together`).
|
||||
To run it as a service, unpack the tarball to `/opt/starsync` and use
|
||||
[deploy/starsync.service](deploy/starsync.service) (copy it to
|
||||
`/etc/systemd/system/`, edit the flags, `systemctl enable --now starsync`).
|
||||
A service set up before the rename (`/opt/watch-together`, running
|
||||
`watch-together-server`) keeps working as it is.
|
||||
|
||||
Something not playing? Settings → About → **Copy diagnostics** puts the
|
||||
app's recent log (passwords, tokens and cookies left out) on the
|
||||
@@ -282,16 +317,22 @@ integrations (linked Jellyfin servers, below, the AnimeFlow connection:
|
||||
"connected as <name>" with what you may do, or why not, and playing the
|
||||
next episode automatically); Notifications & sounds (what pops up; the sound
|
||||
volume and sound set, and every sound with a preview, its own set and a switch); Updates; Keyboard shortcuts (read-only); About
|
||||
(the version; resting the mouse on the Settings tab shows it too).
|
||||
(the version; resting the mouse on the Settings tab shows it too; and the
|
||||
credits, e.g. that the Minimal player design is inspired by uosc).
|
||||
|
||||
Player settings live in the player: ⚙ at the right end of its controls
|
||||
(both designs) opens them, and Settings → Player has the same ones (plus
|
||||
the custom shader files). Player design: Standard or uosc (below);
|
||||
progress line when hidden (on by default: a 2 px line at the bottom of
|
||||
the video while the controls are hidden); upscaling shader (see below);
|
||||
hardware decoding (auto, off or custom; the env var `SYNC_RELAY_HWDEC`
|
||||
overrides it); in the player also the subtitle and audio track. Every
|
||||
change applies right away.
|
||||
Player settings live in the player too: ⚙ → Player settings, at the
|
||||
right of its controls, and Settings → Player has the same ones (plus the
|
||||
controls' layout and the custom shader files). Player design: Standard or
|
||||
Minimal (below); progress line when hidden (on by default: a thin line at
|
||||
the bottom of the video while the controls are hidden); upscaling shader
|
||||
(see below); hardware decoding (auto, off or custom; auto is mpv's
|
||||
`auto-copy` on Windows, `auto` elsewhere; the env var
|
||||
`SYNC_RELAY_HWDEC` overrides it); in the player also the subtitle and
|
||||
audio track. Settings → Player → Controls: time display (elapsed / total
|
||||
or remaining), controls size (normal or large), hide the controls after
|
||||
2, 3 or 5 s, and which optional buttons sit in the bar (chapter buttons,
|
||||
subtitles, audio, chat; hidden ones are still in ⚙). Every change
|
||||
applies right away.
|
||||
|
||||
Anywhere in a server (unless you are typing): Space = ready / unready,
|
||||
Tab = chat & activity (Tab never moves between buttons). Anywhere:
|
||||
@@ -304,28 +345,37 @@ mini it asks to leave, Ctrl+R = reload video,
|
||||
Ctrl+1..6 = Anime4K mode A, B, C, A+A, B+B, C+A, Ctrl+0 = shaders off.
|
||||
Every seek, skip and chapter jump moves everyone.
|
||||
|
||||
Controls: the seek bar marks chapter starts with a diamond; hovering it
|
||||
shows the time and the chapter title. The chapters button lists them
|
||||
(current one checked; click to jump). "1:30" skips ahead 1:30. Audio and
|
||||
Subtitles buttons (with the track count) list tracks as "Japanese · E-AC3
|
||||
2.0 · title"; subtitles have Off. Volume (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.
|
||||
Controls: both designs have the same buttons in the same places, only
|
||||
the look differs. Bottom left: play / pause first (it is your ready, like
|
||||
Space: ✓ while you are ready and the others are not), previous / next
|
||||
episode (when there is one; next plays Up next first), previous / next
|
||||
chapter (when the video has chapters), volume and the time (click it to
|
||||
switch between elapsed / total and the time left). Bottom right:
|
||||
Subtitles and Audio (with the track count; tracks listed as "Japanese ·
|
||||
E-AC3 2.0 · title", subtitles with Off), ⚙, chat and fullscreen last.
|
||||
Everything else is in ⚙: chapters (current one checked; click to jump),
|
||||
Skip 1:30, upscaling shader, change video, copy link, reload video,
|
||||
mark everyone ready, mark a problem, player settings and leave. The top
|
||||
bar has the title, Up next and back to the library (in fullscreen: exit
|
||||
fullscreen and the window buttons). Volume (scroll anywhere on the
|
||||
video, hover the speaker, or Up/Down) goes to 150 % like mpv: past the
|
||||
tick at 100 % the bar turns amber and mpv amplifies; a scroll or key
|
||||
shows the volume for a moment. Lists and menus under the mouse scroll
|
||||
as usual.
|
||||
|
||||
The uosc player design (⚙ → Player design, Standard by default,
|
||||
switches right away): video controls modeled on mpv's uosc theme. A
|
||||
translucent timeline across the bottom marks the position with a thin
|
||||
line, hatches the part not downloaded yet, shows chapter starts as
|
||||
diamonds (click one to jump), the elapsed time left, the time remaining
|
||||
right and, while less than a minute is buffered, the buffered seconds
|
||||
next to the position; hovering it shows the time there and the chapter
|
||||
title. With the controls hidden it stays as a thin line (unless
|
||||
"Progress line when hidden" is off). Above it flat buttons: ≡ menu, Subtitles and Audio (with the
|
||||
track count), the shader badge (the active Anime4K mode, e.g. "A HQ"),
|
||||
ready, chapters, 1:30, previous / next episode, change video, volume, ⚙
|
||||
and fullscreen; the title top left, Up next, chat and back-to-library top right. Keys and skip buttons work
|
||||
the same.
|
||||
Standard (the default): a seek bar that marks chapter starts with a
|
||||
diamond (hovering it shows the time and the chapter title) over a row
|
||||
of round buttons. Minimal (⚙ → Player settings → Player design, switches
|
||||
right away): a translucent timeline across the bottom tints what has
|
||||
played, stripes the part not downloaded yet, marks chapter starts with
|
||||
dots (click one to jump), shows the elapsed time left and the total or
|
||||
the time left right (click it to switch) and, while less than a minute
|
||||
is buffered, the buffered seconds next to the position; hovering it shows
|
||||
the time there and the chapter title. With the controls hidden it stays
|
||||
as a thin line (unless "Progress line when hidden" is off). Its buttons
|
||||
sit in two glass capsules above it. Minimal's timeline and controls are
|
||||
inspired by [uosc](https://github.com/tomasklaen/uosc) by tomasklaen
|
||||
(LGPL-2.1).
|
||||
|
||||
Skipping: during the opening a "Skip OP" button shows bottom right, just
|
||||
above the controls (it
|
||||
@@ -395,7 +445,7 @@ Watching:
|
||||
"No Jellyfin account here for ..." with a button to link one; it plays as
|
||||
soon as you are signed in.
|
||||
|
||||
Episodes: the ⏮ / ⏭ buttons next to the controls (or `<` / `>`,
|
||||
Episodes: the ⏮ / ⏭ buttons next to play (or `<` / `>`,
|
||||
Shift+P / Shift+N) put up the previous or next episode for everyone. From
|
||||
the ending (when it is known, see Skipping above; else in the last two
|
||||
minutes) a "Next episode" button floats in the corner while the controls
|
||||
@@ -411,7 +461,7 @@ Your choice is remembered per series (and as a default) and the closest
|
||||
match (language, then title, then codec and channels) is selected on the
|
||||
next episode. This is per person; nobody else is affected.
|
||||
|
||||
"Change video" in the player's controls opens the Library tab (the player
|
||||
⚙ → Change video in the player opens the Library tab (the player
|
||||
goes mini): the library starts on the series you are watching with the
|
||||
current season, else where you left it.
|
||||
|
||||
@@ -467,9 +517,9 @@ affected. The app ships [Anime4K](https://github.com/bloc97/Anime4K) v4.0.1
|
||||
C (480p), A+A, B+B, C+A, each as HQ (strong GPUs, GTX 1080 and up) or Fast
|
||||
(the guide's low-end set). "Custom shaders" runs your own `.glsl` files.
|
||||
|
||||
Pick one with the ✨ button in the player's control bar (also in
|
||||
Pick one with ⚙ → Upscaling shader in the player (also in
|
||||
fullscreen) or Ctrl+0..6; the change applies live and is saved, so the next
|
||||
session starts with it. The player's ⚙ and Settings → Player set it too,
|
||||
session starts with it. ⚙ → Player settings and Settings → Player set it too,
|
||||
with HQ or Fast (used by Ctrl+1..6); Settings → Player holds the custom
|
||||
files. Shaders cost GPU time
|
||||
every frame and need hardware video rendering: on media_kit's software
|
||||
|
||||
+27
-46
@@ -5,17 +5,17 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hive_ce_flutter/hive_flutter.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'src/playback/yt_dlp_app.dart';
|
||||
import 'src/platform/app_log.dart';
|
||||
import 'src/platform/app_shutdown.dart';
|
||||
import 'src/platform/desktop/desktop_app_dirs.dart';
|
||||
import 'src/platform/desktop_entry.dart';
|
||||
import 'src/platform/window_memory.dart';
|
||||
import 'src/screens/home/ui.dart';
|
||||
import 'src/session/app_events.dart' show UpdateAvailable;
|
||||
import 'src/state/session_state.dart';
|
||||
import 'src/state/settings.dart';
|
||||
import 'src/update/update_controller.dart' show launchArgs;
|
||||
import 'src/update/update_controller.dart' show UpdateController, UpdatePhase, launchArgs;
|
||||
|
||||
Future<void> main(List<String> args) async {
|
||||
launchArgs = List.unmodifiable(args);
|
||||
@@ -42,26 +42,18 @@ Future<void> main(List<String> args) async {
|
||||
),
|
||||
);
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await startAppLogging();
|
||||
await Hive.initFlutter();
|
||||
final dirs = await const DesktopAppDirs().resolve();
|
||||
await startAppLogging(dirs);
|
||||
Hive.init(dirs.hive);
|
||||
await AppSettings.instance.ensureLoaded();
|
||||
await windowManager.ensureInitialized();
|
||||
await windowManager.setPreventClose(true);
|
||||
final memory = WindowMemory(Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null);
|
||||
windowManager.addListener(_CloseGuard(memory));
|
||||
final windowOptions = WindowOptions(
|
||||
size: memory.size,
|
||||
minimumSize: const Size(160, 90),
|
||||
center: !memory.hasPosition,
|
||||
backgroundColor: const Color(0xFF000000),
|
||||
titleBarStyle: TitleBarStyle.hidden,
|
||||
title: appName,
|
||||
);
|
||||
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||
await memory.restore();
|
||||
await windowManager.show();
|
||||
await windowManager.focus();
|
||||
});
|
||||
// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
|
||||
// see "left", a hosted server shuts down and no player outlives the
|
||||
// engine.
|
||||
final ports = SessionState.instance.ports;
|
||||
await ports.shell.start(
|
||||
title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: shutDownApp);
|
||||
// Focus, minimise and restore feed auto sit-out.
|
||||
ports.shell.watchActivity(SessionState.instance.coordinator.windowActivity);
|
||||
try {
|
||||
await SessionState.instance.loadForm();
|
||||
} catch (e, st) {
|
||||
@@ -69,30 +61,19 @@ Future<void> main(List<String> args) async {
|
||||
}
|
||||
MediaKit.ensureInitialized();
|
||||
// yt-dlp for web page links: downloaded on first run, kept current.
|
||||
AppYtDlp.instance.warmUp();
|
||||
ports.links.warmUp();
|
||||
// Linux: the launcher entry and icon Wayland needs (release installs).
|
||||
unawaited(installDesktopEntry());
|
||||
// A found update is an event too (the band shows it).
|
||||
final updates = UpdateController.instance;
|
||||
updates.status.addListener(() {
|
||||
final s = updates.status.value;
|
||||
final version = s.manifest?.version;
|
||||
if (s.phase == UpdatePhase.available && version != null) {
|
||||
SessionState.instance.notifier.notify(UpdateAvailable(version));
|
||||
}
|
||||
});
|
||||
runApp(const WatchTogetherApp());
|
||||
}
|
||||
|
||||
/// Close button / Alt+F4: leave gracefully first (others see "left", a
|
||||
/// hosted server shuts down), bounded so a hung socket can't keep the
|
||||
/// window open.
|
||||
class _CloseGuard with WindowListener {
|
||||
final WindowMemory memory;
|
||||
bool _closing = false;
|
||||
_CloseGuard(this.memory);
|
||||
|
||||
@override
|
||||
Future<void> onWindowClose() async {
|
||||
if (_closing) return;
|
||||
_closing = true;
|
||||
await memory.save();
|
||||
try {
|
||||
await SessionState.instance.leave().timeout(const Duration(seconds: 2));
|
||||
} catch (e) {
|
||||
debugPrint('leave on close failed: $e');
|
||||
}
|
||||
await windowManager.destroy();
|
||||
}
|
||||
// The sound device opens once the window is up, not on the first sound.
|
||||
Future.delayed(const Duration(seconds: 2), SessionState.instance.sfx.warmUp);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
||||
|
||||
import '../jellyfin/track_prefs.dart' show MpvTrack;
|
||||
import '../playback/media_marks.dart';
|
||||
import '../shaders/shader_menu.dart';
|
||||
import '../shell/mark_problem.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import '../widgets/copy_open.dart';
|
||||
import 'frame.dart';
|
||||
import 'model/controls_layout.dart';
|
||||
import 'model/controls_model.dart';
|
||||
import 'player_settings.dart';
|
||||
|
||||
// The controls' menus, shared by both skins: the track and chapter lists
|
||||
// and the ⚙ menu, all on the app's menu (`showAppMenu`), anchored on the
|
||||
// button that opens them. Each keeps the controls up while open.
|
||||
|
||||
/// How many tracks of [type] there are.
|
||||
int trackCount(List<MpvTrack> tracks, JellyfinStreamType type) => tracks.where((t) => t.type == type).length;
|
||||
|
||||
/// The audio or subtitle tracks ("Japanese · E-AC3 2.0 · title"), mpv's
|
||||
/// selection checked; subtitles start with Off.
|
||||
List<AppMenuItem<TrackPick>> trackMenuItems(JellyfinStreamType type, List<MpvTrack> tracks) {
|
||||
final audio = type == JellyfinStreamType.audio;
|
||||
final list = tracks.where((t) => t.type == type).toList();
|
||||
return [
|
||||
if (!audio)
|
||||
AppMenuItem(
|
||||
key: const ValueKey('track-off'),
|
||||
value: (aid: null, sid: null, subtitlesOff: true),
|
||||
label: 'Off',
|
||||
checked: !list.any((t) => t.selected),
|
||||
),
|
||||
if (audio && list.isEmpty)
|
||||
const AppMenuItem(value: (aid: null, sid: null, subtitlesOff: false), label: 'None', enabled: false),
|
||||
for (final t in list)
|
||||
AppMenuItem(
|
||||
key: ValueKey('track-${t.type.name}-${t.id}'),
|
||||
value: audio ? (aid: t.id, sid: null, subtitlesOff: false) : (aid: null, sid: t.id, subtitlesOff: false),
|
||||
label: t.label,
|
||||
checked: t.selected,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Chapters with their start times, the playing one checked.
|
||||
List<AppMenuItem<MediaChapter>> chapterMenuItems(List<MediaChapter> chapters, int? current) => [
|
||||
for (final c in chapters)
|
||||
AppMenuItem(
|
||||
key: ValueKey('chapter-${c.index}'),
|
||||
value: c,
|
||||
label: c.title.isEmpty ? 'Chapter ${c.index + 1}' : c.title,
|
||||
hint: formatClock(c.start),
|
||||
checked: c.index == current,
|
||||
),
|
||||
];
|
||||
|
||||
Future<T?> _menu<T>(BuildContext context, PlayerControlsModel model, List<AppMenuItem<T>> items) async {
|
||||
model.menusOpen++;
|
||||
try {
|
||||
return await showAppMenu<T>(context, items: items);
|
||||
} finally {
|
||||
model.menusOpen--;
|
||||
}
|
||||
}
|
||||
|
||||
/// The audio or subtitle list; a pick is applied (and remembered).
|
||||
Future<void> openTrackMenu(BuildContext context, PlayerControlsModel model, JellyfinStreamType type) async {
|
||||
final pick = await _menu(context, model, trackMenuItems(type, model.tracks.peek()));
|
||||
if (pick != null) model.pickTrack(pick);
|
||||
}
|
||||
|
||||
/// The chapter list; a pick jumps there for everyone.
|
||||
Future<void> openChapterMenu(BuildContext context, PlayerControlsModel model) async {
|
||||
final pick = await _menu(context, model, chapterMenuItems(model.chapters.peek(), model.currentChapter.peek()));
|
||||
if (pick != null) model.pickChapter(pick);
|
||||
}
|
||||
|
||||
/// The ⚙ menu: every action that is not a bar button.
|
||||
Future<void> openControlsMenu(BuildContext context, PlayerControlsModel model) async {
|
||||
final s = AppSettings.instance;
|
||||
final shader = shaderShortLabel(s.shaderPreset.peek(), s.shaderQuality.peek()) ?? 'Off';
|
||||
final pick = await _menu(context, model, [
|
||||
for (final a in model.menu())
|
||||
AppMenuItem(
|
||||
key: ValueKey('controls-menu-${a.name}'),
|
||||
value: a,
|
||||
label: a.label,
|
||||
hint: a == ControlAction.shaders ? shader : a.hint,
|
||||
),
|
||||
]);
|
||||
if (pick != null && context.mounted) await runControlAction(context, model, pick);
|
||||
}
|
||||
|
||||
/// Runs a ⚙ menu action; [context] anchors what it opens next.
|
||||
Future<void> runControlAction(BuildContext context, PlayerControlsModel model, ControlAction action) async {
|
||||
switch (action) {
|
||||
case ControlAction.chapters:
|
||||
await openChapterMenu(context, model);
|
||||
case ControlAction.subtitles:
|
||||
await openTrackMenu(context, model, JellyfinStreamType.subtitle);
|
||||
case ControlAction.audio:
|
||||
await openTrackMenu(context, model, JellyfinStreamType.audio);
|
||||
case ControlAction.skipForward:
|
||||
model.skipForward();
|
||||
case ControlAction.shaders:
|
||||
model.menusOpen++;
|
||||
try {
|
||||
await ControlsFrame.holdWhile(
|
||||
context,
|
||||
() => showShaderMenu(context, renderMode: model.renderMode, onPick: model.pickShader),
|
||||
);
|
||||
} finally {
|
||||
model.menusOpen--;
|
||||
}
|
||||
case ControlAction.chat:
|
||||
model.toggleChat();
|
||||
case ControlAction.changeVideo:
|
||||
model.changeVideo();
|
||||
case ControlAction.copyLink:
|
||||
final link = model.link();
|
||||
if (link != null) await copyToClipboard(link, done: 'Link copied');
|
||||
case ControlAction.reload:
|
||||
model.reload();
|
||||
case ControlAction.markAllReady:
|
||||
model.markAllReady();
|
||||
case ControlAction.markProblem:
|
||||
await ControlsFrame.holdWhile(context, () => showMarkProblem(context));
|
||||
case ControlAction.playerSettings:
|
||||
await showPlayerSettings(context, renderMode: model.renderMode);
|
||||
case ControlAction.leave:
|
||||
model.leave();
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,7 @@ import 'package:flutter/material.dart';
|
||||
/// 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`).
|
||||
/// countdown. Both skins follow it (through `ControlsFrame`).
|
||||
class ControlsHold extends ChangeNotifier {
|
||||
final _by = <Object>{};
|
||||
bool _told = false;
|
||||
@@ -74,109 +73,3 @@ class _ControlsHoldRegionState extends State<ControlsHoldRegion> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Follows whether media_kit's desktop controls around [child] are up,
|
||||
/// by the same rule they use: up on mount and on any mouse movement,
|
||||
/// down [hideAfter] later or when the mouse leaves. media_kit does not
|
||||
/// expose its visibility; this lets the player draw a progress line while
|
||||
/// they are down. With [hold] held it keeps them (and itself) up. Never
|
||||
/// takes input.
|
||||
class ControlsHoverProbe extends StatefulWidget {
|
||||
final ValueNotifier<bool> shown;
|
||||
final Duration hideAfter;
|
||||
final ControlsHold? hold;
|
||||
final Widget child;
|
||||
const ControlsHoverProbe(
|
||||
{super.key, required this.shown, required this.hideAfter, this.hold, required this.child});
|
||||
|
||||
@override
|
||||
State<ControlsHoverProbe> createState() => _ControlsHoverProbeState();
|
||||
}
|
||||
|
||||
class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
|
||||
Timer? _timer;
|
||||
// While held: media_kit's own countdown is restarted before it ends.
|
||||
Timer? _keep;
|
||||
|
||||
bool get _held => widget.hold?.held ?? false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.hold?.addListener(_holdChanged);
|
||||
// Not during this build: the line listening to [shown] may be built.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _wake();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ControlsHoverProbe oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.hold != widget.hold) {
|
||||
oldWidget.hold?.removeListener(_holdChanged);
|
||||
widget.hold?.addListener(_holdChanged);
|
||||
_holdChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.hold?.removeListener(_holdChanged);
|
||||
_timer?.cancel();
|
||||
_keep?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _holdChanged() {
|
||||
if (!mounted) return;
|
||||
_keep?.cancel();
|
||||
_keep = null;
|
||||
if (_held) {
|
||||
_keep = Timer.periodic(widget.hideAfter * 0.5, (_) => _pokeControls());
|
||||
}
|
||||
_pokeControls();
|
||||
_wake();
|
||||
}
|
||||
|
||||
/// media_kit's controls state is private: a guarded dynamic call to its
|
||||
/// hover handler, as a mouse movement on it would make.
|
||||
void _pokeControls() {
|
||||
if (!mounted) return;
|
||||
void visit(Element e) {
|
||||
if (e is StatefulElement && e.state.runtimeType.toString() == '_MaterialDesktopVideoControlsState') {
|
||||
try {
|
||||
(e.state as dynamic).onHover();
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
e.visitChildElements(visit);
|
||||
}
|
||||
|
||||
context.visitChildElements(visit);
|
||||
}
|
||||
|
||||
void _wake() {
|
||||
widget.shown.value = true;
|
||||
_timer?.cancel();
|
||||
_timer = Timer(widget.hideAfter, _hide);
|
||||
}
|
||||
|
||||
void _hide() {
|
||||
_timer?.cancel();
|
||||
if (!mounted || _held) return;
|
||||
widget.shown.value = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => MouseRegion(
|
||||
opaque: false,
|
||||
hitTestBehavior: HitTestBehavior.translucent,
|
||||
onEnter: (_) => _wake(),
|
||||
onHover: (_) => _wake(),
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'controls_probe.dart' show ControlsHold;
|
||||
|
||||
// The frame both control skins live in: when the controls are up, and the
|
||||
// double-click on the picture. Mouse movement shows them; [hideAfter]
|
||||
// without any (or the mouse leaving) hides them and the cursor. Holds keep
|
||||
// them up: a seek drag (no hover events arrive), an open menu (a route
|
||||
// takes the mouse), and the session's [ControlsHold] (the mouse on a
|
||||
// floating pill or card, the Up next popover).
|
||||
|
||||
class ControlsFrame extends StatefulWidget {
|
||||
/// Draws the controls; [visible] false while they are hidden.
|
||||
final Widget Function(BuildContext context, bool visible) builder;
|
||||
final VoidCallback? onDoubleClick;
|
||||
|
||||
/// Bands at the top and bottom where a double-click is the bars', not
|
||||
/// the picture's.
|
||||
final double barTop;
|
||||
final double barBottom;
|
||||
final Duration hideAfter;
|
||||
|
||||
/// Follows whether the controls are up (for overlays outside them).
|
||||
final ValueNotifier<bool>? shown;
|
||||
|
||||
/// Keeps them up while held.
|
||||
final ControlsHold? holdBy;
|
||||
const ControlsFrame({
|
||||
super.key,
|
||||
required this.builder,
|
||||
this.onDoubleClick,
|
||||
this.barTop = 0,
|
||||
this.barBottom = 0,
|
||||
this.hideAfter = const Duration(seconds: 3),
|
||||
this.shown,
|
||||
this.holdBy,
|
||||
});
|
||||
|
||||
/// The frame around [context], for widgets that keep it up (a drag, a
|
||||
/// menu).
|
||||
static ControlsFrameState? maybeOf(BuildContext context) => context.findAncestorStateOfType<ControlsFrameState>();
|
||||
|
||||
/// Runs [open] (a menu, a popover) with the controls around [context]
|
||||
/// held up, if there are any.
|
||||
static Future<T> holdWhile<T>(BuildContext context, Future<T> Function() open) {
|
||||
final frame = maybeOf(context);
|
||||
return frame == null ? open() : frame.holdDuring(open);
|
||||
}
|
||||
|
||||
/// The fade of the controls.
|
||||
static const fade = Duration(milliseconds: 150);
|
||||
|
||||
@override
|
||||
State<ControlsFrame> createState() => ControlsFrameState();
|
||||
}
|
||||
|
||||
class ControlsFrameState extends State<ControlsFrame> {
|
||||
bool _visible = true;
|
||||
int _holds = 0;
|
||||
bool _heldBy = false;
|
||||
Timer? _timer;
|
||||
|
||||
bool get visible => _visible;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.holdBy?.addListener(_holdByChanged);
|
||||
// Not during this build: an overlay listening to [shown] may be built.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) widget.shown?.value = true;
|
||||
});
|
||||
_hideLater();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ControlsFrame oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.holdBy != widget.holdBy) {
|
||||
oldWidget.holdBy?.removeListener(_holdByChanged);
|
||||
widget.holdBy?.addListener(_holdByChanged);
|
||||
_holdByChanged();
|
||||
}
|
||||
if (oldWidget.hideAfter != widget.hideAfter && _visible) _hideLater();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.holdBy?.removeListener(_holdByChanged);
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _holdByChanged() {
|
||||
final on = widget.holdBy?.held ?? false;
|
||||
if (on == _heldBy || !mounted) return;
|
||||
_heldBy = on;
|
||||
hold(on);
|
||||
}
|
||||
|
||||
/// Show the controls and restart the hide countdown.
|
||||
void wake() {
|
||||
if (!_visible && mounted) {
|
||||
setState(() => _visible = true);
|
||||
widget.shown?.value = true;
|
||||
}
|
||||
_hideLater();
|
||||
}
|
||||
|
||||
/// Keep the controls up while [on] (paired calls).
|
||||
void hold(bool on) {
|
||||
_holds = on ? _holds + 1 : (_holds - 1).clamp(0, 1 << 20);
|
||||
wake();
|
||||
}
|
||||
|
||||
/// Runs [open] (a menu) with the controls held up.
|
||||
Future<T> holdDuring<T>(Future<T> Function() open) async {
|
||||
hold(true);
|
||||
try {
|
||||
return await open();
|
||||
} finally {
|
||||
if (mounted) hold(false);
|
||||
}
|
||||
}
|
||||
|
||||
void _hideLater() {
|
||||
_timer?.cancel();
|
||||
_timer = Timer(widget.hideAfter, _hide);
|
||||
}
|
||||
|
||||
void _hide() {
|
||||
if (!mounted || _holds > 0 || !_visible) return;
|
||||
setState(() => _visible = false);
|
||||
widget.shown?.value = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
cursor: _visible ? SystemMouseCursors.basic : SystemMouseCursors.none,
|
||||
onEnter: (_) => wake(),
|
||||
onHover: (_) => wake(),
|
||||
onExit: (_) {
|
||||
_timer?.cancel();
|
||||
_hide();
|
||||
},
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (widget.onDoubleClick != null)
|
||||
Positioned.fill(
|
||||
child: DoubleClickListener(
|
||||
onDoubleClick: widget.onDoubleClick!,
|
||||
skipTop: _visible ? widget.barTop : 0,
|
||||
skipBottom: _visible ? widget.barBottom : 0,
|
||||
child: const ColoredBox(color: Color(0x00000000), child: SizedBox.expand()),
|
||||
),
|
||||
),
|
||||
widget.builder(context, _visible),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Double-click on the picture (not on the bars) without joining the
|
||||
/// gesture arena, so single clicks on buttons stay instant.
|
||||
class DoubleClickListener extends StatefulWidget {
|
||||
final VoidCallback onDoubleClick;
|
||||
final double skipTop, skipBottom;
|
||||
final Widget child;
|
||||
const DoubleClickListener({
|
||||
super.key,
|
||||
required this.onDoubleClick,
|
||||
this.skipTop = 0,
|
||||
this.skipBottom = 0,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DoubleClickListener> createState() => _DoubleClickListenerState();
|
||||
}
|
||||
|
||||
class _DoubleClickListenerState extends State<DoubleClickListener> {
|
||||
DateTime? _last;
|
||||
Offset? _lastAt;
|
||||
|
||||
void _down(PointerDownEvent e) {
|
||||
if (e.buttons != kPrimaryMouseButton) return;
|
||||
final h = context.size?.height ?? 0;
|
||||
final y = e.localPosition.dy;
|
||||
if (y < widget.skipTop || y > h - widget.skipBottom) {
|
||||
_last = null;
|
||||
return;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final last = _last, at = _lastAt;
|
||||
if (last != null &&
|
||||
at != null &&
|
||||
now.difference(last) < const Duration(milliseconds: 400) &&
|
||||
(e.localPosition - at).distance < 24) {
|
||||
_last = null;
|
||||
widget.onDoubleClick();
|
||||
return;
|
||||
}
|
||||
_last = now;
|
||||
_lastAt = e.localPosition;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
Listener(behavior: HitTestBehavior.translucent, onPointerDown: _down, child: widget.child);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
||||
|
||||
import '../../queue/queue_ui.dart';
|
||||
import '../../shell/now_playing_title.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../control_menus.dart';
|
||||
import '../controls_probe.dart' show ControlsHold;
|
||||
import '../frame.dart';
|
||||
import '../model/controls_layout.dart';
|
||||
import '../model/controls_model.dart';
|
||||
import '../model/controls_prefs.dart';
|
||||
import '../volume.dart';
|
||||
import 'style.dart';
|
||||
import 'timeline.dart';
|
||||
|
||||
// The Minimal skin (inspired by mpv's uosc): a timeline band across the
|
||||
// bottom that carries the clock, and above it the buttons in two glass
|
||||
// capsules, in the layout's order. Top: what plays, Up next and the window
|
||||
// controls. Hidden, the band shrinks to a thin progress line (unless
|
||||
// switched off).
|
||||
|
||||
class MinimalControls extends StatelessWidget {
|
||||
final PlayerControlsModel model;
|
||||
final bool visible;
|
||||
final double scale;
|
||||
final ControlsHold? hold;
|
||||
final Widget? windowButtons;
|
||||
const MinimalControls({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.visible,
|
||||
this.scale = 1,
|
||||
this.hold,
|
||||
this.windowButtons,
|
||||
});
|
||||
|
||||
static const _rowGap = 8.0;
|
||||
|
||||
/// The button row's height at [scale] (a capsule).
|
||||
static double rowHeight(double scale) => MinimalStyle.button * scale + 2 * MinimalStyle.capsulePad;
|
||||
|
||||
/// Buttons and timeline at [scale].
|
||||
static double bottomHeight(double scale) => rowHeight(scale) + _rowGap + MinimalTimeline.heightFor(scale);
|
||||
|
||||
/// The top bar's height.
|
||||
static const topHeight = 72.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const fade = ControlsFrame.fade;
|
||||
Widget shown(Widget child, {Key? key}) => IgnorePointer(
|
||||
key: key,
|
||||
ignoring: !visible,
|
||||
child: AnimatedOpacity(duration: fade, opacity: visible ? 1 : 0, child: child),
|
||||
);
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: shown(
|
||||
key: const ValueKey('minimal-top'),
|
||||
DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x99000000), Color(0x00000000)],
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: topHeight + 24,
|
||||
child: Padding(padding: const EdgeInsets.fromLTRB(16, 0, 10, 24), child: _top()),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
shown(
|
||||
key: const ValueKey('minimal-buttons'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, _rowGap),
|
||||
child: SizedBox(
|
||||
height: rowHeight(scale),
|
||||
child: SignalBuilder(
|
||||
builder: (context) {
|
||||
final s = model.snapshot.value;
|
||||
final layout = model.bar.value;
|
||||
// The clock lives on the timeline.
|
||||
List<Widget> cluster(List<BarControl> cs) => [
|
||||
for (final c in cs)
|
||||
if (c != BarControl.time) _control(context, c, s),
|
||||
];
|
||||
return Row(
|
||||
children: [
|
||||
MinimalCapsule(scale: scale, children: cluster(layout.left)),
|
||||
const Spacer(),
|
||||
MinimalCapsule(scale: scale, children: cluster(layout.right)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SignalBuilder(
|
||||
builder: (context) {
|
||||
final line = AppSettings.instance.progressLine.value;
|
||||
return AnimatedOpacity(
|
||||
key: const ValueKey('minimal-timeline-wrap'),
|
||||
duration: fade,
|
||||
opacity: visible || line ? 1 : 0,
|
||||
child: MinimalTimeline(
|
||||
position: model.position.value,
|
||||
duration: model.duration.value,
|
||||
buffered: model.buffered.value,
|
||||
chapters: model.chapters.value,
|
||||
expanded: visible,
|
||||
remaining: model.settings.timeDisplay.value == TimeDisplay.remaining,
|
||||
scale: scale,
|
||||
onSeek: model.seek,
|
||||
onToggleTime: model.toggleTimeDisplay,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _top() {
|
||||
final m = model;
|
||||
return Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Align(alignment: AlignmentDirectional.centerStart, child: NowPlayingTitle()),
|
||||
),
|
||||
UpNextButton(queue: m.queue, onPlayNextEpisode: m.playNextEpisode, look: UpNextLook.minimal, hold: hold),
|
||||
const SizedBox(width: 4),
|
||||
SignalBuilder(
|
||||
builder: (context) {
|
||||
final full = m.fullscreen.value;
|
||||
return MinimalButton(
|
||||
key: const ValueKey('minimal-collapse'),
|
||||
icon: full ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
|
||||
tooltip: full ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
|
||||
onPressed: full ? m.toggleFullscreen : m.collapse,
|
||||
);
|
||||
},
|
||||
),
|
||||
?windowButtons,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _control(BuildContext context, BarControl c, ControlsSnapshot s) {
|
||||
final m = model;
|
||||
final key = ValueKey('control-${c.name}');
|
||||
final enabled = controlEnabled(c, s);
|
||||
Widget button(VoidCallback? onPressed, {int? badge, bool accent = false}) => MinimalButton(
|
||||
key: key,
|
||||
icon: controlIcon(c, s),
|
||||
tooltip: controlTooltip(c, s),
|
||||
badge: badge,
|
||||
accent: accent,
|
||||
scale: scale,
|
||||
onPressed: enabled ? onPressed : null,
|
||||
);
|
||||
switch (c) {
|
||||
case BarControl.playPause:
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(right: 4 * scale),
|
||||
child: button(m.togglePlay, accent: true),
|
||||
);
|
||||
case BarControl.previous:
|
||||
return button(m.previous);
|
||||
case BarControl.next:
|
||||
return button(m.next);
|
||||
case BarControl.chapterPrevious:
|
||||
return button(() => m.stepChapter(-1));
|
||||
case BarControl.chapterNext:
|
||||
return button(() => m.stepChapter(1));
|
||||
case BarControl.volume:
|
||||
return SignalBuilder(
|
||||
key: key,
|
||||
builder: (context) => VolumeControl(
|
||||
volume: m.volume.value,
|
||||
onChanged: m.setVolume,
|
||||
onToggleMute: m.toggleMute,
|
||||
sliderColor: Theme.of(context).colorScheme.primary,
|
||||
trackColor: MinimalStyle.dim.withValues(alpha: 0.4),
|
||||
button: (context, i, color, tip, onPressed) =>
|
||||
MinimalButton(icon: i, tooltip: tip, color: color, scale: scale, onPressed: onPressed),
|
||||
),
|
||||
);
|
||||
case BarControl.time:
|
||||
return const SizedBox.shrink();
|
||||
case BarControl.subtitles || BarControl.audio:
|
||||
final type = c == BarControl.audio ? JellyfinStreamType.audio : JellyfinStreamType.subtitle;
|
||||
return SignalBuilder(
|
||||
key: key,
|
||||
builder: (context) => Builder(
|
||||
builder: (context) => MinimalButton(
|
||||
icon: controlIcon(c, s),
|
||||
tooltip: controlTooltip(c, s),
|
||||
badge: trackCount(m.tracks.value, type),
|
||||
scale: scale,
|
||||
onPressed: () => openTrackMenu(context, m, type),
|
||||
),
|
||||
),
|
||||
);
|
||||
case BarControl.settings:
|
||||
return Builder(
|
||||
key: key,
|
||||
builder: (context) => MinimalButton(
|
||||
icon: controlIcon(c, s),
|
||||
tooltip: controlTooltip(c, s),
|
||||
scale: scale,
|
||||
onPressed: () => openControlsMenu(context, m),
|
||||
),
|
||||
);
|
||||
case BarControl.chat:
|
||||
return SignalBuilder(
|
||||
key: key,
|
||||
builder: (context) => MinimalButton(
|
||||
icon: controlIcon(c, s),
|
||||
tooltip: controlTooltip(c, s),
|
||||
badge: m.unread.value,
|
||||
scale: scale,
|
||||
onPressed: m.toggleChat,
|
||||
),
|
||||
);
|
||||
case BarControl.fullscreen:
|
||||
return button(m.toggleFullscreen);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../widgets/app_menu.dart' show AppMenuStyle;
|
||||
|
||||
// The Minimal skin's look: the app's glass and accent (the theme's primary)
|
||||
// on a thick timeline band, with soft rounded buttons in two glass capsules.
|
||||
|
||||
abstract final class MinimalStyle {
|
||||
/// The timeline band (no blur: it spans the width over the picture).
|
||||
static const band = Color(0xCC0E0E12);
|
||||
|
||||
/// The capsules holding the buttons: the player pills' glass.
|
||||
static const capsule = AppMenuStyle.pillGlass;
|
||||
static const hover = Color(0x1FFFFFFF);
|
||||
static const text = Colors.white;
|
||||
static const dim = Color(0xB3FFFFFF);
|
||||
static const radius = 10.0;
|
||||
static const fontSize = 13.0;
|
||||
|
||||
/// The tooltips and the timeline's hover tip.
|
||||
static const tip = Color(0xEB18181C);
|
||||
|
||||
/// A button's box at scale 1.
|
||||
static const button = 38.0;
|
||||
|
||||
/// The capsules' padding around the buttons.
|
||||
static const capsulePad = 4.0;
|
||||
}
|
||||
|
||||
/// The Minimal skin's tooltip: the app's dark glass colour, above.
|
||||
class MinimalTooltip extends StatelessWidget {
|
||||
final String message;
|
||||
final Widget child;
|
||||
const MinimalTooltip({super.key, required this.message, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (message.isEmpty) return child;
|
||||
return Tooltip(
|
||||
message: message,
|
||||
preferBelow: false,
|
||||
verticalOffset: 28,
|
||||
waitDuration: const Duration(milliseconds: 300),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: MinimalStyle.tip,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppMenuStyle.border, width: 0.5),
|
||||
),
|
||||
textStyle: const TextStyle(color: MinimalStyle.text, fontSize: 12),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A Minimal button: a white icon on a rounded square that lights on hover
|
||||
/// ([accent]: filled with the theme's primary, the play button), an
|
||||
/// optional [label] and a count [badge]. Never takes focus.
|
||||
class MinimalButton extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback? onPressed;
|
||||
final String? label;
|
||||
final int? badge;
|
||||
final bool accent;
|
||||
final Color? color;
|
||||
final double scale;
|
||||
const MinimalButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.onPressed,
|
||||
this.label,
|
||||
this.badge,
|
||||
this.accent = false,
|
||||
this.color,
|
||||
this.scale = 1,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MinimalButton> createState() => _MinimalButtonState();
|
||||
}
|
||||
|
||||
class _MinimalButtonState extends State<MinimalButton> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final w = widget;
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
final onPrimary = Theme.of(context).colorScheme.onPrimary;
|
||||
final enabled = w.onPressed != null;
|
||||
final size = MinimalStyle.button * w.scale;
|
||||
final fg = !enabled
|
||||
? MinimalStyle.dim.withValues(alpha: 0.35)
|
||||
: w.accent
|
||||
? onPrimary
|
||||
: (w.color ?? MinimalStyle.text);
|
||||
final bg = w.accent
|
||||
? (_hover ? Color.lerp(primary, Colors.white, 0.18)! : primary)
|
||||
: (_hover && enabled ? MinimalStyle.hover : Colors.transparent);
|
||||
final badge = w.badge;
|
||||
return MinimalTooltip(
|
||||
message: w.tooltip,
|
||||
child: MouseRegion(
|
||||
cursor: enabled ? SystemMouseCursors.click : SystemMouseCursors.basic,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: w.onPressed,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
height: size,
|
||||
constraints: BoxConstraints(minWidth: size),
|
||||
padding: EdgeInsets.symmetric(horizontal: w.label == null ? 0 : 10 * w.scale),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(w.accent ? size / 2 : MinimalStyle.radius * w.scale),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Icon(w.icon, color: fg, size: (w.accent ? 26 : 22) * w.scale),
|
||||
if (badge != null && badge > 0)
|
||||
Positioned(
|
||||
right: -7,
|
||||
top: -5,
|
||||
child: Container(
|
||||
key: const ValueKey('minimal-badge'),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(color: primary, borderRadius: BorderRadius.circular(999)),
|
||||
child: Text(
|
||||
'$badge',
|
||||
style: TextStyle(color: onPrimary, fontSize: 9, height: 1.35, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (w.label != null) ...[
|
||||
SizedBox(width: 6 * w.scale),
|
||||
Text(
|
||||
w.label!,
|
||||
style: TextStyle(
|
||||
color: fg,
|
||||
fontSize: MinimalStyle.fontSize * w.scale,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A glass capsule around a row of Minimal buttons.
|
||||
class MinimalCapsule extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
final double scale;
|
||||
const MinimalCapsule({super.key, required this.children, this.scale = 1});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final r = BorderRadius.circular(MinimalStyle.radius * scale + MinimalStyle.capsulePad);
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: MinimalStyle.capsule,
|
||||
borderRadius: r,
|
||||
border: Border.all(color: AppMenuStyle.border, width: 0.5),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(MinimalStyle.capsulePad),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, spacing: 2, children: children),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../playback/media_marks.dart';
|
||||
import '../frame.dart';
|
||||
import 'style.dart';
|
||||
|
||||
// The Minimal skin's timeline: a band across the bottom with the played
|
||||
// part tinted in the accent and the position as a line, the part not
|
||||
// downloaded yet striped, chapter starts as dots on the top edge (click one
|
||||
// to jump), the elapsed time left and the total or the time left right,
|
||||
// the buffered seconds by the position while under a minute, and a tip
|
||||
// with the time (and the chapter title) under the mouse. Hidden, it shrinks
|
||||
// to a thin progress line.
|
||||
//
|
||||
// Inspired by the timeline of uosc (github.com/tomasklaen/uosc, LGPL-2.1,
|
||||
// by tomasklaen): the striped unbuffered part, the remaining time and the
|
||||
// thin line when hidden come from there. The drawing, geometry and values
|
||||
// here are our own.
|
||||
|
||||
/// Sizes at scale 1.
|
||||
abstract final class MinimalTimelineSpec {
|
||||
/// The band's height while the controls are up.
|
||||
static const size = 36.0;
|
||||
|
||||
/// What is left of it while they are hidden.
|
||||
static const lineSize = 3.0;
|
||||
|
||||
/// The position line.
|
||||
static const lineWidth = 2.0;
|
||||
static const fontSize = 15.0;
|
||||
|
||||
/// The times' inset from the ends.
|
||||
static const inset = 12.0;
|
||||
|
||||
/// A chapter dot's radius (doubled-ish while hovered).
|
||||
static const dot = 3.5;
|
||||
|
||||
/// The cache label shows while less than this is buffered ahead.
|
||||
static const bufferedThreshold = Duration(seconds: 60);
|
||||
|
||||
/// Collapse / grow.
|
||||
static const animation = Duration(milliseconds: 120);
|
||||
}
|
||||
|
||||
/// Fraction of [total] at [d], 0..1 (0 without a duration).
|
||||
double timelineFraction(Duration d, Duration total) {
|
||||
final t = total.inMicroseconds;
|
||||
if (t <= 0) return 0;
|
||||
return (d.inMicroseconds / t).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// Where the part not downloaded yet starts (it runs to the end), as a
|
||||
/// fraction; null when nothing known is missing. We know only the end of
|
||||
/// mpv's cache ahead ([buffered]); a cache within half a second of the end
|
||||
/// leaves nothing.
|
||||
double? uncachedFrom({required Duration position, required Duration buffered, required Duration duration}) {
|
||||
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
|
||||
final from = buffered > position ? buffered : position;
|
||||
if (duration - from <= const Duration(milliseconds: 500)) return null;
|
||||
return timelineFraction(from, duration);
|
||||
}
|
||||
|
||||
/// The cache label by the position ("12s"): the seconds buffered ahead,
|
||||
/// while under [MinimalTimelineSpec.bufferedThreshold] and the cache does
|
||||
/// not reach the end.
|
||||
String? cacheLabel({required Duration position, required Duration buffered, required Duration duration}) {
|
||||
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
|
||||
if (duration - buffered <= const Duration(milliseconds: 500)) return null;
|
||||
final ahead = buffered > position ? buffered - position : Duration.zero;
|
||||
if (ahead >= MinimalTimelineSpec.bufferedThreshold) return null;
|
||||
return '${(ahead.inMilliseconds / 1000).round()}s';
|
||||
}
|
||||
|
||||
/// The band's height while it animates between hidden (0) and shown (1).
|
||||
double minimalBandSize(double visibility, double scale) =>
|
||||
MinimalTimelineSpec.lineSize +
|
||||
((MinimalTimelineSpec.size * scale - MinimalTimelineSpec.lineSize) * visibility).roundToDouble();
|
||||
|
||||
/// The Minimal timeline. [expanded] false (the controls hidden) shrinks it,
|
||||
/// animated, to the progress line. Seeks ([onSeek]) on press, on every drag
|
||||
/// move and on release; a press on a hovered chapter dot jumps to that
|
||||
/// chapter, one on the right-hand time switches it ([onToggleTime]).
|
||||
class MinimalTimeline extends StatefulWidget {
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
|
||||
/// End of the cached range.
|
||||
final Duration buffered;
|
||||
final List<MediaChapter> chapters;
|
||||
final bool expanded;
|
||||
|
||||
/// The right-hand time: the time left instead of the total.
|
||||
final bool remaining;
|
||||
final double scale;
|
||||
final ValueChanged<Duration> onSeek;
|
||||
final VoidCallback? onToggleTime;
|
||||
const MinimalTimeline({
|
||||
super.key,
|
||||
required this.position,
|
||||
required this.duration,
|
||||
this.buffered = Duration.zero,
|
||||
this.chapters = const [],
|
||||
this.expanded = true,
|
||||
this.remaining = true,
|
||||
this.scale = 1,
|
||||
required this.onSeek,
|
||||
this.onToggleTime,
|
||||
});
|
||||
|
||||
static double heightFor(double scale) => MinimalTimelineSpec.size * scale;
|
||||
static const collapsedHeight = MinimalTimelineSpec.lineSize;
|
||||
|
||||
@override
|
||||
State<MinimalTimeline> createState() => _MinimalTimelineState();
|
||||
}
|
||||
|
||||
class _MinimalTimelineState extends State<MinimalTimeline> {
|
||||
Offset? _hover;
|
||||
bool _dragging = false;
|
||||
double _dragFraction = 0;
|
||||
Duration? _lastSent;
|
||||
// A released seek, shown until the position catches up (else the line
|
||||
// jumps back to the old position for a moment).
|
||||
double? _pending;
|
||||
Timer? _pendingTimer;
|
||||
|
||||
double get _scale => widget.scale;
|
||||
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
|
||||
|
||||
void _send(Duration d) {
|
||||
if (d == _lastSent) return;
|
||||
_lastSent = d;
|
||||
widget.onSeek(d);
|
||||
}
|
||||
|
||||
void _drag(bool on) {
|
||||
if (_dragging == on) return;
|
||||
setState(() => _dragging = on);
|
||||
ControlsFrame.maybeOf(context)?.hold(on);
|
||||
}
|
||||
|
||||
void _settle(double fraction) {
|
||||
_pending = fraction;
|
||||
_pendingTimer?.cancel();
|
||||
_pendingTimer = Timer(const Duration(seconds: 2), () {
|
||||
if (mounted) setState(() => _pending = null);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(MinimalTimeline old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (!widget.expanded && old.expanded) _hover = null;
|
||||
final p = _pending;
|
||||
if (p != null && widget.duration > Duration.zero) {
|
||||
final off = (widget.position - _at(p)).abs();
|
||||
if (off < const Duration(milliseconds: 1500)) {
|
||||
_pending = null;
|
||||
_pendingTimer?.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pendingTimer?.cancel();
|
||||
// A drag cut short by a rebuild must not keep the controls up.
|
||||
if (_dragging) ControlsFrame.maybeOf(context)?.hold(false);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double _dotRadius(bool hovered) => MinimalTimelineSpec.dot * _scale * (hovered ? 1.7 : 1);
|
||||
|
||||
/// The chapter whose dot is within reach of [at].
|
||||
int? _hoveredChapter(double width, List<MediaChapter> marks, Offset? at) {
|
||||
if (at == null || _dragging) return null;
|
||||
final reach = _dotRadius(false) * 2.5;
|
||||
int? best;
|
||||
var bestD = double.infinity;
|
||||
for (var i = 0; i < marks.length; i++) {
|
||||
final cx = width * timelineFraction(marks[i].start, widget.duration);
|
||||
final d = (Offset(cx, 0) - at).distance;
|
||||
if (d <= reach && d < bestD) {
|
||||
best = i;
|
||||
bestD = d;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
String? _rightText(Duration shown) {
|
||||
final d = widget.duration;
|
||||
if (d <= Duration.zero) return null;
|
||||
if (!widget.remaining) return formatClock(d);
|
||||
final left = d - shown;
|
||||
return '-${formatClock(left.isNegative ? Duration.zero : left)}';
|
||||
}
|
||||
|
||||
// The painter's text takes the app's font (a TextPainter does not
|
||||
// inherit it).
|
||||
String? _font;
|
||||
|
||||
TextStyle get _timeStyle => TextStyle(
|
||||
fontFamily: _font,
|
||||
color: MinimalStyle.text,
|
||||
fontSize: MinimalTimelineSpec.fontSize * _scale,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
);
|
||||
|
||||
/// Where the right-hand time sits (a press there switches it).
|
||||
bool _onRightTime(Offset at, double width, String? text) {
|
||||
if (text == null || widget.onToggleTime == null) return false;
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(text: text, style: _timeStyle),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
return at.dx >= width - MinimalTimelineSpec.inset * _scale - tp.width - 6;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_font = DefaultTextStyle.of(context).style.fontFamily ?? Theme.of(context).textTheme.bodyMedium?.fontFamily;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: widget.expanded ? 1 : 0),
|
||||
duration: MinimalTimelineSpec.animation,
|
||||
builder: (context, visibility, _) => _band(context, constraints.maxWidth, visibility),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _band(BuildContext context, double width, double visibility) {
|
||||
final expanded = widget.expanded;
|
||||
final duration = widget.duration;
|
||||
final size = minimalBandSize(visibility, _scale);
|
||||
final progress = _dragging ? _dragFraction : (_pending ?? timelineFraction(widget.position, duration));
|
||||
final shown = _at(progress);
|
||||
final marks = duration > Duration.zero ? widget.chapters : const <MediaChapter>[];
|
||||
final hover = expanded ? _hover : null;
|
||||
final hovered = _hoveredChapter(width, marks, hover);
|
||||
final hoverFraction = hovered != null
|
||||
? timelineFraction(marks[hovered].start, duration)
|
||||
: (hover == null || duration <= Duration.zero || width <= 0 ? null : (hover.dx / width).clamp(0.0, 1.0));
|
||||
final hoverAt = hoverFraction == null ? null : _at(hoverFraction);
|
||||
final cursorX = hoverFraction == null ? null : width * hoverFraction;
|
||||
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
|
||||
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
|
||||
// Text fades out before it would overflow the shrinking band.
|
||||
final minText = MinimalTimelineSpec.fontSize * _scale;
|
||||
final textOpacity = ((size - minText) / (minText / 2)).clamp(0.0, 1.0);
|
||||
final right = _rightText(shown);
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
final painter = MinimalTimelinePainter(
|
||||
progress: progress,
|
||||
visibility: visibility,
|
||||
accent: primary,
|
||||
scale: _scale,
|
||||
uncached: uncachedFrom(position: widget.position, buffered: widget.buffered, duration: duration),
|
||||
chapters: [for (final c in marks) timelineFraction(c.start, duration)],
|
||||
hoveredChapter: hovered,
|
||||
hoverX: hovered == null ? cursorX : null,
|
||||
textOpacity: textOpacity,
|
||||
elapsed: duration > Duration.zero ? formatClock(shown) : null,
|
||||
right: right,
|
||||
cache: cacheLabel(position: widget.position, buffered: widget.buffered, duration: duration),
|
||||
timeStyle: _timeStyle,
|
||||
);
|
||||
final bar = SizedBox(
|
||||
key: const ValueKey('minimal-timeline'),
|
||||
width: width,
|
||||
height: size,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: RepaintBoundary(child: CustomPaint(painter: painter)),
|
||||
),
|
||||
if (hoverAt != null && cursorX != null)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: size + 8,
|
||||
height: 120,
|
||||
child: IgnorePointer(
|
||||
child: CustomMultiChildLayout(
|
||||
delegate: _TipLayout(x: cursorX),
|
||||
children: [
|
||||
LayoutId(
|
||||
id: #tip,
|
||||
child: _HoverTip(time: formatClock(hoverAt), chapter: hoverTitle, scale: _scale),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (!expanded) return IgnorePointer(child: bar);
|
||||
Offset clampTo(Offset p) => Offset(p.dx.clamp(0.0, width), p.dy);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (e) => setState(() => _hover = clampTo(e.localPosition)),
|
||||
onHover: (e) => setState(() => _hover = clampTo(e.localPosition)),
|
||||
onExit: (_) {
|
||||
if (!_dragging) setState(() => _hover = null);
|
||||
},
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: (e) {
|
||||
if (width <= 0) return;
|
||||
final at = clampTo(e.localPosition);
|
||||
if (_onRightTime(at, width, right)) {
|
||||
widget.onToggleTime?.call();
|
||||
return;
|
||||
}
|
||||
if (duration <= Duration.zero) return;
|
||||
final chapter = _hoveredChapter(width, marks, at);
|
||||
_lastSent = null;
|
||||
if (chapter != null) {
|
||||
_settle(timelineFraction(marks[chapter].start, duration));
|
||||
_send(marks[chapter].start);
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
_pending = null;
|
||||
_dragFraction = (at.dx / width).clamp(0.0, 1.0);
|
||||
_drag(true);
|
||||
_send(_at(_dragFraction));
|
||||
},
|
||||
onPointerMove: (e) {
|
||||
if (!_dragging) return;
|
||||
final at = clampTo(e.localPosition);
|
||||
setState(() {
|
||||
_hover = at;
|
||||
_dragFraction = (at.dx / width).clamp(0.0, 1.0);
|
||||
});
|
||||
_send(_at(_dragFraction));
|
||||
},
|
||||
onPointerUp: (e) {
|
||||
if (!_dragging) return;
|
||||
_send(_at(_dragFraction));
|
||||
_settle(_dragFraction);
|
||||
final p = e.localPosition;
|
||||
if (p.dx < 0 || p.dx > width || p.dy < 0 || p.dy > size) _hover = null;
|
||||
_drag(false);
|
||||
},
|
||||
onPointerCancel: (_) => _drag(false),
|
||||
child: bar,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HoverTip extends StatelessWidget {
|
||||
final String time;
|
||||
final String chapter;
|
||||
final double scale;
|
||||
const _HoverTip({required this.time, required this.chapter, required this.scale});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
padding: EdgeInsets.symmetric(horizontal: 10 * scale, vertical: 5 * scale),
|
||||
decoration: BoxDecoration(
|
||||
color: MinimalStyle.tip,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0x1FFFFFFF), width: 0.5),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (chapter.isNotEmpty)
|
||||
Text(
|
||||
chapter,
|
||||
key: const ValueKey('minimal-hover-chapter'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: MinimalStyle.text, fontSize: 13 * scale, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
time,
|
||||
key: const ValueKey('minimal-hover-time'),
|
||||
style: TextStyle(
|
||||
color: chapter.isEmpty ? MinimalStyle.text : MinimalStyle.dim,
|
||||
fontSize: 13 * scale,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The hover tip centred on [x], kept inside the band's ends.
|
||||
class _TipLayout extends MultiChildLayoutDelegate {
|
||||
final double x;
|
||||
_TipLayout({required this.x});
|
||||
|
||||
@override
|
||||
void performLayout(Size size) {
|
||||
const gap = 4.0;
|
||||
final tip = layoutChild(#tip, BoxConstraints.loose(size));
|
||||
final left = (x - tip.width / 2).clamp(gap, math.max(gap, size.width - gap - tip.width)).toDouble();
|
||||
positionChild(#tip, Offset(left, size.height - tip.height));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_TipLayout old) => old.x != x;
|
||||
}
|
||||
|
||||
/// Paints the band: background, the played tint and the (white) position
|
||||
/// line, the stripes over the part not downloaded, chapter marks, the
|
||||
/// times, the hover line.
|
||||
class MinimalTimelinePainter extends CustomPainter {
|
||||
/// The playing position, 0..1.
|
||||
final double progress;
|
||||
|
||||
/// 0 hidden .. 1 shown.
|
||||
final double visibility;
|
||||
final Color accent;
|
||||
final double scale;
|
||||
|
||||
/// Start of the part not downloaded (to the end), as a fraction.
|
||||
final double? uncached;
|
||||
|
||||
/// Chapter starts, as fractions.
|
||||
final List<double> chapters;
|
||||
final int? hoveredChapter;
|
||||
final double? hoverX;
|
||||
final double textOpacity;
|
||||
final String? elapsed;
|
||||
final String? right;
|
||||
final String? cache;
|
||||
final TextStyle timeStyle;
|
||||
const MinimalTimelinePainter({
|
||||
required this.progress,
|
||||
this.visibility = 1,
|
||||
this.accent = Colors.white,
|
||||
this.scale = 1,
|
||||
this.uncached,
|
||||
this.chapters = const [],
|
||||
this.hoveredChapter,
|
||||
this.hoverX,
|
||||
this.textOpacity = 1,
|
||||
this.elapsed,
|
||||
this.right,
|
||||
this.cache,
|
||||
this.timeStyle = const TextStyle(fontSize: MinimalTimelineSpec.fontSize),
|
||||
});
|
||||
|
||||
/// x of the position line's centre.
|
||||
double lineX(double width) {
|
||||
final lw = MinimalTimelineSpec.lineWidth * scale;
|
||||
return (width * progress).clamp(lw / 2, math.max(lw / 2, width - lw / 2));
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final w = size.width, h = size.height;
|
||||
final x = lineX(w);
|
||||
final band = Offset.zero & size;
|
||||
// The band, darker as it grows; hidden, a faint track.
|
||||
canvas.drawRect(band, Paint()..color = Color.lerp(const Color(0x40FFFFFF), MinimalStyle.band, visibility)!);
|
||||
// Played: an accent tint, solid once the band is a line.
|
||||
canvas.drawRect(
|
||||
Rect.fromLTRB(0, 0, x, h),
|
||||
Paint()..color = accent.withValues(alpha: 0.28 + 0.72 * (1 - visibility)),
|
||||
);
|
||||
_stripes(canvas, w, h);
|
||||
_chapters(canvas, w, h);
|
||||
if (visibility > 0) {
|
||||
final lw = MinimalTimelineSpec.lineWidth * scale;
|
||||
canvas.drawRect(
|
||||
Rect.fromLTRB(x - lw / 2, 0, x + lw / 2, h),
|
||||
Paint()..color = Colors.white.withValues(alpha: visibility),
|
||||
);
|
||||
}
|
||||
if (textOpacity > 0) _times(canvas, w, h, x);
|
||||
final hx = hoverX;
|
||||
if (hx != null) {
|
||||
canvas.drawRect(Rect.fromLTRB(hx - 0.5, 0, hx + 0.5, h), Paint()..color = Colors.white.withValues(alpha: 0.45));
|
||||
}
|
||||
}
|
||||
|
||||
/// Thin 45° stripes over the part not downloaded yet.
|
||||
void _stripes(Canvas canvas, double w, double h) {
|
||||
final from = uncached;
|
||||
if (from == null) return;
|
||||
final ax = (w * from).floorToDouble();
|
||||
if (ax >= w) return;
|
||||
final period = 7.0 * scale;
|
||||
canvas.save();
|
||||
canvas.clipRect(Rect.fromLTRB(ax, 0, w, h));
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.10 + 0.08 * (1 - visibility))
|
||||
..strokeWidth = 1.5 * scale;
|
||||
for (var c = (ax / period).floorToDouble() * period - h; c <= w; c += period) {
|
||||
canvas.drawLine(Offset(c, h), Offset(c + h, 0), paint);
|
||||
}
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
void _chapters(Canvas canvas, double w, double h) {
|
||||
final cut = Paint()..color = Colors.black.withValues(alpha: 0.55);
|
||||
for (final f in chapters) {
|
||||
if (f <= 0) continue;
|
||||
final cx = w * f;
|
||||
canvas.drawRect(Rect.fromLTRB(cx - 0.5, 0, cx + 0.5, h), cut);
|
||||
}
|
||||
if (visibility <= 0) return;
|
||||
final r = MinimalTimelineSpec.dot * scale * visibility;
|
||||
void dot(double f, double radius, Color fill) {
|
||||
final c = Offset(w * f, 0);
|
||||
canvas.drawCircle(c, radius + 1, Paint()..color = Colors.black.withValues(alpha: 0.7));
|
||||
canvas.drawCircle(c, radius, Paint()..color = fill);
|
||||
}
|
||||
|
||||
// Not at 0: the band's start says that already.
|
||||
for (var i = 0; i < chapters.length; i++) {
|
||||
if (i != hoveredChapter && chapters[i] > 0) dot(chapters[i], r, Colors.white.withValues(alpha: 0.9));
|
||||
}
|
||||
final hv = hoveredChapter;
|
||||
if (hv != null && hv < chapters.length) dot(chapters[hv], r * 1.7, accent);
|
||||
}
|
||||
|
||||
TextPainter _text(String s, TextStyle style) => TextPainter(
|
||||
text: TextSpan(text: s, style: style),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
|
||||
/// White text with a soft shadow, so it reads over the accent tint too.
|
||||
void _label(Canvas canvas, String s, TextStyle style, Offset at) {
|
||||
final o = textOpacity;
|
||||
_text(
|
||||
s,
|
||||
style.copyWith(
|
||||
color: (style.color ?? Colors.white).withValues(alpha: o),
|
||||
shadows: [Shadow(color: Colors.black.withValues(alpha: 0.8 * o), blurRadius: 3)],
|
||||
),
|
||||
).paint(canvas, at);
|
||||
}
|
||||
|
||||
void _times(Canvas canvas, double w, double h, double lineX) {
|
||||
final inset = MinimalTimelineSpec.inset * scale;
|
||||
final fs = timeStyle.fontSize ?? MinimalTimelineSpec.fontSize;
|
||||
final cy = h / 2;
|
||||
final e = elapsed, r = right;
|
||||
final ew = e == null ? 0.0 : _text(e, timeStyle).width;
|
||||
final rw = r == null ? 0.0 : _text(r, timeStyle).width;
|
||||
final c = cache;
|
||||
if (c != null) {
|
||||
final cs = timeStyle.copyWith(fontSize: fs * 0.8, color: MinimalStyle.dim);
|
||||
final cw = _text(c, cs).width;
|
||||
const margin = 6.0;
|
||||
var x = lineX + margin;
|
||||
final minX = inset + margin + ew, maxX = w - inset - margin - rw;
|
||||
if (x < minX) {
|
||||
x = minX;
|
||||
} else if (x + cw > maxX) {
|
||||
x = maxX - cw;
|
||||
}
|
||||
_label(canvas, c, cs, Offset(x, cy - fs * 0.4));
|
||||
}
|
||||
if (e != null) _label(canvas, e, timeStyle, Offset(inset, cy - fs / 2));
|
||||
if (r != null) _label(canvas, r, timeStyle, Offset(w - inset - rw, cy - fs / 2));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(MinimalTimelinePainter old) => true;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import 'package:flutter/material.dart' show IconData, Icons;
|
||||
|
||||
import '../../playback/media_marks.dart' show formatClock;
|
||||
import 'controls_prefs.dart';
|
||||
|
||||
// The controls layout, defined once for both skins: which controls the
|
||||
// bottom bar has, in which order, which actions sit in the ⚙ menu instead,
|
||||
// and their icons, tooltips and labels. Pure: everything here is a
|
||||
// function of a [ControlsSnapshot] and the user's choices, so tests cover
|
||||
// it without a player.
|
||||
|
||||
/// What playback does as this viewer sees it. In a server "play" is being
|
||||
/// ready: the video plays once everyone is.
|
||||
enum PlayState {
|
||||
/// The video plays (the button pauses: you go unready).
|
||||
playing,
|
||||
|
||||
/// You are ready; the others are not yet (the button unreadies you).
|
||||
waiting,
|
||||
|
||||
/// You are not ready (the button readies you).
|
||||
paused,
|
||||
|
||||
/// The media ended.
|
||||
finished,
|
||||
}
|
||||
|
||||
/// A control in the bottom bar.
|
||||
enum BarControl {
|
||||
playPause,
|
||||
|
||||
/// Previous episode.
|
||||
previous,
|
||||
|
||||
/// Up next's first item, else the next episode.
|
||||
next,
|
||||
chapterPrevious,
|
||||
chapterNext,
|
||||
volume,
|
||||
time,
|
||||
subtitles,
|
||||
audio,
|
||||
|
||||
/// ⚙: everything that is not a bar button.
|
||||
settings,
|
||||
chat,
|
||||
fullscreen,
|
||||
}
|
||||
|
||||
/// An action in the ⚙ menu.
|
||||
enum ControlAction {
|
||||
chapters('Chapters…', null),
|
||||
subtitles('Subtitles…', null),
|
||||
audio('Audio…', null),
|
||||
skipForward('Skip 1:30', 'Shift+Right'),
|
||||
shaders('Upscaling shader…', 'Ctrl+0…6'),
|
||||
chat('Chat & activity', 'Tab'),
|
||||
changeVideo('Change video', null),
|
||||
copyLink('Copy link', null),
|
||||
reload('Reload video', 'Ctrl+R'),
|
||||
markAllReady('Mark everyone ready', null),
|
||||
markProblem('Mark a problem', 'Ctrl+Shift+B'),
|
||||
playerSettings('Player settings…', null),
|
||||
leave('Leave server', null);
|
||||
|
||||
final String label;
|
||||
final String? hint;
|
||||
const ControlAction(this.label, this.hint);
|
||||
}
|
||||
|
||||
/// The bar's order, left cluster then right cluster. The play button is
|
||||
/// always first and fullscreen always last; controls that do not apply
|
||||
/// (no episode, no chapters) or that the user hid are left out, the rest
|
||||
/// keep their order.
|
||||
abstract final class ControlsLayout {
|
||||
static const left = [
|
||||
BarControl.playPause,
|
||||
BarControl.previous,
|
||||
BarControl.next,
|
||||
BarControl.chapterPrevious,
|
||||
BarControl.chapterNext,
|
||||
BarControl.volume,
|
||||
BarControl.time,
|
||||
];
|
||||
static const right = [
|
||||
BarControl.subtitles,
|
||||
BarControl.audio,
|
||||
BarControl.settings,
|
||||
BarControl.chat,
|
||||
BarControl.fullscreen,
|
||||
];
|
||||
|
||||
/// The ⚙ menu's order.
|
||||
static const menu = ControlAction.values;
|
||||
}
|
||||
|
||||
/// What the layout depends on, at one moment.
|
||||
class ControlsSnapshot {
|
||||
final PlayState play;
|
||||
|
||||
/// The previous episode's label; null: none.
|
||||
final String? previousLabel;
|
||||
|
||||
/// Up next's first item's title, else the next episode's label; null:
|
||||
/// nothing next.
|
||||
final String? nextLabel;
|
||||
|
||||
/// [nextLabel] is Up next (not an episode).
|
||||
final bool nextIsUpNext;
|
||||
final bool hasChapters;
|
||||
|
||||
/// The media's length is known (seeks and skips make sense).
|
||||
final bool hasDuration;
|
||||
|
||||
/// Watching alone (no one to mark ready).
|
||||
final bool solo;
|
||||
|
||||
/// A link to copy (`SessionCoordinator.currentLink`).
|
||||
final bool hasLink;
|
||||
final bool fullscreen;
|
||||
final bool chatOpen;
|
||||
const ControlsSnapshot({
|
||||
this.play = PlayState.paused,
|
||||
this.previousLabel,
|
||||
this.nextLabel,
|
||||
this.nextIsUpNext = false,
|
||||
this.hasChapters = false,
|
||||
this.hasDuration = false,
|
||||
this.solo = false,
|
||||
this.hasLink = false,
|
||||
this.fullscreen = false,
|
||||
this.chatOpen = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// The bar's two clusters for [s], in [ControlsLayout] order.
|
||||
({List<BarControl> left, List<BarControl> right}) barControls(ControlsSnapshot s, Set<OptionalControl> hidden) {
|
||||
bool on(BarControl c) => switch (c) {
|
||||
BarControl.previous => s.previousLabel != null,
|
||||
BarControl.next => s.nextLabel != null,
|
||||
BarControl.chapterPrevious || BarControl.chapterNext => s.hasChapters && !hidden.contains(OptionalControl.chapters),
|
||||
BarControl.subtitles => !hidden.contains(OptionalControl.subtitles),
|
||||
BarControl.audio => !hidden.contains(OptionalControl.audio),
|
||||
BarControl.chat => !hidden.contains(OptionalControl.chat),
|
||||
_ => true,
|
||||
};
|
||||
return (
|
||||
left: [
|
||||
for (final c in ControlsLayout.left)
|
||||
if (on(c)) c,
|
||||
],
|
||||
right: [
|
||||
for (final c in ControlsLayout.right)
|
||||
if (on(c)) c,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The ⚙ menu for [s]: every action that applies, minus those the bar
|
||||
/// already shows.
|
||||
List<ControlAction> menuActions(ControlsSnapshot s, Set<OptionalControl> hidden) => [
|
||||
for (final a in ControlsLayout.menu)
|
||||
if (switch (a) {
|
||||
ControlAction.chapters => s.hasChapters,
|
||||
ControlAction.subtitles => hidden.contains(OptionalControl.subtitles),
|
||||
ControlAction.audio => hidden.contains(OptionalControl.audio),
|
||||
ControlAction.skipForward => s.hasDuration,
|
||||
ControlAction.chat => hidden.contains(OptionalControl.chat),
|
||||
ControlAction.copyLink => s.hasLink,
|
||||
ControlAction.markAllReady => !s.solo,
|
||||
_ => true,
|
||||
})
|
||||
a,
|
||||
];
|
||||
|
||||
/// Whether a bar control does something now (a skin dims it otherwise).
|
||||
bool controlEnabled(BarControl c, ControlsSnapshot s) => switch (c) {
|
||||
BarControl.chapterPrevious || BarControl.chapterNext => s.hasChapters && s.hasDuration,
|
||||
BarControl.previous => s.previousLabel != null,
|
||||
BarControl.next => s.nextLabel != null,
|
||||
_ => true,
|
||||
};
|
||||
|
||||
/// The icon of a bar control (volume and the clock draw their own).
|
||||
IconData controlIcon(BarControl c, ControlsSnapshot s) => switch (c) {
|
||||
BarControl.playPause => switch (s.play) {
|
||||
PlayState.playing => Icons.pause_rounded,
|
||||
// Ready: the check says so; a click unreadies.
|
||||
PlayState.waiting => Icons.check_rounded,
|
||||
PlayState.finished => Icons.replay_rounded,
|
||||
PlayState.paused => Icons.play_arrow_rounded,
|
||||
},
|
||||
BarControl.previous => Icons.skip_previous_rounded,
|
||||
BarControl.next => Icons.skip_next_rounded,
|
||||
BarControl.chapterPrevious => Icons.chevron_left_rounded,
|
||||
BarControl.chapterNext => Icons.chevron_right_rounded,
|
||||
BarControl.volume => Icons.volume_up_rounded,
|
||||
BarControl.time => Icons.schedule_rounded,
|
||||
BarControl.subtitles => Icons.subtitles_outlined,
|
||||
BarControl.audio => Icons.graphic_eq_rounded,
|
||||
BarControl.settings => Icons.settings_outlined,
|
||||
BarControl.chat => s.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded,
|
||||
BarControl.fullscreen => s.fullscreen ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded,
|
||||
};
|
||||
|
||||
/// The tooltip of a bar control, with its key.
|
||||
String controlTooltip(BarControl c, ControlsSnapshot s) => switch (c) {
|
||||
BarControl.playPause => switch (s.play) {
|
||||
PlayState.playing => 'Pause for everyone (Space)',
|
||||
PlayState.waiting => 'Ready, waiting for the others (Space: not ready)',
|
||||
PlayState.paused => s.solo ? 'Play (Space)' : 'Ready: play when everyone is (Space)',
|
||||
PlayState.finished => 'Ready (Space)',
|
||||
},
|
||||
BarControl.previous => 'Previous: ${s.previousLabel ?? ''} (< or Shift+P)',
|
||||
BarControl.next => '${s.nextIsUpNext ? 'Up next' : 'Next'}: ${s.nextLabel ?? ''} (> or Shift+N)',
|
||||
BarControl.chapterPrevious => 'Previous chapter (PgUp)',
|
||||
BarControl.chapterNext => 'Next chapter (PgDn)',
|
||||
BarControl.volume => 'Volume (scroll, ↑ / ↓)',
|
||||
BarControl.time => 'Elapsed / remaining (click to switch)',
|
||||
BarControl.subtitles => 'Subtitles',
|
||||
BarControl.audio => 'Audio',
|
||||
BarControl.settings => 'Settings and more',
|
||||
BarControl.chat => 'Chat & activity (Tab)',
|
||||
BarControl.fullscreen => s.fullscreen ? 'Exit fullscreen (F, Esc)' : 'Fullscreen (F)',
|
||||
};
|
||||
|
||||
/// The bar's clock: "6:00 / 24:00", or the time left ("-18:00"); "0:00"
|
||||
/// parts while the length is unknown.
|
||||
String clockText(Duration position, Duration duration, TimeDisplay display) {
|
||||
if (display == TimeDisplay.remaining && duration > Duration.zero) {
|
||||
final left = duration - position;
|
||||
return '-${formatClock(left.isNegative ? Duration.zero : left)}';
|
||||
}
|
||||
return '${formatClock(position)} / ${formatClock(duration)}';
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../../jellyfin/track_prefs.dart' show MpvTrack;
|
||||
import '../../playback/media_marks.dart';
|
||||
import '../../playback/playback_controller.dart';
|
||||
import '../../queue/queue_controller.dart';
|
||||
import '../../session/session_coordinator.dart';
|
||||
import '../../shaders/shader_presets.dart';
|
||||
import '../../state/session_state.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../player_settings.dart' show applyShaderChoice;
|
||||
import '../volume.dart' as vol;
|
||||
import 'controls_layout.dart';
|
||||
|
||||
/// A track pick from a track menu: audio id, subtitle id, or off.
|
||||
typedef TrackPick = ({int? aid, int? sid, bool subtitlesOff});
|
||||
|
||||
/// The player controls, independent of how they look: what the controls
|
||||
/// show (state, as signals) and what they do (actions), wired to the
|
||||
/// session once. Both skins only draw this and call its actions, so a
|
||||
/// control added here shows in both. Built by the session player per
|
||||
/// mount; [fullscreen], [chatOpen] and [unread] are the shell's, passed in.
|
||||
class PlayerControlsModel {
|
||||
final SessionCoordinator coord;
|
||||
final ReadonlySignal<bool> fullscreen;
|
||||
final ReadonlySignal<bool> chatOpen;
|
||||
final ReadonlySignal<int> unread;
|
||||
final VoidCallback _toggleFullscreen;
|
||||
final VoidCallback _toggleChat;
|
||||
final VoidCallback _changeVideo;
|
||||
final VoidCallback _collapse;
|
||||
final VoidCallback _leave;
|
||||
|
||||
PlayerControlsModel({
|
||||
required this.coord,
|
||||
required this.fullscreen,
|
||||
required this.chatOpen,
|
||||
required this.unread,
|
||||
required VoidCallback onToggleFullscreen,
|
||||
required VoidCallback onToggleChat,
|
||||
required VoidCallback onChangeVideo,
|
||||
required VoidCallback onCollapse,
|
||||
required VoidCallback onLeave,
|
||||
}) : _toggleFullscreen = onToggleFullscreen,
|
||||
_toggleChat = onToggleChat,
|
||||
_changeVideo = onChangeVideo,
|
||||
_collapse = onCollapse,
|
||||
_leave = onLeave;
|
||||
|
||||
PlaybackController get playback => coord.playback;
|
||||
QueueController get queue => coord.queue;
|
||||
AppSettings get settings => AppSettings.instance;
|
||||
|
||||
// ---------------------------------------------------------------- state
|
||||
|
||||
ReadonlySignal<Duration> get position => playback.position;
|
||||
ReadonlySignal<Duration> get duration => playback.duration;
|
||||
|
||||
/// End of the cached range ahead of the position.
|
||||
ReadonlySignal<Duration> get buffered => playback.buffered;
|
||||
ReadonlySignal<List<MediaChapter>> get chapters => playback.chapters;
|
||||
ReadonlySignal<int?> get currentChapter => playback.currentChapter;
|
||||
ReadonlySignal<List<MpvTrack>> get tracks => playback.tracks;
|
||||
|
||||
/// 0..`maxVolume`.
|
||||
ReadonlySignal<double> get volume => playback.volume;
|
||||
ReadonlySignal<SkipOffer?> get skipOffer => coord.skipOffer;
|
||||
ReadonlySignal<RenderMode> get renderMode => playback.renderMode;
|
||||
|
||||
/// Play / pause as the session sees it ([PlayState]).
|
||||
late final Computed<PlayState> play = computed(() {
|
||||
final r = coord.serverState.value;
|
||||
final self = coord.selfMember.value;
|
||||
return switch (r?.transport) {
|
||||
'playing' => PlayState.playing,
|
||||
'finished' => PlayState.finished,
|
||||
_ => (self?.ready ?? false) && (self?.inSet ?? false) ? PlayState.waiting : PlayState.paused,
|
||||
};
|
||||
});
|
||||
|
||||
/// Everything the layout depends on (not the position: that changes
|
||||
/// four times a second and only the timeline needs it).
|
||||
late final Computed<ControlsSnapshot> snapshot = computed(() {
|
||||
final items = queue.items.value;
|
||||
final next = coord.nextEpisode.value;
|
||||
return ControlsSnapshot(
|
||||
play: play.value,
|
||||
previousLabel: coord.previousEpisode.value?.label,
|
||||
nextLabel: items.isNotEmpty ? items.first.title : next?.label,
|
||||
nextIsUpNext: items.isNotEmpty,
|
||||
hasChapters: chapters.value.isNotEmpty,
|
||||
hasDuration: duration.value > Duration.zero,
|
||||
solo: SessionState.instance.solo.value,
|
||||
// Not a signal: read again whenever the state changes.
|
||||
hasLink: coord.serverState.value != null && coord.currentLink() != null,
|
||||
fullscreen: fullscreen.value,
|
||||
chatOpen: chatOpen.value,
|
||||
);
|
||||
});
|
||||
|
||||
/// The bar's clusters, in the layout's order.
|
||||
late final Computed<({List<BarControl> left, List<BarControl> right})> bar = computed(
|
||||
() => barControls(snapshot.value, settings.hiddenControls.value),
|
||||
);
|
||||
|
||||
/// What the ⚙ menu lists now.
|
||||
List<ControlAction> menu() => menuActions(snapshot.peek(), settings.hiddenControls.peek());
|
||||
|
||||
/// Ticks on each wheel / key volume step: the skins flash the volume.
|
||||
final volumeFlash = ValueNotifier(0);
|
||||
|
||||
/// A menu of the controls is open (the player's Esc closes it).
|
||||
int menusOpen = 0;
|
||||
|
||||
void dispose() => volumeFlash.dispose();
|
||||
|
||||
// -------------------------------------------------------------- actions
|
||||
|
||||
/// Space: ready / not ready (being ready is "play" in a server).
|
||||
void togglePlay() => coord.toggleReady();
|
||||
|
||||
/// A seek bar's press or drag; through `player.seek`, the user-seek
|
||||
/// path, so everyone follows.
|
||||
void seek(Duration to) => playback.player.seek(to);
|
||||
|
||||
/// Arrow keys: ±[secs], relative to a seek still in flight.
|
||||
void nudge(double secs) => coord.nudge(secs);
|
||||
|
||||
/// +1:30 for everyone.
|
||||
void skipForward() => coord.skipForward();
|
||||
|
||||
/// A skip pill (Skip OP / ED / 1:30).
|
||||
void skip(SkipOffer offer) => coord.skip(offer);
|
||||
|
||||
/// PgDn (+1) / PgUp (-1). False when there is no chapter to go to.
|
||||
bool stepChapter(int delta) => coord.stepChapter(delta);
|
||||
|
||||
void pickChapter(MediaChapter c) => coord.seekTo(c.start.inMicroseconds / 1e6);
|
||||
|
||||
/// Up next's first item, else the next episode. False when neither.
|
||||
bool next() {
|
||||
if (queue.items.peek().isNotEmpty) {
|
||||
queue.skip();
|
||||
return true;
|
||||
}
|
||||
if (coord.nextEpisode.peek() == null) return false;
|
||||
coord.playNextEpisode();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The previous episode. False without one.
|
||||
bool previous() {
|
||||
if (coord.previousEpisode.peek() == null) return false;
|
||||
coord.playPreviousEpisode();
|
||||
return true;
|
||||
}
|
||||
|
||||
void playNextEpisode() => coord.playNextEpisode();
|
||||
|
||||
void setVolume(double v) => playback.player.setVolume(v.clamp(0.0, vol.maxVolume));
|
||||
|
||||
/// One volume step up (+1) or down (-1) from the wheel or ↑ / ↓;
|
||||
/// flashes the volume readout.
|
||||
void stepVolume(int step) {
|
||||
setVolume(vol.stepVolume(volume.peek(), step * vol.volumeStep));
|
||||
volumeFlash.value++;
|
||||
}
|
||||
|
||||
double _beforeMute = 100;
|
||||
|
||||
/// The speaker: mute, or back to the volume before.
|
||||
void toggleMute() {
|
||||
final v = volume.peek();
|
||||
if (v > 0) {
|
||||
_beforeMute = v;
|
||||
setVolume(0);
|
||||
} else {
|
||||
setVolume(_beforeMute <= 0 ? 100 : _beforeMute);
|
||||
}
|
||||
}
|
||||
|
||||
void pickTrack(TrackPick p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff);
|
||||
|
||||
/// Saved as the choice and applied live; a null quality keeps the
|
||||
/// saved one.
|
||||
void pickShader(ShaderPreset preset, ShaderQuality? quality) => applyShaderChoice(preset, quality);
|
||||
|
||||
void toggleTimeDisplay() => settings.setTimeDisplay(settings.timeDisplay.peek().toggled);
|
||||
|
||||
void toggleFullscreen() => _toggleFullscreen();
|
||||
|
||||
void toggleChat() {
|
||||
coord.markInput();
|
||||
_toggleChat();
|
||||
}
|
||||
|
||||
void changeVideo() => _changeVideo();
|
||||
|
||||
/// Back to the library (keeps playing in the mini player).
|
||||
void collapse() => _collapse();
|
||||
|
||||
/// Asks before leaving the server.
|
||||
void leave() => _leave();
|
||||
|
||||
void reload() {
|
||||
coord.markInput();
|
||||
coord.reloadVideo();
|
||||
}
|
||||
|
||||
void markAllReady() => coord.forcePlayAll();
|
||||
|
||||
String? link() => coord.currentLink();
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// The player controls' user choices (Settings → Player, stored in
|
||||
// `AppSettings`). Plain values: the layout spec and both skins read them.
|
||||
|
||||
/// The controls' look. Both skins draw the same controls model in the same
|
||||
/// order ([ControlsLayout]); only the look differs.
|
||||
enum ControlsSkin {
|
||||
/// Material-style bar: a seek bar over a row of round icon buttons.
|
||||
standard('Standard'),
|
||||
|
||||
/// The app's own timeline skin (inspired by mpv's uosc): a thick band
|
||||
/// with the buffered part, chapters and times, flat glass buttons.
|
||||
minimal('Minimal');
|
||||
|
||||
final String label;
|
||||
const ControlsSkin(this.label);
|
||||
|
||||
/// A stored name; the old `mpvControls` flag (true: the uosc design)
|
||||
/// when there is none.
|
||||
static ControlsSkin fromStored(Object? name, {Object? legacyMpvControls}) {
|
||||
for (final s in values) {
|
||||
if (s.name == name) return s;
|
||||
}
|
||||
return legacyMpvControls == true ? minimal : standard;
|
||||
}
|
||||
}
|
||||
|
||||
/// The bar's clock: "6:00 / 24:00" or the time left ("-18:00").
|
||||
enum TimeDisplay {
|
||||
elapsed('Elapsed / total'),
|
||||
remaining('Remaining');
|
||||
|
||||
final String label;
|
||||
const TimeDisplay(this.label);
|
||||
|
||||
TimeDisplay get toggled => this == elapsed ? remaining : elapsed;
|
||||
}
|
||||
|
||||
/// Bar and button size.
|
||||
enum ControlsSize {
|
||||
normal('Normal', 1.0),
|
||||
large('Large', 1.25);
|
||||
|
||||
final String label;
|
||||
final double scale;
|
||||
const ControlsSize(this.label, this.scale);
|
||||
}
|
||||
|
||||
/// Bar buttons the user may hide; hidden ones are still in ⚙.
|
||||
enum OptionalControl {
|
||||
chapters('Chapter buttons', 'Previous / next chapter (PgUp / PgDn)'),
|
||||
subtitles('Subtitles', 'The subtitle track list'),
|
||||
audio('Audio', 'The audio track list'),
|
||||
chat('Chat', 'Chat & activity (Tab)');
|
||||
|
||||
final String label;
|
||||
final String help;
|
||||
const OptionalControl(this.label, this.help);
|
||||
}
|
||||
|
||||
/// The auto-hide delays offered (seconds).
|
||||
const controlsHideChoices = [2, 3, 5];
|
||||
|
||||
/// The default auto-hide delay (seconds).
|
||||
const defaultControlsHideSecs = 3;
|
||||
@@ -12,11 +12,11 @@ import '../shell/mark_problem.dart';
|
||||
import '../shell/motion.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import 'uosc/buttons.dart';
|
||||
import 'uosc/controls.dart';
|
||||
import 'frame.dart';
|
||||
import 'model/controls_prefs.dart';
|
||||
|
||||
// Everything about the player, defined once: the rows of the ⚙ card in
|
||||
// both control designs and of Settings → Player. Each row reads and
|
||||
// Everything about the player, defined once: the rows of the player
|
||||
// settings card (⚙ → Player settings, both skins) and of Settings → Player. Each row reads and
|
||||
// writes [AppSettings] and applies to a running player right away.
|
||||
|
||||
/// One setting: a label, one line of help under it, the control at the
|
||||
@@ -67,7 +67,7 @@ AppSelect<T> _dropdown<T>({
|
||||
}) =>
|
||||
AppSelect<T>(key: key, value: value, items: items, onChanged: onChanged, expanded: expanded);
|
||||
|
||||
/// Standard (our bars over media_kit's controls) or uosc. Live.
|
||||
/// The controls' skin: Standard or Minimal. Live.
|
||||
class PlayerDesignSetting extends StatelessWidget {
|
||||
const PlayerDesignSetting({super.key});
|
||||
|
||||
@@ -77,12 +77,100 @@ class PlayerDesignSetting extends StatelessWidget {
|
||||
return SignalBuilder(
|
||||
builder: (context) => SettingRow(
|
||||
label: 'Player design',
|
||||
help: 'uosc is modeled on mpv: a thick timeline with chapters and what is cached',
|
||||
control: _dropdown<bool>(
|
||||
help: 'Minimal has a thick timeline with chapters and what is downloaded yet',
|
||||
control: _dropdown<ControlsSkin>(
|
||||
key: const ValueKey('player-design'),
|
||||
value: s.mpvControls.value,
|
||||
items: const [(false, 'Standard'), (true, 'uosc')],
|
||||
onChanged: s.setMpvControls,
|
||||
value: s.controlsSkin.value,
|
||||
items: [for (final k in ControlsSkin.values) (k, k.label)],
|
||||
onChanged: s.setControlsSkin,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The bar's clock: elapsed / total or the time left. Live.
|
||||
class TimeDisplaySetting extends StatelessWidget {
|
||||
const TimeDisplaySetting({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
return SignalBuilder(
|
||||
builder: (context) => SettingRow(
|
||||
label: 'Time display',
|
||||
help: 'Also switched by a click on the time',
|
||||
control: _dropdown<TimeDisplay>(
|
||||
key: const ValueKey('time-display'),
|
||||
value: s.timeDisplay.value,
|
||||
items: [for (final t in TimeDisplay.values) (t, t.label)],
|
||||
onChanged: s.setTimeDisplay,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Normal or large bar and buttons. Live.
|
||||
class ControlsSizeSetting extends StatelessWidget {
|
||||
const ControlsSizeSetting({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
return SignalBuilder(
|
||||
builder: (context) => SettingRow(
|
||||
label: 'Controls size',
|
||||
help: 'Large makes the bar and its buttons a quarter bigger',
|
||||
control: _dropdown<ControlsSize>(
|
||||
key: const ValueKey('controls-size'),
|
||||
value: s.controlsSize.value,
|
||||
items: [for (final z in ControlsSize.values) (z, z.label)],
|
||||
onChanged: s.setControlsSize,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// How long the controls stay up without mouse movement. Live.
|
||||
class HideDelaySetting extends StatelessWidget {
|
||||
const HideDelaySetting({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
return SignalBuilder(
|
||||
builder: (context) => SettingRow(
|
||||
label: 'Hide the controls after',
|
||||
help: 'Without mouse movement over the video',
|
||||
control: _dropdown<int>(
|
||||
key: const ValueKey('controls-hide'),
|
||||
value: s.controlsHideSecs.value,
|
||||
items: [for (final secs in controlsHideChoices) (secs, '$secs s')],
|
||||
onChanged: s.setControlsHideSecs,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One optional bar button on or off (off: it is still in ⚙). Live.
|
||||
class BarButtonSetting extends StatelessWidget {
|
||||
final OptionalControl control;
|
||||
const BarButtonSetting({super.key, required this.control});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
return SignalBuilder(
|
||||
builder: (context) => SettingRow(
|
||||
label: control.label,
|
||||
help: control.help,
|
||||
control: Switch(
|
||||
key: ValueKey('bar-${control.name}'),
|
||||
value: !s.hiddenControls.value.contains(control),
|
||||
onChanged: (on) => s.setControlShown(control, on),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -296,7 +384,20 @@ class TrackSetting extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The player rows of Settings → Player and the ⚙ card, in order.
|
||||
/// The controls' own choices (Settings → Player → Controls).
|
||||
List<Widget> controlsSettingRows() => [
|
||||
const TimeDisplaySetting(),
|
||||
const ControlsSizeSetting(),
|
||||
const HideDelaySetting(),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 10, bottom: 2),
|
||||
child: Text('Buttons in the bar (hidden ones stay in ⚙)', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
),
|
||||
for (final c in OptionalControl.values) BarButtonSetting(control: c),
|
||||
];
|
||||
|
||||
/// The player rows of Settings → Player and the player settings card, in
|
||||
/// order.
|
||||
List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
|
||||
const PlayerDesignSetting(),
|
||||
const ProgressLineSetting(),
|
||||
@@ -304,7 +405,8 @@ List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
|
||||
const HwdecSetting(),
|
||||
];
|
||||
|
||||
/// The ⚙ card: every player setting, plus the running player's tracks.
|
||||
/// The player settings card: every player setting, plus the running
|
||||
/// player's tracks.
|
||||
class PlayerSettingsCard extends StatelessWidget {
|
||||
final ReadonlySignal<RenderMode>? renderMode;
|
||||
/// Show the audio / subtitle pickers (a player is running).
|
||||
@@ -337,7 +439,7 @@ class PlayerSettingsCard extends StatelessWidget {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Custom shader files: Settings → Player',
|
||||
'Controls layout and custom shaders: Settings → Player',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
),
|
||||
@@ -352,51 +454,14 @@ class PlayerSettingsCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the ⚙ card over the player, near the controls' right end. The
|
||||
/// uosc controls stay up while it is open.
|
||||
Future<void> showPlayerSettings(BuildContext context, {ReadonlySignal<RenderMode>? renderMode}) {
|
||||
Future<void> open() => showPopover<void>(
|
||||
/// Opens the player settings card over the player, near the controls'
|
||||
/// right end; the controls stay up while it is open.
|
||||
Future<void> showPlayerSettings(BuildContext context, {ReadonlySignal<RenderMode>? renderMode}) =>
|
||||
ControlsFrame.holdWhile(
|
||||
context,
|
||||
() => showPopover<void>(
|
||||
context,
|
||||
at: const Alignment(0.96, 0.5),
|
||||
child: PlayerSettingsCard(renderMode: renderMode),
|
||||
),
|
||||
);
|
||||
final controls = UoscControls.maybeOf(context);
|
||||
return controls == null ? open() : controls.holdDuring(open);
|
||||
}
|
||||
|
||||
/// ⚙ in the standard controls' bottom bar.
|
||||
class PlayerSettingsButton extends StatelessWidget {
|
||||
final FocusNode? focusNode;
|
||||
final ReadonlySignal<RenderMode>? renderMode;
|
||||
const PlayerSettingsButton({super.key, this.focusNode, this.renderMode});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
key: const ValueKey('player-settings'),
|
||||
focusNode: focusNode,
|
||||
tooltip: 'Player settings',
|
||||
color: Colors.white,
|
||||
iconSize: 20,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.tune),
|
||||
onPressed: () => showPlayerSettings(context, renderMode: renderMode),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ⚙ in the uosc controls' button row.
|
||||
class UoscPlayerSettingsButton extends StatelessWidget {
|
||||
final ReadonlySignal<RenderMode>? renderMode;
|
||||
const UoscPlayerSettingsButton({super.key, this.renderMode});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UoscButton(
|
||||
key: const ValueKey('uosc-player-settings'),
|
||||
icon: Icons.tune_rounded,
|
||||
tooltip: 'Player settings',
|
||||
onPressed: () => showPlayerSettings(context, renderMode: renderMode),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../state/settings.dart';
|
||||
import 'controls_probe.dart' show ControlsHold;
|
||||
import 'frame.dart';
|
||||
import 'minimal/minimal_controls.dart';
|
||||
import 'model/controls_model.dart';
|
||||
import 'model/controls_prefs.dart';
|
||||
import 'standard/standard_controls.dart';
|
||||
|
||||
// The session's controls: one [ControlsFrame] (when they are up) around the
|
||||
// skin the settings pick, both drawing the same [PlayerControlsModel].
|
||||
// Skin, size and the hide delay follow the settings live.
|
||||
|
||||
/// The bottom controls' height in [skin] at [size] (the player's floating
|
||||
/// pills sit just above it).
|
||||
double controlsBottomHeight(ControlsSkin skin, ControlsSize size) => switch (skin) {
|
||||
ControlsSkin.standard => StandardControls.bottomHeight(size.scale),
|
||||
ControlsSkin.minimal => MinimalControls.bottomHeight(size.scale),
|
||||
};
|
||||
|
||||
/// The top bar's height in [skin].
|
||||
double controlsTopHeight(ControlsSkin skin) => switch (skin) {
|
||||
ControlsSkin.standard => StandardControls.topHeight,
|
||||
ControlsSkin.minimal => MinimalControls.topHeight,
|
||||
};
|
||||
|
||||
class SessionControls extends StatelessWidget {
|
||||
final PlayerControlsModel model;
|
||||
|
||||
/// Follows whether the controls are up.
|
||||
final ValueNotifier<bool> shown;
|
||||
final ControlsHold hold;
|
||||
final VoidCallback onDoubleClick;
|
||||
final Widget? windowButtons;
|
||||
const SessionControls({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.shown,
|
||||
required this.hold,
|
||||
required this.onDoubleClick,
|
||||
this.windowButtons,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
return SignalBuilder(
|
||||
builder: (context) {
|
||||
final skin = s.controlsSkin.value;
|
||||
final size = s.controlsSize.value;
|
||||
final scale = size.scale;
|
||||
return ControlsFrame(
|
||||
// A new frame per skin: each starts up, like a fresh mount.
|
||||
key: ValueKey(skin),
|
||||
shown: shown,
|
||||
holdBy: hold,
|
||||
hideAfter: Duration(seconds: s.controlsHideSecs.value),
|
||||
onDoubleClick: onDoubleClick,
|
||||
barTop: controlsTopHeight(skin),
|
||||
barBottom: controlsBottomHeight(skin, size),
|
||||
builder: (context, visible) => switch (skin) {
|
||||
ControlsSkin.standard => StandardControls(
|
||||
model: model,
|
||||
visible: visible,
|
||||
scale: scale,
|
||||
hold: hold,
|
||||
windowButtons: windowButtons,
|
||||
),
|
||||
ControlsSkin.minimal => MinimalControls(
|
||||
model: model,
|
||||
visible: visible,
|
||||
scale: scale,
|
||||
hold: hold,
|
||||
windowButtons: windowButtons,
|
||||
),
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../playback/media_marks.dart';
|
||||
import '../frame.dart';
|
||||
|
||||
// The Standard skin's seek bar: a Material-style track with the played and
|
||||
// buffered parts, chapter marks and a hover tip.
|
||||
|
||||
/// The seek bar: played / buffered track, a diamond at each chapter start
|
||||
/// (not the one at 0), and on hover the time with the chapter title above
|
||||
/// it. Like media_kit's bar it reports a seek on every drag move and on
|
||||
/// release ([onSeek]); the session routes those through the user-seek path
|
||||
/// (debounced, everyone follows).
|
||||
class ChapterSeekBar extends StatefulWidget {
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
final Duration buffer;
|
||||
final List<MediaChapter> chapters;
|
||||
final ValueChanged<Duration> onSeek;
|
||||
|
||||
/// The bar's height (its hit area); the track is centred in it.
|
||||
final double height;
|
||||
const ChapterSeekBar({
|
||||
super.key,
|
||||
required this.position,
|
||||
required this.duration,
|
||||
this.buffer = Duration.zero,
|
||||
this.chapters = const [],
|
||||
required this.onSeek,
|
||||
this.height = ChapterSeekBar.defaultHeight,
|
||||
});
|
||||
|
||||
static const defaultHeight = 28.0;
|
||||
|
||||
@override
|
||||
State<ChapterSeekBar> createState() => _ChapterSeekBarState();
|
||||
}
|
||||
|
||||
class _ChapterSeekBarState extends State<ChapterSeekBar> {
|
||||
double? _hoverX;
|
||||
bool _dragging = false;
|
||||
double _dragFraction = 0;
|
||||
|
||||
double _fraction(Duration d) {
|
||||
final total = widget.duration.inMicroseconds;
|
||||
if (total <= 0) return 0;
|
||||
return (d.inMicroseconds / total).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
|
||||
|
||||
// A drag sends no hover events: the controls are held up through it.
|
||||
void _drag(bool on) {
|
||||
if (_dragging == on) return;
|
||||
setState(() => _dragging = on);
|
||||
ControlsFrame.maybeOf(context)?.hold(on);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_dragging) ControlsFrame.maybeOf(context)?.hold(false);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _seekTo(double x, double width) {
|
||||
if (widget.duration <= Duration.zero || width <= 0) return;
|
||||
_dragFraction = (x / width).clamp(0.0, 1.0);
|
||||
widget.onSeek(_at(_dragFraction));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final active = _hoverX != null || _dragging;
|
||||
final barHeight = active ? 5.0 : 3.0;
|
||||
final played = _dragging ? _dragFraction : _fraction(widget.position);
|
||||
final mid = widget.height / 2;
|
||||
final hoverX = _hoverX;
|
||||
final hoverAt = hoverX == null || width <= 0 ? null : _at(hoverX / width);
|
||||
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
|
||||
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
|
||||
onHover: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
|
||||
onExit: (_) => setState(() => _hoverX = null),
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: (e) {
|
||||
_dragFraction = width <= 0 ? 0 : (e.localPosition.dx / width).clamp(0.0, 1.0);
|
||||
_drag(true);
|
||||
},
|
||||
onPointerMove: (e) {
|
||||
setState(() => _hoverX = e.localPosition.dx.clamp(0, width));
|
||||
_seekTo(e.localPosition.dx, width);
|
||||
},
|
||||
onPointerUp: (e) {
|
||||
_seekTo(e.localPosition.dx, width);
|
||||
_drag(false);
|
||||
},
|
||||
onPointerCancel: (_) => _drag(false),
|
||||
child: SizedBox(
|
||||
key: const ValueKey('seek-bar'),
|
||||
width: width,
|
||||
height: widget.height,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: mid - barHeight / 2,
|
||||
height: barHeight,
|
||||
child: Container(color: Colors.white24),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: mid - barHeight / 2,
|
||||
height: barHeight,
|
||||
width: width * _fraction(widget.buffer),
|
||||
child: Container(color: Colors.white24),
|
||||
),
|
||||
if (hoverX != null && !_dragging)
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: mid - barHeight / 2,
|
||||
height: barHeight,
|
||||
width: hoverX,
|
||||
child: Container(color: Colors.white24),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: mid - barHeight / 2,
|
||||
height: barHeight,
|
||||
width: width * played,
|
||||
child: Container(color: primary),
|
||||
),
|
||||
for (final c in widget.chapters)
|
||||
if (c.start > Duration.zero && widget.duration > Duration.zero)
|
||||
Positioned(
|
||||
key: ValueKey('chapter-mark-${c.index}'),
|
||||
left: width * _fraction(c.start) - 4,
|
||||
top: mid - 4,
|
||||
width: 8,
|
||||
height: 8,
|
||||
child: IgnorePointer(
|
||||
child: Transform.rotate(
|
||||
angle: math.pi / 4,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.black54, width: 0.8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (active)
|
||||
Positioned(
|
||||
left: width * played - 6,
|
||||
top: mid - 6,
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(color: primary, shape: BoxShape.circle),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hoverAt != null)
|
||||
Positioned(
|
||||
// Kept clear of the window edges (the tip is centred).
|
||||
left: hoverX!.clamp(math.min(60.0, width / 2), math.max(width - 60, width / 2)),
|
||||
bottom: widget.height - 2,
|
||||
child: IgnorePointer(
|
||||
child: FractionalTranslation(
|
||||
translation: const Offset(-0.5, 0),
|
||||
child: _HoverTip(title: hoverTitle, time: formatClock(hoverAt)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HoverTip extends StatelessWidget {
|
||||
final String title;
|
||||
final String time;
|
||||
const _HoverTip({required this.title, required this.time});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 320),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.8), borderRadius: BorderRadius.circular(6)),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (title.isNotEmpty)
|
||||
Text(
|
||||
title,
|
||||
key: const ValueKey('seek-hover-chapter'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
time,
|
||||
key: const ValueKey('seek-hover-time'),
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
||||
|
||||
import '../../queue/queue_ui.dart';
|
||||
import '../../shell/now_playing_title.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../control_menus.dart';
|
||||
import '../controls_probe.dart' show ControlsHold;
|
||||
import '../frame.dart';
|
||||
import '../model/controls_layout.dart';
|
||||
import '../model/controls_model.dart';
|
||||
import '../volume.dart';
|
||||
import 'seek_bar.dart';
|
||||
|
||||
// The Standard skin: Material-style bars over the picture. Top: what plays,
|
||||
// Up next and the window controls; bottom: the seek bar (chapter marks,
|
||||
// hover time) over a row of round icon buttons in the layout's order.
|
||||
// Hidden, a thin progress line stays (unless switched off).
|
||||
|
||||
class StandardControls extends StatelessWidget {
|
||||
final PlayerControlsModel model;
|
||||
final bool visible;
|
||||
final double scale;
|
||||
final ControlsHold? hold;
|
||||
|
||||
/// Right of the top bar's own buttons (the window buttons in fullscreen).
|
||||
final Widget? windowButtons;
|
||||
const StandardControls({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.visible,
|
||||
this.scale = 1,
|
||||
this.hold,
|
||||
this.windowButtons,
|
||||
});
|
||||
|
||||
/// The top bar: room for the cover (2:3, 60 px tall).
|
||||
static const topHeight = 72.0;
|
||||
static const _rowHeight = 56.0;
|
||||
|
||||
/// The bottom bar (seek bar and buttons) at [scale].
|
||||
static double bottomHeight(double scale) => (_rowHeight + ChapterSeekBar.defaultHeight) * scale;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const fade = ControlsFrame.fade;
|
||||
Widget bar(Widget child) => IgnorePointer(
|
||||
ignoring: !visible,
|
||||
child: AnimatedOpacity(duration: fade, opacity: visible ? 1 : 0, child: child),
|
||||
);
|
||||
// No button here takes focus (Space is the ready key); the top bar's
|
||||
// Up next keeps its own (its popover has a text field).
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: bar(
|
||||
DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x99000000), Color(0x00000000)],
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
key: const ValueKey('standard-top'),
|
||||
height: topHeight,
|
||||
child: Padding(padding: const EdgeInsets.only(left: 16, right: 8), child: _top()),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: bar(
|
||||
DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x00000000), Color(0xB3000000)],
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: 24 * scale),
|
||||
child: SizedBox(
|
||||
key: const ValueKey('standard-bottom'),
|
||||
height: bottomHeight(scale),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SignalBuilder(
|
||||
builder: (context) => ChapterSeekBar(
|
||||
position: model.position.value,
|
||||
duration: model.duration.value,
|
||||
buffer: model.buffered.value,
|
||||
chapters: model.chapters.value,
|
||||
height: ChapterSeekBar.defaultHeight * scale,
|
||||
onSeek: model.seek,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: ExcludeFocus(
|
||||
child: SignalBuilder(
|
||||
builder: (context) {
|
||||
final s = model.snapshot.value;
|
||||
final layout = model.bar.value;
|
||||
return Row(
|
||||
key: const ValueKey('standard-buttons'),
|
||||
children: [
|
||||
for (final c in layout.left) _control(context, c, s),
|
||||
const Spacer(),
|
||||
for (final c in layout.right) _control(context, c, s),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Hidden: the thin progress line.
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: IgnorePointer(
|
||||
child: SignalBuilder(
|
||||
builder: (context) {
|
||||
if (visible || !AppSettings.instance.progressLine.value) return const SizedBox.shrink();
|
||||
final d = model.duration.value, p = model.position.value;
|
||||
final v = d.inMilliseconds == 0 ? 0.0 : (p.inMilliseconds / d.inMilliseconds).clamp(0.0, 1.0);
|
||||
return LinearProgressIndicator(
|
||||
key: const ValueKey('progress-line-standard'),
|
||||
value: v,
|
||||
minHeight: 2,
|
||||
backgroundColor: Colors.transparent,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _top() {
|
||||
final m = model;
|
||||
return Row(
|
||||
children: [
|
||||
const Expanded(child: NowPlayingTitle()),
|
||||
UpNextButton(queue: m.queue, onPlayNextEpisode: m.playNextEpisode, look: UpNextLook.standard, hold: hold),
|
||||
SignalBuilder(
|
||||
builder: (context) {
|
||||
final full = m.fullscreen.value;
|
||||
return ExcludeFocus(
|
||||
child: _IconButton(
|
||||
key: const ValueKey('standard-collapse'),
|
||||
icon: full ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
|
||||
tooltip: full ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
|
||||
onPressed: full ? m.toggleFullscreen : m.collapse,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
?windowButtons,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _control(BuildContext context, BarControl c, ControlsSnapshot s) {
|
||||
final m = model;
|
||||
final key = ValueKey('control-${c.name}');
|
||||
final enabled = controlEnabled(c, s);
|
||||
Widget icon(VoidCallback? onPressed, {int badge = 0, double size = 24, Color? color}) => _IconButton(
|
||||
key: key,
|
||||
icon: controlIcon(c, s),
|
||||
tooltip: controlTooltip(c, s),
|
||||
badge: badge,
|
||||
size: size * scale,
|
||||
color: color,
|
||||
onPressed: enabled ? onPressed : null,
|
||||
);
|
||||
switch (c) {
|
||||
case BarControl.playPause:
|
||||
final waiting = s.play == PlayState.waiting;
|
||||
return icon(m.togglePlay, size: 30, color: waiting ? Theme.of(context).colorScheme.primary : null);
|
||||
case BarControl.previous:
|
||||
return icon(m.previous);
|
||||
case BarControl.next:
|
||||
return icon(m.next);
|
||||
case BarControl.chapterPrevious:
|
||||
return icon(() => m.stepChapter(-1));
|
||||
case BarControl.chapterNext:
|
||||
return icon(() => m.stepChapter(1));
|
||||
case BarControl.volume:
|
||||
return SignalBuilder(
|
||||
key: key,
|
||||
builder: (context) => VolumeControl(
|
||||
volume: m.volume.value,
|
||||
onChanged: m.setVolume,
|
||||
onToggleMute: m.toggleMute,
|
||||
button: (context, i, color, tip, onPressed) =>
|
||||
_IconButton(icon: i, tooltip: tip, color: color, size: 24 * scale, onPressed: onPressed),
|
||||
),
|
||||
);
|
||||
case BarControl.time:
|
||||
return SignalBuilder(
|
||||
key: key,
|
||||
builder: (context) => _Clock(
|
||||
text: clockText(m.position.value, m.duration.value, m.settings.timeDisplay.value),
|
||||
tooltip: controlTooltip(c, s),
|
||||
scale: scale,
|
||||
onPressed: m.toggleTimeDisplay,
|
||||
),
|
||||
);
|
||||
case BarControl.subtitles || BarControl.audio:
|
||||
final type = c == BarControl.audio ? JellyfinStreamType.audio : JellyfinStreamType.subtitle;
|
||||
return SignalBuilder(
|
||||
key: key,
|
||||
builder: (context) => Builder(
|
||||
builder: (context) => _IconButton(
|
||||
icon: controlIcon(c, s),
|
||||
tooltip: controlTooltip(c, s),
|
||||
badge: trackCount(m.tracks.value, type),
|
||||
size: 22 * scale,
|
||||
onPressed: () => openTrackMenu(context, m, type),
|
||||
),
|
||||
),
|
||||
);
|
||||
case BarControl.settings:
|
||||
return Builder(
|
||||
key: key,
|
||||
builder: (context) => _IconButton(
|
||||
icon: controlIcon(c, s),
|
||||
tooltip: controlTooltip(c, s),
|
||||
size: 22 * scale,
|
||||
onPressed: () => openControlsMenu(context, m),
|
||||
),
|
||||
);
|
||||
case BarControl.chat:
|
||||
return SignalBuilder(
|
||||
key: key,
|
||||
builder: (context) => _IconButton(
|
||||
icon: controlIcon(c, s),
|
||||
tooltip: controlTooltip(c, s),
|
||||
badge: m.unread.value,
|
||||
size: 22 * scale,
|
||||
onPressed: m.toggleChat,
|
||||
),
|
||||
);
|
||||
case BarControl.fullscreen:
|
||||
return icon(m.toggleFullscreen, size: 26);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A round white icon button with an optional count badge (the Material
|
||||
/// look of media_kit's bar).
|
||||
class _IconButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback? onPressed;
|
||||
final int badge;
|
||||
final double size;
|
||||
final Color? color;
|
||||
const _IconButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.onPressed,
|
||||
this.badge = 0,
|
||||
this.size = 24,
|
||||
this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return IconButton(
|
||||
tooltip: tooltip,
|
||||
iconSize: size,
|
||||
color: color ?? Colors.white,
|
||||
disabledColor: Colors.white38,
|
||||
onPressed: onPressed,
|
||||
icon: Badge(
|
||||
isLabelVisible: badge > 0,
|
||||
backgroundColor: scheme.primary,
|
||||
textColor: scheme.onPrimary,
|
||||
label: Text('$badge'),
|
||||
child: Icon(icon),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The clock: "6:00 / 24:00" or "-18:00"; a click switches.
|
||||
class _Clock extends StatelessWidget {
|
||||
final String text;
|
||||
final String tooltip;
|
||||
final double scale;
|
||||
final VoidCallback onPressed;
|
||||
const _Clock({required this.text, required this.tooltip, required this.scale, required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
waitDuration: const Duration(milliseconds: 500),
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
padding: EdgeInsets.symmetric(horizontal: 10 * scale),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
onPressed: onPressed,
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(fontSize: 14 * scale, fontFeatures: const [FontFeature.tabularFigures()]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
||||
|
||||
import '../../jellyfin/track_prefs.dart' show MpvTrack;
|
||||
import '../../playback/media_marks.dart';
|
||||
import '../../playback/playback_controller.dart' show RenderMode;
|
||||
import '../../screens/session/player_controls.dart' show TrackPick;
|
||||
import '../../shaders/shader_menu.dart';
|
||||
import '../../shaders/shader_presets.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../volume.dart';
|
||||
import 'controls.dart';
|
||||
import 'menu.dart';
|
||||
import 'style.dart';
|
||||
|
||||
/// Flat uosc button: a white icon (optionally a [label] beside it and a
|
||||
/// small count [badge] in the corner), a faint highlight on hover, a dark
|
||||
/// tooltip. Never takes focus (the session's keys are global).
|
||||
class UoscButton extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback? onPressed;
|
||||
final String? label;
|
||||
final int? badge;
|
||||
/// Tint for an "on" state (ready, shaders on).
|
||||
final Color? color;
|
||||
const UoscButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.onPressed,
|
||||
this.label,
|
||||
this.badge,
|
||||
this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
State<UoscButton> createState() => _UoscButtonState();
|
||||
}
|
||||
|
||||
class _UoscButtonState extends State<UoscButton> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final w = widget;
|
||||
final color = w.onPressed == null ? UoscStyle.dim.withValues(alpha: 0.4) : (w.color ?? UoscStyle.text);
|
||||
final badge = w.badge;
|
||||
return UoscTooltip(
|
||||
message: w.tooltip,
|
||||
child: MouseRegion(
|
||||
cursor: w.onPressed == null ? SystemMouseCursors.basic : SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: w.onPressed,
|
||||
child: Container(
|
||||
height: UoscControls.buttonRowHeight,
|
||||
constraints: const BoxConstraints(minWidth: UoscControls.buttonRowHeight),
|
||||
padding: EdgeInsets.symmetric(horizontal: w.label == null ? 0 : 8),
|
||||
decoration: BoxDecoration(
|
||||
color: _hover && w.onPressed != null ? UoscStyle.hover : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(UoscStyle.radius),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Icon(w.icon, color: color, size: 22),
|
||||
if (badge != null && badge > 0)
|
||||
Positioned(
|
||||
right: -8,
|
||||
bottom: -5,
|
||||
child: Container(
|
||||
key: const ValueKey('uosc-badge'),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: UoscStyle.text,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text('$badge',
|
||||
style: const TextStyle(
|
||||
color: Colors.black, fontSize: 9, height: 1.3, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (w.label != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
Text(w.label!, style: TextStyle(color: color, fontSize: UoscStyle.fontSize)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Badge-style text button ("HQ" in uosc): a small outlined box with a
|
||||
/// short label, filled when [active].
|
||||
class UoscBadgeButton extends StatefulWidget {
|
||||
final String text;
|
||||
final String tooltip;
|
||||
final bool active;
|
||||
final VoidCallback onPressed;
|
||||
const UoscBadgeButton({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.tooltip,
|
||||
required this.active,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
State<UoscBadgeButton> createState() => _UoscBadgeButtonState();
|
||||
}
|
||||
|
||||
class _UoscBadgeButtonState extends State<UoscBadgeButton> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final w = widget;
|
||||
return UoscTooltip(
|
||||
message: w.tooltip,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: w.onPressed,
|
||||
child: Container(
|
||||
height: UoscControls.buttonRowHeight,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: _hover ? UoscStyle.hover : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(UoscStyle.radius),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: w.active ? UoscStyle.text : Colors.transparent,
|
||||
border: Border.all(color: w.active ? UoscStyle.text : UoscStyle.dim, width: 1.4),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(w.text,
|
||||
style: TextStyle(
|
||||
color: w.active ? Colors.black : UoscStyle.dim,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.3)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Volume: click mutes / unmutes, hovering shows a [VolumeBar] up to
|
||||
/// [maxVolume], the wheel steps [volumeStep].
|
||||
class UoscVolumeButton extends StatefulWidget {
|
||||
/// 0..[maxVolume].
|
||||
final double volume;
|
||||
final ValueChanged<double> onChanged;
|
||||
const UoscVolumeButton({super.key, required this.volume, required this.onChanged});
|
||||
|
||||
@override
|
||||
State<UoscVolumeButton> createState() => _UoscVolumeButtonState();
|
||||
}
|
||||
|
||||
class _UoscVolumeButtonState extends State<UoscVolumeButton> {
|
||||
final _steps = WheelSteps();
|
||||
double _beforeMute = 100;
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final v = widget.volume;
|
||||
final icon = v <= 0 ? Icons.volume_off_rounded : (v < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded);
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: Listener(
|
||||
// Claimed here, so the player's own wheel handler skips it.
|
||||
onPointerSignal: (e) => _steps.handle(e, (step) => widget.onChanged(stepVolume(v, step * volumeStep))),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: _hover
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 6, right: 2),
|
||||
child: VolumeBar(
|
||||
key: const ValueKey('uosc-volume-bar'),
|
||||
volume: v,
|
||||
onChanged: widget.onChanged,
|
||||
color: UoscStyle.text,
|
||||
trackColor: UoscStyle.dim.withValues(alpha: 0.5),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
UoscButton(
|
||||
key: const ValueKey('uosc-volume'),
|
||||
icon: icon,
|
||||
color: v > 100 ? boostColor : null,
|
||||
tooltip: 'Volume ${v.round()} % (scroll to change)',
|
||||
onPressed: () {
|
||||
if (v > 0) {
|
||||
_beforeMute = v;
|
||||
widget.onChanged(0);
|
||||
} else {
|
||||
widget.onChanged(_beforeMute <= 0 ? 100 : _beforeMute);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Subtitles or audio: the icon with the track count; the menu lists the
|
||||
/// tracks (mpv's selection active).
|
||||
class UoscTracksButton extends StatelessWidget {
|
||||
final JellyfinStreamType type;
|
||||
final List<MpvTrack> tracks;
|
||||
final void Function(TrackPick pick) onPick;
|
||||
final ValueChanged<bool>? onMenuOpen;
|
||||
const UoscTracksButton({super.key, required this.type, required this.tracks, required this.onPick, this.onMenuOpen});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final audio = type == JellyfinStreamType.audio;
|
||||
final count = tracks.where((t) => t.type == type).length;
|
||||
return UoscButton(
|
||||
key: ValueKey(audio ? 'uosc-audio' : 'uosc-subtitles'),
|
||||
icon: audio ? Icons.graphic_eq_rounded : Icons.subtitles_outlined,
|
||||
tooltip: audio ? 'Audio' : 'Subtitles',
|
||||
badge: count,
|
||||
onPressed: () async {
|
||||
onMenuOpen?.call(true);
|
||||
final pick = await showUoscMenu<TrackPick>(context,
|
||||
title: audio ? 'Audio' : 'Subtitles', items: trackMenuItems(type, tracks));
|
||||
onMenuOpen?.call(false);
|
||||
if (pick != null) onPick(pick);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Chapters: the list with start times, the playing one active; a pick
|
||||
/// jumps there. Nothing without chapters.
|
||||
class UoscChaptersButton extends StatelessWidget {
|
||||
final List<MediaChapter> chapters;
|
||||
final int? current;
|
||||
final Duration duration;
|
||||
final ValueChanged<MediaChapter> onPick;
|
||||
final ValueChanged<bool>? onMenuOpen;
|
||||
const UoscChaptersButton({
|
||||
super.key,
|
||||
required this.chapters,
|
||||
required this.current,
|
||||
required this.duration,
|
||||
required this.onPick,
|
||||
this.onMenuOpen,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (chapters.isEmpty) return const SizedBox.shrink();
|
||||
return UoscButton(
|
||||
key: const ValueKey('uosc-chapters'),
|
||||
icon: Icons.bookmarks_outlined,
|
||||
tooltip: 'Chapters (PgUp / PgDn)',
|
||||
onPressed: () async {
|
||||
onMenuOpen?.call(true);
|
||||
final pick = await showUoscMenu<MediaChapter>(context,
|
||||
title: 'Chapters', items: chapterMenuItems(chapters, current, duration));
|
||||
onMenuOpen?.call(false);
|
||||
if (pick != null) onPick(pick);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shaders as uosc's quality badge: the active Anime4K preset ("A HQ"),
|
||||
/// "4K" dimmed when off. Opens the shader menu.
|
||||
class UoscShaderButton extends StatelessWidget {
|
||||
final ReadonlySignal<RenderMode> renderMode;
|
||||
final void Function(ShaderPreset preset, ShaderQuality? quality) onPick;
|
||||
final ValueChanged<bool>? onMenuOpen;
|
||||
const UoscShaderButton({super.key, required this.renderMode, required this.onPick, this.onMenuOpen});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
return SignalBuilder(builder: (context) {
|
||||
final preset = s.shaderPreset.value;
|
||||
final quality = s.shaderQuality.value;
|
||||
final short = shaderShortLabel(preset, quality);
|
||||
return Builder(
|
||||
builder: (context) => UoscBadgeButton(
|
||||
key: const ValueKey('uosc-shaders'),
|
||||
text: short ?? '4K',
|
||||
active: short != null,
|
||||
tooltip: short == null
|
||||
? 'Shaders: off (Ctrl+1..6)'
|
||||
: 'Shaders: ${shaderLabel(preset, quality)} (Ctrl+0 off)',
|
||||
onPressed: () {
|
||||
final controls = UoscControls.maybeOf(context);
|
||||
Future<void> open() =>
|
||||
showShaderMenu(context, renderMode: renderMode, onPick: onPick, onMenuOpen: onMenuOpen);
|
||||
controls == null ? open() : controls.holdDuring(open);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry of the ≡ menu.
|
||||
typedef UoscAction = ({String title, String? hint, VoidCallback run});
|
||||
|
||||
/// ≡: the session's actions as a menu.
|
||||
class UoscMainMenuButton extends StatelessWidget {
|
||||
final List<UoscAction> actions;
|
||||
final ValueChanged<bool>? onMenuOpen;
|
||||
const UoscMainMenuButton({super.key, required this.actions, this.onMenuOpen});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UoscButton(
|
||||
key: const ValueKey('uosc-menu-button'),
|
||||
icon: Icons.menu_rounded,
|
||||
tooltip: 'Menu',
|
||||
onPressed: () async {
|
||||
onMenuOpen?.call(true);
|
||||
final pick = await showUoscMenu<UoscAction>(context, items: [
|
||||
for (final a in actions) UoscMenuItem(value: a, title: a.title, hint: a.hint),
|
||||
]);
|
||||
onMenuOpen?.call(false);
|
||||
pick?.run();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../../state/settings.dart';
|
||||
import '../controls_probe.dart' show ControlsHold;
|
||||
|
||||
// The mpv-style (uosc) controls frame: a full-width timeline at the
|
||||
// bottom with a row of flat buttons above it, the title top left and a
|
||||
// corner widget top right.
|
||||
// Mouse movement shows them; after [UoscControls.hideAfter] without any
|
||||
// they fade out, the timeline shrinks to uosc's progress line (gone when
|
||||
// "Progress line when hidden" is off) and the cursor hides. Our own frame: no
|
||||
// media_kit controls state to reach into.
|
||||
|
||||
class UoscControls extends StatefulWidget {
|
||||
/// The timeline; [expanded] is false while the controls are hidden.
|
||||
final Widget Function(BuildContext context, bool expanded) timeline;
|
||||
/// Buttons left and right of the row above the timeline.
|
||||
final List<Widget> left;
|
||||
final List<Widget> right;
|
||||
/// Top-right corner (chat, back to the library), shown with the controls.
|
||||
final Widget? top;
|
||||
/// Top left (what is playing), shown with the controls; gets the width
|
||||
/// [top] leaves.
|
||||
final Widget? topLeft;
|
||||
final VoidCallback? onDoubleTap;
|
||||
final Duration hideAfter;
|
||||
/// Follows whether the controls are up (for overlays outside them).
|
||||
final ValueNotifier<bool>? shown;
|
||||
/// Keeps them up while held (the mouse on an overlay, an open popover).
|
||||
final ControlsHold? holdBy;
|
||||
const UoscControls({
|
||||
super.key,
|
||||
required this.timeline,
|
||||
this.left = const [],
|
||||
this.right = const [],
|
||||
this.top,
|
||||
this.topLeft,
|
||||
this.onDoubleTap,
|
||||
this.hideAfter = const Duration(seconds: 3),
|
||||
this.shown,
|
||||
this.holdBy,
|
||||
});
|
||||
|
||||
static const buttonRowHeight = 40.0;
|
||||
/// Under the button row, above the timeline.
|
||||
static const rowBottomPadding = 4.0;
|
||||
|
||||
/// The controls around [context], for widgets that need them kept up
|
||||
/// (a timeline drag, an open menu).
|
||||
static UoscControlsState? maybeOf(BuildContext context) =>
|
||||
context.findAncestorStateOfType<UoscControlsState>();
|
||||
|
||||
@override
|
||||
State<UoscControls> createState() => UoscControlsState();
|
||||
}
|
||||
|
||||
class UoscControlsState extends State<UoscControls> {
|
||||
bool _visible = true;
|
||||
int _holds = 0;
|
||||
bool _heldBy = false;
|
||||
Timer? _timer;
|
||||
|
||||
bool get visible => _visible;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.holdBy?.addListener(_holdByChanged);
|
||||
// Not during this build: an overlay listening to [shown] may be built.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) widget.shown?.value = true;
|
||||
});
|
||||
_hideLater();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(UoscControls oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.holdBy != widget.holdBy) {
|
||||
oldWidget.holdBy?.removeListener(_holdByChanged);
|
||||
widget.holdBy?.addListener(_holdByChanged);
|
||||
_holdByChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.holdBy?.removeListener(_holdByChanged);
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _holdByChanged() {
|
||||
final on = widget.holdBy?.held ?? false;
|
||||
if (on == _heldBy || !mounted) return;
|
||||
_heldBy = on;
|
||||
hold(on);
|
||||
}
|
||||
|
||||
/// Show the controls and restart the hide countdown.
|
||||
void wake() {
|
||||
if (!_visible && mounted) {
|
||||
setState(() => _visible = true);
|
||||
widget.shown?.value = true;
|
||||
}
|
||||
_hideLater();
|
||||
}
|
||||
|
||||
/// Keep the controls up while [on] (paired calls): a drag sends no
|
||||
/// hover events and a menu route takes the mouse.
|
||||
void hold(bool on) {
|
||||
_holds = on ? _holds + 1 : (_holds - 1).clamp(0, 1 << 20);
|
||||
wake();
|
||||
}
|
||||
|
||||
/// Runs [open] (a menu) with the controls held up.
|
||||
Future<T> holdDuring<T>(Future<T> Function() open) async {
|
||||
hold(true);
|
||||
try {
|
||||
return await open();
|
||||
} finally {
|
||||
if (mounted) hold(false);
|
||||
}
|
||||
}
|
||||
|
||||
void _hideLater() {
|
||||
_timer?.cancel();
|
||||
_timer = Timer(widget.hideAfter, _hide);
|
||||
}
|
||||
|
||||
void _hide() {
|
||||
if (!mounted || _holds > 0 || !_visible) return;
|
||||
setState(() => _visible = false);
|
||||
widget.shown?.value = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final visible = _visible;
|
||||
const fade = Duration(milliseconds: 150);
|
||||
return MouseRegion(
|
||||
cursor: visible ? SystemMouseCursors.basic : SystemMouseCursors.none,
|
||||
onEnter: (_) => wake(),
|
||||
onHover: (_) => wake(),
|
||||
onExit: (_) {
|
||||
_timer?.cancel();
|
||||
_hide();
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onDoubleTap: widget.onDoubleTap,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 160,
|
||||
child: IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
duration: fade,
|
||||
opacity: visible ? 1 : 0,
|
||||
child: const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x00000000), Color(0x8C000000)],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.topLeft != null)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: 120,
|
||||
child: IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
duration: fade,
|
||||
opacity: visible ? 1 : 0,
|
||||
child: const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x8C000000), Color(0x00000000)],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.top != null || widget.topLeft != null)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: IgnorePointer(
|
||||
key: const ValueKey('uosc-top'),
|
||||
ignoring: !visible,
|
||||
child: AnimatedOpacity(
|
||||
duration: fade,
|
||||
opacity: visible ? 1 : 0,
|
||||
// The buttons centred on the title, like the standard
|
||||
// design's top bar.
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: widget.topLeft == null
|
||||
? const SizedBox.shrink()
|
||||
: Align(alignment: AlignmentDirectional.centerStart, child: widget.topLeft),
|
||||
),
|
||||
),
|
||||
if (widget.top != null)
|
||||
Padding(padding: const EdgeInsets.fromLTRB(0, 12, 6, 0), child: widget.top),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
IgnorePointer(
|
||||
key: const ValueKey('uosc-buttons'),
|
||||
ignoring: !visible,
|
||||
child: AnimatedOpacity(
|
||||
duration: fade,
|
||||
opacity: visible ? 1 : 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(6, 0, 6, UoscControls.rowBottomPadding),
|
||||
child: SizedBox(
|
||||
height: UoscControls.buttonRowHeight,
|
||||
child: Row(children: [...widget.left, const Spacer(), ...widget.right]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Hidden: the collapsed timeline is the progress line,
|
||||
// unless the player settings turn it off.
|
||||
SignalBuilder(
|
||||
builder: (context) {
|
||||
final line = AppSettings.instance.progressLine.value;
|
||||
return AnimatedOpacity(
|
||||
key: const ValueKey('uosc-timeline-wrap'),
|
||||
duration: fade,
|
||||
opacity: visible || line ? 1 : 0,
|
||||
child: widget.timeline(context, visible),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The session's controls: the standard ones ([material]) or, with the
|
||||
/// "Player design" setting on uosc, the uosc ones ([mpv]). Switches live
|
||||
/// when the setting changes.
|
||||
class SessionControlsSwitch extends StatelessWidget {
|
||||
final WidgetBuilder material;
|
||||
final WidgetBuilder mpv;
|
||||
const SessionControlsSwitch({super.key, required this.material, required this.mpv});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SignalBuilder(
|
||||
builder: (context) =>
|
||||
AppSettings.instance.mpvControls.value ? mpv(context) : material(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
||||
|
||||
import '../../jellyfin/track_prefs.dart' show MpvTrack;
|
||||
import '../../playback/media_marks.dart';
|
||||
import '../../screens/session/player_controls.dart' show TrackPick;
|
||||
import 'controls.dart';
|
||||
import 'style.dart';
|
||||
|
||||
/// One row of a uosc menu: [title], a dim [hint] on the right (a time, a
|
||||
/// key), [active] = the current one (highlighted).
|
||||
class UoscMenuItem<T> {
|
||||
final T value;
|
||||
final String title;
|
||||
final String? hint;
|
||||
final bool active;
|
||||
final bool enabled;
|
||||
const UoscMenuItem({
|
||||
required this.value,
|
||||
required this.title,
|
||||
this.hint,
|
||||
this.active = false,
|
||||
this.enabled = true,
|
||||
});
|
||||
}
|
||||
|
||||
/// A flat dark menu over the controls, above the widget of [context] (the
|
||||
/// button that opens it), with an optional [title] row. Resolves to the
|
||||
/// picked value, or null when dismissed (click outside, Esc). Keeps the
|
||||
/// controls up while open.
|
||||
Future<T?> showUoscMenu<T>(
|
||||
BuildContext context, {
|
||||
String? title,
|
||||
required List<UoscMenuItem<T>> items,
|
||||
}) {
|
||||
final navigator = Navigator.of(context);
|
||||
final overlay = navigator.overlay!.context.findRenderObject() as RenderBox;
|
||||
final box = context.findRenderObject() as RenderBox;
|
||||
final anchor = Rect.fromPoints(
|
||||
box.localToGlobal(Offset.zero, ancestor: overlay),
|
||||
box.localToGlobal(box.size.bottomRight(Offset.zero), ancestor: overlay),
|
||||
);
|
||||
Future<T?> open() => navigator.push<T>(_UoscMenuRoute<T>(anchor: anchor, title: title, items: items));
|
||||
final controls = UoscControls.maybeOf(context);
|
||||
return controls == null ? open() : controls.holdDuring(open);
|
||||
}
|
||||
|
||||
class _UoscMenuRoute<T> extends PopupRoute<T> {
|
||||
final Rect anchor;
|
||||
final String? title;
|
||||
final List<UoscMenuItem<T>> items;
|
||||
_UoscMenuRoute({required this.anchor, required this.title, required this.items});
|
||||
|
||||
@override
|
||||
Color? get barrierColor => null;
|
||||
|
||||
@override
|
||||
bool get barrierDismissible => true;
|
||||
|
||||
@override
|
||||
String? get barrierLabel => 'Close menu';
|
||||
|
||||
@override
|
||||
Duration get transitionDuration => Duration.zero;
|
||||
|
||||
@override
|
||||
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
|
||||
return LayoutBuilder(builder: (context, constraints) {
|
||||
const width = 360.0;
|
||||
final screen = constraints.biggest;
|
||||
final left = anchor.left.clamp(8.0, math.max(8.0, screen.width - width - 8));
|
||||
final maxHeight = math.max(120.0, anchor.top - 16);
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: left.toDouble(),
|
||||
bottom: screen.height - anchor.top + 6,
|
||||
width: math.min(width, screen.width - 16),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
child: UoscMenuPanel<T>(
|
||||
title: title,
|
||||
items: items,
|
||||
onPick: (v) => Navigator.of(context).pop(v),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu's panel alone (tests pump it without a route).
|
||||
class UoscMenuPanel<T> extends StatefulWidget {
|
||||
final String? title;
|
||||
final List<UoscMenuItem<T>> items;
|
||||
final ValueChanged<T> onPick;
|
||||
const UoscMenuPanel({super.key, this.title, required this.items, required this.onPick});
|
||||
|
||||
static const rowHeight = 36.0;
|
||||
|
||||
@override
|
||||
State<UoscMenuPanel<T>> createState() => _UoscMenuPanelState<T>();
|
||||
}
|
||||
|
||||
class _UoscMenuPanelState<T> extends State<UoscMenuPanel<T>> {
|
||||
// Long lists open on the current row.
|
||||
late final _scroll = ScrollController(
|
||||
initialScrollOffset: math.max(0, widget.items.indexWhere((i) => i.active) - 3) * UoscMenuPanel.rowHeight);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = widget.title;
|
||||
return Material(
|
||||
key: const ValueKey('uosc-menu'),
|
||||
color: UoscStyle.panel,
|
||||
borderRadius: BorderRadius.circular(UoscStyle.radius * 2),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (title != null)
|
||||
Container(
|
||||
height: 34,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0x22FFFFFF)))),
|
||||
child: Text(title,
|
||||
style: const TextStyle(color: UoscStyle.dim, fontSize: UoscStyle.fontSize, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.all(4),
|
||||
controller: _scroll,
|
||||
itemCount: widget.items.length,
|
||||
itemExtent: UoscMenuPanel.rowHeight,
|
||||
itemBuilder: (context, i) => _Row<T>(item: widget.items[i], onPick: widget.onPick),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Row<T> extends StatefulWidget {
|
||||
final UoscMenuItem<T> item;
|
||||
final ValueChanged<T> onPick;
|
||||
const _Row({super.key, required this.item, required this.onPick});
|
||||
|
||||
@override
|
||||
State<_Row<T>> createState() => _RowState<T>();
|
||||
}
|
||||
|
||||
class _RowState<T> extends State<_Row<T>> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final item = widget.item;
|
||||
final color = item.active
|
||||
? UoscStyle.fill
|
||||
: _hover && item.enabled
|
||||
? UoscStyle.hover
|
||||
: Colors.transparent;
|
||||
final textColor = item.enabled ? UoscStyle.text : UoscStyle.dim.withValues(alpha: 0.4);
|
||||
return MouseRegion(
|
||||
cursor: item.enabled ? SystemMouseCursors.click : SystemMouseCursors.basic,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: GestureDetector(
|
||||
key: ValueKey('uosc-menu-item-${item.title}'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: item.enabled ? () => widget.onPick(item.value) : null,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(UoscStyle.radius)),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: UoscStyle.fontSize,
|
||||
fontWeight: item.active ? FontWeight.w600 : FontWeight.normal)),
|
||||
),
|
||||
if (item.hint != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
Text(item.hint!,
|
||||
style: TextStyle(
|
||||
color: item.active ? UoscStyle.text : UoscStyle.dim,
|
||||
fontSize: 12,
|
||||
fontFeatures: const [FontFeature.tabularFigures()])),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Chapters with their start times, the playing one active.
|
||||
List<UoscMenuItem<MediaChapter>> chapterMenuItems(List<MediaChapter> chapters, int? current, Duration duration) {
|
||||
final withHours = duration >= const Duration(hours: 1);
|
||||
return [
|
||||
for (final c in chapters)
|
||||
UoscMenuItem(
|
||||
value: c,
|
||||
title: c.title.isEmpty ? 'Chapter ${c.index + 1}' : c.title,
|
||||
hint: formatUoscTime(c.start, withHours: withHours),
|
||||
active: c.index == current,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// The audio or subtitle tracks ("Japanese · E-AC3 2.0 · title"), mpv's
|
||||
/// selection active; subtitles start with Off.
|
||||
List<UoscMenuItem<TrackPick>> trackMenuItems(JellyfinStreamType type, List<MpvTrack> tracks) {
|
||||
final audio = type == JellyfinStreamType.audio;
|
||||
final list = tracks.where((t) => t.type == type).toList();
|
||||
return [
|
||||
if (!audio)
|
||||
UoscMenuItem(
|
||||
value: (aid: null, sid: null, subtitlesOff: true),
|
||||
title: 'Off',
|
||||
active: !list.any((t) => t.selected),
|
||||
),
|
||||
for (final t in list)
|
||||
UoscMenuItem(
|
||||
value: audio ? (aid: t.id, sid: null, subtitlesOff: false) : (aid: null, sid: t.id, subtitlesOff: false),
|
||||
title: t.label,
|
||||
hint: '#${t.id}',
|
||||
active: t.selected,
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// Look of the experimental mpv-style controls, after the uosc theme as the
|
||||
// owner runs it: flat black panels, white icons, a muted blue fill.
|
||||
|
||||
abstract final class UoscStyle {
|
||||
/// The active menu row.
|
||||
static const fill = Color(0xFF4C6A8E);
|
||||
/// Menus and tooltips.
|
||||
static const panel = Color(0xF0101010);
|
||||
static const hover = Color(0x24FFFFFF);
|
||||
static const text = Colors.white;
|
||||
static const dim = Color(0xA6FFFFFF);
|
||||
static const radius = 4.0;
|
||||
static const fontSize = 13.0;
|
||||
}
|
||||
|
||||
/// uosc's clock: "01:51", "1:02:03" style but zero-padded ("01:02:03")
|
||||
/// once [withHours] (the media is an hour or longer).
|
||||
String formatUoscTime(Duration d, {bool withHours = false}) {
|
||||
final t = d.isNegative ? Duration.zero : d;
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
final h = t.inHours, m = t.inMinutes % 60, s = t.inSeconds % 60;
|
||||
return withHours || h > 0 ? '${two(h)}:${two(m)}:${two(s)}' : '${two(m)}:${two(s)}';
|
||||
}
|
||||
|
||||
/// Dark rounded hover tip ("Subtitles"), shown above its child.
|
||||
class UoscTooltip extends StatelessWidget {
|
||||
final String message;
|
||||
final Widget child;
|
||||
const UoscTooltip({super.key, required this.message, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (message.isEmpty) return child;
|
||||
return Tooltip(
|
||||
message: message,
|
||||
preferBelow: false,
|
||||
verticalOffset: 26,
|
||||
waitDuration: const Duration(milliseconds: 250),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: UoscStyle.panel,
|
||||
borderRadius: BorderRadius.circular(UoscStyle.radius),
|
||||
),
|
||||
textStyle: const TextStyle(color: UoscStyle.text, fontSize: 12),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A small dark box with one line of text (the timeline's hover time and
|
||||
/// chapter title).
|
||||
class UoscBox extends StatelessWidget {
|
||||
final String text;
|
||||
final Color color;
|
||||
final TextStyle style;
|
||||
const UoscBox(
|
||||
this.text, {
|
||||
super.key,
|
||||
this.color = UoscStyle.panel,
|
||||
this.style = const TextStyle(color: UoscStyle.text, fontSize: UoscStyle.fontSize),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(UoscStyle.radius)),
|
||||
child: Text(text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: style.copyWith(fontFeatures: const [FontFeature.tabularFigures()])),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,645 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../playback/media_marks.dart';
|
||||
import 'controls.dart';
|
||||
import 'style.dart';
|
||||
|
||||
// A port of uosc's timeline (`elements/Timeline.lua`, defaults from
|
||||
// `uosc.conf` / `main.lua`), style `line`, scale 1: a translucent black
|
||||
// band, the position a thin white line (no filled progress), the
|
||||
// uncached part hatched, chapter diamonds on the top edge, elapsed time
|
||||
// left and the time remaining right, the buffered seconds next to the
|
||||
// position while under the threshold, and a tooltip with the time (and
|
||||
// the chapter title) under the mouse.
|
||||
|
||||
/// uosc's defaults at scale 1.
|
||||
abstract final class UoscTimelineSpec {
|
||||
/// `timeline_size`: the band's height when expanded.
|
||||
static const size = 40.0;
|
||||
|
||||
/// `timeline_border`: band above the line, also the diamonds' border.
|
||||
static const border = 1.0;
|
||||
|
||||
/// `timeline_line_width`: the position line.
|
||||
static const lineWidth = 2.0;
|
||||
|
||||
/// `progress_size`: what is left when the controls hide.
|
||||
static const progressSize = 2.0;
|
||||
|
||||
/// `progress_line_width`: the position line once minimized.
|
||||
static const progressLineWidth = 20.0;
|
||||
|
||||
/// floor(min((size + 60) * 0.2, size * 0.96)).
|
||||
static const fontSize = 20.0;
|
||||
|
||||
/// max(floor((size - fontSize) / 2.5), 4): the times' inset.
|
||||
static const spacing = 8.0;
|
||||
|
||||
/// `text_border`: the outline of text drawn on the band.
|
||||
static const textBorder = 1.2;
|
||||
|
||||
/// `opacity.timeline` (the band) and `opacity.chapters` (diamond fill).
|
||||
static const opacity = 0.9;
|
||||
static const chaptersOpacity = 0.8;
|
||||
|
||||
/// Gap between the band and its tooltips, and between tooltips.
|
||||
static const tooltipGap = 2.0;
|
||||
|
||||
/// `buffered_time_threshold`: the cache label shows below this.
|
||||
static const bufferedThreshold = Duration(seconds: 60);
|
||||
|
||||
/// max((size + border) / 10, 3): diamond radius cap.
|
||||
static const chapterSize = (size + border) / 10;
|
||||
}
|
||||
|
||||
/// uosc's `format_time(seconds, duration)`: mpv's "HH:MM:SS", trimmed to
|
||||
/// "MM:SS" for media under an hour and "SS" under a minute; negative
|
||||
/// values keep their minus ("-01:47:30").
|
||||
String uoscClock(Duration d, Duration max) {
|
||||
final neg = d.isNegative && d.inSeconds != 0;
|
||||
final t = d.abs();
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
final full = '${two(t.inHours)}:${two(t.inMinutes % 60)}:${two(t.inSeconds % 60)}';
|
||||
final trim = max < const Duration(minutes: 1) ? 6 : (max < const Duration(hours: 1) ? 3 : 0);
|
||||
return '${neg ? '-' : ''}${full.substring(trim)}';
|
||||
}
|
||||
|
||||
/// Fraction of [total] at [d], 0..1 (0 without a duration).
|
||||
double timelineFraction(Duration d, Duration total) {
|
||||
final t = total.inMicroseconds;
|
||||
if (t <= 0) return 0;
|
||||
return (d.inMicroseconds / t).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// Where the uncached part starts (it runs to the end), as a fraction;
|
||||
/// null when nothing known is uncached. We know only the end of mpv's
|
||||
/// cache ahead ([buffered]); without it (0) uosc draws no ranges either,
|
||||
/// and a cache that reaches the end (within uosc's 0.5 s) leaves none.
|
||||
double? uncachedFrom({required Duration position, required Duration buffered, required Duration duration}) {
|
||||
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
|
||||
final from = buffered > position ? buffered : position;
|
||||
if (duration - from <= const Duration(milliseconds: 500)) return null;
|
||||
return timelineFraction(from, duration);
|
||||
}
|
||||
|
||||
/// The cache label next to the position ("12s"): the seconds buffered
|
||||
/// ahead, shown while under [UoscTimelineSpec.bufferedThreshold] and the
|
||||
/// cache does not reach the end (uosc drops it at eof-cached).
|
||||
String? cacheLabel({required Duration position, required Duration buffered, required Duration duration}) {
|
||||
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
|
||||
if (duration - buffered <= const Duration(milliseconds: 500)) return null;
|
||||
final ahead = buffered > position ? buffered - position : Duration.zero;
|
||||
if (ahead >= UoscTimelineSpec.bufferedThreshold) return null;
|
||||
return '${(ahead.inMilliseconds / 1000).round()}s';
|
||||
}
|
||||
|
||||
/// The band's geometry at one size, as uosc lays it out: the position
|
||||
/// line takes [lineWidth] pixels, and times map around it (`t2x`) so no
|
||||
/// time lies inside the line.
|
||||
class UoscTimelineGeometry {
|
||||
final double width;
|
||||
|
||||
/// The foreground height (without the top border).
|
||||
final double size;
|
||||
|
||||
/// The playing position, 0..1.
|
||||
final double progress;
|
||||
final double lineWidth;
|
||||
const UoscTimelineGeometry({
|
||||
required this.width,
|
||||
required this.size,
|
||||
required this.progress,
|
||||
this.lineWidth = UoscTimelineSpec.lineWidth,
|
||||
});
|
||||
|
||||
/// [size] while the band animates between hidden (0) and shown (1)
|
||||
/// (`get_effective_size`).
|
||||
static double sizeAt(double visibility) =>
|
||||
UoscTimelineSpec.progressSize +
|
||||
((UoscTimelineSpec.size - UoscTimelineSpec.progressSize) * visibility).ceilToDouble();
|
||||
|
||||
/// The position line's width at [size]: it grows to
|
||||
/// [UoscTimelineSpec.progressLineWidth] over the last eighth of the
|
||||
/// collapse.
|
||||
static double lineWidthAt(double size) {
|
||||
const p = UoscTimelineSpec.progressSize;
|
||||
final minimized = 1 - math.min((size - p) / ((UoscTimelineSpec.size - p) / 8), 1.0);
|
||||
return UoscTimelineSpec.lineWidth + (UoscTimelineSpec.progressLineWidth - UoscTimelineSpec.lineWidth) * minimized;
|
||||
}
|
||||
|
||||
double get height => size + UoscTimelineSpec.border;
|
||||
|
||||
/// Top of the foreground (below the border).
|
||||
double get top => UoscTimelineSpec.border;
|
||||
double get lineLeft => ((width - lineWidth) * progress).roundToDouble();
|
||||
double get lineRight => ((width - lineWidth) * progress + lineWidth).roundToDouble();
|
||||
|
||||
/// x of [fraction] (uosc's `t2x`): past the position, shifted over the line.
|
||||
double x(double fraction) {
|
||||
final lw = lineWidth - 1;
|
||||
final at = 0.5 + (width - lw - 1) * fraction;
|
||||
return fraction <= progress ? at : at + lw;
|
||||
}
|
||||
|
||||
/// The fraction at [x] (uosc's `get_time_at_x`): x on the line is the
|
||||
/// position.
|
||||
double fractionAt(double x) {
|
||||
const lw = UoscTimelineSpec.lineWidth - 1;
|
||||
final timeWidth = width - lw - 1;
|
||||
if (timeWidth <= 0) return 0;
|
||||
final a = timeWidth * progress, b = a + lw;
|
||||
var at = x - 0.5;
|
||||
if (at > b) {
|
||||
at -= lw;
|
||||
} else if (at > a) {
|
||||
at = a;
|
||||
}
|
||||
return (at / timeWidth).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// Chapter diamond radius at this size; doubled while hovered.
|
||||
double get diamondRadius => math.min(math.max(1.0, size * 0.8), UoscTimelineSpec.chapterSize);
|
||||
}
|
||||
|
||||
/// The uosc timeline. [expanded] false (the controls hidden) shrinks it,
|
||||
/// animated, to the progress line. Seeks ([onSeek]) on press, on every
|
||||
/// drag move and on release; a press on a hovered chapter diamond jumps
|
||||
/// to that chapter. The session sends those through the user-seek path.
|
||||
class UoscTimeline extends StatefulWidget {
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
|
||||
/// End of the cached range ([PlaybackController.buffered]).
|
||||
final Duration buffered;
|
||||
final List<MediaChapter> chapters;
|
||||
final bool expanded;
|
||||
final ValueChanged<Duration> onSeek;
|
||||
const UoscTimeline({
|
||||
super.key,
|
||||
required this.position,
|
||||
required this.duration,
|
||||
this.buffered = Duration.zero,
|
||||
this.chapters = const [],
|
||||
this.expanded = true,
|
||||
required this.onSeek,
|
||||
});
|
||||
|
||||
static const height = UoscTimelineSpec.size + UoscTimelineSpec.border;
|
||||
static const collapsedHeight = UoscTimelineSpec.progressSize + UoscTimelineSpec.border;
|
||||
|
||||
/// uosc's `animation_duration`.
|
||||
static const animation = Duration(milliseconds: 100);
|
||||
|
||||
@override
|
||||
State<UoscTimeline> createState() => _UoscTimelineState();
|
||||
}
|
||||
|
||||
class _UoscTimelineState extends State<UoscTimeline> {
|
||||
Offset? _hover;
|
||||
bool _dragging = false;
|
||||
double _dragFraction = 0;
|
||||
Duration? _lastSent;
|
||||
// A released seek, shown until the position catches up (else the line
|
||||
// jumps back to the old position for a moment).
|
||||
double? _pending;
|
||||
Timer? _pendingTimer;
|
||||
|
||||
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
|
||||
|
||||
void _send(Duration d) {
|
||||
if (d == _lastSent) return;
|
||||
_lastSent = d;
|
||||
widget.onSeek(d);
|
||||
}
|
||||
|
||||
void _drag(bool on) {
|
||||
if (_dragging == on) return;
|
||||
setState(() => _dragging = 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
|
||||
void didUpdateWidget(UoscTimeline old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (!widget.expanded && old.expanded) _hover = null;
|
||||
final p = _pending;
|
||||
if (p != null && widget.duration > Duration.zero) {
|
||||
final off = (widget.position - _at(p)).abs();
|
||||
if (off < const Duration(milliseconds: 1500)) {
|
||||
_pending = null;
|
||||
_pendingTimer?.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pendingTimer?.cancel();
|
||||
// A drag cut short by a rebuild must not keep the controls up.
|
||||
if (_dragging) UoscControls.maybeOf(context)?.hold(false);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// The chapter whose diamond is within twice its radius of [at].
|
||||
int? _hoveredChapter(UoscTimelineGeometry geo, List<MediaChapter> marks, Offset? at) {
|
||||
if (at == null || _dragging) return null;
|
||||
final reach = geo.diamondRadius * 2;
|
||||
int? best;
|
||||
var bestD = double.infinity;
|
||||
for (var i = 0; i < marks.length; i++) {
|
||||
final cx = geo.x(timelineFraction(marks[i].start, widget.duration));
|
||||
final d = (Offset(cx, 0) - at).distance;
|
||||
if (d <= reach && d < bestD) {
|
||||
best = i;
|
||||
bestD = d;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final font = DefaultTextStyle.of(context).style.fontFamily ?? Theme.of(context).textTheme.bodyMedium?.fontFamily;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: widget.expanded ? 1 : 0),
|
||||
duration: UoscTimeline.animation,
|
||||
builder: (context, visibility, _) => _band(width, visibility, font),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _band(double width, double visibility, String? font) {
|
||||
final expanded = widget.expanded;
|
||||
final duration = widget.duration;
|
||||
final size = UoscTimelineGeometry.sizeAt(visibility);
|
||||
final progress = _dragging ? _dragFraction : (_pending ?? timelineFraction(widget.position, duration));
|
||||
final shown = _at(progress);
|
||||
final geo = UoscTimelineGeometry(
|
||||
width: width,
|
||||
size: size,
|
||||
progress: progress,
|
||||
lineWidth: UoscTimelineGeometry.lineWidthAt(size),
|
||||
);
|
||||
final marks = duration > Duration.zero ? widget.chapters : const <MediaChapter>[];
|
||||
final hover = expanded ? _hover : null;
|
||||
final hovered = _hoveredChapter(geo, marks, hover);
|
||||
final hoverFraction = hovered != null
|
||||
? timelineFraction(marks[hovered].start, duration)
|
||||
: (hover == null || duration <= Duration.zero ? null : geo.fractionAt(hover.dx));
|
||||
final hoverAt = hoverFraction == null ? null : _at(hoverFraction);
|
||||
final cursorX = hovered != null ? geo.x(hoverFraction!) : hover?.dx;
|
||||
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
|
||||
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
|
||||
// Text fades out just before it would overflow the shrinking band.
|
||||
const hideBelow = UoscTimelineSpec.fontSize * 0.8;
|
||||
final textOpacity = (size - hideBelow).clamp(0.0, hideBelow / 2) / (hideBelow / 2);
|
||||
final radius = geo.diamondRadius;
|
||||
final timestampGap = UoscTimelineSpec.tooltipGap + (hovered != null ? radius * 2 : radius).roundToDouble();
|
||||
final painter = UoscTimelinePainter(
|
||||
geometry: geo,
|
||||
visibility: visibility,
|
||||
uncached: uncachedFrom(position: widget.position, buffered: widget.buffered, duration: duration),
|
||||
chapters: [for (final c in marks) timelineFraction(c.start, duration)],
|
||||
hoveredChapter: hovered,
|
||||
hoverX: cursorX,
|
||||
textOpacity: textOpacity,
|
||||
elapsed: duration > Duration.zero ? uoscClock(shown, duration) : null,
|
||||
remaining: duration > Duration.zero ? uoscClock(shown - duration, duration) : null,
|
||||
cache: cacheLabel(position: widget.position, buffered: widget.buffered, duration: duration),
|
||||
fontFamily: font,
|
||||
);
|
||||
final bar = SizedBox(
|
||||
key: const ValueKey('uosc-timeline'),
|
||||
width: width,
|
||||
height: geo.height,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: RepaintBoundary(child: CustomPaint(painter: painter)),
|
||||
),
|
||||
if (hoverAt != null && cursorX != null)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: geo.height + timestampGap,
|
||||
height: 160,
|
||||
child: IgnorePointer(
|
||||
child: CustomMultiChildLayout(
|
||||
delegate: _TooltipLayout(x: cursorX),
|
||||
children: [
|
||||
LayoutId(
|
||||
id: #time,
|
||||
child: UoscBox(
|
||||
uoscClock(hoverAt, duration),
|
||||
key: const ValueKey('uosc-hover-time'),
|
||||
color: Colors.black,
|
||||
style: _tipStyle,
|
||||
),
|
||||
),
|
||||
if (hoverTitle.isNotEmpty)
|
||||
LayoutId(
|
||||
id: #chapter,
|
||||
child: UoscBox(
|
||||
hoverTitle,
|
||||
key: const ValueKey('uosc-hover-chapter'),
|
||||
color: Colors.black,
|
||||
style: _tipStyle.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (!expanded) return IgnorePointer(child: bar);
|
||||
Offset clampTo(Offset p) => Offset(p.dx.clamp(0.0, width), p.dy);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (e) => setState(() => _hover = clampTo(e.localPosition)),
|
||||
onHover: (e) => setState(() => _hover = clampTo(e.localPosition)),
|
||||
onExit: (_) {
|
||||
if (!_dragging) setState(() => _hover = null);
|
||||
},
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: (e) {
|
||||
if (duration <= Duration.zero || width <= 0) return;
|
||||
final at = clampTo(e.localPosition);
|
||||
final chapter = _hoveredChapter(geo, marks, at);
|
||||
_lastSent = null;
|
||||
if (chapter != null) {
|
||||
final f = timelineFraction(marks[chapter].start, duration);
|
||||
_settle(f);
|
||||
_send(marks[chapter].start);
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
_pending = null;
|
||||
_dragFraction = geo.fractionAt(at.dx);
|
||||
_drag(true);
|
||||
_send(_at(_dragFraction));
|
||||
},
|
||||
onPointerMove: (e) {
|
||||
if (!_dragging) return;
|
||||
final at = clampTo(e.localPosition);
|
||||
setState(() {
|
||||
_hover = at;
|
||||
_dragFraction = geo.fractionAt(at.dx);
|
||||
});
|
||||
_send(_at(_dragFraction));
|
||||
},
|
||||
onPointerUp: (e) {
|
||||
if (!_dragging) return;
|
||||
_send(_at(_dragFraction));
|
||||
_settle(_dragFraction);
|
||||
final p = e.localPosition;
|
||||
if (p.dx < 0 || p.dx > width || p.dy < 0 || p.dy > geo.height) _hover = null;
|
||||
_drag(false);
|
||||
},
|
||||
onPointerCancel: (_) => _drag(false),
|
||||
child: bar,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static const _tipStyle = TextStyle(color: UoscStyle.text, fontSize: UoscTimelineSpec.fontSize, height: 1);
|
||||
}
|
||||
|
||||
/// The hover tooltips: the time centred on [x], the chapter title over
|
||||
/// it; each kept [UoscTimelineSpec.tooltipGap] inside the band's ends.
|
||||
class _TooltipLayout extends MultiChildLayoutDelegate {
|
||||
final double x;
|
||||
_TooltipLayout({required this.x});
|
||||
|
||||
@override
|
||||
void performLayout(Size size) {
|
||||
const gap = UoscTimelineSpec.tooltipGap;
|
||||
double left(double w) => (x - w / 2).clamp(gap, math.max(gap, size.width - gap - w));
|
||||
final loose = BoxConstraints.loose(size);
|
||||
final time = layoutChild(#time, loose);
|
||||
final timeTop = size.height - time.height;
|
||||
positionChild(#time, Offset(left(time.width), timeTop));
|
||||
if (hasChild(#chapter)) {
|
||||
final chapter = layoutChild(#chapter, loose);
|
||||
positionChild(#chapter, Offset(left(chapter.width), timeTop - gap - chapter.height));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_TooltipLayout old) => old.x != x;
|
||||
}
|
||||
|
||||
/// Paints the band as uosc does: background, the position line, the
|
||||
/// uncached hatching, chapter diamonds, the times, the hover line.
|
||||
class UoscTimelinePainter extends CustomPainter {
|
||||
final UoscTimelineGeometry geometry;
|
||||
|
||||
/// 0 hidden .. 1 shown (picks the hatching texture and its strength).
|
||||
final double visibility;
|
||||
|
||||
/// Start of the uncached part (to the end), as a fraction.
|
||||
final double? uncached;
|
||||
|
||||
/// Chapter starts, as fractions.
|
||||
final List<double> chapters;
|
||||
final int? hoveredChapter;
|
||||
|
||||
/// The hover line (the mouse, or the hovered chapter).
|
||||
final double? hoverX;
|
||||
final double textOpacity;
|
||||
final String? elapsed;
|
||||
final String? remaining;
|
||||
final String? cache;
|
||||
final String? fontFamily;
|
||||
const UoscTimelinePainter({
|
||||
required this.geometry,
|
||||
this.visibility = 1,
|
||||
this.uncached,
|
||||
this.chapters = const [],
|
||||
this.hoveredChapter,
|
||||
this.hoverX,
|
||||
this.textOpacity = 1,
|
||||
this.elapsed,
|
||||
this.remaining,
|
||||
this.cache,
|
||||
this.fontFamily,
|
||||
});
|
||||
|
||||
double get played => geometry.progress;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final g = geometry;
|
||||
final w = size.width, bottom = size.height, top = g.top;
|
||||
final line = Rect.fromLTRB(g.lineLeft, top, g.lineRight, bottom);
|
||||
// Background (`opacity.timeline`) around the position line.
|
||||
final bg = Path()
|
||||
..fillType = PathFillType.evenOdd
|
||||
..addRect(Offset.zero & size)
|
||||
..addRect(line);
|
||||
canvas.drawPath(bg, Paint()..color = Colors.black.withValues(alpha: UoscTimelineSpec.opacity));
|
||||
canvas.drawRect(line, Paint()..color = Colors.white);
|
||||
_hatch(canvas, w, top, bottom);
|
||||
_chapters(canvas);
|
||||
if (textOpacity > 0) _times(canvas, w, top, bottom, line);
|
||||
final x = hoverX;
|
||||
if (x != null) {
|
||||
final onLine = line.left - 0.5 < x && x < line.right + 0.5;
|
||||
canvas.drawRect(
|
||||
Rect.fromLTRB(x - 0.5, top, x + 0.5, bottom),
|
||||
Paint()..color = (onLine ? Colors.black : Colors.white).withValues(alpha: 0.33),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// uosc's texture "b" (shown) or "a" (hidden) at 80 px: "/" stripes
|
||||
/// 1.6 px thick, white then black shifted by size / 24 (or / 28).
|
||||
void _hatch(Canvas canvas, double w, double top, double bottom) {
|
||||
final from = uncached;
|
||||
if (from == null) return;
|
||||
final ax = from * (geometry.width) < 0.5 ? 0.0 : geometry.x(from).floorToDouble();
|
||||
if (ax >= w) return;
|
||||
final shown = visibility > 0;
|
||||
const tile = 80.0, unit = tile / 100;
|
||||
final period = (shown ? 100 / 12 : 100 / 14) * unit;
|
||||
final phase = shown ? 0.0 : 5.727 * unit;
|
||||
final offset = tile / (shown ? 24 : 28);
|
||||
final h = bottom - top;
|
||||
canvas.save();
|
||||
canvas.clipRect(Rect.fromLTRB(ax, top, w, bottom));
|
||||
void stripes(Color color, double opacity, double anchor) {
|
||||
final paint = Paint()
|
||||
..color = color.withValues(alpha: opacity)
|
||||
..strokeWidth = 2 * unit;
|
||||
// x + (y - bottom) = anchor + phase + k * period.
|
||||
final base = anchor + phase;
|
||||
final k0 = ((ax - h - base) / period).floor() - 1;
|
||||
for (var k = k0; base + k * period <= w + 1; k++) {
|
||||
final c = base + k * period;
|
||||
canvas.drawLine(Offset(c, bottom), Offset(c + h, top), paint);
|
||||
}
|
||||
}
|
||||
|
||||
stripes(Colors.white, 0.4 - 0.2 * visibility, 0);
|
||||
stripes(Colors.black, 0.6 - 0.2 * visibility, offset);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
void _chapters(Canvas canvas) {
|
||||
final g = geometry;
|
||||
final r = g.diamondRadius;
|
||||
if (r <= 0) return;
|
||||
void diamond(double fraction, double radius) {
|
||||
final c = Offset(g.x(fraction), 0);
|
||||
Path shape(double r) => Path()
|
||||
..moveTo(c.dx - r, c.dy)
|
||||
..lineTo(c.dx, c.dy - r)
|
||||
..lineTo(c.dx + r, c.dy)
|
||||
..lineTo(c.dx, c.dy + r)
|
||||
..close();
|
||||
final inner = shape(radius);
|
||||
// A 1 px border outside the fill (`\bord`), the fill translucent.
|
||||
final outer = shape(radius + UoscTimelineSpec.border * math.sqrt2);
|
||||
canvas.drawPath(Path.combine(PathOperation.difference, outer, inner), Paint()..color = Colors.black);
|
||||
canvas.drawPath(inner, Paint()..color = Colors.white.withValues(alpha: UoscTimelineSpec.chaptersOpacity));
|
||||
}
|
||||
|
||||
for (var i = 0; i < chapters.length; i++) {
|
||||
if (i != hoveredChapter) diamond(chapters[i], r);
|
||||
}
|
||||
final h = hoveredChapter;
|
||||
if (h != null && h < chapters.length) diamond(chapters[h], r * 2);
|
||||
}
|
||||
|
||||
TextPainter _text(String s, double fontSize, Color color, {Paint? foreground}) => TextPainter(
|
||||
text: TextSpan(
|
||||
text: s,
|
||||
style: TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: fontSize,
|
||||
height: 1,
|
||||
color: foreground == null ? color : null,
|
||||
foreground: foreground,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
|
||||
/// Text on the band: white with a black outline, inverted where it
|
||||
/// crosses the position line (uosc clips one copy to each).
|
||||
void _label(Canvas canvas, String s, double fontSize, double opacity, Offset at, Rect line, Size size) {
|
||||
void copy(Color fill, Color outline) {
|
||||
_text(
|
||||
s,
|
||||
fontSize,
|
||||
fill,
|
||||
foreground: Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = UoscTimelineSpec.textBorder * 2
|
||||
..strokeJoin = StrokeJoin.round
|
||||
..color = outline.withValues(alpha: opacity),
|
||||
).paint(canvas, at);
|
||||
_text(s, fontSize, fill.withValues(alpha: opacity)).paint(canvas, at);
|
||||
}
|
||||
|
||||
canvas.save();
|
||||
canvas.clipPath(
|
||||
Path()
|
||||
..fillType = PathFillType.evenOdd
|
||||
..addRect(Rect.fromLTWH(-20, -20, size.width + 40, size.height + 40))
|
||||
..addRect(line),
|
||||
);
|
||||
copy(Colors.white, Colors.black);
|
||||
canvas.restore();
|
||||
canvas.save();
|
||||
canvas.clipRect(line);
|
||||
copy(Colors.black, Colors.white);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
void _times(Canvas canvas, double w, double top, double bottom, Rect line) {
|
||||
const fs = UoscTimelineSpec.fontSize, spacing = UoscTimelineSpec.spacing;
|
||||
final size = Size(w, bottom);
|
||||
final cy = top + (bottom - top) / 2;
|
||||
final e = elapsed, r = remaining;
|
||||
final ew = e == null ? 0.0 : _text(e, fs, Colors.white).width;
|
||||
final rw = r == null ? 0.0 : _text(r, fs, Colors.white).width;
|
||||
final c = cache;
|
||||
if (c != null) {
|
||||
const cfs = fs * 0.8, margin = 5.0;
|
||||
final cw = _text(c, cfs, Colors.white).width;
|
||||
var x = line.right + margin;
|
||||
final minX = spacing + margin + ew, maxX = w - spacing - margin - rw;
|
||||
if (x < minX) {
|
||||
x = minX;
|
||||
} else if (x + cw > maxX) {
|
||||
x = maxX - cw;
|
||||
}
|
||||
_label(canvas, c, cfs, textOpacity * 0.6, Offset(x, cy - cfs / 2), line, size);
|
||||
}
|
||||
if (e != null) _label(canvas, e, fs, textOpacity, Offset(spacing, cy - fs / 2), line, size);
|
||||
if (r != null) _label(canvas, r, fs, textOpacity, Offset(w - spacing - rw, cy - fs / 2), line, size);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(UoscTimelinePainter old) => true;
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -3,7 +3,6 @@ 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;
|
||||
@@ -90,7 +89,7 @@ class VolumeFlash extends StatefulWidget {
|
||||
final WidgetBuilder builder;
|
||||
const VolumeFlash({super.key, required this.trigger, required this.builder});
|
||||
|
||||
/// uosc's `flash_duration`.
|
||||
/// How long the readout stays after the last step.
|
||||
static const hold = Duration(seconds: 1);
|
||||
|
||||
@override
|
||||
@@ -142,7 +141,7 @@ class _VolumeFlashState extends State<VolumeFlash> {
|
||||
);
|
||||
}
|
||||
|
||||
/// The standard design's volume readout for [VolumeFlash]: a glass pill
|
||||
/// The volume readout for [VolumeFlash] (both skins): a glass pill
|
||||
/// (the player's pill look) with the icon, a [VolumeBar] and the
|
||||
/// percentage.
|
||||
class VolumeReadout extends StatelessWidget {
|
||||
@@ -159,11 +158,7 @@ class VolumeReadout extends StatelessWidget {
|
||||
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,
|
||||
),
|
||||
Icon(volumeIcon(v), size: 20, color: v > 100 ? boostColor : Colors.white),
|
||||
const SizedBox(width: 8),
|
||||
VolumeBar(volume: v, onChanged: (_) {}, width: 96),
|
||||
const SizedBox(width: 10),
|
||||
@@ -246,64 +241,82 @@ class _VolumePainter extends CustomPainter {
|
||||
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});
|
||||
/// The speaker icon for [volume].
|
||||
IconData volumeIcon(double volume) =>
|
||||
volume <= 0 ? Icons.volume_off_rounded : (volume < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded);
|
||||
|
||||
/// Builds a skin's button for the [VolumeControl].
|
||||
typedef VolumeButtonBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
IconData icon,
|
||||
Color? color,
|
||||
String tooltip,
|
||||
VoidCallback onPressed,
|
||||
);
|
||||
|
||||
/// The bar's volume, in both skins: the skin's speaker button (click
|
||||
/// mutes / unmutes), a [VolumeBar] sliding out on hover, the wheel on it
|
||||
/// stepping [volumeStep] (claimed, so the player's wheel skips it).
|
||||
class VolumeControl extends StatefulWidget {
|
||||
/// 0..[maxVolume].
|
||||
final double volume;
|
||||
final ValueChanged<double> onChanged;
|
||||
final VoidCallback onToggleMute;
|
||||
final VolumeButtonBuilder button;
|
||||
final Color sliderColor;
|
||||
final Color trackColor;
|
||||
final double sliderWidth;
|
||||
const VolumeControl({
|
||||
super.key,
|
||||
required this.volume,
|
||||
required this.onChanged,
|
||||
required this.onToggleMute,
|
||||
required this.button,
|
||||
this.sliderColor = Colors.white,
|
||||
this.trackColor = Colors.white30,
|
||||
this.sliderWidth = 72,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VolumeButton> createState() => _VolumeButtonState();
|
||||
State<VolumeControl> createState() => _VolumeControlState();
|
||||
}
|
||||
|
||||
class _VolumeButtonState extends State<VolumeButton> {
|
||||
class _VolumeControlState extends State<VolumeControl> {
|
||||
final _steps = WheelSteps();
|
||||
bool _hover = false;
|
||||
double _beforeMute = 100;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final player = widget.player;
|
||||
return StreamBuilder<double>(
|
||||
stream: player.stream.volume,
|
||||
initialData: player.state.volume,
|
||||
builder: (context, snap) {
|
||||
final v = snap.data ?? 100;
|
||||
final v = widget.volume;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: Listener(
|
||||
// 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))),
|
||||
onPointerSignal: (e) => _steps.handle(e, (step) => widget.onChanged(stepVolume(v, step * volumeStep))),
|
||||
child: Row(
|
||||
key: const ValueKey('volume-control'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
key: const ValueKey('volume-button'),
|
||||
focusNode: widget.focusNode,
|
||||
tooltip: 'Volume ${v.round()} %',
|
||||
iconSize: 22,
|
||||
color: Colors.white,
|
||||
onPressed: () {
|
||||
if (v > 0) {
|
||||
_beforeMute = v;
|
||||
player.setVolume(0);
|
||||
} else {
|
||||
player.setVolume(_beforeMute <= 0 ? 100 : _beforeMute);
|
||||
}
|
||||
},
|
||||
icon: Icon(v <= 0 ? Icons.volume_off : (v < 50 ? Icons.volume_down : Icons.volume_up)),
|
||||
widget.button(
|
||||
context,
|
||||
volumeIcon(v),
|
||||
v > 100 ? boostColor : null,
|
||||
'Volume ${v.round()} % (scroll, ↑ / ↓)',
|
||||
widget.onToggleMute,
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: _hover
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 4, right: 12),
|
||||
child: VolumeBar(volume: v, onChanged: player.setVolume),
|
||||
padding: const EdgeInsets.only(left: 4, right: 8),
|
||||
child: VolumeBar(
|
||||
key: const ValueKey('volume-bar'),
|
||||
volume: v,
|
||||
onChanged: widget.onChanged,
|
||||
width: widget.sliderWidth,
|
||||
color: widget.sliderColor,
|
||||
trackColor: widget.trackColor,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
@@ -311,7 +324,5 @@ class _VolumeButtonState extends State<VolumeButton> {
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,15 +33,36 @@ const _timeout = Duration(seconds: 20);
|
||||
int _running = 0;
|
||||
final _waiting = <Completer<void>>[];
|
||||
final _inFlight = <String, Future<Thumbnail?>>{};
|
||||
final _players = <Player>{};
|
||||
bool _closed = false;
|
||||
|
||||
/// App close: no new thumbnails, running ones stop at their next check
|
||||
/// and dispose their player. Players still up after [deadline] are
|
||||
/// disposed here, so no mpv outlives the engine.
|
||||
Future<void> cancelAllThumbnails({
|
||||
Duration deadline = const Duration(milliseconds: 800),
|
||||
}) async {
|
||||
_closed = true;
|
||||
try {
|
||||
await Future.wait(_inFlight.values.toList()).timeout(deadline);
|
||||
} catch (_) {}
|
||||
for (final p in _players.toList()) {
|
||||
_players.remove(p);
|
||||
try {
|
||||
await p.dispose().timeout(deadline);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The cached or freshly made thumbnail of the video at [path], or null
|
||||
/// when it can't be made (not a video, unreadable, timed out, cancelled).
|
||||
/// Never throws. [cancelled] is checked while queued and before the
|
||||
/// expensive steps: a scrolled-away tile returns null without decoding.
|
||||
Future<Thumbnail?> thumbnailFor(String path, {bool Function()? cancelled}) {
|
||||
if (_closed) return Future.value(null);
|
||||
final existing = _inFlight[path];
|
||||
if (existing != null) return existing;
|
||||
final f = _thumbnailFor(path, cancelled ?? () => false).catchError((
|
||||
final f = _thumbnailFor(path, () => _closed || (cancelled?.call() ?? false)).catchError((
|
||||
Object e,
|
||||
StackTrace st,
|
||||
) {
|
||||
@@ -136,6 +157,7 @@ Future<Thumbnail?> _generate(
|
||||
bufferSize: 4 << 20,
|
||||
),
|
||||
);
|
||||
_players.add(player);
|
||||
try {
|
||||
final mpv = player.platform as NativePlayer;
|
||||
for (final (k, v) in [
|
||||
@@ -176,6 +198,7 @@ Future<Thumbnail?> _generate(
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
await player.dispose();
|
||||
// [cancelAllThumbnails] may have taken it already.
|
||||
if (_players.remove(player)) await player.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../../shell/motion.dart';
|
||||
import '../../platform/ports/folder_access.dart';
|
||||
import '../../state/session_state.dart';
|
||||
import '../../widgets/app_menu.dart';
|
||||
import '../folder_store.dart';
|
||||
import '../name_parser.dart';
|
||||
@@ -32,6 +33,8 @@ String? _lastOpen;
|
||||
final _expanded = <String>{};
|
||||
final _showRequests = ValueNotifier(0);
|
||||
|
||||
FolderAccess get _folders => SessionState.instance.ports.folders;
|
||||
|
||||
/// Opens [dir] (null: Recent) in the Files page: now if one is showing,
|
||||
/// else when it next opens.
|
||||
void showFilesFolder(String? dir) {
|
||||
@@ -60,7 +63,7 @@ class FilesPage extends StatefulWidget {
|
||||
final String? nowPlayingPath;
|
||||
/// Defaults to [thumbnailFor].
|
||||
final ThumbnailLoader? thumbnails;
|
||||
/// Defaults to [scanFolder].
|
||||
/// Defaults to [FolderAccess.scan].
|
||||
final Future<FolderScan> Function(String dir)? scan;
|
||||
/// Defaults to [FolderStore.instance].
|
||||
final FolderStore? store;
|
||||
@@ -142,7 +145,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
|
||||
void _shown() => _select(_lastOpen);
|
||||
|
||||
Future<FolderScan> _runScan(String dir) => (widget.scan ?? scanFolder)(dir);
|
||||
Future<FolderScan> _runScan(String dir) => (widget.scan ?? _folders.scan)(dir);
|
||||
|
||||
void _select(String? dir) {
|
||||
setState(() {
|
||||
@@ -176,7 +179,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
String? dir;
|
||||
try {
|
||||
dir = await (widget.pickFolder ??
|
||||
() => FilePicker.getDirectoryPath(dialogTitle: 'Add a folder with videos'))();
|
||||
() => _folders.pickFolder(dialogTitle: 'Add a folder with videos'))();
|
||||
} catch (e) {
|
||||
_showError('Folder dialog unavailable (${e.runtimeType})');
|
||||
}
|
||||
@@ -196,12 +199,8 @@ class _FilesPageState extends State<FilesPage> {
|
||||
}
|
||||
|
||||
static Future<String?> _pickVideo() async {
|
||||
final files = await FilePicker.pickFiles(
|
||||
dialogTitle: 'Pick a video',
|
||||
type: FileType.custom,
|
||||
allowedExtensions: videoExtensions.toList(),
|
||||
);
|
||||
return files.isEmpty ? null : files.first.path;
|
||||
final files = await _folders.pickFiles(dialogTitle: 'Pick a video', extensions: videoExtensions.toList());
|
||||
return files.firstOrNull;
|
||||
}
|
||||
|
||||
void _removeFolder(String dir) {
|
||||
@@ -589,7 +588,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
),
|
||||
if (scan.videos.isEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Directory(dir).existsSync()
|
||||
child: _folders.folderExists(dir)
|
||||
? const FilesEmpty(
|
||||
Icons.search_off,
|
||||
'No videos here',
|
||||
@@ -701,7 +700,7 @@ class _RecentRowState extends State<_RecentRow> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
File(widget.path).stat().then((s) {
|
||||
_folders.stat(widget.path).then((s) {
|
||||
if (mounted) setState(() => _stat = s);
|
||||
}, onError: (Object _) {});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../screens/home/picked_media.dart';
|
||||
import '../session/party.dart' show isPresent;
|
||||
import 'accounts.dart';
|
||||
import 'format.dart';
|
||||
|
||||
@@ -333,13 +334,13 @@ Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? ending
|
||||
|
||||
/// Who performs the auto-advance swap, so several counting clients do not
|
||||
/// swap twice: the lowest member id among members that play the item with
|
||||
/// their own account (`jf[serverId] == true`, not stale, not out). Only what every
|
||||
/// their own account (`jf[serverId] == true`, present: `in` or joining). Only what every
|
||||
/// client sees in `state` decides (who put the item up is not in it: a
|
||||
/// late joiner never saw that swap), so all clients agree.
|
||||
String? autoAdvanceSwapper(List<MemberView> members, String serverId) {
|
||||
final eligible = [
|
||||
for (final m in members)
|
||||
if (!m.stale && !m.out && m.jf[serverId] == true) m
|
||||
if (isPresent(m) && m.jf[serverId] == true) m
|
||||
];
|
||||
if (eligible.isEmpty) return null;
|
||||
eligible.sort((a, b) => compareMemberIds(a.id, b.id));
|
||||
|
||||
@@ -2,11 +2,10 @@ 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 'ports/app_dirs.dart';
|
||||
import '../state/session_state.dart';
|
||||
|
||||
// The app's log on disk (<app support>/logs/app-YYYY-MM-DD.log; core's
|
||||
@@ -18,13 +17,11 @@ 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 {
|
||||
/// Opens the log files in [dirs]' log folder and routes `debugPrint` and
|
||||
/// uncaught errors into the log.
|
||||
Future<void> startAppLogging(AppDirs dirs) async {
|
||||
try {
|
||||
final base = await getApplicationSupportDirectory();
|
||||
final dir = '${base.path}${Platform.pathSeparator}logs';
|
||||
if (startLogFiles(dir) != null) appLogDir = dir;
|
||||
if (startLogFiles(dirs.logs) != null) appLogDir = dirs.logs;
|
||||
} catch (e) {
|
||||
_appLog.warning('no log files: $e');
|
||||
}
|
||||
@@ -40,7 +37,13 @@ Future<void> startAppLogging() async {
|
||||
return false;
|
||||
};
|
||||
_appLog.info('$appName ${displayVersion()} on ${Platform.operatingSystem} '
|
||||
'${Platform.operatingSystemVersion}, pid $pid, Dart ${Platform.version.split(' ').first}');
|
||||
'${Platform.operatingSystemVersion}, pid $pid, Dart ${Platform.version.split(' ').first}'
|
||||
'${dirs.profile == null ? '' : ', profile ${dirs.profile}'}');
|
||||
for (final line in dirs.migrated) {
|
||||
_appLog.info(line);
|
||||
}
|
||||
final bad = AppDirs.invalidProfile(Platform.environment);
|
||||
if (bad != null) _appLog.warning('$profileEnv="$bad" ignored: use letters, digits, - and _ (at most 32)');
|
||||
}
|
||||
|
||||
/// The text "Copy diagnostics" gives: versions, then the last [since] of
|
||||
@@ -59,7 +62,7 @@ Future<String> diagnosticsText({Duration since = const Duration(hours: 6), bool
|
||||
b.writeln('mpv: unknown');
|
||||
}
|
||||
try {
|
||||
final y = await AppYtDlp.instance.ytDlp.timeout(const Duration(seconds: 2));
|
||||
final y = await SessionState.instance.ports.links.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'}');
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../files/thumbnails.dart';
|
||||
import '../state/session_state.dart';
|
||||
|
||||
// The one way out of the app (close button, Alt+F4, Update and restart).
|
||||
// Order matters: every mpv player goes before the engine does (a live one
|
||||
// calls a dead Dart callback), then the network goodbye, then the window
|
||||
// closes through the platform's normal path so the engine is torn down
|
||||
// inside the message loop. The runners end the process right after
|
||||
// (`linux/runner/main.cc`, `windows/runner/main.cpp`).
|
||||
|
||||
bool _shuttingDown = false;
|
||||
|
||||
/// True once [shutDownApp] started.
|
||||
bool get shuttingDown => _shuttingDown;
|
||||
|
||||
/// Hides the window, releases every player and the sound device, leaves
|
||||
/// (the `bye` goes out first, so others see "left" at once; a hosted
|
||||
/// server stops), runs [beforeQuit], then closes the window. [restart]
|
||||
/// (Update and restart) leaves without the `bye`: the member stays
|
||||
/// `gone`, and the restarted app, same client id, takes it back within
|
||||
/// the grace. Every step is bounded; a second call does nothing.
|
||||
Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = false}) async {
|
||||
if (_shuttingDown) return;
|
||||
_shuttingDown = true;
|
||||
final session = SessionState.instance;
|
||||
final shell = session.ports.shell;
|
||||
final total = Stopwatch()..start();
|
||||
await _step('window save', shell.saveWindow(), const Duration(milliseconds: 500));
|
||||
await _step('hide', shell.hide(), const Duration(milliseconds: 500));
|
||||
await Future.wait([
|
||||
_step('players', session.coordinator.releasePlayers(), const Duration(seconds: 3)),
|
||||
_step('thumbnails', cancelAllThumbnails(), const Duration(milliseconds: 1500)),
|
||||
_step('sounds', session.sfx.dispose(), const Duration(milliseconds: 500)),
|
||||
]);
|
||||
// Windows: media_kit_video frees a player's video output on a thread of
|
||||
// its own after dispose returns; it must be done before the engine
|
||||
// stops, or the plugin's teardown waits on it for good.
|
||||
if (Platform.isWindows) await Future.delayed(const Duration(milliseconds: 300));
|
||||
// Jellyfin stop report, last diagnostics, the bye (flushed), then the
|
||||
// hosted server: 3 s in all.
|
||||
await _step('leave', session.leave(bye: !restart), const Duration(milliseconds: 2500));
|
||||
await _step('server stop', session.stopServer(), const Duration(milliseconds: 500));
|
||||
if (beforeQuit != null) await _step('before quit', beforeQuit(), const Duration(seconds: 3));
|
||||
debugPrint('close: ready to quit after ${total.elapsedMilliseconds} ms');
|
||||
await shell.quit();
|
||||
// The engine stops with the window, so this only fires when the close
|
||||
// did not go through. A kill, not exit(): no exit handlers (NVIDIA's
|
||||
// EGL one segfaults, Windows DLL detach can hang).
|
||||
Timer(const Duration(seconds: 3), () {
|
||||
debugPrint('close: the window did not close; ending the process');
|
||||
Process.killPid(pid, ProcessSignal.sigkill);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _step(String what, Future<void>? f, Duration limit) async {
|
||||
if (f == null) return;
|
||||
final sw = Stopwatch()..start();
|
||||
try {
|
||||
await f.timeout(limit);
|
||||
} on TimeoutException {
|
||||
debugPrint('close: $what did not finish in ${limit.inMilliseconds} ms');
|
||||
} catch (e) {
|
||||
debugPrint('close: $what failed: $e');
|
||||
}
|
||||
if (sw.elapsedMilliseconds > 200) debugPrint('close: $what took ${sw.elapsedMilliseconds} ms');
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_soloud/flutter_soloud.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
final _log = createLogger('sound');
|
||||
|
||||
/// What [SoundEngine] needs from an audio library: [SoLoudBackend] in the
|
||||
/// app, a fake in tests. A sound handle is whatever [load] returns.
|
||||
abstract class AudioBackend {
|
||||
Future<void> init();
|
||||
/// Decodes [wav] (a whole WAV file) into memory under [name].
|
||||
Future<Object> load(String name, Uint8List wav);
|
||||
/// Starts one voice of [sound] at [volume] (linear gain, 0..1); voices
|
||||
/// overlap.
|
||||
void play(Object sound, double volume);
|
||||
Future<void> unload(Object sound);
|
||||
Future<void> deinit();
|
||||
}
|
||||
|
||||
/// flutter_soloud (SoLoud mixing into miniaudio's output device).
|
||||
class SoLoudBackend implements AudioBackend {
|
||||
SoLoud get _s => SoLoud.instance;
|
||||
|
||||
@override
|
||||
Future<void> init() => _s.init();
|
||||
|
||||
@override
|
||||
Future<Object> load(String name, Uint8List wav) => _s.loadMem(name, wav);
|
||||
|
||||
@override
|
||||
void play(Object sound, double volume) => _s.play(sound as AudioSource, volume: volume);
|
||||
|
||||
@override
|
||||
Future<void> unload(Object sound) => _s.disposeSource(sound as AudioSource);
|
||||
|
||||
@override
|
||||
Future<void> deinit() => _s.deinitAsync();
|
||||
}
|
||||
|
||||
/// The app's short in-memory sounds. The audio device opens on [warmUp] or
|
||||
/// the first [play]; if it can't (no device, a broken driver) the engine
|
||||
/// logs once and every play is silently dropped. Each sound is loaded once
|
||||
/// per slot from the WAV bytes its renderer gives; a new key in a slot
|
||||
/// replaces (and frees) what the slot held.
|
||||
class SoundEngine {
|
||||
final AudioBackend _backend;
|
||||
SoundEngine({AudioBackend? backend}) : _backend = backend ?? SoLoudBackend();
|
||||
|
||||
Future<bool>? _ready;
|
||||
var _disposed = false;
|
||||
/// Per slot: the loaded key and its sound (null: failed to load).
|
||||
final Map<String, (String, Future<Object?>)> _slots = {};
|
||||
/// Load names are unique: SoLoud hands back the old sound for a name it
|
||||
/// already holds, which would defeat a re-render.
|
||||
var _loads = 0;
|
||||
|
||||
/// Opens the audio device now, so the first sound has no wait.
|
||||
Future<bool> warmUp() => _ready ??= _init();
|
||||
|
||||
Future<bool> _init() async {
|
||||
if (_disposed) return false;
|
||||
try {
|
||||
await _backend.init();
|
||||
return true;
|
||||
} catch (e) {
|
||||
_log.warning('no sounds: audio init failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays the sound of [slot] at [volume] (linear gain). [key] names what
|
||||
/// [wav] renders: the same key reuses the loaded sound; another key, or
|
||||
/// [fresh], renders and loads it again.
|
||||
Future<void> play(String slot, String key, Future<Uint8List> Function() wav,
|
||||
{required double volume, bool fresh = false}) async {
|
||||
if (_disposed || volume <= 0) return;
|
||||
if (!await warmUp() || _disposed) return;
|
||||
var held = _slots[slot];
|
||||
if (held == null || held.$1 != key || fresh) {
|
||||
if (held != null) unawaited(_unload(held.$2));
|
||||
held = (key, _load(key, wav));
|
||||
_slots[slot] = held;
|
||||
}
|
||||
final sound = await held.$2;
|
||||
if (sound == null) {
|
||||
// Retry on the next play.
|
||||
if (identical(_slots[slot], held)) _slots.remove(slot);
|
||||
return;
|
||||
}
|
||||
if (_disposed || !identical(_slots[slot], held)) return;
|
||||
try {
|
||||
_backend.play(sound, volume.clamp(0.0, 1.0));
|
||||
} catch (e) {
|
||||
_log.warning('sound $key: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Object?> _load(String key, Future<Uint8List> Function() wav) async {
|
||||
try {
|
||||
return await _backend.load('$key#${_loads++}', await wav());
|
||||
} catch (e) {
|
||||
_log.warning('sound $key: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _unload(Future<Object?> sound) async {
|
||||
try {
|
||||
final s = await sound;
|
||||
if (s != null && !_disposed) await _backend.unload(s);
|
||||
} catch (e) {
|
||||
_log.fine('unload: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Frees every sound and closes the audio device. Plays after it are
|
||||
/// dropped.
|
||||
Future<void> dispose() async {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_slots.clear();
|
||||
final ready = _ready;
|
||||
if (ready == null || !await ready) return;
|
||||
try {
|
||||
// Deinit frees the loaded sounds with the engine.
|
||||
await _backend.deinit();
|
||||
} catch (e) {
|
||||
_log.warning('audio deinit: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
// First start of the renamed app (Starsync, app id dev.foxyz.starsync):
|
||||
// copy what the old names left, never move, so a 1.2.x install keeps
|
||||
// working next to it. Runs once (the marker file), then never again.
|
||||
|
||||
/// Written into the new support folder once the migration ran (whether or
|
||||
/// not anything was found): `migratedFrom` (the old support folder or
|
||||
/// null), `hiveFrom` (the folder the boxes came from or null), `boxes`,
|
||||
/// `files`.
|
||||
const migrationMarker = 'migration.json';
|
||||
|
||||
/// The Hive boxes the app opens, by name (settings, connect form, Files,
|
||||
/// Jellyfin sign-ins, track choices). Only these leave the documents
|
||||
/// folder, which other apps' boxes share.
|
||||
const appHiveBoxes = ['settings', 'connect_form', 'files', 'jellyfin', 'track_prefs'];
|
||||
|
||||
/// Support folders of the old names for the new [support] folder, the
|
||||
/// most likely first. Linux: `$XDG_DATA_HOME/moe.senkochan.watch_together`
|
||||
/// (the GTK application id up to 1.2.x), then `watch_together` (the
|
||||
/// executable name path_provider falls back to). Windows:
|
||||
/// `%APPDATA%\com.example\watch_together` (company and product name of
|
||||
/// the exe up to 1.2.x).
|
||||
List<String> legacySupportDirs(String support, {bool? windows}) {
|
||||
final win = windows ?? Platform.isWindows;
|
||||
final sep = win ? r'\' : '/';
|
||||
String parent(String p) {
|
||||
final norm = p.endsWith(sep) ? p.substring(0, p.length - 1) : p;
|
||||
final i = norm.lastIndexOf(sep);
|
||||
return i <= 0 ? norm : norm.substring(0, i);
|
||||
}
|
||||
|
||||
if (win) return ['${parent(parent(support))}${sep}com.example${sep}watch_together'];
|
||||
final share = parent(support);
|
||||
return ['$share${sep}moe.senkochan.watch_together', '$share${sep}watch_together'];
|
||||
}
|
||||
|
||||
/// Copies the first existing [oldSupport] folder into [support] and the
|
||||
/// [appHiveBoxes] (`.hive` and `.lock`) from [documents] into [hive],
|
||||
/// skipping anything already there; the originals stay. Once per support
|
||||
/// folder ([migrationMarker]). Returns one log line per thing copied
|
||||
/// (paths and counts, never contents); empty when it ran before.
|
||||
Future<List<String>> migrateLegacyData(
|
||||
{required String support, required String hive, required List<String> oldSupport, String? documents}) async {
|
||||
final sep = Platform.pathSeparator;
|
||||
final marker = File('$support$sep$migrationMarker');
|
||||
if (marker.existsSync()) return const [];
|
||||
final lines = <String>[];
|
||||
String? from;
|
||||
var files = 0;
|
||||
for (final old in oldSupport) {
|
||||
final dir = Directory(old);
|
||||
if (!dir.existsSync() || _same(old, support)) continue;
|
||||
files = await _copyTree(dir, Directory(support));
|
||||
from = old;
|
||||
lines.add('migration: copied $files files from $old to $support');
|
||||
break;
|
||||
}
|
||||
final boxes = <String>[];
|
||||
if (documents != null && Directory(documents).existsSync() && !_same(documents, hive)) {
|
||||
await Directory(hive).create(recursive: true);
|
||||
for (final box in appHiveBoxes) {
|
||||
for (final ext in const ['hive', 'lock']) {
|
||||
final src = File('$documents$sep$box.$ext');
|
||||
final dst = File('$hive$sep$box.$ext');
|
||||
if (!src.existsSync() || dst.existsSync()) continue;
|
||||
await src.copy(dst.path);
|
||||
if (ext == 'hive') boxes.add(box);
|
||||
}
|
||||
}
|
||||
if (boxes.isNotEmpty) {
|
||||
lines.add('migration: copied ${boxes.length} Hive boxes (${boxes.join(', ')}) from $documents to $hive');
|
||||
}
|
||||
}
|
||||
if (lines.isEmpty) lines.add('migration: no data from before the rename');
|
||||
await Directory(support).create(recursive: true);
|
||||
await marker.writeAsString(jsonEncode({
|
||||
'migratedFrom': from,
|
||||
'hiveFrom': boxes.isEmpty ? null : documents,
|
||||
'boxes': boxes,
|
||||
'files': files,
|
||||
}));
|
||||
return lines;
|
||||
}
|
||||
|
||||
bool _same(String a, String b) {
|
||||
try {
|
||||
return FileSystemEntity.identicalSync(a, b);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Files (File.copy keeps the mode on POSIX: cookies.txt stays 0600, yt-dlp
|
||||
// executable) and symlinks as they are; existing targets win.
|
||||
Future<int> _copyTree(Directory from, Directory to) async {
|
||||
var n = 0;
|
||||
await to.create(recursive: true);
|
||||
await for (final e in from.list(recursive: true, followLinks: false)) {
|
||||
final target = '${to.path}${e.path.substring(from.path.length)}';
|
||||
if (e is Directory) {
|
||||
await Directory(target).create(recursive: true);
|
||||
} else if (await FileSystemEntity.type(target, followLinks: false) != FileSystemEntityType.notFound) {
|
||||
continue;
|
||||
} else if (e is Link) {
|
||||
await Directory(File(target).parent.path).create(recursive: true);
|
||||
await Link(target).create(await e.target());
|
||||
} else if (e is File) {
|
||||
await Directory(File(target).parent.path).create(recursive: true);
|
||||
await e.copy(target);
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,35 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../data_migration.dart';
|
||||
import '../ports/app_dirs.dart';
|
||||
|
||||
/// The desktop's [AppDirsPort]: the app support folder from path_provider
|
||||
/// (Linux `$XDG_DATA_HOME/dev.foxyz.starsync`, Windows
|
||||
/// `%APPDATA%\foxyz\Starsync`); on the first start it copies the data of
|
||||
/// the old names in ([migrateLegacyData]). Needs Flutter's binding.
|
||||
class DesktopAppDirs implements AppDirsPort {
|
||||
const DesktopAppDirs();
|
||||
|
||||
@override
|
||||
Future<AppDirs> resolve() async {
|
||||
final support = (await getApplicationSupportDirectory()).path;
|
||||
final base = AppDirs.fromEnv(support: support, env: const {});
|
||||
List<String> migrated;
|
||||
try {
|
||||
String? documents;
|
||||
try {
|
||||
// Where Hive.initFlutter() kept the boxes up to 1.2.x.
|
||||
documents = (await getApplicationDocumentsDirectory()).path;
|
||||
} catch (_) {}
|
||||
migrated = await migrateLegacyData(
|
||||
support: support, hive: base.hive, oldSupport: legacySupportDirs(support), documents: documents);
|
||||
} catch (e) {
|
||||
migrated = ['migration failed: $e'];
|
||||
}
|
||||
final dirs = AppDirs.fromEnv(support: support, env: Platform.environment, migrated: migrated);
|
||||
await Directory(dirs.hive).create(recursive: true);
|
||||
return dirs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
|
||||
import '../../files/scanner.dart';
|
||||
import '../ports/folder_access.dart';
|
||||
import '../ports/url_opener.dart';
|
||||
|
||||
/// [FolderAccess] on a desktop file system: file_picker's dialogs, the
|
||||
/// [scanFolder] walk, the file manager through [UrlOpener].
|
||||
class DesktopFolders implements FolderAccess {
|
||||
final UrlOpener urls;
|
||||
const DesktopFolders(this.urls);
|
||||
|
||||
@override
|
||||
Future<String?> pickFolder({required String dialogTitle}) => FilePicker.getDirectoryPath(dialogTitle: dialogTitle);
|
||||
|
||||
@override
|
||||
Future<List<String>> pickFiles({required String dialogTitle, required List<String> extensions}) async {
|
||||
final files = await FilePicker.pickFiles(
|
||||
dialogTitle: dialogTitle,
|
||||
type: FileType.custom,
|
||||
allowedExtensions: extensions,
|
||||
);
|
||||
return [for (final f in files) ?f.path];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FolderScan> scan(String dir) => scanFolder(dir);
|
||||
|
||||
@override
|
||||
bool folderExists(String dir) => Directory(dir).existsSync();
|
||||
|
||||
@override
|
||||
Future<FileStat> stat(String path) => File(path).stat();
|
||||
|
||||
// The file manager opens a folder path the way a browser opens a link.
|
||||
@override
|
||||
Future<bool> reveal(String dir) => urls.open(Uri.directory(dir).toString());
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import '../../playback/yt_dlp_app.dart';
|
||||
import '../ports/ports.dart';
|
||||
import 'desktop_folders.dart';
|
||||
import 'desktop_shell.dart';
|
||||
import 'desktop_urls.dart';
|
||||
|
||||
/// The desktop's [Ports].
|
||||
Ports desktopPorts() {
|
||||
const urls = DesktopUrls();
|
||||
return Ports(
|
||||
shell: DesktopShell(),
|
||||
urls: urls,
|
||||
folders: const DesktopFolders(urls),
|
||||
links: AppYtDlp.instance,
|
||||
notifications: const NoSystemNotifications(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget;
|
||||
import 'package:hive_ce_flutter/hive_flutter.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../../state/settings.dart' show settingsBoxName;
|
||||
import 'attention.dart';
|
||||
import '../ports/shell_port.dart';
|
||||
import 'window_memory.dart';
|
||||
|
||||
/// [ShellPort] on Linux and Windows: window_manager for the window,
|
||||
/// [WindowMemory] for its size and place, the runner's channel for
|
||||
/// attention, fox_utils for drops and the clipboard.
|
||||
class DesktopShell implements ShellPort {
|
||||
WindowMemory? _memory;
|
||||
|
||||
@override
|
||||
Future<void> start({required String title, required Future<void> Function() onCloseRequest}) async {
|
||||
await windowManager.ensureInitialized();
|
||||
await windowManager.setPreventClose(true);
|
||||
final memory = _memory = WindowMemory(Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null);
|
||||
windowManager.addListener(_CloseGuard(onCloseRequest));
|
||||
final windowOptions = WindowOptions(
|
||||
size: memory.size,
|
||||
minimumSize: const Size(160, 90),
|
||||
center: !memory.hasPosition,
|
||||
backgroundColor: const Color(0xFF000000),
|
||||
titleBarStyle: TitleBarStyle.hidden,
|
||||
title: title,
|
||||
);
|
||||
unawaited(windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||
await memory.restore();
|
||||
await windowManager.show();
|
||||
await windowManager.focus();
|
||||
}));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setFullScreen(bool on) => windowManager.setFullScreen(on);
|
||||
@override
|
||||
Future<void> minimize() => windowManager.minimize();
|
||||
@override
|
||||
Future<bool> isMaximized() => windowManager.isMaximized();
|
||||
@override
|
||||
Future<void> maximize() => windowManager.maximize();
|
||||
@override
|
||||
Future<void> unmaximize() => windowManager.unmaximize();
|
||||
|
||||
@override
|
||||
void Function() watchMaximized(void Function(bool maximized) onChange) {
|
||||
final l = _MaximizeWatch(onChange);
|
||||
windowManager.addListener(l);
|
||||
return () => windowManager.removeListener(l);
|
||||
}
|
||||
|
||||
final _activity = <void Function(WindowActivity)>{};
|
||||
|
||||
@override
|
||||
void Function() watchActivity(void Function(WindowActivity activity) onChange) {
|
||||
final l = _ActivityWatch(onChange);
|
||||
_activity.add(onChange);
|
||||
windowManager.addListener(l);
|
||||
return () {
|
||||
_activity.remove(onChange);
|
||||
windowManager.removeListener(l);
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> startDragging() => windowManager.startDragging();
|
||||
|
||||
@override
|
||||
Future<void> requestClose() => windowManager.close();
|
||||
|
||||
@override
|
||||
Future<void> saveWindow() async => _memory?.save();
|
||||
|
||||
@override
|
||||
Future<void> hide() async {
|
||||
// window_manager has no hide event: tell the watchers here.
|
||||
for (final f in [..._activity]) {
|
||||
f(WindowActivity.hidden);
|
||||
}
|
||||
await windowManager.hide();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> quit() async {
|
||||
try {
|
||||
await windowManager.setPreventClose(false);
|
||||
await windowManager.close();
|
||||
} catch (e) {
|
||||
debugPrint('window close failed: $e');
|
||||
await windowManager.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> requestAttention() => requestWindowAttention();
|
||||
|
||||
@override
|
||||
Future<Paste> readPaste() async {
|
||||
var files = const <String>[];
|
||||
String? text;
|
||||
try {
|
||||
files = await ClipboardX.readFiles();
|
||||
} catch (_) {}
|
||||
if (files.isEmpty) {
|
||||
try {
|
||||
text = await ClipboardX.readText();
|
||||
} catch (_) {}
|
||||
}
|
||||
return (files: files, text: text);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget dropTarget({
|
||||
Key? key,
|
||||
bool enable = true,
|
||||
VoidCallback? onEnter,
|
||||
VoidCallback? onExit,
|
||||
required void Function(List<String> paths, Offset at) onDrop,
|
||||
required Widget child,
|
||||
}) =>
|
||||
DropTarget(
|
||||
key: key,
|
||||
enable: enable,
|
||||
onDragEntered: onEnter == null ? null : (_) => onEnter(),
|
||||
onDragExited: onExit == null ? null : (_) => onExit(),
|
||||
onDragDone: (d) => onDrop(DesktopDropX.pathsOf(d.files), d.localPosition),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
/// Close button / Alt+F4 run the orderly close instead.
|
||||
class _CloseGuard with WindowListener {
|
||||
final Future<void> Function() onClose;
|
||||
_CloseGuard(this.onClose);
|
||||
@override
|
||||
void onWindowClose() => unawaited(onClose());
|
||||
}
|
||||
|
||||
class _MaximizeWatch with WindowListener {
|
||||
final void Function(bool) onChange;
|
||||
_MaximizeWatch(this.onChange);
|
||||
@override
|
||||
void onWindowMaximize() => onChange(true);
|
||||
@override
|
||||
void onWindowUnmaximize() => onChange(false);
|
||||
}
|
||||
|
||||
/// Focus, blur, minimise and restore as [WindowActivity] (a restored
|
||||
/// window is shown; its focus event follows when it gets the keyboard).
|
||||
class _ActivityWatch with WindowListener {
|
||||
final void Function(WindowActivity) onChange;
|
||||
_ActivityWatch(this.onChange);
|
||||
@override
|
||||
void onWindowFocus() => onChange(WindowActivity.focused);
|
||||
@override
|
||||
void onWindowBlur() => onChange(WindowActivity.unfocused);
|
||||
@override
|
||||
void onWindowMinimize() => onChange(WindowActivity.minimised);
|
||||
@override
|
||||
void onWindowRestore() => onChange(WindowActivity.unfocused);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../ports/url_opener.dart';
|
||||
|
||||
/// [UrlOpener] without url_launcher: `xdg-open`, `open` or Windows'
|
||||
/// `url.dll` handler.
|
||||
class DesktopUrls implements UrlOpener {
|
||||
const DesktopUrls();
|
||||
|
||||
@override
|
||||
Future<bool> open(String url) async {
|
||||
try {
|
||||
final ProcessResult r;
|
||||
if (Platform.isWindows) {
|
||||
r = await Process.run('rundll32', ['url.dll,FileProtocolHandler', url]);
|
||||
} else if (Platform.isMacOS) {
|
||||
r = await Process.run('open', [url]);
|
||||
} else {
|
||||
r = await Process.run('xdg-open', [url]);
|
||||
}
|
||||
return r.exitCode == 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -5,16 +5,24 @@ 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 appId = 'dev.foxyz.starsync';
|
||||
const desktopFileName = '$appId.desktop';
|
||||
|
||||
/// The app id up to 1.2.x: its entry and icons are removed (only those
|
||||
/// exact files, which that version wrote).
|
||||
const legacyAppId = 'moe.senkochan.watch_together';
|
||||
|
||||
const _iconSizes = [16, 32, 48, 64, 128, 256, 512];
|
||||
|
||||
/// 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;
|
||||
/// icon). The entry and icons of [legacyAppId] go. Never in debug runs
|
||||
/// (unless [force], for tests); failures only log.
|
||||
Future<void> installDesktopEntry(
|
||||
{String? executable, String? dataHome, String? home, @visibleForTesting bool force = false}) async {
|
||||
if (!Platform.isLinux || (kDebugMode && !force)) return;
|
||||
try {
|
||||
final exe = executable ?? Platform.resolvedExecutable;
|
||||
final root = findInstallRoot(exe, windows: false);
|
||||
@@ -22,7 +30,8 @@ Future<void> installDesktopEntry({String? executable, String? dataHome, String?
|
||||
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]) {
|
||||
await _removeLegacy(share);
|
||||
for (final size in _iconSizes) {
|
||||
final src = File('$icons/icon_$size.png');
|
||||
if (!src.existsSync()) continue;
|
||||
final dst = File('$share/icons/hicolor/${size}x$size/apps/$appId.png');
|
||||
@@ -40,6 +49,21 @@ Future<void> installDesktopEntry({String? executable, String? dataHome, String?
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeLegacy(String share) async {
|
||||
for (final f in [
|
||||
File('$share/applications/$legacyAppId.desktop'),
|
||||
for (final size in _iconSizes) File('$share/icons/hicolor/${size}x$size/apps/$legacyAppId.png'),
|
||||
]) {
|
||||
if (!f.existsSync()) continue;
|
||||
try {
|
||||
await f.delete();
|
||||
debugPrint('desktop entry: removed ${f.path}');
|
||||
} catch (e) {
|
||||
debugPrint('desktop entry: cannot remove ${f.path}: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
String desktopEntryText(String exe) => '''[Desktop Entry]
|
||||
Type=Application
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'dart:io';
|
||||
|
||||
/// The environment variable that picks a separate profile (DEVELOPMENT.md
|
||||
/// "Dev loop": two instances on one machine).
|
||||
const profileEnv = 'STARSYNC_PROFILE';
|
||||
|
||||
/// Where the app keeps its own files, resolved once in `main` through an
|
||||
/// [AppDirsPort] and handed on from there.
|
||||
///
|
||||
/// Everything lives in the app support folder: Hive's boxes in
|
||||
/// `<app support>/hive`, the logs in `<app support>/logs`. With
|
||||
/// `STARSYNC_PROFILE=<name>` (letters, digits, `-`, `_`; at most 32) the
|
||||
/// per-install data moves to `<app support>/profiles/<name>/`: the Hive
|
||||
/// boxes (settings and so the profile, connect form, window bounds,
|
||||
/// folders, Jellyfin sign-ins; the client id from restructure step 1 on)
|
||||
/// under `hive/`, the logs under `logs/`. Downloads and caches stay
|
||||
/// shared: yt-dlp and its cookies, the extracted shaders, thumbnails, the
|
||||
/// AniList cache.
|
||||
class AppDirs {
|
||||
/// The profile name; null for the normal app.
|
||||
final String? profile;
|
||||
/// The app support folder (yt-dlp, shaders; Hive and logs without a
|
||||
/// profile).
|
||||
final String support;
|
||||
/// Hive's folder.
|
||||
final String hive;
|
||||
/// The app's log folder.
|
||||
final String logs;
|
||||
/// What resolving copied over from before the rename, one line each
|
||||
/// (paths and counts), for the log once it is open.
|
||||
final List<String> migrated;
|
||||
|
||||
const AppDirs(
|
||||
{required this.profile,
|
||||
required this.support,
|
||||
required this.hive,
|
||||
required this.logs,
|
||||
this.migrated = const []});
|
||||
|
||||
/// The layout for [support] (the app support folder) and [env]; an
|
||||
/// invalid profile name is ignored ([invalidProfile] says which).
|
||||
static AppDirs fromEnv(
|
||||
{required String support, required Map<String, String> env, List<String> migrated = const []}) {
|
||||
final sep = Platform.pathSeparator;
|
||||
final name = validProfile(env[profileEnv]);
|
||||
final root = name == null ? support : '$support${sep}profiles$sep$name';
|
||||
return AppDirs(
|
||||
profile: name, support: support, hive: '$root${sep}hive', logs: '$root${sep}logs', migrated: migrated);
|
||||
}
|
||||
|
||||
/// [raw] when it is a usable profile name, else null.
|
||||
static String? validProfile(String? raw) {
|
||||
final v = raw?.trim() ?? '';
|
||||
return RegExp(r'^[A-Za-z0-9_-]{1,32}$').hasMatch(v) ? v : null;
|
||||
}
|
||||
|
||||
/// The env value when it is set but not a usable name (for a warning).
|
||||
static String? invalidProfile(Map<String, String> env) {
|
||||
final raw = env[profileEnv];
|
||||
return raw != null && raw.trim().isNotEmpty && validProfile(raw) == null ? raw : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the app's folders on this platform (and, the first time, brings
|
||||
/// over the data of the names before the rename).
|
||||
abstract interface class AppDirsPort {
|
||||
/// The layout for this process; its Hive folder exists afterwards.
|
||||
Future<AppDirs> resolve();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'dart:io' show FileStat;
|
||||
|
||||
import '../../files/scanner.dart' show FolderScan;
|
||||
|
||||
/// The user's folders and files: picking them, listing videos, looking a
|
||||
/// path up. Paths for now; a platform without them (Android's tree URIs)
|
||||
/// brings its own implementation.
|
||||
abstract interface class FolderAccess {
|
||||
/// The system folder dialog; null when cancelled.
|
||||
Future<String?> pickFolder({required String dialogTitle});
|
||||
|
||||
/// The system file dialog limited to [extensions]; the picked paths
|
||||
/// (empty when cancelled).
|
||||
Future<List<String>> pickFiles({required String dialogTitle, required List<String> extensions});
|
||||
|
||||
/// The videos under [dir] (see [FolderScan]); never throws.
|
||||
Future<FolderScan> scan(String dir);
|
||||
|
||||
/// Whether the folder [dir] exists.
|
||||
bool folderExists(String dir);
|
||||
|
||||
/// Size and dates of [path]; throws when it is gone.
|
||||
Future<FileStat> stat(String path);
|
||||
|
||||
/// Shows the folder [dir] in the file manager; false when nothing
|
||||
/// opened it.
|
||||
Future<bool> reveal(String dir);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:sync_relay_core/core.dart' show LinkProbe, YtDlp;
|
||||
|
||||
/// Web page links: what they are and how they play, through the app's
|
||||
/// yt-dlp (a process on desktop).
|
||||
abstract interface class LinkResolver {
|
||||
/// The yt-dlp behind it (relaying a link, cookies, the hosted server's
|
||||
/// shared folder).
|
||||
Future<YtDlp> get ytDlp;
|
||||
|
||||
/// Gets it ready in the background (download on first run, updates).
|
||||
void warmUp();
|
||||
|
||||
/// mpv's `ytdl-raw-options` (sub languages, cookies).
|
||||
Future<String> mpvRawOptions();
|
||||
|
||||
/// What [url] is, remembered for a while ([keep]); [fresh] asks again.
|
||||
Future<LinkProbe> probe(String url, {Duration keep, bool fresh});
|
||||
|
||||
/// [url] no longer needs keeping longer than usual.
|
||||
void release(String url);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'folder_access.dart';
|
||||
import 'link_resolver.dart';
|
||||
import 'shell_port.dart';
|
||||
import 'system_notifications.dart';
|
||||
import 'url_opener.dart';
|
||||
|
||||
export 'app_dirs.dart';
|
||||
export 'folder_access.dart';
|
||||
export 'link_resolver.dart';
|
||||
export 'shell_port.dart';
|
||||
export 'system_notifications.dart';
|
||||
export 'url_opener.dart';
|
||||
|
||||
/// The platform as the app sees it: one set, made in the composition
|
||||
/// root ([SessionState]) and handed on from there.
|
||||
class Ports {
|
||||
final ShellPort shell;
|
||||
final UrlOpener urls;
|
||||
final FolderAccess folders;
|
||||
final LinkResolver links;
|
||||
final SystemNotifications notifications;
|
||||
const Ports({
|
||||
required this.shell,
|
||||
required this.urls,
|
||||
required this.folders,
|
||||
required this.links,
|
||||
required this.notifications,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Where the app window stands, for auto sit-out
|
||||
/// ([ShellPort.watchActivity]).
|
||||
enum WindowActivity {
|
||||
/// The window has the keyboard focus.
|
||||
focused,
|
||||
|
||||
/// Shown, but another window has the focus.
|
||||
unfocused,
|
||||
|
||||
/// Minimised to the taskbar.
|
||||
minimised,
|
||||
|
||||
/// Hidden (closing, or a platform that hides instead of minimising).
|
||||
hidden,
|
||||
}
|
||||
|
||||
/// What the clipboard holds for a paste: its files, else its text.
|
||||
typedef Paste = ({List<String> files, String? text});
|
||||
|
||||
/// The app window and what the desktop hands it (drops, pastes). The
|
||||
/// desktop implementation wraps window_manager and fox_utils
|
||||
/// ([DesktopShell]); another platform brings its own.
|
||||
abstract interface class ShellPort {
|
||||
/// Before the first frame: the window shown at its remembered size and
|
||||
/// place; a close request (close button, Alt+F4) runs [onCloseRequest]
|
||||
/// instead of closing.
|
||||
Future<void> start({required String title, required Future<void> Function() onCloseRequest});
|
||||
|
||||
Future<void> setFullScreen(bool on);
|
||||
Future<void> minimize();
|
||||
Future<bool> isMaximized();
|
||||
Future<void> maximize();
|
||||
Future<void> unmaximize();
|
||||
|
||||
/// Calls [onChange] whenever the window is maximized or restored;
|
||||
/// returns the call that stops it.
|
||||
void Function() watchMaximized(void Function(bool maximized) onChange);
|
||||
|
||||
/// Calls [onChange] whenever the window gains or loses the focus, is
|
||||
/// minimised, restored or hidden; returns the call that stops it.
|
||||
void Function() watchActivity(void Function(WindowActivity activity) onChange);
|
||||
|
||||
/// Moves the window with the pointer (a press on the title area).
|
||||
Future<void> startDragging();
|
||||
|
||||
/// Asks to close the window: the same path as the close button.
|
||||
Future<void> requestClose();
|
||||
|
||||
/// The way out: remembers the window, hides it ([hide]), and later
|
||||
/// closes it for good ([quit]).
|
||||
Future<void> saveWindow();
|
||||
Future<void> hide();
|
||||
Future<void> quit();
|
||||
|
||||
/// Points the desktop at the window when it is not focused (taskbar
|
||||
/// flash, urgency hint); nothing when it is.
|
||||
Future<void> requestAttention();
|
||||
|
||||
/// The clipboard's files, else its text (Ctrl+V outside a text field).
|
||||
Future<Paste> readPaste();
|
||||
|
||||
/// [child] taking dropped files ([onDrop]: their paths and where they
|
||||
/// landed); [enable] off lets drops through.
|
||||
Widget dropTarget({
|
||||
Key? key,
|
||||
bool enable = true,
|
||||
VoidCallback? onEnter,
|
||||
VoidCallback? onExit,
|
||||
required void Function(List<String> paths, Offset at) onDrop,
|
||||
required Widget child,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/// Notifications outside the window (the desktop's notification center,
|
||||
/// Android's shade). The policy that uses them comes with the Notifier.
|
||||
abstract interface class SystemNotifications {
|
||||
/// Shows [title] / [body]; replaces the one with the same [tag].
|
||||
Future<void> show({required String title, required String body, String tag = ''});
|
||||
}
|
||||
|
||||
/// Shows nothing (desktop today).
|
||||
class NoSystemNotifications implements SystemNotifications {
|
||||
const NoSystemNotifications();
|
||||
|
||||
@override
|
||||
Future<void> show({required String title, required String body, String tag = ''}) async {}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/// Opens links outside the app.
|
||||
abstract interface class UrlOpener {
|
||||
/// Opens [url] with the system's handler (a browser for web links, the
|
||||
/// file manager for a `file:` folder); false when nothing opened it.
|
||||
Future<bool> open(String url);
|
||||
}
|
||||
@@ -30,7 +30,9 @@ class PlaybackController {
|
||||
static const readAheadSecs = 150;
|
||||
/// Forward demuxer cache. Holds [readAheadSecs] up to ~14 Mbit/s (a
|
||||
/// 1.4 GB / 24 min file is ~8); higher bitrates get less time. In RAM:
|
||||
/// media_kit sets cache-on-disk but no cache-dir, so mpv falls back.
|
||||
/// `cache-on-disk=no` (media_kit sets it on but no cache-dir, so mpv
|
||||
/// fell back to RAM anyway, warning "Failed to create file cache." per
|
||||
/// open).
|
||||
static const maxBytes = 256 * 1024 * 1024;
|
||||
/// Back buffer (media_kit sets it to bufferSize too): enough for a short
|
||||
/// seek back without holding another 256 MiB.
|
||||
@@ -149,6 +151,9 @@ class PlaybackController {
|
||||
/// `demuxer-cache-time`, media_kit's `buffer`), in 250 ms steps.
|
||||
final buffered = signal(Duration.zero);
|
||||
|
||||
/// The player's volume, 0..[maxVolume] (media_kit's `volume`).
|
||||
final volume = signal(100.0);
|
||||
|
||||
/// The opening ([openingRange]: Intro segment, else an OP-titled
|
||||
/// chapter) and the ending ([endingRange]: Outro, else ED-titled).
|
||||
late final Computed<SkipRange?> opening =
|
||||
@@ -172,6 +177,7 @@ class PlaybackController {
|
||||
final existing = _video;
|
||||
if (existing != null) return existing;
|
||||
final hwdec = _hwdecOverride;
|
||||
_logRenderEnv();
|
||||
_requestLog(player, 'v'); // back to [logLevel] by _detectRenderMode
|
||||
// media_kit's video output sets hwdec itself on attach ('auto' unless
|
||||
// configured), which used to override SYNC_RELAY_HWDEC.
|
||||
@@ -250,6 +256,7 @@ class PlaybackController {
|
||||
await _pin('cache-secs', '$readAheadSecs', on);
|
||||
await _pin('demuxer-max-bytes', '$ahead', on);
|
||||
await _pin('demuxer-max-back-bytes', '$back', on);
|
||||
await _pin('cache-on-disk', 'no', on);
|
||||
final hwdec = _hwdecOverride;
|
||||
if (hwdec != null) {
|
||||
await _pin('hwdec', hwdec, on);
|
||||
@@ -307,13 +314,12 @@ class PlaybackController {
|
||||
}
|
||||
if (!identical(p, _player)) return;
|
||||
if (problem != null) paths = const [];
|
||||
final value = joinShaderPaths(paths);
|
||||
if (value != _shaders || problem != null) {
|
||||
final got = await setShaderValue(value);
|
||||
if (joinShaderPaths(paths) != _shaders || problem != null) {
|
||||
final took = await setShaderPaths(paths);
|
||||
if (!identical(p, _player)) return;
|
||||
if (problem == null && got != value) {
|
||||
if (problem == null && !took) {
|
||||
problem = 'mpv did not take them';
|
||||
await setShaderValue('');
|
||||
await setShaderPaths(const []);
|
||||
}
|
||||
}
|
||||
if (problem != null) {
|
||||
@@ -323,20 +329,23 @@ class PlaybackController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets mpv `glsl-shaders` on the current player and returns the
|
||||
/// read-back (null on error).
|
||||
/// Sets mpv `glsl-shaders` to [paths] on the current player; true when
|
||||
/// mpv's read-back is that list ([shaderListMatches]: old mpv prints it
|
||||
/// joined with ',').
|
||||
@visibleForTesting
|
||||
Future<String?> setShaderValue(String value) async {
|
||||
Future<bool> setShaderPaths(List<String> paths) async {
|
||||
final value = joinShaderPaths(paths);
|
||||
try {
|
||||
final dynamic platform = player.platform;
|
||||
await platform?.setProperty('glsl-shaders', value);
|
||||
final got = '${await platform?.getProperty('glsl-shaders')}';
|
||||
_shaders = got;
|
||||
if (got != value) debugPrint('glsl-shaders=$value not applied: "$got"');
|
||||
return got;
|
||||
final took = shaderListMatches(paths, got);
|
||||
_shaders = took ? value : got;
|
||||
if (!took) debugPrint('glsl-shaders=$value not applied: "$got"');
|
||||
return took;
|
||||
} catch (e, st) {
|
||||
debugPrint('glsl-shaders=$value failed: $e\n$st');
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,6 +396,9 @@ class PlaybackController {
|
||||
return;
|
||||
}
|
||||
if (log.prefix != 'libmpv_render') return;
|
||||
// Which GL the video output got (verbose, heard only during the
|
||||
// render-mode probe): kept in the app log.
|
||||
if (_glInfo.hasMatch(log.text)) _mpvLog.info('render: [libmpv_render] ${log.text.trim()}');
|
||||
if (log.text.contains('suspected software renderer')) {
|
||||
_setRenderMode(RenderMode.softwareGl);
|
||||
} else if (log.text.startsWith('GL_VERSION') &&
|
||||
@@ -399,7 +411,7 @@ class PlaybackController {
|
||||
_shaders = '';
|
||||
_shaderOp = _shaderOp.then((_) async {
|
||||
if (!identical(p, _player)) return;
|
||||
await setShaderValue('');
|
||||
await setShaderPaths(const []);
|
||||
_turnShadersOff('Shaders failed in mpv (${log.text}) — turned off');
|
||||
});
|
||||
}
|
||||
@@ -411,6 +423,52 @@ class PlaybackController {
|
||||
static final _mpvLog = createLogger('mpv');
|
||||
static final _mpvThrottle = LogThrottle(burst: 5);
|
||||
|
||||
// mpv's GL init lines (`GL_VENDOR='...'`, `Detected desktop OpenGL 4.6.`).
|
||||
static final _glInfo =
|
||||
RegExp(r'^\s*(GL_(VENDOR|RENDERER|VERSION|SHADING_LANGUAGE_VERSION)=|Detected (GLES|desktop OpenGL))');
|
||||
|
||||
// Once per app start: what decides how media_kit and mpv render (the
|
||||
// display server, Flutter's and Mesa's overrides). Not secrets.
|
||||
static bool _renderEnvLogged = false;
|
||||
static void _logRenderEnv() {
|
||||
if (_renderEnvLogged || !Platform.isLinux) return;
|
||||
_renderEnvLogged = true;
|
||||
const names = [
|
||||
'XDG_SESSION_TYPE',
|
||||
'WAYLAND_DISPLAY',
|
||||
'DISPLAY',
|
||||
'GDK_BACKEND',
|
||||
'FLUTTER_LINUX_RENDERER',
|
||||
'LIBGL_ALWAYS_SOFTWARE',
|
||||
'__EGL_VENDOR_LIBRARY_FILENAMES',
|
||||
];
|
||||
final env = Platform.environment;
|
||||
_mpvLog.info('render env: ${[for (final n in names) '$n=${env[n] ?? '-'}'].join(' ')}');
|
||||
}
|
||||
|
||||
// Once per player, after its first frame: the decoder mpv picked; the
|
||||
// first time per app start also mpv's and ffmpeg's versions.
|
||||
static bool _versionsLogged = false;
|
||||
Player? _frameLoggedFor;
|
||||
Future<void> _logFirstFrame(Player p, int? width) async {
|
||||
if (width == null || width <= 0 || identical(p, _frameLoggedFor)) return;
|
||||
_frameLoggedFor = p;
|
||||
try {
|
||||
final dynamic platform = p.platform;
|
||||
final current = await platform?.getProperty('hwdec-current');
|
||||
final wanted = await platform?.getProperty('hwdec');
|
||||
_mpvLog.info('render: first frame ${p.state.width}x${p.state.height}, '
|
||||
'hwdec-current=${'$current'.isEmpty ? 'no' : current} (hwdec=$wanted), mode ${renderMode.peek().name}');
|
||||
if (!_versionsLogged) {
|
||||
_versionsLogged = true;
|
||||
_mpvLog.info('render: ${await platform?.getProperty('mpv-version')}, '
|
||||
'ffmpeg ${await platform?.getProperty('ffmpeg-version')}');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('first frame info: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static void _toAppLog(PlayerLog log, {String player = ''}) {
|
||||
final level = log.level;
|
||||
if (level != 'warn' && level != 'error' && level != 'fatal') return;
|
||||
@@ -448,11 +506,15 @@ class PlaybackController {
|
||||
} finally {
|
||||
if (identical(p, _player)) _requestLog(p, logLevel);
|
||||
}
|
||||
if (!identical(p, _player) || renderMode.peek() != RenderMode.unknown) {
|
||||
if (!identical(p, _player)) return;
|
||||
final mode = renderMode.peek();
|
||||
if (mode != RenderMode.unknown) {
|
||||
_mpvLog.info('render: ${mode.name} (${mode == RenderMode.gpu ? 'OpenGL on the GPU' : 'OpenGL on the CPU'})');
|
||||
return;
|
||||
}
|
||||
_setRenderMode(RenderMode.software);
|
||||
debugPrint('video output: software rendering (no GL init logged)');
|
||||
_mpvLog.info("render: software (no GL init logged: media_kit's own fallback; "
|
||||
'its reason is on stderr, "media_kit: VideoOutput: ..." when run from a terminal)');
|
||||
if (_shaders.isNotEmpty) {
|
||||
_notice('Shaders have no effect: video uses software rendering');
|
||||
}
|
||||
@@ -504,9 +566,11 @@ class PlaybackController {
|
||||
}
|
||||
}
|
||||
|
||||
// Position / duration from media_kit's streams; chapter-list and
|
||||
// Position, duration and volume from media_kit's streams; chapter-list and
|
||||
// track-list observed in mpv (read back as JSON).
|
||||
void _watchMarks(SyncPlayer p) {
|
||||
volume.value = p.state.volume;
|
||||
_timeSubs.add(p.stream.volume.listen((v) => volume.value = v));
|
||||
_timeSubs.add(p.stream.position.listen((d) {
|
||||
// At the end the bar shows full: mpv's last position is the last
|
||||
// frame's start (and the 250 ms steps round down), short of duration.
|
||||
@@ -520,6 +584,8 @@ class PlaybackController {
|
||||
_timeSubs.add(p.stream.completed.listen((done) {
|
||||
if (done && duration.value > Duration.zero) position.value = duration.value;
|
||||
}));
|
||||
_timeSubs.add(p.stream.width.listen((w) => _logFirstFrame(p, w)));
|
||||
unawaited(_logFirstFrame(p, p.state.width)); // a promoted preload has one already
|
||||
_timeSubs.add(p.stream.buffer.listen((d) {
|
||||
final ms = d.inMilliseconds;
|
||||
buffered.value = Duration(milliseconds: ms - ms % 250);
|
||||
@@ -686,6 +752,24 @@ class PlaybackController {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// App close: every mpv this controller holds goes before the engine
|
||||
/// does (a live one calls back into a dead isolate): the preload, the
|
||||
/// session player and the one still retiring, each bounded by [each].
|
||||
/// A player an open in flight brought up meanwhile goes too.
|
||||
Future<void> disposeAll({Duration each = const Duration(milliseconds: 1500)}) async {
|
||||
Future<void> bounded(Future<void> f) => f.timeout(each).catchError((Object _) {});
|
||||
for (var round = 0; round < 2; round++) {
|
||||
final pre = _pre;
|
||||
_pre = null;
|
||||
await Future.wait([
|
||||
if (pre != null) bounded(_disposePreload(pre)),
|
||||
bounded(_retiring),
|
||||
bounded(dispose()),
|
||||
]);
|
||||
if (_player == null && _pre == null) return;
|
||||
}
|
||||
}
|
||||
|
||||
// Two frames: one may already be mid-build when [alive] flips. Then
|
||||
// the length of a route pop (the fullscreen route holds its own `Video`
|
||||
// and leaves animated). Bounded, since a hidden window renders no
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'dart:io';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../platform/ports/link_resolver.dart';
|
||||
|
||||
/// mpv's `ytdl-format` for links every player opens itself.
|
||||
const ytdlFormat = 'bv*[height<=1080]+ba/b';
|
||||
|
||||
@@ -16,13 +18,14 @@ 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 {
|
||||
class AppYtDlp implements LinkResolver {
|
||||
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;
|
||||
@override
|
||||
Future<YtDlp> get ytDlp => _ytDlp ??= () async {
|
||||
final base = await getApplicationSupportDirectory();
|
||||
return YtDlp(dir(base.path));
|
||||
@@ -32,10 +35,12 @@ class AppYtDlp {
|
||||
|
||||
/// Gets yt-dlp ready in the background (download on first run, a daily
|
||||
/// update check afterwards).
|
||||
@override
|
||||
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]).
|
||||
@override
|
||||
Future<String> mpvRawOptions() async {
|
||||
const subs = 'sub-langs=%${ytdlSubLangs.length}%$ytdlSubLangs';
|
||||
try {
|
||||
@@ -54,6 +59,7 @@ class AppYtDlp {
|
||||
/// 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.
|
||||
@override
|
||||
Future<LinkProbe> probe(String url, {Duration keep = _probeTtl, bool fresh = false}) {
|
||||
final now = DateTime.now();
|
||||
_probes.removeWhere((_, v) => now.isAfter(v.until));
|
||||
@@ -80,6 +86,7 @@ class AppYtDlp {
|
||||
}
|
||||
|
||||
/// [url] no longer needs keeping longer: back to the usual 10 minutes.
|
||||
@override
|
||||
void release(String url) {
|
||||
final hit = _probes[url];
|
||||
if (hit == null) return;
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/format.dart' show pickFor;
|
||||
import '../jellyfin/session_media.dart';
|
||||
import '../screens/home/picked_media.dart';
|
||||
import '../session/app_events.dart' show AddedToUpNext;
|
||||
import '../session/media_art.dart';
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../state/settings.dart';
|
||||
@@ -42,7 +43,7 @@ class QueueController {
|
||||
QueueController(this.coord);
|
||||
|
||||
/// Queued items' pictures by hash, from `/i/`.
|
||||
late final PictureCache thumbs = PictureCache(coord.sync,
|
||||
late final PictureCache thumbs = PictureCache(coord.connection.client,
|
||||
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).
|
||||
@@ -76,13 +77,12 @@ class QueueController {
|
||||
String _continuationFor = '';
|
||||
final _random = Random.secure();
|
||||
|
||||
String get _me => coord.sync.memberId;
|
||||
String get _me => coord.connection.memberId;
|
||||
|
||||
void attach() {
|
||||
if (_attached) return;
|
||||
_attached = true;
|
||||
coord.sync.states.listen(_onState);
|
||||
coord.sync.feeds.listen(_onFeed);
|
||||
coord.connection.states.listen(_onState);
|
||||
effect(() {
|
||||
final next = coord.nextEpisode.value;
|
||||
final plan = coord.jellyfinPlan.value;
|
||||
@@ -93,7 +93,7 @@ class QueueController {
|
||||
effect(() {
|
||||
shownContinuation.value;
|
||||
coord.settledMediaId.value;
|
||||
coord.selfOut.value;
|
||||
coord.selfAway.value;
|
||||
final r = coord.serverState.value;
|
||||
untracked(() => _warm(r));
|
||||
});
|
||||
@@ -107,6 +107,7 @@ class QueueController {
|
||||
Future<String?> add(PickedMedia pick, {bool next = false}) async {
|
||||
coord.markInput();
|
||||
if (coord.serverState.peek() == null) return 'not in a server';
|
||||
if (coord.stateStale.peek()) return 'reconnecting — add it again once back';
|
||||
if (items.peek().length >= RelayConstants.maxQueueItems) {
|
||||
return 'Up next is full (${RelayConstants.maxQueueItems} items)';
|
||||
}
|
||||
@@ -121,8 +122,8 @@ class QueueController {
|
||||
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');
|
||||
coord.connection.sendQueue(QueueIntent.add(item, next: next, thumb: thumb == null ? '' : base64Encode(thumb)));
|
||||
coord.notify(AddedToUpNext(item.title));
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -288,12 +289,12 @@ class QueueController {
|
||||
|
||||
void move(String id, int to) {
|
||||
coord.markInput();
|
||||
coord.sync.sendQueue(QueueIntent.move(id, to));
|
||||
coord.connection.sendQueue(QueueIntent.move(id, to));
|
||||
}
|
||||
|
||||
void remove(String id) {
|
||||
coord.markInput();
|
||||
coord.sync.sendQueue(QueueIntent.remove(id));
|
||||
coord.connection.sendQueue(QueueIntent.remove(id));
|
||||
}
|
||||
|
||||
/// Plays [id] now: this app puts it up when it is the one that can,
|
||||
@@ -306,7 +307,7 @@ class QueueController {
|
||||
if (queuePerformer(item, r.members).id == _me) {
|
||||
_perform(item);
|
||||
} else {
|
||||
coord.sync.sendQueue(QueueIntent.play(id));
|
||||
coord.connection.sendQueue(QueueIntent.play(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,12 +370,6 @@ class QueueController {
|
||||
_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.
|
||||
@@ -394,7 +389,7 @@ class QueueController {
|
||||
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));
|
||||
coord.connection.sendQueue(QueueIntent.remove(item.id, why: why));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -467,7 +462,7 @@ class QueueController {
|
||||
}
|
||||
if (error != null) {
|
||||
debugPrint('queue: ${item.id} cannot go up: $error');
|
||||
coord.sync.sendQueue(QueueIntent.remove(item.id, why: "couldn't play it ($error)"));
|
||||
coord.connection.sendQueue(QueueIntent.remove(item.id, why: "couldn't play it ($error)"));
|
||||
}
|
||||
}());
|
||||
}
|
||||
@@ -496,7 +491,7 @@ class QueueController {
|
||||
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();
|
||||
if (coord.selfAway.peek()) coord.joinBack();
|
||||
return coord.swapPicked(local.pick, queueId: item.id);
|
||||
}
|
||||
}
|
||||
@@ -534,7 +529,7 @@ class QueueController {
|
||||
/// puts up (its swap reads it). Null: nothing.
|
||||
_WarmTarget? _warmTarget(StateBroadcast? r) {
|
||||
final t = _warmTargetHere(r);
|
||||
if (t == null || !coord.selfOut.peek()) return t;
|
||||
if (t == null || !coord.selfAway.peek()) return t;
|
||||
return t.probe == null ? null : _WarmTarget(t.id, probe: t.probe);
|
||||
}
|
||||
|
||||
@@ -621,7 +616,7 @@ class QueueController {
|
||||
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}';
|
||||
final key = '${head.id}|${coord.connection.token}';
|
||||
if (key == _fedWarm) return;
|
||||
switch (head.how) {
|
||||
case QueueHow.through:
|
||||
@@ -724,7 +719,7 @@ class QueueController {
|
||||
final seen = _lastState;
|
||||
if (r == null || (seen != null && seen.mediaId > r.mediaId)) r = seen;
|
||||
if (r == null) return;
|
||||
final out = coord.selfOut.peek();
|
||||
final out = coord.selfAway.peek();
|
||||
if (!out && r.media != null && coord.settledMediaId.peek() != r.mediaId) return;
|
||||
final want = out ? null : _preloadWant;
|
||||
final playback = coord.playback;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../jellyfin/session_media.dart' show compareMemberIds;
|
||||
import '../session/party.dart' show isPresent;
|
||||
|
||||
// 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
|
||||
@@ -12,20 +13,22 @@ import '../jellyfin/session_media.dart' show compareMemberIds;
|
||||
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.
|
||||
/// (their app has the file, the cookies). Gone (disconnected, in its
|
||||
/// grace): it waits for them; left for good: 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.
|
||||
/// else the lowest member id that does (`jf[serverId] == true`,
|
||||
/// present: `in` or joining). 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.
|
||||
/// present, else the lowest present 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;
|
||||
bool active(MemberView m) => isPresent(m);
|
||||
if (item.how.adderOnly) {
|
||||
if (adder == null) return (id: null, why: '${_name(item)} left');
|
||||
if (adder.presence == Presence.gone) return (id: null, why: null);
|
||||
return (id: adder.id, why: null);
|
||||
}
|
||||
final ref = item.media.jellyfin;
|
||||
@@ -48,8 +51,9 @@ String? autoAdvanceLowest(Iterable<MemberView> members) {
|
||||
}
|
||||
|
||||
/// 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));
|
||||
/// the lowest member id that is connected (not gone).
|
||||
String? queueJanitor(List<MemberView> members) =>
|
||||
autoAdvanceLowest(members.where((m) => m.presence != Presence.gone));
|
||||
|
||||
/// How [item] plays, in a few words for its row: "through Foxyz" (you:
|
||||
/// "through you"), "server fetches", "everyone fetches", "Jellyfin",
|
||||
|
||||
@@ -3,15 +3,12 @@ import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget;
|
||||
import 'package:leaf_widgets/leaf_widgets.dart' show LeafPopover, LeafPopoverController;
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../controls/controls_probe.dart' show ControlsHold;
|
||||
import '../controls/uosc/buttons.dart' show UoscButton;
|
||||
import '../controls/uosc/controls.dart' show UoscControls;
|
||||
import '../controls/uosc/style.dart';
|
||||
import '../controls/minimal/style.dart';
|
||||
import '../files/scanner.dart' show FileKind, kindOf;
|
||||
import '../jellyfin/images.dart';
|
||||
import '../jellyfin/session_media.dart' show EpisodeLink;
|
||||
@@ -28,16 +25,16 @@ import '../widgets/text_focus.dart' show KeyboardClaim;
|
||||
import 'queue_controller.dart';
|
||||
import 'queue_logic.dart';
|
||||
|
||||
// Up next in the player: its control in the top bar (with chat and
|
||||
// fullscreen), the popover it opens (the queue, the continuation, a field
|
||||
// Up next in the player: its control in the top bar (with the window
|
||||
// controls), the popover it opens (the queue, the continuation, a field
|
||||
// and a drop zone to add), and the end-of-media card for the first item.
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- button
|
||||
|
||||
/// Which controls design the Up next control sits in: it takes the look
|
||||
/// Which controls skin the Up next control sits in: it takes the look
|
||||
/// of that bar's buttons.
|
||||
enum UpNextLook { standard, uosc }
|
||||
enum UpNextLook { standard, minimal }
|
||||
|
||||
/// The next item's picture, "UP NEXT" over its title, a +n count and ⏭
|
||||
/// (play it now) in the player's top bar, one flat control among its
|
||||
@@ -66,7 +63,7 @@ class _UpNextButtonState extends State<UpNextButton> {
|
||||
bool _hoverTitle = false;
|
||||
|
||||
QueueController get _q => widget.queue;
|
||||
bool get _uosc => widget.look == UpNextLook.uosc;
|
||||
bool get _minimal => widget.look == UpNextLook.minimal;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(UpNextButton oldWidget) {
|
||||
@@ -135,16 +132,16 @@ class _UpNextButtonState extends State<UpNextButton> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// The bar's own metrics: uosc's flat 40 px boxes, or media_kit's
|
||||
// round 44 px icon buttons.
|
||||
final height = _uosc ? UoscControls.buttonRowHeight : 44.0;
|
||||
final radius = _uosc ? UoscStyle.radius : height / 2;
|
||||
final hover = _uosc ? UoscStyle.hover : Colors.white10;
|
||||
final fontSize = _uosc ? UoscStyle.fontSize : 14.0;
|
||||
final dim = _uosc ? UoscStyle.dim : Colors.white70;
|
||||
// The bar's own metrics: the Minimal skin's rounded squares, or the
|
||||
// Standard skin's round 44 px icon buttons.
|
||||
final height = _minimal ? MinimalStyle.button + 2 * MinimalStyle.capsulePad : 44.0;
|
||||
final radius = _minimal ? MinimalStyle.radius + MinimalStyle.capsulePad : height / 2;
|
||||
final hover = _minimal ? MinimalStyle.hover : Colors.white10;
|
||||
final fontSize = _minimal ? MinimalStyle.fontSize : 14.0;
|
||||
final dim = _minimal ? MinimalStyle.dim : Colors.white70;
|
||||
// The picture fills the button's height but for a 7 px margin.
|
||||
final thumbWidth = (height - 14) * 16 / 9;
|
||||
final thumbRadius = _uosc ? UoscStyle.radius : 6.0;
|
||||
final thumbRadius = _minimal ? MinimalStyle.radius - 4 : 6.0;
|
||||
return SignalBuilder(builder: (context) {
|
||||
final items = _q.items.value;
|
||||
final cont = _q.shownContinuation.value;
|
||||
@@ -197,7 +194,7 @@ class _UpNextButtonState extends State<UpNextButton> {
|
||||
duration: const Duration(milliseconds: 150),
|
||||
height: height,
|
||||
// The picture clears a round button's curve.
|
||||
padding: EdgeInsets.only(left: _uosc ? 7 : 12, right: 6),
|
||||
padding: EdgeInsets.only(left: _minimal ? 7 : 12, right: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: _hoverTitle || open ? hover : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
@@ -234,7 +231,7 @@ class _UpNextButtonState extends State<UpNextButton> {
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(color: UoscStyle.hover, borderRadius: BorderRadius.circular(999)),
|
||||
decoration: BoxDecoration(color: MinimalStyle.hover, borderRadius: BorderRadius.circular(999)),
|
||||
child: Text('+$more', style: TextStyle(color: dim, fontSize: 11)),
|
||||
),
|
||||
],
|
||||
@@ -254,8 +251,8 @@ class _UpNextButtonState extends State<UpNextButton> {
|
||||
? 'Play ${item!.title} now ($playNextKeys)'
|
||||
: 'Play ${episode == null ? '' : episode.label} now ($playNextKeys)';
|
||||
final VoidCallback? play = queued ? _q.skip : (episode != null ? widget.onPlayNextEpisode : null);
|
||||
final playButton = _uosc
|
||||
? UoscButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play)
|
||||
final playButton = _minimal
|
||||
? MinimalButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play)
|
||||
: Tooltip(
|
||||
message: playTip,
|
||||
waitDuration: const Duration(milliseconds: 500),
|
||||
@@ -347,7 +344,7 @@ class UpNextPreview extends StatelessWidget {
|
||||
const picture = _width - 2 * _pad;
|
||||
if (item != null) {
|
||||
final r = queue.coord.serverState.value;
|
||||
final selfId = queue.coord.sync.memberId;
|
||||
final selfId = queue.coord.connection.memberId;
|
||||
final more = queue.items.value.length - 1;
|
||||
final ready = warm?.id == item.id ? warm!.state : null;
|
||||
return Column(
|
||||
@@ -452,7 +449,7 @@ class _KeyCap extends StatelessWidget {
|
||||
String? performerNote(QueueController queue, QueueItem item, StateBroadcast? r) {
|
||||
if (r == null) return null;
|
||||
final id = queuePerformer(item, r.members).id;
|
||||
if (id == null || id == queue.coord.sync.memberId) return null;
|
||||
if (id == null || id == queue.coord.connection.memberId) return null;
|
||||
final name = r.members.where((m) => m.id == id).firstOrNull?.name;
|
||||
return name == null ? null : '$name puts it up';
|
||||
}
|
||||
@@ -642,23 +639,9 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
Future<({List<String> files, String? text})> _readClipboard() async {
|
||||
var files = const <String>[];
|
||||
String? text;
|
||||
try {
|
||||
files = await ClipboardX.readFiles();
|
||||
} catch (_) {}
|
||||
if (files.isEmpty) {
|
||||
try {
|
||||
text = await ClipboardX.readText();
|
||||
} catch (_) {}
|
||||
}
|
||||
return (files: files, text: text);
|
||||
}
|
||||
|
||||
/// Ctrl+V: the clipboard's video files, else the links in its text.
|
||||
Future<void> _paste() async {
|
||||
final clip = await (widget.readClipboard ?? _readClipboard)();
|
||||
final clip = await (widget.readClipboard ?? SessionState.instance.ports.shell.readPaste)();
|
||||
final picks = _picksFrom([...clip.files, ...?clip.text?.split(RegExp(r'\s+'))]);
|
||||
if (!mounted) return;
|
||||
if (picks.isEmpty) {
|
||||
@@ -831,12 +814,12 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final height = MediaQuery.sizeOf(context).height;
|
||||
final drop = DropTarget(
|
||||
onDragEntered: (_) => setState(() => _dragging = true),
|
||||
onDragExited: (_) => setState(() => _dragging = false),
|
||||
onDragDone: (d) {
|
||||
final drop = SessionState.instance.ports.shell.dropTarget(
|
||||
onEnter: () => setState(() => _dragging = true),
|
||||
onExit: () => setState(() => _dragging = false),
|
||||
onDrop: (paths, _) {
|
||||
setState(() => _dragging = false);
|
||||
_dropped(DesktopDropX.pathsOf(d.files));
|
||||
_dropped(paths);
|
||||
},
|
||||
// Esc: the popover's own (it fades out).
|
||||
child: Builder(
|
||||
@@ -858,7 +841,7 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
final cont = _q.shownContinuation.value;
|
||||
final warm = _q.warm.value;
|
||||
final r = _q.coord.serverState.value;
|
||||
final selfId = _q.coord.sync.memberId;
|
||||
final selfId = _q.coord.connection.memberId;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -1575,7 +1558,7 @@ class UpNextCard extends StatelessWidget {
|
||||
final item = c.item;
|
||||
final left = c.secondsLeft;
|
||||
final r = queue.coord.serverState.value;
|
||||
final selfId = queue.coord.sync.memberId;
|
||||
final selfId = queue.coord.connection.memberId;
|
||||
final waiting = left == null ? performerNote(queue, item, r) : null;
|
||||
return AppGlass(
|
||||
key: const ValueKey('up-next-card'),
|
||||
|
||||
@@ -158,7 +158,7 @@ class AniListClient {
|
||||
_cacheDir = cacheDir ?? _defaultCacheDir,
|
||||
_delay = delay ?? Future<void>.delayed,
|
||||
_now = now ?? DateTime.now,
|
||||
userAgent = userAgent ?? 'watch-together/$appVersion';
|
||||
userAgent = userAgent ?? 'Starsync/$appVersion';
|
||||
|
||||
final Uri url;
|
||||
final Duration memoryTtl;
|
||||
|
||||
@@ -474,7 +474,7 @@ class AnimeFlowClient {
|
||||
_ownsHttp = http == null,
|
||||
_delay = delay ?? Future<void>.delayed,
|
||||
_now = now ?? DateTime.now,
|
||||
userAgent = userAgent ?? 'watch-together/$appVersion';
|
||||
userAgent = userAgent ?? 'Starsync/$appVersion';
|
||||
|
||||
/// Authenticated with the linked Jellyfin account on the same domain
|
||||
/// (see [tokenFor]), looked up on every call so a re-link applies.
|
||||
|
||||
@@ -12,6 +12,10 @@ import 'settings_page.dart';
|
||||
import 'ui.dart';
|
||||
import 'update_indicator.dart';
|
||||
|
||||
/// The Minimal player skin's credit (README says the same).
|
||||
const uoscCredit = 'Player timeline and controls inspired by uosc by tomasklaen, LGPL-2.1 '
|
||||
'(github.com/tomasklaen/uosc).';
|
||||
|
||||
/// Name, version, update state, the source link and the logs (Settings →
|
||||
/// About).
|
||||
class AboutPanel extends StatelessWidget {
|
||||
@@ -48,6 +52,8 @@ class AboutPanel extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(uoscCredit, key: const ValueKey('uosc-credit'), style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
const SizedBox(height: kGap),
|
||||
Text('Something went wrong? Copy diagnostics and send them to whoever helps you: '
|
||||
'versions and the last hours of the log, passwords, tokens and cookies left out. '
|
||||
@@ -113,8 +119,7 @@ class AboutPanel extends StatelessWidget {
|
||||
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())) {
|
||||
if (!await SessionState.instance.ports.folders.reveal(dir)) {
|
||||
await copyToClipboard(dir, done: "Couldn't open it — folder path copied");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../session/connection.dart' show connectErrorText;
|
||||
import '../../session/identity.dart';
|
||||
import '../../session/session_coordinator.dart';
|
||||
import '../../state/session_state.dart';
|
||||
import 'profile.dart';
|
||||
import '../../sync/sync_client.dart' show HelloRefused;
|
||||
|
||||
/// What a card shows under its fields after a failed attempt.
|
||||
class CardError {
|
||||
@@ -107,7 +109,9 @@ class ConnectFlow extends ChangeNotifier {
|
||||
} catch (e) {
|
||||
if (myAttempt != _attempt) return;
|
||||
busy = null;
|
||||
errors[mode] = CardError('Could not connect', '$e');
|
||||
// A refusal says why in plain words (an older host, a wrong password,
|
||||
// another window).
|
||||
errors[mode] = CardError('Could not connect', e is HelloRefused ? connectErrorText(e) : '$e');
|
||||
_notify();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../../state/session_state.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../../update/update_controller.dart';
|
||||
import 'ui.dart';
|
||||
@@ -89,12 +89,11 @@ class CustomShaderFiles extends StatelessWidget {
|
||||
const CustomShaderFiles({super.key});
|
||||
|
||||
Future<void> _addFiles() async {
|
||||
final List<PlatformFile> files;
|
||||
final List<String> files;
|
||||
try {
|
||||
files = await FilePicker.pickFiles(
|
||||
files = await SessionState.instance.ports.folders.pickFiles(
|
||||
dialogTitle: 'Pick mpv GLSL shaders',
|
||||
type: FileType.custom,
|
||||
allowedExtensions: const ['glsl', 'hook'],
|
||||
extensions: const ['glsl', 'hook'],
|
||||
);
|
||||
} catch (_) {
|
||||
return;
|
||||
@@ -102,8 +101,8 @@ class CustomShaderFiles extends StatelessWidget {
|
||||
final s = AppSettings.instance;
|
||||
final current = s.customShaders.peek();
|
||||
final added = [
|
||||
for (final f in files)
|
||||
if (f.path case final p? when !current.contains(p)) p,
|
||||
for (final p in files)
|
||||
if (!current.contains(p)) p,
|
||||
];
|
||||
if (added.isNotEmpty) s.setCustomShaders([...current, ...added]);
|
||||
}
|
||||
@@ -198,8 +197,8 @@ class UpdateSettings extends StatelessWidget {
|
||||
builder: (context) => SwitchListTile(
|
||||
key: const ValueKey('auto-check-updates'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Check for updates on start'),
|
||||
subtitle: const Text('Looks for a new version when the app opens; nothing installs without asking'),
|
||||
title: const Text('Check for updates automatically'),
|
||||
subtitle: const Text('When the app opens and every few hours; nothing installs without asking'),
|
||||
value: s.autoCheckUpdates.value,
|
||||
onChanged: s.setAutoCheckUpdates,
|
||||
),
|
||||
|
||||
@@ -22,6 +22,10 @@ String displayVersion({String version = appVersion, String pubspec = _pubspecVer
|
||||
return isDevBuild(version) && commit.isNotEmpty ? '$version · $commit' : version;
|
||||
}
|
||||
|
||||
/// The version in a few characters, always on screen (under the strip's
|
||||
/// Settings tab): "1.2.1", "1.2.1-dev.2", "dev run".
|
||||
String shortVersion({String version = appVersion}) => version == '0.0.0-dev' ? 'dev run' : version;
|
||||
|
||||
const kRadius = 12.0;
|
||||
const kGap = 16.0;
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart' show isDevBuild;
|
||||
|
||||
import '../../update/update_controller.dart';
|
||||
import '../../widgets/update_banner.dart';
|
||||
import 'ui.dart';
|
||||
|
||||
/// Compact "Update vX" pill for the party strip; hidden until there is
|
||||
/// something to install. Click opens the full [UpdateBanner].
|
||||
class UpdateIndicator extends StatelessWidget {
|
||||
/// The band across the top of the window while an update is up for
|
||||
/// grabs: "Starsync 1.2.1 is ready", its notes, Update and restart and
|
||||
/// Later (gone until the next check or start). The shell keeps it off
|
||||
/// the full player while a video is on ([AppShell]).
|
||||
class UpdateBar extends StatelessWidget {
|
||||
final UpdateController? controller;
|
||||
const UpdateIndicator({super.key, this.controller});
|
||||
const UpdateBar({super.key, this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -21,34 +24,102 @@ class UpdateIndicator extends StatelessWidget {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final alert = m.isRequired || s.phase == UpdatePhase.failed;
|
||||
final fg = alert ? scheme.onErrorContainer : scheme.onPrimaryContainer;
|
||||
final current = c.currentVersion;
|
||||
// A release (not a test build) that is behind: say which it runs.
|
||||
final behind = isDevBuild(current) ? null : 'You are running $current';
|
||||
final String title;
|
||||
String detail;
|
||||
switch (s.phase) {
|
||||
case UpdatePhase.downloading:
|
||||
title = 'Downloading $appName ${m.version}… ${((s.progress ?? 0) * 100).round()}%';
|
||||
detail = '';
|
||||
case UpdatePhase.applying:
|
||||
title = 'Installing $appName ${m.version}…';
|
||||
detail = '';
|
||||
case UpdatePhase.restarting:
|
||||
title = 'Restarting into $appName ${m.version}…';
|
||||
detail = '';
|
||||
case UpdatePhase.failed:
|
||||
title = 'Update to $appName ${m.version} failed';
|
||||
detail = '${s.error}';
|
||||
case UpdatePhase.available || UpdatePhase.idle:
|
||||
if (!s.canInstall) {
|
||||
title = 'This copy of $appName is older than the latest release (${m.version})';
|
||||
detail = 'Download the new release and replace this folder by hand';
|
||||
} else if (m.isRequired) {
|
||||
title = '$appName ${m.version} is required to join servers on it';
|
||||
detail = m.notes;
|
||||
} else {
|
||||
title = '$appName ${m.version} is ready';
|
||||
detail = m.notes;
|
||||
}
|
||||
detail = [?behind, detail].where((t) => t.isNotEmpty).join(' · ');
|
||||
}
|
||||
final busy = s.phase == UpdatePhase.downloading ||
|
||||
s.phase == UpdatePhase.applying ||
|
||||
s.phase == UpdatePhase.restarting;
|
||||
final label = switch (s.phase) {
|
||||
UpdatePhase.downloading =>
|
||||
'Updating ${((s.progress ?? 0) * 100).round()}%',
|
||||
UpdatePhase.applying => 'Installing…',
|
||||
UpdatePhase.restarting => 'Restarting…',
|
||||
UpdatePhase.failed => 'Update failed',
|
||||
_ => m.isRequired ? 'Required update' : 'Update v${m.version}',
|
||||
};
|
||||
return Tooltip(
|
||||
message: 'Show update',
|
||||
child: FilledButton.tonalIcon(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: m.isRequired || s.phase == UpdatePhase.failed
|
||||
? scheme.errorContainer
|
||||
: null,
|
||||
visualDensity: VisualDensity.compact,
|
||||
return Material(
|
||||
key: const ValueKey('update-bar'),
|
||||
color: alert ? scheme.errorContainer : scheme.primaryContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
if (busy)
|
||||
SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: fg,
|
||||
value: s.phase == UpdatePhase.downloading ? s.progress : null,
|
||||
),
|
||||
)
|
||||
else
|
||||
Icon(Icons.system_update_alt, size: 20, color: fg),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fg, fontWeight: FontWeight.w600)),
|
||||
if (detail.isNotEmpty)
|
||||
Tooltip(
|
||||
message: detail,
|
||||
waitDuration: const Duration(milliseconds: 600),
|
||||
child: Text(detail,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fg.withValues(alpha: 0.8), fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!busy) ...[
|
||||
const SizedBox(width: 12),
|
||||
if (!m.isRequired)
|
||||
TextButton(
|
||||
key: const ValueKey('update-later'),
|
||||
style: TextButton.styleFrom(foregroundColor: fg),
|
||||
onPressed: c.dismiss,
|
||||
child: const Text('Later'),
|
||||
),
|
||||
if (s.canInstall) ...[
|
||||
const SizedBox(width: 4),
|
||||
FilledButton(
|
||||
key: const ValueKey('update-install'),
|
||||
onPressed: c.install,
|
||||
child: Text(s.phase == UpdatePhase.failed ? 'Retry' : 'Update and restart'),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
onPressed: () => showUpdateDialog(context, controller: c),
|
||||
icon: busy
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.system_update_alt, size: 18),
|
||||
label: Text(label),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -85,6 +156,9 @@ Future<void> showUpdateDialog(BuildContext context, {UpdateController? controlle
|
||||
);
|
||||
}
|
||||
|
||||
/// How often a running app looks again (auto-check on).
|
||||
const updateCheckEvery = Duration(hours: 4);
|
||||
|
||||
enum UpdateCheck { notRun, checking, done }
|
||||
|
||||
/// Progress of this run's update check, for Settings/About wording.
|
||||
|
||||
@@ -8,35 +8,6 @@ import 'player_controls.dart' show FloatingPill;
|
||||
// Jellyfin pieces of the session player. Plain widgets fed by the
|
||||
// coordinator's signals, so tests can pump them without the player.
|
||||
|
||||
/// Previous / next episode in the video controls. Nothing when there is
|
||||
/// no such episode or this viewer cannot put it up (no account).
|
||||
class EpisodeSkipButton extends StatelessWidget {
|
||||
final EpisodeLink? episode;
|
||||
final bool next;
|
||||
final VoidCallback onPressed;
|
||||
final FocusNode? focusNode;
|
||||
const EpisodeSkipButton(
|
||||
{super.key, required this.episode, required this.next, required this.onPressed, this.focusNode});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final e = episode;
|
||||
if (e == null) return const SizedBox.shrink();
|
||||
return Tooltip(
|
||||
message: '${next ? 'Next' : 'Previous'}: ${e.label} (${next ? '> or Shift+N' : '< or Shift+P'})',
|
||||
child: IconButton(
|
||||
key: ValueKey(next ? 'episode-next' : 'episode-previous'),
|
||||
focusNode: focusNode,
|
||||
color: Colors.white,
|
||||
iconSize: 22,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: Icon(next ? Icons.skip_next_rounded : Icons.skip_previous_rounded),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Floating "Next episode ›" from the ED (or the last two minutes), while
|
||||
/// the controls are up (`PillGate`): faint at rest, lit on hover.
|
||||
class NextEpisodePill extends StatelessWidget {
|
||||
|
||||
@@ -1,530 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
||||
|
||||
import '../../jellyfin/track_prefs.dart' show MpvTrack;
|
||||
import '../../playback/media_marks.dart';
|
||||
import '../../widgets/app_menu.dart' show AppGlass;
|
||||
|
||||
// Session control-bar pieces built on the playback signals (chapters,
|
||||
// tracks, skip offers): the seek bar with chapter marks, the chapter and
|
||||
// track menus, the 1:30 skip button and the floating skip pill. Plain
|
||||
// widgets fed by values and callbacks, so tests pump them without a player.
|
||||
|
||||
/// Keeps media_kit's desktop controls shown, as a mouse hover would.
|
||||
/// While a seek bar drag runs no hover events arrive, and the controls
|
||||
/// would hide (and unmount the bar) mid-drag. A guarded dynamic call on
|
||||
/// the private controls state; nothing happens outside those controls.
|
||||
void keepVideoControlsAwake(BuildContext context) {
|
||||
context.visitAncestorElements((e) {
|
||||
if (e is StatefulElement &&
|
||||
e.state.runtimeType.toString() == '_MaterialDesktopVideoControlsState') {
|
||||
try {
|
||||
(e.state as dynamic).onHover();
|
||||
} catch (_) {}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/// The seek bar: played / buffered track, a diamond at each chapter start
|
||||
/// (not the one at 0), and on hover the time with the chapter title above
|
||||
/// it. Like media_kit's bar it reports a seek on every drag move and on
|
||||
/// release ([onSeek]); the session routes those through the user-seek path
|
||||
/// (debounced, everyone follows).
|
||||
class ChapterSeekBar extends StatefulWidget {
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
final Duration buffer;
|
||||
final List<MediaChapter> chapters;
|
||||
final ValueChanged<Duration> onSeek;
|
||||
/// Any pointer activity on the bar (keeps the controls up).
|
||||
final VoidCallback? onActivity;
|
||||
const ChapterSeekBar({
|
||||
super.key,
|
||||
required this.position,
|
||||
required this.duration,
|
||||
this.buffer = Duration.zero,
|
||||
this.chapters = const [],
|
||||
required this.onSeek,
|
||||
this.onActivity,
|
||||
});
|
||||
|
||||
static const height = 28.0;
|
||||
|
||||
@override
|
||||
State<ChapterSeekBar> createState() => _ChapterSeekBarState();
|
||||
}
|
||||
|
||||
class _ChapterSeekBarState extends State<ChapterSeekBar> {
|
||||
double? _hoverX;
|
||||
bool _dragging = false;
|
||||
double _dragFraction = 0;
|
||||
|
||||
double _fraction(Duration d) {
|
||||
final total = widget.duration.inMicroseconds;
|
||||
if (total <= 0) return 0;
|
||||
return (d.inMicroseconds / total).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
|
||||
|
||||
void _seekTo(double x, double width) {
|
||||
if (widget.duration <= Duration.zero || width <= 0) return;
|
||||
_dragFraction = (x / width).clamp(0.0, 1.0);
|
||||
widget.onSeek(_at(_dragFraction));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
return LayoutBuilder(builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final active = _hoverX != null || _dragging;
|
||||
final barHeight = active ? 5.0 : 3.0;
|
||||
final played = _dragging ? _dragFraction : _fraction(widget.position);
|
||||
const mid = ChapterSeekBar.height / 2;
|
||||
final hoverX = _hoverX;
|
||||
final hoverAt = hoverX == null || width <= 0 ? null : _at(hoverX / width);
|
||||
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
|
||||
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
|
||||
onHover: (e) {
|
||||
setState(() => _hoverX = e.localPosition.dx.clamp(0, width));
|
||||
widget.onActivity?.call();
|
||||
},
|
||||
onExit: (_) => setState(() => _hoverX = null),
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: (e) {
|
||||
widget.onActivity?.call();
|
||||
setState(() {
|
||||
_dragging = true;
|
||||
_dragFraction = width <= 0 ? 0 : (e.localPosition.dx / width).clamp(0.0, 1.0);
|
||||
});
|
||||
},
|
||||
onPointerMove: (e) {
|
||||
widget.onActivity?.call();
|
||||
setState(() => _hoverX = e.localPosition.dx.clamp(0, width));
|
||||
_seekTo(e.localPosition.dx, width);
|
||||
},
|
||||
onPointerUp: (e) {
|
||||
_seekTo(e.localPosition.dx, width);
|
||||
setState(() => _dragging = false);
|
||||
widget.onActivity?.call();
|
||||
},
|
||||
onPointerCancel: (_) => setState(() => _dragging = false),
|
||||
child: SizedBox(
|
||||
key: const ValueKey('seek-bar'),
|
||||
width: width,
|
||||
height: ChapterSeekBar.height,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: mid - barHeight / 2,
|
||||
height: barHeight,
|
||||
child: Container(color: Colors.white24),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: mid - barHeight / 2,
|
||||
height: barHeight,
|
||||
width: width * _fraction(widget.buffer),
|
||||
child: Container(color: Colors.white24),
|
||||
),
|
||||
if (hoverX != null && !_dragging)
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: mid - barHeight / 2,
|
||||
height: barHeight,
|
||||
width: hoverX,
|
||||
child: Container(color: Colors.white24),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: mid - barHeight / 2,
|
||||
height: barHeight,
|
||||
width: width * played,
|
||||
child: Container(color: primary),
|
||||
),
|
||||
for (final c in widget.chapters)
|
||||
if (c.start > Duration.zero && widget.duration > Duration.zero)
|
||||
Positioned(
|
||||
key: ValueKey('chapter-mark-${c.index}'),
|
||||
left: width * _fraction(c.start) - 4,
|
||||
top: mid - 4,
|
||||
width: 8,
|
||||
height: 8,
|
||||
child: IgnorePointer(
|
||||
child: Transform.rotate(
|
||||
angle: math.pi / 4,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.black54, width: 0.8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (active)
|
||||
Positioned(
|
||||
left: width * played - 6,
|
||||
top: mid - 6,
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(color: primary, shape: BoxShape.circle),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hoverAt != null)
|
||||
Positioned(
|
||||
// Kept clear of the window edges (the tip is centred).
|
||||
left: hoverX!.clamp(math.min(60.0, width / 2), math.max(width - 60, width / 2)),
|
||||
bottom: ChapterSeekBar.height - 2,
|
||||
child: IgnorePointer(
|
||||
child: FractionalTranslation(
|
||||
translation: const Offset(-0.5, 0),
|
||||
child: _HoverTip(title: hoverTitle, time: formatClock(hoverAt)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _HoverTip extends StatelessWidget {
|
||||
final String title;
|
||||
final String time;
|
||||
const _HoverTip({required this.title, required this.time});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 320),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.8),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (title.isNotEmpty)
|
||||
Text(title,
|
||||
key: const ValueKey('seek-hover-chapter'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600)),
|
||||
Text(time,
|
||||
key: const ValueKey('seek-hover-time'),
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [ChapterSeekBar] on a live player: position, duration and buffer from
|
||||
/// its streams. Seeks go to `player.seek`, which the session's
|
||||
/// `SyncPlayer` turns into user seeks.
|
||||
class PlayerSeekBar extends StatefulWidget {
|
||||
final Player player;
|
||||
final List<MediaChapter> chapters;
|
||||
const PlayerSeekBar({super.key, required this.player, required this.chapters});
|
||||
|
||||
@override
|
||||
State<PlayerSeekBar> createState() => _PlayerSeekBarState();
|
||||
}
|
||||
|
||||
class _PlayerSeekBarState extends State<PlayerSeekBar> {
|
||||
final List<StreamSubscription<Object>> _subs = [];
|
||||
late Duration _position, _duration, _buffer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_listen();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PlayerSeekBar old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (!identical(old.player, widget.player)) {
|
||||
_cancel();
|
||||
_listen();
|
||||
}
|
||||
}
|
||||
|
||||
void _listen() {
|
||||
final p = widget.player;
|
||||
_position = p.state.position;
|
||||
_duration = p.state.duration;
|
||||
_buffer = p.state.buffer;
|
||||
void set(VoidCallback f) {
|
||||
if (mounted) setState(f);
|
||||
}
|
||||
|
||||
_subs.addAll([
|
||||
p.stream.position.listen((d) => set(() => _position = d)),
|
||||
p.stream.duration.listen((d) => set(() => _duration = d)),
|
||||
p.stream.buffer.listen((d) => set(() => _buffer = d)),
|
||||
]);
|
||||
}
|
||||
|
||||
void _cancel() {
|
||||
for (final s in _subs) {
|
||||
s.cancel();
|
||||
}
|
||||
_subs.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChapterSeekBar(
|
||||
position: _position,
|
||||
duration: _duration,
|
||||
buffer: _buffer,
|
||||
chapters: widget.chapters,
|
||||
onSeek: (d) => widget.player.seek(d),
|
||||
onActivity: () => keepVideoControlsAwake(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RelativeRect _menuPosition(BuildContext context) {
|
||||
final button = context.findRenderObject() as RenderBox;
|
||||
final overlay = Navigator.of(context).overlay!.context.findRenderObject() as RenderBox;
|
||||
return RelativeRect.fromRect(
|
||||
Rect.fromPoints(
|
||||
button.localToGlobal(Offset.zero, ancestor: overlay),
|
||||
button.localToGlobal(button.size.bottomRight(Offset.zero), ancestor: overlay),
|
||||
),
|
||||
Offset.zero & overlay.size,
|
||||
);
|
||||
}
|
||||
|
||||
/// Control-bar icon with a small count in the corner.
|
||||
class _BadgedIcon extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final int count;
|
||||
const _BadgedIcon(this.icon, this.count);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Icon(icon),
|
||||
if (count > 0)
|
||||
Positioned(
|
||||
right: -7,
|
||||
top: -5,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0.5),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text('$count',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 9, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Chapter list for the controls: current chapter checked, a pick seeks
|
||||
/// to its start. Nothing when the file has no chapters.
|
||||
class ChaptersControlButton extends StatelessWidget {
|
||||
final List<MediaChapter> chapters;
|
||||
final int? current;
|
||||
final ValueChanged<MediaChapter> onPick;
|
||||
final FocusNode? focusNode;
|
||||
final ValueChanged<bool>? onMenuOpen;
|
||||
const ChaptersControlButton({
|
||||
super.key,
|
||||
required this.chapters,
|
||||
required this.current,
|
||||
required this.onPick,
|
||||
this.focusNode,
|
||||
this.onMenuOpen,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (chapters.isEmpty) return const SizedBox.shrink();
|
||||
return Tooltip(
|
||||
message: 'Chapters (PgUp / PgDn)',
|
||||
child: IconButton(
|
||||
key: const ValueKey('chapters-button'),
|
||||
focusNode: focusNode,
|
||||
color: Colors.white,
|
||||
iconSize: 20,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: _BadgedIcon(Icons.format_list_bulleted, chapters.length),
|
||||
onPressed: () => _open(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _open(BuildContext context) async {
|
||||
final position = _menuPosition(context);
|
||||
onMenuOpen?.call(true);
|
||||
final pick = await showMenu<MediaChapter>(
|
||||
context: context,
|
||||
position: position,
|
||||
popUpAnimationStyle: AnimationStyle.noAnimation,
|
||||
constraints: const BoxConstraints(minWidth: 240, maxWidth: 420),
|
||||
items: [
|
||||
for (final c in chapters)
|
||||
CheckedPopupMenuItem(
|
||||
value: c,
|
||||
checked: c.index == current,
|
||||
height: 36,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(c.title.isEmpty ? 'Chapter ${c.index + 1}' : c.title,
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(formatClock(c.start), style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
onMenuOpen?.call(false);
|
||||
if (pick != null) onPick(pick);
|
||||
}
|
||||
}
|
||||
|
||||
/// A track pick from [TrackMenuButton]: audio id, subtitle id, or off.
|
||||
typedef TrackPick = ({int? aid, int? sid, bool subtitlesOff});
|
||||
|
||||
/// Audio or subtitle menu for the controls bar (the count of tracks on the
|
||||
/// icon): "Japanese · E-AC3 2.0 · title", mpv's selection checked,
|
||||
/// subtitles with Off. The choice is remembered for the next Jellyfin item
|
||||
/// (per viewer).
|
||||
class TrackMenuButton extends StatelessWidget {
|
||||
final JellyfinStreamType type;
|
||||
/// All tracks; only those of [type] are listed.
|
||||
final List<MpvTrack> tracks;
|
||||
final void Function(TrackPick pick) onPick;
|
||||
final FocusNode? focusNode;
|
||||
final ValueChanged<bool>? onMenuOpen;
|
||||
const TrackMenuButton({
|
||||
super.key,
|
||||
required this.type,
|
||||
required this.tracks,
|
||||
required this.onPick,
|
||||
this.focusNode,
|
||||
this.onMenuOpen,
|
||||
});
|
||||
|
||||
bool get _audio => type == JellyfinStreamType.audio;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final list = tracks.where((t) => t.type == type).toList();
|
||||
return Tooltip(
|
||||
message: _audio ? 'Audio' : 'Subtitles',
|
||||
child: IconButton(
|
||||
key: ValueKey(_audio ? 'audio-button' : 'subtitles-button'),
|
||||
focusNode: focusNode,
|
||||
color: Colors.white,
|
||||
iconSize: 20,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: _BadgedIcon(_audio ? Icons.audiotrack_outlined : Icons.subtitles_outlined, list.length),
|
||||
onPressed: () => _open(context, list),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _open(BuildContext context, List<MpvTrack> list) async {
|
||||
final position = _menuPosition(context);
|
||||
onMenuOpen?.call(true);
|
||||
final pick = await showMenu<TrackPick>(
|
||||
context: context,
|
||||
position: position,
|
||||
popUpAnimationStyle: AnimationStyle.noAnimation,
|
||||
constraints: const BoxConstraints(minWidth: 280, maxWidth: 460),
|
||||
items: [
|
||||
if (!_audio)
|
||||
CheckedPopupMenuItem(
|
||||
value: (aid: null, sid: null, subtitlesOff: true),
|
||||
checked: !list.any((t) => t.selected),
|
||||
height: 36,
|
||||
child: const Text('Off'),
|
||||
),
|
||||
if (_audio && list.isEmpty)
|
||||
const PopupMenuItem(enabled: false, height: 32, child: Text('None')),
|
||||
for (final t in list)
|
||||
CheckedPopupMenuItem(
|
||||
value: _audio
|
||||
? (aid: t.id, sid: null, subtitlesOff: false)
|
||||
: (aid: null, sid: t.id, subtitlesOff: false),
|
||||
checked: t.selected,
|
||||
height: 36,
|
||||
child: Text(t.label, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
);
|
||||
onMenuOpen?.call(false);
|
||||
if (pick != null) onPick(pick);
|
||||
}
|
||||
}
|
||||
|
||||
/// "1:30" in the controls: skip forward [fallbackSkip] (Shift+Right).
|
||||
class SkipForwardButton extends StatelessWidget {
|
||||
final VoidCallback onPressed;
|
||||
final FocusNode? focusNode;
|
||||
const SkipForwardButton({super.key, required this.onPressed, this.focusNode});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: 'Skip 1:30 for everyone (Shift+Right)',
|
||||
child: TextButton.icon(
|
||||
key: const ValueKey('skip-forward-button'),
|
||||
focusNode: focusNode,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
onPressed: onPressed,
|
||||
icon: const Icon(Icons.fast_forward_rounded, size: 16),
|
||||
label: const Text('1:30', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// The player's floating action pills (Skip OP / ED / 1:30, Next episode):
|
||||
// plain widgets fed by values and callbacks, so tests pump them without a
|
||||
// player. They live in the action area, outside the controls.
|
||||
|
||||
/// The player's action pill (Next episode, Skip OP / ED / 1:30): the
|
||||
/// status pill's glass ([AppGlass.pill]) at [height], faint at rest, lit
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import 'package:sync_relay_core/core.dart' show FeedMessage;
|
||||
|
||||
// Everything that may become a toast, a sound, an attention request or an
|
||||
// activity-log line, as one sealed type. Code that has news emits an
|
||||
// event ([Notifier.notify]); only the notifier decides what the user sees
|
||||
// and hears. Pure Dart (no Flutter): it can move to core with the policy.
|
||||
|
||||
sealed class AppEvent {
|
||||
const AppEvent();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- feed
|
||||
|
||||
/// A server `feed` line (see PROTOCOL.md, `feed.action`); every one lands
|
||||
/// in the activity log.
|
||||
sealed class FeedEvent extends AppEvent {
|
||||
final FeedMessage feed;
|
||||
const FeedEvent(this.feed);
|
||||
|
||||
String get actor => feed.actor;
|
||||
|
||||
/// [feed] as its typed event. [selfId]: this member's id (people are
|
||||
/// matched by `actorId` / `targetId`, never by name); [readyCount]:
|
||||
/// members ready in the latest state (the pitch of a `ready` sound).
|
||||
factory FeedEvent.of(FeedMessage f, {String selfId = '', int readyCount = 0}) {
|
||||
bool isSelf(String id) => selfId.isNotEmpty && id == selfId;
|
||||
return switch (f.action) {
|
||||
'join' => MemberJoined(f),
|
||||
'leave' => MemberLeft(f),
|
||||
'gone' => MemberGone(f),
|
||||
'return' => MemberReturned(f),
|
||||
'away' => MemberSatOut(f,
|
||||
atYou: isSelf(f.targetId) && !isSelf(f.actorId),
|
||||
autoSelf: isSelf(f.actorId) && f.targetId.isEmpty && f.reason.isNotEmpty),
|
||||
'in' => MemberBack(f),
|
||||
'ready' => ReadyChanged(f, ready: true, readyIndex: readyCount),
|
||||
'unready' => ReadyChanged(f, ready: false),
|
||||
'force-ready' => ForcedReady(f, ready: true),
|
||||
'force-unready' => ForcedReady(f, ready: false),
|
||||
'force-play' => ForcedTransport(f, play: true),
|
||||
'force-hold' => ForcedTransport(f, play: false),
|
||||
'swap' => MediaSwapped(f),
|
||||
'seek' => Seeked(f),
|
||||
'pause' => Paused(f),
|
||||
'resume' => Resumed(f),
|
||||
'finished' => Finished(f),
|
||||
'chat' => ChatPosted(f, own: isSelf(f.actorId)),
|
||||
'rename' => MemberRenamed(f),
|
||||
'avatar' => AvatarChanged(f),
|
||||
'queue-add' => QueueAdded(f),
|
||||
'queue-remove' => QueueRemoved(f),
|
||||
'queue-skip' => QueueSkipped(f),
|
||||
'fetch-fail' => FetchFailed(f),
|
||||
_ => OtherFeed(f),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class MemberJoined extends FeedEvent {
|
||||
const MemberJoined(super.feed);
|
||||
}
|
||||
|
||||
/// Left for good (`reason` `bye`: the app closed or Leave; `grace`: gone
|
||||
/// too long).
|
||||
class MemberLeft extends FeedEvent {
|
||||
const MemberLeft(super.feed);
|
||||
}
|
||||
|
||||
/// Lost signal: gone for a while, kept in its grace period.
|
||||
class MemberGone extends FeedEvent {
|
||||
const MemberGone(super.feed);
|
||||
}
|
||||
|
||||
/// Back within its grace after a [MemberGone].
|
||||
class MemberReturned extends FeedEvent {
|
||||
const MemberReturned(super.feed);
|
||||
}
|
||||
|
||||
/// Someone sat out (`away`: the actor itself, or `target` sat out by the
|
||||
/// actor; `reason` `idle` / `minimised` for an automatic one); [atYou]:
|
||||
/// someone else sat this member out; [autoSelf]: this app's auto sit-out
|
||||
/// set this member away.
|
||||
class MemberSatOut extends FeedEvent {
|
||||
final bool atYou;
|
||||
final bool autoSelf;
|
||||
const MemberSatOut(super.feed, {this.atYou = false, this.autoSelf = false});
|
||||
}
|
||||
|
||||
/// Back from sitting out (`in`).
|
||||
class MemberBack extends FeedEvent {
|
||||
const MemberBack(super.feed);
|
||||
}
|
||||
|
||||
class ReadyChanged extends FeedEvent {
|
||||
final bool ready;
|
||||
/// Members ready when it came (pitch of the ready sound).
|
||||
final int readyIndex;
|
||||
const ReadyChanged(super.feed, {required this.ready, this.readyIndex = 0});
|
||||
}
|
||||
|
||||
class ForcedReady extends FeedEvent {
|
||||
final bool ready;
|
||||
const ForcedReady(super.feed, {required this.ready});
|
||||
}
|
||||
|
||||
/// Force play / hold.
|
||||
class ForcedTransport extends FeedEvent {
|
||||
final bool play;
|
||||
const ForcedTransport(super.feed, {required this.play});
|
||||
}
|
||||
|
||||
class MediaSwapped extends FeedEvent {
|
||||
const MediaSwapped(super.feed);
|
||||
}
|
||||
|
||||
class Seeked extends FeedEvent {
|
||||
const Seeked(super.feed);
|
||||
}
|
||||
|
||||
class Paused extends FeedEvent {
|
||||
const Paused(super.feed);
|
||||
}
|
||||
|
||||
class Resumed extends FeedEvent {
|
||||
const Resumed(super.feed);
|
||||
}
|
||||
|
||||
class Finished extends FeedEvent {
|
||||
const Finished(super.feed);
|
||||
}
|
||||
|
||||
/// A chat message (`reason` carries the text); [own]: this member sent it.
|
||||
class ChatPosted extends FeedEvent {
|
||||
final bool own;
|
||||
const ChatPosted(super.feed, {this.own = false});
|
||||
}
|
||||
|
||||
class MemberRenamed extends FeedEvent {
|
||||
const MemberRenamed(super.feed);
|
||||
}
|
||||
|
||||
class AvatarChanged extends FeedEvent {
|
||||
const AvatarChanged(super.feed);
|
||||
}
|
||||
|
||||
class QueueAdded extends FeedEvent {
|
||||
const QueueAdded(super.feed);
|
||||
}
|
||||
|
||||
class QueueRemoved extends FeedEvent {
|
||||
const QueueRemoved(super.feed);
|
||||
}
|
||||
|
||||
/// Up next skipped an item (`target` its title, `reason` why).
|
||||
class QueueSkipped extends FeedEvent {
|
||||
const QueueSkipped(super.feed);
|
||||
}
|
||||
|
||||
/// The server could not fetch the current media.
|
||||
class FetchFailed extends FeedEvent {
|
||||
const FetchFailed(super.feed);
|
||||
}
|
||||
|
||||
/// An action this app does not know (a newer server).
|
||||
class OtherFeed extends FeedEvent {
|
||||
const OtherFeed(super.feed);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- session
|
||||
|
||||
/// Someone poked this member.
|
||||
class Nudged extends AppEvent {
|
||||
/// Their name; '' when unknown.
|
||||
final String name;
|
||||
const Nudged(this.name);
|
||||
}
|
||||
|
||||
/// Another member's player cannot open the current media.
|
||||
class MemberCantPlay extends AppEvent {
|
||||
final String name;
|
||||
final String error;
|
||||
const MemberCantPlay(this.name, this.error);
|
||||
}
|
||||
|
||||
/// This member passes the video on slower than it plays.
|
||||
class SlowSource extends AppEvent {
|
||||
final String text;
|
||||
const SlowSource(this.text);
|
||||
}
|
||||
|
||||
/// This member started passing the video on through the server.
|
||||
class RelayStarted extends AppEvent {
|
||||
const RelayStarted();
|
||||
}
|
||||
|
||||
class RelayFailed extends AppEvent {
|
||||
final String error;
|
||||
const RelayFailed(this.error);
|
||||
}
|
||||
|
||||
/// The connection to the server dropped and stayed down a few seconds
|
||||
/// (once per drop).
|
||||
class ConnectionLost extends AppEvent {
|
||||
const ConnectionLost();
|
||||
}
|
||||
|
||||
/// It came back after [ConnectionLost].
|
||||
class ConnectionBack extends AppEvent {
|
||||
const ConnectionBack();
|
||||
}
|
||||
|
||||
/// Jellyfin progress reports fail (once per media).
|
||||
class JellyfinProgressFailed extends AppEvent {
|
||||
final bool signInExpired;
|
||||
const JellyfinProgressFailed({required this.signInExpired});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ app
|
||||
|
||||
/// The player's shader message (a preset change, shaders turned off).
|
||||
class ShaderMessage extends AppEvent {
|
||||
final String text;
|
||||
const ShaderMessage(this.text);
|
||||
}
|
||||
|
||||
/// This app added an item to Up next.
|
||||
class AddedToUpNext extends AppEvent {
|
||||
final String title;
|
||||
const AddedToUpNext(this.title);
|
||||
}
|
||||
|
||||
/// Putting something up or adding it to Up next failed ([text]: why).
|
||||
class ActionFailed extends AppEvent {
|
||||
final String text;
|
||||
const ActionFailed(this.text);
|
||||
}
|
||||
|
||||
/// The Schedule tab's request results and errors.
|
||||
class ScheduleResult extends AppEvent {
|
||||
final String text;
|
||||
const ScheduleResult(this.text);
|
||||
}
|
||||
|
||||
/// "Mark a problem" done; [inSession]: in the server's session log.
|
||||
class ProblemMarked extends AppEvent {
|
||||
final bool inSession;
|
||||
const ProblemMarked({required this.inSession});
|
||||
}
|
||||
|
||||
/// Something went on the clipboard ([text]: the confirmation, e.g.
|
||||
/// "Link copied").
|
||||
class Copied extends AppEvent {
|
||||
final String text;
|
||||
const Copied(this.text);
|
||||
}
|
||||
|
||||
class CopyFailed extends AppEvent {
|
||||
const CopyFailed();
|
||||
}
|
||||
|
||||
/// A newer release is out (the update band shows it).
|
||||
class UpdateAvailable extends AppEvent {
|
||||
final String version;
|
||||
const UpdateAvailable(this.version);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:sync_relay_core/core.dart' show Presence, PresenceReason;
|
||||
|
||||
import '../platform/ports/shell_port.dart' show WindowActivity;
|
||||
|
||||
/// The auto sit-out timings, in one place.
|
||||
abstract final class AutoSitOutTimes {
|
||||
/// Not playing and no input for this long: away (`idle`). Watching
|
||||
/// counts as activity, so a pause after a long watch starts fresh.
|
||||
static const idle = Duration(minutes: 5);
|
||||
|
||||
/// Minimised (or hidden) for this long, playing or not: away
|
||||
/// (`minimised`).
|
||||
static const minimised = Duration(minutes: 10);
|
||||
}
|
||||
|
||||
/// What [AutoSitOut.tick] asks the session to do.
|
||||
sealed class AutoSitOutStep {
|
||||
const AutoSitOutStep();
|
||||
}
|
||||
|
||||
/// Nothing to do.
|
||||
final class AutoStay extends AutoSitOutStep {
|
||||
const AutoStay();
|
||||
}
|
||||
|
||||
/// Sit this member out (`presence` `away` with [reason]).
|
||||
final class AutoAway extends AutoSitOutStep {
|
||||
final PresenceReason reason;
|
||||
const AutoAway(this.reason);
|
||||
}
|
||||
|
||||
/// Bring this member back `in`: it was set away automatically and the
|
||||
/// user is back.
|
||||
final class AutoBack extends AutoSitOutStep {
|
||||
const AutoBack();
|
||||
}
|
||||
|
||||
/// Auto sit-out: idle for
|
||||
/// [AutoSitOutTimes.idle] while nothing plays, or minimised for
|
||||
/// [AutoSitOutTimes.minimised] (playing or not), sets this member away;
|
||||
/// input or the window's focus brings it back, but only from an away
|
||||
/// this set ([autoAway]); a manual away (own or someone else's) stays.
|
||||
/// Never when alone (nobody to hold up or to tell). Pure: the session
|
||||
/// feeds it input, window changes and a 1 s [tick].
|
||||
class AutoSitOut {
|
||||
final Duration idleAfter;
|
||||
final Duration minimisedAfter;
|
||||
AutoSitOut(DateTime now,
|
||||
{this.idleAfter = AutoSitOutTimes.idle, this.minimisedAfter = AutoSitOutTimes.minimised})
|
||||
: _lastActive = now;
|
||||
|
||||
DateTime _lastActive;
|
||||
DateTime? _minimisedSince;
|
||||
bool _activitySinceAway = false;
|
||||
bool _autoAway = false;
|
||||
// An away sent, not yet seen in a state.
|
||||
DateTime? _sentAt;
|
||||
static const _echoWait = Duration(seconds: 10);
|
||||
|
||||
/// The member is away because this set it (not by hand, not someone
|
||||
/// else).
|
||||
bool get autoAway => _autoAway;
|
||||
|
||||
/// Keyboard, mouse or any action in the app.
|
||||
void input(DateTime now) {
|
||||
_lastActive = now;
|
||||
_activitySinceAway = true;
|
||||
}
|
||||
|
||||
/// The window's focus or visibility changed.
|
||||
void window(WindowActivity a, DateTime now) {
|
||||
switch (a) {
|
||||
case WindowActivity.minimised:
|
||||
case WindowActivity.hidden:
|
||||
_minimisedSince ??= now;
|
||||
case WindowActivity.focused:
|
||||
_minimisedSince = null;
|
||||
input(now);
|
||||
case WindowActivity.unfocused:
|
||||
_minimisedSince = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// The user sat out (or came back) by hand: an away is no longer ours.
|
||||
void manual() {
|
||||
_autoAway = false;
|
||||
_sentAt = null;
|
||||
}
|
||||
|
||||
/// Once a second. [self]: this member's presence in the latest state
|
||||
/// (null: not in one); [alone]: nobody else connected ([isAlone]);
|
||||
/// [enabled]: the setting (and not a solo session).
|
||||
AutoSitOutStep tick(DateTime now,
|
||||
{required Presence? self, required bool playing, required bool alone, required bool enabled}) {
|
||||
if (playing) _lastActive = now;
|
||||
if (self == null || self == Presence.gone) return const AutoStay();
|
||||
if (self != Presence.away) {
|
||||
final sent = _sentAt;
|
||||
if (sent != null && now.difference(sent) < _echoWait) return const AutoStay();
|
||||
_sentAt = null;
|
||||
_autoAway = false;
|
||||
} else {
|
||||
_sentAt = null;
|
||||
if (_autoAway && _activitySinceAway && _minimisedSince == null) {
|
||||
_autoAway = false;
|
||||
return const AutoBack();
|
||||
}
|
||||
return const AutoStay();
|
||||
}
|
||||
if (!enabled || alone) return const AutoStay();
|
||||
final since = _minimisedSince;
|
||||
final PresenceReason? reason;
|
||||
if (since != null && now.difference(since) >= minimisedAfter) {
|
||||
reason = PresenceReason.minimised;
|
||||
} else if (!playing && now.difference(_lastActive) >= idleAfter) {
|
||||
reason = PresenceReason.idle;
|
||||
} else {
|
||||
reason = null;
|
||||
}
|
||||
if (reason == null) return const AutoStay();
|
||||
_autoAway = true;
|
||||
_activitySinceAway = false;
|
||||
_sentAt = now;
|
||||
return AutoAway(reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:signals/signals_core.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../screens/home/ui.dart' show appName, displayVersion;
|
||||
import '../state/settings.dart';
|
||||
import '../sync/sync_client.dart';
|
||||
import 'app_events.dart';
|
||||
import 'identity.dart' show ClientIdentity;
|
||||
|
||||
/// Thrown when a connect is superseded by cancel/leave before it finished.
|
||||
class ConnectCancelled implements Exception {
|
||||
const ConnectCancelled();
|
||||
@override
|
||||
String toString() => 'connection cancelled';
|
||||
}
|
||||
|
||||
class ConnectParams {
|
||||
final String host;
|
||||
final int port;
|
||||
final String secret;
|
||||
final String name;
|
||||
final String avatar;
|
||||
final String mediaPath;
|
||||
final String linkUrl;
|
||||
/// Sent in hello by the hosting app's own client (see AppConfig.hostKey).
|
||||
final String hostKey;
|
||||
/// Put up with a `swap` right after joining (a Jellyfin ref or a link);
|
||||
/// wins over [linkUrl].
|
||||
final HelloMedia? media;
|
||||
const ConnectParams({
|
||||
required this.host,
|
||||
required this.port,
|
||||
required this.secret,
|
||||
required this.name,
|
||||
this.avatar = '',
|
||||
this.mediaPath = '',
|
||||
this.linkUrl = '',
|
||||
this.hostKey = '',
|
||||
this.media,
|
||||
});
|
||||
|
||||
ConnectParams withMedia(HelloMedia? media) => ConnectParams(
|
||||
host: host,
|
||||
port: port,
|
||||
secret: secret,
|
||||
name: name,
|
||||
avatar: avatar,
|
||||
mediaPath: mediaPath,
|
||||
linkUrl: media == null ? linkUrl : '',
|
||||
hostKey: hostKey,
|
||||
media: media,
|
||||
);
|
||||
|
||||
ConnectParams withProfile({String? name, String? avatar}) => ConnectParams(
|
||||
host: host,
|
||||
port: port,
|
||||
secret: secret,
|
||||
name: name ?? this.name,
|
||||
avatar: avatar ?? this.avatar,
|
||||
mediaPath: mediaPath,
|
||||
linkUrl: linkUrl,
|
||||
hostKey: hostKey,
|
||||
media: media,
|
||||
);
|
||||
}
|
||||
|
||||
/// Where the [Connection] is. Each change is a new value (a repeat is
|
||||
/// notified too).
|
||||
sealed class ConnState {
|
||||
const ConnState();
|
||||
|
||||
/// The reconnect loop runs ([ConnLost] or [ConnReconnecting]).
|
||||
bool get retrying => false;
|
||||
|
||||
/// A welcomed socket is up ([ConnOnline] or [ConnUnstable]): what the
|
||||
/// server says is current.
|
||||
bool get online => false;
|
||||
}
|
||||
|
||||
/// Not connected and not trying: before the first connect, after leave,
|
||||
/// cancel or "stop reconnecting".
|
||||
final class ConnClosed extends ConnState {
|
||||
const ConnClosed();
|
||||
@override
|
||||
String toString() => 'closed';
|
||||
}
|
||||
|
||||
/// The first dial of a session (hello sent, waiting for welcome).
|
||||
final class ConnConnecting extends ConnState {
|
||||
const ConnConnecting();
|
||||
@override
|
||||
String toString() => 'connecting';
|
||||
}
|
||||
|
||||
/// Welcome received; frames arrive.
|
||||
final class ConnOnline extends ConnState {
|
||||
const ConnOnline();
|
||||
@override
|
||||
bool get online => true;
|
||||
@override
|
||||
String toString() => 'online';
|
||||
}
|
||||
|
||||
/// Still the same socket, but nothing came for [Connection.unstableAfter]
|
||||
/// (the server sends a state every second): the link may be dying. Back
|
||||
/// to [ConnOnline] with the next frame, [ConnLost] at
|
||||
/// [Connection.silenceLimit].
|
||||
final class ConnUnstable extends ConnState {
|
||||
const ConnUnstable();
|
||||
@override
|
||||
bool get online => true;
|
||||
@override
|
||||
String toString() => 'unstable';
|
||||
}
|
||||
|
||||
/// Dropped (the socket closed, or nothing came for
|
||||
/// [Connection.silenceLimit]); the next attempt is scheduled. The session
|
||||
/// keeps what it had (the last state, the player, a relay it serves).
|
||||
final class ConnLost extends ConnState {
|
||||
const ConnLost();
|
||||
@override
|
||||
bool get retrying => true;
|
||||
@override
|
||||
String toString() => 'lost';
|
||||
}
|
||||
|
||||
/// Reconnect attempt [attempt] (1 is the first after the drop) is
|
||||
/// dialling; after it failed it stays until the next one, unless the
|
||||
/// socket's close said [ConnLost] meanwhile.
|
||||
final class ConnReconnecting extends ConnState {
|
||||
final int attempt;
|
||||
const ConnReconnecting(this.attempt);
|
||||
@override
|
||||
bool get retrying => true;
|
||||
@override
|
||||
String toString() => 'reconnecting($attempt)';
|
||||
}
|
||||
|
||||
/// The server refused this app; nothing retries. [code]: `version` or
|
||||
/// `secret` when the server said so, else empty (offline, timeout,
|
||||
/// anything else on the first dial); [message]: in plain words
|
||||
/// ([connectErrorText]).
|
||||
final class ConnRefused extends ConnState {
|
||||
final String code;
|
||||
final String message;
|
||||
const ConnRefused(this.code, this.message);
|
||||
@override
|
||||
String toString() => 'refused($code: $message)';
|
||||
}
|
||||
|
||||
/// Another connection of this install holds the member (`err` `replaced`,
|
||||
/// close 4013, or this hello was `stale`): Starsync was opened in another
|
||||
/// window. Nothing retries ("Retry" takes the member back).
|
||||
final class ConnReplaced extends ConnState {
|
||||
const ConnReplaced();
|
||||
String get message => replacedText;
|
||||
@override
|
||||
String toString() => 'replaced';
|
||||
}
|
||||
|
||||
/// [ConnReplaced] in plain words.
|
||||
const replacedText = 'Starsync was opened in another window';
|
||||
|
||||
/// A version refusal in plain words: [err] is the server's `err`
|
||||
/// `version` (its range in `min`/`max`), or a proto 15 server's codeless
|
||||
/// `proto mismatch`, which is always an older host.
|
||||
String versionText(ErrorFrame? err) {
|
||||
final max = err?.max, min = err?.min;
|
||||
if (err?.code == null || (max != null && max < RelayConstants.protoMin)) {
|
||||
return 'the host runs an older Starsync — they need to update';
|
||||
}
|
||||
if (min != null && min > RelayConstants.protoMax) return 'the host runs a newer Starsync — update the app';
|
||||
return 'the host runs a different Starsync version — update both apps';
|
||||
}
|
||||
|
||||
/// A failed dial in plain words.
|
||||
String connectErrorText(Object e) {
|
||||
if (e is HelloRefused) {
|
||||
final err = e.error;
|
||||
if (err != null && err.code == null && err.message.contains('proto mismatch')) return versionText(err);
|
||||
return switch (e.code) {
|
||||
ErrCode.version => versionText(err),
|
||||
ErrCode.secret => 'wrong server password (401)',
|
||||
ErrCode.replaced || ErrCode.stale => replacedText,
|
||||
_ => 'refused: ${err?.message ?? 'closed (${e.closeCode})'}',
|
||||
};
|
||||
}
|
||||
final msg = '$e';
|
||||
if (msg.contains('SocketException') || msg.contains('Connection refused')) {
|
||||
return 'host offline — check address and port';
|
||||
}
|
||||
return 'connect failed: $e';
|
||||
}
|
||||
|
||||
/// The terminal state a failed dial leaves, or null when another attempt
|
||||
/// may work.
|
||||
ConnState? _terminalFor(Object e, {required bool first}) {
|
||||
if (e is HelloRefused) {
|
||||
final err = e.error;
|
||||
if (err != null && err.code == null && err.message.contains('proto mismatch')) {
|
||||
return ConnRefused('version', versionText(err));
|
||||
}
|
||||
switch (e.code) {
|
||||
case ErrCode.replaced:
|
||||
case ErrCode.stale:
|
||||
return const ConnReplaced();
|
||||
case ErrCode.version:
|
||||
return ConnRefused('version', connectErrorText(e));
|
||||
case ErrCode.secret:
|
||||
return ConnRefused('secret', connectErrorText(e));
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return first ? ConnRefused('', connectErrorText(e)) : null;
|
||||
}
|
||||
|
||||
/// What a hello says about this install besides the connect form.
|
||||
typedef HelloIdentity = ({String client, int seq, ClientCaps caps, int? feedAfter});
|
||||
|
||||
/// Builds the `hello` for [params]. Injectable so later protocol versions
|
||||
/// add fields without changing [Connection].
|
||||
typedef HelloBuilder = HelloMessage Function(ConnectParams params, HelloIdentity id);
|
||||
|
||||
/// Protocol 16's hello: this build's version range, the install's client
|
||||
/// id and connection count, the app version, platform and what it can do;
|
||||
/// `feedAfter` on a reconnect to the same server.
|
||||
HelloMessage defaultHello(ConnectParams params, HelloIdentity id) => HelloMessage(
|
||||
proto: RelayConstants.protoMax,
|
||||
protoMin: RelayConstants.protoMin,
|
||||
secret: params.secret,
|
||||
user: HelloUser(name: params.name, avatar: params.avatar),
|
||||
hostKey: params.hostKey,
|
||||
client: id.client,
|
||||
seq: id.seq,
|
||||
app: appVersion,
|
||||
platform: Platform.operatingSystem,
|
||||
caps: id.caps,
|
||||
feedAfter: id.feedAfter,
|
||||
);
|
||||
|
||||
/// How a welcome relates to the member this app held before.
|
||||
typedef Rejoin = ({
|
||||
/// A reconnect of the running session (not its first dial).
|
||||
bool reconnect,
|
||||
/// `welcome.resumed`: the same member, everything kept.
|
||||
bool resumed,
|
||||
/// The same server boot as the last welcome (`welcome.instance`).
|
||||
bool sameServer,
|
||||
/// The token the session held before this welcome ('' on the first).
|
||||
String oldToken,
|
||||
});
|
||||
|
||||
/// The session's part of a welcome, called before its state goes out
|
||||
/// (see [Connection.onWelcome]).
|
||||
typedef WelcomeHandler = void Function(WelcomeMessage welcome, Rejoin rejoin);
|
||||
|
||||
/// The link to the server: the [SyncClient] socket, the first connect,
|
||||
/// reconnects (backoff with jitter between attempts, epochs that make a
|
||||
/// cancelled attempt stale), the silence policy ("unstable" after
|
||||
/// [unstableAfter] without a frame, lost at [silenceLimit]), the 1 s
|
||||
/// heartbeat (clock ping, which also keeps this member under the
|
||||
/// server's silence limit, and diagnostics), the connection lost/back
|
||||
/// events and [state]. The install's identity rides in every hello
|
||||
/// ([identity]); what a welcome means to the session is its [onWelcome].
|
||||
class Connection {
|
||||
final SyncClient client;
|
||||
/// Where [ConnectionLost] / [ConnectionBack] go (the Notifier).
|
||||
final void Function(AppEvent event) _notify;
|
||||
/// Builds each dial's hello.
|
||||
final HelloBuilder hello;
|
||||
/// `hello.client` / `hello.seq` (the stored one in the app; a fresh
|
||||
/// in-memory one by default, so every test connection is its own
|
||||
/// install).
|
||||
final ClientIdentity identity;
|
||||
/// `hello.caps` at each dial (the session sets it).
|
||||
ClientCaps Function() caps;
|
||||
/// Wait before the first reconnect attempt; it doubles per failed
|
||||
/// attempt up to [maxReconnectDelay], each with ±20 % jitter.
|
||||
final Duration reconnectDelay;
|
||||
final Duration maxReconnectDelay;
|
||||
/// No frame for this long: [ConnUnstable].
|
||||
final Duration unstableAfter;
|
||||
/// No frame for this long means the connection is lost.
|
||||
final Duration silenceLimit;
|
||||
/// The lost sound plays only once a drop lasted this long (a quick
|
||||
/// reconnect says nothing, and no lost/back pair per flap).
|
||||
final Duration lostSoundAfter;
|
||||
/// The clock the silence and the lost sound are measured on (UTC; a
|
||||
/// test passes a fake one). The heartbeat that checks it stays a real
|
||||
/// 1 s timer.
|
||||
final DateTime Function() now;
|
||||
final Random _random;
|
||||
|
||||
Connection(
|
||||
{void Function(AppEvent event)? notify,
|
||||
SyncClient? client,
|
||||
this.hello = defaultHello,
|
||||
ClientIdentity? identity,
|
||||
ClientCaps Function()? caps,
|
||||
this.reconnectDelay = const Duration(seconds: 1),
|
||||
this.maxReconnectDelay = const Duration(seconds: 15),
|
||||
this.unstableAfter = const Duration(seconds: 3),
|
||||
this.silenceLimit = const Duration(seconds: 9),
|
||||
this.lostSoundAfter = const Duration(seconds: 4),
|
||||
DateTime Function()? now,
|
||||
Random? random})
|
||||
: _notify = notify ?? ((_) {}),
|
||||
now = now ?? _utcNow,
|
||||
identity = identity ?? ClientIdentity.ephemeral(),
|
||||
caps = caps ?? (() => const ClientCaps(source: true)),
|
||||
_random = random ?? Random(),
|
||||
client = client ?? SyncClient() {
|
||||
this.client.onFrame = () => _lastFrameAt = this.now();
|
||||
_subs = [
|
||||
this.client.status.listen(_onStatus),
|
||||
this.client.errors.listen(_onError),
|
||||
];
|
||||
}
|
||||
|
||||
late final List<StreamSubscription> _subs;
|
||||
|
||||
static DateTime _utcNow() => DateTime.now().toUtc();
|
||||
|
||||
final _state = signal<ConnState>(const ConnClosed());
|
||||
/// Every change is notified, a repeat too.
|
||||
ReadonlySignal<ConnState> get state => _state;
|
||||
|
||||
/// Set by the session (see [WelcomeHandler]).
|
||||
WelcomeHandler? onWelcome;
|
||||
|
||||
/// This app's log lines to the server's session log (Settings: "Share
|
||||
/// diagnostics with the server"), and "Mark a problem". Runs from
|
||||
/// [begin] to [close].
|
||||
late final DiagUploader diag = DiagUploader(
|
||||
target: _diagTarget, version: '$appName ${displayVersion()} on ${Platform.operatingSystem}');
|
||||
|
||||
DiagTarget? _diagTarget() {
|
||||
final uri = client.wsUri;
|
||||
if (client.token.isEmpty || uri == null || !client.clockSync.hasSample) return null;
|
||||
return (base: DiagUploader.baseOf(uri), token: client.token, offsetMs: client.clockSync.offsetMs);
|
||||
}
|
||||
|
||||
ConnectParams? _params;
|
||||
/// The last connect's parameters (with [updateProfile]'s changes); a
|
||||
/// reconnect dials with them.
|
||||
ConnectParams? get params => _params;
|
||||
|
||||
// Bumped by every connect/leave/cancel/retry: stale attempts no-op.
|
||||
int _epoch = 0;
|
||||
Timer? _reconnectTimer;
|
||||
Timer? _heartbeat;
|
||||
// Set by close/cancel/a terminal refusal: no reconnect, no lost detection.
|
||||
bool _closed = false;
|
||||
bool _everOnline = false;
|
||||
/// The connection-lost sound played; the reconnected one answers it once.
|
||||
bool _lostSounded = false;
|
||||
DateTime? _lastFrameAt;
|
||||
/// When the current drop began (the last frame for a silent link).
|
||||
DateTime? _droppedAt;
|
||||
int _attempt = 0;
|
||||
/// Non-terminal `err` frames, in plain words.
|
||||
final _errorTexts = StreamController<String>.broadcast();
|
||||
|
||||
// The socket and what the server said, read through.
|
||||
Stream<StateBroadcast> get states => client.states;
|
||||
/// Feed entries, chat included; replayed ones after a reconnect.
|
||||
Stream<FeedMessage> get feeds => client.feeds;
|
||||
Stream<NudgeNotice> get nudges => client.nudges;
|
||||
/// `err` frames after the welcome (a `replaced` one ends the connection
|
||||
/// instead).
|
||||
Stream<String> get errors => _errorTexts.stream;
|
||||
String get token => client.token;
|
||||
String get memberId => client.memberId;
|
||||
/// This member's `/j/...` URL from `welcome`.
|
||||
String? get relayUrl => client.relayUrl;
|
||||
StateBroadcast? get lastState => client.lastState;
|
||||
/// Now on the server's clock (the ping/pong clock offset).
|
||||
DateTime serverNow() => client.serverNow();
|
||||
|
||||
void sendReady(bool ready, {String source = 'manual'}) => client.sendReady(ready, source: source);
|
||||
void sendForce(String target, bool ready) => client.sendForce(target, ready);
|
||||
void sendSeek(double position, {int seq = 0}) => client.sendSeek(position, seq: seq);
|
||||
void sendSwap(HelloMedia media, {String? queueId}) => client.sendSwap(media, queueId: queueId);
|
||||
void sendQueue(QueueIntent intent) => client.sendQueue(intent);
|
||||
void sendReport({
|
||||
required double position,
|
||||
required double buffered,
|
||||
required int eventId,
|
||||
double duration = 0,
|
||||
int mediaId = 0,
|
||||
Map<String, bool>? jf,
|
||||
String error = '',
|
||||
}) =>
|
||||
client.sendReport(
|
||||
position: position,
|
||||
buffered: buffered,
|
||||
eventId: eventId,
|
||||
duration: duration,
|
||||
mediaId: mediaId,
|
||||
jf: jf,
|
||||
error: error);
|
||||
void sendChat(String text) => client.sendChat(text);
|
||||
void sendArt(int mediaId, {String? poster, String? banner}) =>
|
||||
client.sendArt(mediaId, poster: poster, banner: banner);
|
||||
void sendPresence(PresenceIntent intent) => client.sendPresence(intent);
|
||||
void sendNudge(String target) => client.sendNudge(target);
|
||||
|
||||
/// Name and/or picture change for everyone now; reconnects send them too.
|
||||
void updateProfile({String? name, String? avatar}) {
|
||||
_params = _params?.withProfile(name: name, avatar: avatar);
|
||||
client.sendProfile(name: name, avatar: avatar);
|
||||
}
|
||||
|
||||
/// A new epoch: attempts in flight become stale, a scheduled reconnect
|
||||
/// is dropped. Returns it.
|
||||
int invalidate() {
|
||||
_epoch++;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
return _epoch;
|
||||
}
|
||||
|
||||
/// False once a cancel, retry, leave or new connect followed [epoch].
|
||||
bool isCurrent(int epoch) => epoch == _epoch;
|
||||
|
||||
/// A new session with [params]: forgets the last one's server and
|
||||
/// starts the diagnostics. [dial] connects.
|
||||
void begin(ConnectParams params) {
|
||||
_params = params;
|
||||
_closed = false;
|
||||
_everOnline = false;
|
||||
_lostSounded = false;
|
||||
_droppedAt = null;
|
||||
_attempt = 0;
|
||||
diag
|
||||
..share = AppSettings.instance.shareDiagnostics.peek()
|
||||
..start();
|
||||
}
|
||||
|
||||
/// The first dial of the session [begin] started. Throws
|
||||
/// [ConnectCancelled] when [epoch] went stale meanwhile (whoever bumped
|
||||
/// it also closed the socket); any other failure leaves [state] refused
|
||||
/// (or replaced).
|
||||
Future<WelcomeMessage> dial(int epoch) async {
|
||||
final params = _params!;
|
||||
_set(const ConnConnecting());
|
||||
try {
|
||||
final welcome = await _connect(params, reconnect: false);
|
||||
if (epoch != _epoch) throw const ConnectCancelled();
|
||||
_online();
|
||||
return welcome;
|
||||
} catch (e) {
|
||||
if (epoch != _epoch) throw const ConnectCancelled();
|
||||
debugPrint('connect failed: $e');
|
||||
_set(_terminalFor(e, first: true)!);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<WelcomeMessage> _connect(ConnectParams params, {required bool reconnect}) {
|
||||
final oldToken = client.token;
|
||||
final before = client.instance;
|
||||
final id = (
|
||||
client: identity.clientId,
|
||||
seq: identity.nextSeq(),
|
||||
caps: caps(),
|
||||
feedAfter: reconnect && before.isNotEmpty && client.lastFeedSeq > 0 ? client.lastFeedSeq : null,
|
||||
);
|
||||
return client.connectWith(
|
||||
wsUri: _wsUri(params),
|
||||
hello: hello(params, id),
|
||||
onWelcome: (w) {
|
||||
_lastFrameAt = now();
|
||||
onWelcome?.call(w, (
|
||||
reconnect: reconnect,
|
||||
resumed: w.resumed,
|
||||
sameServer: before.isNotEmpty && before == w.instance,
|
||||
oldToken: oldToken,
|
||||
));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Uri _wsUri(ConnectParams p) => Uri.parse('ws://${p.host}:${p.port}/sync');
|
||||
|
||||
void _online() {
|
||||
_attempt = 0;
|
||||
_everOnline = true;
|
||||
_droppedAt = null;
|
||||
if (_lostSounded) {
|
||||
_lostSounded = false;
|
||||
_notify(const ConnectionBack());
|
||||
}
|
||||
_lastFrameAt = now();
|
||||
_set(const ConnOnline());
|
||||
_heartbeat ??= Timer.periodic(const Duration(seconds: 1), (_) => _beat());
|
||||
}
|
||||
|
||||
void _set(ConnState s) => _state.set(s, force: true);
|
||||
|
||||
void _onStatus(SyncStatus s) {
|
||||
// Before the first welcome a close is just the dial failing; during a
|
||||
// reconnect attempt the attempt reports it. A socket error (a reset
|
||||
// link) may come without a close.
|
||||
if (s != SyncStatus.closed && s != SyncStatus.error) return;
|
||||
if (_closed || !_everOnline || !_state.peek().online) return;
|
||||
switch (s == SyncStatus.closed ? client.closeCode : null) {
|
||||
case CloseCodes.replaced:
|
||||
case CloseCodes.stale:
|
||||
_end(const ConnReplaced());
|
||||
case CloseCodes.version:
|
||||
_end(ConnRefused('version', versionText(null)));
|
||||
case CloseCodes.secret:
|
||||
_end(const ConnRefused('secret', 'wrong server password (401)'));
|
||||
default:
|
||||
_dropped(now());
|
||||
}
|
||||
}
|
||||
|
||||
void _onError(ErrorFrame e) {
|
||||
if (e.code == ErrCode.replaced) {
|
||||
_end(const ConnReplaced());
|
||||
return;
|
||||
}
|
||||
if (!_errorTexts.isClosed) _errorTexts.add(e.message);
|
||||
}
|
||||
|
||||
/// A terminal state: no more attempts, the socket goes.
|
||||
void _end(ConnState s) {
|
||||
debugPrint('connection ended: $s');
|
||||
_halt(s);
|
||||
unawaited(client.drop());
|
||||
}
|
||||
|
||||
/// Lost since [since]: the socket goes, a reconnect is scheduled (unless
|
||||
/// one already is or dials).
|
||||
void _dropped(DateTime since) {
|
||||
if (_closed || _state.peek().retrying) return;
|
||||
_droppedAt = since;
|
||||
unawaited(client.drop());
|
||||
scheduleReconnect();
|
||||
}
|
||||
|
||||
/// Every second once online: a ping (a clock sample; it also keeps this
|
||||
/// member under the server's silence limit while nothing else is sent),
|
||||
/// the diagnostics, the silence check and the delayed lost sound.
|
||||
void _beat() {
|
||||
final t = now();
|
||||
final s = _state.peek();
|
||||
if (s.online) client.sendPing();
|
||||
diag
|
||||
..share = AppSettings.instance.shareDiagnostics.peek()
|
||||
..tick();
|
||||
if (_closed) return;
|
||||
final last = _lastFrameAt;
|
||||
if (s.online && last != null) {
|
||||
final quiet = t.difference(last);
|
||||
if (quiet >= silenceLimit) {
|
||||
debugPrint('connection: nothing for ${quiet.inSeconds} s; reconnecting');
|
||||
_dropped(last);
|
||||
} else if (quiet >= unstableAfter) {
|
||||
if (s is ConnOnline) _set(const ConnUnstable());
|
||||
} else if (s is ConnUnstable) {
|
||||
_set(const ConnOnline());
|
||||
}
|
||||
}
|
||||
final since = _droppedAt;
|
||||
if (_state.peek().retrying && since != null && !_lostSounded && t.difference(since) >= lostSoundAfter) {
|
||||
_lostSounded = true;
|
||||
_notify(const ConnectionLost());
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconnect after the backoff ([state] lost), unless closed, never
|
||||
/// online (unless [manual]) or already retrying.
|
||||
void scheduleReconnect({bool manual = false}) {
|
||||
if (_closed || _params == null) return;
|
||||
if (!manual && !_everOnline) return;
|
||||
if (_state.peek().retrying) return;
|
||||
_droppedAt ??= now();
|
||||
_set(const ConnLost());
|
||||
_armReconnect();
|
||||
}
|
||||
|
||||
/// The wait before attempt [attempt] (1 = the first after a drop).
|
||||
Duration delayFor(int attempt) {
|
||||
final base = reconnectDelay.inMilliseconds * pow(2, max(0, attempt - 1));
|
||||
final capped = min(base.toDouble(), maxReconnectDelay.inMilliseconds.toDouble());
|
||||
return Duration(milliseconds: (capped * (0.8 + 0.4 * _random.nextDouble())).round());
|
||||
}
|
||||
|
||||
void _armReconnect() {
|
||||
final epoch = _epoch;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = Timer(delayFor(_attempt + 1), () => _reconnect(epoch));
|
||||
}
|
||||
|
||||
/// One reconnect attempt. An attempt from an older epoch must not touch
|
||||
/// state (it would kill the newer connection).
|
||||
Future<void> _reconnect(int epoch) async {
|
||||
_reconnectTimer = null;
|
||||
bool stale() => epoch != _epoch;
|
||||
final params = _params;
|
||||
if (stale() || params == null || _closed) return;
|
||||
try {
|
||||
_set(ConnReconnecting(++_attempt));
|
||||
await _connect(params, reconnect: true);
|
||||
// Whoever bumped the epoch also closed (or replaced) the socket.
|
||||
if (stale()) return;
|
||||
_online();
|
||||
} catch (e) {
|
||||
if (stale()) return;
|
||||
debugPrint('reconnect failed: $e');
|
||||
final end = _terminalFor(e, first: false);
|
||||
if (end != null) {
|
||||
_end(end);
|
||||
} else {
|
||||
_retryAfterFailure(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _retryAfterFailure(Object e) {
|
||||
if (_closed) {
|
||||
_set(const ConnClosed());
|
||||
} else if (!_everOnline) {
|
||||
// Only a manual retry before any welcome gets here.
|
||||
_set(_terminalFor(e, first: true)!);
|
||||
} else {
|
||||
if (!_state.peek().retrying) _set(const ConnLost());
|
||||
_armReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/// "Stop reconnecting": no more attempts; one dialling is aborted.
|
||||
void cancelReconnect() {
|
||||
_halt(const ConnClosed());
|
||||
unawaited(client.drop());
|
||||
}
|
||||
|
||||
/// Cancel during setup: aborts the dial.
|
||||
Future<void> cancelConnect() async {
|
||||
_halt(const ConnClosed());
|
||||
try {
|
||||
await client.drop();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _halt(ConnState s) {
|
||||
_closed = true;
|
||||
invalidate();
|
||||
_heartbeat?.cancel();
|
||||
_heartbeat = null;
|
||||
_set(s);
|
||||
}
|
||||
|
||||
/// "Retry": a reconnect after the first backoff whatever the state
|
||||
/// (after [ConnReplaced] it takes the member back from the other
|
||||
/// window). False without a session to retry.
|
||||
bool retry() {
|
||||
if (_params == null) return false;
|
||||
invalidate();
|
||||
_closed = false;
|
||||
_attempt = 0;
|
||||
_set(const ConnClosed());
|
||||
scheduleReconnect(manual: true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// No more reconnects or silence checks, [state] closed; the socket
|
||||
/// stays until [close] (the session's last words still go out).
|
||||
void stop() {
|
||||
_closed = true;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
_heartbeat?.cancel();
|
||||
_heartbeat = null;
|
||||
_set(const ConnClosed());
|
||||
}
|
||||
|
||||
/// [stop], the last diagnostics (before the token goes), with [bye] the
|
||||
/// member's `bye` (flushed: the server's close is awaited briefly), then
|
||||
/// the socket.
|
||||
Future<void> close({bool bye = false}) async {
|
||||
stop();
|
||||
await diag.stop();
|
||||
// A welcomed socket still open (a dead one costs the short wait).
|
||||
if (bye && client.isOpen && client.token.isNotEmpty) await client.sendBye();
|
||||
try {
|
||||
await client.disconnect();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
stop();
|
||||
for (final s in _subs) {
|
||||
await s.cancel();
|
||||
}
|
||||
await _errorTexts.close();
|
||||
await client.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../jellyfin/session_media.dart';
|
||||
import 'party.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.
|
||||
@@ -217,7 +218,7 @@ Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? nam
|
||||
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;
|
||||
if (!isPresent(m) || m.id == sourceId) continue;
|
||||
switch (routeOf(m, kind, sourceId, ref, r.relay)) {
|
||||
case DeliveryRoute.server:
|
||||
via++;
|
||||
@@ -277,7 +278,7 @@ DeliveryRoute? routeOf(MemberView m, DeliveryKind kind, String sourceId, Jellyfi
|
||||
|
||||
/// [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 counts = countsForReady(m);
|
||||
final need = r.bufferNeed;
|
||||
final ref = refOfStateMedia(r.media);
|
||||
final health = !counts
|
||||
@@ -288,7 +289,7 @@ MemberDelivery memberDeliveryOf(MemberView m, StateBroadcast r, Delivery d) {
|
||||
? BufferHealth.starving
|
||||
: BufferHealth.low;
|
||||
return MemberDelivery(
|
||||
route: m.out ? null : routeOf(m, d.kind, d.sourceId, ref, r.relay),
|
||||
route: m.presence == Presence.away ? null : routeOf(m, d.kind, d.sourceId, ref, r.relay),
|
||||
kind: d.kind,
|
||||
jellyfin: ref != null,
|
||||
rate: m.rate,
|
||||
|
||||
+73
-3
@@ -1,13 +1,83 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show validClientId;
|
||||
|
||||
import '../../state/connect_prefs.dart';
|
||||
import '../../state/session_state.dart';
|
||||
import '../../widgets/avatar_pipeline.dart';
|
||||
import '../state/connect_prefs.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../state/settings.dart' show settingsBoxName;
|
||||
import '../widgets/avatar_pipeline.dart';
|
||||
|
||||
// Who this app is: to the server (the per-install client id and its
|
||||
// rising connection count, [ClientIdentity]) and to people (name, picture
|
||||
// and colour, [Profile]).
|
||||
|
||||
/// `hello.client` and `hello.seq` (PROTOCOL.md "Presence"): a random id
|
||||
/// per install, kept in the settings box (so per data folder, and per
|
||||
/// `STARSYNC_PROFILE`), and a number that rises with every connection this
|
||||
/// install makes. A server maps the id to one lasting member; the higher
|
||||
/// `seq` wins when two windows of the same install connect.
|
||||
class ClientIdentity {
|
||||
static const _keyClient = 'clientId';
|
||||
static const _keySeq = 'clientSeq';
|
||||
|
||||
final Box? _box;
|
||||
ClientIdentity._(this._box, this.clientId, this._seq);
|
||||
|
||||
/// The id: 22 characters of base64url (16 random bytes). Private: never
|
||||
/// shown, never logged.
|
||||
final String clientId;
|
||||
int _seq;
|
||||
|
||||
/// The last `seq` handed out.
|
||||
int get seq => _seq;
|
||||
|
||||
/// The stored identity (the settings box, once open); one is made and
|
||||
/// stored on first use. Without the box (tests, no store): [ephemeral].
|
||||
factory ClientIdentity.stored() {
|
||||
final box = Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null;
|
||||
if (box == null) return ClientIdentity.ephemeral();
|
||||
var id = box.get(_keyClient);
|
||||
if (id is! String || !validClientId(id)) {
|
||||
id = newClientId();
|
||||
unawaited(_put(box, _keyClient, id));
|
||||
}
|
||||
final seq = box.get(_keySeq);
|
||||
return ClientIdentity._(box, id, seq is int && seq > 0 ? seq : 0);
|
||||
}
|
||||
|
||||
/// A fresh id kept in memory only (each test coordinator is its own
|
||||
/// install).
|
||||
factory ClientIdentity.ephemeral() => ClientIdentity._(null, newClientId(), 0);
|
||||
|
||||
/// The `seq` for the next hello: one higher than any before, stored at
|
||||
/// once.
|
||||
int nextSeq() {
|
||||
_seq++;
|
||||
final box = _box;
|
||||
if (box != null) unawaited(_put(box, _keySeq, _seq));
|
||||
return _seq;
|
||||
}
|
||||
|
||||
static Future<void> _put(Box box, String key, Object value) async {
|
||||
try {
|
||||
await box.put(key, value);
|
||||
} catch (e) {
|
||||
debugPrint('save $key failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 16 random bytes as 22 characters of base64url.
|
||||
static String newClientId() {
|
||||
final r = Random.secure();
|
||||
return base64Url.encode([for (var i = 0; i < 16; i++) r.nextInt(256)]).replaceAll('=', '');
|
||||
}
|
||||
}
|
||||
|
||||
/// Colours offered in the profile card; the first is the default.
|
||||
const profileColors = <Color>[
|
||||
@@ -1,43 +0,0 @@
|
||||
enum IdleEvent { none, warn, unready }
|
||||
|
||||
/// Idle auto-unready clock. Idle time counts from the later of the user's
|
||||
/// last input and the last moment playback was running: watching is
|
||||
/// activity, so a long watch followed by a pause starts a fresh 10 min.
|
||||
/// The unready also always trails its warning by the full grace period.
|
||||
class IdleTracker {
|
||||
static const warnAfter = Duration(minutes: 8);
|
||||
static const unreadyAfter = Duration(minutes: 10);
|
||||
|
||||
DateTime _lastActive;
|
||||
DateTime? _warnedAt;
|
||||
|
||||
IdleTracker(DateTime now) : _lastActive = now;
|
||||
|
||||
void activity(DateTime now) {
|
||||
_lastActive = now;
|
||||
_warnedAt = null;
|
||||
}
|
||||
|
||||
/// Called once per tick. Warn and unready only matter while self is
|
||||
/// ready; while playing the ready flag gates nothing.
|
||||
IdleEvent tick(DateTime now, {required bool playing, required bool selfReady}) {
|
||||
if (playing) {
|
||||
activity(now);
|
||||
return IdleEvent.none;
|
||||
}
|
||||
if (!selfReady) return IdleEvent.none;
|
||||
final warnedAt = _warnedAt;
|
||||
if (warnedAt != null) {
|
||||
if (now.difference(warnedAt) >= unreadyAfter - warnAfter) {
|
||||
activity(now);
|
||||
return IdleEvent.unready;
|
||||
}
|
||||
return IdleEvent.none;
|
||||
}
|
||||
if (now.difference(_lastActive) >= warnAfter) {
|
||||
_warnedAt = now;
|
||||
return IdleEvent.warn;
|
||||
}
|
||||
return IdleEvent.none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:signals/signals_core.dart';
|
||||
import 'package:sync_relay_core/core.dart' show FeedMessage;
|
||||
|
||||
import '../sfx/sfx.dart';
|
||||
import 'app_events.dart';
|
||||
|
||||
export 'app_events.dart';
|
||||
|
||||
class ToastItem {
|
||||
final int id;
|
||||
final String text;
|
||||
final DateTime at;
|
||||
/// The member a feed or chat toast is about (their name, as the feed
|
||||
/// carries it; [text] starts with it); null for the app's own toasts.
|
||||
final String? actor;
|
||||
/// [actor]'s member id (feed and chat toasts); '' when unknown.
|
||||
final String actorId;
|
||||
const ToastItem({required this.id, required this.text, required this.at, this.actor, this.actorId = ''});
|
||||
}
|
||||
|
||||
/// What one [AppEvent] does: a toast ([toast], about [actor]), a sound,
|
||||
/// the window asking for attention, an activity-log line.
|
||||
class Notice {
|
||||
final String? toast;
|
||||
final String? actor;
|
||||
final SfxCue? sound;
|
||||
final bool attention;
|
||||
/// The feed line it goes into the activity log as.
|
||||
final FeedMessage? log;
|
||||
const Notice({this.toast, this.actor, this.sound, this.attention = false, this.log});
|
||||
|
||||
static const none = Notice();
|
||||
}
|
||||
|
||||
/// The policy: what each event does.
|
||||
Notice noticeFor(AppEvent e) => switch (e) {
|
||||
// Lines a reconnect replayed (`welcome.feed`): the log only.
|
||||
FeedEvent(:final feed) when feed.replayed => Notice(log: feed),
|
||||
// The feed: everything is logged; joins, leaves, swaps and chat
|
||||
// pop up; the social actions have a sound.
|
||||
MemberJoined(:final feed) => Notice(
|
||||
log: feed, toast: '${feed.actor} joined', actor: feed.actor, sound: const SfxCue(SfxKind.join)),
|
||||
MemberLeft(:final feed) => Notice(
|
||||
log: feed, toast: '${feed.actor} left', actor: feed.actor, sound: const SfxCue(SfxKind.leave)),
|
||||
MediaSwapped(:final feed) => Notice(
|
||||
log: feed, toast: '${feed.actor} changed media', actor: feed.actor, sound: const SfxCue(SfxKind.swap)),
|
||||
ChatPosted(:final feed, :final own) => Notice(
|
||||
log: feed,
|
||||
toast: '${feed.actor}: ${feed.reason}',
|
||||
actor: feed.actor,
|
||||
// Your own message needs no sound.
|
||||
sound: own ? null : const SfxCue(SfxKind.chat)),
|
||||
MemberSatOut(:final feed, atYou: true) => Notice(
|
||||
log: feed, toast: '${feed.actor} sat you out', actor: feed.actor, sound: const SfxCue(SfxKind.kick)),
|
||||
MemberSatOut(:final feed, autoSelf: true) => Notice(
|
||||
log: feed,
|
||||
toast: 'You sit out (${feed.reason == 'minimised' ? 'minimised' : 'idle'}); you are back as soon as you return',
|
||||
sound: const SfxCue(SfxKind.warn)),
|
||||
ReadyChanged(:final feed, ready: true, :final readyIndex) =>
|
||||
Notice(log: feed, sound: SfxCue(SfxKind.ready, readyIndex: readyIndex)),
|
||||
ReadyChanged(:final feed, ready: false) => Notice(log: feed, sound: const SfxCue(SfxKind.unready)),
|
||||
ForcedReady(:final feed, ready: true) => Notice(log: feed, sound: const SfxCue(SfxKind.forceReady)),
|
||||
QueueSkipped(:final feed) => Notice(
|
||||
log: feed, toast: 'Skipped ${feed.target ?? 'an item'}${feed.reason.isEmpty ? '' : ': ${feed.reason}'}'),
|
||||
FeedEvent(:final feed) => Notice(log: feed),
|
||||
// The session.
|
||||
Nudged(:final name) => Notice(
|
||||
toast: '${name.isEmpty ? 'Someone' : name} nudged you',
|
||||
actor: name.isEmpty ? null : name,
|
||||
sound: const SfxCue(SfxKind.nudge),
|
||||
attention: true),
|
||||
MemberCantPlay(:final name, :final error) => Notice(toast: "$name can't play this: $error", actor: name),
|
||||
SlowSource(:final text) => Notice(toast: text),
|
||||
RelayStarted() =>
|
||||
const Notice(toast: 'Passing the video on through the server. Keep the app open while it plays.'),
|
||||
RelayFailed(:final error) => Notice(toast: 'Cannot pass the video on: $error'),
|
||||
ConnectionLost() => const Notice(sound: SfxCue(SfxKind.lost)),
|
||||
ConnectionBack() => const Notice(sound: SfxCue(SfxKind.back)),
|
||||
JellyfinProgressFailed(:final signInExpired) =>
|
||||
Notice(toast: 'Jellyfin progress not saved: ${signInExpired ? 'sign-in expired' : 'server unreachable'}'),
|
||||
// The app.
|
||||
ShaderMessage(:final text) => Notice(toast: text),
|
||||
AddedToUpNext(:final title) => Notice(toast: 'Added $title to Up next'),
|
||||
ActionFailed(:final text) => Notice(toast: text),
|
||||
ScheduleResult(:final text) => Notice(toast: text),
|
||||
ProblemMarked(:final inSession) =>
|
||||
Notice(toast: inSession ? 'Marked in the session log' : "Marked in this app's log"),
|
||||
Copied(:final text) => Notice(toast: text),
|
||||
CopyFailed() => const Notice(toast: "Couldn't copy"),
|
||||
// The update band shows it.
|
||||
UpdateAvailable() => Notice.none,
|
||||
};
|
||||
|
||||
/// The one way news reaches the user: [notify] turns an [AppEvent] into
|
||||
/// toasts ([toasts]), sounds, an attention request and the activity log
|
||||
/// ([feedLog]) per [noticeFor].
|
||||
class Notifier {
|
||||
final SfxService sfx;
|
||||
final Future<void> Function() attention;
|
||||
Notifier({this.sfx = const NoopSfxService(), Future<void> Function()? attention})
|
||||
: attention = attention ?? _noAttention;
|
||||
|
||||
static Future<void> _noAttention() async {}
|
||||
|
||||
/// Toasts on screen (the shell shows them; [expire] drops old ones).
|
||||
final toasts = signal<List<ToastItem>>([]);
|
||||
/// The activity log: the last 100 feed lines.
|
||||
final feedLog = signal<List<FeedMessage>>([]);
|
||||
int _toastId = 0;
|
||||
|
||||
void notify(AppEvent e) {
|
||||
final n = noticeFor(e);
|
||||
final log = n.log;
|
||||
if (log != null) {
|
||||
final all = [...feedLog.peek(), log];
|
||||
feedLog.value = all.length > 100 ? all.sublist(all.length - 100) : all;
|
||||
}
|
||||
final text = n.toast;
|
||||
if (text != null) {
|
||||
_toastId++;
|
||||
toasts.value = [
|
||||
...toasts.peek(),
|
||||
ToastItem(
|
||||
id: _toastId,
|
||||
text: text,
|
||||
at: DateTime.now().toUtc(),
|
||||
actor: n.actor,
|
||||
actorId: n.actor == null ? '' : n.log?.actorId ?? ''),
|
||||
];
|
||||
}
|
||||
final sound = n.sound;
|
||||
if (sound != null) unawaited(sfx.play(sound));
|
||||
if (n.attention) unawaited(attention());
|
||||
}
|
||||
|
||||
/// Drops toasts 4 s old or older.
|
||||
void expire(DateTime now) {
|
||||
toasts.value = toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList();
|
||||
}
|
||||
|
||||
/// A new session: no toasts, an empty log.
|
||||
void clear() {
|
||||
feedLog.value = [];
|
||||
toasts.value = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
// The app's reading of presence (PROTOCOL.md "Presence"): every count,
|
||||
// list and wait in the UI goes through these, on top of core's
|
||||
// [PresencePolicy] and [isAlone].
|
||||
|
||||
/// What each presence counts for (the one table, shared with the server).
|
||||
const presencePolicy = PresencePolicy();
|
||||
|
||||
/// Counted in "2/3 ready" and waited for by name: its presence counts for
|
||||
/// the ready counter and its player can open the media.
|
||||
bool countsForReady(MemberView m) => m.error.isEmpty && presencePolicy.countsFor(m.presence, Purpose.readyCounter);
|
||||
|
||||
/// Connected and watching (`in` or `joining`): can do things for the
|
||||
/// party now (put up an Up next item, the auto-advance swap).
|
||||
bool isPresent(MemberView m) => m.presence == Presence.watching || m.presence == Presence.joining;
|
||||
|
||||
/// Nobody but [selfId] is connected in [members] (D9, core's [isAlone]).
|
||||
bool aloneIn(List<MemberView> members, String selfId) =>
|
||||
isAlone([for (final m in members) if (m.id != selfId) m.presence]);
|
||||
|
||||
/// "3 min" / "40 s": how long since [since] (a `goneSince`), at [now].
|
||||
String sinceText(DateTime since, DateTime now) {
|
||||
final d = now.difference(since);
|
||||
if (d.inMinutes >= 1) return '${d.inMinutes} min';
|
||||
return '${d.inSeconds.clamp(0, 59)} s';
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -13,7 +13,7 @@ enum SfxKind {
|
||||
/// A new video or episode is put up.
|
||||
swap,
|
||||
chat,
|
||||
/// Idle warning: auto-unready soon.
|
||||
/// Auto sit-out set you away (idle, minimised).
|
||||
warn,
|
||||
/// The connection to the server dropped / came back.
|
||||
lost,
|
||||
|
||||
@@ -261,7 +261,7 @@ const soundInfo = {
|
||||
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.warn: ('Auto sit-out', 'You were idle or minimised and now sit out'),
|
||||
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)'),
|
||||
|
||||
@@ -1,88 +1,60 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../platform/audio/sound_engine.dart';
|
||||
import 'sfx.dart';
|
||||
import 'sound_bank.dart';
|
||||
import 'synth.dart';
|
||||
|
||||
/// Plays [SoundSpec]s: each is rendered once to a WAV in the temp dir and
|
||||
/// played on a small pool of audio-only mpv players (so two sounds can
|
||||
/// overlap). [volume] (0..1) is read per play and applied on a square
|
||||
/// curve (ears are log); [enabled] filters cues.
|
||||
/// Plays [SoundSpec]s on a [SoundEngine]: each is rendered once (off the
|
||||
/// UI isolate) to WAV bytes and loaded into memory; sounds overlap.
|
||||
/// [volume] (0..1) is read per play and applied as v^6: the square curve
|
||||
/// (ears are log) the app always used, times mpv's own cubic volume it
|
||||
/// used to play through, so the slider sounds as it did; [enabled]
|
||||
/// filters cues.
|
||||
class SynthSfx implements SfxService {
|
||||
final double Function() volume;
|
||||
final bool Function(SfxKind kind) enabled;
|
||||
/// Which sound set each sound plays from.
|
||||
final SoundPreset Function(SfxKind kind) presetFor;
|
||||
SynthSfx({required this.volume, bool Function(SfxKind kind)? enabled, SoundPreset Function(SfxKind kind)? presetFor})
|
||||
final SoundEngine engine;
|
||||
SynthSfx(
|
||||
{required this.volume,
|
||||
bool Function(SfxKind kind)? enabled,
|
||||
SoundPreset Function(SfxKind kind)? presetFor,
|
||||
SoundEngine? engine})
|
||||
: enabled = enabled ?? ((_) => true),
|
||||
presetFor = presetFor ?? ((_) => defaultPreset);
|
||||
|
||||
static const _poolSize = 3;
|
||||
final List<Player> _pool = [];
|
||||
var _next = 0;
|
||||
final Map<String, Future<String>> _files = {};
|
||||
Directory? _dir;
|
||||
presetFor = presetFor ?? ((_) => defaultPreset),
|
||||
engine = engine ?? SoundEngine();
|
||||
|
||||
@override
|
||||
Future<void> play(SfxCue cue) async {
|
||||
if (!enabled(cue.kind)) return;
|
||||
final preset = presetFor(cue.kind);
|
||||
await playSpec('${preset.name}-${cue.kind.name}-${cue.readyIndex}',
|
||||
soundFor(cue.kind, readyIndex: cue.readyIndex, preset: preset));
|
||||
Future<void> play(SfxCue cue) {
|
||||
if (!enabled(cue.kind)) return Future.value();
|
||||
return _play(cue.kind, cue.readyIndex, presetFor(cue.kind));
|
||||
}
|
||||
|
||||
/// Plays [kind] even when it is switched off (Settings' preview), always
|
||||
/// freshly rendered: an edited spec plays after a hot reload.
|
||||
Future<void> preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) {
|
||||
final p = preset ?? presetFor(kind);
|
||||
final key = '${p.name}-${kind.name}-$readyIndex';
|
||||
_files.remove(key);
|
||||
return playSpec(key, soundFor(kind, readyIndex: readyIndex, preset: p));
|
||||
}
|
||||
Future<void> preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) =>
|
||||
_play(kind, readyIndex, preset ?? presetFor(kind), fresh: true);
|
||||
|
||||
/// Plays [spec], cached under [key] (same key = same sound).
|
||||
Future<void> playSpec(String key, SoundSpec spec) async {
|
||||
/// One slot per sound and ready step: a new preset replaces it.
|
||||
Future<void> _play(SfxKind kind, int readyIndex, SoundPreset preset, {bool fresh = false}) {
|
||||
final v = volume();
|
||||
if (v <= 0) return;
|
||||
try {
|
||||
final path = await (_files[key] ??= _write(key, spec));
|
||||
final player = await _player();
|
||||
await player.setVolume(100 * v * v);
|
||||
await player.open(Media(path));
|
||||
} catch (e) {
|
||||
_files.remove(key);
|
||||
debugPrint('sfx $key: $e');
|
||||
}
|
||||
return engine.play('${kind.name}-$readyIndex', '${preset.name}-${kind.name}-$readyIndex',
|
||||
() => renderWav(soundFor(kind, readyIndex: readyIndex, preset: preset)),
|
||||
volume: math.pow(v, 6).toDouble(), fresh: fresh);
|
||||
}
|
||||
|
||||
Future<String> _write(String key, SoundSpec spec) async {
|
||||
_dir ??= await Directory('${(await getTemporaryDirectory()).path}/watch_together_sfx').create(recursive: true);
|
||||
final f = File('${_dir!.path}/$key.wav');
|
||||
await f.writeAsBytes(wav(render(spec)));
|
||||
return f.path;
|
||||
}
|
||||
/// Opens the audio device ahead of the first sound.
|
||||
Future<void> warmUp() => engine.warmUp();
|
||||
|
||||
Future<Player> _player() async {
|
||||
if (_pool.length < _poolSize) {
|
||||
final p = Player(
|
||||
configuration: const PlayerConfiguration(vo: 'null', osc: false, libass: false, title: 'sfx'),
|
||||
);
|
||||
await (p.platform as NativePlayer).setProperty('audio-display', 'no');
|
||||
_pool.add(p);
|
||||
return p;
|
||||
}
|
||||
return _pool[_next++ % _poolSize];
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
for (final p in _pool) {
|
||||
await p.dispose();
|
||||
}
|
||||
_pool.clear();
|
||||
}
|
||||
Future<void> dispose() => engine.dispose();
|
||||
}
|
||||
|
||||
/// [spec] as WAV bytes, rendered on a worker isolate.
|
||||
Future<Uint8List> renderWav(SoundSpec spec) => compute(_renderWav, spec, debugLabel: 'sfx render');
|
||||
|
||||
Uint8List _renderWav(SoundSpec spec) => wav(render(spec));
|
||||
@@ -8,50 +8,6 @@ import 'shader_presets.dart';
|
||||
/// A shader pick from the menu; a null quality keeps the saved one.
|
||||
typedef ShaderPick = (ShaderPreset, ShaderQuality?);
|
||||
|
||||
/// Compact shader button for the video controls bar (normal and
|
||||
/// fullscreen). Opens a small menu: Off, the six Anime4K modes with
|
||||
/// HQ / Fast, Custom. The menu is a route, so it outlives the controls
|
||||
/// bar hiding under it; [onPick] runs on the caller's side.
|
||||
class ShaderControlButton extends StatelessWidget {
|
||||
final FocusNode focusNode;
|
||||
final ReadonlySignal<RenderMode> renderMode;
|
||||
final void Function(ShaderPreset preset, ShaderQuality? quality) onPick;
|
||||
final ValueChanged<bool>? onMenuOpen;
|
||||
const ShaderControlButton({
|
||||
super.key,
|
||||
required this.focusNode,
|
||||
required this.renderMode,
|
||||
required this.onPick,
|
||||
this.onMenuOpen,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = AppSettings.instance;
|
||||
return SignalBuilder(builder: (context) {
|
||||
final preset = s.shaderPreset.value;
|
||||
final quality = s.shaderQuality.value;
|
||||
final short = shaderShortLabel(preset, quality) ?? 'Shaders';
|
||||
return Tooltip(
|
||||
message: 'Video shaders (Ctrl+1..6, Ctrl+0 off)',
|
||||
child: TextButton.icon(
|
||||
focusNode: focusNode,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor:
|
||||
preset == ShaderPreset.off ? Colors.white : Colors.amberAccent,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
onPressed: () => showShaderMenu(context,
|
||||
renderMode: renderMode, onPick: onPick, onMenuOpen: onMenuOpen),
|
||||
icon: const Icon(Icons.auto_awesome, size: 16),
|
||||
label: Text(short, style: const TextStyle(fontSize: 12)),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// "A HQ", "C+A Fast", "Custom"; null when shaders are off.
|
||||
String? shaderShortLabel(ShaderPreset preset, ShaderQuality quality) =>
|
||||
switch (preset) {
|
||||
@@ -61,7 +17,8 @@ String? shaderShortLabel(ShaderPreset preset, ShaderQuality quality) =>
|
||||
};
|
||||
|
||||
/// The shader menu, anchored on [context]'s box (the button that opens
|
||||
/// it): Off, the six Anime4K modes with HQ / Fast, Custom.
|
||||
/// it; the controls' ⚙): Off, the six Anime4K modes with HQ / Fast,
|
||||
/// Custom. A route, so it outlives the controls hiding under it.
|
||||
Future<void> showShaderMenu(
|
||||
BuildContext context, {
|
||||
required ReadonlySignal<RenderMode> renderMode,
|
||||
|
||||
@@ -85,3 +85,11 @@ String shaderListSeparator({bool? windows}) =>
|
||||
/// The `glsl-shaders` value for [paths].
|
||||
String joinShaderPaths(List<String> paths, {bool? windows}) =>
|
||||
paths.join(shaderListSeparator(windows: windows));
|
||||
|
||||
/// Whether mpv's read-back [got] of `glsl-shaders` is the list [paths].
|
||||
/// Recent mpv prints a path list joined with [shaderListSeparator]; older
|
||||
/// mpv (0.37 and before, the bundled Windows libmpv among them) joins it
|
||||
/// with ','. Matched against each joined form rather than split: a path
|
||||
/// may itself hold ':' (a Windows drive letter) or ','.
|
||||
bool shaderListMatches(List<String> paths, String? got) =>
|
||||
got != null && const [',', ';', ':'].any((sep) => paths.join(sep) == got);
|
||||
@@ -3,20 +3,20 @@ import 'dart:io' show File;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget;
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../controls/frame.dart' show DoubleClickListener;
|
||||
import '../files/folder_store.dart';
|
||||
import '../files/ui/files_page.dart' show showFilesFolder;
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/browse_scope.dart' show BrowseStart;
|
||||
import '../jellyfin/session_media.dart';
|
||||
import '../screens/home/connect_flow.dart';
|
||||
import '../session/app_events.dart' show ActionFailed, ScheduleResult;
|
||||
import '../session/session_coordinator.dart' show ConnectParams;
|
||||
import '../screens/home/health_probe.dart';
|
||||
import '../screens/home/media_picker.dart';
|
||||
import '../screens/home/profile.dart';
|
||||
import '../session/identity.dart';
|
||||
import '../screens/home/update_indicator.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../state/settings.dart';
|
||||
@@ -24,6 +24,7 @@ import '../update/update_controller.dart';
|
||||
import 'chat_panel.dart';
|
||||
import 'connect_cards.dart';
|
||||
import 'handoff.dart';
|
||||
import 'library_link.dart' show LinkProber, defaultLinkProber;
|
||||
import 'library_tab.dart';
|
||||
import 'motion.dart';
|
||||
import 'party_strip.dart';
|
||||
@@ -57,6 +58,9 @@ class AppShell extends StatefulWidget {
|
||||
final JellyfinAccounts? jellyfin;
|
||||
/// Stand-in for media_kit's `Video` (tests).
|
||||
final VideoViewBuilder? videoBuilder;
|
||||
/// What the Library's Link page asks about a link (tests: a fake, so no
|
||||
/// network or yt-dlp runs).
|
||||
final LinkProber linkProbe;
|
||||
const AppShell({
|
||||
super.key,
|
||||
this.updates,
|
||||
@@ -65,6 +69,7 @@ class AppShell extends StatefulWidget {
|
||||
this.healthGet = defaultHealthGet,
|
||||
this.jellyfin,
|
||||
this.videoBuilder,
|
||||
this.linkProbe = defaultLinkProber,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -127,6 +132,14 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
SessionState.instance.route.value == AppRoute.session &&
|
||||
SessionState.instance.coordinator.serverState.value?.media != null);
|
||||
|
||||
/// A video is on in the session (not finished): the update bar stays
|
||||
/// off the full player.
|
||||
final _watching = computed(() {
|
||||
final s = SessionState.instance;
|
||||
final r = s.coordinator.serverState.value;
|
||||
return s.route.value == AppRoute.session && r?.media != null && r?.transport != 'finished';
|
||||
});
|
||||
|
||||
PlayerMode get mode => _mode;
|
||||
PlayerMode get shownMode => _shown;
|
||||
bool get fullscreen => _fullscreen;
|
||||
@@ -189,7 +202,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
// plays again).
|
||||
var wasOut = false;
|
||||
_stops.add(effect(() {
|
||||
final out = coord.selfOut.value;
|
||||
final out = coord.selfAway.value;
|
||||
if (wasOut && !out && session.route.peek() == AppRoute.session) {
|
||||
_later(() {
|
||||
if (_mode == PlayerMode.none) _show(PlayerMode.mini);
|
||||
@@ -225,10 +238,12 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
stop();
|
||||
}
|
||||
HardwareKeyboard.instance.removeHandler(_onKey);
|
||||
_updateTimer?.cancel();
|
||||
_fade.dispose();
|
||||
_online.dispose();
|
||||
_alone.dispose();
|
||||
_hasMedia.dispose();
|
||||
_watching.dispose();
|
||||
_flow.dispose();
|
||||
_join.dispose();
|
||||
_host.dispose();
|
||||
@@ -244,9 +259,17 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
if (mounted) f();
|
||||
});
|
||||
|
||||
Timer? _updateTimer;
|
||||
|
||||
/// At start, then every [updateCheckEvery] while the app runs (when the
|
||||
/// auto-check setting is on).
|
||||
Future<void> _autoCheck() async {
|
||||
final s = AppSettings.instance;
|
||||
await s.ensureLoaded();
|
||||
if (!mounted) return;
|
||||
_updateTimer = Timer.periodic(updateCheckEvery, (_) {
|
||||
if (s.autoCheckUpdates.peek()) unawaited(runUpdateCheck(widget.updates, true));
|
||||
});
|
||||
if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
|
||||
}
|
||||
|
||||
@@ -300,7 +323,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
/// player shows the join-back card). Once out, × closes the player.
|
||||
void _closePlayer() {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
if (coord.selfOut.peek()) {
|
||||
if (coord.selfAway.peek()) {
|
||||
_show(PlayerMode.none);
|
||||
} else {
|
||||
coord.sitOut();
|
||||
@@ -310,7 +333,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
Future<void> _setWindowFullscreen(bool on) async {
|
||||
if (!widget.windowControls) return;
|
||||
try {
|
||||
await windowManager.setFullScreen(on);
|
||||
await SessionState.instance.ports.shell.setFullScreen(on);
|
||||
} catch (_) {
|
||||
// No window manager.
|
||||
}
|
||||
@@ -424,6 +447,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
|
||||
/// Ctrl+V outside a text field (a focused field pastes on its own).
|
||||
bool _onKey(KeyEvent e) {
|
||||
// Any key is activity (auto sit-out).
|
||||
SessionState.instance.coordinator.markInput();
|
||||
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;
|
||||
@@ -438,17 +463,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
/// 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);
|
||||
final clip = await SessionState.instance.ports.shell.readPaste();
|
||||
await _handOff(classifyHandoff(paths: clip.files, text: clip.text), _pointer);
|
||||
}
|
||||
|
||||
Future<void> _handOff(Handoff item, Offset? at) async {
|
||||
@@ -470,7 +486,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
case (HandoffVideos(:final paths), HandoffAction.putUp):
|
||||
final error = await _putUp(FileMedia(paths.first));
|
||||
if (error != null) {
|
||||
SessionState.instance.coordinator.showToast(error);
|
||||
SessionState.instance.coordinator.notify(ActionFailed(error));
|
||||
} else {
|
||||
await FolderStore.instance.ensureLoaded();
|
||||
FolderStore.instance.addRecent(paths.first);
|
||||
@@ -481,7 +497,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
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);
|
||||
SessionState.instance.coordinator.notify(ActionFailed(error));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -490,7 +506,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
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);
|
||||
if (error != null) coord.notify(ActionFailed(error));
|
||||
case (HandoffVideos(:final paths), HandoffAction.addFolder):
|
||||
await _addFolder(File(paths.first).parent.path);
|
||||
case (HandoffFolder(:final path), _):
|
||||
@@ -521,13 +537,13 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
}
|
||||
|
||||
// Off on Settings: its own drop targets (cookies) take the files there.
|
||||
Widget _dropTarget(Widget child) => DropTarget(
|
||||
Widget _dropTarget(Widget child) => SessionState.instance.ports.shell.dropTarget(
|
||||
enable: _tab != ShellTab.settings,
|
||||
onDragEntered: (_) => setState(() => _dragging = _onTop),
|
||||
onDragExited: (_) => setState(() => _dragging = false),
|
||||
onDragDone: (d) {
|
||||
onEnter: () => setState(() => _dragging = _onTop),
|
||||
onExit: () => setState(() => _dragging = false),
|
||||
onDrop: (paths, at) {
|
||||
setState(() => _dragging = false);
|
||||
_handOff(classifyHandoff(paths: DesktopDropX.pathsOf(d.files)), d.localPosition);
|
||||
_handOff(classifyHandoff(paths: paths), at);
|
||||
},
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
@@ -615,6 +631,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
final online = _online.value;
|
||||
final alone = _alone.value;
|
||||
final hasMedia = _hasMedia.value;
|
||||
final onPlayer = _shown == PlayerMode.full && _watching.value;
|
||||
return Column(
|
||||
children: [
|
||||
if (!_fullscreen)
|
||||
@@ -631,9 +648,14 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
onInvite: () => _connect(ConnectMode.host),
|
||||
onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
|
||||
onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null,
|
||||
updates: widget.updates,
|
||||
windowControls: widget.windowControls,
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
alignment: Alignment.topCenter,
|
||||
child: _fullscreen || onPlayer ? const SizedBox(width: double.infinity) : UpdateBar(controller: widget.updates),
|
||||
),
|
||||
Expanded(
|
||||
key: const ValueKey('body'),
|
||||
child: Row(
|
||||
@@ -655,9 +677,18 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
}),
|
||||
),
|
||||
);
|
||||
// Any pointer use anywhere is activity (auto sit-out).
|
||||
final coord = SessionState.instance.coordinator;
|
||||
return Listener(
|
||||
onPointerHover: (e) => _pointer = e.localPosition,
|
||||
onPointerDown: (e) => _pointer = e.localPosition,
|
||||
onPointerHover: (e) {
|
||||
_pointer = e.localPosition;
|
||||
coord.markInput();
|
||||
},
|
||||
onPointerDown: (e) {
|
||||
_pointer = e.localPosition;
|
||||
coord.markInput();
|
||||
},
|
||||
onPointerSignal: (_) => coord.markInput(),
|
||||
child: _dropTarget(shell),
|
||||
);
|
||||
}
|
||||
@@ -693,12 +724,13 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
// Up next needs a server (a solo session has one).
|
||||
onAddToQueue: online ? coord.queue.add : null,
|
||||
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
|
||||
probe: widget.linkProbe,
|
||||
),
|
||||
ShellTab.schedule => SchedulePage(
|
||||
solo: alone,
|
||||
onPutUp: _putUp,
|
||||
onAddToQueue: online ? coord.queue.add : null,
|
||||
onToast: coord.showToast,
|
||||
onToast: (text) => coord.notify(ScheduleResult(text)),
|
||||
),
|
||||
ShellTab.settings => SettingsTab(
|
||||
section: _section,
|
||||
|
||||
@@ -4,8 +4,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../screens/home/profile.dart';
|
||||
import '../session/identity.dart';
|
||||
import '../session/delivery.dart';
|
||||
import '../session/party.dart';
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'motion.dart';
|
||||
@@ -17,9 +18,12 @@ class AvatarInfo {
|
||||
final String name;
|
||||
final Color color;
|
||||
final ImageProvider? image;
|
||||
final bool ready, buffering, relaying, stale, host, self;
|
||||
/// Sitting out: dimmed, with an "away" mark.
|
||||
final bool out;
|
||||
final bool ready, buffering, relaying, host, self;
|
||||
/// Where they stand: joining (a little faded), away (dimmed with a moon),
|
||||
/// gone (dimmed, grey ring: reconnecting).
|
||||
final Presence presence;
|
||||
/// Since when they are gone; null otherwise.
|
||||
final DateTime? goneSince;
|
||||
/// Why their player cannot open the media (`members[].error`): an error
|
||||
/// mark; empty when it can.
|
||||
final String error;
|
||||
@@ -34,13 +38,19 @@ class AvatarInfo {
|
||||
this.ready = false,
|
||||
this.buffering = false,
|
||||
this.relaying = false,
|
||||
this.stale = false,
|
||||
this.presence = Presence.watching,
|
||||
this.goneSince,
|
||||
this.host = false,
|
||||
this.self = false,
|
||||
this.out = false,
|
||||
this.error = '',
|
||||
this.delivery = '',
|
||||
});
|
||||
|
||||
/// Sitting out.
|
||||
bool get away => presence == Presence.away;
|
||||
|
||||
/// Disconnected, in their grace period.
|
||||
bool get gone => presence == Presence.gone;
|
||||
}
|
||||
|
||||
/// Stable colour for someone else (colours are not on the wire).
|
||||
@@ -78,7 +88,7 @@ MemoryImage _memo(Object key, Uint8List bytes) {
|
||||
/// Callers rebuild on [SessionCoordinator.avatars] and [Profile.picture]
|
||||
/// (see [PartyWatch]).
|
||||
ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) {
|
||||
if (m.id.isNotEmpty && m.id == coord.sync.memberId) {
|
||||
if (m.id.isNotEmpty && m.id == coord.connection.memberId) {
|
||||
final own = Profile.instance.picture.value;
|
||||
if (own != null) return _memo('self', own);
|
||||
}
|
||||
@@ -87,7 +97,8 @@ ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) {
|
||||
}
|
||||
|
||||
/// Your own avatar from the profile (also offline).
|
||||
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false, String error = ''}) {
|
||||
AvatarInfo selfAvatarInfo(
|
||||
{bool ready = false, bool host = false, Presence presence = Presence.watching, String error = ''}) {
|
||||
final p = Profile.instance;
|
||||
final png = p.picture.value;
|
||||
final name = p.name.value;
|
||||
@@ -98,31 +109,31 @@ AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = fal
|
||||
ready: ready,
|
||||
host: host,
|
||||
self: true,
|
||||
out: out,
|
||||
presence: presence,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
/// [m] as the party strip, toasts and the chat show it.
|
||||
AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator coord) {
|
||||
final self = m.id.isNotEmpty && m.id == coord.sync.memberId;
|
||||
final self = m.id.isNotEmpty && m.id == coord.connection.memberId;
|
||||
final relay = r == null ? null : stateMediaRelay(r);
|
||||
final playing = r?.media != null;
|
||||
final delivery = r == null ? null : deliveryOf(r, selfId: coord.sync.memberId, names: coord.memberNames);
|
||||
final delivery = r == null ? null : deliveryOf(r, selfId: coord.connection.memberId, names: coord.memberNames);
|
||||
return AvatarInfo(
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
color: self ? Profile.instance.color.value : colorForName(m.name),
|
||||
image: memberAvatarImage(m, coord),
|
||||
ready: m.ready,
|
||||
buffering: !m.stale && m.ready && m.error.isEmpty && playing && m.buffered < r!.bufferNeed,
|
||||
buffering: countsForReady(m) && m.ready && playing && m.buffered < r!.bufferNeed,
|
||||
relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
|
||||
stale: m.stale,
|
||||
presence: m.presence,
|
||||
goneSince: m.goneSince,
|
||||
// The state marks the host (`members[].host`); your own app also
|
||||
// knows before the first state.
|
||||
host: m.host || (self && coord.hostFile != null),
|
||||
self: self,
|
||||
out: m.out,
|
||||
error: m.error,
|
||||
delivery: delivery == null ? '' : memberDeliveryOf(m, r!, delivery).detail,
|
||||
);
|
||||
@@ -150,8 +161,9 @@ class PartyWatch extends StatelessWidget {
|
||||
|
||||
/// Circle avatar with a status ring outside it and a gap in the background
|
||||
/// colour, so overlapping neighbours never hide the status. Crown = host,
|
||||
/// tower = passing the video on, spinner = buffering, dimmed = stale,
|
||||
/// dimmed with a moon = sitting out, red "!" = cannot play the media.
|
||||
/// tower = passing the video on, spinner = buffering, a little faded =
|
||||
/// joining, dimmed with a grey ring = gone (reconnecting), dimmed with a
|
||||
/// moon = sitting out, red "!" = cannot play the media.
|
||||
class StatusAvatar extends StatelessWidget {
|
||||
final AvatarInfo info;
|
||||
final double size;
|
||||
@@ -168,7 +180,7 @@ class StatusAvatar extends StatelessWidget {
|
||||
final bg = Theme.of(context).colorScheme.surfaceContainerLowest;
|
||||
final ringColor = !ring
|
||||
? bg
|
||||
: info.stale
|
||||
: info.gone
|
||||
? Colors.grey.shade700
|
||||
: info.buffering
|
||||
? Colors.amber
|
||||
@@ -195,7 +207,11 @@ class StatusAvatar extends StatelessWidget {
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
||||
padding: EdgeInsets.all(stroke),
|
||||
child: Opacity(
|
||||
opacity: info.stale || info.out ? 0.4 : 1,
|
||||
opacity: info.gone || info.away
|
||||
? 0.4
|
||||
: info.presence == Presence.joining
|
||||
? 0.7
|
||||
: 1,
|
||||
child: CircleAvatar(
|
||||
radius: size / 2,
|
||||
backgroundColor: info.color,
|
||||
@@ -224,7 +240,7 @@ class StatusAvatar extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (info.out)
|
||||
if (info.away)
|
||||
Positioned(
|
||||
key: const ValueKey('away-mark'),
|
||||
left: -3,
|
||||
@@ -281,7 +297,7 @@ class _AvatarStackState extends State<AvatarStack> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = widget.size;
|
||||
int rank(AvatarInfo m) => m.host ? 0 : (m.out ? 3 : m.ready ? 1 : 2);
|
||||
int rank(AvatarInfo m) => m.host ? 0 : (m.gone ? 4 : m.away ? 3 : m.ready ? 1 : 2);
|
||||
final sorted = [...widget.members]..sort((a, b) => rank(a) - rank(b));
|
||||
final visible = sorted.take(widget.shown).toList();
|
||||
final extra = sorted.length - visible.length;
|
||||
@@ -356,13 +372,16 @@ class Pill extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Someone by display name (feed lines carry names, not ids): from the
|
||||
/// latest state when still there, else their initial on a stable colour.
|
||||
AvatarInfo avatarForName(String name, [SessionCoordinator? coord]) {
|
||||
/// Someone in a feed line or toast: by member [id] when it has one, else
|
||||
/// by display [name]; from the latest state when still there, else their
|
||||
/// initial on a stable colour.
|
||||
AvatarInfo avatarForName(String name, [SessionCoordinator? coord, String id = '']) {
|
||||
final c = coord ?? SessionState.instance.coordinator;
|
||||
final r = c.serverState.value;
|
||||
for (final m in r?.members ?? const <MemberView>[]) {
|
||||
if (m.name == name) return memberAvatarInfo(m, r, c);
|
||||
final members = r?.members ?? const <MemberView>[];
|
||||
// By id when the line has one (names change and repeat), else by name.
|
||||
for (final m in members) {
|
||||
if (id.isNotEmpty ? m.id == id : m.name == name) return memberAvatarInfo(m, r, c);
|
||||
}
|
||||
return AvatarInfo(name: name, color: colorForName(name));
|
||||
}
|
||||
@@ -188,7 +188,7 @@ class ActivityRow extends StatelessWidget {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
StatusAvatar(info: avatarForName(item.actor), size: 34, badges: false),
|
||||
StatusAvatar(info: avatarForName(item.actor, null, item.actorId), size: 34, badges: false),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -261,7 +261,7 @@ Future<void> _messageMenu(BuildContext context, String text, String? link) async
|
||||
case 'join':
|
||||
return (Icons.login, Colors.green, 'joined');
|
||||
case 'leave':
|
||||
final reason = f.reason == 'silent too long' ? ' · timed out' : (f.reason.isEmpty ? '' : ' · ${f.reason}');
|
||||
final reason = f.reason == 'grace' ? ' · lost signal too long' : '';
|
||||
return (Icons.logout, Colors.grey, 'left$reason');
|
||||
case 'ready':
|
||||
return (Icons.check_circle, Colors.green, 'is ready');
|
||||
@@ -279,12 +279,15 @@ Future<void> _messageMenu(BuildContext context, String text, String? link) async
|
||||
return (Icons.fast_forward, scheme.primary, 'seeked');
|
||||
case 'swap':
|
||||
return (Icons.swap_horiz, scheme.primary, 'changed the media');
|
||||
case 'stale':
|
||||
case 'gone':
|
||||
return (Icons.cloud_off, Colors.orange, 'lost signal');
|
||||
case 'out':
|
||||
case 'return':
|
||||
return (Icons.cloud_done, Colors.green, 'is back');
|
||||
case 'away':
|
||||
final who = f.target;
|
||||
return (Icons.bedtime, Colors.blueGrey, who == null || who.isEmpty ? 'is sitting out' : 'sat $who out');
|
||||
case 'back':
|
||||
final why = f.reason.isEmpty ? '' : ' · ${f.reason}';
|
||||
return (Icons.bedtime, Colors.blueGrey, '${who == null || who.isEmpty ? 'is sitting out' : 'sat $who out'}$why');
|
||||
case 'in':
|
||||
return (Icons.login, Colors.green, 'joined back');
|
||||
case 'queue-add':
|
||||
return (Icons.playlist_add_rounded, scheme.primary, 'added ${f.target ?? ''} to Up next');
|
||||
|
||||
@@ -6,11 +6,11 @@ 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 '../widgets/app_menu.dart';
|
||||
import '../widgets/copy_open.dart';
|
||||
import '../state/connect_prefs.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
// The Library's Link source: a big paste field, a preview of what the link
|
||||
@@ -236,10 +236,10 @@ class LinkPreviewStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a link is (see [AppYtDlp.probe]).
|
||||
/// What a link is (see [LinkResolver.probe]).
|
||||
typedef LinkProber = Future<LinkProbe> Function(String url);
|
||||
|
||||
Future<LinkProbe> defaultLinkProber(String url) => AppYtDlp.instance.probe(url);
|
||||
Future<LinkProbe> defaultLinkProber(String url) => SessionState.instance.ports.links.probe(url);
|
||||
|
||||
String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:sync_relay_core/core.dart' show RelayConstants;
|
||||
|
||||
import '../session/app_events.dart' show ProblemMarked;
|
||||
import '../state/session_state.dart';
|
||||
import '../widgets/app_menu.dart' show AppGlass;
|
||||
import 'motion.dart';
|
||||
@@ -19,7 +20,7 @@ Future<void> showMarkProblem(BuildContext context) async {
|
||||
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");
|
||||
coord.notify(ProblemMarked(inSession: inSession));
|
||||
}
|
||||
|
||||
/// The small card: a one-line note and Mark. Pops with the note (maybe
|
||||
|
||||
@@ -3,13 +3,11 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../screens/home/ui.dart' show appName, displayVersion;
|
||||
import '../screens/home/update_indicator.dart';
|
||||
import '../screens/home/ui.dart' show appName, displayVersion, shortVersion;
|
||||
import '../session/delivery.dart';
|
||||
import '../session/party.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import '../widgets/app_keys.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import '../widgets/copy_open.dart';
|
||||
@@ -56,7 +54,6 @@ class PartyStrip extends StatelessWidget {
|
||||
|
||||
/// Online with something up but the player closed: brings it back.
|
||||
final VoidCallback? onShowPlayer;
|
||||
final UpdateController? updates;
|
||||
|
||||
/// Off in tests: the drag area and window buttons need window_manager.
|
||||
final bool windowControls;
|
||||
@@ -73,7 +70,6 @@ class PartyStrip extends StatelessWidget {
|
||||
required this.onProfile,
|
||||
this.onInvite,
|
||||
this.onShowPlayer,
|
||||
this.updates,
|
||||
this.windowControls = true,
|
||||
});
|
||||
|
||||
@@ -125,8 +121,10 @@ class PartyStrip extends StatelessWidget {
|
||||
label: t.label,
|
||||
selected: t == tab,
|
||||
compact: compact,
|
||||
// The version, a rest of the mouse away.
|
||||
// The version: always in sight under Settings,
|
||||
// the full one a rest of the mouse away.
|
||||
tooltip: t == ShellTab.settings ? '$appName ${displayVersion()}' : null,
|
||||
caption: t == ShellTab.settings ? shortVersion() : null,
|
||||
onTap: () => onTab(t),
|
||||
),
|
||||
],
|
||||
@@ -135,7 +133,6 @@ class PartyStrip extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
UpdateIndicator(controller: updates),
|
||||
const SizedBox(width: 8),
|
||||
if (online && onShowPlayer != null)
|
||||
IconButton(
|
||||
@@ -266,7 +263,7 @@ class PartyStrip extends StatelessWidget {
|
||||
// their own clicks.
|
||||
if (windowControls)
|
||||
const Positioned.fill(
|
||||
child: DragToMoveArea(child: SizedBox.expand()),
|
||||
child: WindowDragArea(child: SizedBox.expand()),
|
||||
),
|
||||
Positioned.fill(child: row),
|
||||
],
|
||||
@@ -286,8 +283,11 @@ class _ServerLabel extends StatelessWidget {
|
||||
builder: (context) {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
final r = coord.serverState.value;
|
||||
final members = r?.members ?? const <MemberView>[];
|
||||
final ready = members.where((m) => m.ready).length;
|
||||
// The ready counter counts who the presence policy counts (not away,
|
||||
// not gone, able to play it).
|
||||
final counted = (r?.members ?? const <MemberView>[]).where(countsForReady).toList();
|
||||
final ready = counted.where((m) => m.ready).length;
|
||||
final stale = coord.stateStale.value;
|
||||
return Column(
|
||||
key: const ValueKey('server-label'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -298,7 +298,11 @@ class _ServerLabel extends StatelessWidget {
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.1),
|
||||
),
|
||||
Text(
|
||||
r == null ? 'connecting…' : 'ready $ready/${members.length}',
|
||||
r == null
|
||||
? 'connecting…'
|
||||
: stale
|
||||
? 'reconnecting…'
|
||||
: 'ready $ready/${counted.length}',
|
||||
style: TextStyle(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
@@ -369,6 +373,8 @@ class StripTab extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
/// Shown after resting on the tab.
|
||||
final String? tooltip;
|
||||
/// Small text under the tab (the version under Settings).
|
||||
final String? caption;
|
||||
const StripTab({
|
||||
super.key,
|
||||
required this.label,
|
||||
@@ -376,6 +382,7 @@ class StripTab extends StatelessWidget {
|
||||
required this.onTap,
|
||||
this.compact = false,
|
||||
this.tooltip,
|
||||
this.caption,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -391,7 +398,11 @@ class StripTab extends StatelessWidget {
|
||||
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 12),
|
||||
child: SizedBox(
|
||||
height: partyStripHeight,
|
||||
child: Column(
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 3),
|
||||
@@ -415,6 +426,29 @@ class StripTab extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (caption case final c?)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 2,
|
||||
height: 13,
|
||||
child: OverflowBox(
|
||||
maxWidth: 160,
|
||||
child: Text(
|
||||
c,
|
||||
key: const ValueKey('strip-version'),
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
height: 1.2,
|
||||
color: scheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -490,7 +524,7 @@ class MembersCard extends StatelessWidget {
|
||||
: '${session.lastForm.host}:${session.lastForm.port}';
|
||||
final delivery = r == null
|
||||
? null
|
||||
: deliveryOf(r, selfId: coord.sync.memberId, names: coord.memberNames);
|
||||
: deliveryOf(r, selfId: coord.connection.memberId, names: coord.memberNames);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -560,10 +594,13 @@ class _MemberTile extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
final m = member;
|
||||
final status = m.stale
|
||||
? 'reconnecting…'
|
||||
: m.out
|
||||
final since = m.goneSince;
|
||||
final status = m.presence == Presence.gone
|
||||
? 'reconnecting…${since == null ? '' : ' (${sinceText(since, DateTime.now().toUtc())})'}'
|
||||
: m.presence == Presence.away
|
||||
? 'sitting out'
|
||||
: m.presence == Presence.joining
|
||||
? 'joining…'
|
||||
: m.error.isNotEmpty
|
||||
? "can't play this: ${m.error}"
|
||||
: info.buffering
|
||||
@@ -602,11 +639,11 @@ class _MemberTile extends StatelessWidget {
|
||||
}
|
||||
},
|
||||
items: () => [
|
||||
if (!m.out) AppMenuItem(value: 'force', label: m.ready ? 'Force unready' : 'Force ready'),
|
||||
if (m.presence != Presence.away) AppMenuItem(value: 'force', label: m.ready ? 'Force unready' : 'Force ready'),
|
||||
if (!info.self) const AppMenuItem(value: 'nudge', label: 'Nudge'),
|
||||
if (info.self && m.out)
|
||||
if (info.self && m.presence == Presence.away)
|
||||
const AppMenuItem(value: 'back', label: 'Join back')
|
||||
else if (!m.out)
|
||||
else if (m.presence != Presence.away)
|
||||
const AppMenuItem(value: 'out', label: 'Sit out'),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../session/health.dart';
|
||||
import '../session/party.dart';
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../widgets/app_menu.dart' show AppGlass;
|
||||
@@ -61,6 +62,7 @@ String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1
|
||||
StatusView? playerStatusFor({
|
||||
required HealthStatus health,
|
||||
bool reconnecting = false,
|
||||
bool unstable = false,
|
||||
StateBroadcast? state,
|
||||
MemberView? self,
|
||||
bool slowConnect = false,
|
||||
@@ -70,7 +72,11 @@ StatusView? playerStatusFor({
|
||||
return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
|
||||
tone: HealthSeverity.warn, action: StatusAction.cancel);
|
||||
}
|
||||
if (playerError.isNotEmpty && state?.media != null && !(self?.out ?? false)) {
|
||||
if (unstable) {
|
||||
return const StatusView(StatusKind.reconnecting, 'Connection unstable…', tone: HealthSeverity.warn);
|
||||
}
|
||||
final away = self?.presence == Presence.away;
|
||||
if (playerError.isNotEmpty && state?.media != null && !away) {
|
||||
return StatusView(StatusKind.problem, "Can't play this: $playerError",
|
||||
tone: HealthSeverity.error, action: StatusAction.reload, short: "Can't play this");
|
||||
}
|
||||
@@ -84,7 +90,7 @@ StatusView? playerStatusFor({
|
||||
final r = state;
|
||||
if (r == null || r.media == null || r.transport == 'playing') return null;
|
||||
// Sitting out: the join-back card says it; nobody waits for you.
|
||||
if (self != null && self.out) return null;
|
||||
if (away) return null;
|
||||
if (r.transport == 'finished') {
|
||||
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
|
||||
}
|
||||
@@ -92,7 +98,7 @@ StatusView? playerStatusFor({
|
||||
// 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,
|
||||
if (m.error.isNotEmpty && isPresent(m) && (self == null || m.id != self.id)) m,
|
||||
];
|
||||
// As the server holds playback: someone in the ready set short of the
|
||||
// minimum buffer.
|
||||
@@ -119,8 +125,8 @@ StatusView? playerStatusFor({
|
||||
return StatusView(StatusKind.you, "Press Space when you're ready",
|
||||
who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready');
|
||||
}
|
||||
// Members sitting out are never waited for nor counted.
|
||||
final active = [for (final m in r.members) if (!m.stale && !m.out && m.error.isEmpty) m];
|
||||
// Members sitting out or gone are never waited for nor counted.
|
||||
final active = [for (final m in r.members) if (countsForReady(m)) m];
|
||||
final others = [
|
||||
for (final m in active)
|
||||
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
|
||||
@@ -137,6 +143,7 @@ StatusView? playerStatusNow({bool slowConnect = false}) {
|
||||
return playerStatusFor(
|
||||
health: c.health.value,
|
||||
reconnecting: c.reconnecting.value,
|
||||
unstable: c.unstable.value,
|
||||
state: c.serverState.value,
|
||||
self: c.selfMember.value,
|
||||
slowConnect: slowConnect,
|
||||
|
||||
@@ -3,13 +3,13 @@ import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show Presence;
|
||||
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../screens/home/profile.dart';
|
||||
import '../session/identity.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import 'avatar.dart';
|
||||
@@ -81,8 +81,8 @@ class _ProfileCardState extends State<ProfileCard> {
|
||||
final hosting = online && !session.solo.value && coord.hostFile != null;
|
||||
final color = _p.color.value;
|
||||
final png = _p.picture.value;
|
||||
final out = self?.out ?? false;
|
||||
final me = selfAvatarInfo(ready: self?.ready ?? false, host: hosting, out: out);
|
||||
final out = self?.presence == Presence.away;
|
||||
final me = selfAvatarInfo(ready: self?.ready ?? false, host: hosting, presence: self?.presence ?? Presence.watching);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -329,18 +329,17 @@ class _CropPickerState extends State<CropPicker> {
|
||||
}
|
||||
|
||||
Future<void> _pick() async {
|
||||
final List<PlatformFile> files;
|
||||
final List<String> files;
|
||||
try {
|
||||
files = await FilePicker.pickFiles(
|
||||
files = await SessionState.instance.ports.folders.pickFiles(
|
||||
dialogTitle: 'Pick a picture',
|
||||
type: FileType.custom,
|
||||
allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'],
|
||||
extensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'],
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = 'file dialog unavailable — try again');
|
||||
return;
|
||||
}
|
||||
final path = files.isEmpty ? null : files.first.path;
|
||||
final path = files.firstOrNull;
|
||||
if (path == null) return;
|
||||
try {
|
||||
final codec = await ui.instantiateImageCodec(await File(path).readAsBytes());
|
||||
|
||||
@@ -2,26 +2,21 @@ 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/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../controls/controls_probe.dart';
|
||||
import '../controls/player_settings.dart';
|
||||
import '../controls/uosc/buttons.dart';
|
||||
import '../controls/uosc/controls.dart';
|
||||
import '../controls/uosc/timeline.dart';
|
||||
import '../controls/uosc/volume_flash.dart';
|
||||
import '../controls/model/controls_model.dart';
|
||||
import '../controls/session_controls.dart';
|
||||
import '../controls/volume.dart';
|
||||
import '../queue/queue_ui.dart';
|
||||
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
|
||||
import '../screens/session/jellyfin_widgets.dart';
|
||||
import '../screens/session/player_controls.dart';
|
||||
import '../session/app_events.dart' show ShaderMessage;
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../shaders/shader_menu.dart';
|
||||
import '../shaders/shader_presets.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../state/settings.dart';
|
||||
@@ -29,7 +24,6 @@ import '../widgets/app_keys.dart';
|
||||
import '../widgets/text_focus.dart';
|
||||
import '../widgets/window_buttons.dart';
|
||||
import 'avatar.dart';
|
||||
import 'mark_problem.dart';
|
||||
import 'motion.dart';
|
||||
import 'now_playing_title.dart';
|
||||
import 'player_status.dart';
|
||||
@@ -46,8 +40,8 @@ enum PlayerMode { none, mini, full }
|
||||
typedef VideoViewBuilder = Widget Function(BuildContext context, VideoControlsBuilder? controls);
|
||||
|
||||
/// The session's player: the one `Video` of the current player and
|
||||
/// everything over it. Full: our bars on media_kit's controls or the uosc
|
||||
/// set (Settings), the start card, the status pill, Jellyfin overlays and
|
||||
/// everything over it. Full: the controls (one model, the skin picked in
|
||||
/// Settings), the start card, the status pill, Jellyfin overlays and
|
||||
/// the player keys. Mini: hover controls and a progress line. The shell keeps this
|
||||
/// widget mounted at one place in the tree while the mode changes, so the
|
||||
/// `Video` is never mounted twice nor rebuilt for a move.
|
||||
@@ -99,21 +93,29 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
|
||||
DateTime? _lastKeyAt;
|
||||
late final void Function() _stopShaderWatch;
|
||||
bool _shaderMenuOpen = false;
|
||||
// Own toast ids, negative: the coordinator counts up from 1.
|
||||
int _toastSeq = 0;
|
||||
int _shaderNoticeSeen = 0;
|
||||
bool _miniHover = false;
|
||||
// Whether the controls are up: the standard ones tracked like media_kit
|
||||
// does (shown on mount and on mouse movement, hidden 3 s later or on
|
||||
// exit), the uosc ones reporting it.
|
||||
// Whether the controls are up (their frame reports 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;
|
||||
// The shell's state the controls show, as signals for the model.
|
||||
late final _fullscreen = signal(widget.fullscreen);
|
||||
late final _chatOpen = signal(widget.chatOpen);
|
||||
late final _unread = signal(widget.unread);
|
||||
late final PlayerControlsModel _model = PlayerControlsModel(
|
||||
coord: _coord,
|
||||
fullscreen: _fullscreen,
|
||||
chatOpen: _chatOpen,
|
||||
unread: _unread,
|
||||
onToggleFullscreen: () => widget.onToggleFullscreen(),
|
||||
onToggleChat: () => widget.onToggleChat(),
|
||||
onChangeVideo: () => widget.onChangeVideo(),
|
||||
onCollapse: () => widget.onCollapse(),
|
||||
onLeave: () => widget.onLeave(),
|
||||
);
|
||||
|
||||
SessionCoordinator get _coord => SessionState.instance.coordinator;
|
||||
bool get _full => widget.mode == PlayerMode.full;
|
||||
@@ -131,13 +133,21 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
final n = _coord.playback.shaderNotice.value;
|
||||
if (n == null || n.id == _shaderNoticeSeen) return;
|
||||
_shaderNoticeSeen = n.id;
|
||||
_toast(n.text);
|
||||
_coord.notify(ShaderMessage(n.text));
|
||||
});
|
||||
_stopPopoverWatch = effect(() {
|
||||
_hold.set(#upNextPopover, _coord.queue.popoverOpen.value);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SessionPlayer oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_fullscreen.value = widget.fullscreen;
|
||||
_chatOpen.value = widget.chatOpen;
|
||||
_unread.value = widget.unread;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
HardwareKeyboard.instance.removeHandler(_globalKey);
|
||||
@@ -147,38 +157,11 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_noFocus.dispose();
|
||||
_controlsShown.dispose();
|
||||
_hold.dispose();
|
||||
_volumeFlash.dispose();
|
||||
_model.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _reloadVideo() {
|
||||
_coord.markInput();
|
||||
_coord.reloadVideo();
|
||||
}
|
||||
|
||||
void _toast(String text) {
|
||||
_coord.toasts.value = [
|
||||
..._coord.toasts.peek(),
|
||||
ToastItem(id: --_toastSeq, text: text, at: DateTime.now().toUtc()),
|
||||
];
|
||||
}
|
||||
|
||||
/// Menu, Ctrl+0..6: saved as the choice (also used on the next join)
|
||||
/// and applied live. A null quality keeps the saved one.
|
||||
void _pickShader(ShaderPreset preset, ShaderQuality? quality) => applyShaderChoice(preset, quality);
|
||||
|
||||
/// One volume step up (+1) or down (-1), from the wheel or ↑ / ↓;
|
||||
/// flashes the design's volume readout.
|
||||
void _stepVolume(int step) {
|
||||
final player = _coord.playback.player;
|
||||
player.setVolume(stepVolume(player.state.volume, step * volumeStep));
|
||||
_volumeFlash.value++;
|
||||
}
|
||||
|
||||
void _toggleChat() {
|
||||
_coord.markInput();
|
||||
widget.onToggleChat();
|
||||
}
|
||||
void _toggleChat() => _model.toggleChat();
|
||||
|
||||
/// The player keys, while the player fills the body and nothing (a
|
||||
/// dialog, a card, a text field) is in front of it. Space and Tab are
|
||||
@@ -196,7 +179,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
final route = ModalRoute.of(context);
|
||||
if (route != null && !route.isCurrent) {
|
||||
// A menu of ours is open: Esc closes it.
|
||||
if (_shaderMenuOpen && event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
if (_model.menusOpen > 0 && event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
Navigator.of(context, rootNavigator: true).maybePop();
|
||||
return true;
|
||||
}
|
||||
@@ -207,62 +190,55 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) {
|
||||
return false;
|
||||
}
|
||||
final coord = _coord;
|
||||
final model = _model;
|
||||
final key = event.logicalKey;
|
||||
// Ctrl+0..6: Anime4K's own mpv keys (off, modes A..C+A).
|
||||
final digit = _shaderKeys.indexOf(key);
|
||||
if (digit >= 0 && HardwareKeyboard.instance.isControlPressed) {
|
||||
_lastKeyAt = now;
|
||||
_pickShader(digit == 0 ? ShaderPreset.off : ShaderPreset.modes[digit - 1], null);
|
||||
model.pickShader(digit == 0 ? ShaderPreset.off : ShaderPreset.modes[digit - 1], null);
|
||||
return true;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.keyR && HardwareKeyboard.instance.isControlPressed) {
|
||||
_lastKeyAt = now;
|
||||
_reloadVideo();
|
||||
model.reload();
|
||||
return true;
|
||||
}
|
||||
// mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes.
|
||||
final shift = HardwareKeyboard.instance.isShiftPressed;
|
||||
if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) {
|
||||
// Up next first, then the next episode.
|
||||
if (coord.queue.items.peek().isNotEmpty) {
|
||||
if (!model.next()) return false;
|
||||
_lastKeyAt = now;
|
||||
coord.queue.skip();
|
||||
return true;
|
||||
}
|
||||
if (coord.nextEpisode.peek() == null) return false;
|
||||
_lastKeyAt = now;
|
||||
coord.playNextEpisode();
|
||||
return true;
|
||||
}
|
||||
if (event.character == '<' || (shift && key == LogicalKeyboardKey.keyP)) {
|
||||
if (coord.previousEpisode.peek() == null) return false;
|
||||
if (!model.previous()) return false;
|
||||
_lastKeyAt = now;
|
||||
coord.playPreviousEpisode();
|
||||
return true;
|
||||
}
|
||||
// mpv's chapter keys.
|
||||
if (key == LogicalKeyboardKey.pageDown || key == LogicalKeyboardKey.pageUp) {
|
||||
_lastKeyAt = now;
|
||||
return coord.stepChapter(key == LogicalKeyboardKey.pageDown ? 1 : -1);
|
||||
return model.stepChapter(key == LogicalKeyboardKey.pageDown ? 1 : -1);
|
||||
}
|
||||
if (shift && key == LogicalKeyboardKey.arrowRight) {
|
||||
_lastKeyAt = now;
|
||||
coord.skipForward();
|
||||
model.skipForward();
|
||||
return true;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.arrowDown) {
|
||||
_stepVolume(key == LogicalKeyboardKey.arrowUp ? 1 : -1);
|
||||
model.stepVolume(key == LogicalKeyboardKey.arrowUp ? 1 : -1);
|
||||
return true;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.arrowRight) {
|
||||
_lastKeyAt = now;
|
||||
coord.nudge(10);
|
||||
model.nudge(10);
|
||||
return true;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.arrowLeft) {
|
||||
_lastKeyAt = now;
|
||||
coord.nudge(-10);
|
||||
model.nudge(-10);
|
||||
return true;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.escape) {
|
||||
@@ -312,13 +288,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
// (lists, menus and popovers under the mouse scroll instead).
|
||||
return VolumeWheel(
|
||||
enabled: _full,
|
||||
onStep: _stepVolume,
|
||||
onStep: _model.stepVolume,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
video,
|
||||
if (_full) ...[
|
||||
_progressLine(),
|
||||
_blockedCard(),
|
||||
_actionArea(),
|
||||
if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()],
|
||||
@@ -327,7 +302,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_miniOverlay(),
|
||||
// Sitting out: nothing is loaded; the card covers the
|
||||
// empty player (which stays mounted for joining back).
|
||||
if (_coord.selfOut.value) _sitOut(),
|
||||
if (_coord.selfAway.value) _sitOut(),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -336,24 +311,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _video(VideoControlsBuilder? controls) {
|
||||
final theme = _controlsTheme();
|
||||
return MaterialDesktopVideoControlsTheme(
|
||||
normal: theme,
|
||||
fullscreen: theme,
|
||||
child: Video(
|
||||
Widget _video(VideoControlsBuilder? controls) => Video(
|
||||
key: _videoKey,
|
||||
controller: _coord.playback.video,
|
||||
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
|
||||
controls: controls,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static const _barHeight = 56.0;
|
||||
/// The standard top bar: room for the cover (2:3, 60 px tall).
|
||||
static const _topBarHeight = 72.0;
|
||||
static const _controlsHide = Duration(seconds: 3);
|
||||
|
||||
/// Fullscreen hides the strip and its window buttons: the top bar
|
||||
/// carries them while the controls are up (restore leaves fullscreen).
|
||||
@@ -373,337 +336,18 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
);
|
||||
}
|
||||
|
||||
/// The standard design's progress line while its controls are hidden
|
||||
/// (the uosc timeline collapses into its own).
|
||||
Widget _progressLine() => Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: IgnorePointer(
|
||||
child: SignalBuilder(builder: (context) {
|
||||
final s = AppSettings.instance;
|
||||
if (s.mpvControls.value || !s.progressLine.value) return const SizedBox.shrink();
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: _controlsShown,
|
||||
builder: (context, shown, _) => shown
|
||||
? const SizedBox.shrink()
|
||||
: const _MiniSeekBar(key: ValueKey('progress-line-standard'), thin: true),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
MaterialDesktopVideoControlsThemeData _controlsTheme() {
|
||||
final coord = _coord;
|
||||
return MaterialDesktopVideoControlsThemeData(
|
||||
playAndPauseOnTap: false,
|
||||
modifyVolumeOnScroll: false,
|
||||
// media_kit's double-click pushes its own fullscreen route (a second
|
||||
// `Video`); ours toggles the window instead (see [DoubleClickListener]).
|
||||
toggleFullscreenOnDoublePress: false,
|
||||
keyboardShortcuts: {},
|
||||
visibleOnMount: true,
|
||||
hideMouseOnControlsRemoval: true,
|
||||
controlsHoverDuration: _controlsHide,
|
||||
// Our seek bar (chapter marks, hover title) sits in the bottom bar,
|
||||
// which grows by its height; the top bar keeps its row at the top.
|
||||
displaySeekBar: false,
|
||||
buttonBarHeight: _barHeight + ChapterSeekBar.height,
|
||||
topButtonBar: [
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: SizedBox(
|
||||
height: _topBarHeight,
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(child: NowPlayingTitle()),
|
||||
UpNextButton(
|
||||
queue: coord.queue,
|
||||
onPlayNextEpisode: coord.playNextEpisode,
|
||||
look: UpNextLook.standard,
|
||||
hold: _hold),
|
||||
MaterialDesktopCustomButton(
|
||||
icon: const Icon(Icons.video_library_outlined),
|
||||
onPressed: widget.onChangeVideo,
|
||||
),
|
||||
MaterialDesktopCustomButton(
|
||||
icon: Badge(
|
||||
isLabelVisible: widget.unread > 0,
|
||||
label: Text('${widget.unread}'),
|
||||
child: Icon(widget.chatOpen ? Icons.chat_bubble : Icons.chat_bubble_outline),
|
||||
),
|
||||
onPressed: _toggleChat,
|
||||
),
|
||||
MaterialDesktopCustomButton(
|
||||
icon: Icon(widget.fullscreen ? Icons.fullscreen_exit : Icons.close_fullscreen),
|
||||
onPressed: widget.fullscreen ? widget.onToggleFullscreen : widget.onCollapse,
|
||||
),
|
||||
?_fullscreenWindowButtons(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
bottomButtonBar: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
SignalBuilder(
|
||||
builder: (context) => PlayerSeekBar(
|
||||
player: coord.playback.player,
|
||||
chapters: coord.playback.chapters.value,
|
||||
),
|
||||
),
|
||||
Expanded(child: Row(children: _bottomButtons())),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _bottomButtons() {
|
||||
final coord = _coord;
|
||||
final playback = coord.playback;
|
||||
void menuOpen(bool open) => _shaderMenuOpen = open;
|
||||
void pickTrack(TrackPick p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff);
|
||||
return [
|
||||
SignalBuilder(
|
||||
builder: (context) => EpisodeSkipButton(
|
||||
focusNode: _noFocus,
|
||||
episode: coord.previousEpisode.value,
|
||||
next: false,
|
||||
onPressed: coord.playPreviousEpisode,
|
||||
),
|
||||
),
|
||||
SignalBuilder(
|
||||
builder: (context) => EpisodeSkipButton(
|
||||
focusNode: _noFocus,
|
||||
episode: coord.nextEpisode.value,
|
||||
next: true,
|
||||
onPressed: coord.playNextEpisode,
|
||||
),
|
||||
),
|
||||
VolumeButton(player: playback.player, focusNode: _noFocus),
|
||||
_ReadyControlButton(focusNode: _noFocus),
|
||||
_BarTextButton(
|
||||
focusNode: _noFocus,
|
||||
label: 'Mark everyone ready',
|
||||
onPressed: coord.forcePlayAll,
|
||||
),
|
||||
_BarTextButton(
|
||||
focusNode: _noFocus,
|
||||
icon: Icons.refresh,
|
||||
label: 'Reload video',
|
||||
tooltip: 'Reload video (Ctrl+R) — fixes a frozen picture',
|
||||
onPressed: _reloadVideo,
|
||||
),
|
||||
const MaterialDesktopPositionIndicator(),
|
||||
const Spacer(),
|
||||
SkipForwardButton(focusNode: _noFocus, onPressed: coord.skipForward),
|
||||
SignalBuilder(
|
||||
builder: (context) => ChaptersControlButton(
|
||||
focusNode: _noFocus,
|
||||
chapters: playback.chapters.value,
|
||||
current: playback.currentChapter.value,
|
||||
onPick: (c) => coord.seekTo(c.start.inMicroseconds / 1e6),
|
||||
onMenuOpen: menuOpen,
|
||||
),
|
||||
),
|
||||
SignalBuilder(
|
||||
builder: (context) => TrackMenuButton(
|
||||
focusNode: _noFocus,
|
||||
type: JellyfinStreamType.audio,
|
||||
tracks: playback.tracks.value,
|
||||
onPick: pickTrack,
|
||||
onMenuOpen: menuOpen,
|
||||
),
|
||||
),
|
||||
SignalBuilder(
|
||||
builder: (context) => TrackMenuButton(
|
||||
focusNode: _noFocus,
|
||||
type: JellyfinStreamType.subtitle,
|
||||
tracks: playback.tracks.value,
|
||||
onPick: pickTrack,
|
||||
onMenuOpen: menuOpen,
|
||||
),
|
||||
),
|
||||
ShaderControlButton(
|
||||
focusNode: _noFocus,
|
||||
renderMode: playback.renderMode,
|
||||
onPick: _pickShader,
|
||||
onMenuOpen: menuOpen,
|
||||
),
|
||||
PlayerSettingsButton(focusNode: _noFocus, renderMode: playback.renderMode),
|
||||
MaterialDesktopCustomButton(
|
||||
icon: Icon(widget.fullscreen ? Icons.fullscreen_exit : Icons.fullscreen),
|
||||
onPressed: widget.onToggleFullscreen,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _sessionControls(VideoState state) {
|
||||
// The start card sits under the controls (they stay usable on it).
|
||||
// The "Player design" setting swaps them live.
|
||||
return Stack(fit: StackFit.expand, children: [const StartBanner(), _controlsSwitch(state)]);
|
||||
}
|
||||
|
||||
Widget _controlsSwitch(VideoState state) {
|
||||
return SessionControlsSwitch(
|
||||
material: (_) => ControlsHoverProbe(
|
||||
shown: _controlsShown,
|
||||
hideAfter: _controlsHide,
|
||||
hold: _hold,
|
||||
child: DoubleClickListener(
|
||||
onDoubleClick: widget.onToggleFullscreen,
|
||||
// Clicks on the bars are button clicks, not the video.
|
||||
skipTop: _topBarHeight,
|
||||
skipBottom: _barHeight + ChapterSeekBar.height,
|
||||
child: MaterialDesktopVideoControls(state),
|
||||
),
|
||||
),
|
||||
mpv: (_) => _mpvControls(),
|
||||
);
|
||||
}
|
||||
|
||||
/// The uosc controls: same actions as the bars above,
|
||||
/// all through the coordinator (seeks via `player.seek`, the user path).
|
||||
Widget _mpvControls() {
|
||||
final coord = _coord;
|
||||
final playback = coord.playback;
|
||||
void menuOpen(bool open) => _shaderMenuOpen = open;
|
||||
void pickTrack(TrackPick p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff);
|
||||
Widget episode(bool next) => SignalBuilder(builder: (context) {
|
||||
final e = next ? coord.nextEpisode.value : coord.previousEpisode.value;
|
||||
if (e == null) return const SizedBox.shrink();
|
||||
return UoscButton(
|
||||
key: ValueKey(next ? 'episode-next' : 'episode-previous'),
|
||||
icon: next ? Icons.skip_next_rounded : Icons.skip_previous_rounded,
|
||||
tooltip: '${next ? 'Next' : 'Previous'}: ${e.label} (${next ? '> or Shift+N' : '< or Shift+P'})',
|
||||
onPressed: next ? coord.playNextEpisode : coord.playPreviousEpisode,
|
||||
);
|
||||
});
|
||||
return UoscControls(
|
||||
shown: _controlsShown,
|
||||
holdBy: _hold,
|
||||
onDoubleTap: widget.onToggleFullscreen,
|
||||
topLeft: const NowPlayingTitle(),
|
||||
// Up next is one of the top bar's controls, before chat.
|
||||
top: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
UpNextButton(
|
||||
queue: coord.queue, onPlayNextEpisode: coord.playNextEpisode, look: UpNextLook.uosc, hold: _hold),
|
||||
UoscButton(
|
||||
key: const ValueKey('uosc-chat'),
|
||||
icon: widget.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded,
|
||||
tooltip: 'Chat & activity (Tab)',
|
||||
badge: widget.unread,
|
||||
onPressed: _toggleChat,
|
||||
),
|
||||
UoscButton(
|
||||
icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
|
||||
tooltip: widget.fullscreen ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
|
||||
onPressed: widget.fullscreen ? widget.onToggleFullscreen : widget.onCollapse,
|
||||
),
|
||||
?_fullscreenWindowButtons(),
|
||||
],
|
||||
),
|
||||
timeline: (context, expanded) => SignalBuilder(
|
||||
builder: (context) => UoscTimeline(
|
||||
position: playback.position.value,
|
||||
duration: playback.duration.value,
|
||||
buffered: playback.buffered.value,
|
||||
chapters: playback.chapters.value,
|
||||
expanded: expanded,
|
||||
onSeek: (d) => playback.player.seek(d),
|
||||
),
|
||||
),
|
||||
left: [
|
||||
UoscMainMenuButton(
|
||||
onMenuOpen: menuOpen,
|
||||
actions: [
|
||||
(title: 'Change video', hint: null, run: widget.onChangeVideo),
|
||||
(title: 'Chat & activity', hint: 'Tab', run: _toggleChat),
|
||||
(title: 'Reload video', hint: 'Ctrl+R', run: _reloadVideo),
|
||||
(title: 'Mark a problem', hint: 'Ctrl+Shift+B', run: () => showMarkProblem(context)),
|
||||
(title: 'Mark everyone ready', hint: null, run: coord.forcePlayAll),
|
||||
(title: 'Fullscreen', hint: 'F', run: widget.onToggleFullscreen),
|
||||
(title: 'Leave server', hint: 'Esc', run: widget.onLeave),
|
||||
],
|
||||
),
|
||||
SignalBuilder(
|
||||
builder: (context) => UoscTracksButton(
|
||||
type: JellyfinStreamType.subtitle,
|
||||
tracks: playback.tracks.value,
|
||||
onPick: pickTrack,
|
||||
onMenuOpen: menuOpen,
|
||||
),
|
||||
),
|
||||
SignalBuilder(
|
||||
builder: (context) => UoscTracksButton(
|
||||
type: JellyfinStreamType.audio,
|
||||
tracks: playback.tracks.value,
|
||||
onPick: pickTrack,
|
||||
onMenuOpen: menuOpen,
|
||||
),
|
||||
),
|
||||
UoscShaderButton(renderMode: playback.renderMode, onPick: _pickShader, onMenuOpen: menuOpen),
|
||||
const SizedBox(width: 12),
|
||||
SignalBuilder(builder: (context) {
|
||||
final ready = coord.selfMember.value?.ready ?? false;
|
||||
return UoscButton(
|
||||
key: const ValueKey('uosc-ready'),
|
||||
icon: ready ? Icons.check_circle_rounded : Icons.radio_button_unchecked_rounded,
|
||||
label: ready ? 'Ready' : 'Not ready',
|
||||
color: ready ? Colors.greenAccent : null,
|
||||
tooltip: 'Ready / not ready (Space)',
|
||||
onPressed: coord.toggleReady,
|
||||
);
|
||||
}),
|
||||
UoscButton(icon: Icons.done_all_rounded, tooltip: 'Mark everyone ready', onPressed: coord.forcePlayAll),
|
||||
UoscButton(
|
||||
icon: Icons.refresh_rounded,
|
||||
tooltip: 'Reload video (Ctrl+R) — fixes a frozen picture',
|
||||
onPressed: _reloadVideo,
|
||||
),
|
||||
],
|
||||
right: [
|
||||
UoscButton(
|
||||
key: const ValueKey('uosc-skip-forward'),
|
||||
icon: Icons.fast_forward_rounded,
|
||||
label: '1:30',
|
||||
tooltip: 'Skip 1:30 for everyone (Shift+Right)',
|
||||
onPressed: coord.skipForward,
|
||||
),
|
||||
SignalBuilder(
|
||||
builder: (context) => UoscChaptersButton(
|
||||
chapters: playback.chapters.value,
|
||||
current: playback.currentChapter.value,
|
||||
duration: playback.duration.value,
|
||||
onPick: (c) => coord.seekTo(c.start.inMicroseconds / 1e6),
|
||||
onMenuOpen: menuOpen,
|
||||
),
|
||||
),
|
||||
episode(false),
|
||||
episode(true),
|
||||
const SizedBox(width: 12),
|
||||
UoscButton(icon: Icons.video_library_outlined, tooltip: 'Change video', onPressed: widget.onChangeVideo),
|
||||
StreamBuilder<double>(
|
||||
stream: playback.player.stream.volume,
|
||||
initialData: playback.player.state.volume,
|
||||
builder: (context, v) => UoscVolumeButton(
|
||||
volume: v.data ?? 100,
|
||||
onChanged: (x) => playback.player.setVolume(x),
|
||||
),
|
||||
),
|
||||
UoscPlayerSettingsButton(renderMode: playback.renderMode),
|
||||
UoscButton(
|
||||
icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded,
|
||||
tooltip: 'Fullscreen (F)',
|
||||
onPressed: widget.onToggleFullscreen,
|
||||
const StartBanner(),
|
||||
SessionControls(
|
||||
model: _model,
|
||||
shown: _controlsShown,
|
||||
hold: _hold,
|
||||
onDoubleClick: widget.onToggleFullscreen,
|
||||
windowButtons: _fullscreenWindowButtons(),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -713,64 +357,26 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
/// the gap between them and the controls.
|
||||
static const _edge = 16.0;
|
||||
|
||||
/// The bottom controls' height in the design in use.
|
||||
static double _bottomControlsHeight(bool uosc) => uosc
|
||||
? UoscControls.buttonRowHeight + UoscControls.rowBottomPadding + UoscTimeline.height
|
||||
: _barHeight + ChapterSeekBar.height;
|
||||
|
||||
/// The volume flash after a wheel or key step, in the design in use:
|
||||
/// uosc's vertical slider at the right edge (centred between the top
|
||||
/// buttons and the bottom controls, as uosc places it), else a pill
|
||||
/// under the top bar.
|
||||
Widget _volumeReadout() {
|
||||
final playback = _coord.playback;
|
||||
Widget withVolume(Widget Function(double v) build) => StreamBuilder<double>(
|
||||
stream: playback.player.stream.volume,
|
||||
initialData: playback.player.state.volume,
|
||||
builder: (context, v) => build(v.data ?? 100),
|
||||
);
|
||||
return SignalBuilder(builder: (context) {
|
||||
if (AppSettings.instance.mpvControls.value) {
|
||||
const top = 12 + UoscControls.buttonRowHeight;
|
||||
final bottom = _bottomControlsHeight(true);
|
||||
return Positioned(
|
||||
right: UoscVolumeSlider.width / 2,
|
||||
top: top,
|
||||
bottom: bottom,
|
||||
width: UoscVolumeSlider.width,
|
||||
child: LayoutBuilder(builder: (context, c) {
|
||||
final h = UoscVolumeSlider.heightFor(c.maxHeight);
|
||||
if (h <= UoscVolumeSlider.width) return const SizedBox.shrink();
|
||||
return Align(
|
||||
// Centred with the mute button's room below, like uosc.
|
||||
alignment: Alignment.center,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: UoscVolumeSlider.width),
|
||||
child: SizedBox(
|
||||
height: h,
|
||||
child: VolumeFlash(
|
||||
trigger: _volumeFlash,
|
||||
builder: (context) => withVolume((v) => UoscVolumeSlider(volume: v)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
/// The bottom controls' height in the skin and size in use.
|
||||
static double _bottomControlsHeight() {
|
||||
final s = AppSettings.instance;
|
||||
return controlsBottomHeight(s.controlsSkin.value, s.controlsSize.value);
|
||||
}
|
||||
return Positioned(
|
||||
|
||||
static double get _topBarHeight => controlsTopHeight(AppSettings.instance.controlsSkin.peek());
|
||||
|
||||
/// The volume flash after a wheel or key step: a pill under the top bar.
|
||||
Widget _volumeReadout() => Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: _topBarHeight + 8,
|
||||
child: Center(
|
||||
child: VolumeFlash(
|
||||
trigger: _volumeFlash,
|
||||
builder: (context) => withVolume((v) => VolumeReadout(volume: v)),
|
||||
trigger: _model.volumeFlash,
|
||||
builder: (context) => SignalBuilder(builder: (context) => VolumeReadout(volume: _model.volume.value)),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// The action area: one band above the bottom controls for everything
|
||||
/// that floats there. The status pill (the ready prompt among it)
|
||||
@@ -781,7 +387,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
/// ([ControlsHold]).
|
||||
Widget _actionArea() {
|
||||
return SignalBuilder(builder: (context) {
|
||||
final controls = _bottomControlsHeight(AppSettings.instance.mpvControls.value);
|
||||
final controls = _bottomControlsHeight();
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: _controlsShown,
|
||||
builder: (context, shown, child) => AnimatedPositioned(
|
||||
@@ -848,7 +454,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
announce: skip.kind != SkipKind.ending,
|
||||
controlsShown: _controlsShown,
|
||||
hold: _hold,
|
||||
child: SkipPill(offer: skip, onPressed: () => coord.skip(skip)),
|
||||
child: SkipPill(offer: skip, onPressed: () => _model.skip(skip)),
|
||||
),
|
||||
if (showNext)
|
||||
PillGate(
|
||||
@@ -1171,7 +777,7 @@ const _shaderKeys = [
|
||||
class _MiniSeekBar extends StatelessWidget {
|
||||
final bool thin;
|
||||
final ValueChanged<Duration>? onSeek;
|
||||
const _MiniSeekBar({super.key, this.thin = false, this.onSeek});
|
||||
const _MiniSeekBar({this.thin = false, this.onSeek});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -1199,91 +805,3 @@ class _MiniSeekBar extends StatelessWidget {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Double-click on the picture (not on the bars) without joining the
|
||||
/// gesture arena, so single clicks on buttons stay instant.
|
||||
class DoubleClickListener extends StatefulWidget {
|
||||
final VoidCallback onDoubleClick;
|
||||
final double skipTop, skipBottom;
|
||||
final Widget child;
|
||||
const DoubleClickListener(
|
||||
{super.key, required this.onDoubleClick, this.skipTop = 0, this.skipBottom = 0, required this.child});
|
||||
|
||||
@override
|
||||
State<DoubleClickListener> createState() => _DoubleClickListenerState();
|
||||
}
|
||||
|
||||
class _DoubleClickListenerState extends State<DoubleClickListener> {
|
||||
DateTime? _last;
|
||||
Offset? _lastAt;
|
||||
|
||||
void _down(PointerDownEvent e) {
|
||||
if (e.buttons != kPrimaryMouseButton) return;
|
||||
final h = context.size?.height ?? 0;
|
||||
final y = e.localPosition.dy;
|
||||
if (y < widget.skipTop || y > h - widget.skipBottom) {
|
||||
_last = null;
|
||||
return;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final last = _last, at = _lastAt;
|
||||
if (last != null &&
|
||||
at != null &&
|
||||
now.difference(last) < const Duration(milliseconds: 400) &&
|
||||
(e.localPosition - at).distance < 24) {
|
||||
_last = null;
|
||||
widget.onDoubleClick();
|
||||
return;
|
||||
}
|
||||
_last = now;
|
||||
_lastAt = e.localPosition;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
Listener(behavior: HitTestBehavior.translucent, onPointerDown: _down, child: widget.child);
|
||||
}
|
||||
|
||||
class _ReadyControlButton extends StatelessWidget {
|
||||
final FocusNode focusNode;
|
||||
const _ReadyControlButton({required this.focusNode});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SignalBuilder(
|
||||
builder: (context) {
|
||||
final ready = SessionState.instance.coordinator.selfMember.value?.ready ?? false;
|
||||
return _BarTextButton(
|
||||
focusNode: focusNode,
|
||||
label: ready ? 'Ready ✓' : 'Not ready',
|
||||
onPressed: SessionState.instance.coordinator.toggleReady,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BarTextButton extends StatelessWidget {
|
||||
final FocusNode focusNode;
|
||||
final String label;
|
||||
final IconData? icon;
|
||||
final String? tooltip;
|
||||
final VoidCallback onPressed;
|
||||
const _BarTextButton(
|
||||
{required this.focusNode, required this.label, required this.onPressed, this.icon, this.tooltip});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = TextButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
);
|
||||
final text = Text(label, style: const TextStyle(fontSize: 12));
|
||||
final button = icon == null
|
||||
? TextButton(focusNode: focusNode, style: style, onPressed: onPressed, child: text)
|
||||
: TextButton.icon(
|
||||
focusNode: focusNode, style: style, onPressed: onPressed, icon: Icon(icon, size: 16), label: text);
|
||||
return tooltip == null ? button : Tooltip(message: tooltip, child: button);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fox_utils/fox_utils.dart' show DesktopDropX, DropTarget;
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart'
|
||||
show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, parseCookies;
|
||||
@@ -12,13 +10,12 @@ import 'package:sync_relay_core/core.dart'
|
||||
import '../controls/player_settings.dart';
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/accounts_panel.dart';
|
||||
import '../playback/yt_dlp_app.dart';
|
||||
import '../schedule/animeflow.dart';
|
||||
import '../screens/home/about_page.dart';
|
||||
import '../screens/home/settings_page.dart';
|
||||
import '../session/auto_sit_out.dart' show AutoSitOutTimes;
|
||||
import '../sfx/sfx.dart';
|
||||
import '../sfx/sound_bank.dart';
|
||||
import '../sfx/synth_sfx.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../update/update_controller.dart';
|
||||
@@ -104,15 +101,17 @@ class SettingsTab extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
SettingsPanel(
|
||||
title: 'When idle',
|
||||
title: 'Away',
|
||||
child: SignalBuilder(
|
||||
builder: (context) => SwitchListTile(
|
||||
key: const ValueKey('idle-sits-out'),
|
||||
key: const ValueKey('auto-sit-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,
|
||||
title: const Text('Auto sit-out'),
|
||||
subtitle: Text('Idle for ${AutoSitOutTimes.idle.inMinutes} min while nothing plays, or minimised '
|
||||
'for ${AutoSitOutTimes.minimised.inMinutes} min, sits you out so nobody waits for you; '
|
||||
"you're back as soon as you return. Never when you're alone"),
|
||||
value: AppSettings.instance.autoSitOut.value,
|
||||
onChanged: AppSettings.instance.setAutoSitOut,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -121,12 +120,16 @@ class SettingsTab extends StatelessWidget {
|
||||
return [
|
||||
const SettingsHintCard(
|
||||
icon: Icons.tune,
|
||||
text: "Player settings are also in the player's ⚙, at the right end of its controls.",
|
||||
text: "Player settings are also in the player: ⚙ → Player settings, at the right of its controls.",
|
||||
),
|
||||
SettingsPanel(
|
||||
title: 'Player',
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()),
|
||||
),
|
||||
SettingsPanel(
|
||||
title: 'Controls',
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: controlsSettingRows()),
|
||||
),
|
||||
const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()),
|
||||
const SettingsPanel(title: 'yt-dlp', child: YtDlpSettings()),
|
||||
];
|
||||
@@ -377,7 +380,7 @@ 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.
|
||||
/// Defaults to the app's ([Ports.links]).
|
||||
final Future<YtDlp>? ytDlp;
|
||||
const YtDlpSettings({super.key, this.ytDlp});
|
||||
|
||||
@@ -386,7 +389,7 @@ class YtDlpSettings extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _YtDlpSettingsState extends State<YtDlpSettings> {
|
||||
late final Future<YtDlp> _y = widget.ytDlp ?? AppYtDlp.instance.ytDlp;
|
||||
late final Future<YtDlp> _y = widget.ytDlp ?? SessionState.instance.ports.links.ytDlp;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -469,18 +472,17 @@ class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
|
||||
};
|
||||
|
||||
Future<void> _pickFile() async {
|
||||
final List<PlatformFile> files;
|
||||
final List<String> files;
|
||||
try {
|
||||
files = await FilePicker.pickFiles(
|
||||
files = await SessionState.instance.ports.folders.pickFiles(
|
||||
dialogTitle: 'Pick a cookies.txt or a JSON cookie export',
|
||||
type: FileType.custom,
|
||||
allowedExtensions: const ['txt', 'json'],
|
||||
extensions: const ['txt', 'json'],
|
||||
);
|
||||
} catch (_) {
|
||||
_reload('file dialog unavailable — try again');
|
||||
return;
|
||||
}
|
||||
final path = files.firstOrNull?.path;
|
||||
final path = files.firstOrNull;
|
||||
if (path != null) await _importFile(path);
|
||||
}
|
||||
|
||||
@@ -532,12 +534,12 @@ class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return DropTarget(
|
||||
onDragEntered: (_) => setState(() => _dragging = true),
|
||||
onDragExited: (_) => setState(() => _dragging = false),
|
||||
onDragDone: (d) {
|
||||
return SessionState.instance.ports.shell.dropTarget(
|
||||
onEnter: () => setState(() => _dragging = true),
|
||||
onExit: () => setState(() => _dragging = false),
|
||||
onDrop: (paths, _) {
|
||||
setState(() => _dragging = false);
|
||||
final path = DesktopDropX.pathsOf(d.files).firstOrNull;
|
||||
final path = paths.firstOrNull;
|
||||
if (path != null) _importFile(path);
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
@@ -779,7 +781,7 @@ class NotificationsInfo extends StatelessWidget {
|
||||
),
|
||||
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'),
|
||||
line('App notices', 'Shader changes, auto sit-out setting you away'),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -794,10 +796,7 @@ class NotificationsInfo extends StatelessWidget {
|
||||
class SoundSettings extends StatelessWidget {
|
||||
const SoundSettings({super.key});
|
||||
|
||||
void _preview(SfxKind kind) {
|
||||
final sfx = SessionState.instance.sfx;
|
||||
if (sfx is SynthSfx) unawaited(sfx.preview(kind, readyIndex: 2));
|
||||
}
|
||||
void _preview(SfxKind kind) => unawaited(SessionState.instance.sfx.preview(kind, readyIndex: 2));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -50,7 +50,7 @@ class _SitOutCardState extends State<SitOutCard> {
|
||||
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());
|
||||
final at = r == null ? 0.0 : serverPositionAt(r, coord.connection.serverNow());
|
||||
Duration secs(double s) => Duration(milliseconds: (s * 1000).round());
|
||||
final where = r?.media == null
|
||||
? 'Nothing is up'
|
||||
|
||||
@@ -80,7 +80,7 @@ class _PartyToastsState extends State<PartyToasts> {
|
||||
final playing = coord.serverState.value?.transport == 'playing';
|
||||
final popups = AppSettings.instance.chatPopups.value;
|
||||
for (final t in fresh) {
|
||||
final toast = shellToastFor(t, (n) => avatarForName(n, coord));
|
||||
final toast = shellToastFor(t, (n) => avatarForName(n, coord, t.actorId));
|
||||
// Chat per Settings → Notifications (it is in the chat either way).
|
||||
if (toast.chat && !popups.shows(playing: playing)) continue;
|
||||
_add(toast);
|
||||
|
||||
@@ -11,11 +11,15 @@ 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 '../platform/desktop/desktop_ports.dart';
|
||||
import '../platform/ports/ports.dart';
|
||||
import '../sfx/sfx.dart';
|
||||
import '../sfx/sound_bank.dart';
|
||||
import '../sfx/synth_sfx.dart';
|
||||
import '../session/health.dart';
|
||||
import '../session/notifier.dart';
|
||||
import '../session/connection.dart' show Connection;
|
||||
import '../session/identity.dart' show ClientIdentity;
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../widgets/avatar_pipeline.dart';
|
||||
import 'connect_prefs.dart';
|
||||
@@ -160,13 +164,23 @@ class SessionState {
|
||||
final route = signal(AppRoute.home);
|
||||
final recents = signal<List<RecentServer>>(const []);
|
||||
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
||||
final SfxService sfx = SynthSfx(
|
||||
/// Owns the app's audio device ([SynthSfx.dispose] on close).
|
||||
final SynthSfx sfx = SynthSfx(
|
||||
volume: () => AppSettings.instance.soundVolume.peek(),
|
||||
// A nudge is an explicit poke: it ignores its switch (not the volume).
|
||||
enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name),
|
||||
presetFor: soundPresetFor,
|
||||
);
|
||||
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
|
||||
/// The platform (window, links, folders, yt-dlp, notifications).
|
||||
final Ports ports = desktopPorts();
|
||||
/// Every toast, sound and activity-log line goes through it.
|
||||
late final Notifier notifier = Notifier(sfx: sfx, attention: ports.shell.requestAttention);
|
||||
/// The link to the server; lost/back go to the notifier.
|
||||
/// The link to the server, with this install's stored identity
|
||||
/// (`hello.client` / `seq`); lost/back go to the notifier.
|
||||
late final Connection connection = Connection(notify: notifier.notify, identity: ClientIdentity.stored());
|
||||
late final SessionCoordinator coordinator =
|
||||
SessionCoordinator(notifier: notifier, connection: connection, ytDlp: ports.links);
|
||||
final lastForm = ConnectForm();
|
||||
JellyfinAccounts get jellyfin => JellyfinAccounts.instance;
|
||||
/// Set when the hosted server died mid-session; Home shows it on the
|
||||
@@ -187,7 +201,7 @@ class SessionState {
|
||||
/// default) when the app-support folder is not available.
|
||||
static Future<String> _sharedYtDlpDir() async {
|
||||
try {
|
||||
return (await AppYtDlp.instance.ytDlp).dir;
|
||||
return (await instance.ports.links.ytDlp).dir;
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
@@ -284,7 +298,7 @@ class SessionState {
|
||||
try {
|
||||
if (carry != null) await _resume(carry);
|
||||
} catch (e) {
|
||||
coordinator.showToast("couldn't put it up again: $e");
|
||||
coordinator.notify(ActionFailed("couldn't put it up again: $e"));
|
||||
}
|
||||
} finally {
|
||||
switching.value = false;
|
||||
@@ -341,7 +355,7 @@ class SessionState {
|
||||
});
|
||||
try {
|
||||
await landed.future.timeout(const Duration(seconds: 20));
|
||||
coordinator.sync.sendSeek(carry.position);
|
||||
coordinator.connection.sendSeek(carry.position);
|
||||
} on TimeoutException {
|
||||
debugPrint('resume: the media did not come up; not seeking');
|
||||
} finally {
|
||||
@@ -502,8 +516,10 @@ class SessionState {
|
||||
await server?.stop();
|
||||
}
|
||||
|
||||
Future<void> leave() async {
|
||||
await coordinator.leave();
|
||||
/// Out of the session (Leave, the app closing). [bye] false: Update
|
||||
/// and restart, the restarted app takes the member back within its grace.
|
||||
Future<void> leave({bool bye = true}) async {
|
||||
await coordinator.leave(bye: bye);
|
||||
await stopServer();
|
||||
solo.value = false;
|
||||
coordinator.solo = false;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../controls/model/controls_prefs.dart';
|
||||
import '../shaders/shader_presets.dart';
|
||||
import 'settings_schema.dart';
|
||||
|
||||
// App preferences (Settings page), one Hive box. Signals start at their
|
||||
// defaults and take the stored values once [AppSettings.ensureLoaded]
|
||||
@@ -17,7 +21,14 @@ const _keyShaderPreset = 'shaderPreset';
|
||||
const _keyShaderQuality = 'shaderQuality';
|
||||
const _keyCustomShaders = 'customShaders';
|
||||
const _keyAutoAdvance = 'autoAdvance';
|
||||
/// Legacy: true was the uosc design (now [ControlsSkin.minimal]); read
|
||||
/// once when [_keyControlsSkin] is missing.
|
||||
const _keyMpvControls = 'mpvControls';
|
||||
const _keyControlsSkin = 'controlsSkin';
|
||||
const _keyTimeDisplay = 'timeDisplay';
|
||||
const _keyControlsSize = 'controlsSize';
|
||||
const _keyControlsHideSecs = 'controlsHideSecs';
|
||||
const _keyHiddenControls = 'hiddenControls';
|
||||
const _keyHideReReleases = 'hideReReleases';
|
||||
const _keyProgressLine = 'progressLine';
|
||||
const _keySoundVolume = 'soundVolume';
|
||||
@@ -26,12 +37,18 @@ const _keySoundPreset = 'soundPreset';
|
||||
const _keyWelcomeDismissed = 'welcomeDismissed';
|
||||
const _keySoundPresetFor = 'soundPresetFor';
|
||||
const _keyUnreadyOnSwap = 'unreadyOnSwap';
|
||||
const _keyIdleSitsOut = 'idleSitsOut';
|
||||
const keyAutoSitOut = 'autoSitOut';
|
||||
const _keyChatPopups = 'chatPopups';
|
||||
const _keyShareDiagnostics = 'shareDiagnostics';
|
||||
|
||||
enum HwdecMode { auto, off, custom }
|
||||
|
||||
/// The mpv `hwdec` of [HwdecMode.auto]. Windows: 'auto-copy' (decoded on
|
||||
/// the GPU, frames copied back): media_kit renders through ANGLE, where
|
||||
/// plain 'auto' picks the dxva2 interop, which cannot create its EGL
|
||||
/// surface there ("Failed to create EGL surface"). Elsewhere 'auto'.
|
||||
String get autoHwdec => Platform.isWindows ? 'auto-copy' : 'auto';
|
||||
|
||||
/// When chat messages pop up as toasts (they always reach the chat).
|
||||
enum ChatPopups {
|
||||
all('All'),
|
||||
@@ -60,22 +77,29 @@ class AppSettings {
|
||||
/// Session: send ready=false when the video changes (a swap keeps the
|
||||
/// server's ready flags).
|
||||
final unreadyOnSwap = signal(false);
|
||||
/// Session: the idle auto-unready sits this member out instead.
|
||||
final idleSitsOut = signal(false);
|
||||
/// Player design: the uosc controls (`controls/uosc/`) instead of the
|
||||
/// standard ones (media_kit's with our bars). Applies live.
|
||||
final mpvControls = signal(false);
|
||||
/// Session: auto sit-out (idle while nothing plays, or minimised for a
|
||||
/// while, sets you away; coming back sets you in).
|
||||
final autoSitOut = signal(true);
|
||||
/// The player controls' skin (`controls/`). Applies live.
|
||||
final controlsSkin = signal(ControlsSkin.standard);
|
||||
/// The bar's clock: elapsed / total or the time left.
|
||||
final timeDisplay = signal(TimeDisplay.elapsed);
|
||||
final controlsSize = signal(ControlsSize.normal);
|
||||
/// Seconds without mouse movement before the controls hide.
|
||||
final controlsHideSecs = signal(defaultControlsHideSecs);
|
||||
/// Optional bar buttons switched off (they stay in ⚙).
|
||||
final hiddenControls = signal<Set<OptionalControl>>(const {});
|
||||
/// Library: leave out of "Latest in X" what was added long after it
|
||||
/// first aired (re-uploads, old seasons added late).
|
||||
final hideReReleases = signal(true);
|
||||
/// A 2 px progress line at the bottom of the video while the controls
|
||||
/// are hidden (both designs). Applies live.
|
||||
/// are hidden (both skins). Applies live.
|
||||
final progressLine = signal(true);
|
||||
/// Send this app's log lines to the server's session log while in a
|
||||
/// session (never chat). Off: nothing is sent but a note that it is off.
|
||||
final shareDiagnostics = signal(true);
|
||||
/// Chat message toasts (default: only when paused). Applies live.
|
||||
final chatPopups = signal(ChatPopups.paused);
|
||||
/// Chat message toasts (default: all). Applies live.
|
||||
final chatPopups = signal(ChatPopups.all);
|
||||
/// App sounds (`sfx/`): 0..1, 0 = silent.
|
||||
final soundVolume = signal(1.0);
|
||||
/// `SfxKind` names switched off one by one.
|
||||
@@ -95,13 +119,13 @@ class AppSettings {
|
||||
/// .glsl files of [ShaderPreset.custom], in mpv order.
|
||||
final customShaders = signal<List<String>>(const []);
|
||||
|
||||
/// mpv `hwdec` value: 'auto', 'no', or the custom value ('auto' when
|
||||
/// custom is blank).
|
||||
/// mpv `hwdec` value: [autoHwdec], 'no', or the custom value
|
||||
/// ([autoHwdec] when custom is blank).
|
||||
late final hwdec = computed(() => switch (hwdecMode.value) {
|
||||
HwdecMode.auto => 'auto',
|
||||
HwdecMode.auto => autoHwdec,
|
||||
HwdecMode.off => 'no',
|
||||
HwdecMode.custom =>
|
||||
hwdecCustom.value.trim().isEmpty ? 'auto' : hwdecCustom.value.trim(),
|
||||
hwdecCustom.value.trim().isEmpty ? autoHwdec : hwdecCustom.value.trim(),
|
||||
});
|
||||
|
||||
Future<void>? _load;
|
||||
@@ -112,6 +136,9 @@ class AppSettings {
|
||||
Future<void> _loadNow() async {
|
||||
try {
|
||||
final box = _box = await Hive.openBox(settingsBoxName);
|
||||
for (final line in await migrateSettings(box)) {
|
||||
debugPrint(line);
|
||||
}
|
||||
final mode = box.get(_keyHwdecMode);
|
||||
hwdecMode.value = HwdecMode.values
|
||||
.where((m) => m.name == mode)
|
||||
@@ -125,10 +152,20 @@ class AppSettings {
|
||||
if (advance is bool) autoAdvance.value = advance;
|
||||
final unready = box.get(_keyUnreadyOnSwap);
|
||||
if (unready is bool) unreadyOnSwap.value = unready;
|
||||
final idleOut = box.get(_keyIdleSitsOut);
|
||||
if (idleOut is bool) idleSitsOut.value = idleOut;
|
||||
final mpv = box.get(_keyMpvControls);
|
||||
if (mpv is bool) mpvControls.value = mpv;
|
||||
final autoOut = box.get(keyAutoSitOut);
|
||||
if (autoOut is bool) autoSitOut.value = autoOut;
|
||||
controlsSkin.value =
|
||||
ControlsSkin.fromStored(box.get(_keyControlsSkin), legacyMpvControls: box.get(_keyMpvControls));
|
||||
final time = box.get(_keyTimeDisplay);
|
||||
timeDisplay.value = TimeDisplay.values.where((t) => t.name == time).firstOrNull ?? TimeDisplay.elapsed;
|
||||
final size = box.get(_keyControlsSize);
|
||||
controlsSize.value = ControlsSize.values.where((t) => t.name == size).firstOrNull ?? ControlsSize.normal;
|
||||
final hide = box.get(_keyControlsHideSecs);
|
||||
if (hide is int && controlsHideChoices.contains(hide)) controlsHideSecs.value = hide;
|
||||
final hidden = box.get(_keyHiddenControls);
|
||||
if (hidden is List) {
|
||||
hiddenControls.value = Set.unmodifiable(OptionalControl.values.where((c) => hidden.contains(c.name)));
|
||||
}
|
||||
final hideRe = box.get(_keyHideReReleases);
|
||||
if (hideRe is bool) hideReReleases.value = hideRe;
|
||||
final line = box.get(_keyProgressLine);
|
||||
@@ -136,7 +173,7 @@ class AppSettings {
|
||||
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;
|
||||
chatPopups.value = ChatPopups.values.where((m) => m.name == popups).firstOrNull ?? ChatPopups.all;
|
||||
final vol = box.get(_keySoundVolume);
|
||||
if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1);
|
||||
final off = box.get(_keySoundsOff);
|
||||
@@ -194,14 +231,36 @@ class AppSettings {
|
||||
_put(_keyUnreadyOnSwap, on);
|
||||
}
|
||||
|
||||
void setIdleSitsOut(bool on) {
|
||||
idleSitsOut.value = on;
|
||||
_put(_keyIdleSitsOut, on);
|
||||
void setAutoSitOut(bool on) {
|
||||
autoSitOut.value = on;
|
||||
_put(keyAutoSitOut, on);
|
||||
}
|
||||
|
||||
void setMpvControls(bool on) {
|
||||
mpvControls.value = on;
|
||||
_put(_keyMpvControls, on);
|
||||
void setControlsSkin(ControlsSkin skin) {
|
||||
controlsSkin.value = skin;
|
||||
_put(_keyControlsSkin, skin.name);
|
||||
}
|
||||
|
||||
void setTimeDisplay(TimeDisplay t) {
|
||||
timeDisplay.value = t;
|
||||
_put(_keyTimeDisplay, t.name);
|
||||
}
|
||||
|
||||
void setControlsSize(ControlsSize size) {
|
||||
controlsSize.value = size;
|
||||
_put(_keyControlsSize, size.name);
|
||||
}
|
||||
|
||||
void setControlsHideSecs(int secs) {
|
||||
controlsHideSecs.value = secs;
|
||||
_put(_keyControlsHideSecs, secs);
|
||||
}
|
||||
|
||||
void setControlShown(OptionalControl c, bool on) {
|
||||
final next = {...hiddenControls.value};
|
||||
on ? next.remove(c) : next.add(c);
|
||||
hiddenControls.value = Set.unmodifiable(next);
|
||||
_put(_keyHiddenControls, [for (final c in next) c.name]);
|
||||
}
|
||||
|
||||
void setProgressLine(bool on) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:hive_ce/hive.dart';
|
||||
|
||||
// The `settings` box's layout version and the ordered steps between
|
||||
// versions. A step rewrites stored values for a new layout (renamed keys,
|
||||
// changed formats); the restructure steps add theirs here.
|
||||
|
||||
/// The layout this build reads. 1: the layout of 1.2.x, where versioning
|
||||
/// starts (a box without a version is at 1). 2: auto sit-out replaces the
|
||||
/// idle switch.
|
||||
const settingsSchemaVersion = 2;
|
||||
|
||||
/// The box key holding the stored version.
|
||||
const settingsSchemaKey = 'schemaVersion';
|
||||
|
||||
/// One step: brings a box at `to - 1` to [to].
|
||||
class SettingsMigration {
|
||||
final int to;
|
||||
/// For the log ("settings schema 2: ...").
|
||||
final String what;
|
||||
final Future<void> Function(Box box) apply;
|
||||
const SettingsMigration(this.to, this.what, this.apply);
|
||||
}
|
||||
|
||||
/// Every step in order of [SettingsMigration.to] (2, 3, ...).
|
||||
const settingsMigrations = <SettingsMigration>[
|
||||
SettingsMigration(2, 'auto sit-out replaces "when idle, sit out instead of just unready"', _autoSitOut),
|
||||
];
|
||||
|
||||
/// 2: `idleSitsOut` only chose between two automatic steps after 10 idle
|
||||
/// minutes (unready, or sit out); both are auto sit-out now, on unless
|
||||
/// switched off (`autoSitOut`).
|
||||
Future<void> _autoSitOut(Box box) async {
|
||||
if (!box.containsKey('idleSitsOut')) return;
|
||||
await box.delete('idleSitsOut');
|
||||
if (!box.containsKey('autoSitOut')) await box.put('autoSitOut', true);
|
||||
}
|
||||
|
||||
/// Runs the [steps] past the box's stored version up to [current], in
|
||||
/// order, storing the version after each, then stores [current]. A box
|
||||
/// from a newer build (a downgrade) is left as it is. Returns a log line
|
||||
/// per step run.
|
||||
Future<List<String>> migrateSettings(Box box,
|
||||
{List<SettingsMigration> steps = settingsMigrations, int current = settingsSchemaVersion}) async {
|
||||
final stored = box.get(settingsSchemaKey);
|
||||
var at = stored is int ? stored : 1;
|
||||
if (at > current) return ['settings schema $at is newer than this build ($current): left as is'];
|
||||
final ran = <String>[];
|
||||
for (final step in [...steps]..sort((a, b) => a.to.compareTo(b.to))) {
|
||||
if (step.to <= at || step.to > current) continue;
|
||||
await step.apply(box);
|
||||
at = step.to;
|
||||
await box.put(settingsSchemaKey, at);
|
||||
ran.add('settings schema ${step.to}: ${step.what}');
|
||||
}
|
||||
if (stored != current) await box.put(settingsSchemaKey, current);
|
||||
return ran;
|
||||
}
|
||||
@@ -6,6 +6,31 @@ import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
enum SyncStatus { idle, connecting, open, closed, error }
|
||||
|
||||
/// The server refused the hello: its `err` ([error]; a proto 15 server's
|
||||
/// has no code), or the socket closed before a welcome with [closeCode].
|
||||
class HelloRefused implements Exception {
|
||||
final ErrorFrame? error;
|
||||
final int? closeCode;
|
||||
const HelloRefused({this.error, this.closeCode});
|
||||
|
||||
/// The refusal's code: the frame's, else read from the close code.
|
||||
ErrCode? get code =>
|
||||
error?.code ??
|
||||
switch (closeCode) {
|
||||
CloseCodes.version => ErrCode.version,
|
||||
CloseCodes.secret => ErrCode.secret,
|
||||
CloseCodes.replaced => ErrCode.replaced,
|
||||
CloseCodes.stale => ErrCode.stale,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@override
|
||||
String toString() => error?.message ?? 'closed before welcome ($closeCode)';
|
||||
}
|
||||
|
||||
/// One `/sync` socket at a time: the hello and welcome, the frames as
|
||||
/// streams, the sends. [token], [memberId] and [lastState] outlive a
|
||||
/// dropped socket ([drop]); only [disconnect] forgets them.
|
||||
class SyncClient {
|
||||
/// Local clock; injectable so tests can run a client whose clock is off.
|
||||
final DateTime Function() _clock;
|
||||
@@ -21,19 +46,27 @@ class SyncClient {
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _sub;
|
||||
Completer<void>? _socketDone;
|
||||
final StreamController<StateBroadcast> _states = StreamController.broadcast();
|
||||
final StreamController<FeedMessage> _feeds = StreamController.broadcast();
|
||||
final StreamController<NudgeNotice> _nudges = StreamController.broadcast();
|
||||
final StreamController<String> _errors = StreamController.broadcast();
|
||||
final StreamController<ErrorFrame> _errors = StreamController.broadcast();
|
||||
final StreamController<SyncStatus> _status = StreamController.broadcast();
|
||||
|
||||
Stream<StateBroadcast> get states => _states.stream;
|
||||
/// Feed lines (chat included, with its text), and after a welcome the
|
||||
/// lines it replayed (`replayed`).
|
||||
Stream<FeedMessage> get feeds => _feeds.stream;
|
||||
/// Someone poked this member (`nudge`).
|
||||
Stream<NudgeNotice> get nudges => _nudges.stream;
|
||||
Stream<String> get errors => _errors.stream;
|
||||
/// `err` frames after the welcome.
|
||||
Stream<ErrorFrame> get errors => _errors.stream;
|
||||
Stream<SyncStatus> get status => _status.stream;
|
||||
|
||||
/// Called on every frame from the server (the welcome included), before
|
||||
/// it is handled: the connection's silence clock.
|
||||
void Function()? onFrame;
|
||||
|
||||
/// The `/sync` URL last dialled; its host serves `/a/...` and the media.
|
||||
Uri? wsUri;
|
||||
String token = '';
|
||||
@@ -43,24 +76,51 @@ class SyncClient {
|
||||
/// relays); private like [token].
|
||||
String? relayUrl;
|
||||
StateBroadcast? lastState;
|
||||
/// `welcome.instance` of the server last joined: a welcome of another
|
||||
/// one is a restarted (or another) server.
|
||||
String instance = '';
|
||||
/// The highest `feed.seq` seen from [instance] (`hello.feedAfter`).
|
||||
int lastFeedSeq = 0;
|
||||
/// The close code of the last socket that closed (null: none given).
|
||||
int? closeCode;
|
||||
Completer<WelcomeMessage>? _pendingWelcome;
|
||||
|
||||
/// A plain hello (tools and tests): [client] / [seq] as given.
|
||||
Future<WelcomeMessage> connect({
|
||||
required Uri wsUri,
|
||||
required String secret,
|
||||
required String name,
|
||||
String avatar = '',
|
||||
HelloMedia? media,
|
||||
String hostKey = '',
|
||||
String resume = '',
|
||||
}) async {
|
||||
await disconnect();
|
||||
String client = '',
|
||||
int seq = 0,
|
||||
}) =>
|
||||
connectWith(
|
||||
wsUri: wsUri,
|
||||
hello: HelloMessage(
|
||||
secret: secret,
|
||||
user: HelloUser(name: name, avatar: avatar),
|
||||
hostKey: hostKey,
|
||||
client: client,
|
||||
seq: seq,
|
||||
),
|
||||
);
|
||||
|
||||
/// Dials [wsUri], sends [hello] and waits for the welcome (10 s). A
|
||||
/// socket still open is dropped first. [onWelcome] runs once the welcome
|
||||
/// is read, before its state and replayed feed go out on the streams.
|
||||
/// Throws [HelloRefused] when the server refuses.
|
||||
Future<WelcomeMessage> connectWith(
|
||||
{required Uri wsUri, required HelloMessage hello, void Function(WelcomeMessage welcome)? onWelcome}) async {
|
||||
await drop();
|
||||
clockSync.reset();
|
||||
closeCode = null;
|
||||
this.wsUri = wsUri;
|
||||
_status.add(SyncStatus.connecting);
|
||||
final ch = WebSocketChannel.connect(wsUri);
|
||||
unawaited(ch.ready.then((_) {}, onError: (_) {}));
|
||||
_channel = ch;
|
||||
final done = _socketDone = Completer<void>();
|
||||
final welcomeCompleter = Completer<WelcomeMessage>();
|
||||
_sub = ch.stream.listen(
|
||||
(raw) => _onText('$raw', welcomeCompleter),
|
||||
@@ -71,21 +131,17 @@ class SyncClient {
|
||||
_status.add(SyncStatus.error);
|
||||
},
|
||||
onDone: () {
|
||||
closeCode = ch.closeCode;
|
||||
if (!done.isCompleted) done.complete();
|
||||
if (!welcomeCompleter.isCompleted) {
|
||||
welcomeCompleter.completeError(StateError('closed before welcome'));
|
||||
welcomeCompleter.completeError(ch.closeCode == null
|
||||
? StateError('closed before welcome')
|
||||
: HelloRefused(closeCode: ch.closeCode));
|
||||
}
|
||||
_status.add(SyncStatus.closed);
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
final hello = HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: secret,
|
||||
user: HelloUser(name: name, avatar: avatar),
|
||||
media: media ?? const HelloMedia(name: '', size: 0, duration: 0),
|
||||
hostKey: hostKey,
|
||||
resume: resume,
|
||||
);
|
||||
ch.sink.add(jsonEncode(hello.toJson()));
|
||||
_pendingWelcome = welcomeCompleter;
|
||||
try {
|
||||
@@ -93,28 +149,47 @@ class SyncClient {
|
||||
const Duration(seconds: 10),
|
||||
);
|
||||
_pendingWelcome = null;
|
||||
final sameServer = instance.isNotEmpty && welcome.instance == instance;
|
||||
token = welcome.token;
|
||||
memberId = welcome.memberId;
|
||||
mediaUrl = welcome.mediaUrl;
|
||||
relayUrl = welcome.relayUrl;
|
||||
instance = welcome.instance;
|
||||
// Another server's lines count from 1 again: its replay is not ours.
|
||||
if (!sameServer) lastFeedSeq = 0;
|
||||
onWelcome?.call(welcome);
|
||||
if (welcome.state != null) {
|
||||
try {
|
||||
lastState = StateBroadcast.fromJson(welcome.state!);
|
||||
_states.add(lastState!);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (sameServer) {
|
||||
for (final f in welcome.feed) {
|
||||
_addFeed(f.copyWith(replayed: true));
|
||||
}
|
||||
}
|
||||
_status.add(SyncStatus.open);
|
||||
sendPing();
|
||||
return welcome;
|
||||
} catch (e) {
|
||||
_pendingWelcome = null;
|
||||
await disconnect();
|
||||
await drop();
|
||||
_status.add(SyncStatus.error);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _addFeed(FeedMessage f) {
|
||||
if (f.seq > lastFeedSeq) lastFeedSeq = f.seq;
|
||||
// The server delivers every chat twice: a `chat` frame carrying the
|
||||
// text (turned into a feed line below) and a textless feed line. Keep
|
||||
// only the former: one send, one row, one toast, one sound.
|
||||
if (f.action != 'chat') _feeds.add(f);
|
||||
}
|
||||
|
||||
void _onText(String raw, Completer<WelcomeMessage> welcomeCompleter) {
|
||||
onFrame?.call();
|
||||
Map<String, Object?> decoded;
|
||||
try {
|
||||
final parsed = jsonDecode(raw);
|
||||
@@ -132,9 +207,13 @@ class SyncClient {
|
||||
welcomeCompleter.completeError(e);
|
||||
}
|
||||
} else if (t == 'err') {
|
||||
welcomeCompleter.completeError(
|
||||
ProtocolException('${decoded['message'] ?? 'rejected'}'),
|
||||
);
|
||||
ErrorFrame frame;
|
||||
try {
|
||||
frame = ErrorFrame.fromJson(decoded);
|
||||
} catch (_) {
|
||||
frame = const ErrorFrame(code: null, message: 'rejected');
|
||||
}
|
||||
welcomeCompleter.completeError(HelloRefused(error: frame));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -147,12 +226,7 @@ class SyncClient {
|
||||
} catch (_) {}
|
||||
case 'feed':
|
||||
try {
|
||||
final f = FeedMessage.fromJson(decoded);
|
||||
// Server delivers every chat twice: a ChatMessage carrying the
|
||||
// text (converted to a feed entry in `case 'chat'` below) and a
|
||||
// textless feed entry. Keep only the former: one send, one row,
|
||||
// one toast, one sfx.
|
||||
if (f.action != 'chat') _feeds.add(f);
|
||||
_addFeed(FeedMessage.fromJson(decoded));
|
||||
} catch (_) {}
|
||||
case 'nudge':
|
||||
try {
|
||||
@@ -167,21 +241,26 @@ class SyncClient {
|
||||
receivedMs: _clock().millisecondsSinceEpoch);
|
||||
} catch (_) {}
|
||||
case 'err':
|
||||
_errors.add('${decoded['message'] ?? 'error'}');
|
||||
case 'chat':
|
||||
try {
|
||||
_errors.add(ErrorFrame.fromJson(decoded));
|
||||
} catch (_) {}
|
||||
case 'chat':
|
||||
_feeds.add(
|
||||
FeedMessage(
|
||||
actor: '${decoded['from'] ?? '?'}',
|
||||
actorId: decoded['fromId'] is String ? decoded['fromId'] as String : '',
|
||||
action: 'chat',
|
||||
reason: (decoded['text'] as String?) ?? '',
|
||||
at: DateTime.now().toUtc(),
|
||||
),
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A socket is open (it may be half-open: the connection's silence
|
||||
/// check tells).
|
||||
bool get isOpen => _channel != null;
|
||||
|
||||
void _send(Map<String, Object?> json) {
|
||||
final ch = _channel;
|
||||
if (ch == null) return;
|
||||
@@ -203,7 +282,8 @@ class SyncClient {
|
||||
_send(SeekIntent(position: position, seq: seq).toJson());
|
||||
}
|
||||
|
||||
/// One clock sample; the coordinator sends one per second.
|
||||
/// One clock sample; the connection sends one per second (it also keeps
|
||||
/// this member under the server's silence limit).
|
||||
void sendPing() {
|
||||
_send(PingMessage(clientMs: _clock().millisecondsSinceEpoch).toJson());
|
||||
}
|
||||
@@ -259,34 +339,58 @@ class SyncClient {
|
||||
_send(ArtMessage(mediaId: mediaId, poster: poster, banner: banner).toJson());
|
||||
}
|
||||
|
||||
/// Sits the member [target] out, or with [out] false brings this
|
||||
/// member back (only oneself can come back).
|
||||
void sendOut(String target, bool out) {
|
||||
_send(OutIntent(target: target, out: out).toJson());
|
||||
/// Sits someone out, or brings this member back in (see [PresenceIntent]).
|
||||
void sendPresence(PresenceIntent intent) {
|
||||
_send(intent.toJson());
|
||||
}
|
||||
|
||||
void sendNudge(String target) {
|
||||
_send(NudgeIntent(target: target).toJson());
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
/// `bye`: this member leaves now. Waits (at most [wait]) for the server
|
||||
/// to close the socket, so the frame went out before ours closes.
|
||||
Future<void> sendBye({Duration wait = const Duration(milliseconds: 600)}) async {
|
||||
final done = _socketDone;
|
||||
if (_channel == null || done == null) return;
|
||||
_send(const ByeMessage().toJson());
|
||||
try {
|
||||
await done.future.timeout(wait);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// Closes the socket (no [SyncStatus] for it) and keeps who this member
|
||||
/// is ([token], [memberId], [lastState], [instance]): a reconnect takes
|
||||
/// it over. The close is not waited for (a half-open link never
|
||||
/// answers it).
|
||||
Future<void> drop() async {
|
||||
final pending = _pendingWelcome;
|
||||
_pendingWelcome = null;
|
||||
if (pending != null && !pending.isCompleted) {
|
||||
pending.completeError(StateError('cancelled'));
|
||||
} try {
|
||||
await _sub?.cancel();
|
||||
} catch (_) {}
|
||||
}
|
||||
final sub = _sub;
|
||||
_sub = null;
|
||||
try {
|
||||
await _channel?.sink.close();
|
||||
} catch (_) {}
|
||||
final ch = _channel;
|
||||
_channel = null;
|
||||
_socketDone = null;
|
||||
try {
|
||||
await sub?.cancel().timeout(const Duration(milliseconds: 500));
|
||||
} catch (_) {}
|
||||
if (ch != null) unawaited(ch.sink.close().timeout(const Duration(seconds: 2), onTimeout: () {}).catchError((_) {}));
|
||||
}
|
||||
|
||||
/// [drop], then forgets the member and the server: the next connect is
|
||||
/// a new session.
|
||||
Future<void> disconnect() async {
|
||||
await drop();
|
||||
token = '';
|
||||
memberId = '';
|
||||
mediaUrl = null;
|
||||
relayUrl = null;
|
||||
lastState = null;
|
||||
instance = '';
|
||||
lastFeedSeq = 0;
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
@echo off
|
||||
rem Update this install in place (same as clicking Update in the app).
|
||||
"%~dp0watch-together-server.exe" --upgrade %*
|
||||
rem The old server name is the fallback for installs from before the rename.
|
||||
set "SERVER=%~dp0starsync-server.exe"
|
||||
if not exist "%SERVER%" set "SERVER=%~dp0watch-together-server.exe"
|
||||
"%SERVER%" --upgrade %*
|
||||
pause
|
||||
@@ -1,4 +1,7 @@
|
||||
#!/bin/sh
|
||||
# Update this install in place (same as clicking Update in the app).
|
||||
# The server binary runs the updater: it needs no GTK/display (VPS).
|
||||
cd "$(dirname "$0")" && exec ./watch-together-server --upgrade "$@"
|
||||
# The old server name is the fallback for installs from before the rename.
|
||||
cd "$(dirname "$0")" || exit 1
|
||||
if [ -x ./starsync-server ]; then exec ./starsync-server --upgrade "$@"; fi
|
||||
exec ./watch-together-server --upgrade "$@"
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
# The dedicated server's name up to 1.2.x: systemd units (ExecStart) and
|
||||
# old update.sh copies run it.
|
||||
exec "$(dirname "$(readlink -f "$0")")/starsync-server" "$@"
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
# Starsync's name up to 1.2.x: their updater restarts it after updating to
|
||||
# this release, and launchers or scripts may still point at it.
|
||||
exec "$(dirname "$(readlink -f "$0")")/starsync" "$@"
|
||||
@@ -4,7 +4,9 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_ce_flutter/hive_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
// In-app update flow behind the Home update indicator. Same code path as
|
||||
import '../platform/app_shutdown.dart';
|
||||
|
||||
// In-app update flow behind the update bar (UpdateBar). Same code path as
|
||||
// `--upgrade` (core's updater, downloadAndApply); this adds state for the
|
||||
// UI and the restart. Check failures are logged only — the banner shows up just when
|
||||
// there is something to install.
|
||||
@@ -28,26 +30,23 @@ class UpdateStatus {
|
||||
// Process args as main() received them — restart relaunches with these.
|
||||
List<String> launchArgs = const [];
|
||||
|
||||
// Relaunch the (now replaced) binary detached with the same args, then
|
||||
// exit this process.
|
||||
Future<Never> restartApp(String executable, List<String> args) async {
|
||||
final keep = args.where((a) => a != '--upgrade').toList();
|
||||
await Process.start(executable, keep,
|
||||
mode: ProcessStartMode.detached,
|
||||
workingDirectory: Directory.current.path);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
typedef RestartFn = Future<void> Function(String executable, List<String> args);
|
||||
|
||||
// Release Hive's box locks before the new instance opens the same boxes.
|
||||
// The orderly close (players, leave), Hive's box locks released before
|
||||
// the new instance opens the same boxes, the (now replaced) binary
|
||||
// relaunched detached with the same args, then the window closes.
|
||||
Future<void> _closeAndRestart(String executable, List<String> args) async {
|
||||
final keep = args.where((a) => a != '--upgrade').toList();
|
||||
await shutDownApp(restart: true, beforeQuit: () async {
|
||||
try {
|
||||
await Hive.close();
|
||||
} catch (e) {
|
||||
_log.warning('closing hive before restart: $e');
|
||||
}
|
||||
await restartApp(executable, args);
|
||||
await Process.start(executable, keep,
|
||||
mode: ProcessStartMode.detached,
|
||||
workingDirectory: Directory.current.path);
|
||||
});
|
||||
}
|
||||
|
||||
class UpdateController {
|
||||
@@ -115,15 +114,22 @@ class UpdateController {
|
||||
return;
|
||||
}
|
||||
_log.info('update available: ${m.version} (proto ${m.protocol})');
|
||||
lastCheck.value = 'Update v${m.version} available';
|
||||
// A re-check never cuts into an install under way.
|
||||
if (_installing) return;
|
||||
status.value = UpdateStatus(UpdatePhase.available,
|
||||
manifest: m, canInstall: root != null);
|
||||
lastCheck.value = 'Update v${m.version} available';
|
||||
} catch (e) {
|
||||
_log.warning('update check failed: $e');
|
||||
lastCheck.value = 'Update check failed: $e';
|
||||
}
|
||||
}
|
||||
|
||||
bool get _installing => switch (status.value.phase) {
|
||||
UpdatePhase.downloading || UpdatePhase.applying || UpdatePhase.restarting => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
void dismiss() {
|
||||
final s = status.value;
|
||||
if ((s.phase == UpdatePhase.available || s.phase == UpdatePhase.failed) &&
|
||||
@@ -167,6 +173,8 @@ class UpdateController {
|
||||
_log.info('updated to ${m.version}, restarting');
|
||||
status.value =
|
||||
UpdateStatus(UpdatePhase.restarting, manifest: m, canInstall: true);
|
||||
await restart(_exe, launchArgs);
|
||||
// Started by an old name (1.2.x's restart, an old launcher): the new
|
||||
// binary next to it.
|
||||
await restart(relaunchExecutable(_exe), launchArgs);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import 'dart:ui' show ImageFilter;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../controls/uosc/controls.dart';
|
||||
import '../controls/frame.dart';
|
||||
import '../shell/motion.dart';
|
||||
|
||||
// The app's one menu: a frosted dark panel anchored under (or over) the
|
||||
@@ -158,9 +158,8 @@ Future<int?> _openMenu(BuildContext context, List<AppMenuItem<Object?>> items, d
|
||||
minWidth: minWidth - shift,
|
||||
capturedThemes: InheritedTheme.capture(from: context, to: navigator.context),
|
||||
));
|
||||
// Over the uosc controls: keep them up while the menu is open.
|
||||
final controls = UoscControls.maybeOf(context);
|
||||
return controls == null ? open() : controls.holdDuring(open);
|
||||
// Over the player's controls: keep them up while the menu is open.
|
||||
return ControlsFrame.holdWhile(context, open);
|
||||
}
|
||||
|
||||
class _AppMenuRoute extends PopupRoute<int> {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../session/notifier.dart' show AppEvent, Copied, CopyFailed, noticeFor;
|
||||
import '../state/session_state.dart';
|
||||
|
||||
// Reusing what the app shows elsewhere: copying a link or a title, opening
|
||||
@@ -22,28 +21,10 @@ abstract final class ReuseText {
|
||||
@visibleForTesting
|
||||
void Function(String text)? debugReuseToast;
|
||||
|
||||
void _toast(String text) {
|
||||
void _notify(AppEvent event) {
|
||||
final debug = debugReuseToast;
|
||||
if (debug != null) return debug(text);
|
||||
SessionState.instance.coordinator.showToast(text);
|
||||
}
|
||||
|
||||
/// Opens [url] in the default browser (no url_launcher dependency); false
|
||||
/// when no browser could be started.
|
||||
Future<bool> openInBrowser(String url) async {
|
||||
try {
|
||||
final ProcessResult r;
|
||||
if (Platform.isWindows) {
|
||||
r = await Process.run('rundll32', ['url.dll,FileProtocolHandler', url]);
|
||||
} else if (Platform.isMacOS) {
|
||||
r = await Process.run('open', [url]);
|
||||
} else {
|
||||
r = await Process.run('xdg-open', [url]);
|
||||
}
|
||||
return r.exitCode == 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
if (debug != null) return debug(noticeFor(event).toast ?? '');
|
||||
SessionState.instance.coordinator.notify(event);
|
||||
}
|
||||
|
||||
/// Puts [text] on the clipboard and confirms it with a toast ([done]:
|
||||
@@ -52,15 +33,15 @@ Future<void> copyToClipboard(String text, {String done = 'Copied'}) async {
|
||||
try {
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
} catch (_) {
|
||||
_toast("Couldn't copy");
|
||||
_notify(const CopyFailed());
|
||||
return;
|
||||
}
|
||||
_toast(done);
|
||||
_notify(Copied(done));
|
||||
}
|
||||
|
||||
/// Opens [url] in the browser; when none opens, copies it instead and
|
||||
/// says so.
|
||||
Future<void> openLink(String url) async {
|
||||
if (await openInBrowser(url)) return;
|
||||
if (await SessionState.instance.ports.urls.open(url)) return;
|
||||
await copyToClipboard(url, done: "Couldn't open a browser — link copied");
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../update/update_controller.dart';
|
||||
|
||||
// "Update available" card (Home update indicator dialog). Kicks off the one
|
||||
// "Update available" card (Settings → Show update dialog). Kicks off the one
|
||||
// background check on first build; renders nothing until there is an
|
||||
// update to offer. Never shown over playback.
|
||||
class UpdateBanner extends StatefulWidget {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../state/session_state.dart';
|
||||
|
||||
@@ -18,43 +17,37 @@ class WindowButtons extends StatefulWidget {
|
||||
State<WindowButtons> createState() => _WindowButtonsState();
|
||||
}
|
||||
|
||||
class _WindowButtonsState extends State<WindowButtons> with WindowListener {
|
||||
class _WindowButtonsState extends State<WindowButtons> {
|
||||
final _shell = SessionState.instance.ports.shell;
|
||||
bool _maximized = false;
|
||||
late final void Function() _stopWatch;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
windowManager.addListener(this);
|
||||
windowManager.isMaximized().then((v) {
|
||||
_stopWatch = _shell.watchMaximized((v) {
|
||||
if (mounted) setState(() => _maximized = v);
|
||||
});
|
||||
_shell.isMaximized().then((v) {
|
||||
if (mounted) setState(() => _maximized = v);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
windowManager.removeListener(this);
|
||||
_stopWatch();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMaximize() {
|
||||
if (mounted) setState(() => _maximized = true);
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowUnmaximize() {
|
||||
if (mounted) setState(() => _maximized = false);
|
||||
}
|
||||
|
||||
Future<void> _toggleMaximize() async {
|
||||
if (widget.onToggleFullscreen != null) {
|
||||
widget.onToggleFullscreen!();
|
||||
return;
|
||||
}
|
||||
if (await windowManager.isMaximized()) {
|
||||
await windowManager.unmaximize();
|
||||
if (await _shell.isMaximized()) {
|
||||
await _shell.unmaximize();
|
||||
} else {
|
||||
await windowManager.maximize();
|
||||
await _shell.maximize();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +56,7 @@ class _WindowButtonsState extends State<WindowButtons> with WindowListener {
|
||||
await SessionState.instance.coordinator.leave();
|
||||
await SessionState.instance.stopServer();
|
||||
} catch (_) {}
|
||||
await windowManager.close();
|
||||
await _shell.requestClose();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -77,7 +70,7 @@ class _WindowButtonsState extends State<WindowButtons> with WindowListener {
|
||||
icon: Icons.remove,
|
||||
tooltip: 'Minimize',
|
||||
focusNode: focus,
|
||||
onPressed: () => windowManager.minimize(),
|
||||
onPressed: () => _shell.minimize(),
|
||||
),
|
||||
_WinBtn(
|
||||
icon: restored ? Icons.filter_none : Icons.crop_square,
|
||||
@@ -131,3 +124,27 @@ class _WinBtn extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The title area: drag moves the window, a double click maximizes or
|
||||
/// restores it (window_manager's DragToMoveArea, through [ShellPort]).
|
||||
class WindowDragArea extends StatelessWidget {
|
||||
final Widget child;
|
||||
const WindowDragArea({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shell = SessionState.instance.ports.shell;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPanStart: (_) => shell.startDragging(),
|
||||
onDoubleTap: () async {
|
||||
if (await shell.isMaximized()) {
|
||||
await shell.unmaximize();
|
||||
} else {
|
||||
await shell.maximize();
|
||||
}
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@ project(runner LANGUAGES CXX)
|
||||
|
||||
# The name of the executable created for the application. Change this to change
|
||||
# the on-disk name of your application.
|
||||
set(BINARY_NAME "watch_together")
|
||||
set(BINARY_NAME "starsync")
|
||||
# The unique GTK application identifier for this application. See:
|
||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||
set(APPLICATION_ID "moe.senkochan.watch_together")
|
||||
set(APPLICATION_ID "dev.foxyz.starsync")
|
||||
|
||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||
# versions of CMake.
|
||||
|
||||
Loaded 100 of 222 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user