15 Commits
Author SHA1 Message Date
foxyzandClaude Opus 5.5 eac45746ce Restructure step 0: Connection extracted from the coordinator
ci / core (push) Successful in 27s
ci / updater-windows (push) Successful in 6m3s
ci / server (push) Successful in 1m6s
Connection owns the socket lifecycle, connect/dial with epochs,
reconnect, the clock offset, diagnostics upload and a sealed ConnState
signal; the hello is built by an injectable builder, the resume token
stays private, and the reconnect delay and silence limit are parameters.
The coordinator maps ConnState to the same health texts and keeps the
reconnect restore and drop handling (step 1 replaces them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:52:49 +03:00
foxyzandClaude Opus 5.5 a52133b313 Roadmap: second review applied (rename before step 1, drops keep the session, pipeline before what's next, release step, AppGraph root, protocol 16 additions)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:44:17 +03:00
foxyzandClaude Opus 5.5 a1b270fd4e Restructure step 0: test safety net, AppEvent/Notifier seam, platform ports, protocol 16 planned
ci / core (push) Successful in 29s
ci / server (push) Successful in 1m9s
ci / updater-windows (push) Successful in 5m58s
- Tests: the four known failures fixed (test-side causes), one duplicate
  file and four detail-pinning cases removed, every test that pins
  behaviour the restructure changes tagged with its step.
- Every toast, sound and activity-log line goes through Notifier.notify
  (AppEvent), mapped one-to-one to today's behaviour.
- Platform ports (shell, URLs, folders, link resolver, system
  notifications) with desktop implementations; window_manager, Process
  and file_picker now live only in platform/desktop/.
- ROADMAP: protocol 16 planned; app id dev.foxyz.starsync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:24:11 +03:00
foxyzandClaude Opus 5.5 7e98ebd1db Roadmap: no Windows dev builds; Windows friends move at 1.3.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:10:19 +03:00
foxyzandClaude Opus 5.5 0b0c67acb7 Roadmap: how the restructure runs (incremental on main, signals, 1.3.0-dev), rooms out, Windows dev builds in step 0, rename as its own step
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:07:43 +03:00
foxyzandClaude Opus 5.5 622df58295 Roadmap: ws allowed, wss preferred; test audit in step 0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:57:24 +03:00
foxyzandClaude Opus 5.5 df6f6ef893 Roadmap: joiners don't pause (D2), auto sit-out, queue in memory, rooms and starting together open, wss with pinned self-signed certificates, Windows dev builds (P35)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:45:14 +03:00
foxyzandClaude Opus 5.5 ea607c1a52 Stabilisation quick fixes: orderly close, visible version and update band, chat pop-ups on, Windows shaders and hwdec, render logs
ci / core (push) Successful in 27s
ci / server (push) Successful in 1m4s
ci / updater-windows (push) Successful in 5m50s
- Close: hide the window, dispose every player, thumbnail job and the
  sound engine, leave, then close normally; Linux runner _exit (the
  NVIDIA EGL exit handler crashed 10/10 closes, 0/41 now); Windows tears
  down inside the message loop, then TerminateProcess; watchdogs.
- Version under the Settings tab; an update band instead of a dot,
  never over a video; checks at start and every 4 hours.
- Chat pop-ups default to All again.
- Shader read-back compared as a list (old mpv joins with ','), so
  Windows stops turning shaders off; Windows hwdec auto-copy.
- Render diagnostics (`mpv: render` lines); RAM-only demuxer cache pin
  silences the file-cache warning; a volume readout test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:01:44 +03:00
foxyzandClaude Opus 5.5 e537f9d5f8 Roadmap: restructure revised after review (three-way truth, step 0 seams, proto 16 once, your server and media identity earlier) and open decisions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:50:11 +03:00
foxyzandClaude Opus 5.5 cf59251449 Roadmap: P32 is mostly a Windows/others report
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:46:08 +03:00
foxyzandClaude Opus 5.5 ef562342e2 Roadmap: P34 test library
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:42:42 +03:00
foxyzandClaude Opus 5.5 3d81342725 Roadmap: stabilisation section with the agreed decisions and the numbered problem list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:30:59 +03:00
foxyzandClaude Opus 5.5 b949951ef9 Sounds play on flutter_soloud instead of three mpv players
ci / core (push) Successful in 27s
ci / server (push) Successful in 1m3s
ci / updater-windows (push) Successful in 10m15s
SoundEngine (platform/audio) opens the device once (2 s after start,
or on the first sound), loads each synthesized sound into memory (no
temp files), plays overlapping voices and is disposed on window close,
before leaving the server. The SfxService interface and its callers
are unchanged; loudness matches the old mpv path (v^6). WAV only, so
no codec libraries are bundled and no build packages are needed.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:03:15 +03:00
foxyzandClaude Opus 5.5 959eed38a4 CI: skip the Windows job on Release commits (release.yml needs the one Windows runner)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:05:06 +03:00
123 changed files with 7248 additions and 4918 deletions

No files matched your search

+3
View File
@@ -43,6 +43,9 @@ jobs:
# The updater's Windows apply path (running exe parked as .old) only # The updater's Windows apply path (running exe parked as .old) only
# means something on real NTFS. Runner notes: see release.yml. # means something on real NTFS. Runner notes: see release.yml.
updater-windows: updater-windows:
# Not for a "Release v…" commit: release.yml builds it on the same
# (only) Windows runner, which this job would hold up.
if: ${{ !startsWith(github.event.head_commit.message, 'Release v') }}
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- name: Checkout - name: Checkout
+58 -41
View File
@@ -32,10 +32,12 @@ From the Releases page of this repo:
`watch_together.exe`. mpv is bundled. New and not yet tried on a real `watch_together.exe`. mpv is bundled. New and not yet tried on a real
Windows desktop; reports welcome. Windows desktop; reports welcome.
Update from the app: when a newer version exists, an update indicator Update from the app: the app looks for a new version at start and every
appears in the strip at the top of the window and opens the update card. "Update and restart" downloads, few hours, and when one exists a band across the top of the window says so
verifies, applies in place and restarts; the install folder must be writable (never over a playing video). "Update and restart" downloads,
by you. Settings has Check now and an auto-check toggle. From a terminal: 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:
`./watch-together-server --upgrade` (no display needed, so it works on a `./watch-together-server --upgrade` (no display needed, so it works on a
server box too), `watch_together --upgrade`, or the shipped `update.sh` / server box too), `watch_together --upgrade`, or the shipped `update.sh` /
`update.bat` (which run the first). Each updates the whole folder, app and `update.bat` (which run the first). Each updates the whole folder, app and
@@ -93,8 +95,8 @@ right-click for Copy message and, with a link in it, Copy link / Open in
browser), and its "Activity" toggle adds browser), and its "Activity" toggle adds
joins, ready, seeks and the like between them. Joins, leaves, chat and joins, ready, seeks and the like between them. Joins, leaves, chat and
other news also pop up bottom left with the person's avatar; click a message to open the chat 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, (Settings → Notifications → Chat messages: All by default, or Only when
or All, or Off; messages always reach the chat). Unread chat shows as a count on the chat 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, buttons and, in fullscreen with the controls hidden and the video paused,
as a small chip top right. as a small chip top right.
@@ -182,7 +184,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 app open for that. Everyone shares one Up next and anyone can change it; what
you added stays when you leave. 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 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 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 to start); ⏭ plays it now, a click opens the queue: drag rows to reorder, hover a
@@ -282,16 +284,22 @@ integrations (linked Jellyfin servers, below, the AnimeFlow connection:
"connected as <name>" with what you may do, or why not, and playing the "connected as <name>" with what you may do, or why not, and playing the
next episode automatically); Notifications & sounds (what pops up; the sound next episode automatically); Notifications & sounds (what pops up; the sound
volume and sound set, and every sound with a preview, its own set and a switch); Updates; Keyboard shortcuts (read-only); About volume and sound set, and every sound with a preview, its own set and a switch); Updates; Keyboard shortcuts (read-only); About
(the version; 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 Player settings live in the player too: ⚙ → Player settings, at the
(both designs) opens them, and Settings → Player has the same ones (plus right of its controls, and Settings → Player has the same ones (plus the
the custom shader files). Player design: Standard or uosc (below); controls' layout and the custom shader files). Player design: Standard or
progress line when hidden (on by default: a 2 px line at the bottom of Minimal (below); progress line when hidden (on by default: a thin line at
the video while the controls are hidden); upscaling shader (see below); the bottom of the video while the controls are hidden); upscaling shader
hardware decoding (auto, off or custom; the env var `SYNC_RELAY_HWDEC` (see below); hardware decoding (auto, off or custom; auto is mpv's
overrides it); in the player also the subtitle and audio track. Every `auto-copy` on Windows, `auto` elsewhere; the env var
change applies right away. `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, Anywhere in a server (unless you are typing): Space = ready / unready,
Tab = chat & activity (Tab never moves between buttons). Anywhere: Tab = chat & activity (Tab never moves between buttons). Anywhere:
@@ -304,28 +312,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. Ctrl+1..6 = Anime4K mode A, B, C, A+A, B+B, C+A, Ctrl+0 = shaders off.
Every seek, skip and chapter jump moves everyone. Every seek, skip and chapter jump moves everyone.
Controls: the seek bar marks chapter starts with a diamond; hovering it Controls: both designs have the same buttons in the same places, only
shows the time and the chapter title. The chapters button lists them the look differs. Bottom left: play / pause first (it is your ready, like
(current one checked; click to jump). "1:30" skips ahead 1:30. Audio and Space: ✓ while you are ready and the others are not), previous / next
Subtitles buttons (with the track count) list tracks as "Japanese · E-AC3 episode (when there is one; next plays Up next first), previous / next
2.0 · title"; subtitles have Off. Volume (scroll anywhere on the video, chapter (when the video has chapters), volume and the time (click it to
hover the speaker, or Up/Down) goes to 150 % like mpv: past the tick at switch between elapsed / total and the time left). Bottom right:
100 % the bar turns amber and mpv amplifies; a scroll or key shows the Subtitles and Audio (with the track count; tracks listed as "Japanese ·
volume for a moment. Lists and menus under the mouse scroll as usual. E-AC3 2.0 · title", subtitles with Off), ⚙, chat and fullscreen last.
Everything else is in ⚙: chapters (current one checked; click to jump),
Skip 1:30, upscaling shader, change video, copy link, reload video,
mark everyone ready, mark a problem, player settings and leave. The top
bar has the title, Up next and back to the library (in fullscreen: exit
fullscreen and the window buttons). Volume (scroll anywhere on the
video, hover the speaker, or Up/Down) goes to 150 % like mpv: past the
tick at 100 % the bar turns amber and mpv amplifies; a scroll or key
shows the volume for a moment. Lists and menus under the mouse scroll
as usual.
The uosc player design (⚙ → Player design, Standard by default, Standard (the default): a seek bar that marks chapter starts with a
switches right away): video controls modeled on mpv's uosc theme. A diamond (hovering it shows the time and the chapter title) over a row
translucent timeline across the bottom marks the position with a thin of round buttons. Minimal (⚙ → Player settings → Player design, switches
line, hatches the part not downloaded yet, shows chapter starts as right away): a translucent timeline across the bottom tints what has
diamonds (click one to jump), the elapsed time left, the time remaining played, stripes the part not downloaded yet, marks chapter starts with
right and, while less than a minute is buffered, the buffered seconds dots (click one to jump), shows the elapsed time left and the total or
next to the position; hovering it shows the time there and the chapter the time left right (click it to switch) and, while less than a minute
title. With the controls hidden it stays as a thin line (unless is buffered, the buffered seconds next to the position; hovering it shows
"Progress line when hidden" is off). Above it flat buttons: ≡ menu, Subtitles and Audio (with the the time there and the chapter title. With the controls hidden it stays
track count), the shader badge (the active Anime4K mode, e.g. "A HQ"), as a thin line (unless "Progress line when hidden" is off). Its buttons
ready, chapters, 1:30, previous / next episode, change video, volume, ⚙ sit in two glass capsules above it. Minimal's timeline and controls are
and fullscreen; the title top left, Up next, chat and back-to-library top right. Keys and skip buttons work inspired by [uosc](https://github.com/tomasklaen/uosc) by tomasklaen
the same. (LGPL-2.1).
Skipping: during the opening a "Skip OP" button shows bottom right, just Skipping: during the opening a "Skip OP" button shows bottom right, just
above the controls (it above the controls (it
@@ -395,7 +412,7 @@ Watching:
"No Jellyfin account here for ..." with a button to link one; it plays as "No Jellyfin account here for ..." with a button to link one; it plays as
soon as you are signed in. soon as you are signed in.
Episodes: the ⏮ / ⏭ buttons next to the controls (or `<` / `>`, Episodes: the ⏮ / ⏭ buttons next to play (or `<` / `>`,
Shift+P / Shift+N) put up the previous or next episode for everyone. From Shift+P / Shift+N) put up the previous or next episode for everyone. From
the ending (when it is known, see Skipping above; else in the last two the ending (when it is known, see Skipping above; else in the last two
minutes) a "Next episode" button floats in the corner while the controls minutes) a "Next episode" button floats in the corner while the controls
@@ -411,7 +428,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 match (language, then title, then codec and channels) is selected on the
next episode. This is per person; nobody else is affected. next episode. This is per person; nobody else is affected.
"Change video" in the player's controls opens the Library tab (the player ⚙ → Change video in the player opens the Library tab (the player
goes mini): the library starts on the series you are watching with the goes mini): the library starts on the series you are watching with the
current season, else where you left it. current season, else where you left it.
@@ -467,9 +484,9 @@ affected. The app ships [Anime4K](https://github.com/bloc97/Anime4K) v4.0.1
C (480p), A+A, B+B, C+A, each as HQ (strong GPUs, GTX 1080 and up) or Fast C (480p), A+A, B+B, C+A, each as HQ (strong GPUs, GTX 1080 and up) or Fast
(the guide's low-end set). "Custom shaders" runs your own `.glsl` files. (the guide's low-end set). "Custom shaders" runs your own `.glsl` files.
Pick one with the ✨ button in the player's control bar (also in Pick one with ⚙ → Upscaling shader in the player (also in
fullscreen) or Ctrl+0..6; the change applies live and is saved, so the next fullscreen) or Ctrl+0..6; the change applies live and is saved, so the next
session starts with it. The player's ⚙ and Settings → Player set it too, session starts with it. ⚙ → Player settings and Settings → Player set it too,
with HQ or Fast (used by Ctrl+1..6); Settings → Player holds the custom with HQ or Fast (used by Ctrl+1..6); Settings → Player holds the custom
files. Shaders cost GPU time files. Shaders cost GPU time
every frame and need hardware video rendering: on media_kit's software every frame and need hardware video rendering: on media_kit's software
+20 -44
View File
@@ -5,17 +5,16 @@ import 'package:flutter/material.dart';
import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:window_manager/window_manager.dart';
import 'app.dart'; import 'app.dart';
import 'src/playback/yt_dlp_app.dart';
import 'src/platform/app_log.dart'; import 'src/platform/app_log.dart';
import 'src/platform/app_shutdown.dart';
import 'src/platform/desktop_entry.dart'; import 'src/platform/desktop_entry.dart';
import 'src/platform/window_memory.dart';
import 'src/screens/home/ui.dart'; import 'src/screens/home/ui.dart';
import 'src/session/app_events.dart' show UpdateAvailable;
import 'src/state/session_state.dart'; import 'src/state/session_state.dart';
import 'src/state/settings.dart'; import 'src/state/settings.dart';
import 'src/update/update_controller.dart' show launchArgs; import 'src/update/update_controller.dart' show UpdateController, UpdatePhase, launchArgs;
Future<void> main(List<String> args) async { Future<void> main(List<String> args) async {
launchArgs = List.unmodifiable(args); launchArgs = List.unmodifiable(args);
@@ -45,23 +44,11 @@ Future<void> main(List<String> args) async {
await startAppLogging(); await startAppLogging();
await Hive.initFlutter(); await Hive.initFlutter();
await AppSettings.instance.ensureLoaded(); await AppSettings.instance.ensureLoaded();
await windowManager.ensureInitialized(); // Close button / Alt+F4: the orderly close ([shutDownApp]), so others
await windowManager.setPreventClose(true); // see "left", a hosted server shuts down and no player outlives the
final memory = WindowMemory(Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null); // engine.
windowManager.addListener(_CloseGuard(memory)); final ports = SessionState.instance.ports;
final windowOptions = WindowOptions( await ports.shell.start(title: appName, onCloseRequest: shutDownApp);
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();
});
try { try {
await SessionState.instance.loadForm(); await SessionState.instance.loadForm();
} catch (e, st) { } catch (e, st) {
@@ -69,30 +56,19 @@ Future<void> main(List<String> args) async {
} }
MediaKit.ensureInitialized(); MediaKit.ensureInitialized();
// yt-dlp for web page links: downloaded on first run, kept current. // 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). // Linux: the launcher entry and icon Wayland needs (release installs).
unawaited(installDesktopEntry()); 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()); runApp(const WatchTogetherApp());
} // The sound device opens once the window is up, not on the first sound.
Future.delayed(const Duration(seconds: 2), SessionState.instance.sfx.warmUp);
/// Close button / Alt+F4: leave gracefully first (others see "left", a
/// hosted server shuts down), bounded so a hung socket can't keep the
/// window open.
class _CloseGuard with WindowListener {
final WindowMemory memory;
bool _closing = false;
_CloseGuard(this.memory);
@override
Future<void> onWindowClose() async {
if (_closing) return;
_closing = true;
await memory.save();
try {
await SessionState.instance.leave().timeout(const Duration(seconds: 2));
} catch (e) {
debugPrint('leave on close failed: $e');
}
await windowManager.destroy();
}
} }
@@ -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 /// mouse on a floating element over the picture (a pill, a card, the
/// status pill: [ControlsHoldRegion]) or an open popover. While anything /// status pill: [ControlsHoldRegion]) or an open popover. While anything
/// holds, the controls stay up; on release they hide on their usual /// holds, the controls stay up; on release they hide on their usual
/// countdown. Both designs follow it ([ControlsHoverProbe] with /// countdown. Both skins follow it (through `ControlsFrame`).
/// media_kit's controls, `UoscControls`).
class ControlsHold extends ChangeNotifier { class ControlsHold extends ChangeNotifier {
final _by = <Object>{}; final _by = <Object>{};
bool _told = false; 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,
);
}
+216
View File
@@ -0,0 +1,216 @@
import 'dart:async';
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
import 'package:flutter/material.dart';
import 'controls_probe.dart' show ControlsHold;
// The frame both control skins live in: when the controls are up, and the
// double-click on the picture. Mouse movement shows them; [hideAfter]
// without any (or the mouse leaving) hides them and the cursor. Holds keep
// them up: a seek drag (no hover events arrive), an open menu (a route
// takes the mouse), and the session's [ControlsHold] (the mouse on a
// floating pill or card, the Up next popover).
class ControlsFrame extends StatefulWidget {
/// Draws the controls; [visible] false while they are hidden.
final Widget Function(BuildContext context, bool visible) builder;
final VoidCallback? onDoubleClick;
/// Bands at the top and bottom where a double-click is the bars', not
/// the picture's.
final double barTop;
final double barBottom;
final Duration hideAfter;
/// Follows whether the controls are up (for overlays outside them).
final ValueNotifier<bool>? shown;
/// Keeps them up while held.
final ControlsHold? holdBy;
const ControlsFrame({
super.key,
required this.builder,
this.onDoubleClick,
this.barTop = 0,
this.barBottom = 0,
this.hideAfter = const Duration(seconds: 3),
this.shown,
this.holdBy,
});
/// The frame around [context], for widgets that keep it up (a drag, a
/// menu).
static ControlsFrameState? maybeOf(BuildContext context) => context.findAncestorStateOfType<ControlsFrameState>();
/// Runs [open] (a menu, a popover) with the controls around [context]
/// held up, if there are any.
static Future<T> holdWhile<T>(BuildContext context, Future<T> Function() open) {
final frame = maybeOf(context);
return frame == null ? open() : frame.holdDuring(open);
}
/// The fade of the controls.
static const fade = Duration(milliseconds: 150);
@override
State<ControlsFrame> createState() => ControlsFrameState();
}
class ControlsFrameState extends State<ControlsFrame> {
bool _visible = true;
int _holds = 0;
bool _heldBy = false;
Timer? _timer;
bool get visible => _visible;
@override
void initState() {
super.initState();
widget.holdBy?.addListener(_holdByChanged);
// Not during this build: an overlay listening to [shown] may be built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.shown?.value = true;
});
_hideLater();
}
@override
void didUpdateWidget(ControlsFrame oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.holdBy != widget.holdBy) {
oldWidget.holdBy?.removeListener(_holdByChanged);
widget.holdBy?.addListener(_holdByChanged);
_holdByChanged();
}
if (oldWidget.hideAfter != widget.hideAfter && _visible) _hideLater();
}
@override
void dispose() {
widget.holdBy?.removeListener(_holdByChanged);
_timer?.cancel();
super.dispose();
}
void _holdByChanged() {
final on = widget.holdBy?.held ?? false;
if (on == _heldBy || !mounted) return;
_heldBy = on;
hold(on);
}
/// Show the controls and restart the hide countdown.
void wake() {
if (!_visible && mounted) {
setState(() => _visible = true);
widget.shown?.value = true;
}
_hideLater();
}
/// Keep the controls up while [on] (paired calls).
void hold(bool on) {
_holds = on ? _holds + 1 : (_holds - 1).clamp(0, 1 << 20);
wake();
}
/// Runs [open] (a menu) with the controls held up.
Future<T> holdDuring<T>(Future<T> Function() open) async {
hold(true);
try {
return await open();
} finally {
if (mounted) hold(false);
}
}
void _hideLater() {
_timer?.cancel();
_timer = Timer(widget.hideAfter, _hide);
}
void _hide() {
if (!mounted || _holds > 0 || !_visible) return;
setState(() => _visible = false);
widget.shown?.value = false;
}
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: _visible ? SystemMouseCursors.basic : SystemMouseCursors.none,
onEnter: (_) => wake(),
onHover: (_) => wake(),
onExit: (_) {
_timer?.cancel();
_hide();
},
child: Stack(
fit: StackFit.expand,
children: [
if (widget.onDoubleClick != null)
Positioned.fill(
child: DoubleClickListener(
onDoubleClick: widget.onDoubleClick!,
skipTop: _visible ? widget.barTop : 0,
skipBottom: _visible ? widget.barBottom : 0,
child: const ColoredBox(color: Color(0x00000000), child: SizedBox.expand()),
),
),
widget.builder(context, _visible),
],
),
);
}
}
/// Double-click on the picture (not on the bars) without joining the
/// gesture arena, so single clicks on buttons stay instant.
class DoubleClickListener extends StatefulWidget {
final VoidCallback onDoubleClick;
final double skipTop, skipBottom;
final Widget child;
const DoubleClickListener({
super.key,
required this.onDoubleClick,
this.skipTop = 0,
this.skipBottom = 0,
required this.child,
});
@override
State<DoubleClickListener> createState() => _DoubleClickListenerState();
}
class _DoubleClickListenerState extends State<DoubleClickListener> {
DateTime? _last;
Offset? _lastAt;
void _down(PointerDownEvent e) {
if (e.buttons != kPrimaryMouseButton) return;
final h = context.size?.height ?? 0;
final y = e.localPosition.dy;
if (y < widget.skipTop || y > h - widget.skipBottom) {
_last = null;
return;
}
final now = DateTime.now();
final last = _last, at = _lastAt;
if (last != null &&
at != null &&
now.difference(last) < const Duration(milliseconds: 400) &&
(e.localPosition - at).distance < 24) {
_last = null;
widget.onDoubleClick();
return;
}
_last = now;
_lastAt = e.localPosition;
}
@override
Widget build(BuildContext context) =>
Listener(behavior: HitTestBehavior.translucent, onPointerDown: _down, child: widget.child);
}
@@ -0,0 +1,250 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../../queue/queue_ui.dart';
import '../../shell/now_playing_title.dart';
import '../../state/settings.dart';
import '../control_menus.dart';
import '../controls_probe.dart' show ControlsHold;
import '../frame.dart';
import '../model/controls_layout.dart';
import '../model/controls_model.dart';
import '../model/controls_prefs.dart';
import '../volume.dart';
import 'style.dart';
import 'timeline.dart';
// The Minimal skin (inspired by mpv's uosc): a timeline band across the
// bottom that carries the clock, and above it the buttons in two glass
// capsules, in the layout's order. Top: what plays, Up next and the window
// controls. Hidden, the band shrinks to a thin progress line (unless
// switched off).
class MinimalControls extends StatelessWidget {
final PlayerControlsModel model;
final bool visible;
final double scale;
final ControlsHold? hold;
final Widget? windowButtons;
const MinimalControls({
super.key,
required this.model,
required this.visible,
this.scale = 1,
this.hold,
this.windowButtons,
});
static const _rowGap = 8.0;
/// The button row's height at [scale] (a capsule).
static double rowHeight(double scale) => MinimalStyle.button * scale + 2 * MinimalStyle.capsulePad;
/// Buttons and timeline at [scale].
static double bottomHeight(double scale) => rowHeight(scale) + _rowGap + MinimalTimeline.heightFor(scale);
/// The top bar's height.
static const topHeight = 72.0;
@override
Widget build(BuildContext context) {
const fade = ControlsFrame.fade;
Widget shown(Widget child, {Key? key}) => IgnorePointer(
key: key,
ignoring: !visible,
child: AnimatedOpacity(duration: fade, opacity: visible ? 1 : 0, child: child),
);
return Stack(
children: [
Positioned(
left: 0,
right: 0,
top: 0,
child: shown(
key: const ValueKey('minimal-top'),
DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x99000000), Color(0x00000000)],
),
),
child: SizedBox(
height: topHeight + 24,
child: Padding(padding: const EdgeInsets.fromLTRB(16, 0, 10, 24), child: _top()),
),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
shown(
key: const ValueKey('minimal-buttons'),
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, _rowGap),
child: SizedBox(
height: rowHeight(scale),
child: SignalBuilder(
builder: (context) {
final s = model.snapshot.value;
final layout = model.bar.value;
// The clock lives on the timeline.
List<Widget> cluster(List<BarControl> cs) => [
for (final c in cs)
if (c != BarControl.time) _control(context, c, s),
];
return Row(
children: [
MinimalCapsule(scale: scale, children: cluster(layout.left)),
const Spacer(),
MinimalCapsule(scale: scale, children: cluster(layout.right)),
],
);
},
),
),
),
),
SignalBuilder(
builder: (context) {
final line = AppSettings.instance.progressLine.value;
return AnimatedOpacity(
key: const ValueKey('minimal-timeline-wrap'),
duration: fade,
opacity: visible || line ? 1 : 0,
child: MinimalTimeline(
position: model.position.value,
duration: model.duration.value,
buffered: model.buffered.value,
chapters: model.chapters.value,
expanded: visible,
remaining: model.settings.timeDisplay.value == TimeDisplay.remaining,
scale: scale,
onSeek: model.seek,
onToggleTime: model.toggleTimeDisplay,
),
);
},
),
],
),
),
],
);
}
Widget _top() {
final m = model;
return Row(
children: [
const Expanded(
child: Align(alignment: AlignmentDirectional.centerStart, child: NowPlayingTitle()),
),
UpNextButton(queue: m.queue, onPlayNextEpisode: m.playNextEpisode, look: UpNextLook.minimal, hold: hold),
const SizedBox(width: 4),
SignalBuilder(
builder: (context) {
final full = m.fullscreen.value;
return MinimalButton(
key: const ValueKey('minimal-collapse'),
icon: full ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
tooltip: full ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
onPressed: full ? m.toggleFullscreen : m.collapse,
);
},
),
?windowButtons,
],
);
}
Widget _control(BuildContext context, BarControl c, ControlsSnapshot s) {
final m = model;
final key = ValueKey('control-${c.name}');
final enabled = controlEnabled(c, s);
Widget button(VoidCallback? onPressed, {int? badge, bool accent = false}) => MinimalButton(
key: key,
icon: controlIcon(c, s),
tooltip: controlTooltip(c, s),
badge: badge,
accent: accent,
scale: scale,
onPressed: enabled ? onPressed : null,
);
switch (c) {
case BarControl.playPause:
return Padding(
padding: EdgeInsets.only(right: 4 * scale),
child: button(m.togglePlay, accent: true),
);
case BarControl.previous:
return button(m.previous);
case BarControl.next:
return button(m.next);
case BarControl.chapterPrevious:
return button(() => m.stepChapter(-1));
case BarControl.chapterNext:
return button(() => m.stepChapter(1));
case BarControl.volume:
return SignalBuilder(
key: key,
builder: (context) => VolumeControl(
volume: m.volume.value,
onChanged: m.setVolume,
onToggleMute: m.toggleMute,
sliderColor: Theme.of(context).colorScheme.primary,
trackColor: MinimalStyle.dim.withValues(alpha: 0.4),
button: (context, i, color, tip, onPressed) =>
MinimalButton(icon: i, tooltip: tip, color: color, scale: scale, onPressed: onPressed),
),
);
case BarControl.time:
return const SizedBox.shrink();
case BarControl.subtitles || BarControl.audio:
final type = c == BarControl.audio ? JellyfinStreamType.audio : JellyfinStreamType.subtitle;
return SignalBuilder(
key: key,
builder: (context) => Builder(
builder: (context) => MinimalButton(
icon: controlIcon(c, s),
tooltip: controlTooltip(c, s),
badge: trackCount(m.tracks.value, type),
scale: scale,
onPressed: () => openTrackMenu(context, m, type),
),
),
);
case BarControl.settings:
return Builder(
key: key,
builder: (context) => MinimalButton(
icon: controlIcon(c, s),
tooltip: controlTooltip(c, s),
scale: scale,
onPressed: () => openControlsMenu(context, m),
),
);
case BarControl.chat:
return SignalBuilder(
key: key,
builder: (context) => MinimalButton(
icon: controlIcon(c, s),
tooltip: controlTooltip(c, s),
badge: m.unread.value,
scale: scale,
onPressed: m.toggleChat,
),
);
case BarControl.fullscreen:
return button(m.toggleFullscreen);
}
}
}
@@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import '../../widgets/app_menu.dart' show AppMenuStyle;
// The Minimal skin's look: the app's glass and accent (the theme's primary)
// on a thick timeline band, with soft rounded buttons in two glass capsules.
abstract final class MinimalStyle {
/// The timeline band (no blur: it spans the width over the picture).
static const band = Color(0xCC0E0E12);
/// The capsules holding the buttons: the player pills' glass.
static const capsule = AppMenuStyle.pillGlass;
static const hover = Color(0x1FFFFFFF);
static const text = Colors.white;
static const dim = Color(0xB3FFFFFF);
static const radius = 10.0;
static const fontSize = 13.0;
/// The tooltips and the timeline's hover tip.
static const tip = Color(0xEB18181C);
/// A button's box at scale 1.
static const button = 38.0;
/// The capsules' padding around the buttons.
static const capsulePad = 4.0;
}
/// The Minimal skin's tooltip: the app's dark glass colour, above.
class MinimalTooltip extends StatelessWidget {
final String message;
final Widget child;
const MinimalTooltip({super.key, required this.message, required this.child});
@override
Widget build(BuildContext context) {
if (message.isEmpty) return child;
return Tooltip(
message: message,
preferBelow: false,
verticalOffset: 28,
waitDuration: const Duration(milliseconds: 300),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: MinimalStyle.tip,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppMenuStyle.border, width: 0.5),
),
textStyle: const TextStyle(color: MinimalStyle.text, fontSize: 12),
child: child,
);
}
}
/// A Minimal button: a white icon on a rounded square that lights on hover
/// ([accent]: filled with the theme's primary, the play button), an
/// optional [label] and a count [badge]. Never takes focus.
class MinimalButton extends StatefulWidget {
final IconData icon;
final String tooltip;
final VoidCallback? onPressed;
final String? label;
final int? badge;
final bool accent;
final Color? color;
final double scale;
const MinimalButton({
super.key,
required this.icon,
required this.tooltip,
required this.onPressed,
this.label,
this.badge,
this.accent = false,
this.color,
this.scale = 1,
});
@override
State<MinimalButton> createState() => _MinimalButtonState();
}
class _MinimalButtonState extends State<MinimalButton> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final w = widget;
final primary = Theme.of(context).colorScheme.primary;
final onPrimary = Theme.of(context).colorScheme.onPrimary;
final enabled = w.onPressed != null;
final size = MinimalStyle.button * w.scale;
final fg = !enabled
? MinimalStyle.dim.withValues(alpha: 0.35)
: w.accent
? onPrimary
: (w.color ?? MinimalStyle.text);
final bg = w.accent
? (_hover ? Color.lerp(primary, Colors.white, 0.18)! : primary)
: (_hover && enabled ? MinimalStyle.hover : Colors.transparent);
final badge = w.badge;
return MinimalTooltip(
message: w.tooltip,
child: MouseRegion(
cursor: enabled ? SystemMouseCursors.click : SystemMouseCursors.basic,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: w.onPressed,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
height: size,
constraints: BoxConstraints(minWidth: size),
padding: EdgeInsets.symmetric(horizontal: w.label == null ? 0 : 10 * w.scale),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(w.accent ? size / 2 : MinimalStyle.radius * w.scale),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Stack(
clipBehavior: Clip.none,
children: [
Icon(w.icon, color: fg, size: (w.accent ? 26 : 22) * w.scale),
if (badge != null && badge > 0)
Positioned(
right: -7,
top: -5,
child: Container(
key: const ValueKey('minimal-badge'),
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(color: primary, borderRadius: BorderRadius.circular(999)),
child: Text(
'$badge',
style: TextStyle(color: onPrimary, fontSize: 9, height: 1.35, fontWeight: FontWeight.w700),
),
),
),
],
),
if (w.label != null) ...[
SizedBox(width: 6 * w.scale),
Text(
w.label!,
style: TextStyle(
color: fg,
fontSize: MinimalStyle.fontSize * w.scale,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
],
),
),
),
),
);
}
}
/// A glass capsule around a row of Minimal buttons.
class MinimalCapsule extends StatelessWidget {
final List<Widget> children;
final double scale;
const MinimalCapsule({super.key, required this.children, this.scale = 1});
@override
Widget build(BuildContext context) {
final r = BorderRadius.circular(MinimalStyle.radius * scale + MinimalStyle.capsulePad);
return DecoratedBox(
decoration: BoxDecoration(
color: MinimalStyle.capsule,
borderRadius: r,
border: Border.all(color: AppMenuStyle.border, width: 0.5),
),
child: Padding(
padding: const EdgeInsets.all(MinimalStyle.capsulePad),
child: Row(mainAxisSize: MainAxisSize.min, spacing: 2, children: children),
),
);
}
}
@@ -0,0 +1,578 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../playback/media_marks.dart';
import '../frame.dart';
import 'style.dart';
// The Minimal skin's timeline: a band across the bottom with the played
// part tinted in the accent and the position as a line, the part not
// downloaded yet striped, chapter starts as dots on the top edge (click one
// to jump), the elapsed time left and the total or the time left right,
// the buffered seconds by the position while under a minute, and a tip
// with the time (and the chapter title) under the mouse. Hidden, it shrinks
// to a thin progress line.
//
// Inspired by the timeline of uosc (github.com/tomasklaen/uosc, LGPL-2.1,
// by tomasklaen): the striped unbuffered part, the remaining time and the
// thin line when hidden come from there. The drawing, geometry and values
// here are our own.
/// Sizes at scale 1.
abstract final class MinimalTimelineSpec {
/// The band's height while the controls are up.
static const size = 36.0;
/// What is left of it while they are hidden.
static const lineSize = 3.0;
/// The position line.
static const lineWidth = 2.0;
static const fontSize = 15.0;
/// The times' inset from the ends.
static const inset = 12.0;
/// A chapter dot's radius (doubled-ish while hovered).
static const dot = 3.5;
/// The cache label shows while less than this is buffered ahead.
static const bufferedThreshold = Duration(seconds: 60);
/// Collapse / grow.
static const animation = Duration(milliseconds: 120);
}
/// Fraction of [total] at [d], 0..1 (0 without a duration).
double timelineFraction(Duration d, Duration total) {
final t = total.inMicroseconds;
if (t <= 0) return 0;
return (d.inMicroseconds / t).clamp(0.0, 1.0);
}
/// Where the part not downloaded yet starts (it runs to the end), as a
/// fraction; null when nothing known is missing. We know only the end of
/// mpv's cache ahead ([buffered]); a cache within half a second of the end
/// leaves nothing.
double? uncachedFrom({required Duration position, required Duration buffered, required Duration duration}) {
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
final from = buffered > position ? buffered : position;
if (duration - from <= const Duration(milliseconds: 500)) return null;
return timelineFraction(from, duration);
}
/// The cache label by the position ("12s"): the seconds buffered ahead,
/// while under [MinimalTimelineSpec.bufferedThreshold] and the cache does
/// not reach the end.
String? cacheLabel({required Duration position, required Duration buffered, required Duration duration}) {
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
if (duration - buffered <= const Duration(milliseconds: 500)) return null;
final ahead = buffered > position ? buffered - position : Duration.zero;
if (ahead >= MinimalTimelineSpec.bufferedThreshold) return null;
return '${(ahead.inMilliseconds / 1000).round()}s';
}
/// The band's height while it animates between hidden (0) and shown (1).
double minimalBandSize(double visibility, double scale) =>
MinimalTimelineSpec.lineSize +
((MinimalTimelineSpec.size * scale - MinimalTimelineSpec.lineSize) * visibility).roundToDouble();
/// The Minimal timeline. [expanded] false (the controls hidden) shrinks it,
/// animated, to the progress line. Seeks ([onSeek]) on press, on every drag
/// move and on release; a press on a hovered chapter dot jumps to that
/// chapter, one on the right-hand time switches it ([onToggleTime]).
class MinimalTimeline extends StatefulWidget {
final Duration position;
final Duration duration;
/// End of the cached range.
final Duration buffered;
final List<MediaChapter> chapters;
final bool expanded;
/// The right-hand time: the time left instead of the total.
final bool remaining;
final double scale;
final ValueChanged<Duration> onSeek;
final VoidCallback? onToggleTime;
const MinimalTimeline({
super.key,
required this.position,
required this.duration,
this.buffered = Duration.zero,
this.chapters = const [],
this.expanded = true,
this.remaining = true,
this.scale = 1,
required this.onSeek,
this.onToggleTime,
});
static double heightFor(double scale) => MinimalTimelineSpec.size * scale;
static const collapsedHeight = MinimalTimelineSpec.lineSize;
@override
State<MinimalTimeline> createState() => _MinimalTimelineState();
}
class _MinimalTimelineState extends State<MinimalTimeline> {
Offset? _hover;
bool _dragging = false;
double _dragFraction = 0;
Duration? _lastSent;
// A released seek, shown until the position catches up (else the line
// jumps back to the old position for a moment).
double? _pending;
Timer? _pendingTimer;
double get _scale => widget.scale;
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
void _send(Duration d) {
if (d == _lastSent) return;
_lastSent = d;
widget.onSeek(d);
}
void _drag(bool on) {
if (_dragging == on) return;
setState(() => _dragging = on);
ControlsFrame.maybeOf(context)?.hold(on);
}
void _settle(double fraction) {
_pending = fraction;
_pendingTimer?.cancel();
_pendingTimer = Timer(const Duration(seconds: 2), () {
if (mounted) setState(() => _pending = null);
});
}
@override
void didUpdateWidget(MinimalTimeline old) {
super.didUpdateWidget(old);
if (!widget.expanded && old.expanded) _hover = null;
final p = _pending;
if (p != null && widget.duration > Duration.zero) {
final off = (widget.position - _at(p)).abs();
if (off < const Duration(milliseconds: 1500)) {
_pending = null;
_pendingTimer?.cancel();
}
}
}
@override
void dispose() {
_pendingTimer?.cancel();
// A drag cut short by a rebuild must not keep the controls up.
if (_dragging) ControlsFrame.maybeOf(context)?.hold(false);
super.dispose();
}
double _dotRadius(bool hovered) => MinimalTimelineSpec.dot * _scale * (hovered ? 1.7 : 1);
/// The chapter whose dot is within reach of [at].
int? _hoveredChapter(double width, List<MediaChapter> marks, Offset? at) {
if (at == null || _dragging) return null;
final reach = _dotRadius(false) * 2.5;
int? best;
var bestD = double.infinity;
for (var i = 0; i < marks.length; i++) {
final cx = width * timelineFraction(marks[i].start, widget.duration);
final d = (Offset(cx, 0) - at).distance;
if (d <= reach && d < bestD) {
best = i;
bestD = d;
}
}
return best;
}
String? _rightText(Duration shown) {
final d = widget.duration;
if (d <= Duration.zero) return null;
if (!widget.remaining) return formatClock(d);
final left = d - shown;
return '-${formatClock(left.isNegative ? Duration.zero : left)}';
}
// The painter's text takes the app's font (a TextPainter does not
// inherit it).
String? _font;
TextStyle get _timeStyle => TextStyle(
fontFamily: _font,
color: MinimalStyle.text,
fontSize: MinimalTimelineSpec.fontSize * _scale,
height: 1,
fontWeight: FontWeight.w500,
fontFeatures: const [FontFeature.tabularFigures()],
);
/// Where the right-hand time sits (a press there switches it).
bool _onRightTime(Offset at, double width, String? text) {
if (text == null || widget.onToggleTime == null) return false;
final tp = TextPainter(
text: TextSpan(text: text, style: _timeStyle),
textDirection: TextDirection.ltr,
)..layout();
return at.dx >= width - MinimalTimelineSpec.inset * _scale - tp.width - 6;
}
@override
Widget build(BuildContext context) {
_font = DefaultTextStyle.of(context).style.fontFamily ?? Theme.of(context).textTheme.bodyMedium?.fontFamily;
return LayoutBuilder(
builder: (context, constraints) => TweenAnimationBuilder<double>(
tween: Tween(end: widget.expanded ? 1 : 0),
duration: MinimalTimelineSpec.animation,
builder: (context, visibility, _) => _band(context, constraints.maxWidth, visibility),
),
);
}
Widget _band(BuildContext context, double width, double visibility) {
final expanded = widget.expanded;
final duration = widget.duration;
final size = minimalBandSize(visibility, _scale);
final progress = _dragging ? _dragFraction : (_pending ?? timelineFraction(widget.position, duration));
final shown = _at(progress);
final marks = duration > Duration.zero ? widget.chapters : const <MediaChapter>[];
final hover = expanded ? _hover : null;
final hovered = _hoveredChapter(width, marks, hover);
final hoverFraction = hovered != null
? timelineFraction(marks[hovered].start, duration)
: (hover == null || duration <= Duration.zero || width <= 0 ? null : (hover.dx / width).clamp(0.0, 1.0));
final hoverAt = hoverFraction == null ? null : _at(hoverFraction);
final cursorX = hoverFraction == null ? null : width * hoverFraction;
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
// Text fades out before it would overflow the shrinking band.
final minText = MinimalTimelineSpec.fontSize * _scale;
final textOpacity = ((size - minText) / (minText / 2)).clamp(0.0, 1.0);
final right = _rightText(shown);
final primary = Theme.of(context).colorScheme.primary;
final painter = MinimalTimelinePainter(
progress: progress,
visibility: visibility,
accent: primary,
scale: _scale,
uncached: uncachedFrom(position: widget.position, buffered: widget.buffered, duration: duration),
chapters: [for (final c in marks) timelineFraction(c.start, duration)],
hoveredChapter: hovered,
hoverX: hovered == null ? cursorX : null,
textOpacity: textOpacity,
elapsed: duration > Duration.zero ? formatClock(shown) : null,
right: right,
cache: cacheLabel(position: widget.position, buffered: widget.buffered, duration: duration),
timeStyle: _timeStyle,
);
final bar = SizedBox(
key: const ValueKey('minimal-timeline'),
width: width,
height: size,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: RepaintBoundary(child: CustomPaint(painter: painter)),
),
if (hoverAt != null && cursorX != null)
Positioned(
left: 0,
right: 0,
bottom: size + 8,
height: 120,
child: IgnorePointer(
child: CustomMultiChildLayout(
delegate: _TipLayout(x: cursorX),
children: [
LayoutId(
id: #tip,
child: _HoverTip(time: formatClock(hoverAt), chapter: hoverTitle, scale: _scale),
),
],
),
),
),
],
),
);
if (!expanded) return IgnorePointer(child: bar);
Offset clampTo(Offset p) => Offset(p.dx.clamp(0.0, width), p.dy);
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (e) => setState(() => _hover = clampTo(e.localPosition)),
onHover: (e) => setState(() => _hover = clampTo(e.localPosition)),
onExit: (_) {
if (!_dragging) setState(() => _hover = null);
},
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (e) {
if (width <= 0) return;
final at = clampTo(e.localPosition);
if (_onRightTime(at, width, right)) {
widget.onToggleTime?.call();
return;
}
if (duration <= Duration.zero) return;
final chapter = _hoveredChapter(width, marks, at);
_lastSent = null;
if (chapter != null) {
_settle(timelineFraction(marks[chapter].start, duration));
_send(marks[chapter].start);
setState(() {});
return;
}
_pending = null;
_dragFraction = (at.dx / width).clamp(0.0, 1.0);
_drag(true);
_send(_at(_dragFraction));
},
onPointerMove: (e) {
if (!_dragging) return;
final at = clampTo(e.localPosition);
setState(() {
_hover = at;
_dragFraction = (at.dx / width).clamp(0.0, 1.0);
});
_send(_at(_dragFraction));
},
onPointerUp: (e) {
if (!_dragging) return;
_send(_at(_dragFraction));
_settle(_dragFraction);
final p = e.localPosition;
if (p.dx < 0 || p.dx > width || p.dy < 0 || p.dy > size) _hover = null;
_drag(false);
},
onPointerCancel: (_) => _drag(false),
child: bar,
),
);
}
}
class _HoverTip extends StatelessWidget {
final String time;
final String chapter;
final double scale;
const _HoverTip({required this.time, required this.chapter, required this.scale});
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxWidth: 360),
padding: EdgeInsets.symmetric(horizontal: 10 * scale, vertical: 5 * scale),
decoration: BoxDecoration(
color: MinimalStyle.tip,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0x1FFFFFFF), width: 0.5),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (chapter.isNotEmpty)
Text(
chapter,
key: const ValueKey('minimal-hover-chapter'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: MinimalStyle.text, fontSize: 13 * scale, fontWeight: FontWeight.w600),
),
Text(
time,
key: const ValueKey('minimal-hover-time'),
style: TextStyle(
color: chapter.isEmpty ? MinimalStyle.text : MinimalStyle.dim,
fontSize: 13 * scale,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
);
}
}
/// The hover tip centred on [x], kept inside the band's ends.
class _TipLayout extends MultiChildLayoutDelegate {
final double x;
_TipLayout({required this.x});
@override
void performLayout(Size size) {
const gap = 4.0;
final tip = layoutChild(#tip, BoxConstraints.loose(size));
final left = (x - tip.width / 2).clamp(gap, math.max(gap, size.width - gap - tip.width)).toDouble();
positionChild(#tip, Offset(left, size.height - tip.height));
}
@override
bool shouldRelayout(_TipLayout old) => old.x != x;
}
/// Paints the band: background, the played tint and the (white) position
/// line, the stripes over the part not downloaded, chapter marks, the
/// times, the hover line.
class MinimalTimelinePainter extends CustomPainter {
/// The playing position, 0..1.
final double progress;
/// 0 hidden .. 1 shown.
final double visibility;
final Color accent;
final double scale;
/// Start of the part not downloaded (to the end), as a fraction.
final double? uncached;
/// Chapter starts, as fractions.
final List<double> chapters;
final int? hoveredChapter;
final double? hoverX;
final double textOpacity;
final String? elapsed;
final String? right;
final String? cache;
final TextStyle timeStyle;
const MinimalTimelinePainter({
required this.progress,
this.visibility = 1,
this.accent = Colors.white,
this.scale = 1,
this.uncached,
this.chapters = const [],
this.hoveredChapter,
this.hoverX,
this.textOpacity = 1,
this.elapsed,
this.right,
this.cache,
this.timeStyle = const TextStyle(fontSize: MinimalTimelineSpec.fontSize),
});
/// x of the position line's centre.
double lineX(double width) {
final lw = MinimalTimelineSpec.lineWidth * scale;
return (width * progress).clamp(lw / 2, math.max(lw / 2, width - lw / 2));
}
@override
void paint(Canvas canvas, Size size) {
final w = size.width, h = size.height;
final x = lineX(w);
final band = Offset.zero & size;
// The band, darker as it grows; hidden, a faint track.
canvas.drawRect(band, Paint()..color = Color.lerp(const Color(0x40FFFFFF), MinimalStyle.band, visibility)!);
// Played: an accent tint, solid once the band is a line.
canvas.drawRect(
Rect.fromLTRB(0, 0, x, h),
Paint()..color = accent.withValues(alpha: 0.28 + 0.72 * (1 - visibility)),
);
_stripes(canvas, w, h);
_chapters(canvas, w, h);
if (visibility > 0) {
final lw = MinimalTimelineSpec.lineWidth * scale;
canvas.drawRect(
Rect.fromLTRB(x - lw / 2, 0, x + lw / 2, h),
Paint()..color = Colors.white.withValues(alpha: visibility),
);
}
if (textOpacity > 0) _times(canvas, w, h, x);
final hx = hoverX;
if (hx != null) {
canvas.drawRect(Rect.fromLTRB(hx - 0.5, 0, hx + 0.5, h), Paint()..color = Colors.white.withValues(alpha: 0.45));
}
}
/// Thin 45° stripes over the part not downloaded yet.
void _stripes(Canvas canvas, double w, double h) {
final from = uncached;
if (from == null) return;
final ax = (w * from).floorToDouble();
if (ax >= w) return;
final period = 7.0 * scale;
canvas.save();
canvas.clipRect(Rect.fromLTRB(ax, 0, w, h));
final paint = Paint()
..color = Colors.white.withValues(alpha: 0.10 + 0.08 * (1 - visibility))
..strokeWidth = 1.5 * scale;
for (var c = (ax / period).floorToDouble() * period - h; c <= w; c += period) {
canvas.drawLine(Offset(c, h), Offset(c + h, 0), paint);
}
canvas.restore();
}
void _chapters(Canvas canvas, double w, double h) {
final cut = Paint()..color = Colors.black.withValues(alpha: 0.55);
for (final f in chapters) {
if (f <= 0) continue;
final cx = w * f;
canvas.drawRect(Rect.fromLTRB(cx - 0.5, 0, cx + 0.5, h), cut);
}
if (visibility <= 0) return;
final r = MinimalTimelineSpec.dot * scale * visibility;
void dot(double f, double radius, Color fill) {
final c = Offset(w * f, 0);
canvas.drawCircle(c, radius + 1, Paint()..color = Colors.black.withValues(alpha: 0.7));
canvas.drawCircle(c, radius, Paint()..color = fill);
}
// Not at 0: the band's start says that already.
for (var i = 0; i < chapters.length; i++) {
if (i != hoveredChapter && chapters[i] > 0) dot(chapters[i], r, Colors.white.withValues(alpha: 0.9));
}
final hv = hoveredChapter;
if (hv != null && hv < chapters.length) dot(chapters[hv], r * 1.7, accent);
}
TextPainter _text(String s, TextStyle style) => TextPainter(
text: TextSpan(text: s, style: style),
textDirection: TextDirection.ltr,
)..layout();
/// White text with a soft shadow, so it reads over the accent tint too.
void _label(Canvas canvas, String s, TextStyle style, Offset at) {
final o = textOpacity;
_text(
s,
style.copyWith(
color: (style.color ?? Colors.white).withValues(alpha: o),
shadows: [Shadow(color: Colors.black.withValues(alpha: 0.8 * o), blurRadius: 3)],
),
).paint(canvas, at);
}
void _times(Canvas canvas, double w, double h, double lineX) {
final inset = MinimalTimelineSpec.inset * scale;
final fs = timeStyle.fontSize ?? MinimalTimelineSpec.fontSize;
final cy = h / 2;
final e = elapsed, r = right;
final ew = e == null ? 0.0 : _text(e, timeStyle).width;
final rw = r == null ? 0.0 : _text(r, timeStyle).width;
final c = cache;
if (c != null) {
final cs = timeStyle.copyWith(fontSize: fs * 0.8, color: MinimalStyle.dim);
final cw = _text(c, cs).width;
const margin = 6.0;
var x = lineX + margin;
final minX = inset + margin + ew, maxX = w - inset - margin - rw;
if (x < minX) {
x = minX;
} else if (x + cw > maxX) {
x = maxX - cw;
}
_label(canvas, c, cs, Offset(x, cy - fs * 0.4));
}
if (e != null) _label(canvas, e, timeStyle, Offset(inset, cy - fs / 2));
if (r != null) _label(canvas, r, timeStyle, Offset(w - inset - rw, cy - fs / 2));
}
@override
bool shouldRepaint(MinimalTimelinePainter old) => true;
}
@@ -0,0 +1,235 @@
import 'package:flutter/material.dart' show IconData, Icons;
import '../../playback/media_marks.dart' show formatClock;
import 'controls_prefs.dart';
// The controls layout, defined once for both skins: which controls the
// bottom bar has, in which order, which actions sit in the ⚙ menu instead,
// and their icons, tooltips and labels. Pure: everything here is a
// function of a [ControlsSnapshot] and the user's choices, so tests cover
// it without a player.
/// What playback does as this viewer sees it. In a server "play" is being
/// ready: the video plays once everyone is.
enum PlayState {
/// The video plays (the button pauses: you go unready).
playing,
/// You are ready; the others are not yet (the button unreadies you).
waiting,
/// You are not ready (the button readies you).
paused,
/// The media ended.
finished,
}
/// A control in the bottom bar.
enum BarControl {
playPause,
/// Previous episode.
previous,
/// Up next's first item, else the next episode.
next,
chapterPrevious,
chapterNext,
volume,
time,
subtitles,
audio,
/// ⚙: everything that is not a bar button.
settings,
chat,
fullscreen,
}
/// An action in the ⚙ menu.
enum ControlAction {
chapters('Chapters…', null),
subtitles('Subtitles…', null),
audio('Audio…', null),
skipForward('Skip 1:30', 'Shift+Right'),
shaders('Upscaling shader…', 'Ctrl+0…6'),
chat('Chat & activity', 'Tab'),
changeVideo('Change video', null),
copyLink('Copy link', null),
reload('Reload video', 'Ctrl+R'),
markAllReady('Mark everyone ready', null),
markProblem('Mark a problem', 'Ctrl+Shift+B'),
playerSettings('Player settings…', null),
leave('Leave server', null);
final String label;
final String? hint;
const ControlAction(this.label, this.hint);
}
/// The bar's order, left cluster then right cluster. The play button is
/// always first and fullscreen always last; controls that do not apply
/// (no episode, no chapters) or that the user hid are left out, the rest
/// keep their order.
abstract final class ControlsLayout {
static const left = [
BarControl.playPause,
BarControl.previous,
BarControl.next,
BarControl.chapterPrevious,
BarControl.chapterNext,
BarControl.volume,
BarControl.time,
];
static const right = [
BarControl.subtitles,
BarControl.audio,
BarControl.settings,
BarControl.chat,
BarControl.fullscreen,
];
/// The ⚙ menu's order.
static const menu = ControlAction.values;
}
/// What the layout depends on, at one moment.
class ControlsSnapshot {
final PlayState play;
/// The previous episode's label; null: none.
final String? previousLabel;
/// Up next's first item's title, else the next episode's label; null:
/// nothing next.
final String? nextLabel;
/// [nextLabel] is Up next (not an episode).
final bool nextIsUpNext;
final bool hasChapters;
/// The media's length is known (seeks and skips make sense).
final bool hasDuration;
/// Watching alone (no one to mark ready).
final bool solo;
/// A link to copy (`SessionCoordinator.currentLink`).
final bool hasLink;
final bool fullscreen;
final bool chatOpen;
const ControlsSnapshot({
this.play = PlayState.paused,
this.previousLabel,
this.nextLabel,
this.nextIsUpNext = false,
this.hasChapters = false,
this.hasDuration = false,
this.solo = false,
this.hasLink = false,
this.fullscreen = false,
this.chatOpen = false,
});
}
/// The bar's two clusters for [s], in [ControlsLayout] order.
({List<BarControl> left, List<BarControl> right}) barControls(ControlsSnapshot s, Set<OptionalControl> hidden) {
bool on(BarControl c) => switch (c) {
BarControl.previous => s.previousLabel != null,
BarControl.next => s.nextLabel != null,
BarControl.chapterPrevious || BarControl.chapterNext => s.hasChapters && !hidden.contains(OptionalControl.chapters),
BarControl.subtitles => !hidden.contains(OptionalControl.subtitles),
BarControl.audio => !hidden.contains(OptionalControl.audio),
BarControl.chat => !hidden.contains(OptionalControl.chat),
_ => true,
};
return (
left: [
for (final c in ControlsLayout.left)
if (on(c)) c,
],
right: [
for (final c in ControlsLayout.right)
if (on(c)) c,
],
);
}
/// The ⚙ menu for [s]: every action that applies, minus those the bar
/// already shows.
List<ControlAction> menuActions(ControlsSnapshot s, Set<OptionalControl> hidden) => [
for (final a in ControlsLayout.menu)
if (switch (a) {
ControlAction.chapters => s.hasChapters,
ControlAction.subtitles => hidden.contains(OptionalControl.subtitles),
ControlAction.audio => hidden.contains(OptionalControl.audio),
ControlAction.skipForward => s.hasDuration,
ControlAction.chat => hidden.contains(OptionalControl.chat),
ControlAction.copyLink => s.hasLink,
ControlAction.markAllReady => !s.solo,
_ => true,
})
a,
];
/// Whether a bar control does something now (a skin dims it otherwise).
bool controlEnabled(BarControl c, ControlsSnapshot s) => switch (c) {
BarControl.chapterPrevious || BarControl.chapterNext => s.hasChapters && s.hasDuration,
BarControl.previous => s.previousLabel != null,
BarControl.next => s.nextLabel != null,
_ => true,
};
/// The icon of a bar control (volume and the clock draw their own).
IconData controlIcon(BarControl c, ControlsSnapshot s) => switch (c) {
BarControl.playPause => switch (s.play) {
PlayState.playing => Icons.pause_rounded,
// Ready: the check says so; a click unreadies.
PlayState.waiting => Icons.check_rounded,
PlayState.finished => Icons.replay_rounded,
PlayState.paused => Icons.play_arrow_rounded,
},
BarControl.previous => Icons.skip_previous_rounded,
BarControl.next => Icons.skip_next_rounded,
BarControl.chapterPrevious => Icons.chevron_left_rounded,
BarControl.chapterNext => Icons.chevron_right_rounded,
BarControl.volume => Icons.volume_up_rounded,
BarControl.time => Icons.schedule_rounded,
BarControl.subtitles => Icons.subtitles_outlined,
BarControl.audio => Icons.graphic_eq_rounded,
BarControl.settings => Icons.settings_outlined,
BarControl.chat => s.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded,
BarControl.fullscreen => s.fullscreen ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded,
};
/// The tooltip of a bar control, with its key.
String controlTooltip(BarControl c, ControlsSnapshot s) => switch (c) {
BarControl.playPause => switch (s.play) {
PlayState.playing => 'Pause for everyone (Space)',
PlayState.waiting => 'Ready, waiting for the others (Space: not ready)',
PlayState.paused => s.solo ? 'Play (Space)' : 'Ready: play when everyone is (Space)',
PlayState.finished => 'Ready (Space)',
},
BarControl.previous => 'Previous: ${s.previousLabel ?? ''} (< or Shift+P)',
BarControl.next => '${s.nextIsUpNext ? 'Up next' : 'Next'}: ${s.nextLabel ?? ''} (> or Shift+N)',
BarControl.chapterPrevious => 'Previous chapter (PgUp)',
BarControl.chapterNext => 'Next chapter (PgDn)',
BarControl.volume => 'Volume (scroll, ↑ / ↓)',
BarControl.time => 'Elapsed / remaining (click to switch)',
BarControl.subtitles => 'Subtitles',
BarControl.audio => 'Audio',
BarControl.settings => 'Settings and more',
BarControl.chat => 'Chat & activity (Tab)',
BarControl.fullscreen => s.fullscreen ? 'Exit fullscreen (F, Esc)' : 'Fullscreen (F)',
};
/// The bar's clock: "6:00 / 24:00", or the time left ("-18:00"); "0:00"
/// parts while the length is unknown.
String clockText(Duration position, Duration duration, TimeDisplay display) {
if (display == TimeDisplay.remaining && duration > Duration.zero) {
final left = duration - position;
return '-${formatClock(left.isNegative ? Duration.zero : left)}';
}
return '${formatClock(position)} / ${formatClock(duration)}';
}
@@ -0,0 +1,214 @@
import 'package:flutter/foundation.dart';
import 'package:signals/signals_flutter.dart';
import '../../jellyfin/track_prefs.dart' show MpvTrack;
import '../../playback/media_marks.dart';
import '../../playback/playback_controller.dart';
import '../../queue/queue_controller.dart';
import '../../session/session_coordinator.dart';
import '../../shaders/shader_presets.dart';
import '../../state/session_state.dart';
import '../../state/settings.dart';
import '../player_settings.dart' show applyShaderChoice;
import '../volume.dart' as vol;
import 'controls_layout.dart';
/// A track pick from a track menu: audio id, subtitle id, or off.
typedef TrackPick = ({int? aid, int? sid, bool subtitlesOff});
/// The player controls, independent of how they look: what the controls
/// show (state, as signals) and what they do (actions), wired to the
/// session once. Both skins only draw this and call its actions, so a
/// control added here shows in both. Built by the session player per
/// mount; [fullscreen], [chatOpen] and [unread] are the shell's, passed in.
class PlayerControlsModel {
final SessionCoordinator coord;
final ReadonlySignal<bool> fullscreen;
final ReadonlySignal<bool> chatOpen;
final ReadonlySignal<int> unread;
final VoidCallback _toggleFullscreen;
final VoidCallback _toggleChat;
final VoidCallback _changeVideo;
final VoidCallback _collapse;
final VoidCallback _leave;
PlayerControlsModel({
required this.coord,
required this.fullscreen,
required this.chatOpen,
required this.unread,
required VoidCallback onToggleFullscreen,
required VoidCallback onToggleChat,
required VoidCallback onChangeVideo,
required VoidCallback onCollapse,
required VoidCallback onLeave,
}) : _toggleFullscreen = onToggleFullscreen,
_toggleChat = onToggleChat,
_changeVideo = onChangeVideo,
_collapse = onCollapse,
_leave = onLeave;
PlaybackController get playback => coord.playback;
QueueController get queue => coord.queue;
AppSettings get settings => AppSettings.instance;
// ---------------------------------------------------------------- state
ReadonlySignal<Duration> get position => playback.position;
ReadonlySignal<Duration> get duration => playback.duration;
/// End of the cached range ahead of the position.
ReadonlySignal<Duration> get buffered => playback.buffered;
ReadonlySignal<List<MediaChapter>> get chapters => playback.chapters;
ReadonlySignal<int?> get currentChapter => playback.currentChapter;
ReadonlySignal<List<MpvTrack>> get tracks => playback.tracks;
/// 0..`maxVolume`.
ReadonlySignal<double> get volume => playback.volume;
ReadonlySignal<SkipOffer?> get skipOffer => coord.skipOffer;
ReadonlySignal<RenderMode> get renderMode => playback.renderMode;
/// Play / pause as the session sees it ([PlayState]).
late final Computed<PlayState> play = computed(() {
final r = coord.serverState.value;
final self = coord.selfMember.value;
return switch (r?.transport) {
'playing' => PlayState.playing,
'finished' => PlayState.finished,
_ => (self?.ready ?? false) && (self?.inSet ?? false) ? PlayState.waiting : PlayState.paused,
};
});
/// Everything the layout depends on (not the position: that changes
/// four times a second and only the timeline needs it).
late final Computed<ControlsSnapshot> snapshot = computed(() {
final items = queue.items.value;
final next = coord.nextEpisode.value;
return ControlsSnapshot(
play: play.value,
previousLabel: coord.previousEpisode.value?.label,
nextLabel: items.isNotEmpty ? items.first.title : next?.label,
nextIsUpNext: items.isNotEmpty,
hasChapters: chapters.value.isNotEmpty,
hasDuration: duration.value > Duration.zero,
solo: SessionState.instance.solo.value,
// Not a signal: read again whenever the state changes.
hasLink: coord.serverState.value != null && coord.currentLink() != null,
fullscreen: fullscreen.value,
chatOpen: chatOpen.value,
);
});
/// The bar's clusters, in the layout's order.
late final Computed<({List<BarControl> left, List<BarControl> right})> bar = computed(
() => barControls(snapshot.value, settings.hiddenControls.value),
);
/// What the ⚙ menu lists now.
List<ControlAction> menu() => menuActions(snapshot.peek(), settings.hiddenControls.peek());
/// Ticks on each wheel / key volume step: the skins flash the volume.
final volumeFlash = ValueNotifier(0);
/// A menu of the controls is open (the player's Esc closes it).
int menusOpen = 0;
void dispose() => volumeFlash.dispose();
// -------------------------------------------------------------- actions
/// Space: ready / not ready (being ready is "play" in a server).
void togglePlay() => coord.toggleReady();
/// A seek bar's press or drag; through `player.seek`, the user-seek
/// path, so everyone follows.
void seek(Duration to) => playback.player.seek(to);
/// Arrow keys: ±[secs], relative to a seek still in flight.
void nudge(double secs) => coord.nudge(secs);
/// +1:30 for everyone.
void skipForward() => coord.skipForward();
/// A skip pill (Skip OP / ED / 1:30).
void skip(SkipOffer offer) => coord.skip(offer);
/// PgDn (+1) / PgUp (-1). False when there is no chapter to go to.
bool stepChapter(int delta) => coord.stepChapter(delta);
void pickChapter(MediaChapter c) => coord.seekTo(c.start.inMicroseconds / 1e6);
/// Up next's first item, else the next episode. False when neither.
bool next() {
if (queue.items.peek().isNotEmpty) {
queue.skip();
return true;
}
if (coord.nextEpisode.peek() == null) return false;
coord.playNextEpisode();
return true;
}
/// The previous episode. False without one.
bool previous() {
if (coord.previousEpisode.peek() == null) return false;
coord.playPreviousEpisode();
return true;
}
void playNextEpisode() => coord.playNextEpisode();
void setVolume(double v) => playback.player.setVolume(v.clamp(0.0, vol.maxVolume));
/// One volume step up (+1) or down (-1) from the wheel or ↑ / ↓;
/// flashes the volume readout.
void stepVolume(int step) {
setVolume(vol.stepVolume(volume.peek(), step * vol.volumeStep));
volumeFlash.value++;
}
double _beforeMute = 100;
/// The speaker: mute, or back to the volume before.
void toggleMute() {
final v = volume.peek();
if (v > 0) {
_beforeMute = v;
setVolume(0);
} else {
setVolume(_beforeMute <= 0 ? 100 : _beforeMute);
}
}
void pickTrack(TrackPick p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff);
/// Saved as the choice and applied live; a null quality keeps the
/// saved one.
void pickShader(ShaderPreset preset, ShaderQuality? quality) => applyShaderChoice(preset, quality);
void toggleTimeDisplay() => settings.setTimeDisplay(settings.timeDisplay.peek().toggled);
void toggleFullscreen() => _toggleFullscreen();
void toggleChat() {
coord.markInput();
_toggleChat();
}
void changeVideo() => _changeVideo();
/// Back to the library (keeps playing in the mini player).
void collapse() => _collapse();
/// Asks before leaving the server.
void leave() => _leave();
void reload() {
coord.markInput();
coord.reloadVideo();
}
void markAllReady() => coord.forcePlayAll();
String? link() => coord.currentLink();
}
@@ -0,0 +1,64 @@
// The player controls' user choices (Settings → Player, stored in
// `AppSettings`). Plain values: the layout spec and both skins read them.
/// The controls' look. Both skins draw the same controls model in the same
/// order ([ControlsLayout]); only the look differs.
enum ControlsSkin {
/// Material-style bar: a seek bar over a row of round icon buttons.
standard('Standard'),
/// The app's own timeline skin (inspired by mpv's uosc): a thick band
/// with the buffered part, chapters and times, flat glass buttons.
minimal('Minimal');
final String label;
const ControlsSkin(this.label);
/// A stored name; the old `mpvControls` flag (true: the uosc design)
/// when there is none.
static ControlsSkin fromStored(Object? name, {Object? legacyMpvControls}) {
for (final s in values) {
if (s.name == name) return s;
}
return legacyMpvControls == true ? minimal : standard;
}
}
/// The bar's clock: "6:00 / 24:00" or the time left ("-18:00").
enum TimeDisplay {
elapsed('Elapsed / total'),
remaining('Remaining');
final String label;
const TimeDisplay(this.label);
TimeDisplay get toggled => this == elapsed ? remaining : elapsed;
}
/// Bar and button size.
enum ControlsSize {
normal('Normal', 1.0),
large('Large', 1.25);
final String label;
final double scale;
const ControlsSize(this.label, this.scale);
}
/// Bar buttons the user may hide; hidden ones are still in ⚙.
enum OptionalControl {
chapters('Chapter buttons', 'Previous / next chapter (PgUp / PgDn)'),
subtitles('Subtitles', 'The subtitle track list'),
audio('Audio', 'The audio track list'),
chat('Chat', 'Chat & activity (Tab)');
final String label;
final String help;
const OptionalControl(this.label, this.help);
}
/// The auto-hide delays offered (seconds).
const controlsHideChoices = [2, 3, 5];
/// The default auto-hide delay (seconds).
const defaultControlsHideSecs = 3;
@@ -12,11 +12,11 @@ import '../shell/mark_problem.dart';
import '../shell/motion.dart'; import '../shell/motion.dart';
import '../state/settings.dart'; import '../state/settings.dart';
import '../widgets/app_menu.dart'; import '../widgets/app_menu.dart';
import 'uosc/buttons.dart'; import 'frame.dart';
import 'uosc/controls.dart'; import 'model/controls_prefs.dart';
// Everything about the player, defined once: the rows of the ⚙ card in // Everything about the player, defined once: the rows of the player
// both control designs and of Settings → Player. Each row reads and // settings card (⚙ → Player settings, both skins) and of Settings → Player. Each row reads and
// writes [AppSettings] and applies to a running player right away. // writes [AppSettings] and applies to a running player right away.
/// One setting: a label, one line of help under it, the control at the /// One setting: a label, one line of help under it, the control at the
@@ -67,7 +67,7 @@ AppSelect<T> _dropdown<T>({
}) => }) =>
AppSelect<T>(key: key, value: value, items: items, onChanged: onChanged, expanded: expanded); 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 { class PlayerDesignSetting extends StatelessWidget {
const PlayerDesignSetting({super.key}); const PlayerDesignSetting({super.key});
@@ -77,12 +77,100 @@ class PlayerDesignSetting extends StatelessWidget {
return SignalBuilder( return SignalBuilder(
builder: (context) => SettingRow( builder: (context) => SettingRow(
label: 'Player design', label: 'Player design',
help: 'uosc is modeled on mpv: a thick timeline with chapters and what is cached', help: 'Minimal has a thick timeline with chapters and what is downloaded yet',
control: _dropdown<bool>( control: _dropdown<ControlsSkin>(
key: const ValueKey('player-design'), key: const ValueKey('player-design'),
value: s.mpvControls.value, value: s.controlsSkin.value,
items: const [(false, 'Standard'), (true, 'uosc')], items: [for (final k in ControlsSkin.values) (k, k.label)],
onChanged: s.setMpvControls, onChanged: s.setControlsSkin,
),
),
);
}
}
/// The bar's clock: elapsed / total or the time left. Live.
class TimeDisplaySetting extends StatelessWidget {
const TimeDisplaySetting({super.key});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SettingRow(
label: 'Time display',
help: 'Also switched by a click on the time',
control: _dropdown<TimeDisplay>(
key: const ValueKey('time-display'),
value: s.timeDisplay.value,
items: [for (final t in TimeDisplay.values) (t, t.label)],
onChanged: s.setTimeDisplay,
),
),
);
}
}
/// Normal or large bar and buttons. Live.
class ControlsSizeSetting extends StatelessWidget {
const ControlsSizeSetting({super.key});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SettingRow(
label: 'Controls size',
help: 'Large makes the bar and its buttons a quarter bigger',
control: _dropdown<ControlsSize>(
key: const ValueKey('controls-size'),
value: s.controlsSize.value,
items: [for (final z in ControlsSize.values) (z, z.label)],
onChanged: s.setControlsSize,
),
),
);
}
}
/// How long the controls stay up without mouse movement. Live.
class HideDelaySetting extends StatelessWidget {
const HideDelaySetting({super.key});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SettingRow(
label: 'Hide the controls after',
help: 'Without mouse movement over the video',
control: _dropdown<int>(
key: const ValueKey('controls-hide'),
value: s.controlsHideSecs.value,
items: [for (final secs in controlsHideChoices) (secs, '$secs s')],
onChanged: s.setControlsHideSecs,
),
),
);
}
}
/// One optional bar button on or off (off: it is still in ⚙). Live.
class BarButtonSetting extends StatelessWidget {
final OptionalControl control;
const BarButtonSetting({super.key, required this.control});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SettingRow(
label: control.label,
help: control.help,
control: Switch(
key: ValueKey('bar-${control.name}'),
value: !s.hiddenControls.value.contains(control),
onChanged: (on) => s.setControlShown(control, on),
), ),
), ),
); );
@@ -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}) => [ List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
const PlayerDesignSetting(), const PlayerDesignSetting(),
const ProgressLineSetting(), const ProgressLineSetting(),
@@ -304,7 +405,8 @@ List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
const HwdecSetting(), const HwdecSetting(),
]; ];
/// The ⚙ card: every player setting, plus the running player's tracks. /// The player settings card: every player setting, plus the running
/// player's tracks.
class PlayerSettingsCard extends StatelessWidget { class PlayerSettingsCard extends StatelessWidget {
final ReadonlySignal<RenderMode>? renderMode; final ReadonlySignal<RenderMode>? renderMode;
/// Show the audio / subtitle pickers (a player is running). /// Show the audio / subtitle pickers (a player is running).
@@ -337,7 +439,7 @@ class PlayerSettingsCard extends StatelessWidget {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
'Custom shader files: Settings → Player', 'Controls layout and custom shaders: Settings → Player',
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12), style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12),
), ),
), ),
@@ -352,51 +454,14 @@ class PlayerSettingsCard extends StatelessWidget {
} }
} }
/// Opens the ⚙ card over the player, near the controls' right end. The /// Opens the player settings card over the player, near the controls'
/// uosc controls stay up while it is open. /// right end; the controls stay up while it is open.
Future<void> showPlayerSettings(BuildContext context, {ReadonlySignal<RenderMode>? renderMode}) { Future<void> showPlayerSettings(BuildContext context, {ReadonlySignal<RenderMode>? renderMode}) =>
Future<void> open() => showPopover<void>( ControlsFrame.holdWhile(
context,
() => showPopover<void>(
context, context,
at: const Alignment(0.96, 0.5), at: const Alignment(0.96, 0.5),
child: PlayerSettingsCard(renderMode: renderMode), child: PlayerSettingsCard(renderMode: renderMode),
),
); );
final controls = UoscControls.maybeOf(context);
return controls == null ? open() : controls.holdDuring(open);
}
/// ⚙ in the standard controls' bottom bar.
class PlayerSettingsButton extends StatelessWidget {
final FocusNode? focusNode;
final ReadonlySignal<RenderMode>? renderMode;
const PlayerSettingsButton({super.key, this.focusNode, this.renderMode});
@override
Widget build(BuildContext context) {
return IconButton(
key: const ValueKey('player-settings'),
focusNode: focusNode,
tooltip: 'Player settings',
color: Colors.white,
iconSize: 20,
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.tune),
onPressed: () => showPlayerSettings(context, renderMode: renderMode),
);
}
}
/// ⚙ in the uosc controls' button row.
class UoscPlayerSettingsButton extends StatelessWidget {
final ReadonlySignal<RenderMode>? renderMode;
const UoscPlayerSettingsButton({super.key, this.renderMode});
@override
Widget build(BuildContext context) {
return UoscButton(
key: const ValueKey('uosc-player-settings'),
icon: Icons.tune_rounded,
tooltip: 'Player settings',
onPressed: () => showPlayerSettings(context, renderMode: renderMode),
);
}
}
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../state/settings.dart';
import 'controls_probe.dart' show ControlsHold;
import 'frame.dart';
import 'minimal/minimal_controls.dart';
import 'model/controls_model.dart';
import 'model/controls_prefs.dart';
import 'standard/standard_controls.dart';
// The session's controls: one [ControlsFrame] (when they are up) around the
// skin the settings pick, both drawing the same [PlayerControlsModel].
// Skin, size and the hide delay follow the settings live.
/// The bottom controls' height in [skin] at [size] (the player's floating
/// pills sit just above it).
double controlsBottomHeight(ControlsSkin skin, ControlsSize size) => switch (skin) {
ControlsSkin.standard => StandardControls.bottomHeight(size.scale),
ControlsSkin.minimal => MinimalControls.bottomHeight(size.scale),
};
/// The top bar's height in [skin].
double controlsTopHeight(ControlsSkin skin) => switch (skin) {
ControlsSkin.standard => StandardControls.topHeight,
ControlsSkin.minimal => MinimalControls.topHeight,
};
class SessionControls extends StatelessWidget {
final PlayerControlsModel model;
/// Follows whether the controls are up.
final ValueNotifier<bool> shown;
final ControlsHold hold;
final VoidCallback onDoubleClick;
final Widget? windowButtons;
const SessionControls({
super.key,
required this.model,
required this.shown,
required this.hold,
required this.onDoubleClick,
this.windowButtons,
});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) {
final skin = s.controlsSkin.value;
final size = s.controlsSize.value;
final scale = size.scale;
return ControlsFrame(
// A new frame per skin: each starts up, like a fresh mount.
key: ValueKey(skin),
shown: shown,
holdBy: hold,
hideAfter: Duration(seconds: s.controlsHideSecs.value),
onDoubleClick: onDoubleClick,
barTop: controlsTopHeight(skin),
barBottom: controlsBottomHeight(skin, size),
builder: (context, visible) => switch (skin) {
ControlsSkin.standard => StandardControls(
model: model,
visible: visible,
scale: scale,
hold: hold,
windowButtons: windowButtons,
),
ControlsSkin.minimal => MinimalControls(
model: model,
visible: visible,
scale: scale,
hold: hold,
windowButtons: windowButtons,
),
},
);
},
);
}
}
@@ -0,0 +1,229 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../playback/media_marks.dart';
import '../frame.dart';
// The Standard skin's seek bar: a Material-style track with the played and
// buffered parts, chapter marks and a hover tip.
/// The seek bar: played / buffered track, a diamond at each chapter start
/// (not the one at 0), and on hover the time with the chapter title above
/// it. Like media_kit's bar it reports a seek on every drag move and on
/// release ([onSeek]); the session routes those through the user-seek path
/// (debounced, everyone follows).
class ChapterSeekBar extends StatefulWidget {
final Duration position;
final Duration duration;
final Duration buffer;
final List<MediaChapter> chapters;
final ValueChanged<Duration> onSeek;
/// The bar's height (its hit area); the track is centred in it.
final double height;
const ChapterSeekBar({
super.key,
required this.position,
required this.duration,
this.buffer = Duration.zero,
this.chapters = const [],
required this.onSeek,
this.height = ChapterSeekBar.defaultHeight,
});
static const defaultHeight = 28.0;
@override
State<ChapterSeekBar> createState() => _ChapterSeekBarState();
}
class _ChapterSeekBarState extends State<ChapterSeekBar> {
double? _hoverX;
bool _dragging = false;
double _dragFraction = 0;
double _fraction(Duration d) {
final total = widget.duration.inMicroseconds;
if (total <= 0) return 0;
return (d.inMicroseconds / total).clamp(0.0, 1.0);
}
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
// A drag sends no hover events: the controls are held up through it.
void _drag(bool on) {
if (_dragging == on) return;
setState(() => _dragging = on);
ControlsFrame.maybeOf(context)?.hold(on);
}
@override
void dispose() {
if (_dragging) ControlsFrame.maybeOf(context)?.hold(false);
super.dispose();
}
void _seekTo(double x, double width) {
if (widget.duration <= Duration.zero || width <= 0) return;
_dragFraction = (x / width).clamp(0.0, 1.0);
widget.onSeek(_at(_dragFraction));
}
@override
Widget build(BuildContext context) {
final primary = Theme.of(context).colorScheme.primary;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final active = _hoverX != null || _dragging;
final barHeight = active ? 5.0 : 3.0;
final played = _dragging ? _dragFraction : _fraction(widget.position);
final mid = widget.height / 2;
final hoverX = _hoverX;
final hoverAt = hoverX == null || width <= 0 ? null : _at(hoverX / width);
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
onHover: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
onExit: (_) => setState(() => _hoverX = null),
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (e) {
_dragFraction = width <= 0 ? 0 : (e.localPosition.dx / width).clamp(0.0, 1.0);
_drag(true);
},
onPointerMove: (e) {
setState(() => _hoverX = e.localPosition.dx.clamp(0, width));
_seekTo(e.localPosition.dx, width);
},
onPointerUp: (e) {
_seekTo(e.localPosition.dx, width);
_drag(false);
},
onPointerCancel: (_) => _drag(false),
child: SizedBox(
key: const ValueKey('seek-bar'),
width: width,
height: widget.height,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
right: 0,
top: mid - barHeight / 2,
height: barHeight,
child: Container(color: Colors.white24),
),
Positioned(
left: 0,
top: mid - barHeight / 2,
height: barHeight,
width: width * _fraction(widget.buffer),
child: Container(color: Colors.white24),
),
if (hoverX != null && !_dragging)
Positioned(
left: 0,
top: mid - barHeight / 2,
height: barHeight,
width: hoverX,
child: Container(color: Colors.white24),
),
Positioned(
left: 0,
top: mid - barHeight / 2,
height: barHeight,
width: width * played,
child: Container(color: primary),
),
for (final c in widget.chapters)
if (c.start > Duration.zero && widget.duration > Duration.zero)
Positioned(
key: ValueKey('chapter-mark-${c.index}'),
left: width * _fraction(c.start) - 4,
top: mid - 4,
width: 8,
height: 8,
child: IgnorePointer(
child: Transform.rotate(
angle: math.pi / 4,
child: Container(
margin: const EdgeInsets.all(1),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.black54, width: 0.8),
),
),
),
),
),
if (active)
Positioned(
left: width * played - 6,
top: mid - 6,
width: 12,
height: 12,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(color: primary, shape: BoxShape.circle),
),
),
),
if (hoverAt != null)
Positioned(
// Kept clear of the window edges (the tip is centred).
left: hoverX!.clamp(math.min(60.0, width / 2), math.max(width - 60, width / 2)),
bottom: widget.height - 2,
child: IgnorePointer(
child: FractionalTranslation(
translation: const Offset(-0.5, 0),
child: _HoverTip(title: hoverTitle, time: formatClock(hoverAt)),
),
),
),
],
),
),
),
);
},
);
}
}
class _HoverTip extends StatelessWidget {
final String title;
final String time;
const _HoverTip({required this.title, required this.time});
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxWidth: 320),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.8), borderRadius: BorderRadius.circular(6)),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (title.isNotEmpty)
Text(
title,
key: const ValueKey('seek-hover-chapter'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600),
),
Text(
time,
key: const ValueKey('seek-hover-time'),
style: const TextStyle(color: Colors.white70, fontSize: 11),
),
],
),
);
}
}
@@ -0,0 +1,340 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../../queue/queue_ui.dart';
import '../../shell/now_playing_title.dart';
import '../../state/settings.dart';
import '../control_menus.dart';
import '../controls_probe.dart' show ControlsHold;
import '../frame.dart';
import '../model/controls_layout.dart';
import '../model/controls_model.dart';
import '../volume.dart';
import 'seek_bar.dart';
// The Standard skin: Material-style bars over the picture. Top: what plays,
// Up next and the window controls; bottom: the seek bar (chapter marks,
// hover time) over a row of round icon buttons in the layout's order.
// Hidden, a thin progress line stays (unless switched off).
class StandardControls extends StatelessWidget {
final PlayerControlsModel model;
final bool visible;
final double scale;
final ControlsHold? hold;
/// Right of the top bar's own buttons (the window buttons in fullscreen).
final Widget? windowButtons;
const StandardControls({
super.key,
required this.model,
required this.visible,
this.scale = 1,
this.hold,
this.windowButtons,
});
/// The top bar: room for the cover (2:3, 60 px tall).
static const topHeight = 72.0;
static const _rowHeight = 56.0;
/// The bottom bar (seek bar and buttons) at [scale].
static double bottomHeight(double scale) => (_rowHeight + ChapterSeekBar.defaultHeight) * scale;
@override
Widget build(BuildContext context) {
const fade = ControlsFrame.fade;
Widget bar(Widget child) => IgnorePointer(
ignoring: !visible,
child: AnimatedOpacity(duration: fade, opacity: visible ? 1 : 0, child: child),
);
// No button here takes focus (Space is the ready key); the top bar's
// Up next keeps its own (its popover has a text field).
return Stack(
children: [
Positioned(
left: 0,
right: 0,
top: 0,
child: bar(
DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x99000000), Color(0x00000000)],
),
),
child: SizedBox(
key: const ValueKey('standard-top'),
height: topHeight,
child: Padding(padding: const EdgeInsets.only(left: 16, right: 8), child: _top()),
),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: bar(
DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x00000000), Color(0xB3000000)],
),
),
child: Padding(
padding: EdgeInsets.only(top: 24 * scale),
child: SizedBox(
key: const ValueKey('standard-bottom'),
height: bottomHeight(scale),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SignalBuilder(
builder: (context) => ChapterSeekBar(
position: model.position.value,
duration: model.duration.value,
buffer: model.buffered.value,
chapters: model.chapters.value,
height: ChapterSeekBar.defaultHeight * scale,
onSeek: model.seek,
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: ExcludeFocus(
child: SignalBuilder(
builder: (context) {
final s = model.snapshot.value;
final layout = model.bar.value;
return Row(
key: const ValueKey('standard-buttons'),
children: [
for (final c in layout.left) _control(context, c, s),
const Spacer(),
for (final c in layout.right) _control(context, c, s),
],
);
},
),
),
),
),
],
),
),
),
),
),
),
// Hidden: the thin progress line.
Positioned(
left: 0,
right: 0,
bottom: 0,
child: IgnorePointer(
child: SignalBuilder(
builder: (context) {
if (visible || !AppSettings.instance.progressLine.value) return const SizedBox.shrink();
final d = model.duration.value, p = model.position.value;
final v = d.inMilliseconds == 0 ? 0.0 : (p.inMilliseconds / d.inMilliseconds).clamp(0.0, 1.0);
return LinearProgressIndicator(
key: const ValueKey('progress-line-standard'),
value: v,
minHeight: 2,
backgroundColor: Colors.transparent,
);
},
),
),
),
],
);
}
Widget _top() {
final m = model;
return Row(
children: [
const Expanded(child: NowPlayingTitle()),
UpNextButton(queue: m.queue, onPlayNextEpisode: m.playNextEpisode, look: UpNextLook.standard, hold: hold),
SignalBuilder(
builder: (context) {
final full = m.fullscreen.value;
return ExcludeFocus(
child: _IconButton(
key: const ValueKey('standard-collapse'),
icon: full ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
tooltip: full ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
onPressed: full ? m.toggleFullscreen : m.collapse,
),
);
},
),
?windowButtons,
],
);
}
Widget _control(BuildContext context, BarControl c, ControlsSnapshot s) {
final m = model;
final key = ValueKey('control-${c.name}');
final enabled = controlEnabled(c, s);
Widget icon(VoidCallback? onPressed, {int badge = 0, double size = 24, Color? color}) => _IconButton(
key: key,
icon: controlIcon(c, s),
tooltip: controlTooltip(c, s),
badge: badge,
size: size * scale,
color: color,
onPressed: enabled ? onPressed : null,
);
switch (c) {
case BarControl.playPause:
final waiting = s.play == PlayState.waiting;
return icon(m.togglePlay, size: 30, color: waiting ? Theme.of(context).colorScheme.primary : null);
case BarControl.previous:
return icon(m.previous);
case BarControl.next:
return icon(m.next);
case BarControl.chapterPrevious:
return icon(() => m.stepChapter(-1));
case BarControl.chapterNext:
return icon(() => m.stepChapter(1));
case BarControl.volume:
return SignalBuilder(
key: key,
builder: (context) => VolumeControl(
volume: m.volume.value,
onChanged: m.setVolume,
onToggleMute: m.toggleMute,
button: (context, i, color, tip, onPressed) =>
_IconButton(icon: i, tooltip: tip, color: color, size: 24 * scale, onPressed: onPressed),
),
);
case BarControl.time:
return SignalBuilder(
key: key,
builder: (context) => _Clock(
text: clockText(m.position.value, m.duration.value, m.settings.timeDisplay.value),
tooltip: controlTooltip(c, s),
scale: scale,
onPressed: m.toggleTimeDisplay,
),
);
case BarControl.subtitles || BarControl.audio:
final type = c == BarControl.audio ? JellyfinStreamType.audio : JellyfinStreamType.subtitle;
return SignalBuilder(
key: key,
builder: (context) => Builder(
builder: (context) => _IconButton(
icon: controlIcon(c, s),
tooltip: controlTooltip(c, s),
badge: trackCount(m.tracks.value, type),
size: 22 * scale,
onPressed: () => openTrackMenu(context, m, type),
),
),
);
case BarControl.settings:
return Builder(
key: key,
builder: (context) => _IconButton(
icon: controlIcon(c, s),
tooltip: controlTooltip(c, s),
size: 22 * scale,
onPressed: () => openControlsMenu(context, m),
),
);
case BarControl.chat:
return SignalBuilder(
key: key,
builder: (context) => _IconButton(
icon: controlIcon(c, s),
tooltip: controlTooltip(c, s),
badge: m.unread.value,
size: 22 * scale,
onPressed: m.toggleChat,
),
);
case BarControl.fullscreen:
return icon(m.toggleFullscreen, size: 26);
}
}
}
/// A round white icon button with an optional count badge (the Material
/// look of media_kit's bar).
class _IconButton extends StatelessWidget {
final IconData icon;
final String tooltip;
final VoidCallback? onPressed;
final int badge;
final double size;
final Color? color;
const _IconButton({
super.key,
required this.icon,
required this.tooltip,
required this.onPressed,
this.badge = 0,
this.size = 24,
this.color,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return IconButton(
tooltip: tooltip,
iconSize: size,
color: color ?? Colors.white,
disabledColor: Colors.white38,
onPressed: onPressed,
icon: Badge(
isLabelVisible: badge > 0,
backgroundColor: scheme.primary,
textColor: scheme.onPrimary,
label: Text('$badge'),
child: Icon(icon),
),
);
}
}
/// The clock: "6:00 / 24:00" or "-18:00"; a click switches.
class _Clock extends StatelessWidget {
final String text;
final String tooltip;
final double scale;
final VoidCallback onPressed;
const _Clock({required this.text, required this.tooltip, required this.scale, required this.onPressed});
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
waitDuration: const Duration(milliseconds: 500),
child: TextButton(
style: TextButton.styleFrom(
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(horizontal: 10 * scale),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: onPressed,
child: Text(
text,
style: TextStyle(fontSize: 14 * scale, fontFeatures: const [FontFeature.tabularFigures()]),
),
),
);
}
}
@@ -1,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;
}
+58 -47
View File
@@ -3,7 +3,6 @@ import 'dart:async';
import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../playback/playback_controller.dart' show PlaybackController; import '../playback/playback_controller.dart' show PlaybackController;
import '../widgets/app_menu.dart' show AppGlass; import '../widgets/app_menu.dart' show AppGlass;
@@ -90,7 +89,7 @@ class VolumeFlash extends StatefulWidget {
final WidgetBuilder builder; final WidgetBuilder builder;
const VolumeFlash({super.key, required this.trigger, required this.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); static const hold = Duration(seconds: 1);
@override @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 /// (the player's pill look) with the icon, a [VolumeBar] and the
/// percentage. /// percentage.
class VolumeReadout extends StatelessWidget { class VolumeReadout extends StatelessWidget {
@@ -159,11 +158,7 @@ class VolumeReadout extends StatelessWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(volumeIcon(v), size: 20, color: v > 100 ? boostColor : Colors.white),
v <= 0 ? Icons.volume_off : (v < 50 ? Icons.volume_down : Icons.volume_up),
size: 20,
color: v > 100 ? boostColor : Colors.white,
),
const SizedBox(width: 8), const SizedBox(width: 8),
VolumeBar(volume: v, onChanged: (_) {}, width: 96), VolumeBar(volume: v, onChanged: (_) {}, width: 96),
const SizedBox(width: 10), 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; 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 /// The speaker icon for [volume].
/// 100): the icon mutes / unmutes, hovering shows a [VolumeBar], the IconData volumeIcon(double volume) =>
/// wheel steps [volumeStep]. volume <= 0 ? Icons.volume_off_rounded : (volume < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded);
class VolumeButton extends StatefulWidget {
final Player player; /// Builds a skin's button for the [VolumeControl].
final FocusNode? focusNode; typedef VolumeButtonBuilder = Widget Function(
const VolumeButton({super.key, required this.player, this.focusNode}); BuildContext context,
IconData icon,
Color? color,
String tooltip,
VoidCallback onPressed,
);
/// The bar's volume, in both skins: the skin's speaker button (click
/// mutes / unmutes), a [VolumeBar] sliding out on hover, the wheel on it
/// stepping [volumeStep] (claimed, so the player's wheel skips it).
class VolumeControl extends StatefulWidget {
/// 0..[maxVolume].
final double volume;
final ValueChanged<double> onChanged;
final VoidCallback onToggleMute;
final VolumeButtonBuilder button;
final Color sliderColor;
final Color trackColor;
final double sliderWidth;
const VolumeControl({
super.key,
required this.volume,
required this.onChanged,
required this.onToggleMute,
required this.button,
this.sliderColor = Colors.white,
this.trackColor = Colors.white30,
this.sliderWidth = 72,
});
@override @override
State<VolumeButton> createState() => _VolumeButtonState(); State<VolumeControl> createState() => _VolumeControlState();
} }
class _VolumeButtonState extends State<VolumeButton> { class _VolumeControlState extends State<VolumeControl> {
final _steps = WheelSteps(); final _steps = WheelSteps();
bool _hover = false; bool _hover = false;
double _beforeMute = 100;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final player = widget.player; final v = widget.volume;
return StreamBuilder<double>(
stream: player.stream.volume,
initialData: player.state.volume,
builder: (context, snap) {
final v = snap.data ?? 100;
return MouseRegion( return MouseRegion(
onEnter: (_) => setState(() => _hover = true), onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false), onExit: (_) => setState(() => _hover = false),
child: Listener( child: Listener(
// Claimed here, so the player's own wheel handler skips it. onPointerSignal: (e) => _steps.handle(e, (step) => widget.onChanged(stepVolume(v, step * volumeStep))),
onPointerSignal: (e) =>
_steps.handle(e, (step) => player.setVolume(stepVolume(player.state.volume, step * volumeStep))),
child: Row( child: Row(
key: const ValueKey('volume-control'),
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox(width: 4), widget.button(
IconButton( context,
key: const ValueKey('volume-button'), volumeIcon(v),
focusNode: widget.focusNode, v > 100 ? boostColor : null,
tooltip: 'Volume ${v.round()} %', 'Volume ${v.round()} % (scroll, ↑ / ↓)',
iconSize: 22, widget.onToggleMute,
color: Colors.white,
onPressed: () {
if (v > 0) {
_beforeMute = v;
player.setVolume(0);
} else {
player.setVolume(_beforeMute <= 0 ? 100 : _beforeMute);
}
},
icon: Icon(v <= 0 ? Icons.volume_off : (v < 50 ? Icons.volume_down : Icons.volume_up)),
), ),
AnimatedSize( AnimatedSize(
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
child: _hover child: _hover
? Padding( ? Padding(
padding: const EdgeInsets.only(left: 4, right: 12), padding: const EdgeInsets.only(left: 4, right: 8),
child: VolumeBar(volume: v, onChanged: player.setVolume), child: VolumeBar(
key: const ValueKey('volume-bar'),
volume: v,
onChanged: widget.onChanged,
width: widget.sliderWidth,
color: widget.sliderColor,
trackColor: widget.trackColor,
),
) )
: const SizedBox.shrink(), : const SizedBox.shrink(),
), ),
@@ -311,7 +324,5 @@ class _VolumeButtonState extends State<VolumeButton> {
), ),
), ),
); );
},
);
} }
} }
+25 -2
View File
@@ -33,15 +33,36 @@ const _timeout = Duration(seconds: 20);
int _running = 0; int _running = 0;
final _waiting = <Completer<void>>[]; final _waiting = <Completer<void>>[];
final _inFlight = <String, Future<Thumbnail?>>{}; 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 /// 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). /// when it can't be made (not a video, unreadable, timed out, cancelled).
/// Never throws. [cancelled] is checked while queued and before the /// Never throws. [cancelled] is checked while queued and before the
/// expensive steps: a scrolled-away tile returns null without decoding. /// expensive steps: a scrolled-away tile returns null without decoding.
Future<Thumbnail?> thumbnailFor(String path, {bool Function()? cancelled}) { Future<Thumbnail?> thumbnailFor(String path, {bool Function()? cancelled}) {
if (_closed) return Future.value(null);
final existing = _inFlight[path]; final existing = _inFlight[path];
if (existing != null) return existing; if (existing != null) return existing;
final f = _thumbnailFor(path, cancelled ?? () => false).catchError(( final f = _thumbnailFor(path, () => _closed || (cancelled?.call() ?? false)).catchError((
Object e, Object e,
StackTrace st, StackTrace st,
) { ) {
@@ -136,6 +157,7 @@ Future<Thumbnail?> _generate(
bufferSize: 4 << 20, bufferSize: 4 << 20,
), ),
); );
_players.add(player);
try { try {
final mpv = player.platform as NativePlayer; final mpv = player.platform as NativePlayer;
for (final (k, v) in [ for (final (k, v) in [
@@ -176,6 +198,7 @@ Future<Thumbnail?> _generate(
} }
return null; return null;
} finally { } finally {
await player.dispose(); // [cancelAllThumbnails] may have taken it already.
if (_players.remove(player)) await player.dispose();
} }
} }
+11 -12
View File
@@ -1,11 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import '../../shell/motion.dart'; import '../../shell/motion.dart';
import '../../platform/ports/folder_access.dart';
import '../../state/session_state.dart';
import '../../widgets/app_menu.dart'; import '../../widgets/app_menu.dart';
import '../folder_store.dart'; import '../folder_store.dart';
import '../name_parser.dart'; import '../name_parser.dart';
@@ -32,6 +33,8 @@ String? _lastOpen;
final _expanded = <String>{}; final _expanded = <String>{};
final _showRequests = ValueNotifier(0); final _showRequests = ValueNotifier(0);
FolderAccess get _folders => SessionState.instance.ports.folders;
/// Opens [dir] (null: Recent) in the Files page: now if one is showing, /// Opens [dir] (null: Recent) in the Files page: now if one is showing,
/// else when it next opens. /// else when it next opens.
void showFilesFolder(String? dir) { void showFilesFolder(String? dir) {
@@ -60,7 +63,7 @@ class FilesPage extends StatefulWidget {
final String? nowPlayingPath; final String? nowPlayingPath;
/// Defaults to [thumbnailFor]. /// Defaults to [thumbnailFor].
final ThumbnailLoader? thumbnails; final ThumbnailLoader? thumbnails;
/// Defaults to [scanFolder]. /// Defaults to [FolderAccess.scan].
final Future<FolderScan> Function(String dir)? scan; final Future<FolderScan> Function(String dir)? scan;
/// Defaults to [FolderStore.instance]. /// Defaults to [FolderStore.instance].
final FolderStore? store; final FolderStore? store;
@@ -142,7 +145,7 @@ class _FilesPageState extends State<FilesPage> {
void _shown() => _select(_lastOpen); 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) { void _select(String? dir) {
setState(() { setState(() {
@@ -176,7 +179,7 @@ class _FilesPageState extends State<FilesPage> {
String? dir; String? dir;
try { try {
dir = await (widget.pickFolder ?? dir = await (widget.pickFolder ??
() => FilePicker.getDirectoryPath(dialogTitle: 'Add a folder with videos'))(); () => _folders.pickFolder(dialogTitle: 'Add a folder with videos'))();
} catch (e) { } catch (e) {
_showError('Folder dialog unavailable (${e.runtimeType})'); _showError('Folder dialog unavailable (${e.runtimeType})');
} }
@@ -196,12 +199,8 @@ class _FilesPageState extends State<FilesPage> {
} }
static Future<String?> _pickVideo() async { static Future<String?> _pickVideo() async {
final files = await FilePicker.pickFiles( final files = await _folders.pickFiles(dialogTitle: 'Pick a video', extensions: videoExtensions.toList());
dialogTitle: 'Pick a video', return files.firstOrNull;
type: FileType.custom,
allowedExtensions: videoExtensions.toList(),
);
return files.isEmpty ? null : files.first.path;
} }
void _removeFolder(String dir) { void _removeFolder(String dir) {
@@ -589,7 +588,7 @@ class _FilesPageState extends State<FilesPage> {
), ),
if (scan.videos.isEmpty) if (scan.videos.isEmpty)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Directory(dir).existsSync() child: _folders.folderExists(dir)
? const FilesEmpty( ? const FilesEmpty(
Icons.search_off, Icons.search_off,
'No videos here', 'No videos here',
@@ -701,7 +700,7 @@ class _RecentRowState extends State<_RecentRow> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
File(widget.path).stat().then((s) { _folders.stat(widget.path).then((s) {
if (mounted) setState(() => _stat = s); if (mounted) setState(() => _stat = s);
}, onError: (Object _) {}); }, onError: (Object _) {});
} }
+1 -2
View File
@@ -5,7 +5,6 @@ import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../playback/yt_dlp_app.dart';
import '../screens/home/ui.dart' show appName, displayVersion; import '../screens/home/ui.dart' show appName, displayVersion;
import '../state/session_state.dart'; import '../state/session_state.dart';
@@ -59,7 +58,7 @@ Future<String> diagnosticsText({Duration since = const Duration(hours: 6), bool
b.writeln('mpv: unknown'); b.writeln('mpv: unknown');
} }
try { 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); final cookies = y.cookies.read(count: false);
b.writeln('yt-dlp: ${y.version ?? 'not ready'}${y.lastError == null ? '' : ' (${y.lastError})'}, ' b.writeln('yt-dlp: ${y.version ?? 'not ready'}${y.lastError == null ? '' : ' (${y.lastError})'}, '
'cookies: ${cookies == null ? 'none' : 'set'}'); 'cookies: ${cookies == null ? 'none' : 'set'}');
@@ -0,0 +1,68 @@
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
/// (others see "left", a hosted server stops), runs [beforeQuit], then
/// closes the window. Every step is bounded; a second call does nothing.
Future<void> shutDownApp({Future<void> Function()? beforeQuit}) 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 goodbye, then the hosted
// server: 3 s in all.
await _step('leave', session.leave(), 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,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,133 @@
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);
}
@override
Future<void> startDragging() => windowManager.startDragging();
@override
Future<void> requestClose() => windowManager.close();
@override
Future<void> saveWindow() async => _memory?.save();
@override
Future<void> hide() => 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);
}
@@ -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;
}
}
}
@@ -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,28 @@
import 'folder_access.dart';
import 'link_resolver.dart';
import 'shell_port.dart';
import 'system_notifications.dart';
import 'url_opener.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,54 @@
import 'package:flutter/widgets.dart';
/// 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);
/// 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; static const readAheadSecs = 150;
/// Forward demuxer cache. Holds [readAheadSecs] up to ~14 Mbit/s (a /// 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: /// 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; static const maxBytes = 256 * 1024 * 1024;
/// Back buffer (media_kit sets it to bufferSize too): enough for a short /// Back buffer (media_kit sets it to bufferSize too): enough for a short
/// seek back without holding another 256 MiB. /// seek back without holding another 256 MiB.
@@ -149,6 +151,9 @@ class PlaybackController {
/// `demuxer-cache-time`, media_kit's `buffer`), in 250 ms steps. /// `demuxer-cache-time`, media_kit's `buffer`), in 250 ms steps.
final buffered = signal(Duration.zero); final buffered = signal(Duration.zero);
/// The player's volume, 0..[maxVolume] (media_kit's `volume`).
final volume = signal(100.0);
/// The opening ([openingRange]: Intro segment, else an OP-titled /// The opening ([openingRange]: Intro segment, else an OP-titled
/// chapter) and the ending ([endingRange]: Outro, else ED-titled). /// chapter) and the ending ([endingRange]: Outro, else ED-titled).
late final Computed<SkipRange?> opening = late final Computed<SkipRange?> opening =
@@ -172,6 +177,7 @@ class PlaybackController {
final existing = _video; final existing = _video;
if (existing != null) return existing; if (existing != null) return existing;
final hwdec = _hwdecOverride; final hwdec = _hwdecOverride;
_logRenderEnv();
_requestLog(player, 'v'); // back to [logLevel] by _detectRenderMode _requestLog(player, 'v'); // back to [logLevel] by _detectRenderMode
// media_kit's video output sets hwdec itself on attach ('auto' unless // media_kit's video output sets hwdec itself on attach ('auto' unless
// configured), which used to override SYNC_RELAY_HWDEC. // configured), which used to override SYNC_RELAY_HWDEC.
@@ -250,6 +256,7 @@ class PlaybackController {
await _pin('cache-secs', '$readAheadSecs', on); await _pin('cache-secs', '$readAheadSecs', on);
await _pin('demuxer-max-bytes', '$ahead', on); await _pin('demuxer-max-bytes', '$ahead', on);
await _pin('demuxer-max-back-bytes', '$back', on); await _pin('demuxer-max-back-bytes', '$back', on);
await _pin('cache-on-disk', 'no', on);
final hwdec = _hwdecOverride; final hwdec = _hwdecOverride;
if (hwdec != null) { if (hwdec != null) {
await _pin('hwdec', hwdec, on); await _pin('hwdec', hwdec, on);
@@ -307,13 +314,12 @@ class PlaybackController {
} }
if (!identical(p, _player)) return; if (!identical(p, _player)) return;
if (problem != null) paths = const []; if (problem != null) paths = const [];
final value = joinShaderPaths(paths); if (joinShaderPaths(paths) != _shaders || problem != null) {
if (value != _shaders || problem != null) { final took = await setShaderPaths(paths);
final got = await setShaderValue(value);
if (!identical(p, _player)) return; if (!identical(p, _player)) return;
if (problem == null && got != value) { if (problem == null && !took) {
problem = 'mpv did not take them'; problem = 'mpv did not take them';
await setShaderValue(''); await setShaderPaths(const []);
} }
} }
if (problem != null) { if (problem != null) {
@@ -323,20 +329,23 @@ class PlaybackController {
} }
} }
/// Sets mpv `glsl-shaders` on the current player and returns the /// Sets mpv `glsl-shaders` to [paths] on the current player; true when
/// read-back (null on error). /// mpv's read-back is that list ([shaderListMatches]: old mpv prints it
/// joined with ',').
@visibleForTesting @visibleForTesting
Future<String?> setShaderValue(String value) async { Future<bool> setShaderPaths(List<String> paths) async {
final value = joinShaderPaths(paths);
try { try {
final dynamic platform = player.platform; final dynamic platform = player.platform;
await platform?.setProperty('glsl-shaders', value); await platform?.setProperty('glsl-shaders', value);
final got = '${await platform?.getProperty('glsl-shaders')}'; final got = '${await platform?.getProperty('glsl-shaders')}';
_shaders = got; final took = shaderListMatches(paths, got);
if (got != value) debugPrint('glsl-shaders=$value not applied: "$got"'); _shaders = took ? value : got;
return got; if (!took) debugPrint('glsl-shaders=$value not applied: "$got"');
return took;
} catch (e, st) { } catch (e, st) {
debugPrint('glsl-shaders=$value failed: $e\n$st'); debugPrint('glsl-shaders=$value failed: $e\n$st');
return null; return false;
} }
} }
@@ -387,6 +396,9 @@ class PlaybackController {
return; return;
} }
if (log.prefix != 'libmpv_render') 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')) { if (log.text.contains('suspected software renderer')) {
_setRenderMode(RenderMode.softwareGl); _setRenderMode(RenderMode.softwareGl);
} else if (log.text.startsWith('GL_VERSION') && } else if (log.text.startsWith('GL_VERSION') &&
@@ -399,7 +411,7 @@ class PlaybackController {
_shaders = ''; _shaders = '';
_shaderOp = _shaderOp.then((_) async { _shaderOp = _shaderOp.then((_) async {
if (!identical(p, _player)) return; if (!identical(p, _player)) return;
await setShaderValue(''); await setShaderPaths(const []);
_turnShadersOff('Shaders failed in mpv (${log.text}) — turned off'); _turnShadersOff('Shaders failed in mpv (${log.text}) — turned off');
}); });
} }
@@ -411,6 +423,52 @@ class PlaybackController {
static final _mpvLog = createLogger('mpv'); static final _mpvLog = createLogger('mpv');
static final _mpvThrottle = LogThrottle(burst: 5); 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 = ''}) { static void _toAppLog(PlayerLog log, {String player = ''}) {
final level = log.level; final level = log.level;
if (level != 'warn' && level != 'error' && level != 'fatal') return; if (level != 'warn' && level != 'error' && level != 'fatal') return;
@@ -448,11 +506,15 @@ class PlaybackController {
} finally { } finally {
if (identical(p, _player)) _requestLog(p, logLevel); 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; return;
} }
_setRenderMode(RenderMode.software); _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) { if (_shaders.isNotEmpty) {
_notice('Shaders have no effect: video uses software rendering'); _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). // track-list observed in mpv (read back as JSON).
void _watchMarks(SyncPlayer p) { void _watchMarks(SyncPlayer p) {
volume.value = p.state.volume;
_timeSubs.add(p.stream.volume.listen((v) => volume.value = v));
_timeSubs.add(p.stream.position.listen((d) { _timeSubs.add(p.stream.position.listen((d) {
// At the end the bar shows full: mpv's last position is the last // At the end the bar shows full: mpv's last position is the last
// frame's start (and the 250 ms steps round down), short of duration. // frame's start (and the 250 ms steps round down), short of duration.
@@ -520,6 +584,8 @@ class PlaybackController {
_timeSubs.add(p.stream.completed.listen((done) { _timeSubs.add(p.stream.completed.listen((done) {
if (done && duration.value > Duration.zero) position.value = duration.value; 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) { _timeSubs.add(p.stream.buffer.listen((d) {
final ms = d.inMilliseconds; final ms = d.inMilliseconds;
buffered.value = Duration(milliseconds: ms - ms % 250); buffered.value = Duration(milliseconds: ms - ms % 250);
@@ -686,6 +752,24 @@ class PlaybackController {
} catch (_) {} } 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 // 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` // the length of a route pop (the fullscreen route holds its own `Video`
// and leaves animated). Bounded, since a hidden window renders no // 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:path_provider/path_provider.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../platform/ports/link_resolver.dart';
/// mpv's `ytdl-format` for links every player opens itself. /// mpv's `ytdl-format` for links every player opens itself.
const ytdlFormat = 'bv*[height<=1080]+ba/b'; const ytdlFormat = 'bv*[height<=1080]+ba/b';
@@ -16,13 +18,14 @@ const ytdlSubLangs = 'all,-danmaku';
/// The app's yt-dlp (see [YtDlp]): in the app-support folder, shared with /// The app's yt-dlp (see [YtDlp]): in the app-support folder, shared with
/// the hosted server. [probe] remembers what pasted links are for a while, /// the hosted server. [probe] remembers what pasted links are for a while,
/// so the Link preview and the swap ask once. /// so the Link preview and the swap ask once.
class AppYtDlp { class AppYtDlp implements LinkResolver {
static final instance = AppYtDlp(); static final instance = AppYtDlp();
/// [ytDlp]: a given one (tests); default in the app-support folder. /// [ytDlp]: a given one (tests); default in the app-support folder.
AppYtDlp([YtDlp? ytDlp]) : _ytDlp = ytDlp == null ? null : Future.value(ytDlp); AppYtDlp([YtDlp? ytDlp]) : _ytDlp = ytDlp == null ? null : Future.value(ytDlp);
Future<YtDlp>? _ytDlp; Future<YtDlp>? _ytDlp;
@override
Future<YtDlp> get ytDlp => _ytDlp ??= () async { Future<YtDlp> get ytDlp => _ytDlp ??= () async {
final base = await getApplicationSupportDirectory(); final base = await getApplicationSupportDirectory();
return YtDlp(dir(base.path)); return YtDlp(dir(base.path));
@@ -32,10 +35,12 @@ class AppYtDlp {
/// Gets yt-dlp ready in the background (download on first run, a daily /// Gets yt-dlp ready in the background (download on first run, a daily
/// update check afterwards). /// update check afterwards).
@override
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null)); void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
/// mpv's `ytdl-raw-options`: [ytdlSubLangs] and the cookies in the /// mpv's `ytdl-raw-options`: [ytdlSubLangs] and the cookies in the
/// yt-dlp folder (see [YtDlpCookies]). /// yt-dlp folder (see [YtDlpCookies]).
@override
Future<String> mpvRawOptions() async { Future<String> mpvRawOptions() async {
const subs = 'sub-langs=%${ytdlSubLangs.length}%$ytdlSubLangs'; const subs = 'sub-langs=%${ytdlSubLangs.length}%$ytdlSubLangs';
try { try {
@@ -54,6 +59,7 @@ class AppYtDlp {
/// is probed again before that runs out). [fresh] probes again: the /// is probed again before that runs out). [fresh] probes again: the
/// remembered result stays until the new one is in, and stays when the /// remembered result stays until the new one is in, and stays when the
/// new one fails. /// new one fails.
@override
Future<LinkProbe> probe(String url, {Duration keep = _probeTtl, bool fresh = false}) { Future<LinkProbe> probe(String url, {Duration keep = _probeTtl, bool fresh = false}) {
final now = DateTime.now(); final now = DateTime.now();
_probes.removeWhere((_, v) => now.isAfter(v.until)); _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. /// [url] no longer needs keeping longer: back to the usual 10 minutes.
@override
void release(String url) { void release(String url) {
final hit = _probes[url]; final hit = _probes[url];
if (hit == null) return; if (hit == null) return;
@@ -13,6 +13,7 @@ import '../jellyfin/accounts.dart';
import '../jellyfin/format.dart' show pickFor; import '../jellyfin/format.dart' show pickFor;
import '../jellyfin/session_media.dart'; import '../jellyfin/session_media.dart';
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
import '../session/app_events.dart' show AddedToUpNext;
import '../session/media_art.dart'; import '../session/media_art.dart';
import '../session/session_coordinator.dart'; import '../session/session_coordinator.dart';
import '../state/settings.dart'; import '../state/settings.dart';
@@ -42,7 +43,7 @@ class QueueController {
QueueController(this.coord); QueueController(this.coord);
/// Queued items' pictures by hash, from `/i/`. /// 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]); 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). /// Up next, in order (empty when nothing is queued or not in a server).
@@ -76,13 +77,12 @@ class QueueController {
String _continuationFor = ''; String _continuationFor = '';
final _random = Random.secure(); final _random = Random.secure();
String get _me => coord.sync.memberId; String get _me => coord.connection.memberId;
void attach() { void attach() {
if (_attached) return; if (_attached) return;
_attached = true; _attached = true;
coord.sync.states.listen(_onState); coord.connection.states.listen(_onState);
coord.sync.feeds.listen(_onFeed);
effect(() { effect(() {
final next = coord.nextEpisode.value; final next = coord.nextEpisode.value;
final plan = coord.jellyfinPlan.value; final plan = coord.jellyfinPlan.value;
@@ -121,8 +121,8 @@ class QueueController {
final item = built.item; final item = built.item;
if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now()); if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now());
final thumb = built.thumb; final thumb = built.thumb;
coord.sync.sendQueue(QueueIntent.add(item, next: next, thumb: thumb == null ? '' : base64Encode(thumb))); coord.connection.sendQueue(QueueIntent.add(item, next: next, thumb: thumb == null ? '' : base64Encode(thumb)));
coord.showToast('Added ${item.title} to Up next'); coord.notify(AddedToUpNext(item.title));
return null; return null;
} }
@@ -288,12 +288,12 @@ class QueueController {
void move(String id, int to) { void move(String id, int to) {
coord.markInput(); coord.markInput();
coord.sync.sendQueue(QueueIntent.move(id, to)); coord.connection.sendQueue(QueueIntent.move(id, to));
} }
void remove(String id) { void remove(String id) {
coord.markInput(); 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, /// Plays [id] now: this app puts it up when it is the one that can,
@@ -306,7 +306,7 @@ class QueueController {
if (queuePerformer(item, r.members).id == _me) { if (queuePerformer(item, r.members).id == _me) {
_perform(item); _perform(item);
} else { } else {
coord.sync.sendQueue(QueueIntent.play(id)); coord.connection.sendQueue(QueueIntent.play(id));
} }
} }
@@ -369,12 +369,6 @@ class QueueController {
_warm(r); _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 /// Forgets what no longer matters: performing marks after a minute (a
/// swap that never landed may be tried again), local picks no longer /// swap that never landed may be tried again), local picks no longer
/// queued. /// queued.
@@ -394,7 +388,7 @@ class QueueController {
if (perf.id == null) { if (perf.id == null) {
final why = perf.why; final why = perf.why;
if (why != null && queueJanitor(r.members) == _me && _skipped.add(item.id)) { 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; return;
} }
@@ -467,7 +461,7 @@ class QueueController {
} }
if (error != null) { if (error != null) {
debugPrint('queue: ${item.id} cannot go up: $error'); 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)"));
} }
}()); }());
} }
@@ -621,7 +615,7 @@ class QueueController {
final head = r?.queue.firstOrNull; final head = r?.queue.firstOrNull;
final relay = head?.media.relay; final relay = head?.media.relay;
if (r == null || head == null || relay == null || relay.fetch) return; 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; if (key == _fedWarm) return;
switch (head.how) { switch (head.how) {
case QueueHow.through: case QueueHow.through:
+28 -45
View File
@@ -3,15 +3,12 @@ import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget;
import 'package:leaf_widgets/leaf_widgets.dart' show LeafPopover, LeafPopoverController; import 'package:leaf_widgets/leaf_widgets.dart' show LeafPopover, LeafPopoverController;
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../controls/controls_probe.dart' show ControlsHold; import '../controls/controls_probe.dart' show ControlsHold;
import '../controls/uosc/buttons.dart' show UoscButton; import '../controls/minimal/style.dart';
import '../controls/uosc/controls.dart' show UoscControls;
import '../controls/uosc/style.dart';
import '../files/scanner.dart' show FileKind, kindOf; import '../files/scanner.dart' show FileKind, kindOf;
import '../jellyfin/images.dart'; import '../jellyfin/images.dart';
import '../jellyfin/session_media.dart' show EpisodeLink; import '../jellyfin/session_media.dart' show EpisodeLink;
@@ -28,16 +25,16 @@ import '../widgets/text_focus.dart' show KeyboardClaim;
import 'queue_controller.dart'; import 'queue_controller.dart';
import 'queue_logic.dart'; import 'queue_logic.dart';
// Up next in the player: its control in the top bar (with chat and // Up next in the player: its control in the top bar (with the window
// fullscreen), the popover it opens (the queue, the continuation, a field // 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. // and a drop zone to add), and the end-of-media card for the first item.
// ---------------------------------------------------------------- button // ---------------------------------------------------------------- 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. /// 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 ⏭ /// 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 /// (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; bool _hoverTitle = false;
QueueController get _q => widget.queue; QueueController get _q => widget.queue;
bool get _uosc => widget.look == UpNextLook.uosc; bool get _minimal => widget.look == UpNextLook.minimal;
@override @override
void didUpdateWidget(UpNextButton oldWidget) { void didUpdateWidget(UpNextButton oldWidget) {
@@ -135,16 +132,16 @@ class _UpNextButtonState extends State<UpNextButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// The bar's own metrics: uosc's flat 40 px boxes, or media_kit's // The bar's own metrics: the Minimal skin's rounded squares, or the
// round 44 px icon buttons. // Standard skin's round 44 px icon buttons.
final height = _uosc ? UoscControls.buttonRowHeight : 44.0; final height = _minimal ? MinimalStyle.button + 2 * MinimalStyle.capsulePad : 44.0;
final radius = _uosc ? UoscStyle.radius : height / 2; final radius = _minimal ? MinimalStyle.radius + MinimalStyle.capsulePad : height / 2;
final hover = _uosc ? UoscStyle.hover : Colors.white10; final hover = _minimal ? MinimalStyle.hover : Colors.white10;
final fontSize = _uosc ? UoscStyle.fontSize : 14.0; final fontSize = _minimal ? MinimalStyle.fontSize : 14.0;
final dim = _uosc ? UoscStyle.dim : Colors.white70; final dim = _minimal ? MinimalStyle.dim : Colors.white70;
// The picture fills the button's height but for a 7 px margin. // The picture fills the button's height but for a 7 px margin.
final thumbWidth = (height - 14) * 16 / 9; 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) { return SignalBuilder(builder: (context) {
final items = _q.items.value; final items = _q.items.value;
final cont = _q.shownContinuation.value; final cont = _q.shownContinuation.value;
@@ -197,7 +194,7 @@ class _UpNextButtonState extends State<UpNextButton> {
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
height: height, height: height,
// The picture clears a round button's curve. // 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( decoration: BoxDecoration(
color: _hoverTitle || open ? hover : Colors.transparent, color: _hoverTitle || open ? hover : Colors.transparent,
borderRadius: BorderRadius.circular(radius), borderRadius: BorderRadius.circular(radius),
@@ -234,7 +231,7 @@ class _UpNextButtonState extends State<UpNextButton> {
const SizedBox(width: 8), const SizedBox(width: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), 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)), child: Text('+$more', style: TextStyle(color: dim, fontSize: 11)),
), ),
], ],
@@ -254,8 +251,8 @@ class _UpNextButtonState extends State<UpNextButton> {
? 'Play ${item!.title} now ($playNextKeys)' ? 'Play ${item!.title} now ($playNextKeys)'
: 'Play ${episode == null ? '' : episode.label} now ($playNextKeys)'; : 'Play ${episode == null ? '' : episode.label} now ($playNextKeys)';
final VoidCallback? play = queued ? _q.skip : (episode != null ? widget.onPlayNextEpisode : null); final VoidCallback? play = queued ? _q.skip : (episode != null ? widget.onPlayNextEpisode : null);
final playButton = _uosc final playButton = _minimal
? UoscButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play) ? MinimalButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play)
: Tooltip( : Tooltip(
message: playTip, message: playTip,
waitDuration: const Duration(milliseconds: 500), waitDuration: const Duration(milliseconds: 500),
@@ -347,7 +344,7 @@ class UpNextPreview extends StatelessWidget {
const picture = _width - 2 * _pad; const picture = _width - 2 * _pad;
if (item != null) { if (item != null) {
final r = queue.coord.serverState.value; 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 more = queue.items.value.length - 1;
final ready = warm?.id == item.id ? warm!.state : null; final ready = warm?.id == item.id ? warm!.state : null;
return Column( return Column(
@@ -452,7 +449,7 @@ class _KeyCap extends StatelessWidget {
String? performerNote(QueueController queue, QueueItem item, StateBroadcast? r) { String? performerNote(QueueController queue, QueueItem item, StateBroadcast? r) {
if (r == null) return null; if (r == null) return null;
final id = queuePerformer(item, r.members).id; 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; final name = r.members.where((m) => m.id == id).firstOrNull?.name;
return name == null ? null : '$name puts it up'; return name == null ? null : '$name puts it up';
} }
@@ -642,23 +639,9 @@ class _QueuePanelState extends State<QueuePanel> {
return KeyEventResult.ignored; 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. /// Ctrl+V: the clipboard's video files, else the links in its text.
Future<void> _paste() async { 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+'))]); final picks = _picksFrom([...clip.files, ...?clip.text?.split(RegExp(r'\s+'))]);
if (!mounted) return; if (!mounted) return;
if (picks.isEmpty) { if (picks.isEmpty) {
@@ -831,12 +814,12 @@ class _QueuePanelState extends State<QueuePanel> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final height = MediaQuery.sizeOf(context).height; final height = MediaQuery.sizeOf(context).height;
final drop = DropTarget( final drop = SessionState.instance.ports.shell.dropTarget(
onDragEntered: (_) => setState(() => _dragging = true), onEnter: () => setState(() => _dragging = true),
onDragExited: (_) => setState(() => _dragging = false), onExit: () => setState(() => _dragging = false),
onDragDone: (d) { onDrop: (paths, _) {
setState(() => _dragging = false); setState(() => _dragging = false);
_dropped(DesktopDropX.pathsOf(d.files)); _dropped(paths);
}, },
// Esc: the popover's own (it fades out). // Esc: the popover's own (it fades out).
child: Builder( child: Builder(
@@ -858,7 +841,7 @@ class _QueuePanelState extends State<QueuePanel> {
final cont = _q.shownContinuation.value; final cont = _q.shownContinuation.value;
final warm = _q.warm.value; final warm = _q.warm.value;
final r = _q.coord.serverState.value; final r = _q.coord.serverState.value;
final selfId = _q.coord.sync.memberId; final selfId = _q.coord.connection.memberId;
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -1575,7 +1558,7 @@ class UpNextCard extends StatelessWidget {
final item = c.item; final item = c.item;
final left = c.secondsLeft; final left = c.secondsLeft;
final r = queue.coord.serverState.value; 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; final waiting = left == null ? performerNote(queue, item, r) : null;
return AppGlass( return AppGlass(
key: const ValueKey('up-next-card'), key: const ValueKey('up-next-card'),
@@ -12,6 +12,10 @@ import 'settings_page.dart';
import 'ui.dart'; import 'ui.dart';
import 'update_indicator.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 → /// Name, version, update state, the source link and the logs (Settings →
/// About). /// About).
class AboutPanel extends StatelessWidget { 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), const SizedBox(height: kGap),
Text('Something went wrong? Copy diagnostics and send them to whoever helps you: ' 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. ' '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 { static Future<void> _openLogs() async {
final dir = appLogDir; final dir = appLogDir;
if (dir == null) return; if (dir == null) return;
// The file manager opens a folder path the way a browser opens a link. if (!await SessionState.instance.ports.folders.reveal(dir)) {
if (!await openInBrowser(Uri.directory(dir).toString())) {
await copyToClipboard(dir, done: "Couldn't open it — folder path copied"); await copyToClipboard(dir, done: "Couldn't open it — folder path copied");
} }
} }
@@ -1,7 +1,7 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import '../../state/session_state.dart';
import '../../state/settings.dart'; import '../../state/settings.dart';
import '../../update/update_controller.dart'; import '../../update/update_controller.dart';
import 'ui.dart'; import 'ui.dart';
@@ -89,12 +89,11 @@ class CustomShaderFiles extends StatelessWidget {
const CustomShaderFiles({super.key}); const CustomShaderFiles({super.key});
Future<void> _addFiles() async { Future<void> _addFiles() async {
final List<PlatformFile> files; final List<String> files;
try { try {
files = await FilePicker.pickFiles( files = await SessionState.instance.ports.folders.pickFiles(
dialogTitle: 'Pick mpv GLSL shaders', dialogTitle: 'Pick mpv GLSL shaders',
type: FileType.custom, extensions: const ['glsl', 'hook'],
allowedExtensions: const ['glsl', 'hook'],
); );
} catch (_) { } catch (_) {
return; return;
@@ -102,8 +101,8 @@ class CustomShaderFiles extends StatelessWidget {
final s = AppSettings.instance; final s = AppSettings.instance;
final current = s.customShaders.peek(); final current = s.customShaders.peek();
final added = [ final added = [
for (final f in files) for (final p in files)
if (f.path case final p? when !current.contains(p)) p, if (!current.contains(p)) p,
]; ];
if (added.isNotEmpty) s.setCustomShaders([...current, ...added]); if (added.isNotEmpty) s.setCustomShaders([...current, ...added]);
} }
@@ -198,8 +197,8 @@ class UpdateSettings extends StatelessWidget {
builder: (context) => SwitchListTile( builder: (context) => SwitchListTile(
key: const ValueKey('auto-check-updates'), key: const ValueKey('auto-check-updates'),
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: const Text('Check for updates on start'), title: const Text('Check for updates automatically'),
subtitle: const Text('Looks for a new version when the app opens; nothing installs without asking'), subtitle: const Text('When the app opens and every few hours; nothing installs without asking'),
value: s.autoCheckUpdates.value, value: s.autoCheckUpdates.value,
onChanged: s.setAutoCheckUpdates, onChanged: s.setAutoCheckUpdates,
), ),
@@ -22,6 +22,10 @@ String displayVersion({String version = appVersion, String pubspec = _pubspecVer
return isDevBuild(version) && commit.isNotEmpty ? '$version · $commit' : version; 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 kRadius = 12.0;
const kGap = 16.0; const kGap = 16.0;
@@ -1,14 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart' show isDevBuild;
import '../../update/update_controller.dart'; import '../../update/update_controller.dart';
import '../../widgets/update_banner.dart'; import '../../widgets/update_banner.dart';
import 'ui.dart'; import 'ui.dart';
/// Compact "Update vX" pill for the party strip; hidden until there is /// The band across the top of the window while an update is up for
/// something to install. Click opens the full [UpdateBanner]. /// grabs: "Starsync 1.2.1 is ready", its notes, Update and restart and
class UpdateIndicator extends StatelessWidget { /// 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; final UpdateController? controller;
const UpdateIndicator({super.key, this.controller}); const UpdateBar({super.key, this.controller});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -21,34 +24,102 @@ class UpdateIndicator extends StatelessWidget {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final scheme = Theme.of(context).colorScheme; 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 || final busy = s.phase == UpdatePhase.downloading ||
s.phase == UpdatePhase.applying || s.phase == UpdatePhase.applying ||
s.phase == UpdatePhase.restarting; s.phase == UpdatePhase.restarting;
final label = switch (s.phase) { return Material(
UpdatePhase.downloading => key: const ValueKey('update-bar'),
'Updating ${((s.progress ?? 0) * 100).round()}%', color: alert ? scheme.errorContainer : scheme.primaryContainer,
UpdatePhase.applying => 'Installing…', child: Padding(
UpdatePhase.restarting => 'Restarting…', padding: const EdgeInsets.fromLTRB(16, 8, 12, 8),
UpdatePhase.failed => 'Update failed', child: Row(
_ => m.isRequired ? 'Required update' : 'Update v${m.version}', children: [
}; if (busy)
return Tooltip( SizedBox.square(
message: 'Show update', dimension: 18,
child: FilledButton.tonalIcon( child: CircularProgressIndicator(
style: FilledButton.styleFrom( strokeWidth: 2,
backgroundColor: m.isRequired || s.phase == UpdatePhase.failed color: fg,
? scheme.errorContainer value: s.phase == UpdatePhase.downloading ? s.progress : null,
: null, ),
visualDensity: VisualDensity.compact, )
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 } enum UpdateCheck { notRun, checking, done }
/// Progress of this run's update check, for Settings/About wording. /// 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 // Jellyfin pieces of the session player. Plain widgets fed by the
// coordinator's signals, so tests can pump them without the player. // coordinator's signals, so tests can pump them without the player.
/// Previous / next episode in the video controls. Nothing when there is
/// 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 /// Floating "Next episode ›" from the ED (or the last two minutes), while
/// the controls are up (`PillGate`): faint at rest, lit on hover. /// the controls are up (`PillGate`): faint at rest, lit on hover.
class NextEpisodePill extends StatelessWidget { class NextEpisodePill extends StatelessWidget {
@@ -1,530 +1,11 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../../jellyfin/track_prefs.dart' show MpvTrack;
import '../../playback/media_marks.dart'; import '../../playback/media_marks.dart';
import '../../widgets/app_menu.dart' show AppGlass; import '../../widgets/app_menu.dart' show AppGlass;
// Session control-bar pieces built on the playback signals (chapters, // The player's floating action pills (Skip OP / ED / 1:30, Next episode):
// tracks, skip offers): the seek bar with chapter marks, the chapter and // plain widgets fed by values and callbacks, so tests pump them without a
// track menus, the 1:30 skip button and the floating skip pill. Plain // player. They live in the action area, outside the controls.
// widgets fed by values and callbacks, so tests pump them without a player.
/// Keeps media_kit's desktop controls shown, as a mouse hover would.
/// 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 action pill (Next episode, Skip OP / ED / 1:30): the /// 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 /// status pill's glass ([AppGlass.pill]) at [height], faint at rest, lit
@@ -0,0 +1,252 @@
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. [selfName]: this member's name (the feed
/// names members by name); [readyCount]: members ready in the latest
/// state (the pitch of a `ready` sound).
factory FeedEvent.of(FeedMessage f, {String? selfName, int readyCount = 0}) => switch (f.action) {
'join' => MemberJoined(f),
'leave' => MemberLeft(f),
'stale' => MemberStale(f),
'out' => MemberSatOut(f, atYou: f.target != null && f.target == selfName && f.actor != selfName),
'back' => 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: f.actor == selfName),
'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);
}
class MemberLeft extends FeedEvent {
const MemberLeft(super.feed);
}
class MemberStale extends FeedEvent {
const MemberStale(super.feed);
}
/// Someone sat out (`target` sat out by `actor`); [atYou]: someone else
/// sat this member out.
class MemberSatOut extends FeedEvent {
final bool atYou;
const MemberSatOut(super.feed, {this.atYou = false});
}
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);
}
/// Idle: auto-unready (or sit-out, [sitsOut]) in 2 minutes.
class IdleWarning extends AppEvent {
final bool sitsOut;
const IdleWarning({required this.sitsOut});
}
/// The connection to the server dropped (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,532 @@
import 'dart:async';
import 'dart:io';
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';
/// 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;
}
/// 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.
final class ConnOnline extends ConnState {
const ConnOnline();
@override
String toString() => 'online';
}
/// Dropped (the socket closed, or no state for [Connection.silenceLimit]);
/// the next attempt is scheduled.
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 first dial failed; nothing retries. [code]: `version` or `secret`
/// when the server said so, else empty (offline, timeout, anything else);
/// [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 socket took this member over. Not produced yet (protocol 16
/// adds the `replaced` refusal).
final class ConnReplaced extends ConnState {
const ConnReplaced();
@override
String toString() => 'replaced';
}
/// A failed dial in plain words.
String connectErrorText(Object e) {
final msg = '$e';
if (msg.contains('proto mismatch')) return 'host runs a different version — update the app';
if (msg.contains('wrong secret')) return 'wrong server password (401)';
if (msg.contains('SocketException') || msg.contains('Connection refused')) {
return 'host offline — check address and port';
}
return 'connect failed: $e';
}
ConnRefused _refused(Object e) {
final msg = '$e';
final code = msg.contains('proto mismatch')
? 'version'
: msg.contains('wrong secret')
? 'secret'
: '';
return ConnRefused(code, connectErrorText(e));
}
/// Builds the `hello` for [params]. Injectable so later protocol versions
/// add fields without changing [Connection].
typedef HelloBuilder = HelloMessage Function(ConnectParams params);
/// Today's hello.
HelloMessage defaultHello(ConnectParams params) => HelloMessage(
proto: RelayConstants.protoVersion,
secret: params.secret,
user: HelloUser(name: params.name, avatar: params.avatar),
media: const HelloMedia(name: '', size: 0, duration: 0),
hostKey: params.hostKey,
);
/// The session's part of a reconnect: called right before an attempt
/// dials (read what to restore then; the welcome's state replaces it). The
/// function it returns runs once that attempt is online; [stale] turns
/// true when a cancel, retry, leave or new connect superseded it. An error
/// there counts as a failed attempt.
typedef ReconnectHandler = Future<void> Function(WelcomeMessage welcome, bool Function() stale) Function();
/// The link to the server: the [SyncClient] socket, the first connect,
/// reconnects ([reconnectDelay] between attempts, the previous token
/// offered as `resume`, epochs that make a cancelled attempt stale), the
/// "no state for [silenceLimit] = lost" policy, the 1 s heartbeat (clock
/// ping, diagnostics), the connection lost/back events and [state]. What
/// a reconnect restores in the session is the [onReconnect] handler's.
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;
/// Wait before each reconnect attempt.
final Duration reconnectDelay;
/// No state broadcast for this long means the connection is lost.
final Duration silenceLimit;
Connection(
{void Function(AppEvent event)? notify,
SyncClient? client,
this.hello = defaultHello,
this.reconnectDelay = const Duration(milliseconds: 1500),
this.silenceLimit = const Duration(seconds: 5)})
: _notify = notify ?? ((_) {}),
client = client ?? SyncClient() {
_subs = [
this.client.status.listen(_onStatus),
this.client.states.listen((_) => _lastStateAt = DateTime.now().toUtc()),
];
}
late final List<StreamSubscription> _subs;
final _state = signal<ConnState>(const ConnClosed());
/// Every change is notified, a repeat too.
ReadonlySignal<ConnState> get state => _state;
/// Set by the session (see [ReconnectHandler]).
ReconnectHandler? onReconnect;
/// 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);
}
/// No state broadcast for [silenceLimit] (checked every second while not
/// closed): what the server said is stale. Synchronous, before [state]
/// goes lost.
Stream<void> get silence => _silence.stream;
final _silence = StreamController<void>.broadcast(sync: true);
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: 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;
// Token of the current connection, offered as `resume` on reconnect.
String _resumeToken = '';
DateTime? _lastStateAt;
int _attempt = 0;
// The socket and what the server said, read through.
Stream<StateBroadcast> get states => client.states;
/// Feed entries, chat included.
Stream<FeedMessage> get feeds => client.feeds;
Stream<NudgeNotice> get nudges => client.nudges;
/// `err` frames after the welcome.
Stream<String> get errors => client.errors;
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 sendOut(String target, bool out) => client.sendOut(target, out);
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 resume token and
/// starts the diagnostics. [dial] connects.
void begin(ConnectParams params) {
_params = params;
_closed = false;
_everOnline = false;
_lostSounded = false;
_resumeToken = '';
_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.
Future<WelcomeMessage> dial(int epoch) async {
final params = _params!;
_set(const ConnConnecting());
try {
final welcome = await client.connectWith(wsUri: _wsUri(params), hello: hello(params));
if (epoch != _epoch) throw const ConnectCancelled();
_online();
return welcome;
} catch (e) {
if (epoch != _epoch) throw const ConnectCancelled();
debugPrint('connect failed: $e');
_set(_refused(e));
rethrow;
}
}
static Uri _wsUri(ConnectParams p) => Uri.parse('ws://${p.host}:${p.port}/sync');
/// [hello] offering the previous connection's token.
HelloMessage _resumeHello(ConnectParams params) {
final h = hello(params);
return HelloMessage(
proto: h.proto, secret: h.secret, user: h.user, media: h.media, hostKey: h.hostKey, resume: _resumeToken);
}
void _online() {
_attempt = 0;
_everOnline = true;
if (_lostSounded) {
_lostSounded = false;
_notify(const ConnectionBack());
}
_resumeToken = client.token;
_lastStateAt = DateTime.now().toUtc();
_set(const ConnOnline());
_startHeartbeat();
}
void _set(ConnState s) => _state.set(s, force: true);
void _onStatus(SyncStatus s) {
// Before the first welcome a close is just the dial failing.
if (s != SyncStatus.closed || _closed || !_everOnline) return;
_dropped();
}
/// Lost (said again while already retrying), the lost sound once per
/// drop, and a reconnect unless one is already scheduled or dialling.
void _dropped() {
if (_state.peek().retrying) {
_set(const ConnLost());
} else {
scheduleReconnect();
}
_soundLost();
}
void _startHeartbeat() {
_heartbeat?.cancel();
_heartbeat = Timer.periodic(const Duration(seconds: 1), (_) => _beat());
}
/// Every second while connected: a clock sample, the diagnostics, and
/// the silence check.
void _beat() {
final now = DateTime.now().toUtc();
if (client.token.isNotEmpty) client.sendPing();
diag
..share = AppSettings.instance.shareDiagnostics.peek()
..tick();
final last = _lastStateAt;
if (!_closed && last != null && now.difference(last) >= silenceLimit) {
_silence.add(null);
if (!_state.peek().retrying) _dropped();
}
}
void _soundLost() {
if (!_everOnline || _lostSounded) return;
_lostSounded = true;
_notify(const ConnectionLost());
}
/// Reconnect after [reconnectDelay] ([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;
_set(const ConnLost());
_armReconnect();
}
void _armReconnect() {
final epoch = _epoch;
_reconnectTimer?.cancel();
_reconnectTimer = Timer(reconnectDelay, () => _reconnect(epoch));
}
/// One reconnect attempt. An attempt from an older epoch must not touch
/// state (it would kill the newer connection and leave a ghost member).
Future<void> _reconnect(int epoch) async {
_reconnectTimer = null;
bool stale() => epoch != _epoch;
final params = _params;
if (stale() || params == null || _closed) return;
final after = onReconnect?.call();
try {
_set(ConnReconnecting(++_attempt));
final welcome = await client.connectWith(wsUri: _wsUri(params), hello: _resumeHello(params));
// Whoever bumped the epoch also closed (or replaced) the socket.
if (stale()) return;
_online();
if (after != null) await after(welcome, stale);
} catch (e) {
if (stale()) return;
debugPrint('reconnect failed: $e');
_retryAfterFailure(e);
}
}
void _retryAfterFailure(Object e) {
if (_closed) {
_set(const ConnClosed());
} else if (!_everOnline) {
// Only a manual retry before any welcome gets here.
_set(_refused(e));
} else {
if (!_state.peek().retrying) _set(const ConnLost());
_armReconnect();
}
}
/// "Stop reconnecting": no more attempts; one dialling is aborted.
void cancelReconnect() {
_halt();
unawaited(client.disconnect().catchError((_) {}));
}
/// Cancel during setup: aborts the dial.
Future<void> cancelConnect() async {
_halt();
try {
await client.disconnect();
} catch (_) {}
}
void _halt() {
_closed = true;
invalidate();
_heartbeat?.cancel();
_heartbeat = null;
_set(const ConnClosed());
}
/// "Retry": a reconnect after [reconnectDelay] whatever the state.
/// False without a session to retry.
bool retry() {
if (_params == null) return false;
invalidate();
_closed = false;
_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), the socket.
Future<void> close() async {
stop();
await diag.stop();
try {
await client.disconnect();
} catch (_) {}
}
Future<void> dispose() async {
stop();
for (final s in _subs) {
await s.cancel();
}
await _silence.close();
await client.dispose();
}
}
+140
View File
@@ -0,0 +1,140 @@
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;
const ToastItem({required this.id, required this.text, required this.at, this.actor});
}
/// 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) {
// 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)),
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'),
IdleWarning(:final sitsOut) => Notice(
toast: sitsOut
? 'idle — sitting out in 2 min unless you press Space'
: 'idle — auto-unready in 2 min unless you press Space',
sound: const SfxCue(SfxKind.warn)),
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),
];
}
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 = [];
}
}
File diff suppressed because it is too large. Load diff
+34 -62
View File
@@ -1,88 +1,60 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:media_kit/media_kit.dart';
import 'package:path_provider/path_provider.dart';
import '../platform/audio/sound_engine.dart';
import 'sfx.dart'; import 'sfx.dart';
import 'sound_bank.dart'; import 'sound_bank.dart';
import 'synth.dart'; import 'synth.dart';
/// Plays [SoundSpec]s: each is rendered once to a WAV in the temp dir and /// Plays [SoundSpec]s on a [SoundEngine]: each is rendered once (off the
/// played on a small pool of audio-only mpv players (so two sounds can /// UI isolate) to WAV bytes and loaded into memory; sounds overlap.
/// overlap). [volume] (0..1) is read per play and applied on a square /// [volume] (0..1) is read per play and applied as v^6: the square curve
/// curve (ears are log); [enabled] filters cues. /// (ears are log) the app always used, times mpv's own cubic volume it
/// used to play through, so the slider sounds as it did; [enabled]
/// filters cues.
class SynthSfx implements SfxService { class SynthSfx implements SfxService {
final double Function() volume; final double Function() volume;
final bool Function(SfxKind kind) enabled; final bool Function(SfxKind kind) enabled;
/// Which sound set each sound plays from. /// Which sound set each sound plays from.
final SoundPreset Function(SfxKind kind) presetFor; final SoundPreset Function(SfxKind kind) presetFor;
SynthSfx({required this.volume, bool Function(SfxKind kind)? enabled, SoundPreset Function(SfxKind kind)? presetFor}) final SoundEngine engine;
SynthSfx(
{required this.volume,
bool Function(SfxKind kind)? enabled,
SoundPreset Function(SfxKind kind)? presetFor,
SoundEngine? engine})
: enabled = enabled ?? ((_) => true), : enabled = enabled ?? ((_) => true),
presetFor = presetFor ?? ((_) => defaultPreset); presetFor = presetFor ?? ((_) => defaultPreset),
engine = engine ?? SoundEngine();
static const _poolSize = 3;
final List<Player> _pool = [];
var _next = 0;
final Map<String, Future<String>> _files = {};
Directory? _dir;
@override @override
Future<void> play(SfxCue cue) async { Future<void> play(SfxCue cue) {
if (!enabled(cue.kind)) return; if (!enabled(cue.kind)) return Future.value();
final preset = presetFor(cue.kind); return _play(cue.kind, cue.readyIndex, presetFor(cue.kind));
await playSpec('${preset.name}-${cue.kind.name}-${cue.readyIndex}',
soundFor(cue.kind, readyIndex: cue.readyIndex, preset: preset));
} }
/// Plays [kind] even when it is switched off (Settings' preview), always /// Plays [kind] even when it is switched off (Settings' preview), always
/// freshly rendered: an edited spec plays after a hot reload. /// freshly rendered: an edited spec plays after a hot reload.
Future<void> preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) { Future<void> preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) =>
final p = preset ?? presetFor(kind); _play(kind, readyIndex, preset ?? presetFor(kind), fresh: true);
final key = '${p.name}-${kind.name}-$readyIndex';
_files.remove(key);
return playSpec(key, soundFor(kind, readyIndex: readyIndex, preset: p));
}
/// Plays [spec], cached under [key] (same key = same sound). /// One slot per sound and ready step: a new preset replaces it.
Future<void> playSpec(String key, SoundSpec spec) async { Future<void> _play(SfxKind kind, int readyIndex, SoundPreset preset, {bool fresh = false}) {
final v = volume(); final v = volume();
if (v <= 0) return; return engine.play('${kind.name}-$readyIndex', '${preset.name}-${kind.name}-$readyIndex',
try { () => renderWav(soundFor(kind, readyIndex: readyIndex, preset: preset)),
final path = await (_files[key] ??= _write(key, spec)); volume: math.pow(v, 6).toDouble(), fresh: fresh);
final player = await _player();
await player.setVolume(100 * v * v);
await player.open(Media(path));
} catch (e) {
_files.remove(key);
debugPrint('sfx $key: $e');
}
} }
Future<String> _write(String key, SoundSpec spec) async { /// Opens the audio device ahead of the first sound.
_dir ??= await Directory('${(await getTemporaryDirectory()).path}/watch_together_sfx').create(recursive: true); Future<void> warmUp() => engine.warmUp();
final f = File('${_dir!.path}/$key.wav');
await f.writeAsBytes(wav(render(spec)));
return f.path;
}
Future<Player> _player() async { Future<void> dispose() => engine.dispose();
if (_pool.length < _poolSize) {
final p = Player(
configuration: const PlayerConfiguration(vo: 'null', osc: false, libass: false, title: 'sfx'),
);
await (p.platform as NativePlayer).setProperty('audio-display', 'no');
_pool.add(p);
return p;
}
return _pool[_next++ % _poolSize];
}
Future<void> dispose() async {
for (final p in _pool) {
await p.dispose();
}
_pool.clear();
}
} }
/// [spec] as WAV bytes, rendered on a worker isolate.
Future<Uint8List> renderWav(SoundSpec spec) => compute(_renderWav, spec, debugLabel: 'sfx render');
Uint8List _renderWav(SoundSpec spec) => wav(render(spec));
+2 -45
View File
@@ -8,50 +8,6 @@ import 'shader_presets.dart';
/// A shader pick from the menu; a null quality keeps the saved one. /// A shader pick from the menu; a null quality keeps the saved one.
typedef ShaderPick = (ShaderPreset, ShaderQuality?); typedef ShaderPick = (ShaderPreset, ShaderQuality?);
/// Compact shader button for the video controls bar (normal and
/// fullscreen). Opens a small menu: Off, the six Anime4K modes with
/// HQ / Fast, Custom. The menu is a route, so it outlives the controls
/// bar hiding under it; [onPick] runs on the caller's side.
class ShaderControlButton extends StatelessWidget {
final FocusNode focusNode;
final ReadonlySignal<RenderMode> renderMode;
final void Function(ShaderPreset preset, ShaderQuality? quality) onPick;
final ValueChanged<bool>? onMenuOpen;
const ShaderControlButton({
super.key,
required this.focusNode,
required this.renderMode,
required this.onPick,
this.onMenuOpen,
});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(builder: (context) {
final preset = s.shaderPreset.value;
final quality = s.shaderQuality.value;
final short = shaderShortLabel(preset, quality) ?? 'Shaders';
return Tooltip(
message: 'Video shaders (Ctrl+1..6, Ctrl+0 off)',
child: TextButton.icon(
focusNode: focusNode,
style: TextButton.styleFrom(
foregroundColor:
preset == ShaderPreset.off ? Colors.white : Colors.amberAccent,
padding: const EdgeInsets.symmetric(horizontal: 8),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () => showShaderMenu(context,
renderMode: renderMode, onPick: onPick, onMenuOpen: onMenuOpen),
icon: const Icon(Icons.auto_awesome, size: 16),
label: Text(short, style: const TextStyle(fontSize: 12)),
),
);
});
}
}
/// "A HQ", "C+A Fast", "Custom"; null when shaders are off. /// "A HQ", "C+A Fast", "Custom"; null when shaders are off.
String? shaderShortLabel(ShaderPreset preset, ShaderQuality quality) => String? shaderShortLabel(ShaderPreset preset, ShaderQuality quality) =>
switch (preset) { switch (preset) {
@@ -61,7 +17,8 @@ String? shaderShortLabel(ShaderPreset preset, ShaderQuality quality) =>
}; };
/// The shader menu, anchored on [context]'s box (the button that opens /// The shader menu, anchored on [context]'s box (the button that opens
/// it): Off, the six Anime4K modes with HQ / Fast, Custom. /// it; the controls' ⚙): Off, the six Anime4K modes with HQ / Fast,
/// Custom. A route, so it outlives the controls hiding under it.
Future<void> showShaderMenu( Future<void> showShaderMenu(
BuildContext context, { BuildContext context, {
required ReadonlySignal<RenderMode> renderMode, required ReadonlySignal<RenderMode> renderMode,
@@ -85,3 +85,11 @@ String shaderListSeparator({bool? windows}) =>
/// The `glsl-shaders` value for [paths]. /// The `glsl-shaders` value for [paths].
String joinShaderPaths(List<String> paths, {bool? windows}) => String joinShaderPaths(List<String> paths, {bool? windows}) =>
paths.join(shaderListSeparator(windows: 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);
+45 -24
View File
@@ -3,16 +3,16 @@ import 'dart:io' show File;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget;
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:window_manager/window_manager.dart';
import '../controls/frame.dart' show DoubleClickListener;
import '../files/folder_store.dart'; import '../files/folder_store.dart';
import '../files/ui/files_page.dart' show showFilesFolder; import '../files/ui/files_page.dart' show showFilesFolder;
import '../jellyfin/accounts.dart'; import '../jellyfin/accounts.dart';
import '../jellyfin/browse_scope.dart' show BrowseStart; import '../jellyfin/browse_scope.dart' show BrowseStart;
import '../jellyfin/session_media.dart'; import '../jellyfin/session_media.dart';
import '../screens/home/connect_flow.dart'; import '../screens/home/connect_flow.dart';
import '../session/app_events.dart' show ActionFailed, ScheduleResult;
import '../session/session_coordinator.dart' show ConnectParams; import '../session/session_coordinator.dart' show ConnectParams;
import '../screens/home/health_probe.dart'; import '../screens/home/health_probe.dart';
import '../screens/home/media_picker.dart'; import '../screens/home/media_picker.dart';
@@ -24,6 +24,7 @@ import '../update/update_controller.dart';
import 'chat_panel.dart'; import 'chat_panel.dart';
import 'connect_cards.dart'; import 'connect_cards.dart';
import 'handoff.dart'; import 'handoff.dart';
import 'library_link.dart' show LinkProber, defaultLinkProber;
import 'library_tab.dart'; import 'library_tab.dart';
import 'motion.dart'; import 'motion.dart';
import 'party_strip.dart'; import 'party_strip.dart';
@@ -57,6 +58,9 @@ class AppShell extends StatefulWidget {
final JellyfinAccounts? jellyfin; final JellyfinAccounts? jellyfin;
/// Stand-in for media_kit's `Video` (tests). /// Stand-in for media_kit's `Video` (tests).
final VideoViewBuilder? videoBuilder; 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({ const AppShell({
super.key, super.key,
this.updates, this.updates,
@@ -65,6 +69,7 @@ class AppShell extends StatefulWidget {
this.healthGet = defaultHealthGet, this.healthGet = defaultHealthGet,
this.jellyfin, this.jellyfin,
this.videoBuilder, this.videoBuilder,
this.linkProbe = defaultLinkProber,
}); });
@override @override
@@ -127,6 +132,14 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
SessionState.instance.route.value == AppRoute.session && SessionState.instance.route.value == AppRoute.session &&
SessionState.instance.coordinator.serverState.value?.media != null); SessionState.instance.coordinator.serverState.value?.media != null);
/// 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 mode => _mode;
PlayerMode get shownMode => _shown; PlayerMode get shownMode => _shown;
bool get fullscreen => _fullscreen; bool get fullscreen => _fullscreen;
@@ -225,10 +238,12 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
stop(); stop();
} }
HardwareKeyboard.instance.removeHandler(_onKey); HardwareKeyboard.instance.removeHandler(_onKey);
_updateTimer?.cancel();
_fade.dispose(); _fade.dispose();
_online.dispose(); _online.dispose();
_alone.dispose(); _alone.dispose();
_hasMedia.dispose(); _hasMedia.dispose();
_watching.dispose();
_flow.dispose(); _flow.dispose();
_join.dispose(); _join.dispose();
_host.dispose(); _host.dispose();
@@ -244,9 +259,17 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
if (mounted) f(); 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 { Future<void> _autoCheck() async {
final s = AppSettings.instance; final s = AppSettings.instance;
await s.ensureLoaded(); 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); if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
} }
@@ -310,7 +333,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
Future<void> _setWindowFullscreen(bool on) async { Future<void> _setWindowFullscreen(bool on) async {
if (!widget.windowControls) return; if (!widget.windowControls) return;
try { try {
await windowManager.setFullScreen(on); await SessionState.instance.ports.shell.setFullScreen(on);
} catch (_) { } catch (_) {
// No window manager. // No window manager.
} }
@@ -438,17 +461,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
/// Clipboard files first, then text (a link or absolute paths). A pasted /// 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. /// image has no taker yet, so the clipboard's image data is not read.
Future<void> _paste() async { Future<void> _paste() async {
var files = const <String>[]; final clip = await SessionState.instance.ports.shell.readPaste();
String? text; await _handOff(classifyHandoff(paths: clip.files, text: clip.text), _pointer);
try {
files = await ClipboardX.readFiles();
} catch (_) {}
if (files.isEmpty) {
try {
text = await ClipboardX.readText();
} catch (_) {}
}
await _handOff(classifyHandoff(paths: files, text: text), _pointer);
} }
Future<void> _handOff(Handoff item, Offset? at) async { Future<void> _handOff(Handoff item, Offset? at) async {
@@ -470,7 +484,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
case (HandoffVideos(:final paths), HandoffAction.putUp): case (HandoffVideos(:final paths), HandoffAction.putUp):
final error = await _putUp(FileMedia(paths.first)); final error = await _putUp(FileMedia(paths.first));
if (error != null) { if (error != null) {
SessionState.instance.coordinator.showToast(error); SessionState.instance.coordinator.notify(ActionFailed(error));
} else { } else {
await FolderStore.instance.ensureLoaded(); await FolderStore.instance.ensureLoaded();
FolderStore.instance.addRecent(paths.first); FolderStore.instance.addRecent(paths.first);
@@ -481,7 +495,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
for (final p in next ? paths.reversed : paths) { for (final p in next ? paths.reversed : paths) {
final error = await SessionState.instance.coordinator.queue.add(FileMedia(p), next: next); final error = await SessionState.instance.coordinator.queue.add(FileMedia(p), next: next);
if (error != null) { if (error != null) {
SessionState.instance.coordinator.showToast(error); SessionState.instance.coordinator.notify(ActionFailed(error));
break; break;
} }
} }
@@ -490,7 +504,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
final coord = SessionState.instance.coordinator; final coord = SessionState.instance.coordinator;
final error = await coord.queue final error = await coord.queue
.add(LinkMedia(url, relay: _picker.linkRelay), next: action == HandoffAction.playNext); .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): case (HandoffVideos(:final paths), HandoffAction.addFolder):
await _addFolder(File(paths.first).parent.path); await _addFolder(File(paths.first).parent.path);
case (HandoffFolder(:final path), _): case (HandoffFolder(:final path), _):
@@ -521,13 +535,13 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
} }
// Off on Settings: its own drop targets (cookies) take the files there. // 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, enable: _tab != ShellTab.settings,
onDragEntered: (_) => setState(() => _dragging = _onTop), onEnter: () => setState(() => _dragging = _onTop),
onDragExited: (_) => setState(() => _dragging = false), onExit: () => setState(() => _dragging = false),
onDragDone: (d) { onDrop: (paths, at) {
setState(() => _dragging = false); setState(() => _dragging = false);
_handOff(classifyHandoff(paths: DesktopDropX.pathsOf(d.files)), d.localPosition); _handOff(classifyHandoff(paths: paths), at);
}, },
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
@@ -615,6 +629,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
final online = _online.value; final online = _online.value;
final alone = _alone.value; final alone = _alone.value;
final hasMedia = _hasMedia.value; final hasMedia = _hasMedia.value;
final onPlayer = _shown == PlayerMode.full && _watching.value;
return Column( return Column(
children: [ children: [
if (!_fullscreen) if (!_fullscreen)
@@ -631,9 +646,14 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
onInvite: () => _connect(ConnectMode.host), onInvite: () => _connect(ConnectMode.host),
onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates), onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null, onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null,
updates: widget.updates,
windowControls: widget.windowControls, windowControls: widget.windowControls,
), ),
AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topCenter,
child: _fullscreen || onPlayer ? const SizedBox(width: double.infinity) : UpdateBar(controller: widget.updates),
),
Expanded( Expanded(
key: const ValueKey('body'), key: const ValueKey('body'),
child: Row( child: Row(
@@ -693,12 +713,13 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
// Up next needs a server (a solo session has one). // Up next needs a server (a solo session has one).
onAddToQueue: online ? coord.queue.add : null, onAddToQueue: online ? coord.queue.add : null,
onOpenIntegrations: () => _openSettings(SettingsSection.integrations), onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
probe: widget.linkProbe,
), ),
ShellTab.schedule => SchedulePage( ShellTab.schedule => SchedulePage(
solo: alone, solo: alone,
onPutUp: _putUp, onPutUp: _putUp,
onAddToQueue: online ? coord.queue.add : null, onAddToQueue: online ? coord.queue.add : null,
onToast: coord.showToast, onToast: (text) => coord.notify(ScheduleResult(text)),
), ),
ShellTab.settings => SettingsTab( ShellTab.settings => SettingsTab(
section: _section, section: _section,
+3 -3
View File
@@ -78,7 +78,7 @@ MemoryImage _memo(Object key, Uint8List bytes) {
/// Callers rebuild on [SessionCoordinator.avatars] and [Profile.picture] /// Callers rebuild on [SessionCoordinator.avatars] and [Profile.picture]
/// (see [PartyWatch]). /// (see [PartyWatch]).
ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) { 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; final own = Profile.instance.picture.value;
if (own != null) return _memo('self', own); if (own != null) return _memo('self', own);
} }
@@ -105,10 +105,10 @@ AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = fal
/// [m] as the party strip, toasts and the chat show it. /// [m] as the party strip, toasts and the chat show it.
AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator coord) { 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 relay = r == null ? null : stateMediaRelay(r);
final playing = r?.media != null; final playing = r?.media != null;
final delivery = r == null ? null : deliveryOf(r, selfId: coord.sync.memberId, names: coord.memberNames); final delivery = r == null ? null : deliveryOf(r, selfId: coord.connection.memberId, names: coord.memberNames);
return AvatarInfo( return AvatarInfo(
id: m.id, id: m.id,
name: m.name, name: m.name,
+3 -3
View File
@@ -6,11 +6,11 @@ import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart' import 'package:sync_relay_core/core.dart'
show LinkProbe, hasMediaExtension, parseJellyfinLink, playErrorText, unreachableText; show LinkProbe, hasMediaExtension, parseJellyfinLink, playErrorText, unreachableText;
import '../playback/yt_dlp_app.dart';
import '../screens/home/media_picker.dart'; import '../screens/home/media_picker.dart';
import '../widgets/app_menu.dart'; import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart'; import '../widgets/copy_open.dart';
import '../state/connect_prefs.dart'; import '../state/connect_prefs.dart';
import '../state/session_state.dart';
import 'motion.dart'; import 'motion.dart';
// The Library's Link source: a big paste field, a preview of what the link // 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); 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; String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host;
+2 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart' show RelayConstants; import 'package:sync_relay_core/core.dart' show RelayConstants;
import '../session/app_events.dart' show ProblemMarked;
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../widgets/app_menu.dart' show AppGlass; import '../widgets/app_menu.dart' show AppGlass;
import 'motion.dart'; import 'motion.dart';
@@ -19,7 +20,7 @@ Future<void> showMarkProblem(BuildContext context) async {
if (note == null) return; if (note == null) return;
final coord = SessionState.instance.coordinator; final coord = SessionState.instance.coordinator;
final inSession = await coord.markProblem(note); 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 /// The small card: a one-line note and Mark. Pops with the note (maybe
+37 -11
View File
@@ -3,13 +3,10 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart'; 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/ui.dart' show appName, displayVersion, shortVersion;
import '../screens/home/update_indicator.dart';
import '../session/delivery.dart'; import '../session/delivery.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../update/update_controller.dart';
import '../widgets/app_keys.dart'; import '../widgets/app_keys.dart';
import '../widgets/app_menu.dart'; import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart'; import '../widgets/copy_open.dart';
@@ -56,7 +53,6 @@ class PartyStrip extends StatelessWidget {
/// Online with something up but the player closed: brings it back. /// Online with something up but the player closed: brings it back.
final VoidCallback? onShowPlayer; final VoidCallback? onShowPlayer;
final UpdateController? updates;
/// Off in tests: the drag area and window buttons need window_manager. /// Off in tests: the drag area and window buttons need window_manager.
final bool windowControls; final bool windowControls;
@@ -73,7 +69,6 @@ class PartyStrip extends StatelessWidget {
required this.onProfile, required this.onProfile,
this.onInvite, this.onInvite,
this.onShowPlayer, this.onShowPlayer,
this.updates,
this.windowControls = true, this.windowControls = true,
}); });
@@ -125,8 +120,10 @@ class PartyStrip extends StatelessWidget {
label: t.label, label: t.label,
selected: t == tab, selected: t == tab,
compact: compact, compact: compact,
// The version, a rest of the mouse away. // The version: always in sight under Settings,
// the full one a rest of the mouse away.
tooltip: t == ShellTab.settings ? '$appName ${displayVersion()}' : null, tooltip: t == ShellTab.settings ? '$appName ${displayVersion()}' : null,
caption: t == ShellTab.settings ? shortVersion() : null,
onTap: () => onTab(t), onTap: () => onTab(t),
), ),
], ],
@@ -135,7 +132,6 @@ class PartyStrip extends StatelessWidget {
), ),
), ),
), ),
UpdateIndicator(controller: updates),
const SizedBox(width: 8), const SizedBox(width: 8),
if (online && onShowPlayer != null) if (online && onShowPlayer != null)
IconButton( IconButton(
@@ -266,7 +262,7 @@ class PartyStrip extends StatelessWidget {
// their own clicks. // their own clicks.
if (windowControls) if (windowControls)
const Positioned.fill( const Positioned.fill(
child: DragToMoveArea(child: SizedBox.expand()), child: WindowDragArea(child: SizedBox.expand()),
), ),
Positioned.fill(child: row), Positioned.fill(child: row),
], ],
@@ -369,6 +365,8 @@ class StripTab extends StatelessWidget {
final VoidCallback onTap; final VoidCallback onTap;
/// Shown after resting on the tab. /// Shown after resting on the tab.
final String? tooltip; final String? tooltip;
/// Small text under the tab (the version under Settings).
final String? caption;
const StripTab({ const StripTab({
super.key, super.key,
required this.label, required this.label,
@@ -376,6 +374,7 @@ class StripTab extends StatelessWidget {
required this.onTap, required this.onTap,
this.compact = false, this.compact = false,
this.tooltip, this.tooltip,
this.caption,
}); });
@override @override
@@ -391,7 +390,11 @@ class StripTab extends StatelessWidget {
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 12), padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 12),
child: SizedBox( child: SizedBox(
height: partyStripHeight, height: partyStripHeight,
child: Column( child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const SizedBox(height: 3), const SizedBox(height: 3),
@@ -415,6 +418,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 +516,7 @@ class MembersCard extends StatelessWidget {
: '${session.lastForm.host}:${session.lastForm.port}'; : '${session.lastForm.host}:${session.lastForm.port}';
final delivery = r == null final delivery = r == null
? null ? null
: deliveryOf(r, selfId: coord.sync.memberId, names: coord.memberNames); : deliveryOf(r, selfId: coord.connection.memberId, names: coord.memberNames);
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
+4 -6
View File
@@ -3,7 +3,6 @@ import 'dart:math' as math;
import 'dart:typed_data'; import 'dart:typed_data';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:file_picker/file_picker.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
@@ -329,18 +328,17 @@ class _CropPickerState extends State<CropPicker> {
} }
Future<void> _pick() async { Future<void> _pick() async {
final List<PlatformFile> files; final List<String> files;
try { try {
files = await FilePicker.pickFiles( files = await SessionState.instance.ports.folders.pickFiles(
dialogTitle: 'Pick a picture', dialogTitle: 'Pick a picture',
type: FileType.custom, extensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'],
allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'],
); );
} catch (e) { } catch (e) {
if (mounted) setState(() => _error = 'file dialog unavailable — try again'); if (mounted) setState(() => _error = 'file dialog unavailable — try again');
return; return;
} }
final path = files.isEmpty ? null : files.first.path; final path = files.firstOrNull;
if (path == null) return; if (path == null) return;
try { try {
final codec = await ui.instantiateImageCodec(await File(path).readAsBytes()); final codec = await ui.instantiateImageCodec(await File(path).readAsBytes());
+68 -550
View File
@@ -2,26 +2,21 @@ import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:media_kit_video/media_kit_video.dart'; import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../controls/controls_probe.dart'; import '../controls/controls_probe.dart';
import '../controls/player_settings.dart'; import '../controls/model/controls_model.dart';
import '../controls/uosc/buttons.dart'; import '../controls/session_controls.dart';
import '../controls/uosc/controls.dart';
import '../controls/uosc/timeline.dart';
import '../controls/uosc/volume_flash.dart';
import '../controls/volume.dart'; import '../controls/volume.dart';
import '../queue/queue_ui.dart'; import '../queue/queue_ui.dart';
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock; import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
import '../screens/session/jellyfin_widgets.dart'; import '../screens/session/jellyfin_widgets.dart';
import '../screens/session/player_controls.dart'; import '../screens/session/player_controls.dart';
import '../session/app_events.dart' show ShaderMessage;
import '../session/session_coordinator.dart'; import '../session/session_coordinator.dart';
import '../shaders/shader_menu.dart';
import '../shaders/shader_presets.dart'; import '../shaders/shader_presets.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../state/settings.dart'; import '../state/settings.dart';
@@ -29,7 +24,6 @@ import '../widgets/app_keys.dart';
import '../widgets/text_focus.dart'; import '../widgets/text_focus.dart';
import '../widgets/window_buttons.dart'; import '../widgets/window_buttons.dart';
import 'avatar.dart'; import 'avatar.dart';
import 'mark_problem.dart';
import 'motion.dart'; import 'motion.dart';
import 'now_playing_title.dart'; import 'now_playing_title.dart';
import 'player_status.dart'; import 'player_status.dart';
@@ -46,8 +40,8 @@ enum PlayerMode { none, mini, full }
typedef VideoViewBuilder = Widget Function(BuildContext context, VideoControlsBuilder? controls); typedef VideoViewBuilder = Widget Function(BuildContext context, VideoControlsBuilder? controls);
/// The session's player: the one `Video` of the current player and /// 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 /// everything over it. Full: the controls (one model, the skin picked in
/// set (Settings), the start card, the status pill, Jellyfin overlays and /// Settings), the start card, the status pill, Jellyfin overlays and
/// the player keys. Mini: hover controls and a progress line. The shell keeps this /// 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 /// widget mounted at one place in the tree while the mode changes, so the
/// `Video` is never mounted twice nor rebuilt for a move. /// `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); final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
DateTime? _lastKeyAt; DateTime? _lastKeyAt;
late final void Function() _stopShaderWatch; late final void Function() _stopShaderWatch;
bool _shaderMenuOpen = false;
// Own toast ids, negative: the coordinator counts up from 1.
int _toastSeq = 0;
int _shaderNoticeSeen = 0; int _shaderNoticeSeen = 0;
bool _miniHover = false; bool _miniHover = false;
// Whether the controls are up: the standard ones tracked like media_kit // Whether the controls are up (their frame reports it).
// does (shown on mount and on mouse movement, hidden 3 s later or on
// exit), the uosc ones reporting it.
final _controlsShown = ValueNotifier(true); final _controlsShown = ValueNotifier(true);
// The mouse on a floating element, or the Up next popover open, keeps // The mouse on a floating element, or the Up next popover open, keeps
// the controls up. // the controls up.
final _hold = ControlsHold(); final _hold = ControlsHold();
// Ticks on each wheel / key volume step: flashes the volume readout.
final _volumeFlash = ValueNotifier(0);
late final void Function() _stopPopoverWatch; 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; SessionCoordinator get _coord => SessionState.instance.coordinator;
bool get _full => widget.mode == PlayerMode.full; bool get _full => widget.mode == PlayerMode.full;
@@ -131,13 +133,21 @@ class _SessionPlayerState extends State<SessionPlayer> {
final n = _coord.playback.shaderNotice.value; final n = _coord.playback.shaderNotice.value;
if (n == null || n.id == _shaderNoticeSeen) return; if (n == null || n.id == _shaderNoticeSeen) return;
_shaderNoticeSeen = n.id; _shaderNoticeSeen = n.id;
_toast(n.text); _coord.notify(ShaderMessage(n.text));
}); });
_stopPopoverWatch = effect(() { _stopPopoverWatch = effect(() {
_hold.set(#upNextPopover, _coord.queue.popoverOpen.value); _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 @override
void dispose() { void dispose() {
HardwareKeyboard.instance.removeHandler(_globalKey); HardwareKeyboard.instance.removeHandler(_globalKey);
@@ -147,38 +157,11 @@ class _SessionPlayerState extends State<SessionPlayer> {
_noFocus.dispose(); _noFocus.dispose();
_controlsShown.dispose(); _controlsShown.dispose();
_hold.dispose(); _hold.dispose();
_volumeFlash.dispose(); _model.dispose();
super.dispose(); super.dispose();
} }
void _reloadVideo() { void _toggleChat() => _model.toggleChat();
_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();
}
/// The player keys, while the player fills the body and nothing (a /// The player keys, while the player fills the body and nothing (a
/// dialog, a card, a text field) is in front of it. Space and Tab are /// 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); final route = ModalRoute.of(context);
if (route != null && !route.isCurrent) { if (route != null && !route.isCurrent) {
// A menu of ours is open: Esc closes it. // A menu of ours is open: Esc closes it.
if (_shaderMenuOpen && event.logicalKey == LogicalKeyboardKey.escape) { if (_model.menusOpen > 0 && event.logicalKey == LogicalKeyboardKey.escape) {
Navigator.of(context, rootNavigator: true).maybePop(); Navigator.of(context, rootNavigator: true).maybePop();
return true; return true;
} }
@@ -207,62 +190,55 @@ class _SessionPlayerState extends State<SessionPlayer> {
if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) { if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) {
return false; return false;
} }
final coord = _coord; final model = _model;
final key = event.logicalKey; final key = event.logicalKey;
// Ctrl+0..6: Anime4K's own mpv keys (off, modes A..C+A). // Ctrl+0..6: Anime4K's own mpv keys (off, modes A..C+A).
final digit = _shaderKeys.indexOf(key); final digit = _shaderKeys.indexOf(key);
if (digit >= 0 && HardwareKeyboard.instance.isControlPressed) { if (digit >= 0 && HardwareKeyboard.instance.isControlPressed) {
_lastKeyAt = now; _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; return true;
} }
if (key == LogicalKeyboardKey.keyR && HardwareKeyboard.instance.isControlPressed) { if (key == LogicalKeyboardKey.keyR && HardwareKeyboard.instance.isControlPressed) {
_lastKeyAt = now; _lastKeyAt = now;
_reloadVideo(); model.reload();
return true; return true;
} }
// mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes. // mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes.
final shift = HardwareKeyboard.instance.isShiftPressed; final shift = HardwareKeyboard.instance.isShiftPressed;
if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) { if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) {
// Up next first, then the next episode. // Up next first, then the next episode.
if (coord.queue.items.peek().isNotEmpty) { if (!model.next()) return false;
_lastKeyAt = now; _lastKeyAt = now;
coord.queue.skip();
return true;
}
if (coord.nextEpisode.peek() == null) return false;
_lastKeyAt = now;
coord.playNextEpisode();
return true; return true;
} }
if (event.character == '<' || (shift && key == LogicalKeyboardKey.keyP)) { if (event.character == '<' || (shift && key == LogicalKeyboardKey.keyP)) {
if (coord.previousEpisode.peek() == null) return false; if (!model.previous()) return false;
_lastKeyAt = now; _lastKeyAt = now;
coord.playPreviousEpisode();
return true; return true;
} }
// mpv's chapter keys. // mpv's chapter keys.
if (key == LogicalKeyboardKey.pageDown || key == LogicalKeyboardKey.pageUp) { if (key == LogicalKeyboardKey.pageDown || key == LogicalKeyboardKey.pageUp) {
_lastKeyAt = now; _lastKeyAt = now;
return coord.stepChapter(key == LogicalKeyboardKey.pageDown ? 1 : -1); return model.stepChapter(key == LogicalKeyboardKey.pageDown ? 1 : -1);
} }
if (shift && key == LogicalKeyboardKey.arrowRight) { if (shift && key == LogicalKeyboardKey.arrowRight) {
_lastKeyAt = now; _lastKeyAt = now;
coord.skipForward(); model.skipForward();
return true; return true;
} }
if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.arrowDown) { if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.arrowDown) {
_stepVolume(key == LogicalKeyboardKey.arrowUp ? 1 : -1); model.stepVolume(key == LogicalKeyboardKey.arrowUp ? 1 : -1);
return true; return true;
} }
if (key == LogicalKeyboardKey.arrowRight) { if (key == LogicalKeyboardKey.arrowRight) {
_lastKeyAt = now; _lastKeyAt = now;
coord.nudge(10); model.nudge(10);
return true; return true;
} }
if (key == LogicalKeyboardKey.arrowLeft) { if (key == LogicalKeyboardKey.arrowLeft) {
_lastKeyAt = now; _lastKeyAt = now;
coord.nudge(-10); model.nudge(-10);
return true; return true;
} }
if (key == LogicalKeyboardKey.escape) { if (key == LogicalKeyboardKey.escape) {
@@ -312,13 +288,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
// (lists, menus and popovers under the mouse scroll instead). // (lists, menus and popovers under the mouse scroll instead).
return VolumeWheel( return VolumeWheel(
enabled: _full, enabled: _full,
onStep: _stepVolume, onStep: _model.stepVolume,
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
video, video,
if (_full) ...[ if (_full) ...[
_progressLine(),
_blockedCard(), _blockedCard(),
_actionArea(), _actionArea(),
if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()], if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()],
@@ -336,24 +311,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
); );
} }
Widget _video(VideoControlsBuilder? controls) { Widget _video(VideoControlsBuilder? controls) => Video(
final theme = _controlsTheme();
return MaterialDesktopVideoControlsTheme(
normal: theme,
fullscreen: theme,
child: Video(
key: _videoKey, key: _videoKey,
controller: _coord.playback.video, controller: _coord.playback.video,
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false), subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
controls: controls, 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 /// Fullscreen hides the strip and its window buttons: the top bar
/// carries them while the controls are up (restore leaves fullscreen). /// 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) { Widget _sessionControls(VideoState state) {
// The start card sits under the controls (they stay usable on it). // The start card sits under the controls (they stay usable on it).
// The "Player design" setting swaps them live. return Stack(
return Stack(fit: StackFit.expand, children: [const StartBanner(), _controlsSwitch(state)]); fit: StackFit.expand,
}
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,
children: [ children: [
UpNextButton( const StartBanner(),
queue: coord.queue, onPlayNextEpisode: coord.playNextEpisode, look: UpNextLook.uosc, hold: _hold), SessionControls(
UoscButton( model: _model,
key: const ValueKey('uosc-chat'), shown: _controlsShown,
icon: widget.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded, hold: _hold,
tooltip: 'Chat & activity (Tab)', onDoubleClick: widget.onToggleFullscreen,
badge: widget.unread, windowButtons: _fullscreenWindowButtons(),
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,
), ),
], ],
); );
@@ -713,64 +357,26 @@ class _SessionPlayerState extends State<SessionPlayer> {
/// the gap between them and the controls. /// the gap between them and the controls.
static const _edge = 16.0; static const _edge = 16.0;
/// The bottom controls' height in the design in use. /// The bottom controls' height in the skin and size in use.
static double _bottomControlsHeight(bool uosc) => uosc static double _bottomControlsHeight() {
? UoscControls.buttonRowHeight + UoscControls.rowBottomPadding + UoscTimeline.height final s = AppSettings.instance;
: _barHeight + ChapterSeekBar.height; return controlsBottomHeight(s.controlsSkin.value, s.controlsSize.value);
/// The volume flash after a wheel or key step, in the design in use:
/// uosc's vertical slider at the right edge (centred between the top
/// buttons and the bottom controls, as uosc places it), else a pill
/// under the top bar.
Widget _volumeReadout() {
final playback = _coord.playback;
Widget withVolume(Widget Function(double v) build) => StreamBuilder<double>(
stream: playback.player.stream.volume,
initialData: playback.player.state.volume,
builder: (context, v) => build(v.data ?? 100),
);
return SignalBuilder(builder: (context) {
if (AppSettings.instance.mpvControls.value) {
const top = 12 + UoscControls.buttonRowHeight;
final bottom = _bottomControlsHeight(true);
return Positioned(
right: UoscVolumeSlider.width / 2,
top: top,
bottom: bottom,
width: UoscVolumeSlider.width,
child: LayoutBuilder(builder: (context, c) {
final h = UoscVolumeSlider.heightFor(c.maxHeight);
if (h <= UoscVolumeSlider.width) return const SizedBox.shrink();
return Align(
// Centred with the mute button's room below, like uosc.
alignment: Alignment.center,
child: Padding(
padding: const EdgeInsets.only(bottom: UoscVolumeSlider.width),
child: SizedBox(
height: h,
child: VolumeFlash(
trigger: _volumeFlash,
builder: (context) => withVolume((v) => UoscVolumeSlider(volume: v)),
),
),
),
);
}),
);
} }
return Positioned(
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, left: 0,
right: 0, right: 0,
top: _topBarHeight + 8, top: _topBarHeight + 8,
child: Center( child: Center(
child: VolumeFlash( child: VolumeFlash(
trigger: _volumeFlash, trigger: _model.volumeFlash,
builder: (context) => withVolume((v) => VolumeReadout(volume: v)), builder: (context) => SignalBuilder(builder: (context) => VolumeReadout(volume: _model.volume.value)),
), ),
), ),
); );
});
}
/// The action area: one band above the bottom controls for everything /// The action area: one band above the bottom controls for everything
/// that floats there. The status pill (the ready prompt among it) /// that floats there. The status pill (the ready prompt among it)
@@ -781,7 +387,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
/// ([ControlsHold]). /// ([ControlsHold]).
Widget _actionArea() { Widget _actionArea() {
return SignalBuilder(builder: (context) { return SignalBuilder(builder: (context) {
final controls = _bottomControlsHeight(AppSettings.instance.mpvControls.value); final controls = _bottomControlsHeight();
return ValueListenableBuilder<bool>( return ValueListenableBuilder<bool>(
valueListenable: _controlsShown, valueListenable: _controlsShown,
builder: (context, shown, child) => AnimatedPositioned( builder: (context, shown, child) => AnimatedPositioned(
@@ -848,7 +454,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
announce: skip.kind != SkipKind.ending, announce: skip.kind != SkipKind.ending,
controlsShown: _controlsShown, controlsShown: _controlsShown,
hold: _hold, hold: _hold,
child: SkipPill(offer: skip, onPressed: () => coord.skip(skip)), child: SkipPill(offer: skip, onPressed: () => _model.skip(skip)),
), ),
if (showNext) if (showNext)
PillGate( PillGate(
@@ -1171,7 +777,7 @@ const _shaderKeys = [
class _MiniSeekBar extends StatelessWidget { class _MiniSeekBar extends StatelessWidget {
final bool thin; final bool thin;
final ValueChanged<Duration>? onSeek; final ValueChanged<Duration>? onSeek;
const _MiniSeekBar({super.key, this.thin = false, this.onSeek}); const _MiniSeekBar({this.thin = false, this.onSeek});
@override @override
Widget build(BuildContext context) { 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);
}
}
+17 -21
View File
@@ -2,9 +2,7 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fox_utils/fox_utils.dart' show DesktopDropX, DropTarget;
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' import 'package:sync_relay_core/core.dart'
show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, parseCookies; show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, parseCookies;
@@ -12,13 +10,11 @@ import 'package:sync_relay_core/core.dart'
import '../controls/player_settings.dart'; import '../controls/player_settings.dart';
import '../jellyfin/accounts.dart'; import '../jellyfin/accounts.dart';
import '../jellyfin/accounts_panel.dart'; import '../jellyfin/accounts_panel.dart';
import '../playback/yt_dlp_app.dart';
import '../schedule/animeflow.dart'; import '../schedule/animeflow.dart';
import '../screens/home/about_page.dart'; import '../screens/home/about_page.dart';
import '../screens/home/settings_page.dart'; import '../screens/home/settings_page.dart';
import '../sfx/sfx.dart'; import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart'; import '../sfx/sound_bank.dart';
import '../sfx/synth_sfx.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../state/settings.dart'; import '../state/settings.dart';
import '../update/update_controller.dart'; import '../update/update_controller.dart';
@@ -121,12 +117,16 @@ class SettingsTab extends StatelessWidget {
return [ return [
const SettingsHintCard( const SettingsHintCard(
icon: Icons.tune, icon: Icons.tune,
text: "Player settings are also in the player's ⚙, at the right end of its controls.", text: "Player settings are also in the player: ⚙ → Player settings, at the right of its controls.",
), ),
SettingsPanel( SettingsPanel(
title: 'Player', title: 'Player',
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()), child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()),
), ),
SettingsPanel(
title: 'Controls',
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: controlsSettingRows()),
),
const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()), const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()),
const SettingsPanel(title: 'yt-dlp', child: YtDlpSettings()), const SettingsPanel(title: 'yt-dlp', child: YtDlpSettings()),
]; ];
@@ -377,7 +377,7 @@ class RecentServersPanel extends StatelessWidget {
/// The app's yt-dlp (web page links): its version, "Update now", and the /// The app's yt-dlp (web page links): its version, "Update now", and the
/// last error. /// last error.
class YtDlpSettings extends StatefulWidget { class YtDlpSettings extends StatefulWidget {
/// Defaults to [AppYtDlp.instance]'s. /// Defaults to the app's ([Ports.links]).
final Future<YtDlp>? ytDlp; final Future<YtDlp>? ytDlp;
const YtDlpSettings({super.key, this.ytDlp}); const YtDlpSettings({super.key, this.ytDlp});
@@ -386,7 +386,7 @@ class YtDlpSettings extends StatefulWidget {
} }
class _YtDlpSettingsState extends State<YtDlpSettings> { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -469,18 +469,17 @@ class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
}; };
Future<void> _pickFile() async { Future<void> _pickFile() async {
final List<PlatformFile> files; final List<String> files;
try { try {
files = await FilePicker.pickFiles( files = await SessionState.instance.ports.folders.pickFiles(
dialogTitle: 'Pick a cookies.txt or a JSON cookie export', dialogTitle: 'Pick a cookies.txt or a JSON cookie export',
type: FileType.custom, extensions: const ['txt', 'json'],
allowedExtensions: const ['txt', 'json'],
); );
} catch (_) { } catch (_) {
_reload('file dialog unavailable — try again'); _reload('file dialog unavailable — try again');
return; return;
} }
final path = files.firstOrNull?.path; final path = files.firstOrNull;
if (path != null) await _importFile(path); if (path != null) await _importFile(path);
} }
@@ -532,12 +531,12 @@ class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
return DropTarget( return SessionState.instance.ports.shell.dropTarget(
onDragEntered: (_) => setState(() => _dragging = true), onEnter: () => setState(() => _dragging = true),
onDragExited: (_) => setState(() => _dragging = false), onExit: () => setState(() => _dragging = false),
onDragDone: (d) { onDrop: (paths, _) {
setState(() => _dragging = false); setState(() => _dragging = false);
final path = DesktopDropX.pathsOf(d.files).firstOrNull; final path = paths.firstOrNull;
if (path != null) _importFile(path); if (path != null) _importFile(path);
}, },
child: AnimatedContainer( child: AnimatedContainer(
@@ -794,10 +793,7 @@ class NotificationsInfo extends StatelessWidget {
class SoundSettings extends StatelessWidget { class SoundSettings extends StatelessWidget {
const SoundSettings({super.key}); const SoundSettings({super.key});
void _preview(SfxKind kind) { void _preview(SfxKind kind) => unawaited(SessionState.instance.sfx.preview(kind, readyIndex: 2));
final sfx = SessionState.instance.sfx;
if (sfx is SynthSfx) unawaited(sfx.preview(kind, readyIndex: 2));
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
+1 -1
View File
@@ -50,7 +50,7 @@ class _SitOutCardState extends State<SitOutCard> {
final now = coord.nowPlaying.value; final now = coord.nowPlaying.value;
final playing = r?.transport == 'playing'; final playing = r?.transport == 'playing';
final duration = (r?.media?['duration'] as num?)?.toDouble() ?? 0; 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()); Duration secs(double s) => Duration(milliseconds: (s * 1000).round());
final where = r?.media == null final where = r?.media == null
? 'Nothing is up' ? 'Nothing is up'
+17 -6
View File
@@ -11,11 +11,14 @@ import '../jellyfin/session_media.dart';
import '../screens/home/health_probe.dart'; import '../screens/home/health_probe.dart';
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
import '../platform/app_log.dart' show appLogDir; import '../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/sfx.dart';
import '../sfx/sound_bank.dart'; import '../sfx/sound_bank.dart';
import '../sfx/synth_sfx.dart'; import '../sfx/synth_sfx.dart';
import '../session/health.dart'; import '../session/health.dart';
import '../session/notifier.dart';
import '../session/connection.dart' show Connection;
import '../session/session_coordinator.dart'; import '../session/session_coordinator.dart';
import '../widgets/avatar_pipeline.dart'; import '../widgets/avatar_pipeline.dart';
import 'connect_prefs.dart'; import 'connect_prefs.dart';
@@ -160,13 +163,21 @@ class SessionState {
final route = signal(AppRoute.home); final route = signal(AppRoute.home);
final recents = signal<List<RecentServer>>(const []); final recents = signal<List<RecentServer>>(const []);
final status = signal(const HealthStatus(HealthSeverity.info, 'idle')); final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
final SfxService sfx = SynthSfx( /// Owns the app's audio device ([SynthSfx.dispose] on close).
final SynthSfx sfx = SynthSfx(
volume: () => AppSettings.instance.soundVolume.peek(), volume: () => AppSettings.instance.soundVolume.peek(),
// A nudge is an explicit poke: it ignores its switch (not the volume). // A nudge is an explicit poke: it ignores its switch (not the volume).
enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name), enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name),
presetFor: soundPresetFor, 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.
late final Connection connection = Connection(notify: notifier.notify);
late final SessionCoordinator coordinator =
SessionCoordinator(notifier: notifier, connection: connection, ytDlp: ports.links);
final lastForm = ConnectForm(); final lastForm = ConnectForm();
JellyfinAccounts get jellyfin => JellyfinAccounts.instance; JellyfinAccounts get jellyfin => JellyfinAccounts.instance;
/// Set when the hosted server died mid-session; Home shows it on the /// Set when the hosted server died mid-session; Home shows it on the
@@ -187,7 +198,7 @@ class SessionState {
/// default) when the app-support folder is not available. /// default) when the app-support folder is not available.
static Future<String> _sharedYtDlpDir() async { static Future<String> _sharedYtDlpDir() async {
try { try {
return (await AppYtDlp.instance.ytDlp).dir; return (await instance.ports.links.ytDlp).dir;
} catch (_) { } catch (_) {
return ''; return '';
} }
@@ -284,7 +295,7 @@ class SessionState {
try { try {
if (carry != null) await _resume(carry); if (carry != null) await _resume(carry);
} catch (e) { } catch (e) {
coordinator.showToast("couldn't put it up again: $e"); coordinator.notify(ActionFailed("couldn't put it up again: $e"));
} }
} finally { } finally {
switching.value = false; switching.value = false;
@@ -341,7 +352,7 @@ class SessionState {
}); });
try { try {
await landed.future.timeout(const Duration(seconds: 20)); await landed.future.timeout(const Duration(seconds: 20));
coordinator.sync.sendSeek(carry.position); coordinator.connection.sendSeek(carry.position);
} on TimeoutException { } on TimeoutException {
debugPrint('resume: the media did not come up; not seeking'); debugPrint('resume: the media did not come up; not seeking');
} finally { } finally {
+70 -16
View File
@@ -1,7 +1,10 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hive_ce/hive.dart'; import 'package:hive_ce/hive.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import '../controls/model/controls_prefs.dart';
import '../shaders/shader_presets.dart'; import '../shaders/shader_presets.dart';
// App preferences (Settings page), one Hive box. Signals start at their // App preferences (Settings page), one Hive box. Signals start at their
@@ -17,7 +20,14 @@ const _keyShaderPreset = 'shaderPreset';
const _keyShaderQuality = 'shaderQuality'; const _keyShaderQuality = 'shaderQuality';
const _keyCustomShaders = 'customShaders'; const _keyCustomShaders = 'customShaders';
const _keyAutoAdvance = 'autoAdvance'; const _keyAutoAdvance = 'autoAdvance';
/// Legacy: true was the uosc design (now [ControlsSkin.minimal]); read
/// once when [_keyControlsSkin] is missing.
const _keyMpvControls = 'mpvControls'; const _keyMpvControls = 'mpvControls';
const _keyControlsSkin = 'controlsSkin';
const _keyTimeDisplay = 'timeDisplay';
const _keyControlsSize = 'controlsSize';
const _keyControlsHideSecs = 'controlsHideSecs';
const _keyHiddenControls = 'hiddenControls';
const _keyHideReReleases = 'hideReReleases'; const _keyHideReReleases = 'hideReReleases';
const _keyProgressLine = 'progressLine'; const _keyProgressLine = 'progressLine';
const _keySoundVolume = 'soundVolume'; const _keySoundVolume = 'soundVolume';
@@ -32,6 +42,12 @@ const _keyShareDiagnostics = 'shareDiagnostics';
enum HwdecMode { auto, off, custom } 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). /// When chat messages pop up as toasts (they always reach the chat).
enum ChatPopups { enum ChatPopups {
all('All'), all('All'),
@@ -62,20 +78,26 @@ class AppSettings {
final unreadyOnSwap = signal(false); final unreadyOnSwap = signal(false);
/// Session: the idle auto-unready sits this member out instead. /// Session: the idle auto-unready sits this member out instead.
final idleSitsOut = signal(false); final idleSitsOut = signal(false);
/// Player design: the uosc controls (`controls/uosc/`) instead of the /// The player controls' skin (`controls/`). Applies live.
/// standard ones (media_kit's with our bars). Applies live. final controlsSkin = signal(ControlsSkin.standard);
final mpvControls = signal(false); /// The bar's clock: elapsed / total or the time left.
final timeDisplay = signal(TimeDisplay.elapsed);
final controlsSize = signal(ControlsSize.normal);
/// Seconds without mouse movement before the controls hide.
final controlsHideSecs = signal(defaultControlsHideSecs);
/// Optional bar buttons switched off (they stay in ⚙).
final hiddenControls = signal<Set<OptionalControl>>(const {});
/// Library: leave out of "Latest in X" what was added long after it /// Library: leave out of "Latest in X" what was added long after it
/// first aired (re-uploads, old seasons added late). /// first aired (re-uploads, old seasons added late).
final hideReReleases = signal(true); final hideReReleases = signal(true);
/// A 2 px progress line at the bottom of the video while the controls /// A 2 px progress line at the bottom of the video while the controls
/// are hidden (both designs). Applies live. /// are hidden (both skins). Applies live.
final progressLine = signal(true); final progressLine = signal(true);
/// Send this app's log lines to the server's session log while in a /// 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. /// session (never chat). Off: nothing is sent but a note that it is off.
final shareDiagnostics = signal(true); final shareDiagnostics = signal(true);
/// Chat message toasts (default: only when paused). Applies live. /// Chat message toasts (default: all). Applies live.
final chatPopups = signal(ChatPopups.paused); final chatPopups = signal(ChatPopups.all);
/// App sounds (`sfx/`): 0..1, 0 = silent. /// App sounds (`sfx/`): 0..1, 0 = silent.
final soundVolume = signal(1.0); final soundVolume = signal(1.0);
/// `SfxKind` names switched off one by one. /// `SfxKind` names switched off one by one.
@@ -95,13 +117,13 @@ class AppSettings {
/// .glsl files of [ShaderPreset.custom], in mpv order. /// .glsl files of [ShaderPreset.custom], in mpv order.
final customShaders = signal<List<String>>(const []); final customShaders = signal<List<String>>(const []);
/// mpv `hwdec` value: 'auto', 'no', or the custom value ('auto' when /// mpv `hwdec` value: [autoHwdec], 'no', or the custom value
/// custom is blank). /// ([autoHwdec] when custom is blank).
late final hwdec = computed(() => switch (hwdecMode.value) { late final hwdec = computed(() => switch (hwdecMode.value) {
HwdecMode.auto => 'auto', HwdecMode.auto => autoHwdec,
HwdecMode.off => 'no', HwdecMode.off => 'no',
HwdecMode.custom => HwdecMode.custom =>
hwdecCustom.value.trim().isEmpty ? 'auto' : hwdecCustom.value.trim(), hwdecCustom.value.trim().isEmpty ? autoHwdec : hwdecCustom.value.trim(),
}); });
Future<void>? _load; Future<void>? _load;
@@ -127,8 +149,18 @@ class AppSettings {
if (unready is bool) unreadyOnSwap.value = unready; if (unready is bool) unreadyOnSwap.value = unready;
final idleOut = box.get(_keyIdleSitsOut); final idleOut = box.get(_keyIdleSitsOut);
if (idleOut is bool) idleSitsOut.value = idleOut; if (idleOut is bool) idleSitsOut.value = idleOut;
final mpv = box.get(_keyMpvControls); controlsSkin.value =
if (mpv is bool) mpvControls.value = mpv; ControlsSkin.fromStored(box.get(_keyControlsSkin), legacyMpvControls: box.get(_keyMpvControls));
final time = box.get(_keyTimeDisplay);
timeDisplay.value = TimeDisplay.values.where((t) => t.name == time).firstOrNull ?? TimeDisplay.elapsed;
final size = box.get(_keyControlsSize);
controlsSize.value = ControlsSize.values.where((t) => t.name == size).firstOrNull ?? ControlsSize.normal;
final hide = box.get(_keyControlsHideSecs);
if (hide is int && controlsHideChoices.contains(hide)) controlsHideSecs.value = hide;
final hidden = box.get(_keyHiddenControls);
if (hidden is List) {
hiddenControls.value = Set.unmodifiable(OptionalControl.values.where((c) => hidden.contains(c.name)));
}
final hideRe = box.get(_keyHideReReleases); final hideRe = box.get(_keyHideReReleases);
if (hideRe is bool) hideReReleases.value = hideRe; if (hideRe is bool) hideReReleases.value = hideRe;
final line = box.get(_keyProgressLine); final line = box.get(_keyProgressLine);
@@ -136,7 +168,7 @@ class AppSettings {
final share = box.get(_keyShareDiagnostics); final share = box.get(_keyShareDiagnostics);
if (share is bool) shareDiagnostics.value = share; if (share is bool) shareDiagnostics.value = share;
final popups = box.get(_keyChatPopups); 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); final vol = box.get(_keySoundVolume);
if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1); if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1);
final off = box.get(_keySoundsOff); final off = box.get(_keySoundsOff);
@@ -199,9 +231,31 @@ class AppSettings {
_put(_keyIdleSitsOut, on); _put(_keyIdleSitsOut, on);
} }
void setMpvControls(bool on) { void setControlsSkin(ControlsSkin skin) {
mpvControls.value = on; controlsSkin.value = skin;
_put(_keyMpvControls, on); _put(_keyControlsSkin, skin.name);
}
void setTimeDisplay(TimeDisplay t) {
timeDisplay.value = t;
_put(_keyTimeDisplay, t.name);
}
void setControlsSize(ControlsSize size) {
controlsSize.value = size;
_put(_keyControlsSize, size.name);
}
void setControlsHideSecs(int secs) {
controlsHideSecs.value = secs;
_put(_keyControlsHideSecs, secs);
}
void setControlShown(OptionalControl c, bool on) {
final next = {...hiddenControls.value};
on ? next.remove(c) : next.add(c);
hiddenControls.value = Set.unmodifiable(next);
_put(_keyHiddenControls, [for (final c in next) c.name]);
} }
void setProgressLine(bool on) { void setProgressLine(bool on) {
+15 -9
View File
@@ -53,7 +53,21 @@ class SyncClient {
HelloMedia? media, HelloMedia? media,
String hostKey = '', String hostKey = '',
String resume = '', String resume = '',
}) async { }) =>
connectWith(
wsUri: wsUri,
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,
),
);
/// Dials [wsUri], sends [hello] and waits for the welcome (10 s).
Future<WelcomeMessage> connectWith({required Uri wsUri, required HelloMessage hello}) async {
await disconnect(); await disconnect();
clockSync.reset(); clockSync.reset();
this.wsUri = wsUri; this.wsUri = wsUri;
@@ -78,14 +92,6 @@ class SyncClient {
}, },
cancelOnError: false, 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())); ch.sink.add(jsonEncode(hello.toJson()));
_pendingWelcome = welcomeCompleter; _pendingWelcome = welcomeCompleter;
try { try {
@@ -4,7 +4,9 @@ import 'package:flutter/foundation.dart';
import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:sync_relay_core/core.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 // `--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 // UI and the restart. Check failures are logged only — the banner shows up just when
// there is something to install. // there is something to install.
@@ -28,26 +30,23 @@ class UpdateStatus {
// Process args as main() received them — restart relaunches with these. // Process args as main() received them — restart relaunches with these.
List<String> launchArgs = const []; 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); 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 { Future<void> _closeAndRestart(String executable, List<String> args) async {
final keep = args.where((a) => a != '--upgrade').toList();
await shutDownApp(beforeQuit: () async {
try { try {
await Hive.close(); await Hive.close();
} catch (e) { } catch (e) {
_log.warning('closing hive before restart: $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 { class UpdateController {
@@ -115,15 +114,22 @@ class UpdateController {
return; return;
} }
_log.info('update available: ${m.version} (proto ${m.protocol})'); _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, status.value = UpdateStatus(UpdatePhase.available,
manifest: m, canInstall: root != null); manifest: m, canInstall: root != null);
lastCheck.value = 'Update v${m.version} available';
} catch (e) { } catch (e) {
_log.warning('update check failed: $e'); _log.warning('update check failed: $e');
lastCheck.value = '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() { void dismiss() {
final s = status.value; final s = status.value;
if ((s.phase == UpdatePhase.available || s.phase == UpdatePhase.failed) && if ((s.phase == UpdatePhase.available || s.phase == UpdatePhase.failed) &&
+3 -4
View File
@@ -4,7 +4,7 @@ import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../controls/uosc/controls.dart'; import '../controls/frame.dart';
import '../shell/motion.dart'; import '../shell/motion.dart';
// The app's one menu: a frosted dark panel anchored under (or over) the // 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, minWidth: minWidth - shift,
capturedThemes: InheritedTheme.capture(from: context, to: navigator.context), capturedThemes: InheritedTheme.capture(from: context, to: navigator.context),
)); ));
// Over the uosc controls: keep them up while the menu is open. // Over the player's controls: keep them up while the menu is open.
final controls = UoscControls.maybeOf(context); return ControlsFrame.holdWhile(context, open);
return controls == null ? open() : controls.holdDuring(open);
} }
class _AppMenuRoute extends PopupRoute<int> { class _AppMenuRoute extends PopupRoute<int> {
+7 -26
View File
@@ -1,8 +1,7 @@
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../session/notifier.dart' show AppEvent, Copied, CopyFailed, noticeFor;
import '../state/session_state.dart'; import '../state/session_state.dart';
// Reusing what the app shows elsewhere: copying a link or a title, opening // Reusing what the app shows elsewhere: copying a link or a title, opening
@@ -22,28 +21,10 @@ abstract final class ReuseText {
@visibleForTesting @visibleForTesting
void Function(String text)? debugReuseToast; void Function(String text)? debugReuseToast;
void _toast(String text) { void _notify(AppEvent event) {
final debug = debugReuseToast; final debug = debugReuseToast;
if (debug != null) return debug(text); if (debug != null) return debug(noticeFor(event).toast ?? '');
SessionState.instance.coordinator.showToast(text); SessionState.instance.coordinator.notify(event);
}
/// Opens [url] in the default browser (no url_launcher dependency); false
/// when no browser could be started.
Future<bool> openInBrowser(String url) async {
try {
final ProcessResult r;
if (Platform.isWindows) {
r = await Process.run('rundll32', ['url.dll,FileProtocolHandler', url]);
} else if (Platform.isMacOS) {
r = await Process.run('open', [url]);
} else {
r = await Process.run('xdg-open', [url]);
}
return r.exitCode == 0;
} catch (_) {
return false;
}
} }
/// Puts [text] on the clipboard and confirms it with a toast ([done]: /// 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 { try {
await Clipboard.setData(ClipboardData(text: text)); await Clipboard.setData(ClipboardData(text: text));
} catch (_) { } catch (_) {
_toast("Couldn't copy"); _notify(const CopyFailed());
return; return;
} }
_toast(done); _notify(Copied(done));
} }
/// Opens [url] in the browser; when none opens, copies it instead and /// Opens [url] in the browser; when none opens, copies it instead and
/// says so. /// says so.
Future<void> openLink(String url) async { 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"); 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'; 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 // background check on first build; renders nothing until there is an
// update to offer. Never shown over playback. // update to offer. Never shown over playback.
class UpdateBanner extends StatefulWidget { class UpdateBanner extends StatefulWidget {
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
@@ -18,43 +17,37 @@ class WindowButtons extends StatefulWidget {
State<WindowButtons> createState() => _WindowButtonsState(); 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; bool _maximized = false;
late final void Function() _stopWatch;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
windowManager.addListener(this); _stopWatch = _shell.watchMaximized((v) {
windowManager.isMaximized().then((v) { if (mounted) setState(() => _maximized = v);
});
_shell.isMaximized().then((v) {
if (mounted) setState(() => _maximized = v); if (mounted) setState(() => _maximized = v);
}); });
} }
@override @override
void dispose() { void dispose() {
windowManager.removeListener(this); _stopWatch();
super.dispose(); super.dispose();
} }
@override
void onWindowMaximize() {
if (mounted) setState(() => _maximized = true);
}
@override
void onWindowUnmaximize() {
if (mounted) setState(() => _maximized = false);
}
Future<void> _toggleMaximize() async { Future<void> _toggleMaximize() async {
if (widget.onToggleFullscreen != null) { if (widget.onToggleFullscreen != null) {
widget.onToggleFullscreen!(); widget.onToggleFullscreen!();
return; return;
} }
if (await windowManager.isMaximized()) { if (await _shell.isMaximized()) {
await windowManager.unmaximize(); await _shell.unmaximize();
} else { } 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.coordinator.leave();
await SessionState.instance.stopServer(); await SessionState.instance.stopServer();
} catch (_) {} } catch (_) {}
await windowManager.close(); await _shell.requestClose();
} }
@override @override
@@ -77,7 +70,7 @@ class _WindowButtonsState extends State<WindowButtons> with WindowListener {
icon: Icons.remove, icon: Icons.remove,
tooltip: 'Minimize', tooltip: 'Minimize',
focusNode: focus, focusNode: focus,
onPressed: () => windowManager.minimize(), onPressed: () => _shell.minimize(),
), ),
_WinBtn( _WinBtn(
icon: restored ? Icons.filter_none : Icons.crop_square, 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,
);
}
}
@@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
flutter_soloud
jni jni
) )
+12 -2
View File
@@ -1,6 +1,16 @@
#include <stdio.h>
#include <unistd.h>
#include "my_application.h" #include "my_application.h"
int main(int argc, char** argv) { int main(int argc, char** argv) {
g_autoptr(MyApplication) app = my_application_new(); MyApplication* app = my_application_new();
return g_application_run(G_APPLICATION(app), argc, argv); int status = g_application_run(G_APPLICATION(app), argc, argv);
// The window is gone and the engine with it (the app disposed its mpv
// players and left before closing the window). Skip exit(): its handlers
// include NVIDIA's EGL teardown (libEGL_nvidia), which segfaults inside
// exit() for Flutter apps. Nothing is left to flush but stdio.
fflush(stdout);
fflush(stderr);
_exit(status);
} }
@@ -1,5 +1,8 @@
#include "my_application.h" #include "my_application.h"
#include <stdio.h>
#include <unistd.h>
#include <flutter_linux/flutter_linux.h> #include <flutter_linux/flutter_linux.h>
#ifdef GDK_WINDOWING_X11 #ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h> #include <gdk/gdkx.h>
@@ -38,6 +41,28 @@ static void window_method_cb(FlMethodChannel* channel, FlMethodCall* call,
fl_method_call_respond(call, response, nullptr); fl_method_call_respond(call, response, nullptr);
} }
// Ends the process 15 s after the first close request whatever is still
// running: the app's close (lib/src/platform/app_shutdown.dart) is bounded
// to a few seconds, but a native teardown can block for good, and the
// process must never stay behind a hidden window.
static gpointer exit_watchdog(gpointer data) {
g_usleep(15 * G_USEC_PER_SEC);
fflush(stdout);
fflush(stderr);
_exit(0);
return nullptr;
}
static gboolean window_delete_cb(GtkWidget* widget, GdkEvent* event,
gpointer user_data) {
static gboolean armed = FALSE;
if (!armed) {
armed = TRUE;
g_thread_unref(g_thread_new("exit-watchdog", exit_watchdog, nullptr));
}
return FALSE; // window_manager decides whether the window closes
}
static gboolean window_focus_in_cb(GtkWidget* widget, GdkEvent* event, static gboolean window_focus_in_cb(GtkWidget* widget, GdkEvent* event,
gpointer user_data) { gpointer user_data) {
gtk_window_set_urgency_hint(GTK_WINDOW(widget), FALSE); gtk_window_set_urgency_hint(GTK_WINDOW(widget), FALSE);
@@ -111,6 +136,10 @@ static void my_application_activate(GApplication* application) {
self); self);
gtk_widget_realize(GTK_WIDGET(view)); gtk_widget_realize(GTK_WIDGET(view));
// Before the plugins: window_manager's delete-event handler stops the
// emission while the app holds the close.
g_signal_connect(window, "delete-event", G_CALLBACK(window_delete_cb),
nullptr);
fl_register_plugins(FL_PLUGIN_REGISTRY(view)); fl_register_plugins(FL_PLUGIN_REGISTRY(view));
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
+42 -2
View File
@@ -153,6 +153,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.2" version: "0.1.2"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_picker: file_picker:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -227,6 +235,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_soloud:
dependency: "direct main"
description:
name: flutter_soloud
sha256: "1c297e52182c5e8fd447ae8430bf0b598445dc586e72863ceeef0416242b2ae8"
url: "https://pub.dev"
source: hosted
version: "5.2.1"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -246,6 +262,14 @@ packages:
url: "https://git.senkochan.moe/Foxyz/fox_utils.git" url: "https://git.senkochan.moe/Foxyz/fox_utils.git"
source: git source: git
version: "0.1.9" version: "0.1.9"
glob:
dependency: transitive
description:
name: glob
sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
hive_ce: hive_ce:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -495,6 +519,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "2.1.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "90eaad8059ebc2004a63810a4696ea9acdb02473e47ed3b6b3bafb100ac77e31"
url: "https://pub.dev"
source: hosted
version: "0.19.5"
objective_c: objective_c:
dependency: transitive dependency: transitive
description: description:
@@ -623,6 +655,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.0" version: "7.0.0"
process:
dependency: transitive
description:
name: process
sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891"
url: "https://pub.dev"
source: hosted
version: "5.0.6"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
@@ -786,14 +826,14 @@ packages:
path: "../../packages/core" path: "../../packages/core"
relative: true relative: true
source: path source: path
version: "1.1.2" version: "1.2.0"
sync_relay_server: sync_relay_server:
dependency: "direct main" dependency: "direct main"
description: description:
path: "../../packages/server" path: "../../packages/server"
relative: true relative: true
source: path source: path
version: "1.1.2" version: "1.2.0"
synchronized: synchronized:
dependency: transitive dependency: transitive
description: description:
+9
View File
@@ -36,6 +36,15 @@ dependencies:
git: git:
url: https://git.senkochan.moe/Foxyz/leaf_widgets.git url: https://git.senkochan.moe/Foxyz/leaf_widgets.git
ref: v0.1.0 ref: v0.1.0
# App sounds (sfx/, platform/audio/): SoLoud + miniaudio, built from
# source by its build hook (needs only a C++ toolchain).
flutter_soloud: ^5.2.1
# The sounds are WAVs made in memory: no Ogg/Opus/FLAC codecs, so the
# build hook downloads and bundles no Xiph libraries.
hooks:
user_defines:
flutter_soloud:
no_xiph_libs: true
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
+4 -4
View File
@@ -114,7 +114,7 @@ void main() {
a.seekTo(40); a.seekTo(40);
await _poll(() { await _poll(() {
final r = a.serverState.peek(); final r = a.serverState.peek();
return r != null && r.seekBy == a.sync.memberId && (r.position - 40).abs() < 2; return r != null && r.seekBy == a.connection.memberId && (r.position - 40).abs() < 2;
}, 'server at 0:40'); }, 'server at 0:40');
await _poll(() => a.skipOffer.peek()?.kind == SkipKind.opening, 'Skip OP offered'); await _poll(() => a.skipOffer.peek()?.kind == SkipKind.opening, 'Skip OP offered');
expect(a.skipOffer.peek()!.target, const Duration(minutes: 2)); expect(a.skipOffer.peek()!.target, const Duration(minutes: 2));
@@ -125,9 +125,9 @@ void main() {
await _poll(() { await _poll(() {
final r = a.serverState.peek(); final r = a.serverState.peek();
return r != null && return r != null &&
r.seekBy == a.sync.memberId && r.seekBy == a.connection.memberId &&
serverPositionAt(r, a.sync.serverNow()) >= 120 && serverPositionAt(r, a.connection.serverNow()) >= 120 &&
serverPositionAt(r, a.sync.serverNow()) < 125; serverPositionAt(r, a.connection.serverNow()) < 125;
}, 'server at the OP end'); }, 'server at the OP end');
await _poll(() => b.playback.positionSecs >= 119.5 && b.playback.positionSecs < 126, 'b follows'); await _poll(() => b.playback.positionSecs >= 119.5 && b.playback.positionSecs < 126, 'b follows');
await _poll(() => seeks(b) == seeksBefore + 1, 'b saw the Skip OP seek'); await _poll(() => seeks(b) == seeksBefore + 1, 'b saw the Skip OP seek');
+218
View File
@@ -0,0 +1,218 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:signals/signals_flutter.dart';
import 'package:watch_together/src/controls/controls_probe.dart';
import 'package:watch_together/src/controls/model/controls_layout.dart';
import 'package:watch_together/src/controls/model/controls_model.dart';
import 'package:watch_together/src/controls/model/controls_prefs.dart';
import 'package:watch_together/src/controls/session_controls.dart';
import 'package:watch_together/src/playback/media_marks.dart';
import 'package:watch_together/src/state/session_state.dart';
import 'package:watch_together/src/state/settings.dart';
// The controls model and its layout spec: which controls the bar has, in
// which order, what goes to ⚙; and both skins drawing the same order.
const none = <OptionalControl>{};
void main() {
group('bar layout', () {
test('play first, fullscreen last; no episode, no chapters: the base set', () {
final bar = barControls(const ControlsSnapshot(), none);
expect(bar.left, [BarControl.playPause, BarControl.volume, BarControl.time]);
expect(bar.right, [BarControl.subtitles, BarControl.audio, BarControl.settings, BarControl.chat, BarControl.fullscreen]);
});
test('episodes and chapters slot in after play, in the spec order', () {
final bar = barControls(
const ControlsSnapshot(previousLabel: 'S1E6', nextLabel: 'S1E8', hasChapters: true, hasDuration: true), none);
expect(bar.left, [
BarControl.playPause,
BarControl.previous,
BarControl.next,
BarControl.chapterPrevious,
BarControl.chapterNext,
BarControl.volume,
BarControl.time,
]);
expect(barControls(const ControlsSnapshot(nextLabel: 'Up'), none).left,
[BarControl.playPause, BarControl.next, BarControl.volume, BarControl.time]);
});
test('hidden optional buttons leave the bar and show in ⚙', () {
const s = ControlsSnapshot(hasChapters: true, hasDuration: true);
final hidden = OptionalControl.values.toSet();
final bar = barControls(s, hidden);
expect(bar.left, [BarControl.playPause, BarControl.volume, BarControl.time]);
expect(bar.right, [BarControl.settings, BarControl.fullscreen]);
expect(menuActions(s, hidden), containsAllInOrder([ControlAction.subtitles, ControlAction.audio, ControlAction.chat]));
final shown = menuActions(s, none);
expect(shown, isNot(contains(ControlAction.subtitles)));
expect(shown, isNot(contains(ControlAction.chat)));
});
test('⚙ menu: what applies, in a fixed order', () {
expect(menuActions(const ControlsSnapshot(), none), [
ControlAction.shaders,
ControlAction.changeVideo,
ControlAction.reload,
ControlAction.markAllReady,
ControlAction.markProblem,
ControlAction.playerSettings,
ControlAction.leave,
]);
final all = menuActions(const ControlsSnapshot(hasChapters: true, hasDuration: true, hasLink: true), none);
expect(all.first, ControlAction.chapters);
expect(all, containsAllInOrder([ControlAction.skipForward, ControlAction.copyLink]));
expect(menuActions(const ControlsSnapshot(solo: true), none), isNot(contains(ControlAction.markAllReady)),
reason: 'nobody to mark ready');
});
test('play / pause follows the session: playing, ready and waiting, not ready', () {
expect(controlIcon(BarControl.playPause, const ControlsSnapshot(play: PlayState.playing)), Icons.pause_rounded);
expect(controlIcon(BarControl.playPause, const ControlsSnapshot(play: PlayState.paused)), Icons.play_arrow_rounded);
expect(controlIcon(BarControl.playPause, const ControlsSnapshot(play: PlayState.waiting)), Icons.check_rounded);
expect(controlTooltip(BarControl.playPause, const ControlsSnapshot(play: PlayState.playing)),
'Pause for everyone (Space)');
expect(controlTooltip(BarControl.playPause, const ControlsSnapshot(solo: true)), 'Play (Space)');
});
test('episode buttons: the label and the keys', () {
const s = ControlsSnapshot(previousLabel: 'S1E6 Before', nextLabel: 'S1E7 Battle with the Heroes');
expect(controlTooltip(BarControl.next, s), 'Next: S1E7 Battle with the Heroes (> or Shift+N)');
expect(controlTooltip(BarControl.previous, s), 'Previous: S1E6 Before (< or Shift+P)');
expect(controlTooltip(BarControl.next, const ControlsSnapshot(nextLabel: 'Clip', nextIsUpNext: true)),
'Up next: Clip (> or Shift+N)');
expect(controlEnabled(BarControl.chapterNext, const ControlsSnapshot(hasChapters: true)), isFalse,
reason: 'no duration yet');
});
test('clock: elapsed / total, or the time left', () {
const p = Duration(minutes: 6), d = Duration(minutes: 24);
expect(clockText(p, d, TimeDisplay.elapsed), '6:00 / 24:00');
expect(clockText(p, d, TimeDisplay.remaining), '-18:00');
expect(clockText(p, Duration.zero, TimeDisplay.remaining), '6:00 / 0:00', reason: 'length unknown');
expect(TimeDisplay.elapsed.toggled, TimeDisplay.remaining);
});
});
group('both skins draw the model', () {
final s = AppSettings.instance;
final coord = SessionState.instance.coordinator;
tearDown(() {
s.controlsSkin.value = ControlsSkin.standard;
s.hiddenControls.value = const {};
s.progressLine.value = true;
coord.playback.chapters.value = const [];
coord.playback.duration.value = Duration.zero;
});
Future<PlayerControlsModel> pump(WidgetTester tester, ControlsSkin skin) async {
tester.view.physicalSize = const Size(1280, 720);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
s.controlsSkin.value = skin;
final model = PlayerControlsModel(
coord: coord,
fullscreen: signal(false),
chatOpen: signal(false),
unread: signal(0),
onToggleFullscreen: () {},
onToggleChat: () {},
onChangeVideo: () {},
onCollapse: () {},
onLeave: () {},
);
final shown = ValueNotifier(true);
final hold = ControlsHold();
addTearDown(() {
model.dispose();
shown.dispose();
hold.dispose();
});
await tester.pumpWidget(MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(
backgroundColor: Colors.black,
body: SessionControls(model: model, shown: shown, hold: hold, onDoubleClick: () {}),
),
));
await tester.pump();
return model;
}
/// The bar's controls left to right, by their keys.
List<BarControl> drawn(WidgetTester tester) {
final found = <(double, BarControl)>[];
for (final c in BarControl.values) {
final f = find.byKey(ValueKey('control-${c.name}'));
if (f.evaluate().isEmpty) continue;
final r = tester.getRect(f);
if (r.width > 0) found.add((r.left, c));
}
found.sort((a, b) => a.$1.compareTo(b.$1));
return [for (final (_, c) in found) c];
}
for (final skin in ControlsSkin.values) {
testWidgets('${skin.label}: the layout\'s order, play leftmost, fullscreen rightmost', (tester) async {
coord.playback.duration.value = const Duration(minutes: 24);
coord.playback.chapters.value = chaptersFromStarts([
('A', Duration.zero),
('B', const Duration(minutes: 2)),
], const Duration(minutes: 24));
final model = await pump(tester, skin);
final bar = model.bar.value;
final want = [
...bar.left,
...bar.right,
].where((c) => skin == ControlsSkin.standard || c != BarControl.time).toList();
expect(drawn(tester), want);
expect(want.first, BarControl.playPause);
expect(want.last, BarControl.fullscreen);
expect(want, contains(BarControl.chapterNext));
s.setControlShown(OptionalControl.chat, false);
await tester.pump();
expect(drawn(tester), isNot(contains(BarControl.chat)));
});
}
testWidgets('⚙ lists the rest', (tester) async {
await pump(tester, ControlsSkin.minimal);
await tester.tap(find.byKey(const ValueKey('control-settings')));
await tester.pumpAndSettle();
for (final a in [ControlAction.reload, ControlAction.markProblem, ControlAction.playerSettings]) {
expect(find.byKey(ValueKey('controls-menu-${a.name}')), findsOneWidget, reason: a.name);
}
expect(find.byKey(const ValueKey('controls-menu-chapters')), findsNothing, reason: 'no chapters');
await tester.tapAt(const Offset(5, 5));
await tester.pumpAndSettle();
});
testWidgets('Minimal hidden: the timeline stays as a line unless switched off', (tester) async {
s.controlsHideSecs.value = 2;
addTearDown(() => s.controlsHideSecs.value = defaultControlsHideSecs);
await pump(tester, ControlsSkin.minimal);
double opacity() => tester.widget<AnimatedOpacity>(find.byKey(const ValueKey('minimal-timeline-wrap'))).opacity;
await tester.pump(const Duration(seconds: 3));
await tester.pumpAndSettle();
expect(opacity(), 1, reason: 'hidden, line on');
s.progressLine.value = false;
await tester.pump();
expect(opacity(), 0, reason: 'hidden, line off');
});
testWidgets('Standard hidden: a thin progress line unless switched off', (tester) async {
await pump(tester, ControlsSkin.standard);
expect(find.byKey(const ValueKey('progress-line-standard')), findsNothing, reason: 'controls up');
await tester.pump(const Duration(seconds: 4));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('progress-line-standard')), findsOneWidget);
s.progressLine.value = false;
await tester.pump();
expect(find.byKey(const ValueKey('progress-line-standard')), findsNothing);
});
});
}
@@ -1,3 +1,5 @@
// restructure: rewrite in step 1 (Connection + Identity + Presence): reconnect,
// resume and ready restore; "feed and chat toasts" in step 2 (Notifier).
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
+2
View File
@@ -1,3 +1,5 @@
// restructure: rewrite in step 2 (Status + Notifier): delivery lines become
// part of the one computed status (and the source-gone half of P2).
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
+2
View File
@@ -1,3 +1,5 @@
// restructure: rewrite in step 4 (Library + Media identity): series
// grouping on the page follows L1/L2.
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
@@ -1,3 +1,5 @@
// restructure: rewrite in step 4 (Library + Media identity): groupBySeries
// becomes L1 local grouping; the scan and classify tests stay.
import 'dart:io'; import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
+61 -3
View File
@@ -1,4 +1,7 @@
// restructure: rewrite in step 3 (Your server): hosted recents and the host
// card; the rest stays.
import 'dart:convert'; import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -7,6 +10,7 @@ import 'package:watch_together/src/schedule/ui/schedule_page.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/screens/home/health_probe.dart'; import 'package:watch_together/src/screens/home/health_probe.dart';
import 'package:watch_together/src/screens/home/media_picker.dart'; import 'package:watch_together/src/screens/home/media_picker.dart';
import 'package:watch_together/src/screens/home/update_indicator.dart';
import 'package:watch_together/src/shell/app_shell.dart'; import 'package:watch_together/src/shell/app_shell.dart';
import 'package:watch_together/src/shell/library_link.dart' show soloPlayHint; import 'package:watch_together/src/shell/library_link.dart' show soloPlayHint;
import 'package:watch_together/src/state/session_state.dart'; import 'package:watch_together/src/state/session_state.dart';
@@ -58,13 +62,18 @@ void main() {
}); });
}); });
test('chat pop-ups default to All', () {
expect(AppSettings().chatPopups.value, ChatPopups.all);
});
test('hwdec value follows mode', () { test('hwdec value follows mode', () {
final s = AppSettings(); final s = AppSettings();
expect(s.hwdec.value, 'auto'); expect(s.hwdec.value, autoHwdec);
expect(autoHwdec, Platform.isWindows ? 'auto-copy' : 'auto');
s.setHwdecMode(HwdecMode.off); s.setHwdecMode(HwdecMode.off);
expect(s.hwdec.value, 'no'); expect(s.hwdec.value, 'no');
s.setHwdecMode(HwdecMode.custom); s.setHwdecMode(HwdecMode.custom);
expect(s.hwdec.value, 'auto'); expect(s.hwdec.value, autoHwdec);
s.setHwdecCustom(' vaapi '); s.setHwdecCustom(' vaapi ');
expect(s.hwdec.value, 'vaapi'); expect(s.hwdec.value, 'vaapi');
}); });
@@ -150,7 +159,7 @@ void main() {
expect(find.text('Hardware decoding'), findsOneWidget); expect(find.text('Hardware decoding'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('settings-updates'))); await tester.tap(find.byKey(const ValueKey('settings-updates')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Check for updates on start'), findsOneWidget); expect(find.text('Check for updates automatically'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('settings-shortcuts'))); await tester.tap(find.byKey(const ValueKey('settings-shortcuts')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Toggle ready'), findsOneWidget); expect(find.text('Toggle ready'), findsOneWidget);
@@ -288,4 +297,53 @@ void main() {
expect(tester.takeException(), isNull, reason: source); expect(tester.takeException(), isNull, reason: source);
} }
}); });
group('update bar', () {
const m = UpdateManifest(
version: '1.2.1', protocol: 1, url: 'http://x/a.tgz', notes: 'Faster joins');
Future<UpdateController> pump(WidgetTester tester, UpdateStatus s,
{String current = '1.2.0'}) async {
final c = UpdateController(
currentVersion: current,
executablePath: '/nowhere/watch_together',
restart: (_, _) async {});
c.status.value = s;
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: Column(children: [UpdateBar(controller: c)]))));
return c;
}
testWidgets('says what is ready, the version running, Later hides it',
(tester) async {
await pump(tester,
const UpdateStatus(UpdatePhase.available, manifest: m, canInstall: true));
expect(find.text('Starsync 1.2.1 is ready'), findsOneWidget);
expect(find.text('You are running 1.2.0 · Faster joins'), findsOneWidget);
expect(find.text('Update and restart'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('update-later')));
await tester.pump();
expect(find.byKey(const ValueKey('update-bar')), findsNothing);
});
testWidgets('a copy that cannot update itself says it is older',
(tester) async {
await pump(tester, const UpdateStatus(UpdatePhase.available, manifest: m));
expect(find.text('This copy of Starsync is older than the latest release (1.2.1)'),
findsOneWidget);
expect(find.text('Update and restart'), findsNothing);
});
testWidgets('a test build is not told which version it runs',
(tester) async {
await pump(tester,
const UpdateStatus(UpdatePhase.available, manifest: m, canInstall: true),
current: '1.2.1-dev.2');
expect(find.text('Faster joins'), findsOneWidget);
});
testWidgets('nothing to install: no bar', (tester) async {
await pump(tester, const UpdateStatus(UpdatePhase.idle));
expect(find.byKey(const ValueKey('update-bar')), findsNothing);
});
});
} }
+2
View File
@@ -1,3 +1,5 @@
// restructure: rewrite in step 5 (Watch tracking): the progress-reporting
// checks; the guest-through-the-relay flow stays.
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
+2 -17
View File
@@ -1,3 +1,5 @@
// restructure: rewrite in step 5 (What's next + Watch tracking): the
// "progress" and "episodes" groups (reporting, auto-advance swapper).
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
@@ -364,11 +366,6 @@ void main() {
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7'); expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7');
}); });
test('the floating prompt starts two minutes before the end', () {
expect(nextPromptStart(null, const Duration(minutes: 24)), const Duration(minutes: 22));
expect(nextPromptStart(null, const Duration(seconds: 90)), Duration.zero);
});
test('one auto-advance swapper: the lowest id among members with their own account', () { test('one auto-advance swapper: the lowest id among members with their own account', () {
final members = [ final members = [
member('m10', 'Ann', jf: {fixtureServerId: true}), member('m10', 'Ann', jf: {fixtureServerId: true}),
@@ -501,18 +498,6 @@ void main() {
const JellyfinMedia( const JellyfinMedia(
serverId: fixtureServerId, baseUrl: 'b', itemId: nextEpisodeId, title: 'Battle with the Heroes')); serverId: fixtureServerId, baseUrl: 'b', itemId: nextEpisodeId, title: 'Battle with the Heroes'));
testWidgets('next / previous buttons: tooltip, hidden without an episode', (tester) async {
var pressed = 0;
await tester.pumpWidget(app(Row(children: [
EpisodeSkipButton(episode: null, next: false, onPressed: () {}),
EpisodeSkipButton(episode: next, next: true, onPressed: () => pressed++),
])));
expect(find.byKey(const ValueKey('episode-previous')), findsNothing);
expect(find.byTooltip('Next: S1E7 Battle with the Heroes (> or Shift+N)'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('episode-next')));
expect(pressed, 1);
});
testWidgets('end card: Next prompt, countdown with Cancel', (tester) async { testWidgets('end card: Next prompt, countdown with Cancel', (tester) async {
var play = 0, cancel = 0; var play = 0, cancel = 0;
await tester.pumpWidget(app(EndOfEpisodeCard( await tester.pumpWidget(app(EndOfEpisodeCard(
+2
View File
@@ -1,3 +1,5 @@
// restructure: rewrite in step 3 (Your server): the "solo" and "hosting"
// tests (D9: no solo mode); the rest stays.
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
-33
View File
@@ -1,33 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/widgets/media_input.dart';
void main() {
test('http url routes to linkUrl', () {
expect(parseMediaInput('http://example.com/v.mp4'),
(mediaPath: '', linkUrl: 'http://example.com/v.mp4'));
});
test('https url routes to linkUrl', () {
expect(parseMediaInput('https://example.com/v.mp4'),
(mediaPath: '', linkUrl: 'https://example.com/v.mp4'));
});
test('scheme match is case-insensitive and trimmed', () {
expect(parseMediaInput(' HTTPS://example.com/v.mp4 '),
(mediaPath: '', linkUrl: 'HTTPS://example.com/v.mp4'));
});
test('file path routes to mediaPath', () {
expect(parseMediaInput('/home/u/Videos/chiyo.mp4'),
(mediaPath: '/home/u/Videos/chiyo.mp4', linkUrl: ''));
});
test('empty input stays empty', () {
expect(parseMediaInput(' '), (mediaPath: '', linkUrl: ''));
});
test('non-http scheme stays a path', () {
expect(parseMediaInput('ftp://example.com/v.mp4'),
(mediaPath: 'ftp://example.com/v.mp4', linkUrl: ''));
});
}
@@ -0,0 +1,430 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/controls/frame.dart';
import 'package:watch_together/src/controls/minimal/style.dart';
import 'package:watch_together/src/controls/minimal/timeline.dart';
import 'package:watch_together/src/controls/model/controls_prefs.dart';
import 'package:watch_together/src/controls/volume.dart';
import 'package:watch_together/src/playback/media_marks.dart';
import 'package:watch_together/src/shell/settings_tab.dart';
import 'package:watch_together/src/state/settings.dart';
import 'package:watch_together/src/update/update_controller.dart';
import 'package:watch_together/src/widgets/app_menu.dart';
// The Minimal skin's pieces and the shared controls frame (never the
// session screen or a media_kit `Video`).
Duration m(int min, [int sec = 0]) => Duration(minutes: min, seconds: sec);
final chapters = chaptersFromStarts([
('Prologue', Duration.zero),
('Opening', m(0, 30)),
('Part A', m(2)),
('Ending', m(20)),
('Part B', m(21, 30)),
], m(24));
Widget host(Widget child, {double width = 800}) => MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(
backgroundColor: Colors.black,
body: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 40),
child: SizedBox(width: width, child: child),
),
),
),
);
String text(WidgetTester tester, String key) => tester.widget<Text>(find.byKey(ValueKey(key))).data!;
MinimalTimelinePainter painter(WidgetTester tester) => tester
.widget<CustomPaint>(find.descendant(
of: find.byKey(const ValueKey('minimal-timeline')), matching: find.byType(CustomPaint)))
.painter! as MinimalTimelinePainter;
Future<TestGesture> mouseAt(WidgetTester tester, Offset at) async {
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
await mouse.addPointer(location: at);
addTearDown(mouse.removePointer);
return mouse;
}
void main() {
group('timeline math', () {
test('missing part: from the cache end (or the position) to the end', () {
expect(uncachedFrom(position: m(6), buffered: m(9), duration: m(24)), 9 / 24);
expect(uncachedFrom(position: m(6), buffered: m(5), duration: m(24)), 6 / 24, reason: 'stale cache end');
expect(uncachedFrom(position: m(6), buffered: Duration.zero, duration: m(24)), isNull, reason: 'unknown');
expect(uncachedFrom(position: m(6), buffered: m(24), duration: m(24)), isNull, reason: 'cached to the end');
expect(uncachedFrom(position: m(6), buffered: m(9), duration: Duration.zero), isNull);
});
test('cache label: seconds ahead, under 60 s, not when cached to the end', () {
expect(cacheLabel(position: m(6), buffered: m(6, 30), duration: m(24)), '30s');
expect(cacheLabel(position: m(6), buffered: m(6), duration: m(24)), '0s');
expect(cacheLabel(position: m(6), buffered: m(5), duration: m(24)), '0s');
expect(cacheLabel(position: m(6), buffered: m(7), duration: m(24)), isNull, reason: 'a minute or more');
expect(cacheLabel(position: m(23, 40), buffered: m(24), duration: m(24)), isNull, reason: 'eof cached');
expect(cacheLabel(position: m(6), buffered: Duration.zero, duration: m(24)), isNull);
});
test('band size: the full band shown, the line hidden, scaled', () {
expect(minimalBandSize(1, 1), MinimalTimelineSpec.size);
expect(minimalBandSize(0, 1), MinimalTimelineSpec.lineSize);
expect(minimalBandSize(1, 1.25), MinimalTimelineSpec.size * 1.25);
});
});
group('timeline', () {
testWidgets('played part, elapsed left, remaining right, cache label', (tester) async {
await tester.pumpWidget(host(MinimalTimeline(
position: m(6), duration: m(24), buffered: m(6, 30), chapters: chapters, onSeek: (_) {})));
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
expect(bar.width, 800);
expect(bar.height, MinimalTimelineSpec.size);
final p = painter(tester);
expect(p.progress, 0.25);
expect(p.lineX(800), 200);
expect(p.visibility, 1);
expect(p.uncached, 6.5 / 24);
expect(p.elapsed, '6:00');
expect(p.right, '-18:00');
expect(p.cache, '30s');
expect(p.textOpacity, 1);
});
testWidgets('the total instead of the time left', (tester) async {
await tester.pumpWidget(host(MinimalTimeline(
position: m(6), duration: m(24), remaining: false, onSeek: (_) {})));
expect(painter(tester).right, '24:00');
});
testWidgets('a press on the right-hand time switches it, no seek', (tester) async {
final seeks = <Duration>[];
var toggled = 0;
await tester.pumpWidget(host(MinimalTimeline(
position: m(6), duration: m(24), onSeek: seeks.add, onToggleTime: () => toggled++)));
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
await tester.tapAt(Offset(bar.right - 20, bar.center.dy));
expect(toggled, 1);
expect(seeks, isEmpty);
});
testWidgets('a mark at each chapter start', (tester) async {
await tester.pumpWidget(host(MinimalTimeline(position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {})));
expect(painter(tester).chapters, [for (final c in chapters) c.start.inSeconds / m(24).inSeconds]);
});
testWidgets('hover: chapter title over the time, above the band', (tester) async {
await tester.pumpWidget(host(MinimalTimeline(position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {})));
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
final mouse = await mouseAt(tester, Offset.zero);
// 1:51 of 24:00: inside "Opening".
await mouse.moveTo(Offset(bar.left + bar.width * 111 / (24 * 60) + 0.2, bar.center.dy));
await tester.pump();
expect(text(tester, 'minimal-hover-chapter'), 'Opening');
expect(text(tester, 'minimal-hover-time'), '1:51');
final title = tester.getRect(find.byKey(const ValueKey('minimal-hover-chapter')));
final time = tester.getRect(find.byKey(const ValueKey('minimal-hover-time')));
expect(title.bottom, lessThanOrEqualTo(time.top), reason: 'title above');
expect(time.bottom, lessThan(bar.top), reason: 'above the band');
expect(painter(tester).hoverX, isNotNull);
await mouse.moveTo(Offset(bar.left + bar.width * (10 / 24), bar.center.dy));
await tester.pump();
expect(text(tester, 'minimal-hover-chapter'), 'Part A');
await mouse.moveTo(Offset(bar.left, bar.top - 200));
await tester.pump();
expect(find.byKey(const ValueKey('minimal-hover-time')), findsNothing);
});
testWidgets('a press on a hovered chapter mark jumps to its chapter', (tester) async {
final seeks = <Duration>[];
await tester.pumpWidget(host(MinimalTimeline(position: m(1), duration: m(24), chapters: chapters, onSeek: seeks.add)));
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
final at = Offset(bar.left + bar.width * 2 / 24 + 3, bar.top + 2);
final mouse = await mouseAt(tester, at);
await mouse.moveTo(at);
await tester.pump();
expect(painter(tester).hoveredChapter, 2);
expect(text(tester, 'minimal-hover-time'), '2:00');
await mouse.down(at);
await mouse.up();
await tester.pump();
expect(seeks, [m(2)]);
});
testWidgets('hidden controls: the progress line, no text, no input', (tester) async {
final seeks = <Duration>[];
await tester.pumpWidget(host(MinimalTimeline(
position: m(12), duration: m(24), buffered: m(14), chapters: chapters, expanded: false, onSeek: seeks.add)));
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
expect(bar.height, MinimalTimeline.collapsedHeight);
final p = painter(tester);
expect(p.progress, 0.5);
expect(p.visibility, 0);
expect(p.textOpacity, 0);
final mouse = await mouseAt(tester, Offset.zero);
await mouse.moveTo(bar.center);
await tester.pump();
expect(find.byKey(const ValueKey('minimal-hover-time')), findsNothing);
await tester.tapAt(bar.center);
expect(seeks, isEmpty);
});
testWidgets('press seeks, every drag move seeks, release holds the target', (tester) async {
final seeks = <Duration>[];
await tester.pumpWidget(host(MinimalTimeline(position: m(1), duration: m(24), chapters: chapters, onSeek: seeks.add)));
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
final g0 = await tester.startGesture(Offset(bar.left + bar.width / 4, bar.center.dy), kind: PointerDeviceKind.mouse);
expect(seeks, [m(6)], reason: 'the press seeks');
await g0.moveTo(Offset(bar.left + bar.width / 2, bar.center.dy));
await tester.pump();
expect(painter(tester).elapsed, formatClock(seeks.last), reason: 'follows the drag');
await g0.moveTo(Offset(bar.left + bar.width * 3 / 4, bar.center.dy));
await g0.up();
await tester.pump();
expect(seeks, hasLength(3), reason: 'release at the last move: no repeat');
expect(painter(tester).progress, 0.75, reason: 'shows the target until the position catches up');
await tester.pump(const Duration(seconds: 3));
expect(painter(tester).progress, 1 / 24, reason: 'never confirmed: back to the position');
});
});
group('controls frame', () {
Widget frame({Duration hideAfter = const Duration(seconds: 3), Widget? child}) => MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(
backgroundColor: Colors.black,
body: ControlsFrame(
hideAfter: hideAfter,
builder: (context, visible) => Stack(children: [
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Column(mainAxisSize: MainAxisSize.min, children: [
Opacity(opacity: visible ? 1 : 0, child: child ?? const SizedBox(height: 40)),
MinimalTimeline(
position: m(6), duration: m(24), chapters: chapters, expanded: visible, onSeek: (_) {}),
]),
),
]),
),
),
);
bool up(WidgetTester tester) => tester.state<ControlsFrameState>(find.byType(ControlsFrame)).visible;
testWidgets('hide after the delay, mouse brings them back', (tester) async {
await tester.pumpWidget(frame());
expect(up(tester), isTrue);
await tester.pump(const Duration(seconds: 4));
await tester.pumpAndSettle();
expect(up(tester), isFalse);
expect(tester.getRect(find.byKey(const ValueKey('minimal-timeline'))).height, MinimalTimeline.collapsedHeight,
reason: 'still visible as a thin line');
final mouse = await mouseAt(tester, const Offset(400, 200));
await mouse.moveTo(const Offset(420, 220));
await tester.pump();
expect(up(tester), isTrue);
await tester.pumpAndSettle(const Duration(milliseconds: 50));
expect(tester.getRect(find.byKey(const ValueKey('minimal-timeline'))).height, MinimalTimelineSpec.size,
reason: 'grown back (animated)');
});
testWidgets('a long drag on the timeline keeps the controls up', (tester) async {
await tester.pumpWidget(frame());
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
final g = await tester.startGesture(Offset(bar.left + 100, bar.center.dy), kind: PointerDeviceKind.mouse);
for (var i = 0; i < 10; i++) {
await tester.pump(const Duration(seconds: 1));
}
await g.moveTo(Offset(bar.left + 300, bar.center.dy));
await tester.pump(const Duration(seconds: 5));
expect(up(tester), isTrue, reason: 'held while dragging');
await g.up();
await g.removePointer();
await tester.pump(const Duration(seconds: 4));
await tester.pumpAndSettle();
expect(up(tester), isFalse, reason: 'hides again after release');
});
testWidgets('an open app menu keeps the controls up', (tester) async {
await tester.pumpWidget(frame(
child: Builder(
builder: (context) => TextButton(
key: const ValueKey('open'),
onPressed: () => showAppMenu<int>(context, items: const [AppMenuItem(value: 1, label: 'One')]),
child: const Text('open'),
),
),
));
await tester.tap(find.byKey(const ValueKey('open')));
await tester.pumpAndSettle();
expect(find.text('One'), findsOneWidget);
await tester.pump(const Duration(seconds: 10));
expect(up(tester), isTrue);
await tester.tapAt(const Offset(5, 5)); // outside: dismiss
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 4));
await tester.pumpAndSettle();
expect(up(tester), isFalse);
});
testWidgets('double-click on the picture, not on the bars', (tester) async {
var doubles = 0;
await tester.pumpWidget(MaterialApp(
home: ControlsFrame(
onDoubleClick: () => doubles++,
barBottom: 100,
builder: (context, visible) => const SizedBox.expand(),
),
));
Future<void> twice(Offset at) async {
await tester.tapAt(at, kind: PointerDeviceKind.mouse);
await tester.pump(const Duration(milliseconds: 100));
await tester.tapAt(at, kind: PointerDeviceKind.mouse);
await tester.pump(const Duration(milliseconds: 500));
}
await twice(const Offset(400, 200));
expect(doubles, 1);
await twice(const Offset(400, 580));
expect(doubles, 1, reason: 'on the bottom bar');
});
});
group('buttons', () {
testWidgets('count badge in the accent; tooltip on the app glass', (tester) async {
await tester.pumpWidget(host(const Row(children: [
MinimalButton(key: ValueKey('subs'), icon: Icons.subtitles_outlined, tooltip: 'Subtitles', badge: 2, onPressed: null),
MinimalButton(key: ValueKey('plain'), icon: Icons.menu, tooltip: 'Menu', onPressed: null),
])));
expect(
find.descendant(of: find.byKey(const ValueKey('subs')), matching: find.byKey(const ValueKey('minimal-badge'))),
findsOneWidget);
expect(find.descendant(of: find.byKey(const ValueKey('subs')), matching: find.text('2')), findsOneWidget);
expect(
find.descendant(of: find.byKey(const ValueKey('plain')), matching: find.byKey(const ValueKey('minimal-badge'))),
findsNothing);
final tip = tester.widget<Tooltip>(
find.descendant(of: find.byKey(const ValueKey('subs')), matching: find.byType(Tooltip)).first);
expect((tip.decoration as BoxDecoration).color, MinimalStyle.tip);
});
testWidgets('volume: click mutes and restores, wheel steps 5', (tester) async {
final got = <double>[];
var volume = 80.0;
var before = 80.0;
await tester.pumpWidget(host(StatefulBuilder(
builder: (context, setState) => Row(children: [
VolumeControl(
volume: volume,
onChanged: (v) => setState(() => got.add(volume = v)),
onToggleMute: () => setState(() {
if (volume > 0) {
before = volume;
got.add(volume = 0);
} else {
got.add(volume = before);
}
}),
button: (context, icon, color, tip, onPressed) =>
MinimalButton(key: const ValueKey('speaker'), icon: icon, tooltip: tip, onPressed: onPressed),
),
]),
)));
await tester.tap(find.byKey(const ValueKey('speaker')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey('speaker')));
await tester.pump();
final at = tester.getCenter(find.byKey(const ValueKey('speaker')));
await tester.sendEventToBinding(
PointerScrollEvent(kind: PointerDeviceKind.mouse, position: at, scrollDelta: const Offset(0, -60)));
await tester.pump();
expect(got, [0, 80, 85], reason: 'a notch is one step');
for (var i = 0; i < 3; i++) {
await tester.sendEventToBinding(
PointerScrollEvent(kind: PointerDeviceKind.mouse, position: at, scrollDelta: const Offset(0, 20)));
}
await tester.pump();
expect(got, [0, 80, 85, 80], reason: 'smooth deltas add up to a step');
});
});
group('volume wheel', () {
testWidgets('anywhere: vertical steps; lists scroll instead; sideways is nothing', (tester) async {
final steps = <int>[];
final list = ScrollController();
addTearDown(list.dispose);
await tester.pumpWidget(MaterialApp(
home: VolumeWheel(
onStep: steps.add,
child: Stack(children: [
const Positioned.fill(child: ColoredBox(color: Colors.black)),
Positioned(
right: 0,
top: 0,
width: 200,
height: 200,
child: ListView(controller: list, children: [for (var i = 0; i < 50; i++) Text('row $i')]),
),
]),
),
));
Future<void> wheel(Offset at, Offset delta) =>
tester.sendEventToBinding(PointerScrollEvent(kind: PointerDeviceKind.mouse, position: at, scrollDelta: delta));
await wheel(const Offset(100, 400), const Offset(0, -60));
await wheel(const Offset(100, 400), const Offset(0, 60));
expect(steps, [1, -1], reason: 'up louder, down quieter');
await wheel(const Offset(100, 400), const Offset(60, 0));
await wheel(const Offset(100, 400), const Offset(60, -10));
expect(steps, [1, -1], reason: 'sideways');
await wheel(const Offset(700, 100), const Offset(0, 60));
await tester.pump();
expect(steps, [1, -1], reason: 'the list took it');
expect(list.offset, greaterThan(0));
});
});
test('the old uosc choice becomes Minimal', () {
expect(ControlsSkin.fromStored(null), ControlsSkin.standard);
expect(ControlsSkin.fromStored(null, legacyMpvControls: true), ControlsSkin.minimal);
expect(ControlsSkin.fromStored(null, legacyMpvControls: false), ControlsSkin.standard);
expect(ControlsSkin.fromStored('standard', legacyMpvControls: true), ControlsSkin.standard,
reason: 'a saved skin wins');
expect(ControlsSkin.fromStored('minimal'), ControlsSkin.minimal);
});
testWidgets('Settings: "Player design", Standard by default, Minimal picked live', (tester) async {
final s = AppSettings.instance;
s.controlsSkin.value = ControlsSkin.standard;
addTearDown(() => s.controlsSkin.value = ControlsSkin.standard);
tester.view.physicalSize = const Size(1280, 2400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(
body: SettingsTab(
section: SettingsSection.player,
onSection: (_) {},
updates: UpdateController(currentVersion: '0.0.0-dev'),
),
),
));
final design = find.byKey(const ValueKey('player-design'));
expect(find.text('Player design'), findsOneWidget);
expect(tester.widget<AppSelect<ControlsSkin>>(design).value, ControlsSkin.standard);
await tester.tap(design);
await tester.pumpAndSettle();
await tester.tap(find.text('Minimal').last);
await tester.pumpAndSettle();
expect(s.controlsSkin.value, ControlsSkin.minimal);
expect(tester.widget<AppSelect<ControlsSkin>>(design).value, ControlsSkin.minimal);
});
}
+22 -47
View File
@@ -2,11 +2,15 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType; import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import 'package:watch_together/src/controls/control_menus.dart';
import 'package:watch_together/src/controls/standard/seek_bar.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart'; import 'package:watch_together/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/playback/media_marks.dart'; import 'package:watch_together/src/playback/media_marks.dart';
import 'package:watch_together/src/screens/session/player_controls.dart'; import 'package:watch_together/src/screens/session/player_controls.dart';
// The control-bar pieces alone (never the session screen or a `Video`). // The control-bar pieces alone (never the session screen or a `Video`):
// the Standard seek bar, the skip pill and the shared track / chapter
// lists.
Duration m(int min, [int sec = 0]) => Duration(minutes: min, seconds: sec); Duration m(int min, [int sec = 0]) => Duration(minutes: min, seconds: sec);
@@ -18,9 +22,6 @@ final chapters = chaptersFromStarts([
('Part B', m(21, 30)), ('Part B', m(21, 30)),
], m(24)); ], m(24));
Finder item<T>(String text) =>
find.ancestor(of: find.text(text), matching: find.byType(CheckedPopupMenuItem<T>));
Widget host(Widget child, {double width = 960}) => MaterialApp( Widget host(Widget child, {double width = 960}) => MaterialApp(
home: Scaffold( home: Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
@@ -143,56 +144,30 @@ void main() {
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'), MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'),
]; ];
testWidgets('audio menu: count badge, labels, current checked, pick', (tester) async { test('audio list: counts, labels, mpv\'s selection checked', () {
TrackPick? got; expect(trackCount(tracks, JellyfinStreamType.audio), 2);
await tester.pumpWidget(host(Row(children: [ expect(trackCount(tracks, JellyfinStreamType.subtitle), 1);
TrackMenuButton(type: JellyfinStreamType.audio, tracks: tracks, onPick: (p) => got = p), final items = trackMenuItems(JellyfinStreamType.audio, tracks);
TrackMenuButton(type: JellyfinStreamType.subtitle, tracks: tracks, onPick: (p) => got = p), expect(items.map((i) => i.label), ['Japanese · E-AC3 2.0', 'English · AAC 5.1 · Dub']);
])));
expect(find.descendant(of: find.byKey(const ValueKey('audio-button')), matching: find.text('2')),
findsOneWidget);
expect(find.descendant(of: find.byKey(const ValueKey('subtitles-button')), matching: find.text('1')),
findsOneWidget);
await tester.tap(find.byKey(const ValueKey('audio-button')));
await tester.pumpAndSettle();
final items = tester.widgetList<CheckedPopupMenuItem<TrackPick>>(find.byType(CheckedPopupMenuItem<TrackPick>));
expect(items.map((i) => i.checked), [true, false]); expect(items.map((i) => i.checked), [true, false]);
expect(find.text('Japanese · E-AC3 2.0'), findsOneWidget); expect(items[1].value, (aid: 2, sid: null, subtitlesOff: false));
await tester.tap(item<TrackPick>('English · AAC 5.1 · Dub')); expect(trackMenuItems(JellyfinStreamType.audio, const []).single.enabled, isFalse, reason: 'None');
await tester.pumpAndSettle();
expect(got, (aid: 2, sid: null, subtitlesOff: false));
}); });
testWidgets('subtitle menu has Off, checked when none is selected', (tester) async { test('subtitle list starts with Off, checked when none is selected', () {
TrackPick? got; final items = trackMenuItems(JellyfinStreamType.subtitle, tracks);
await tester.pumpWidget(host(TrackMenuButton( expect(items.map((i) => i.label), ['Off', 'English · ASS · Full']);
type: JellyfinStreamType.subtitle, tracks: tracks, onPick: (p) => got = p)));
await tester.tap(find.byKey(const ValueKey('subtitles-button')));
await tester.pumpAndSettle();
final items = tester.widgetList<CheckedPopupMenuItem<TrackPick>>(find.byType(CheckedPopupMenuItem<TrackPick>));
expect(items.map((i) => i.checked), [true, false]); expect(items.map((i) => i.checked), [true, false]);
await tester.tap(item<TrackPick>('Off')); expect(items.first.value, (aid: null, sid: null, subtitlesOff: true));
await tester.pumpAndSettle(); expect(items.last.value, (aid: null, sid: 1, subtitlesOff: false));
expect(got, (aid: null, sid: null, subtitlesOff: true));
}); });
testWidgets('chapter menu: current checked, a pick seeks to its start', (tester) async { test('chapter list: start times, the current one checked', () {
MediaChapter? got; final items = chapterMenuItems(chapters, 2);
await tester.pumpWidget(host(ChaptersControlButton(chapters: chapters, current: 2, onPick: (c) => got = c)));
await tester.tap(find.byKey(const ValueKey('chapters-button')));
await tester.pumpAndSettle();
final items = tester.widgetList<CheckedPopupMenuItem<MediaChapter>>(
find.byType(CheckedPopupMenuItem<MediaChapter>));
expect(items.map((i) => i.checked), [false, false, true, false, false]); expect(items.map((i) => i.checked), [false, false, true, false, false]);
expect(find.text('20:00'), findsOneWidget); expect(items[3].label, 'ED');
await tester.tap(item<MediaChapter>('ED')); expect(items[3].hint, '20:00');
await tester.pumpAndSettle(); expect(items[3].value.start, m(20));
expect(got!.start, m(20));
});
testWidgets('no chapters, no chapter button', (tester) async {
await tester.pumpWidget(host(ChaptersControlButton(chapters: const [], current: null, onPick: (_) {})));
expect(find.byKey(const ValueKey('chapters-button')), findsNothing);
}); });
}); });
} }
+57 -68
View File
@@ -1,13 +1,14 @@
// restructure: rewrite in step 3 (Your server): invite addresses (P24) and
// the hosting invite; the settings cards stay.
import 'dart:io'; import 'dart:io';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/controls/controls_probe.dart'; import 'package:watch_together/src/controls/frame.dart';
import 'package:watch_together/src/controls/model/controls_prefs.dart';
import 'package:watch_together/src/controls/player_settings.dart'; import 'package:watch_together/src/controls/player_settings.dart';
import 'package:watch_together/src/controls/uosc/controls.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart'; import 'package:watch_together/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/schedule/animeflow.dart'; import 'package:watch_together/src/schedule/animeflow.dart';
import 'package:watch_together/src/shaders/shader_presets.dart'; import 'package:watch_together/src/shaders/shader_presets.dart';
@@ -18,7 +19,7 @@ import 'package:watch_together/src/state/session_state.dart';
import 'package:watch_together/src/state/settings.dart'; import 'package:watch_together/src/state/settings.dart';
import 'package:watch_together/src/update/update_controller.dart'; import 'package:watch_together/src/update/update_controller.dart';
// The player's ⚙ card (both designs, never a media_kit `Video`), the // The player settings card (both skins, never a media_kit `Video`), the
// Settings pages, the host flag in the party UI and invite addresses. // Settings pages, the host flag in the party UI and invite addresses.
Widget app(Widget child) => MaterialApp(theme: ThemeData.dark(), home: Scaffold(body: child)); Widget app(Widget child) => MaterialApp(theme: ThemeData.dark(), home: Scaffold(body: child));
@@ -41,7 +42,11 @@ void main() {
final coord = SessionState.instance.coordinator; final coord = SessionState.instance.coordinator;
tearDown(() { tearDown(() {
s.mpvControls.value = false; s.controlsSkin.value = ControlsSkin.standard;
s.timeDisplay.value = TimeDisplay.elapsed;
s.controlsSize.value = ControlsSize.normal;
s.controlsHideSecs.value = defaultControlsHideSecs;
s.hiddenControls.value = const {};
s.progressLine.value = true; s.progressLine.value = true;
s.shaderPreset.value = ShaderPreset.off; s.shaderPreset.value = ShaderPreset.off;
s.shaderQuality.value = ShaderQuality.hq; s.shaderQuality.value = ShaderQuality.hq;
@@ -53,15 +58,23 @@ void main() {
SessionState.instance.recents.value = const []; SessionState.instance.recents.value = const [];
}); });
group('⚙ player settings', () { Widget opener() => Builder(
testWidgets('standard controls: the gear opens every player setting, changes apply live', (tester) async { builder: (context) => TextButton(
key: const ValueKey('open-settings'),
onPressed: () => showPlayerSettings(context),
child: const Text('settings'),
),
);
group('player settings card', () {
testWidgets('lists every player setting, changes apply live', (tester) async {
bigWindow(tester); bigWindow(tester);
coord.playback.tracks.value = const [ coord.playback.tracks.value = const [
MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', codec: 'eac3', channels: 2, selected: true), MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', codec: 'eac3', channels: 2, selected: true),
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'), MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'),
]; ];
await tester.pumpWidget(app(const Align(alignment: Alignment.bottomRight, child: PlayerSettingsButton()))); await tester.pumpWidget(app(Align(alignment: Alignment.bottomRight, child: opener())));
await tester.tap(find.byKey(const ValueKey('player-settings'))); await tester.tap(find.byKey(const ValueKey('open-settings')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final card = find.byKey(const ValueKey('player-settings-card')); final card = find.byKey(const ValueKey('player-settings-card'));
expect(card, findsOneWidget); expect(card, findsOneWidget);
@@ -81,8 +94,8 @@ void main() {
expect(find.descendant(of: find.byKey(const ValueKey('audio-track')), matching: find.textContaining('Japanese')), expect(find.descendant(of: find.byKey(const ValueKey('audio-track')), matching: find.textContaining('Japanese')),
findsOneWidget); findsOneWidget);
await pick(tester, 'player-design', 'uosc'); await pick(tester, 'player-design', 'Minimal');
expect(s.mpvControls.value, isTrue); expect(s.controlsSkin.value, ControlsSkin.minimal);
await tester.tap(find.byKey(const ValueKey('progress-line'))); await tester.tap(find.byKey(const ValueKey('progress-line')));
await tester.pump(); await tester.pump();
expect(s.progressLine.value, isFalse); expect(s.progressLine.value, isFalse);
@@ -98,19 +111,17 @@ void main() {
expect(card, findsNothing); expect(card, findsNothing);
}); });
testWidgets('uosc controls: the gear opens the card and keeps the controls up', (tester) async { testWidgets('keeps the controls up while open', (tester) async {
bigWindow(tester); bigWindow(tester);
s.mpvControls.value = true; await tester.pumpWidget(app(ControlsFrame(
await tester.pumpWidget(app(UoscControls(
hideAfter: const Duration(seconds: 1), hideAfter: const Duration(seconds: 1),
timeline: (context, expanded) => const SizedBox(height: 30), builder: (context, visible) => Align(alignment: Alignment.bottomRight, child: opener()),
right: const [UoscPlayerSettingsButton()],
))); )));
final controls = tester.state<UoscControlsState>(find.byType(UoscControls)); final controls = tester.state<ControlsFrameState>(find.byType(ControlsFrame));
await tester.tap(find.byKey(const ValueKey('uosc-player-settings'))); await tester.tap(find.byKey(const ValueKey('open-settings')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('player-settings-card')), findsOneWidget); expect(find.byKey(const ValueKey('player-settings-card')), findsOneWidget);
expect(find.text('uosc'), findsOneWidget, reason: 'the current design'); expect(find.text('Standard'), findsOneWidget, reason: 'the current skin');
await tester.pump(const Duration(seconds: 3)); await tester.pump(const Duration(seconds: 3));
expect(controls.visible, isTrue, reason: 'held while the card is open'); expect(controls.visible, isTrue, reason: 'held while the card is open');
await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.sendKeyEvent(LogicalKeyboardKey.escape);
@@ -118,49 +129,6 @@ void main() {
await tester.pump(const Duration(seconds: 2)); await tester.pump(const Duration(seconds: 2));
expect(controls.visible, isFalse); expect(controls.visible, isFalse);
}); });
testWidgets('uosc: hidden controls keep the line unless the setting is off', (tester) async {
await tester.pumpWidget(app(UoscControls(
hideAfter: const Duration(milliseconds: 100),
timeline: (context, expanded) => const SizedBox(height: 2),
)));
double opacity() =>
tester.widget<AnimatedOpacity>(find.byKey(const ValueKey('uosc-timeline-wrap'))).opacity;
await tester.pump(const Duration(milliseconds: 200));
expect(opacity(), 1, reason: 'hidden, line on');
s.setProgressLine(false);
await tester.pump();
expect(opacity(), 0, reason: 'hidden, line off');
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
await mouse.addPointer(location: const Offset(400, 300));
addTearDown(mouse.removePointer);
await mouse.moveTo(const Offset(410, 300));
await tester.pump();
expect(opacity(), 1, reason: 'controls up: the full timeline');
await tester.pump(const Duration(milliseconds: 200));
});
});
testWidgets('standard controls: the probe follows media_kit\'s show / hide rule', (tester) async {
final shown = ValueNotifier(false);
addTearDown(shown.dispose);
await tester.pumpWidget(app(ControlsHoverProbe(
shown: shown,
hideAfter: const Duration(seconds: 3),
child: const SizedBox.expand(),
)));
await tester.pump();
expect(shown.value, isTrue, reason: 'up on mount');
await tester.pump(const Duration(seconds: 3));
expect(shown.value, isFalse, reason: 'down 3 s later');
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
await mouse.addPointer(location: const Offset(400, 300));
addTearDown(mouse.removePointer);
await mouse.moveTo(const Offset(420, 300));
await tester.pump();
expect(shown.value, isTrue, reason: 'mouse movement');
await tester.pump(const Duration(seconds: 3));
expect(shown.value, isFalse);
}); });
group('Settings pages', () { group('Settings pages', () {
@@ -195,16 +163,37 @@ void main() {
expect(find.byIcon(Icons.hub_outlined), findsOneWidget); expect(find.byIcon(Icons.hub_outlined), findsOneWidget);
}); });
testWidgets('Player: the same rows as the ⚙, plus a pointer to it', (tester) async { testWidgets('Player: the card\'s rows, a pointer to it, and the controls\' choices', (tester) async {
await pumpSettings(tester, SettingsSection.player); await pumpSettings(tester, SettingsSection.player, size: const Size(1280, 2400));
expect(find.textContaining("also in the player's ⚙"), findsOneWidget); expect(find.textContaining('also in the player: ⚙ → Player settings'), findsOneWidget);
for (final key in ['player-design', 'progress-line', 'shader-preset', 'hwdec']) { for (final key in [
'player-design',
'progress-line',
'shader-preset',
'hwdec',
'time-display',
'controls-size',
'controls-hide',
for (final c in OptionalControl.values) 'bar-${c.name}',
]) {
expect(find.byKey(ValueKey(key)), findsOneWidget, reason: key); expect(find.byKey(ValueKey(key)), findsOneWidget, reason: key);
} }
expect(find.byKey(const ValueKey('audio-track')), findsNothing, reason: 'tracks only in the player'); expect(find.byKey(const ValueKey('audio-track')), findsNothing, reason: 'tracks only in the player');
expect(find.text('Standard'), findsOneWidget); expect(find.text('Standard'), findsOneWidget);
await pick(tester, 'player-design', 'uosc'); await pick(tester, 'player-design', 'Minimal');
expect(s.mpvControls.value, isTrue); expect(s.controlsSkin.value, ControlsSkin.minimal);
await pick(tester, 'time-display', 'Remaining');
expect(s.timeDisplay.value, TimeDisplay.remaining);
await pick(tester, 'controls-size', 'Large');
expect(s.controlsSize.value, ControlsSize.large);
await pick(tester, 'controls-hide', '5 s');
expect(s.controlsHideSecs.value, 5);
await tester.tap(find.byKey(const ValueKey('bar-chat')));
await tester.pump();
expect(s.hiddenControls.value, {OptionalControl.chat});
await tester.tap(find.byKey(const ValueKey('bar-chat')));
await tester.pump();
expect(s.hiddenControls.value, isEmpty);
}); });
testWidgets('Party & network: host defaults and recent servers with remove', (tester) async { testWidgets('Party & network: host defaults and recent servers with remove', (tester) async {
+29 -21
View File
@@ -1,11 +1,13 @@
// restructure: rewrite in step 2 (Status + Notifier): the "status" group
// (one computed level); the title block and overflow checks stay.
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/controls/uosc/buttons.dart'; import 'package:watch_together/src/controls/frame.dart';
import 'package:watch_together/src/controls/uosc/controls.dart'; import 'package:watch_together/src/controls/minimal/style.dart';
import 'package:watch_together/src/controls/uosc/timeline.dart'; import 'package:watch_together/src/controls/minimal/timeline.dart';
import 'package:watch_together/src/session/health.dart'; import 'package:watch_together/src/session/health.dart';
import 'package:watch_together/src/session/now_playing.dart'; import 'package:watch_together/src/session/now_playing.dart';
import 'package:watch_together/src/shell/now_playing_title.dart'; import 'package:watch_together/src/shell/now_playing_title.dart';
@@ -165,16 +167,6 @@ void main() {
expect(find.byKey(const ValueKey('now-playing-episode')), findsNothing); expect(find.byKey(const ValueKey('now-playing-episode')), findsNothing);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets('a new media fades through', (tester) async {
await tester.pumpWidget(host(const NowPlayingBlock(now: now)));
await tester.pumpWidget(host(const NowPlayingBlock(now: NowPlaying(title: 'Dune'))));
await tester.pump(const Duration(milliseconds: 60));
expect(find.text('Frieren'), findsOneWidget, reason: 'still fading out');
await tester.pumpAndSettle();
expect(find.text('Frieren'), findsNothing);
expect(find.text('Dune'), findsOneWidget);
});
}); });
testWidgets('start banner: up before playback, fades on play, never back for that media', (tester) async { testWidgets('start banner: up before playback, fades on play, never back for that media', (tester) async {
@@ -248,21 +240,37 @@ void main() {
expect(find.textContaining('Waiting for Someone With A Long Name 0'), findsOneWidget); expect(find.textContaining('Waiting for Someone With A Long Name 0'), findsOneWidget);
}); });
testWidgets('uosc top bar and start card: ${size.width.toInt()}', (tester) async { testWidgets('minimal top bar and start card: ${size.width.toInt()}', (tester) async {
debugArtBytes = (hash) => png; debugArtBytes = (hash) => png;
await sized( await sized(
tester, tester,
size, size,
Stack(fit: StackFit.expand, children: [ Stack(fit: StackFit.expand, children: [
StartCard(now: long), StartCard(now: long),
UoscControls( ControlsFrame(
topLeft: NowPlayingBlock(now: long), builder: (context, visible) => Stack(children: [
top: Row(mainAxisSize: MainAxisSize.min, children: [ Positioned(
for (final i in [Icons.chat_bubble_outline, Icons.fullscreen, Icons.close]) left: 16,
UoscButton(icon: i, tooltip: '', onPressed: () {}), right: 10,
top: 0,
height: 72,
child: Row(children: [
Expanded(child: NowPlayingBlock(now: long)),
for (final i in [Icons.fullscreen, Icons.close])
MinimalButton(icon: i, tooltip: '', onPressed: () {}),
]),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: MinimalTimeline(
position: Duration.zero,
duration: const Duration(minutes: 24),
expanded: visible,
onSeek: (_) {}),
),
]), ]),
timeline: (context, expanded) =>
UoscTimeline(position: Duration.zero, duration: const Duration(minutes: 24), expanded: expanded, onSeek: (_) {}),
), ),
]), ]),
); );
+2
View File
@@ -1,3 +1,5 @@
// restructure: rewrite in step 5 (What's next): who performs an item and
// the janitor become server state.
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/queue/queue_logic.dart'; import 'package:watch_together/src/queue/queue_logic.dart';
@@ -1,3 +1,5 @@
// restructure: rewrite in step 6 (Media pipeline): preload and the warm
// relay become one pipeline; keep the timing per source.
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
+25 -10
View File
@@ -1,3 +1,6 @@
// restructure: rewrite in step 1 (Connection + Identity + Presence): "the
// source drops uncleanly and resumes" (resume token, D6 source resume); the
// rest stays.
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
@@ -65,6 +68,7 @@ Future<(HttpServer, List<String>)> _origin(String file) async {
void main() { void main() {
late SyncServer server; late SyncServer server;
late List<SessionCoordinator> all; late List<SessionCoordinator> all;
late List<Object> serverErrors;
Future<SessionCoordinator> member(String name) async { Future<SessionCoordinator> member(String name) async {
final acc = JellyfinAccounts(store: MemoryJellyfinStore()); final acc = JellyfinAccounts(store: MemoryJellyfinStore());
@@ -102,7 +106,15 @@ void main() {
MediaKit.ensureInitialized(); MediaKit.ensureInitialized();
all = []; all = [];
server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'vps')); server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'vps'));
await server.start(); // Relic reports an error writing a response (a relay read cut short
// when its relay is dropped at a swap or at the stop in tearDown) to
// the zone the server started in, as it logs it outside tests;
// anything else fails the test.
serverErrors = [];
await runZonedGuarded(server.start, (e, _) {
if ('$e'.contains('Content size below specified contentLength') || '$e'.contains('did not arrive')) return;
serverErrors.add(e);
});
}); });
tearDown(() async { tearDown(() async {
@@ -110,6 +122,7 @@ void main() {
await x.dispose(); await x.dispose();
} }
await server.stop(); await server.stop();
expect(serverErrors, isEmpty, reason: 'server errors');
}); });
test('a guest puts up a file: it plays for everyone through the relay', timeout: const Timeout(Duration(minutes: 2)), test('a guest puts up a file: it plays for everyone through the relay', timeout: const Timeout(Duration(minutes: 2)),
@@ -118,17 +131,18 @@ void main() {
final alice = await member('alice'); final alice = await member('alice');
final bob = await member('bob'); final bob = await member('bob');
expect(await bob.swapPicked(FileMedia(file)), isNull); expect(await bob.swapPicked(FileMedia(file)), isNull);
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source == bob.sync.memberId, 'relay state'); await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source == bob.connection.memberId, 'relay state');
final state = alice.serverState.peek()!; final state = alice.serverState.peek()!;
expect(state.media!.containsKey('url'), isFalse); expect(state.media!.containsKey('url'), isFalse);
expect(bob.relaying.peek(), isTrue); expect(bob.relaying.peek(), isTrue);
await playTogether([alice, bob]); await playTogether([alice, bob]);
final relay = server.hub.relay!; final relay = server.hub.relay!;
expect(relay.buffers.single.bytesToViewers, greaterThan(0), reason: 'alice played through the relay'); expect(relay.buffers.single.bytesToViewers, greaterThan(0), reason: 'alice played through the relay');
expect(relay.sourceToken, bob.sync.token); expect(relay.sourceToken, bob.connection.token);
// The next swap retires bob's source and the buffer. // The next swap retires bob's source and the buffer. Nothing answers
alice.swapPicked(const LinkMedia('http://127.0.0.1:1/none.mkv')); // at that link, so its probe refuses it unless put up anyway.
expect(await alice.swapPicked(const LinkMedia('http://127.0.0.1:1/none.mkv', anyway: true)), isNull);
await _poll(() => !bob.relaying.peek(), 'source stopped'); await _poll(() => !bob.relaying.peek(), 'source stopped');
expect(server.hub.relay, isNull); expect(server.hub.relay, isNull);
}); });
@@ -142,7 +156,7 @@ void main() {
final link = 'http://127.0.0.1:${origin.port}/private/ep.mkv?api_key=SECRET'; final link = 'http://127.0.0.1:${origin.port}/private/ep.mkv?api_key=SECRET';
expect(await bob.swapPicked(LinkMedia(link, relay: LinkRelay.me)), isNull); expect(await bob.swapPicked(LinkMedia(link, relay: LinkRelay.me)), isNull);
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source == bob.sync.memberId, 'me state'); await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source == bob.connection.memberId, 'me state');
expect(alice.serverState.peek()!.toJson().toString(), isNot(contains('SECRET'))); expect(alice.serverState.peek()!.toJson().toString(), isNot(contains('SECRET')));
await playTogether([alice, bob]); await playTogether([alice, bob]);
expect(server.hub.relay!.buffers.single.bytesToViewers, greaterThan(0)); expect(server.hub.relay!.buffers.single.bytesToViewers, greaterThan(0));
@@ -155,7 +169,8 @@ void main() {
await playTogether([alice, bob]); await playTogether([alice, bob]);
expect(hits.length, greaterThan(before)); expect(hits.length, greaterThan(before));
// A page yt-dlp cannot read is refused up front. // A page is refused up front when this computer cannot read it (here
// yt-dlp is off, and the refusal says so).
final page = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); final page = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
page.listen((r) { page.listen((r) {
r.response.headers.contentType = ContentType.html; r.response.headers.contentType = ContentType.html;
@@ -163,7 +178,7 @@ void main() {
r.response.close(); r.response.close();
}); });
expect(await alice.swapPicked(LinkMedia('http://127.0.0.1:${page.port}/watch?v=x', relay: LinkRelay.me)), expect(await alice.swapPicked(LinkMedia('http://127.0.0.1:${page.port}/watch?v=x', relay: LinkRelay.me)),
contains('Not a video')); 'yt-dlp is turned off');
await page.close(force: true); await page.close(force: true);
} finally { } finally {
await origin.close(force: true); await origin.close(force: true);
@@ -225,7 +240,7 @@ void main() {
expect(await bob.swapPicked(FileMedia(file)), isNull); expect(await bob.swapPicked(FileMedia(file)), isNull);
await _poll(() => server.hub.relay?.live == true, 'source live'); await _poll(() => server.hub.relay?.live == true, 'source live');
await _poll(() => alice.playback.durationSecs > 19 && bob.playback.durationSecs > 19, 'both opened'); await _poll(() => alice.playback.durationSecs > 19 && bob.playback.durationSecs > 19, 'both opened');
final token = bob.sync.token; final token = bob.connection.token;
// Bob's network goes silent (no FIN): states stop, he reconnects // Bob's network goes silent (no FIN): states stop, he reconnects
// with `resume`, the server still has him. // with `resume`, the server still has him.
@@ -233,7 +248,7 @@ void main() {
await _poll(() => bob.reconnecting.peek(), 'reconnect engaged', timeout: const Duration(seconds: 15)); await _poll(() => bob.reconnecting.peek(), 'reconnect engaged', timeout: const Duration(seconds: 15));
await _poll(() => !bob.reconnecting.peek() && bob.serverState.peek() != null, 'back', await _poll(() => !bob.reconnecting.peek() && bob.serverState.peek() != null, 'back',
timeout: const Duration(seconds: 20)); timeout: const Duration(seconds: 20));
expect(bob.sync.token, token, reason: 'resumed as the same member'); expect(bob.connection.token, token, reason: 'resumed as the same member');
await _poll(() => server.hub.relay?.live == true, 'source redialled', timeout: const Duration(seconds: 15)); await _poll(() => server.hub.relay?.live == true, 'source redialled', timeout: const Duration(seconds: 15));
expect(bob.relaying.peek(), isTrue); expect(bob.relaying.peek(), isTrue);
expect(bob.playback.durationSecs, greaterThan(19), reason: 'still his own copy'); expect(bob.playback.durationSecs, greaterThan(19), reason: 'still his own copy');
@@ -1,3 +1,5 @@
// restructure: rewrite in step 3 (Your server): "solo: the button says
// Play"; the rest stays.
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
@@ -1,3 +1,5 @@
// restructure: rewrite in step 3 (Your server): one hub with listeners
// attached, nothing restarts, no solo mode.
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
@@ -1,3 +1,6 @@
// restructure: rewrite in step 1 (Connection + Identity + Presence):
// IdleTracker and unreadyForNewMedia give way to auto sit-out;
// linkDisplayName stays.
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/session/idle.dart'; import 'package:watch_together/src/session/idle.dart';
import 'package:watch_together/src/session/session_coordinator.dart'; import 'package:watch_together/src/session/session_coordinator.dart';
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff. Show more