8 Commits
Author SHA1 Message Date
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
74 changed files with 5195 additions and 4162 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
+9 -19
View File
@@ -10,6 +10,7 @@ import 'package:window_manager/window_manager.dart';
import 'app.dart'; import 'app.dart';
import 'src/playback/yt_dlp_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/platform/window_memory.dart';
import 'src/screens/home/ui.dart'; import 'src/screens/home/ui.dart';
@@ -48,7 +49,8 @@ Future<void> main(List<String> args) async {
await windowManager.ensureInitialized(); await windowManager.ensureInitialized();
await windowManager.setPreventClose(true); await windowManager.setPreventClose(true);
final memory = WindowMemory(Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null); final memory = WindowMemory(Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null);
windowManager.addListener(_CloseGuard(memory)); shutdownWindowMemory = memory;
windowManager.addListener(_CloseGuard());
final windowOptions = WindowOptions( final windowOptions = WindowOptions(
size: memory.size, size: memory.size,
minimumSize: const Size(160, 90), minimumSize: const Size(160, 90),
@@ -73,26 +75,14 @@ Future<void> main(List<String> args) async {
// Linux: the launcher entry and icon Wayland needs (release installs). // Linux: the launcher entry and icon Wayland needs (release installs).
unawaited(installDesktopEntry()); unawaited(installDesktopEntry());
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 /// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
/// hosted server shuts down), bounded so a hung socket can't keep the /// see "left", a hosted server shuts down and no player outlives the
/// window open. /// engine.
class _CloseGuard with WindowListener { class _CloseGuard with WindowListener {
final WindowMemory memory;
bool _closing = false;
_CloseGuard(this.memory);
@override @override
Future<void> onWindowClose() async { void onWindowClose() => unawaited(shutDownApp());
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();
} }
} }
@@ -0,0 +1,78 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:window_manager/window_manager.dart';
import '../files/thumbnails.dart';
import '../state/session_state.dart';
import 'window_memory.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`).
/// Saved on the way out; set by main once known.
WindowMemory? shutdownWindowMemory;
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 total = Stopwatch()..start();
await _step('window save', shutdownWindowMemory?.save(), const Duration(milliseconds: 500));
await _step('hide', windowManager.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');
try {
await windowManager.setPreventClose(false);
await windowManager.close();
} catch (e) {
debugPrint('window close failed: $e');
await windowManager.destroy();
}
// 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');
}
}
}
@@ -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
+18 -20
View File
@@ -9,9 +9,7 @@ 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 +26,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 +64,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 +133,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 +195,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 +232,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 +252,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),
@@ -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. '
@@ -198,8 +198,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
@@ -2242,6 +2242,17 @@ class SessionCoordinator {
return 'connect failed: $e'; return 'connect failed: $e';
} }
/// App close, before [leave]: the players go first (no mpv may outlive
/// the engine). The Jellyfin stop report [leave] sends keeps the
/// position they had.
Future<void> releasePlayers() async {
if (_playerReady) _lastSnap = _snapNow();
_playerReady = false;
_ticker?.cancel();
_ticker = null;
await playback.disposeAll();
}
Future<void> leave({bool silent = false}) async { Future<void> leave({bool silent = false}) async {
_invalidateAttempts(); _invalidateAttempts();
await _teardown(silent: silent); await _teardown(silent: silent);
+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);
+26 -1
View File
@@ -7,6 +7,7 @@ import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTar
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import '../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';
@@ -127,6 +128,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 +234,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 +255,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);
} }
@@ -615,6 +634,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 +651,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(
+35 -8
View File
@@ -5,11 +5,9 @@ import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import '../screens/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 +54,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 +70,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 +121,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 +133,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(
@@ -369,6 +366,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 +375,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 +391,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 +419,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),
),
),
),
),
],
),
), ),
), ),
), ),
+66 -540
View File
@@ -2,26 +2,20 @@ 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/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 +23,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 +39,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 +92,31 @@ 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. // Own toast ids, negative: the coordinator counts up from 1.
int _toastSeq = 0; 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;
@@ -138,6 +141,14 @@ class _SessionPlayerState extends State<SessionPlayer> {
}); });
} }
@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,15 +158,10 @@ 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() {
_coord.markInput();
_coord.reloadVideo();
}
void _toast(String text) { void _toast(String text) {
_coord.toasts.value = [ _coord.toasts.value = [
..._coord.toasts.peek(), ..._coord.toasts.peek(),
@@ -163,22 +169,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
]; ];
} }
/// Menu, Ctrl+0..6: saved as the choice (also used on the next join) void _toggleChat() => _model.toggleChat();
/// 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 +187,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 +198,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 +296,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 +319,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 +344,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 +365,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 +395,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 +462,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 +785,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 +813,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);
}
}
+6 -6
View File
@@ -18,7 +18,6 @@ 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 +120,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()),
]; ];
@@ -794,10 +797,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) {
@@ -160,7 +160,8 @@ 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),
+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) {
@@ -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> {
@@ -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 {
@@ -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
+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);
});
});
}
+59 -3
View File
@@ -1,4 +1,5 @@
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 +8,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 +60,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 +157,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 +295,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);
});
});
} }
@@ -501,18 +501,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(
@@ -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);
}); });
}); });
} }
+55 -68
View File
@@ -1,13 +1,12 @@
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 +17,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 +40,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 +56,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 +92,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 +109,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 +127,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 +161,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 {
+27 -11
View File
@@ -3,9 +3,9 @@ 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';
@@ -248,21 +248,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: (_) {}),
), ),
]), ]),
); );
+4 -4
View File
@@ -27,9 +27,9 @@ void main() {
s.setShaderPreset(ShaderPreset.a); s.setShaderPreset(ShaderPreset.a);
await p.init(); await p.init();
final dynamic mpv = p.player.platform; final dynamic mpv = p.player.platform;
final modeA = joinShaderPaths( final modeA =
await shaderPathsFor(ShaderPreset.a, ShaderQuality.hq, const [])); await shaderPathsFor(ShaderPreset.a, ShaderQuality.hq, const []);
expect(await mpv.getProperty('glsl-shaders'), modeA); expect(shaderListMatches(modeA, await mpv.getProperty('glsl-shaders')), isTrue);
expect(p.shaderNotice.value, isNull); expect(p.shaderNotice.value, isNull);
// Live change, announced. // Live change, announced.
@@ -39,7 +39,7 @@ void main() {
final caFast = await shaderPathsFor( final caFast = await shaderPathsFor(
ShaderPreset.ca, ShaderQuality.fast, const []); ShaderPreset.ca, ShaderQuality.fast, const []);
expect(caFast.first, startsWith(support.path)); expect(caFast.first, startsWith(support.path));
expect(await mpv.getProperty('glsl-shaders'), joinShaderPaths(caFast)); expect(shaderListMatches(caFast, await mpv.getProperty('glsl-shaders')), isTrue);
expect(p.shaderNotice.value?.text, 'Shaders: Mode C+A (Fast)'); expect(p.shaderNotice.value?.text, 'Shaders: Mode C+A (Fast)');
// A missing custom file: toast, off, nothing set. // A missing custom file: toast, off, nothing set.
+16
View File
@@ -70,6 +70,22 @@ void main() {
expect(joinShaderPaths([], windows: false), ''); expect(joinShaderPaths([], windows: false), '');
}); });
test("mpv's read-back: the list in ',', ';' or ':' form", () {
const win = [r'C:\Users\a b\shaders\x.glsl', r'D:\y.glsl'];
expect(shaderListMatches(win, r'C:\Users\a b\shaders\x.glsl;D:\y.glsl'), isTrue, reason: 'new mpv, Windows');
expect(shaderListMatches(win, r'C:\Users\a b\shaders\x.glsl,D:\y.glsl'), isTrue, reason: 'old mpv, Windows');
const unix = ['/home/a/x.glsl', '/home/a/y.glsl'];
expect(shaderListMatches(unix, '/home/a/x.glsl:/home/a/y.glsl'), isTrue, reason: 'new mpv, Linux');
expect(shaderListMatches(unix, '/home/a/x.glsl,/home/a/y.glsl'), isTrue, reason: 'old mpv, Linux');
expect(shaderListMatches([r'C:\x.glsl'], r'C:\x.glsl'), isTrue, reason: 'one path');
expect(shaderListMatches(const [], ''), isTrue, reason: 'off');
expect(shaderListMatches(win, r'C:\Users\a b\shaders\x.glsl'), isFalse, reason: 'one dropped');
expect(shaderListMatches(unix, '/home/a/y.glsl:/home/a/x.glsl'), isFalse, reason: 'order matters');
expect(shaderListMatches(unix, ''), isFalse, reason: 'mpv kept none');
expect(shaderListMatches(const [], 'null'), isFalse);
expect(shaderListMatches(unix, null), isFalse, reason: 'read failed');
});
test('path problems: missing file, separator inside a path', () { test('path problems: missing file, separator inside a path', () {
final d = _fresh('shaders_problem'); final d = _fresh('shaders_problem');
final ok = File('${d.path}/ok.glsl')..writeAsStringSync('//'); final ok = File('${d.path}/ok.glsl')..writeAsStringSync('//');
+32 -2
View File
@@ -68,14 +68,14 @@ void main() {
coord.toasts.value = []; coord.toasts.value = [];
}); });
Future<void> pumpShell(WidgetTester tester, Size size) async { Future<void> pumpShell(WidgetTester tester, Size size, {UpdateController? updates}) async {
tester.view.physicalSize = size; tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(MaterialApp(
theme: ThemeData.dark(), theme: ThemeData.dark(),
home: AppShell( home: AppShell(
updates: UpdateController(currentVersion: '0.0.0-dev'), updates: updates ?? UpdateController(currentVersion: '0.0.0-dev'),
autoUpdateCheck: false, autoUpdateCheck: false,
windowControls: false, windowControls: false,
videoBuilder: (context, controls) => const FakeVideo(), videoBuilder: (context, controls) => const FakeVideo(),
@@ -93,6 +93,36 @@ void main() {
member('f', 'Finn'), member('f', 'Finn'),
]; ];
group('version and update bar', () {
testWidgets('the version under Settings; the bar on the tabs, off the full player until the video ends',
(tester) async {
final updates = UpdateController(currentVersion: '0.0.0-dev');
await pumpShell(tester, const Size(1280, 800), updates: updates);
expect(find.text('dev run'), findsOneWidget);
expect(find.byKey(const ValueKey('update-bar')), findsNothing);
updates.status.value = const UpdateStatus(UpdatePhase.available,
manifest: UpdateManifest(version: '9.9.9', protocol: 1, url: 'http://x/a.tgz', notes: ''),
canInstall: true);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('update-bar')), findsOneWidget);
online(party);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('update-bar')), findsNothing);
final r = coord.serverState.value!;
coord.serverState.value = StateBroadcast(
eventId: 2,
transport: 'finished',
position: 0,
serverTime: r.serverTime,
mediaId: r.mediaId,
media: r.media,
members: r.members,
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('update-bar')), findsOneWidget);
});
});
group('party strip', () { group('party strip', () {
testWidgets('offline: Join and Host, no party', (tester) async { testWidgets('offline: Join and Host, no party', (tester) async {
await pumpShell(tester, const Size(1280, 800)); await pumpShell(tester, const Size(1280, 800));
@@ -0,0 +1,35 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_soloud/flutter_soloud.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/platform/audio/sound_engine.dart';
import 'package:watch_together/src/sfx/sfx.dart';
import 'package:watch_together/src/sfx/sound_bank.dart';
import 'package:watch_together/src/sfx/synth_sfx.dart';
// Needs a real audio output: opens the device through flutter_soloud and
// plays every sound of every preset once, quietly (WT_SFX_VOLUME, default
// 0.1, on the square curve like the app).
void main() {
// flutter_soloud's init asks path_provider for a temp dir.
TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'), (_) async => Directory.systemTemp.path);
test('every sound loads and plays on the real device', timeout: const Timeout(Duration(minutes: 2)), () async {
final volume = double.tryParse(Platform.environment['WT_SFX_VOLUME'] ?? '') ?? 0.1;
final engine = SoundEngine();
expect(await engine.warmUp(), isTrue, reason: 'audio device opened');
final sfx = SynthSfx(volume: () => volume, engine: engine);
for (final p in SoundPreset.values) {
for (final k in SfxKind.values) {
await sfx.preview(k, readyIndex: 2, preset: p);
expect(SoLoud.instance.getActiveVoiceCount(), greaterThan(0), reason: '${p.name}/${k.name}');
await Future<void>.delayed(const Duration(milliseconds: 250));
}
}
await Future<void>.delayed(const Duration(seconds: 2));
await sfx.dispose();
expect(SoLoud.instance.isInitialized, isFalse);
});
}
+150
View File
@@ -0,0 +1,150 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/platform/audio/sound_engine.dart';
import 'package:watch_together/src/sfx/sfx.dart';
import 'package:watch_together/src/sfx/sound_bank.dart';
import 'package:watch_together/src/sfx/synth.dart';
import 'package:watch_together/src/sfx/synth_sfx.dart';
class _FakeBackend implements AudioBackend {
final bool failInit;
_FakeBackend({this.failInit = false});
var inits = 0, deinits = 0;
final loads = <String, Uint8List>{};
final unloaded = <Object>[];
final plays = <(String, double)>[];
@override
Future<void> init() async {
inits++;
if (failInit) throw StateError('no audio device');
}
@override
Future<Object> load(String name, Uint8List wav) async {
loads[name] = wav;
return name;
}
@override
void play(Object sound, double volume) => plays.add((sound as String, volume));
@override
Future<void> unload(Object sound) async => unloaded.add(sound);
@override
Future<void> deinit() async => deinits++;
}
Future<Uint8List> _bytes() async => Uint8List.fromList([1, 2, 3]);
void main() {
test('loads a sound once per key and lets plays overlap', () async {
final b = _FakeBackend();
final e = SoundEngine(backend: b);
await Future.wait([
e.play('chat', 'abyss-chat', _bytes, volume: 0.5),
e.play('chat', 'abyss-chat', _bytes, volume: 0.5),
]);
await e.play('chat', 'abyss-chat', _bytes, volume: 0.25);
expect(b.inits, 1);
expect(b.loads.length, 1);
expect(b.plays.map((p) => p.$2), [0.5, 0.5, 0.25]);
});
test('a new key or fresh replaces and frees the slot', () async {
final b = _FakeBackend();
final e = SoundEngine(backend: b);
await e.play('chat', 'abyss-chat', _bytes, volume: 1);
await e.play('chat', 'cosmic-chat', _bytes, volume: 1);
await e.play('chat', 'cosmic-chat', _bytes, volume: 1, fresh: true);
await Future<void>.delayed(Duration.zero);
expect(b.loads.length, 3);
expect(b.loads.keys.toSet().length, 3, reason: 'load names are unique');
expect(b.unloaded.length, 2);
expect(b.plays.map((p) => p.$1).toSet().length, 3);
});
test('silence and zero volume load nothing', () async {
final b = _FakeBackend();
final e = SoundEngine(backend: b);
await e.play('chat', 'abyss-chat', _bytes, volume: 0);
expect(b.inits, 0);
expect(b.loads, isEmpty);
});
test('a failed init means no sounds, no throw, one try', () async {
final b = _FakeBackend(failInit: true);
final e = SoundEngine(backend: b);
expect(await e.warmUp(), isFalse);
await e.play('chat', 'abyss-chat', _bytes, volume: 1);
await e.play('join', 'abyss-join', _bytes, volume: 1);
expect(b.inits, 1);
expect(b.loads, isEmpty);
expect(b.plays, isEmpty);
await e.dispose();
expect(b.deinits, 0);
});
test('a failed render is retried on the next play', () async {
final b = _FakeBackend();
final e = SoundEngine(backend: b);
await e.play('chat', 'abyss-chat', () async => throw StateError('render'), volume: 1);
expect(b.plays, isEmpty);
await e.play('chat', 'abyss-chat', _bytes, volume: 1);
expect(b.plays.length, 1);
});
test('dispose deinits once and drops later plays', () async {
final b = _FakeBackend();
final e = SoundEngine(backend: b);
await e.play('chat', 'abyss-chat', _bytes, volume: 1);
await e.dispose();
await e.dispose();
await e.play('chat', 'abyss-chat', _bytes, volume: 1);
expect(b.deinits, 1);
expect(b.plays.length, 1);
});
test('dispose without a sound ever played opens nothing', () async {
final b = _FakeBackend();
await SoundEngine(backend: b).dispose();
expect(b.inits + b.deinits, 0);
});
group('SynthSfx', () {
test('renders a WAV per sound, volume as before (v^6), switches and preview', () async {
final b = _FakeBackend();
var volume = 0.5;
final off = <SfxKind>{SfxKind.join};
final sfx = SynthSfx(
volume: () => volume,
enabled: (k) => !off.contains(k),
presetFor: (_) => SoundPreset.woodAndGlass,
engine: SoundEngine(backend: b),
);
for (final k in SfxKind.values) {
await sfx.play(SfxCue(k, readyIndex: 2));
}
expect(b.plays.length, SfxKind.values.length - 1);
expect(b.plays.every((p) => p.$2 == 0.015625), isTrue); // 0.5^6
for (final MapEntry(:key, :value) in b.loads.entries) {
expect(String.fromCharCodes(value.sublist(0, 4)), 'RIFF', reason: key);
expect(key, startsWith('woodAndGlass-'));
}
final chat = b.loads.entries.firstWhere((e) => e.key.startsWith('woodAndGlass-chat-2'));
expect(chat.value, wav(render(soundFor(SfxKind.chat, readyIndex: 2, preset: SoundPreset.woodAndGlass))));
await sfx.preview(SfxKind.join, readyIndex: 2);
expect(b.plays.last.$1, startsWith('woodAndGlass-join-2'));
volume = 0;
await sfx.play(const SfxCue(SfxKind.chat));
expect(b.plays.length, SfxKind.values.length);
await sfx.dispose();
expect(b.deinits, 1);
});
});
}
-547
View File
@@ -1,547 +0,0 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import 'package:watch_together/src/controls/uosc/buttons.dart';
import 'package:watch_together/src/controls/uosc/controls.dart';
import 'package:watch_together/src/controls/uosc/menu.dart';
import 'package:watch_together/src/controls/uosc/style.dart';
import 'package:watch_together/src/controls/uosc/timeline.dart';
import 'package:watch_together/src/controls/volume.dart' show VolumeWheel;
import 'package:watch_together/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/playback/media_marks.dart';
import 'package:watch_together/src/playback/playback_controller.dart' show RenderMode;
import 'package:watch_together/src/screens/session/player_controls.dart' show TrackPick;
import 'package:watch_together/src/shaders/shader_presets.dart';
import 'package:watch_together/src/shell/settings_tab.dart';
import 'package:watch_together/src/state/settings.dart';
import 'package:watch_together/src/update/update_controller.dart';
import 'package:watch_together/src/widgets/app_menu.dart';
// The experimental mpv-style (uosc) controls, piece by piece (never the
// session screen or a media_kit `Video`).
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 = 960}) => MaterialApp(
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 boxText(WidgetTester tester, String key) => tester
.widget<Text>(find.descendant(of: find.byKey(ValueKey(key)), matching: find.byType(Text)))
.data!;
UoscTimelinePainter painter(WidgetTester tester) => tester
.widget<CustomPaint>(find.descendant(
of: find.byKey(const ValueKey('uosc-timeline')), matching: find.byType(CustomPaint)))
.painter! as UoscTimelinePainter;
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;
}
const tracks = [
MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', codec: 'eac3', channels: 2, selected: true),
MpvTrack(id: 2, type: JellyfinStreamType.audio, language: 'eng', codec: 'aac', channels: 6, title: 'Dub'),
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'),
MpvTrack(id: 2, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Signs'),
];
void main() {
test('uosc clock: mm:ss, hh:mm:ss from an hour', () {
expect(formatUoscTime(Duration.zero), '00:00');
expect(formatUoscTime(m(1, 51)), '01:51');
expect(formatUoscTime(m(1, 51), withHours: true), '00:01:51');
expect(formatUoscTime(const Duration(hours: 1, minutes: 2, seconds: 3)), '01:02:03');
expect(formatUoscTime(const Duration(seconds: -4)), '00:00');
});
group('timeline math (uosc)', () {
test('uosc clock: trimmed by the duration, minus kept', () {
expect(uoscClock(m(4, 22), const Duration(hours: 1, minutes: 52)), '00:04:22');
expect(uoscClock(-const Duration(hours: 1, minutes: 47, seconds: 30), const Duration(hours: 1, minutes: 52)),
'-01:47:30');
expect(uoscClock(m(6), m(24)), '06:00');
expect(uoscClock(-m(18), m(24)), '-18:00');
expect(uoscClock(const Duration(seconds: 7), const Duration(seconds: 40)), '07');
expect(uoscClock(Duration.zero, m(24)), '00:00');
});
test('uncached part: from the cache end (or the position) to the end', () {
expect(uncachedFrom(position: m(6), buffered: m(9), duration: m(24)), 9 / 24);
expect(uncachedFrom(position: m(6), buffered: m(5), duration: m(24)), 6 / 24, reason: 'stale cache end');
expect(uncachedFrom(position: m(6), buffered: Duration.zero, duration: m(24)), isNull, reason: 'unknown');
expect(uncachedFrom(position: m(6), buffered: m(24), duration: m(24)), isNull, reason: 'cached to the end');
expect(uncachedFrom(position: m(6), buffered: m(9), duration: Duration.zero), isNull);
});
test('cache label: seconds ahead, under 60 s, not when cached to the end', () {
expect(cacheLabel(position: m(6), buffered: m(6, 30), duration: m(24)), '30s');
expect(cacheLabel(position: m(6), buffered: m(6), duration: m(24)), '0s');
expect(cacheLabel(position: m(6), buffered: m(5), duration: m(24)), '0s');
expect(cacheLabel(position: m(6), buffered: m(7), duration: m(24)), isNull, reason: 'a minute or more');
expect(cacheLabel(position: m(23, 40), buffered: m(24), duration: m(24)), isNull, reason: 'eof cached');
expect(cacheLabel(position: m(6), buffered: Duration.zero, duration: m(24)), isNull);
});
test('geometry: times map around the position line', () {
const g = UoscTimelineGeometry(width: 1000, size: 40, progress: 0.5);
expect(g.lineLeft, 499);
expect(g.lineRight, 501);
expect(g.x(0), 0.5);
expect(g.x(1), 1000 - 0.5);
expect(g.x(0.75), greaterThan(g.lineRight), reason: 'past the line');
expect(g.fractionAt(500), 0.5, reason: 'on the line: the position');
expect(g.fractionAt(0), 0);
expect(g.fractionAt(1000), 1);
expect(g.fractionAt(g.x(0.25)), moreOrLessEquals(0.25, epsilon: 0.001));
expect(g.fractionAt(g.x(0.75)), moreOrLessEquals(0.75, epsilon: 0.001));
// Collapsing: the line widens to 20 px over the last eighth.
expect(UoscTimelineGeometry.sizeAt(1), 40);
expect(UoscTimelineGeometry.sizeAt(0), 2);
expect(UoscTimelineGeometry.lineWidthAt(40), 2);
expect(UoscTimelineGeometry.lineWidthAt(2), 20);
});
});
group('timeline', () {
testWidgets('position line, elapsed left, remaining right, cache label', (tester) async {
await tester.pumpWidget(host(UoscTimeline(
position: m(6), duration: m(24), buffered: m(6, 30), chapters: chapters, onSeek: (_) {})));
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
expect(bar.width, 800, reason: 'the whole width (the test window)');
expect(bar.height, UoscTimeline.height);
final p = painter(tester);
expect(p.played, 0.25);
expect(p.geometry.lineRight - p.geometry.lineLeft, UoscTimelineSpec.lineWidth, reason: 'a line, no fill');
expect(p.visibility, 1);
expect(p.uncached, 6.5 / 24);
expect(p.elapsed, '06:00');
expect(p.remaining, '-18:00');
expect(p.cache, '30s');
expect(p.textOpacity, 1);
});
testWidgets('a diamond on the top edge at each chapter start', (tester) async {
await tester.pumpWidget(host(UoscTimeline(
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 hover time, above the bar', (tester) async {
await tester.pumpWidget(host(UoscTimeline(
position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {})));
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
final mouse = await mouseAt(tester, Offset.zero);
final g = painter(tester).geometry;
// 01:51 of 24:00: inside "Opening".
await mouse.moveTo(Offset(bar.left + g.x(111 / (24 * 60)) + 0.2, bar.center.dy));
await tester.pump();
expect(boxText(tester, 'uosc-hover-chapter'), 'Opening');
expect(boxText(tester, 'uosc-hover-time'), '01:51');
final title = tester.getRect(find.byKey(const ValueKey('uosc-hover-chapter')));
final time = tester.getRect(find.byKey(const ValueKey('uosc-hover-time')));
expect(title.bottom, lessThanOrEqualTo(time.top), reason: 'two boxes, title above');
expect(time.bottom, lessThan(bar.top - 4), reason: 'clear of the diamonds');
expect(painter(tester).hoverX, isNotNull);
await mouse.moveTo(Offset(bar.left + bar.width * (10 / 24), bar.center.dy));
await tester.pump();
expect(boxText(tester, 'uosc-hover-chapter'), 'Part A');
await mouse.moveTo(Offset(bar.left, bar.top - 200));
await tester.pump();
expect(find.byKey(const ValueKey('uosc-hover-time')), findsNothing);
});
testWidgets('a press on a hovered diamond jumps to its chapter', (tester) async {
final seeks = <Duration>[];
await tester.pumpWidget(host(UoscTimeline(
position: m(1), duration: m(24), chapters: chapters, onSeek: seeks.add)));
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
final at = Offset(bar.left + painter(tester).geometry.x(2 / 24) + 3, bar.top + 2);
final mouse = await mouseAt(tester, at);
await mouse.moveTo(at);
await tester.pump();
expect(painter(tester).hoveredChapter, 2);
expect(boxText(tester, 'uosc-hover-time'), '02:00');
await mouse.down(at);
await mouse.up();
await tester.pump();
expect(seeks, [m(2)]);
});
testWidgets('hidden controls: the progress line, no text, no input', (tester) async {
final seeks = <Duration>[];
await tester.pumpWidget(host(UoscTimeline(
position: m(12), duration: m(24), buffered: m(14), chapters: chapters, expanded: false, onSeek: seeks.add)));
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
expect(bar.height, UoscTimeline.collapsedHeight);
final p = painter(tester);
expect(p.played, 0.5);
expect(p.visibility, 0);
expect(p.geometry.lineWidth, UoscTimelineSpec.progressLineWidth);
expect(p.textOpacity, 0);
final mouse = await mouseAt(tester, Offset.zero);
await mouse.moveTo(bar.center);
await tester.pump();
expect(find.byKey(const ValueKey('uosc-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(UoscTimeline(
position: m(1), duration: m(24), chapters: chapters, onSeek: seeks.add)));
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
final g = painter(tester).geometry;
final g0 = await tester.startGesture(Offset(bar.left + g.x(0.25), bar.center.dy), kind: PointerDeviceKind.mouse);
expect(seeks, hasLength(1), reason: 'the press seeks');
expect(seeks.single.inSeconds, closeTo(6 * 60, 2));
await g0.moveTo(Offset(bar.left + bar.width / 2, bar.center.dy));
await tester.pump();
expect(painter(tester).elapsed, uoscClock(seeks.last, m(24)), reason: 'follows the drag');
await g0.moveTo(Offset(bar.left + bar.width * 3 / 4, bar.center.dy));
await g0.up();
await tester.pump();
expect(seeks, hasLength(3), reason: 'release at the last move: no repeat');
expect(painter(tester).played, moreOrLessEquals(seeks.last.inMilliseconds / m(24).inMilliseconds, epsilon: 0.01),
reason: 'shows the target until the position catches up');
await tester.pump(const Duration(seconds: 3));
expect(painter(tester).played, 1 / 24, reason: 'never confirmed: back to the position');
});
});
group('controls frame', () {
Widget frame({List<Widget> left = const [], Duration hideAfter = const Duration(seconds: 3)}) => MaterialApp(
home: Scaffold(
backgroundColor: Colors.black,
body: UoscControls(
hideAfter: hideAfter,
left: left,
right: const [UoscButton(key: ValueKey('right'), icon: Icons.fullscreen, tooltip: 'Fullscreen', onPressed: null)],
timeline: (context, expanded) => UoscTimeline(
position: m(6), duration: m(24), chapters: chapters, expanded: expanded, onSeek: (_) {}),
),
),
);
double buttonsOpacity(WidgetTester tester) => tester
.widget<AnimatedOpacity>(find.descendant(
of: find.byKey(const ValueKey('uosc-buttons')), matching: find.byType(AnimatedOpacity)).first)
.opacity;
testWidgets('buttons above a full-width timeline; hide after the delay, mouse brings them back',
(tester) async {
await tester.pumpWidget(frame());
final screen = tester.getSize(find.byType(UoscControls));
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
expect(bar.width, screen.width);
expect(bar.bottom, screen.height);
expect(tester.getRect(find.byKey(const ValueKey('right'))).bottom, lessThanOrEqualTo(bar.top));
expect(buttonsOpacity(tester), 1);
await tester.pump(const Duration(seconds: 4));
await tester.pumpAndSettle();
expect(buttonsOpacity(tester), 0);
expect(tester.getRect(find.byKey(const ValueKey('uosc-timeline'))).height, UoscTimeline.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(buttonsOpacity(tester), 1);
await tester.pump(UoscTimeline.animation);
expect(tester.getRect(find.byKey(const ValueKey('uosc-timeline'))).height, UoscTimeline.height,
reason: 'grown back (animated)');
});
testWidgets('a long drag keeps the controls up', (tester) async {
await tester.pumpWidget(frame());
final bar = tester.getRect(find.byKey(const ValueKey('uosc-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(buttonsOpacity(tester), 1, reason: 'held while dragging');
expect(tester.getRect(find.byKey(const ValueKey('uosc-timeline'))).height, UoscTimeline.height);
await g.up();
await g.removePointer();
await tester.pump(const Duration(seconds: 4));
await tester.pumpAndSettle();
expect(buttonsOpacity(tester), 0, reason: 'hides again after release');
});
testWidgets('an open menu keeps the controls up', (tester) async {
await tester.pumpWidget(frame(left: [
UoscTracksButton(type: JellyfinStreamType.audio, tracks: tracks, onPick: (_) {}),
]));
await tester.tap(find.byKey(const ValueKey('uosc-audio')));
await tester.pump();
expect(find.byType(UoscMenuPanel<TrackPick>), findsOneWidget);
await tester.pump(const Duration(seconds: 10));
expect(buttonsOpacity(tester), 1);
await tester.tapAt(const Offset(5, 5)); // outside: dismiss
await tester.pump();
expect(find.byType(UoscMenuPanel<TrackPick>), findsNothing);
await tester.pump(const Duration(seconds: 4));
await tester.pumpAndSettle();
expect(buttonsOpacity(tester), 0);
});
});
group('buttons', () {
testWidgets('track buttons carry the count; dark tooltip on hover', (tester) async {
await tester.pumpWidget(host(Row(children: [
UoscTracksButton(type: JellyfinStreamType.subtitle, tracks: tracks, onPick: (_) {}),
UoscTracksButton(type: JellyfinStreamType.audio, tracks: tracks.take(1).toList(), onPick: (_) {}),
const UoscButton(key: ValueKey('plain'), icon: Icons.menu, tooltip: 'Menu', onPressed: null),
])));
String badge(String key) => tester
.widget<Text>(find.descendant(
of: find.descendant(of: find.byKey(ValueKey(key)), matching: find.byKey(const ValueKey('uosc-badge'))),
matching: find.byType(Text)))
.data!;
expect(badge('uosc-subtitles'), '2');
expect(badge('uosc-audio'), '1');
expect(find.descendant(of: find.byKey(const ValueKey('plain')), matching: find.byKey(const ValueKey('uosc-badge'))),
findsNothing);
final mouse = await mouseAt(tester, Offset.zero);
await mouse.moveTo(tester.getCenter(find.byKey(const ValueKey('uosc-subtitles'))));
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(find.text('Subtitles'), findsOneWidget);
final tip = tester.widget<Tooltip>(find.descendant(of: find.byKey(const ValueKey('uosc-subtitles')), matching: find.byType(Tooltip)).first);
expect((tip.decoration as BoxDecoration).color, UoscStyle.panel);
});
testWidgets('shader badge shows the preset, dimmed "4K" when off', (tester) async {
final s = AppSettings.instance;
s.shaderPreset.value = ShaderPreset.off;
await tester.pumpWidget(host(Row(children: [
UoscShaderButton(renderMode: signal(RenderMode.gpu), onPick: (_, _) {}),
])));
expect(find.text('4K'), findsOneWidget);
expect(tester.widget<UoscBadgeButton>(find.byType(UoscBadgeButton)).active, isFalse);
s.shaderPreset.value = ShaderPreset.ca;
s.shaderQuality.value = ShaderQuality.fast;
await tester.pump();
expect(find.text('C+A Fast'), findsOneWidget);
expect(tester.widget<UoscBadgeButton>(find.byType(UoscBadgeButton)).active, isTrue);
s.shaderPreset.value = ShaderPreset.off;
s.shaderQuality.value = ShaderQuality.hq;
});
testWidgets('volume: click mutes and restores, wheel steps 5', (tester) async {
final got = <double>[];
var volume = 80.0;
await tester.pumpWidget(host(StatefulBuilder(
builder: (context, setState) => Row(children: [
UoscVolumeButton(volume: volume, onChanged: (v) => setState(() => got.add(volume = v))),
]),
)));
await tester.tap(find.byKey(const ValueKey('uosc-volume')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey('uosc-volume')));
await tester.pump();
await tester.sendEventToBinding(PointerScrollEvent(
kind: PointerDeviceKind.mouse,
position: tester.getCenter(find.byKey(const ValueKey('uosc-volume'))),
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: tester.getCenter(find.byKey(const ValueKey('uosc-volume'))),
scrollDelta: const Offset(0, 20)));
}
await tester.pump();
expect(got, [0, 80, 85, 80], reason: 'smooth deltas add up to a step');
});
});
group('volume wheel', () {
testWidgets('anywhere: vertical steps; lists scroll instead; sideways is nothing', (tester) async {
final steps = <int>[];
final list = ScrollController();
addTearDown(list.dispose);
await tester.pumpWidget(MaterialApp(
home: VolumeWheel(
onStep: steps.add,
child: Stack(children: [
const Positioned.fill(child: ColoredBox(color: Colors.black)),
Positioned(
right: 0,
top: 0,
width: 200,
height: 200,
child: ListView(controller: list, children: [for (var i = 0; i < 50; i++) Text('row $i')]),
),
]),
),
));
Future<void> wheel(Offset at, Offset delta) =>
tester.sendEventToBinding(PointerScrollEvent(kind: PointerDeviceKind.mouse, position: at, scrollDelta: delta));
await wheel(const Offset(100, 400), const Offset(0, -60));
await wheel(const Offset(100, 400), const Offset(0, 60));
expect(steps, [1, -1], reason: 'up louder, down quieter');
await wheel(const Offset(100, 400), const Offset(60, 0));
await wheel(const Offset(100, 400), const Offset(60, -10));
expect(steps, [1, -1], reason: 'sideways');
await wheel(const Offset(700, 100), const Offset(0, 60));
await tester.pump();
expect(steps, [1, -1], reason: 'the list took it');
expect(list.offset, greaterThan(0));
});
});
group('menus', () {
Color rowColor(WidgetTester tester, String title) => (tester
.widget<Container>(find.descendant(
of: find.byKey(ValueKey('uosc-menu-item-$title')), matching: find.byType(Container)).first)
.decoration! as BoxDecoration)
.color!;
testWidgets('audio: flat dark list, current highlighted, pick selects', (tester) async {
TrackPick? got;
await tester.pumpWidget(host(Row(children: [
UoscTracksButton(type: JellyfinStreamType.audio, tracks: tracks, onPick: (p) => got = p),
])));
await tester.tap(find.byKey(const ValueKey('uosc-audio')));
await tester.pumpAndSettle();
final menu = find.byKey(const ValueKey('uosc-menu'));
expect(tester.widget<Material>(menu).color, UoscStyle.panel);
expect(tester.getRect(menu).bottom, lessThan(tester.getRect(find.byKey(const ValueKey('uosc-audio'))).top),
reason: 'opens above the button');
expect(find.text('Audio'), findsOneWidget, reason: 'title row');
expect(rowColor(tester, 'Japanese · E-AC3 2.0'), UoscStyle.fill);
expect(rowColor(tester, 'English · AAC 5.1 · Dub'), isNot(UoscStyle.fill));
await tester.tap(find.byKey(const ValueKey('uosc-menu-item-English · AAC 5.1 · Dub')));
await tester.pumpAndSettle();
expect(got, (aid: 2, sid: null, subtitlesOff: false));
expect(menu, findsNothing);
});
testWidgets('subtitles start with Off, active when none is selected', (tester) async {
TrackPick? got;
await tester.pumpWidget(host(Row(children: [
UoscTracksButton(type: JellyfinStreamType.subtitle, tracks: tracks, onPick: (p) => got = p),
])));
await tester.tap(find.byKey(const ValueKey('uosc-subtitles')));
await tester.pumpAndSettle();
expect(rowColor(tester, 'Off'), UoscStyle.fill);
await tester.tap(find.byKey(const ValueKey('uosc-menu-item-Off')));
await tester.pumpAndSettle();
expect(got, (aid: null, sid: null, subtitlesOff: true));
});
testWidgets('chapters: titles with start times, the playing one highlighted', (tester) async {
MediaChapter? got;
await tester.pumpWidget(host(Row(children: [
UoscChaptersButton(chapters: chapters, current: 2, duration: m(24), onPick: (c) => got = c),
])));
await tester.tap(find.byKey(const ValueKey('uosc-chapters')));
await tester.pumpAndSettle();
for (final c in chapters) {
expect(find.descendant(of: find.byKey(ValueKey('uosc-menu-item-${c.title}')), matching: find.text(formatUoscTime(c.start))),
findsOneWidget);
}
expect(rowColor(tester, 'Part A'), UoscStyle.fill);
expect(rowColor(tester, 'Opening'), isNot(UoscStyle.fill));
await tester.tap(find.byKey(const ValueKey('uosc-menu-item-Ending')));
await tester.pumpAndSettle();
expect(got, chapters[3]);
});
testWidgets('no chapters: no button', (tester) async {
await tester.pumpWidget(host(Row(children: [
UoscChaptersButton(chapters: const [], current: null, duration: m(24), onPick: (_) {}),
])));
expect(find.byKey(const ValueKey('uosc-chapters')), findsNothing);
});
testWidgets('main menu runs the picked action', (tester) async {
final ran = <String>[];
await tester.pumpWidget(host(Row(children: [
UoscMainMenuButton(actions: [
(title: 'Change video', hint: null, run: () => ran.add('swap')),
(title: 'Reload video', hint: 'Ctrl+R', run: () => ran.add('reload')),
]),
])));
await tester.tap(find.byKey(const ValueKey('uosc-menu-button')));
await tester.pumpAndSettle();
expect(find.text('Ctrl+R'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('uosc-menu-item-Reload video')));
await tester.pumpAndSettle();
expect(ran, ['reload']);
});
});
testWidgets('the setting picks the controls builder, live', (tester) async {
final s = AppSettings.instance;
s.mpvControls.value = false;
await tester.pumpWidget(MaterialApp(
home: SessionControlsSwitch(
material: (_) => const Text('material'),
mpv: (_) => const Text('mpv'),
),
));
expect(find.text('material'), findsOneWidget);
s.mpvControls.value = true;
await tester.pump();
expect(find.text('mpv'), findsOneWidget);
expect(find.text('material'), findsNothing);
s.mpvControls.value = false;
await tester.pump();
expect(find.text('material'), findsOneWidget);
});
testWidgets('Settings: "Player design", Standard by default', (tester) async {
final s = AppSettings.instance;
s.mpvControls.value = false;
addTearDown(() => s.mpvControls.value = false);
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<bool>>(design).value, isFalse);
await tester.tap(design);
await tester.pumpAndSettle();
await tester.tap(find.text('uosc').last);
await tester.pumpAndSettle();
expect(s.mpvControls.value, isTrue);
expect(tester.widget<AppSelect<bool>>(design).value, isTrue);
});
}
@@ -205,6 +205,26 @@ void main() {
expect(dev.status.value.phase, UpdatePhase.idle); expect(dev.status.value.phase, UpdatePhase.idle);
}); });
test('Later hides it until the next check; a check leaves an install be',
() async {
final c = UpdateController(
currentVersion: '0.1.0',
manifestUrl: server.manifestUrl,
executablePath: '${work.path}/stray-binary',
restart: (_, _) async {});
await c.checkOnce();
expect(c.status.value.phase, UpdatePhase.available);
c.dismiss();
expect(c.status.value.phase, UpdatePhase.idle);
await c.checkNow();
expect(c.status.value.phase, UpdatePhase.available);
final m = c.status.value.manifest;
c.status.value = UpdateStatus(UpdatePhase.downloading,
manifest: m, progress: 0.4, canInstall: true);
await c.checkNow();
expect(c.status.value.phase, UpdatePhase.downloading);
});
test('failed install shows error, install untouched, retry works', test('failed install shows error, install untouched, retry works',
() async { () async {
server.manifestExtra = {'sha256': '0' * 64}; server.manifestExtra = {'sha256': '0' * 64};
@@ -0,0 +1,97 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart' show VideoState;
import 'package:watch_together/src/controls/model/controls_prefs.dart';
import 'package:watch_together/src/controls/frame.dart';
import 'package:watch_together/src/shell/session_player.dart';
import 'package:watch_together/src/state/session_state.dart';
import 'package:watch_together/src/state/settings.dart';
// The wheel anywhere on the full player flashes the volume readout, in
// both skins, windowed and fullscreen, with the controls up or hidden.
// The picture is a black box with the session's controls on it (never a
// media_kit `Video`).
void main() {
final coord = SessionState.instance.coordinator;
final s = AppSettings.instance;
// A real player for the volume steps to go to. Never disposed: the
// steps' mpv calls start in the test's fake time and never finish, so a
// dispose would wait on them for ever; the test process ending ends it.
setUpAll(() async {
MediaKit.ensureInitialized();
await coord.playback.init();
});
testWidgets('wheel over the video: readout for about a second', (tester) async {
addTearDown(() => s.controlsSkin.value = ControlsSkin.standard);
tester.view.physicalSize = const Size(1280, 720);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final readout = find.byKey(const ValueKey('volume-readout'));
double opacity() => readout.evaluate().isEmpty
? 0
: tester.widget<AnimatedOpacity>(find.ancestor(of: readout, matching: find.byType(AnimatedOpacity)).first).opacity;
Future<void> wheel() async {
await tester.sendEventToBinding(
PointerScrollEvent(kind: PointerDeviceKind.mouse, position: const Offset(640, 300), scrollDelta: const Offset(0, -60)));
await tester.pump();
}
for (final skin in ControlsSkin.values) {
for (final fullscreen in [false, true]) {
final what = '${skin.name}, ${fullscreen ? 'fullscreen' : 'windowed'}';
s.controlsSkin.value = skin;
await tester.pumpWidget(MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(
body: SessionPlayer(
key: ValueKey(what),
mode: PlayerMode.full,
fullscreen: fullscreen,
onExpand: () {},
onCollapse: () {},
onClose: () {},
onToggleFullscreen: () {},
onToggleChat: () {},
onChangeVideo: () {},
onOpenIntegrations: () {},
onLeave: () {},
windowControls: false,
videoBuilder: (context, controls) => Stack(fit: StackFit.expand, children: [
const ColoredBox(color: Colors.black),
if (controls != null) controls(VideoState()),
]),
),
),
));
await tester.pump();
expect(opacity(), 0, reason: '$what: nothing before a step');
// Controls up.
expect(tester.state<ControlsFrameState>(find.byType(ControlsFrame)).visible, isTrue, reason: what);
await wheel();
expect(opacity(), 1, reason: '$what, controls up: shown');
await tester.pump(const Duration(milliseconds: 900));
expect(opacity(), 1, reason: '$what: still up within the second');
await tester.pump(const Duration(milliseconds: 300));
expect(opacity(), 0, reason: '$what: gone after the second');
// Controls hidden (no mouse movement past the hide delay).
await tester.pump(Duration(seconds: s.controlsHideSecs.value + 1));
await tester.pump(const Duration(milliseconds: 500));
expect(tester.state<ControlsFrameState>(find.byType(ControlsFrame)).visible, isFalse, reason: what);
await wheel();
expect(opacity(), 1, reason: '$what, controls hidden: shown');
await tester.pump(const Duration(milliseconds: 1200));
expect(opacity(), 0, reason: '$what: gone after the second');
}
}
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(seconds: 5));
});
}
@@ -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
) )
@@ -1,11 +1,30 @@
#include "flutter_window.h" #include "flutter_window.h"
#include <cstdio>
#include <optional> #include <optional>
#include <thread>
#include <flutter/standard_method_codec.h> #include <flutter/standard_method_codec.h>
#include "flutter/generated_plugin_registrant.h" #include "flutter/generated_plugin_registrant.h"
namespace {
// Ends the process after |ms| whatever is still running: the app's close
// (lib/src/platform/app_shutdown.dart) is bounded, but a native teardown
// can block for good (media_kit_video's ~VideoOutput waits on a texture
// callback the stopped engine never sends). The process must never stay
// behind a hidden window.
void ExitAfter(DWORD ms) {
std::thread([ms] {
::Sleep(ms);
fflush(nullptr);
::TerminateProcess(::GetCurrentProcess(), 0);
}).detach();
}
} // namespace
FlutterWindow::FlutterWindow(const flutter::DartProject& project) FlutterWindow::FlutterWindow(const flutter::DartProject& project)
: project_(project) {} : project_(project) {}
@@ -60,6 +79,8 @@ bool FlutterWindow::OnCreate() {
} }
void FlutterWindow::OnDestroy() { void FlutterWindow::OnDestroy() {
// The engine and the plugins go now; the app released its players first.
ExitAfter(3000);
window_channel_ = nullptr; window_channel_ = nullptr;
if (flutter_controller_) { if (flutter_controller_) {
flutter_controller_ = nullptr; flutter_controller_ = nullptr;
@@ -72,6 +93,13 @@ LRESULT
FlutterWindow::MessageHandler(HWND hwnd, UINT const message, FlutterWindow::MessageHandler(HWND hwnd, UINT const message,
WPARAM const wparam, WPARAM const wparam,
LPARAM const lparam) noexcept { LPARAM const lparam) noexcept {
// A close request starts the app's close (players, leave), at most a
// few seconds; past 15 s the process ends anyway.
if (message == WM_CLOSE && !close_requested_) {
close_requested_ = true;
ExitAfter(15000);
}
// Give Flutter, including plugins, an opportunity to handle window messages. // Give Flutter, including plugins, an opportunity to handle window messages.
if (flutter_controller_) { if (flutter_controller_) {
std::optional<LRESULT> result = std::optional<LRESULT> result =
@@ -34,6 +34,9 @@ class FlutterWindow : public Win32Window {
// "starsync/window": `attention` flashes the taskbar entry while the // "starsync/window": `attention` flashes the taskbar entry while the
// window is not in front (lib/src/platform/attention.dart). // window is not in front (lib/src/platform/attention.dart).
std::unique_ptr<flutter::MethodChannel<flutter::EncodableValue>> window_channel_; std::unique_ptr<flutter::MethodChannel<flutter::EncodableValue>> window_channel_;
// The first WM_CLOSE arms the exit watchdog.
bool close_requested_ = false;
}; };
#endif // RUNNER_FLUTTER_WINDOW_H_ #endif // RUNNER_FLUTTER_WINDOW_H_
+9
View File
@@ -2,6 +2,8 @@
#include <flutter/flutter_view_controller.h> #include <flutter/flutter_view_controller.h>
#include <windows.h> #include <windows.h>
#include <cstdio>
#include "flutter_window.h" #include "flutter_window.h"
#include "utils.h" #include "utils.h"
@@ -38,6 +40,13 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
::DispatchMessage(&msg); ::DispatchMessage(&msg);
} }
// The window closed through WM_CLOSE (the app disposed its mpv players
// and left first), so the engine and plugins were torn down inside the
// loop. End here without the CRT exit and DLL_PROCESS_DETACH: a driver
// or libmpv detach handler can hang waiting on threads that are gone.
// Nothing is left to flush but stdio.
fflush(nullptr);
::CoUninitialize(); ::CoUninitialize();
::TerminateProcess(::GetCurrentProcess(), EXIT_SUCCESS);
return EXIT_SUCCESS; return EXIT_SUCCESS;
} }
+210 -121
View File
@@ -193,7 +193,7 @@ Loosely modeled on Syncplay, with a different readiness design.
the server state and reconnect every 1.5 s until cancelled (an epoch counter makes the server state and reconnect every 1.5 s until cancelled (an epoch counter makes
a cancelled attempt stale). After a reconnect the ready flag is a cancelled attempt stale). After a reconnect the ready flag is
restored from the server's state. Cancel during setup aborts the join. restored from the server's state. Cancel during setup aborts the join.
Closing the window leaves gracefully (2 s timeout). Closing the window leaves gracefully (see "App close" under Desktop UI).
- **Idle** (`IdleTracker`): measured from the later of the last input and the - **Idle** (`IdleTracker`): measured from the later of the last input and the
moment playback stopped. While HELD and self ready: warning at 8 min, moment playback stopped. While HELD and self ready: warning at 8 min,
auto-unready at 10 min, and never less than 2 min after the warning. auto-unready at 10 min, and never less than 2 min after the warning.
@@ -294,24 +294,32 @@ page yt-dlp knows", network errors → "Couldn't reach the site", anything
else as is). It rides in `report.error` (see "Player error" in the sync else as is). It rides in `report.error` (see "Player error" in the sync
model) until the media opens (a new duration) or the server's `mediaId` model) until the media opens (a new duration) or the server's `mediaId`
moves on; the pill shows "Can't play this: ..." with Retry (Reload moves on; the pill shows "Can't play this: ..." with Retry (Reload
video). The cache is in RAM: video). The cache is in RAM: `cache-on-disk=no` is pinned (media_kit
media_kit sets `cache-on-disk` without a `cache-dir`, and mpv falls back. mpv sets it on without a `cache-dir`, so mpv fell back to RAM anyway, warning
"Failed to create file cache." on every open). mpv
ignores invalid values silently, so every pin is read back and a mismatch is ignores invalid values silently, so every pin is read back and a mismatch is
logged and kept in `pinMismatches`. Measured against the `/m` endpoint logged and kept in `pinMismatches`. Measured against the `/m` endpoint
(`readahead_e2e_test`): a paused player buffers ~147 s ahead (10 s before). (`readahead_e2e_test`): a paused player buffers ~147 s ahead (10 s before).
media_kit has no public `setProperty`/`getProperty`; the code calls them media_kit has no public `setProperty`/`getProperty`; the code calls them
through a guarded dynamic call on `player.platform`. Hardware decoding comes from Settings (auto, off, custom) through a guarded dynamic call on `player.platform`. Hardware decoding comes from Settings (auto, off, custom)
and is passed to the `VideoController`; env `SYNC_RELAY_HWDEC` (e.g. `no`) and is passed to the `VideoController`; env `SYNC_RELAY_HWDEC` (e.g. `no`)
overrides it. It is read per player, so a change applies on the next media, overrides it. Auto is mpv `auto` on Linux and `auto-copy` on Windows
(`autoHwdec`, also for a blank custom value): media_kit renders through
ANGLE there, where `auto` picks the dxva2 interop, which cannot create its
EGL surface ("Failed to create EGL surface"); `auto-copy` decodes on the
GPU (d3d11va-copy, dxva2-copy, ... whichever works) and copies frames
back, needing no interop. It is read per player, so a change applies on the next media,
join or Reload video. join or Reload video.
Shaders (per viewer, local only, no wire change): mpv `glsl-shaders`, set Shaders (per viewer, local only, no wire change): mpv `glsl-shaders`, set
through the same dynamic `setProperty` and read back through the same dynamic `setProperty` and read back
(`PlaybackController.applyShaders`). The value is the platform path list (`PlaybackController.applyShaders`). The value is the platform path list
(`:` joined, `;` on Windows). One saved choice in `AppSettings` (`:` joined, `;` on Windows). The read-back is compared as a list
(`shaderListMatches`): older mpv (0.37 and before, the bundled Windows
libmpv among them) prints it `,` joined. One saved choice in `AppSettings`
(`shaderPreset`, `shaderQuality`, `customShaders`): applied on every player (`shaderPreset`, `shaderQuality`, `customShaders`): applied on every player
creation (join, new media, Reload video) and live when changed from the session (the creation (join, new media, Reload video) and live when changed from the session (the
control bar's shader menu, Ctrl+0..6, both also save it). The Anime4K controls' ⚙ → Upscaling shader menu, Ctrl+0..6, both also save it). The Anime4K
v4.0.1 files live in `apps/desktop/assets/shaders/anime4k/` (only the 14 v4.0.1 files live in `apps/desktop/assets/shaders/anime4k/` (only the 14
the presets use, plus its MIT LICENSE); mpv needs real paths, so the presets use, plus its MIT LICENSE); mpv needs real paths, so
`extractAnime4k` copies them once per version to `extractAnime4k` copies them once per version to
@@ -330,12 +338,22 @@ mpv logs at `v` while the controller is created: OpenGL init logs
(`RenderMode.gpu`; "suspected software renderer" means llvmpipe, (`RenderMode.gpu`; "suspected software renderer" means llvmpipe,
`softwareGl`); none within 1.5 s of the controller being ready means the `softwareGl`); none within 1.5 s of the controller being ready means the
software path (`software`, where shaders do nothing; the shader toast, the software path (`software`, where shaders do nothing; the shader toast, the
menu and Settings say so). Otherwise the session player logs at `warn` menu and Settings say so). Into the app log (`mpv`, INFO, all starting
`render`): once per app start on Linux `render env:` (`XDG_SESSION_TYPE`,
`WAYLAND_DISPLAY`, `DISPLAY`, `GDK_BACKEND`, `FLUTTER_LINUX_RENDERER`,
`LIBGL_ALWAYS_SOFTWARE`, `__EGL_VENDOR_LIBRARY_FILENAMES`, `-` when
unset); from that `v` window only mpv's GL init lines (`GL_VENDOR`,
`GL_RENDERER`, `GL_VERSION`, `GL_SHADING_LANGUAGE_VERSION`, `Detected
...`); the mode decided at the end of it; and per player, at its first
frame, `hwdec-current` and the `hwdec` asked for (the first one per app
start also `mpv-version` and `ffmpeg-version`). Why media_kit fell back to
software it only prints on stderr (`media_kit: VideoOutput: ...`), which
the app does not capture. Otherwise the session player logs at `warn`
(`PlaybackController.logLevel`, switched with `mpv_request_log_messages` (`PlaybackController.logLevel`, switched with `mpv_request_log_messages`
on media_kit's handle): media_kit drains mpv's log on the UI thread, one on media_kit's handle): media_kit drains mpv's log on the UI thread, one
message at a time, so a chatty level, or ffmpeg logging every retry of a message at a time, so a chatty level, or ffmpeg logging every retry of a
refused request, could starve the UI. Shader and yt-dlp hook errors are refused request, could starve the UI. Shader and yt-dlp hook errors are
`error` lines and still arrive. The thumbnail and sound players keep `error` lines and still arrive. The thumbnail players keep
media_kit's default (`error`) and nothing listens to their log. The hardware path is media_kit's default (`error`) and nothing listens to their log. The hardware path is
fragile: media_kit borrows whatever EGL context GDK left current on the main fragile: media_kit borrows whatever EGL context GDK left current on the main
thread (upstream media-kit#1404), so an X11 session or a Flutter/GDK change thread (upstream media-kit#1404), so an X11 session or a Flutter/GDK change
@@ -400,7 +418,7 @@ Skip rules (pure, `media_marks.dart`):
decides). Without any opening, without chapters (a chaptered file decides). Without any opening, without chapters (a chaptered file
without an OP chapter has none on purpose) and with media over 20 min, without an OP chapter has none on purpose) and with media over 20 min,
"Skip 1:30" during the first 6 min. "Skip 1:30" during the first 6 min.
- +1:30 (`skipForward`: the pill, the controls' "1:30" button, - +1:30 (`skipForward`: the pill, ⚙ → Skip 1:30,
Shift+Right) is a `nudge`, so repeated presses add up. Shift+Right) is a `nudge`, so repeated presses add up.
- Chapter steps (PgUp / PgDn, `chapterStepTarget`): forward to the next - Chapter steps (PgUp / PgDn, `chapterStepTarget`): forward to the next
start; back to the current start, or the previous one within 5 s of it start; back to the current start, or the previous one within 5 s of it
@@ -863,10 +881,11 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
- **Episodes**: with `OwnPlay` on an episode, `adjacentEpisodes` (one - **Episodes**: with `OwnPlay` on an episode, `adjacentEpisodes` (one
`/Shows/{id}/Episodes` call; `episodeBefore` / `episodeAfter`) gives the `/Shows/{id}/Episodes` call; `episodeBefore` / `episodeAfter`) gives the
previous and next episode. The controls show previous / next buttons previous and next episode. The controls show previous / next buttons
(also `<` `>`, Shift+P / Shift+N); from `nextPromptStart` (the ending's (also `<` `>`, Shift+P / Shift+N; next is Up next's first item when
there is one); from `nextPromptStart` (the ending's
start when one is known, see "Skip rules", else the last 2 min of the start when one is known, see "Skip rules", else the last 2 min of the
runtime) a floating "Next episode" pill shows while the controls are up runtime) a floating "Next episode" pill shows while the controls are up
(see "Session controls"). Auto-advance still waits for `finished`. Any (see "Player controls"). Auto-advance still waits for `finished`. Any
of them swaps to that episode's ref with the clicker's account. Guests of them swaps to that episode's ref with the clicker's account. Guests
(relay, blocked) see none of it. (relay, blocked) see none of it.
- **Auto-advance** (Settings, on by default): at `finished` with a known - **Auto-advance** (Settings, on by default): at `finished` with a known
@@ -881,15 +900,15 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
and with it every countdown. Only while Up next is empty and its and with it every countdown. Only while Up next is empty and its
continuation was not cleared (see "Up next"); a queued item replaces continuation was not cleared (see "Up next"); a queued item replaces
a countdown already running. a countdown already running.
- **Tracks**: the controls' Audio and Subtitles buttons list - **Tracks**: the controls' Audio and Subtitles buttons (or ⚙ when
`PlaybackController.tracks`. A pick is applied, reported, and saved as a `TrackChoice` hidden from the bar) list `PlaybackController.tracks`. A pick is applied, reported, and saved as a `TrackChoice`
(language, title, codec, channels, forced/HI: from the Jellyfin streams (language, title, codec, channels, forced/HI: from the Jellyfin streams
with `OwnPlay`, else from mpv's track list) per series and as the global with `OwnPlay`, else from mpv's track list) per series and as the global
fallback in the `track_prefs` Hive box. On the next Jellyfin item it is fallback in the `track_prefs` Hive box. On the next Jellyfin item it is
matched with `matchTrack` against the source's streams (mapped to mpv ids matched with `matchTrack` against the source's streams (mapped to mpv ids
with `mpvTrackId`) or mpv's track list, and selected locally. Nothing goes with `mpvTrackId`) or mpv's track list, and selected locally. Nothing goes
on the wire. on the wire.
- **Change video**: the player's "Change video" opens the Library tab; - **Change video**: the player's ⚙ → Change video opens the Library tab;
Link goes through `mediaForLink`, a Jellyfin pick as a ref, a file Link goes through `mediaForLink`, a Jellyfin pick as a ref, a file
through `ServerIsolate.setFile` (host) or the relay (`swapFile`). through `ServerIsolate.setFile` (host) or the relay (`swapFile`).
@@ -1015,8 +1034,8 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
"+" adds one. "Clear" hides them for the media playing and keeps its "+" adds one. "Clear" hides them for the media playing and keeps its
next-episode countdown from starting. next-episode countdown from starting.
- **UI**: `UpNextButton` is one of the player's top-bar controls, before - **UI**: `UpNextButton` is one of the player's top-bar controls, before
chat (see "Player overlay" under Desktop UI), in the look of the the window controls (see "Player overlay" under Desktop UI), in the look
design's buttons (`UpNextLook`); it shows and hides with the controls, of the skin's buttons (`UpNextLook`); it shows and hides with the controls,
which an open popover holds up: the first item's picture (`QueueThumb`, which an open popover holds up: the first item's picture (`QueueThumb`,
the rows' faint box and icon until it arrives), "UP NEXT" over its the rows' faint box and icon until it arrives), "UP NEXT" over its
title, a +n count, ⏭ plays it now; without Up next the continuation title, a +n count, ⏭ plays it now; without Up next the continuation
@@ -1085,9 +1104,9 @@ Enter picks, Esc or a click outside closes, and being a route it keeps
the player's keys off (fullscreen too). `AppSelect` is the value picker the player's keys off (fullscreen too). `AppSelect` is the value picker
on it (the current label and a chevron on a soft rounded surface; every on it (the current label and a chevron on a soft rounded surface; every
setting that picks one of a few values), `AppMenuButton` the ⋮ action setting that picks one of a few values), `AppMenuButton` the ⋮ action
menu. Material's own menus (the standard controls' track, chapter and menu; the player controls' track, chapter and ⚙ menus are on it too.
shader menus, the Jellyfin sort and server switch) get the same surface Material's own menus (the shader menu, the Jellyfin sort and server
from `AppMenuStyle.theme`; the uosc controls keep their own flat menus. switch) get the same surface from `AppMenuStyle.theme`.
Reusing what the app shows (`widgets/copy_open.dart`): one wording Reusing what the app shows (`widgets/copy_open.dart`): one wording
(`ReuseText`: Copy link, Copy title, Copy message, Open in browser, Open (`ReuseText`: Copy link, Copy title, Copy message, Open in browser, Open
in Jellyfin) in every right-click menu that offers it, and a toast in Jellyfin) in every right-click menu that offers it, and a toast
@@ -1104,7 +1123,8 @@ panel) and chat messages (Copy message, and a link in it).
- **Party strip** (`party_strip.dart`, 60 px): your avatar (profile card; 42 px like the party's), - **Party strip** (`party_strip.dart`, 60 px): your avatar (profile card; 42 px like the party's),
the Library / Schedule / Settings tabs with a growing underline (an the Library / Schedule / Settings tabs with a growing underline (an
`HScroll` when narrow), the update indicator, then offline a grey dot, `HScroll` when narrow; the version in small text under Settings,
`shortVersion`, the full one in its tooltip), then offline a grey dot,
"Offline", Join a friend and Host (connect cards; icon-less "Join" and "Offline", Join a friend and Host (connect cards; icon-less "Join" and
"Host" under 1100 px), solo a "Solo · only you" chip, Invite friends "Host" under 1100 px), solo a "Solo · only you" chip, Invite friends
(the Host card, titled "Invite friends", "Start hosting") and Stop (no (the Host card, titled "Invite friends", "Start hosting") and Stop (no
@@ -1232,7 +1252,8 @@ panel) and chat messages (Copy message, and a link in it).
(icon chips under 760 px) over pages of rounded Material cards (icon chips under 760 px) over pages of rounded Material cards
(`SettingsPanel`), one help line under every setting: profile, party & (`SettingsPanel`), one help line under every setting: profile, party &
network (Host card port and password in the connect form, recent network (Host card port and password in the connect form, recent
servers), player (the shared rows below, custom shader files), servers), player (the shared rows below, the controls' choices, custom
shader files),
Jellyfin & integrations (linked servers, AnimeFlow's `/me` via Jellyfin & integrations (linked servers, AnimeFlow's `/me` via
`AnimeFlowClient.fromAccounts`, auto-advance), notifications & sounds `AnimeFlowClient.fromAccounts`, auto-advance), notifications & sounds
(what pops up; no switches), updates, shortcuts, about. (what pops up; no switches), updates, shortcuts, about.
@@ -1259,10 +1280,17 @@ panel) and chat messages (Copy message, and a link in it).
dark space reverb) to quiet WAVs; `sound_bank.dart` voices every event dark space reverb) to quiet WAVs; `sound_bank.dart` voices every event
in three presets (`SoundPreset`: Cosmic, Abyss, Wood & glass) in one in three presets (`SoundPreset`: Cosmic, Abyss, Wood & glass) in one
shared key, so mixed presets still agree. `SynthSfx` renders a sound on shared key, so mixed presets still agree. `SynthSfx` renders a sound on
first use into the temp dir (cache key: preset, sound, ready index) and first use (on a worker isolate, to WAV bytes in memory) and hands it to
plays it on a pool of three audio-only mpv players, at `SoundEngine` (`platform/audio/sound_engine.dart`), which loads it into
`AppSettings.soundVolume` (square curve) unless switched off in flutter_soloud (SoLoud mixing into a miniaudio output device; voices
`soundsOff`. Settings → Notifications & sounds has the volume, the overlap) once per slot (sound and ready index); a different preset in a
slot replaces and frees the old sound. Each play is at
`AppSettings.soundVolume` (as v^6, SoLoud's linear gain: the square curve times mpv's cubic volume the sounds used to play through, so the slider sounds as before) unless
switched off in `soundsOff`. The device opens 2 s after startup
(`warmUp`), so neither startup nor the first sound waits on it; if it
can't open, the engine logs `no sounds: audio init failed` once and drops
every play. App close disposes the engine (deinit, 500 ms bound)
before leaving the server. Settings → Notifications & sounds has the volume, the
overall sound set (`soundPreset`), and per sound a preview, its own set overall sound set (`soundPreset`), and per sound a preview, its own set
(`soundPresetFor`, else the overall one) and a switch; a preview always (`soundPresetFor`, else the overall one) and a switch; a preview always
re-renders, so an edited spec plays after a hot reload. re-renders, so an edited spec plays after a hot reload.
@@ -1289,7 +1317,7 @@ panel) and chat messages (Copy message, and a link in it).
fullscreen). The app's shortcuts map Tab / Shift+Tab to nothing, so focus fullscreen). The app's shortcuts map Tab / Shift+Tab to nothing, so focus
never lands on a button Space would press. Something coming up while the player never lands on a button Space would press. Something coming up while the player
is closed opens it: full right after joining or after putting it up is closed opens it: full right after joining or after putting it up
here, else mini. Both control designs show `NowPlayingTitle` top left here, else mini. Both control skins show `NowPlayingTitle` top left
(`now_playing_title.dart`: 2:3 cover, title, episode line from (`now_playing_title.dart`: 2:3 cover, title, episode line from
`coordinator.nowPlaying`, art through `artBytes`; it fades through when `coordinator.nowPlaying`, art through `artBytes`; it fades through when
the media changes); the mini shows title and episode without the cover. the media changes); the mini shows title and episode without the cover.
@@ -1313,28 +1341,29 @@ panel) and chat messages (Copy message, and a link in it).
once (the pill is hidden while playing); avatars carry a red error mark once (the pill is hidden while playing); avatars carry a red error mark
and the members card says "can't play this: ...". 34 px avatars, a main line and the members card says "can't play this: ...". 34 px avatars, a main line
and a muted detail line. The pill glass, colour only for problems; it fades through and a muted detail line. The pill glass, colour only for problems; it fades through
between kinds. The mini shows its short line in its hover bar. ⚙ in both between kinds. The mini shows its short line in its hover bar. ⚙ →
control designs opens the player settings card (`controls/ Player settings (both skins) opens the player settings card (`controls/
player_settings.dart`, `showPopover`, on `AppGlass`): player design, progress line player_settings.dart`, `showPopover`, on `AppGlass`): player design, progress line
when hidden, upscaling shader, hardware decoding (set live on mpv's when hidden, upscaling shader, hardware decoding (set live on mpv's
`hwdec`), subtitle and audio track. The rows (`playerSettingRows`) are `hwdec`), subtitle and audio track. The rows (`playerSettingRows`) are
the same widgets Settings → Player shows; all apply live. The progress the same widgets Settings → Player shows; all apply live. Settings →
line (2 px, `AppSettings.progressLine`) is the collapsed uosc timeline; Player also has the controls' own choices (`controlsSettingRows`, see
for the standard design `ControlsHoverProbe` mirrors media_kit's "Player controls"). The progress line (`AppSettings.progressLine`) is
visibility rule (up on mount and mouse movement, down 3 s later or on what the bottom controls leave while hidden: Standard a 2 px line,
exit), as media_kit does not expose it. Fullscreen adds the Minimal its collapsed timeline. Fullscreen adds the
`WindowButtons` to the top bar while the controls are up (restore `WindowButtons` to the top bar while the controls are up (restore
leaves fullscreen). leaves fullscreen).
- **Player overlay** (full player, both designs; `session_player.dart`): - **Player overlay** (full player, both skins; `session_player.dart`):
everything over the picture has a fixed place on one grid (16 px from everything over the picture has a fixed place on one grid (16 px from
the sides, the bars' inset), so nothing floats on its own. The top the sides, the bars' inset), so nothing floats on its own. The top
bar (part of the controls): now-playing title left; right the Up next bar (part of the controls): now-playing title left; right the Up next
button (`UpNextButton`), chat, back-to-library or exit fullscreen, button (`UpNextButton`), back-to-library or exit fullscreen,
then the window buttons in fullscreen, all in that design's button then the window buttons in fullscreen, all in that skin's button
look. Under it at the right, in fullscreen while paused, the party's look. Under it at the right, in fullscreen while paused, the party's
avatars; with the controls down and unread chat, the unread chip top avatars; with the controls down and unread chat, the unread chip top
right. The action area: one band just above the bottom controls right. The action area: one band just above the bottom controls
(`_actionArea`; their height per design, 16 px gap), sliding down to (`_actionArea`; their height per skin and size, `controlsBottomHeight`,
16 px gap), sliding down to
16 px from the bottom while they are hidden (`AnimatedPositioned`, 16 px from the bottom while they are hidden (`AnimatedPositioned`,
the shell's `ease` / `easeTime`); in it the status pill centred the shell's `ease` / `easeTime`); in it the status pill centred
(moved left only as far as it must to clear the right end, never (moved left only as far as it must to clear the right end, never
@@ -1349,17 +1378,15 @@ panel) and chat messages (Copy message, and a link in it).
tall like the status pill without avatars). The mouse on any tall like the status pill without avatars). The mouse on any
floating element counts as the mouse on the controls: a floating element counts as the mouse on the controls: a
`ControlsHold` (`controls/controls_probe.dart`) held by `ControlsHold` (`controls/controls_probe.dart`) held by
`ControlsHoldRegion`s (and by the open Up next popover or its preview) keeps either `ControlsHoldRegion`s (and by the open Up next popover or its preview) keeps the
design's controls up (`UoscControls.holdBy`; `ControlsHoverProbe.hold`, controls up (`ControlsFrame.holdBy`), and lets them hide on their
which re-wakes media_kit's controls through their private hover usual countdown once released.
handler, the same guarded dynamic call as `keepVideoControlsAwake`),
and lets them hide on their usual countdown once released.
- **Chat** (`chat_panel.dart`): a 340 px panel sliding in at the right - **Chat** (`chat_panel.dart`): a 340 px panel sliding in at the right
(strip button or Tab): chat with avatars; the "Activity" chip (off by (strip button or Tab): chat with avatars; the "Activity" chip (off by
default, `showActivity`, kept for the app's run only) adds the other default, `showActivity`, kept for the app's run only) adds the other
feed lines between them. Opening focuses the field; Tab or Esc in it feed lines between them. Opening focuses the field; Tab or Esc in it
close the panel and focus goes back where it was. Unread chat badges close the panel and focus goes back where it was. Unread chat badges
the strip's and both player designs' chat buttons; in fullscreen with the strip's and the player controls' chat buttons; in fullscreen with
the controls down and the video not playing a glass chip the controls down and the video not playing a glass chip
(`PlayerGlass`) top right shows it (while it plays nothing sits on the (`PlayerGlass`) top right shows it (while it plays nothing sits on the
picture). picture).
@@ -1368,8 +1395,10 @@ panel) and chat messages (Copy message, and a link in it).
own have none); each own have none); each
slides in, stays 5 s, then slides out and collapses its gap. A click on slides in, stays 5 s, then slides out and collapses its gap. A click on
a chat toast opens the chat; the others let clicks through. Chat toasts a chat toast opens the chat; the others let clicks through. Chat toasts
follow `AppSettings.chatPopups` (Settings → Notifications: All, Only follow `AppSettings.chatPopups` (Settings → Notifications: All
when paused (default), Off); the message is in the chat either way. (default), Only when paused, Off); the message is in the chat either
way, and its sound plays whatever this setting (its own switch in
Sounds).
Settings persist in two Hive boxes: `connect_form` (last form, recent Settings persist in two Hive boxes: `connect_form` (last form, recent
servers and links, the profile picture and colour; saved even when a join servers and links, the profile picture and colour; saved even when a join
@@ -1429,83 +1458,133 @@ Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client):
video" opens the library on the series of the episode playing, its video" opens the library on the series of the episode playing, its
season selected and the episode highlighted. season selected and the episode highlighted.
Session controls (the Standard player design): media_kit's Player controls (`lib/src/controls/`): one controls model, two skins.
`MaterialDesktopVideoControls` with our bars (`shell/session_player.dart`, pieces in - **Model** (`model/`): `PlayerControlsModel` (built by `SessionPlayer`,
`screens/session/player_controls.dart`); its own double-click and keys are wired to the coordinator once) holds what the controls show, as
off (its double-click pushes a fullscreen route with a second `Video`), the signals (`play`: `PlayState` playing / waiting (you are ready, the
picture's double-click is a raw pointer observer (`DoubleClickListener`). others not) / paused / finished, from `state.transport` and your ready
The top bar holds the now-playing title, Up next, Change video, chat and back-to-library flag; position, duration, buffered, chapters, current chapter, tracks,
(fullscreen: exit, then the window buttons); the bottom bar ends with the volume, skip offer, render mode; the shell's fullscreen, chat open and
shader button, ⚙ and fullscreen. Its seek bar is off; ours unread count) and what they do (`togglePlay` = ready, as Space;
(`ChapterSeekBar`: chapter diamonds, hover time with the chapter title) `seek` through `player.seek`, the user path; `nudge`, `skipForward`,
sits in the bottom bar, which is taller by the bar's height (the top bar `skip`, `stepChapter`, `pickChapter`; `next` = Up next's first item,
keeps its row at the top). A drag sends no hover events, so the bar keeps else the next episode, `previous`; volume set / step (flashes) / mute;
the controls up through a guarded dynamic `onHover()` on media_kit's `pickTrack`, `pickShader`; time display; fullscreen, chat, change
private controls state (`keepVideoControlsAwake`). Pills (Skip OP / ED / video, collapse, leave, reload, mark everyone ready, link). The
1:30, Next episode) and cards live outside the auto-hiding controls, in player keys call the same actions. `ControlsSnapshot` (computed) is
the action area ("Player overlay"). The what the layout depends on.
cards stay up; a pill (`PillGate`) shows only while the controls are up - **Layout spec** (`model/controls_layout.dart`, pure): `ControlsLayout`
(`ControlsHoverProbe` for this design, `UoscControls.shown` for uosc) or is the bar's order, left cluster play / pause, previous / next episode,
the mouse is on it (which holds the controls up too), and Skip OP / Skip 1:30 also for 5 s when an offer of previous / next chapter, volume, time; right cluster subtitles, audio,
a new kind appears (Skip ED and Next episode never without the controls). ⚙, chat, fullscreen. `barControls` drops what does not apply (no
Volume (`controls/volume.dart`, both episode, no chapters) or what the user hid (`OptionalControl`:
designs): mpv's `volume-max` is pinned to `PlaybackController.maxVolume` chapter buttons, subtitles, audio, chat); the rest keep their order, so
(150) per player; `VolumeBar` runs 0..150 with a tick at 100 and the part play is always first and fullscreen last. `menuActions` is the ⚙ menu
above in amber; the wheel and ↑ / ↓ step 5 % (`volumeStep`), clamped. (`ControlAction`, fixed order): chapters, the hidden track lists,
The wheel works anywhere on the full player (`VolumeWheel` around the Skip 1:30, upscaling shader, the hidden chat, change video, copy link
player's stack): `WheelSteps` makes a mouse notch one step and adds up (when `currentLink` has one), reload, mark everyone ready (not solo),
small smooth-scroll deltas, ignores mostly-horizontal scrolls, and claims mark a problem, player settings, leave. Icons, tooltips (with keys) and
the event through `pointerSignalResolver`, so anything under the mouse the clock text (`clockText`) are defined there too, once.
that scrolls (lists, menus, popovers in their own routes) or the volume - **Shared pieces**: `ControlsFrame` (`frame.dart`): when the controls
buttons themselves take it first. A wheel or key step flashes the are up (mouse movement shows them, `controlsHideSecs` without any or the
design's readout for 1 s (`VolumeFlash`): uosc's vertical volume slider mouse leaving hides them and the cursor), holds (`hold` during a seek
at the right edge (`controls/uosc/volume_flash.dart`, after uosc's drag, `holdDuring` / `holdWhile` around menus and popovers, `holdBy`
`Volume.lua`), else a pill under the top bar (`VolumeReadout`). the session's `ControlsHold`), and the double-click on the picture
(`DoubleClickListener`, not on the bars) that toggles fullscreen; it
The uosc player design (`controls/uosc/`, after mpv's uosc theme; reports `shown` for the overlay. `control_menus.dart`: the track and
"Player design" = `AppSettings.mpvControls`, default Standard): the `Video`'s chapter lists and the ⚙ menu on `showAppMenu` (anchored on the button,
controls builder wraps both sets in `SessionControlsSwitch`, which follows holding the controls), and `runControlAction`. `volume.dart`:
the setting live. `UoscControls` is our `VolumeControl` (the skin's speaker button, a `VolumeBar` sliding out
own frame (no media_kit controls state): mouse movement shows it, 3 s on hover, the wheel on it stepping), `VolumeReadout`. `SessionControls`
without any hides it and the cursor; `hold` keeps it up during a timeline (`session_controls.dart`) puts the skin the settings pick into one
drag and while a menu route is open, double-click toggles fullscreen. frame, live, and gives the overlay the bars' heights.
`UoscTimeline` ports uosc's `Timeline.lua` (style `line`, uosc's defaults - **Standard skin** (`standard/`): Material-style. A top gradient with the
at scale 1, `UoscTimelineSpec`) across the full width at the bottom: a top bar (72 px); at the bottom a gradient with `ChapterSeekBar` (played
40 px band (plus a 1 px top border) of black at 0.9; the position is a and buffered track in the theme's primary, a diamond at each chapter
2 px white line, no filled part, and times map around it start, hover tip with the time and the chapter title, a press holds the
(`UoscTimelineGeometry`, uosc's `t2x` / `get_time_at_x`); the uncached frame and every drag move seeks) over a 56 px row of round white icon
part (from `buffered`, or the position when that is behind, to the end) buttons in the layout's order (badges for track counts and unread
carries uosc's stripe texture (white then black "/" stripes, 0.2 / 0.4 chat, the clock as a text button). Hidden, a 2 px progress line.
strong); chapter diamonds sit on the top edge (white 0.8, 1 px black - **Minimal skin** (`minimal/`, inspired by uosc, github.com/tomasklaen/uosc,
border, doubled and clickable to jump while the mouse is within twice LGPL-2.1; the drawing and values are our own): the buttons sit in two
their radius); elapsed time left and the time remaining ("-01:47:30") glass capsules (`MinimalCapsule`, the pills' tint and hairline;
right in 20 px outlined text, inverted where it crosses the line; while `MinimalButton` rounded squares, the play button a filled accent
less than 60 s is buffered ahead (and the cache does not reach the end) circle) above `MinimalTimeline`, a 36 px band across the bottom: the
a small "12s" rides right of the line. Hover: a faint 1 px line and a played part tinted in the theme's primary, the position a white line,
black tooltip with the time (the chapter title, bold, above it). A press the part not downloaded yet (from `buffered`, or the position when
seeks, every drag move seeks, and after release the line stays at the that is behind, to the end) in thin stripes, chapter starts as dots on
target until the position catches up (at most 2 s). Hidden, it animates the top edge with a dark cut through the band (a press within reach of
down (100 ms) to uosc's progress line: 2 px with a 20 px position a dot jumps to it), elapsed time left and the total or the time left
marker, chapters as small diamonds (faded out when the progress line right (the bar's clock lives here; a press on it switches), "12s" by
setting is off). We know only the end of mpv's cache, so a dropped back the position while under a minute is buffered ahead (and the cache does
buffer is not hatched; uosc's pause while dragging, keyframe seeks for not reach the end). Hover: a faint line and a glass tip with the time
fast drags, thumbnails and the fullscreen scale (1.3) are left out. Above it a row of flat buttons: menu (≡: (the chapter title above it). A press seeks, every drag move seeks,
change video, chat & activity, reload, mark everyone ready, fullscreen, after release the line stays at the target until the position catches
leave), up (at most 2 s). Hidden, it shrinks (120 ms) to a 3 px progress line.
subtitles and audio with track counts, a shader badge ("A HQ", opening the We know only the end of mpv's cache, so a dropped back buffer is not
shared `showShaderMenu`), ready, mark everyone ready, reload; 1:30, shown.
chapters, previous / next episode, change video, volume, ⚙, fullscreen; the - **Settings** (`AppSettings`, `model/controls_prefs.dart`, all live):
now-playing title top left (`UoscControls.topLeft`, with a top gradient), Up next, chat `controlsSkin` (`ControlsSkin` standard / minimal; a stored
and back-to-library (fullscreen: exit) top right, centred on the title. Menus (`showUoscMenu`: a popup route above the button, the `mpvControls: true` from before becomes minimal), `timeDisplay`,
current row highlighted) list tracks and chapters with start times. All `controlsSize` (normal / large: the bottom bar ×1.25),
actions are the coordinator's (seeks through `player.seek`, the user path; `controlsHideSecs` (2 / 3 / 5), `hiddenControls`, `progressLine`.
`selectTrack`, `skipForward`, `seekTo`); keys and the floating pills are The player settings card has the skin and the progress line; Settings →
the same in both sets. Player → Controls has the rest.
- Pills (Skip OP / ED / 1:30, Next episode) and cards live outside the
controls, in the action area ("Player overlay"). The cards stay up; a
pill (`PillGate`) shows only while the controls are up (the frame's
`shown`) or the mouse is on it (which holds the controls up too), and
Skip OP / Skip 1:30 also for 5 s when an offer of a new kind appears
(Skip ED and Next episode never without the controls).
- Volume: mpv's `volume-max` is pinned to `PlaybackController.maxVolume`
(150) per player; `VolumeBar` runs 0..150 with a tick at 100 and the
part above in amber; the wheel and ↑ / ↓ step 5 % (`volumeStep`),
clamped. The wheel works anywhere on the full player (`VolumeWheel`
around the player's stack): `WheelSteps` makes a mouse notch one step
and adds up small smooth-scroll deltas, ignores mostly-horizontal
scrolls, and claims the event through `pointerSignalResolver`, so
anything under the mouse that scrolls (lists, menus, popovers in their
own routes) or the volume control itself take it first. A wheel or key
step flashes `VolumeReadout` (a glass pill under the top bar) for 1 s
(`VolumeFlash`).
- The `Video` gets the controls as its controls builder (with
`StartBanner` under them); media_kit's own controls are not used, so
its keys, double-click route and seek bar are out of the picture.
Window chrome is frameless (`window_manager`): the party strip is the drag Window chrome is frameless (`window_manager`): the party strip is the drag
area and holds the min/max/close buttons (`widgets/window_buttons.dart`); area and holds the min/max/close buttons (`widgets/window_buttons.dart`);
fullscreen has no drag area and shows the buttons in the player's top bar. fullscreen has no drag area and shows the buttons in the player's top bar.
App close (`platform/app_shutdown.dart`, `shutDownApp`; the close button,
Alt+F4, and Update and restart): the window position is saved and the
window hides at once. Then every mpv player goes, before the engine does
(a live one calls its wakeup callback into a dead isolate): the session
player, the preload and the player still retiring
(`PlaybackController.disposeAll`, 1.5 s each, via
`SessionCoordinator.releasePlayers`, which keeps the position for the
Jellyfin stop report), the Files thumbnail jobs (`cancelAllThumbnails`)
and the sound engine (500 ms), side by side; on Windows 300 ms more,
since media_kit_video frees a video output on a thread of its own after
dispose returns, and an output still up when the engine stops blocks the
plugin's teardown (`~VideoOutput` waits on a texture callback that never
comes). Then `SessionState.leave`
(Jellyfin stop report, last diagnostics, the goodbye, so others see
"left") and the hosted server's stop, 3 s in all; an update restart then
closes Hive and starts the new binary. Last the window closes the normal
way (`setPreventClose(false)` + `close`), so the engine and plugins are
torn down inside the platform's main loop, and the runners end the
process without `exit()`'s handlers: Linux `_exit` after
`g_application_run` (NVIDIA's EGL exit handler segfaults inside `exit()`),
Windows `TerminateProcess` after the message loop (no DLL detach
handlers, which can hang on threads already gone). Every step is bounded
and logs (`close: ...`) when it is slow or fails. Backstops, so no process
stays behind a hidden window: 3 s after asking the window to close the
app kills itself (`Process.killPid`, no exit handlers; it only gets there
if the close did not go through); the runners end the process 15 s after
the first close request (Linux: a `delete-event` handler connected before
window_manager's; Windows: the first `WM_CLOSE`), and Windows also 3 s
after the window is destroyed, while the engine and plugins go.
## Logging ## Logging
`packages/core/lib/src/logging.dart`, one setup for the app and the `packages/core/lib/src/logging.dart`, one setup for the app and the
@@ -1662,10 +1741,20 @@ defines for every build and run) with "dev run". CLI: `watch-together-server --u
the shipped `update.sh` / `update.bat` run it), `--check-update` prints only; the shipped `update.sh` / `update.bat` run it), `--check-update` prints only;
it ends with "restart the server to run <version>". `watch_together --upgrade` it ends with "restart the server to run <version>". `watch_together --upgrade`
does the same from the app binary. In the app, `UpdateController` does the same from the app binary. In the app, `UpdateController`
(`apps/desktop/lib/src/update/`) runs the same code for the strip's update (`apps/desktop/lib/src/update/`) runs the same code for the update bar
indicator, which opens the update card, and restarts the app ("Update and (`UpdateBar`, `screens/home/update_indicator.dart`): a band across the
restart", closing Hive first). window under the strip while an update is available: "Starsync X.Y.Z is
Settings has Check now and an auto-check toggle. The manifest is `updates/manifest.json` in ready", the notes, Later (hidden until the next check or start) and
"Update and restart" (download, apply, then the app close above with Hive
closed and the new binary started), its progress in the band. A release
that is behind also says which version it runs; a copy that is not a
packaged install says to replace the folder by hand. The shell keeps the
band off the full player while a video is on (not finished); it shows on
the tabs, the mini player's views and once the video ends. A required
update has no Later. Nothing downloads before the click. The app checks at
start and every 4 hours while running (`updateCheckEvery`), both with the
auto-check setting on; a re-check never touches an install under way.
Settings has Check now and the auto-check toggle. The manifest is `updates/manifest.json` in
this repo, written by `tool/release.sh`. Test builds (`X.Y.Z-dev.N`, this repo, written by `tool/release.sh`. Test builds (`X.Y.Z-dev.N`,
`isDevBuild`) also read `devManifestUrl`, the `manifest.json` of the `dev` `isDevBuild`) also read `devManifestUrl`, the `manifest.json` of the `dev`
prerelease (`tool/dev_release.sh`), and take the newer of the two prerelease (`tool/dev_release.sh`), and take the newer of the two
+22 -2
View File
@@ -6,7 +6,11 @@ Layout and rules: [ARCHITECTURE.md](ARCHITECTURE.md).
Needs the Flutter SDK (beta channel, Dart 3.13+) and, for the Linux desktop Needs the Flutter SDK (beta channel, Dart 3.13+) and, for the Linux desktop
build, the usual GTK toolchain (clang, cmake, ninja, pkg-config, libgtk-3-dev). build, the usual GTK toolchain (clang, cmake, ninja, pkg-config, libgtk-3-dev).
mpv libraries come from `media_kit_libs_video`. `fox_utils` (drag & drop, mpv libraries come from `media_kit_libs_video`. `flutter_soloud` (the app's
sounds) compiles SoLoud and miniaudio from source in its build hook with
that C++ toolchain (MSVC on Windows); it needs no audio dev headers (miniaudio
loads ALSA / PulseAudio / JACK at run time) and, with `no_xiph_libs` set in
the desktop pubspec, downloads nothing. `fox_utils` (drag & drop,
clipboard files and images; native Linux/Windows plugin) is the owner's clipboard files and images; native Linux/Windows plugin) is the owner's
shared package, a git dependency pinned to a tag of shared package, a git dependency pinned to a tag of
`git.senkochan.moe/Foxyz/fox_utils`: bump it by tagging there and changing `git.senkochan.moe/Foxyz/fox_utils`: bump it by tagging there and changing
@@ -67,6 +71,9 @@ and `e2e_check` (simulated viewers against a live server).
media_kit "S/W rendering" because they have no display; that says nothing media_kit "S/W rendering" because they have no display; that says nothing
about the app. Run only tests related to about the app. Run only tests related to
what you changed. what you changed.
- `sound_device_e2e` opens the real audio device (flutter_soloud) and plays
every sound of every preset quietly (`WT_SFX_VOLUME`, default 0.1);
`sound_engine_test` covers the engine with a fake backend.
- CI (`.gitea/workflows/ci.yml`): analyze + test for core and server, plus - CI (`.gitea/workflows/ci.yml`): analyze + test for core and server, plus
`dart compile exe` of the dedicated server (fails if Flutter creeps into `dart compile exe` of the dedicated server (fails if Flutter creeps into
`packages/server`). Desktop tests are not in CI. `packages/server`). Desktop tests are not in CI.
@@ -169,6 +176,19 @@ headers), `mpv:` (`cannot open the media` and ffmpeg's lines), `yt-dlp:`
the headers sent). A viewer that got no bytes while the source was the headers sent). A viewer that got no bytes while the source was
slow to come up shows mpv's error with "no HTTP error logged". slow to come up shows mpv's error with "no HTTP error logged".
For "the video is slow / shaders do nothing", grep `mpv: render` (what
each line holds: "Render mode" in [ARCHITECTURE.md](ARCHITECTURE.md)).
`render: gpu` with a real `GL_RENDERER` (not `llvmpipe` / `softpipe`) is
the good case; `render: softwareGl` is OpenGL on the CPU (Mesa's software
rasterizer: missing or blocked GPU drivers, `LIBGL_ALWAYS_SOFTWARE=1`);
`render: software` with no GL lines is media_kit's own fallback (it found
no usable EGL context: check `render env:` for an X11 session or a
`GDK_BACKEND` / `FLUTTER_LINUX_RENDERER` override). Its reason is only on
stderr: have the user start the app from a terminal
(`watch-together 2>&1 | grep VideoOutput`). `hwdec-current=no` on the
first-frame line means software decoding (no hwdec for that codec, or it
is off), not software rendering.
## Gotchas ## Gotchas
- Relic gives Range as a typed `RangeHeader`; convert from unit + first range. - Relic gives Range as a typed `RangeHeader`; convert from unit + first range.
@@ -184,7 +204,7 @@ slow to come up shows mpv's error with "no HTTP error logged".
- media_kit has no public `Player.setProperty`; the code reaches mpv through a - media_kit has no public `Player.setProperty`; the code reaches mpv through a
dynamic call on `player.platform`. Cache size goes through dynamic call on `player.platform`. Cache size goes through
`PlayerConfiguration(bufferSize:)`. `PlayerConfiguration(bufferSize:)`.
- Hot restart with any media_kit `Player` alive (session, thumbnails, sfx) - Hot restart with any media_kit `Player` alive (session, thumbnails)
used to SIGSEGV on mpv's `core` thread: each mpv handle's wakeup callback used to SIGSEGV on mpv's `core` thread: each mpv handle's wakeup callback
is a `NativeCallable` of the old isolate, freed by the restart, and media_kit is a `NativeCallable` of the old isolate, freed by the restart, and media_kit
then sends the leftover handles `quit`, which fires it. `fox_utils`' Linux then sends the leftover handles `quit`, which fires it. `fox_utils`' Linux
+126
View File
@@ -4,6 +4,132 @@ Backlog and epics, in order. Done work lives in git history. Check an item
off by deleting it in the same commit as the code. Wire changes also edit off by deleting it in the same commit as the code. Wire changes also edit
[PROTOCOL.md](PROTOCOL.md). [PROTOCOL.md](PROTOCOL.md).
## Stabilisation (feature freeze)
No new systems or features until the app is stable on a proper
architecture. The next release is the restructure (1.3.0, or 2.0.0).
Problems are numbered; delete a line when its fix ships.
**Decisions** (agreed):
- D1 Presence: In (watching, full), Away (sat out: still in the server,
chat and chosen notifications, system notifications in the background,
not counted for the gate, ready counter or watched tracking), Gone
(disconnected, held in a grace period).
- D2 A joiner during playback holds it until everyone watching is ready,
like the start gate.
- D3 Watched: at least as good as Jellyfin's own; a peek, skipping around
or a screenshot visit never counts. Basis: time present while it played.
- D4 Toasts above the control bar; toasts for joins/leaves, queue adds
(title, picture, who), swaps, problems; the rest in the activity log.
Chat pop-ups and their sound always on by default; only the unread badge
stays off the picture.
- D5 A reconnect resumes the same member within a grace period (~10 min).
- D6 The person passing a video on drops: others keep playing; on
reconnect the relay continues from where it was.
- D7 One controls model, two skins (done in 1.2.1-dev.1); uosc credited.
- D8 Always the latest version: automatic checks, a clear prompt (no
background download), never during playback.
- D9 Watching alone is a full standalone player: no social sounds or
prompts; social parts appear when others are there; no solo mode.
- D11 Login-only sites: error texts say which cookies were used.
**Problems:**
- P1 A reconnect makes you a new member: the relay, ready, sitting out and
your queue items are lost (log: Hoff m23 → m24 had to put ep 06 up again).
- P2 Joining a relay whose source left serves broken leftovers; mpv loops
on failed seeks; nobody is told the source is gone.
- P3 A joiner during playback lands mid-scene (see D2).
- P4 Reconnects time out repeatedly on a bad link; no clear
"reconnecting" state.
- P6 Sat-out people count in the ready counter.
- P7 Sat-out people get tracked as watched on Jellyfin.
- P8 Sat-out people get the normal notifications.
- P9 The watched rule (position coverage) misses real watches (D3).
- P11 Toasts cover the video controls.
- P12 Queue additions don't show as toasts.
- P15 Commands reach a player mid-switch (`seek 90.0`, `event:5` errors).
- P17 Preload, warm relay and the fresh player per video are layered on
the player instead of being one media pipeline.
- P18 Bilibili "no video formats" with browser cookies set; the hint
wording misleads (D11).
- P22 Friends on different versions (D8). (P20 version visible and P21
update band: fixed in 1.2.1-dev.3; check the band in real use.)
- P23 Solo mode you enter and leave; going hosted loses the queue (D9).
- P24 The invite address list is unhelpful (shows the proxy's 198.18.x).
- P25 ssh to Gitea blocked through the proxy (pushes go over https).
- P27 Failing tests: shell "Ctrl+V outside a field", relay e2e "a guest
puts up a file", "links: through me".
- P28 Log noise: "Failed to create file cache"; an idle relay channel for
Jellyfin items everyone has an account for.
- P29 Relay start delay after a seek into an unbuffered part.
- P30 Shaders don't work on Windows. Fix in 1.2.1-dev.3 (list read-back
check; Windows hwdec `auto-copy`): a Windows user must confirm shaders
stay on and the EGL-surface error is gone.
- P31 Software rendering on a friend's Linux. 1.2.1-dev.3 logs it
(`mpv: render` lines); needs that friend's log, plus
`watch-together 2>&1 | grep VideoOutput` from a terminal if software.
- P32 The app freezes on exit, mostly reported on Windows. 1.2.1-dev.3:
orderly close, Linux `_exit` (0 crashes in 41 closes vs 10/10 before),
Windows normal close + TerminateProcess + watchdogs, untested on
Windows; if it still hangs: Event Viewer 1000/1002, a Task Manager dump,
the `close:` log lines.
- P33 No volume readout on wheel: likely fixed by the controls rework
(1.2.1-dev.1; a test covers both skins); confirm in real use.
- P34 Local anime folders are opened as plain files (no banner, names,
next episode, tracking); test library: the user's
`/mnt/torrents/Torrents/Binux/` (several shows and a movie).
**Restructure** (in order; reviewed against the code). Truth lives in
three places: the server (members, presence, gate, transport, queue incl.
the automatic continuation and countdown, relay state), the device
(client id, library and folders, match corrections, watch history,
settings, notification prefs, what this app is sourcing) and the
integrations (Jellyfin played/resume, AniList list).
0. Safety net and seams, no visible change: fix P27; an `AppEvent` type;
platform ports wrapping today's calls (window/shell, open URL,
notifications, yt-dlp process, folder access, player host);
Connection extracted from the coordinator; the proto 16 spec written.
1. Connection + Identity + Presence, proto 16 in full (one bump, with a
min/max version range and a stable refusal frame so later changes are
additive): a lasting client id; a socket close no longer removes the
member (explicit `bye`); presence joining/in/away/gone with grace;
`PresencePolicy` in core used by server and app alike; bounded D2
hold; relay `sourceGone` and source resume; queue `by` lasting;
`members[].app`. Fixes P1 P3 P4 P6, server half of P2; D1 D2 D5 D6.
2. Status (one computed level) + Notifier (pure policy over events:
toasts above the controls, SoundEngine, system notifications, activity
log). Fixes P8 P11 P12 P21 P33, visible half of P2; D4, D1, D9 alone
policy ("alone" = no other connected member, In or Away).
3. Your server: one hub, listeners attached (loopback; LAN on Invite),
nothing restarts; Invite as a value (link/deep link, filtered
addresses, version gate). Fixes P22 P23 P24; D9.
4. Library + Media identity: L1 local grouping (pure Dart, offline: kinds
episode/movie/extra, folder hints, gaps, merge across folders) and L2
match (AniList/TVDB ids, art, names, manual pin, cache); one `MediaRef`
carried in swap/queue media. Fixes P34 and Files' next episode.
5. What's next (decides which/when, never opens; continuation and
countdown as server state, shared cancel) + Watch tracking (time In,
device history, Jellyfin now, AniList later). Fixes P7 P9; D3.
6. Media pipeline + player host port: plan → resolve → open → ready,
commands queued during a switch, source-drop recovery, resolver and
source chosen by capability. Fixes P2 (client) P15 P17 P18 P28 P29;
P30 P31 P32 as a player-host sub-step; D6 client half, D11.
Not in the restructure: P25 (ops).
**Open decisions** (raised by the review):
- Queue survival: does your own server keep the queue across an app
restart (conflicts with the "No server storage" decision)?
- Transport for Android later: plain `ws://` needs a cleartext exception
on Android 9+; `wss://` behind a proxy changes the Invite format.
- Auto-Away: suggest Away after a long minimise (client intent), or only
by hand?
- Hold and grace lengths: D2 hold bound (e.g. 15 s), grace ~10 min; a
source in grace keeps its relay buffer.
- Platform decision: "Desktop + mpv only" becomes "mpv only, desktop
first" if Android goes ahead.
- D10 was dropped on purpose (preload/warm relay/fresh player are folded
into step 6, not a separate decision).
## Backlog ## Backlog
1. **Auto-skip OP/ED** (undecided): who decides and how (shared for the 1. **Auto-skip OP/ED** (undecided): who decides and how (shared for the