From fd8064611a1b2d6bfa76683680f54d498d5358f8 Mon Sep 17 00:00:00 2001 From: foxyz Date: Thu, 15 Oct 2026 17:03:15 +0300 Subject: [PATCH] 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 --- README.md | 84 ++- .../lib/src/controls/control_menus.dart | 136 ++++ .../lib/src/controls/controls_probe.dart | 109 +-- apps/desktop/lib/src/controls/frame.dart | 216 ++++++ .../controls/minimal/minimal_controls.dart | 250 +++++++ .../lib/src/controls/minimal/style.dart | 186 +++++ .../lib/src/controls/minimal/timeline.dart | 578 ++++++++++++++++ .../src/controls/model/controls_layout.dart | 235 +++++++ .../src/controls/model/controls_model.dart | 214 ++++++ .../src/controls/model/controls_prefs.dart | 64 ++ .../lib/src/controls/player_settings.dart | 179 +++-- .../lib/src/controls/session_controls.dart | 83 +++ .../lib/src/controls/standard/seek_bar.dart | 229 +++++++ .../controls/standard/standard_controls.dart | 340 +++++++++ .../lib/src/controls/uosc/buttons.dart | 359 ---------- .../lib/src/controls/uosc/controls.dart | 293 -------- apps/desktop/lib/src/controls/uosc/menu.dart | 249 ------- apps/desktop/lib/src/controls/uosc/style.dart | 77 --- .../lib/src/controls/uosc/timeline.dart | 645 ------------------ .../lib/src/controls/uosc/volume_flash.dart | 128 ---- apps/desktop/lib/src/controls/volume.dart | 139 ++-- .../lib/src/playback/playback_controller.dart | 7 +- apps/desktop/lib/src/queue/queue_ui.dart | 38 +- .../lib/src/screens/home/about_page.dart | 6 + .../src/screens/session/jellyfin_widgets.dart | 29 - .../src/screens/session/player_controls.dart | 525 +------------- apps/desktop/lib/src/shaders/shader_menu.dart | 47 +- apps/desktop/lib/src/shell/app_shell.dart | 1 + .../desktop/lib/src/shell/session_player.dart | 608 ++--------------- apps/desktop/lib/src/shell/settings_tab.dart | 6 +- apps/desktop/lib/src/state/settings.dart | 64 +- apps/desktop/lib/src/widgets/app_menu.dart | 7 +- apps/desktop/test/controls_model_test.dart | 218 ++++++ apps/desktop/test/jellyfin_session_test.dart | 12 - apps/desktop/test/minimal_controls_test.dart | 430 ++++++++++++ apps/desktop/test/player_controls_test.dart | 69 +- apps/desktop/test/player_settings_test.dart | 123 ++-- apps/desktop/test/player_status_test.dart | 38 +- apps/desktop/test/uosc_controls_test.dart | 547 --------------- docs/ARCHITECTURE.md | 227 +++--- 40 files changed, 3820 insertions(+), 3975 deletions(-) create mode 100644 apps/desktop/lib/src/controls/control_menus.dart create mode 100644 apps/desktop/lib/src/controls/frame.dart create mode 100644 apps/desktop/lib/src/controls/minimal/minimal_controls.dart create mode 100644 apps/desktop/lib/src/controls/minimal/style.dart create mode 100644 apps/desktop/lib/src/controls/minimal/timeline.dart create mode 100644 apps/desktop/lib/src/controls/model/controls_layout.dart create mode 100644 apps/desktop/lib/src/controls/model/controls_model.dart create mode 100644 apps/desktop/lib/src/controls/model/controls_prefs.dart create mode 100644 apps/desktop/lib/src/controls/session_controls.dart create mode 100644 apps/desktop/lib/src/controls/standard/seek_bar.dart create mode 100644 apps/desktop/lib/src/controls/standard/standard_controls.dart delete mode 100644 apps/desktop/lib/src/controls/uosc/buttons.dart delete mode 100644 apps/desktop/lib/src/controls/uosc/controls.dart delete mode 100644 apps/desktop/lib/src/controls/uosc/menu.dart delete mode 100644 apps/desktop/lib/src/controls/uosc/style.dart delete mode 100644 apps/desktop/lib/src/controls/uosc/timeline.dart delete mode 100644 apps/desktop/lib/src/controls/uosc/volume_flash.dart create mode 100644 apps/desktop/test/controls_model_test.dart create mode 100644 apps/desktop/test/minimal_controls_test.dart delete mode 100644 apps/desktop/test/uosc_controls_test.dart diff --git a/README.md b/README.md index 373444c..ebd9349 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ everyone has its start before it goes up; whoever added it keeps the app open for that. Everyone shares one Up next and anyone can change it; what you added stays when you leave. -In the player, Up next sits in the top bar next to the chat button: the +In the player, Up next sits in the top bar, right of the title: the next item's picture and title (or the next episode's). Rest the mouse on it for a larger preview (who added it, its length, whether it is ready to start); ⏭ plays it now, a click opens the queue: drag rows to reorder, hover a @@ -282,16 +282,21 @@ integrations (linked Jellyfin servers, below, the AnimeFlow connection: "connected as " with what you may do, or why not, and playing the next episode automatically); Notifications & sounds (what pops up; the sound volume and sound set, and every sound with a preview, its own set and a switch); Updates; Keyboard shortcuts (read-only); About -(the version; resting the mouse on the Settings tab shows it too). +(the version; resting the mouse on the Settings tab shows it too; and the +credits, e.g. that the Minimal player design is inspired by uosc). -Player settings live in the player: ⚙ at the right end of its controls -(both designs) opens them, and Settings → Player has the same ones (plus -the custom shader files). Player design: Standard or uosc (below); -progress line when hidden (on by default: a 2 px line at the bottom of -the video while the controls are hidden); upscaling shader (see below); -hardware decoding (auto, off or custom; the env var `SYNC_RELAY_HWDEC` -overrides it); in the player also the subtitle and audio track. Every -change applies right away. +Player settings live in the player too: ⚙ → Player settings, at the +right of its controls, and Settings → Player has the same ones (plus the +controls' layout and the custom shader files). Player design: Standard or +Minimal (below); progress line when hidden (on by default: a thin line at +the bottom of the video while the controls are hidden); upscaling shader +(see below); hardware decoding (auto, off or custom; the env var +`SYNC_RELAY_HWDEC` overrides it); in the player also the subtitle and +audio track. Settings → Player → Controls: time display (elapsed / total +or remaining), controls size (normal or large), hide the controls after +2, 3 or 5 s, and which optional buttons sit in the bar (chapter buttons, +subtitles, audio, chat; hidden ones are still in ⚙). Every change +applies right away. Anywhere in a server (unless you are typing): Space = ready / unready, Tab = chat & activity (Tab never moves between buttons). Anywhere: @@ -304,28 +309,37 @@ mini it asks to leave, Ctrl+R = reload video, Ctrl+1..6 = Anime4K mode A, B, C, A+A, B+B, C+A, Ctrl+0 = shaders off. Every seek, skip and chapter jump moves everyone. -Controls: the seek bar marks chapter starts with a diamond; hovering it -shows the time and the chapter title. The chapters button lists them -(current one checked; click to jump). "1:30" skips ahead 1:30. Audio and -Subtitles buttons (with the track count) list tracks as "Japanese · E-AC3 -2.0 · title"; subtitles have Off. Volume (scroll anywhere on the video, -hover the speaker, or Up/Down) goes to 150 % like mpv: past the tick at -100 % the bar turns amber and mpv amplifies; a scroll or key shows the -volume for a moment. Lists and menus under the mouse scroll as usual. +Controls: both designs have the same buttons in the same places, only +the look differs. Bottom left: play / pause first (it is your ready, like +Space: ✓ while you are ready and the others are not), previous / next +episode (when there is one; next plays Up next first), previous / next +chapter (when the video has chapters), volume and the time (click it to +switch between elapsed / total and the time left). Bottom right: +Subtitles and Audio (with the track count; tracks listed as "Japanese · +E-AC3 2.0 · title", subtitles with Off), ⚙, chat and fullscreen last. +Everything else is in ⚙: chapters (current one checked; click to jump), +Skip 1:30, upscaling shader, change video, copy link, reload video, +mark everyone ready, mark a problem, player settings and leave. The top +bar has the title, Up next and back to the library (in fullscreen: exit +fullscreen and the window buttons). Volume (scroll anywhere on the +video, hover the speaker, or Up/Down) goes to 150 % like mpv: past the +tick at 100 % the bar turns amber and mpv amplifies; a scroll or key +shows the volume for a moment. Lists and menus under the mouse scroll +as usual. -The uosc player design (⚙ → Player design, Standard by default, -switches right away): video controls modeled on mpv's uosc theme. A -translucent timeline across the bottom marks the position with a thin -line, hatches the part not downloaded yet, shows chapter starts as -diamonds (click one to jump), the elapsed time left, the time remaining -right and, while less than a minute is buffered, the buffered seconds -next to the position; hovering it shows the time there and the chapter -title. With the controls hidden it stays as a thin line (unless -"Progress line when hidden" is off). Above it flat buttons: ≡ menu, Subtitles and Audio (with the -track count), the shader badge (the active Anime4K mode, e.g. "A HQ"), -ready, chapters, 1:30, previous / next episode, change video, volume, ⚙ -and fullscreen; the title top left, Up next, chat and back-to-library top right. Keys and skip buttons work -the same. +Standard (the default): a seek bar that marks chapter starts with a +diamond (hovering it shows the time and the chapter title) over a row +of round buttons. Minimal (⚙ → Player settings → Player design, switches +right away): a translucent timeline across the bottom tints what has +played, stripes the part not downloaded yet, marks chapter starts with +dots (click one to jump), shows the elapsed time left and the total or +the time left right (click it to switch) and, while less than a minute +is buffered, the buffered seconds next to the position; hovering it shows +the time there and the chapter title. With the controls hidden it stays +as a thin line (unless "Progress line when hidden" is off). Its buttons +sit in two glass capsules above it. Minimal's timeline and controls are +inspired by [uosc](https://github.com/tomasklaen/uosc) by tomasklaen +(LGPL-2.1). Skipping: during the opening a "Skip OP" button shows bottom right, just above the controls (it @@ -395,7 +409,7 @@ Watching: "No Jellyfin account here for ..." with a button to link one; it plays as soon as you are signed in. -Episodes: the ⏮ / ⏭ buttons next to the controls (or `<` / `>`, +Episodes: the ⏮ / ⏭ buttons next to play (or `<` / `>`, Shift+P / Shift+N) put up the previous or next episode for everyone. From the ending (when it is known, see Skipping above; else in the last two minutes) a "Next episode" button floats in the corner while the controls @@ -411,7 +425,7 @@ Your choice is remembered per series (and as a default) and the closest match (language, then title, then codec and channels) is selected on the next episode. This is per person; nobody else is affected. -"Change video" in the player's controls opens the Library tab (the player +⚙ → Change video in the player opens the Library tab (the player goes mini): the library starts on the series you are watching with the current season, else where you left it. @@ -467,9 +481,9 @@ affected. The app ships [Anime4K](https://github.com/bloc97/Anime4K) v4.0.1 C (480p), A+A, B+B, C+A, each as HQ (strong GPUs, GTX 1080 and up) or Fast (the guide's low-end set). "Custom shaders" runs your own `.glsl` files. -Pick one with the ✨ button in the player's control bar (also in +Pick one with ⚙ → Upscaling shader in the player (also in fullscreen) or Ctrl+0..6; the change applies live and is saved, so the next -session starts with it. The player's ⚙ and Settings → Player set it too, +session starts with it. ⚙ → Player settings and Settings → Player set it too, with HQ or Fast (used by Ctrl+1..6); Settings → Player holds the custom files. Shaders cost GPU time every frame and need hardware video rendering: on media_kit's software diff --git a/apps/desktop/lib/src/controls/control_menus.dart b/apps/desktop/lib/src/controls/control_menus.dart new file mode 100644 index 0000000..bb2d2b9 --- /dev/null +++ b/apps/desktop/lib/src/controls/control_menus.dart @@ -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 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> trackMenuItems(JellyfinStreamType type, List 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> chapterMenuItems(List 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 _menu(BuildContext context, PlayerControlsModel model, List> items) async { + model.menusOpen++; + try { + return await showAppMenu(context, items: items); + } finally { + model.menusOpen--; + } +} + +/// The audio or subtitle list; a pick is applied (and remembered). +Future 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 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 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 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(); + } +} diff --git a/apps/desktop/lib/src/controls/controls_probe.dart b/apps/desktop/lib/src/controls/controls_probe.dart index e2115ab..7912d63 100644 --- a/apps/desktop/lib/src/controls/controls_probe.dart +++ b/apps/desktop/lib/src/controls/controls_probe.dart @@ -6,8 +6,7 @@ import 'package:flutter/material.dart'; /// mouse on a floating element over the picture (a pill, a card, the /// status pill: [ControlsHoldRegion]) or an open popover. While anything /// holds, the controls stay up; on release they hide on their usual -/// countdown. Both designs follow it ([ControlsHoverProbe] with -/// media_kit's controls, `UoscControls`). +/// countdown. Both skins follow it (through `ControlsFrame`). class ControlsHold extends ChangeNotifier { final _by = {}; bool _told = false; @@ -74,109 +73,3 @@ class _ControlsHoldRegionState extends State { ); } } - -/// 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 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 createState() => _ControlsHoverProbeState(); -} - -class _ControlsHoverProbeState extends State { - 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, - ); -} diff --git a/apps/desktop/lib/src/controls/frame.dart b/apps/desktop/lib/src/controls/frame.dart new file mode 100644 index 0000000..5e1681d --- /dev/null +++ b/apps/desktop/lib/src/controls/frame.dart @@ -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? 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(); + + /// Runs [open] (a menu, a popover) with the controls around [context] + /// held up, if there are any. + static Future holdWhile(BuildContext context, Future 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 createState() => ControlsFrameState(); +} + +class ControlsFrameState extends State { + 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 holdDuring(Future 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 createState() => _DoubleClickListenerState(); +} + +class _DoubleClickListenerState extends State { + 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); +} diff --git a/apps/desktop/lib/src/controls/minimal/minimal_controls.dart b/apps/desktop/lib/src/controls/minimal/minimal_controls.dart new file mode 100644 index 0000000..055e4c3 --- /dev/null +++ b/apps/desktop/lib/src/controls/minimal/minimal_controls.dart @@ -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 cluster(List 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); + } + } +} diff --git a/apps/desktop/lib/src/controls/minimal/style.dart b/apps/desktop/lib/src/controls/minimal/style.dart new file mode 100644 index 0000000..d63a3e0 --- /dev/null +++ b/apps/desktop/lib/src/controls/minimal/style.dart @@ -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 createState() => _MinimalButtonState(); +} + +class _MinimalButtonState extends State { + 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 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), + ), + ); + } +} diff --git a/apps/desktop/lib/src/controls/minimal/timeline.dart b/apps/desktop/lib/src/controls/minimal/timeline.dart new file mode 100644 index 0000000..4e654cf --- /dev/null +++ b/apps/desktop/lib/src/controls/minimal/timeline.dart @@ -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 chapters; + final bool expanded; + + /// The right-hand time: the time left instead of the total. + final bool remaining; + final double scale; + final ValueChanged 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 createState() => _MinimalTimelineState(); +} + +class _MinimalTimelineState extends State { + 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 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( + 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 []; + 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 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; +} diff --git a/apps/desktop/lib/src/controls/model/controls_layout.dart b/apps/desktop/lib/src/controls/model/controls_layout.dart new file mode 100644 index 0000000..dceaae6 --- /dev/null +++ b/apps/desktop/lib/src/controls/model/controls_layout.dart @@ -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 left, List right}) barControls(ControlsSnapshot s, Set 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 menuActions(ControlsSnapshot s, Set 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)}'; +} diff --git a/apps/desktop/lib/src/controls/model/controls_model.dart b/apps/desktop/lib/src/controls/model/controls_model.dart new file mode 100644 index 0000000..80e60f0 --- /dev/null +++ b/apps/desktop/lib/src/controls/model/controls_model.dart @@ -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 fullscreen; + final ReadonlySignal chatOpen; + final ReadonlySignal 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 get position => playback.position; + ReadonlySignal get duration => playback.duration; + + /// End of the cached range ahead of the position. + ReadonlySignal get buffered => playback.buffered; + ReadonlySignal> get chapters => playback.chapters; + ReadonlySignal get currentChapter => playback.currentChapter; + ReadonlySignal> get tracks => playback.tracks; + + /// 0..`maxVolume`. + ReadonlySignal get volume => playback.volume; + ReadonlySignal get skipOffer => coord.skipOffer; + ReadonlySignal get renderMode => playback.renderMode; + + /// Play / pause as the session sees it ([PlayState]). + late final Computed 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 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 left, List right})> bar = computed( + () => barControls(snapshot.value, settings.hiddenControls.value), + ); + + /// What the ⚙ menu lists now. + List 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(); +} diff --git a/apps/desktop/lib/src/controls/model/controls_prefs.dart b/apps/desktop/lib/src/controls/model/controls_prefs.dart new file mode 100644 index 0000000..7b8fa18 --- /dev/null +++ b/apps/desktop/lib/src/controls/model/controls_prefs.dart @@ -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; diff --git a/apps/desktop/lib/src/controls/player_settings.dart b/apps/desktop/lib/src/controls/player_settings.dart index e1ce673..2b92f0e 100644 --- a/apps/desktop/lib/src/controls/player_settings.dart +++ b/apps/desktop/lib/src/controls/player_settings.dart @@ -12,11 +12,11 @@ import '../shell/mark_problem.dart'; import '../shell/motion.dart'; import '../state/settings.dart'; import '../widgets/app_menu.dart'; -import 'uosc/buttons.dart'; -import 'uosc/controls.dart'; +import 'frame.dart'; +import 'model/controls_prefs.dart'; -// Everything about the player, defined once: the rows of the ⚙ card in -// both control designs and of Settings → Player. Each row reads and +// Everything about the player, defined once: the rows of the player +// settings card (⚙ → Player settings, both skins) and of Settings → Player. Each row reads and // writes [AppSettings] and applies to a running player right away. /// One setting: a label, one line of help under it, the control at the @@ -67,7 +67,7 @@ AppSelect _dropdown({ }) => AppSelect(key: key, value: value, items: items, onChanged: onChanged, expanded: expanded); -/// Standard (our bars over media_kit's controls) or uosc. Live. +/// The controls' skin: Standard or Minimal. Live. class PlayerDesignSetting extends StatelessWidget { const PlayerDesignSetting({super.key}); @@ -77,12 +77,100 @@ class PlayerDesignSetting extends StatelessWidget { return SignalBuilder( builder: (context) => SettingRow( label: 'Player design', - help: 'uosc is modeled on mpv: a thick timeline with chapters and what is cached', - control: _dropdown( + help: 'Minimal has a thick timeline with chapters and what is downloaded yet', + control: _dropdown( key: const ValueKey('player-design'), - value: s.mpvControls.value, - items: const [(false, 'Standard'), (true, 'uosc')], - onChanged: s.setMpvControls, + value: s.controlsSkin.value, + items: [for (final k in ControlsSkin.values) (k, k.label)], + onChanged: s.setControlsSkin, + ), + ), + ); + } +} + +/// The bar's clock: elapsed / total or the time left. Live. +class TimeDisplaySetting extends StatelessWidget { + const TimeDisplaySetting({super.key}); + + @override + Widget build(BuildContext context) { + final s = AppSettings.instance; + return SignalBuilder( + builder: (context) => SettingRow( + label: 'Time display', + help: 'Also switched by a click on the time', + control: _dropdown( + 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( + 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( + 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 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 playerSettingRows({ReadonlySignal? renderMode}) => [ const PlayerDesignSetting(), const ProgressLineSetting(), @@ -304,7 +405,8 @@ List playerSettingRows({ReadonlySignal? renderMode}) => [ const HwdecSetting(), ]; -/// The ⚙ card: every player setting, plus the running player's tracks. +/// The player settings card: every player setting, plus the running +/// player's tracks. class PlayerSettingsCard extends StatelessWidget { final ReadonlySignal? renderMode; /// Show the audio / subtitle pickers (a player is running). @@ -337,7 +439,7 @@ class PlayerSettingsCard extends StatelessWidget { children: [ Expanded( child: Text( - 'Custom shader files: Settings → Player', + 'Controls layout and custom shaders: Settings → Player', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12), ), ), @@ -352,51 +454,14 @@ class PlayerSettingsCard extends StatelessWidget { } } -/// Opens the ⚙ card over the player, near the controls' right end. The -/// uosc controls stay up while it is open. -Future showPlayerSettings(BuildContext context, {ReadonlySignal? renderMode}) { - Future open() => showPopover( +/// Opens the player settings card over the player, near the controls' +/// right end; the controls stay up while it is open. +Future showPlayerSettings(BuildContext context, {ReadonlySignal? renderMode}) => + ControlsFrame.holdWhile( + context, + () => showPopover( context, at: const Alignment(0.96, 0.5), child: PlayerSettingsCard(renderMode: renderMode), - ); - final controls = UoscControls.maybeOf(context); - return controls == null ? open() : controls.holdDuring(open); -} - -/// ⚙ in the standard controls' bottom bar. -class PlayerSettingsButton extends StatelessWidget { - final FocusNode? focusNode; - final ReadonlySignal? renderMode; - 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; - 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), - ); - } -} diff --git a/apps/desktop/lib/src/controls/session_controls.dart b/apps/desktop/lib/src/controls/session_controls.dart new file mode 100644 index 0000000..d163e7c --- /dev/null +++ b/apps/desktop/lib/src/controls/session_controls.dart @@ -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 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, + ), + }, + ); + }, + ); + } +} diff --git a/apps/desktop/lib/src/controls/standard/seek_bar.dart b/apps/desktop/lib/src/controls/standard/seek_bar.dart new file mode 100644 index 0000000..20a5d2e --- /dev/null +++ b/apps/desktop/lib/src/controls/standard/seek_bar.dart @@ -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 chapters; + final ValueChanged 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 createState() => _ChapterSeekBarState(); +} + +class _ChapterSeekBarState extends State { + 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), + ), + ], + ), + ); + } +} diff --git a/apps/desktop/lib/src/controls/standard/standard_controls.dart b/apps/desktop/lib/src/controls/standard/standard_controls.dart new file mode 100644 index 0000000..1dadefd --- /dev/null +++ b/apps/desktop/lib/src/controls/standard/standard_controls.dart @@ -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()]), + ), + ), + ); + } +} diff --git a/apps/desktop/lib/src/controls/uosc/buttons.dart b/apps/desktop/lib/src/controls/uosc/buttons.dart deleted file mode 100644 index 4805e09..0000000 --- a/apps/desktop/lib/src/controls/uosc/buttons.dart +++ /dev/null @@ -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 createState() => _UoscButtonState(); -} - -class _UoscButtonState extends State { - 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 createState() => _UoscBadgeButtonState(); -} - -class _UoscBadgeButtonState extends State { - 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 onChanged; - const UoscVolumeButton({super.key, required this.volume, required this.onChanged}); - - @override - State createState() => _UoscVolumeButtonState(); -} - -class _UoscVolumeButtonState extends State { - 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 tracks; - final void Function(TrackPick pick) onPick; - final ValueChanged? 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(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 chapters; - final int? current; - final Duration duration; - final ValueChanged onPick; - final ValueChanged? 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(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; - final void Function(ShaderPreset preset, ShaderQuality? quality) onPick; - final ValueChanged? 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 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 actions; - final ValueChanged? 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(context, items: [ - for (final a in actions) UoscMenuItem(value: a, title: a.title, hint: a.hint), - ]); - onMenuOpen?.call(false); - pick?.run(); - }, - ); - } -} diff --git a/apps/desktop/lib/src/controls/uosc/controls.dart b/apps/desktop/lib/src/controls/uosc/controls.dart deleted file mode 100644 index ea68a6e..0000000 --- a/apps/desktop/lib/src/controls/uosc/controls.dart +++ /dev/null @@ -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 left; - final List 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? 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(); - - @override - State createState() => UoscControlsState(); -} - -class UoscControlsState extends State { - 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 holdDuring(Future 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), - ); - } -} diff --git a/apps/desktop/lib/src/controls/uosc/menu.dart b/apps/desktop/lib/src/controls/uosc/menu.dart deleted file mode 100644 index 9ee15df..0000000 --- a/apps/desktop/lib/src/controls/uosc/menu.dart +++ /dev/null @@ -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 { - 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 showUoscMenu( - BuildContext context, { - String? title, - required List> 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 open() => navigator.push(_UoscMenuRoute(anchor: anchor, title: title, items: items)); - final controls = UoscControls.maybeOf(context); - return controls == null ? open() : controls.holdDuring(open); -} - -class _UoscMenuRoute extends PopupRoute { - final Rect anchor; - final String? title; - final List> 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 animation, Animation 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( - title: title, - items: items, - onPick: (v) => Navigator.of(context).pop(v), - ), - ), - ), - ], - ); - }); - } -} - -/// The menu's panel alone (tests pump it without a route). -class UoscMenuPanel extends StatefulWidget { - final String? title; - final List> items; - final ValueChanged onPick; - const UoscMenuPanel({super.key, this.title, required this.items, required this.onPick}); - - static const rowHeight = 36.0; - - @override - State> createState() => _UoscMenuPanelState(); -} - -class _UoscMenuPanelState extends State> { - // 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(item: widget.items[i], onPick: widget.onPick), - ), - ), - ], - ), - ); - } -} - -class _Row extends StatefulWidget { - final UoscMenuItem item; - final ValueChanged onPick; - const _Row({super.key, required this.item, required this.onPick}); - - @override - State<_Row> createState() => _RowState(); -} - -class _RowState extends State<_Row> { - 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> chapterMenuItems(List 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> trackMenuItems(JellyfinStreamType type, List 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, - ), - ]; -} diff --git a/apps/desktop/lib/src/controls/uosc/style.dart b/apps/desktop/lib/src/controls/uosc/style.dart deleted file mode 100644 index 88591c3..0000000 --- a/apps/desktop/lib/src/controls/uosc/style.dart +++ /dev/null @@ -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()])), - ); - } -} diff --git a/apps/desktop/lib/src/controls/uosc/timeline.dart b/apps/desktop/lib/src/controls/uosc/timeline.dart deleted file mode 100644 index c6c8098..0000000 --- a/apps/desktop/lib/src/controls/uosc/timeline.dart +++ /dev/null @@ -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 chapters; - final bool expanded; - final ValueChanged 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 createState() => _UoscTimelineState(); -} - -class _UoscTimelineState extends State { - 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 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( - 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 []; - 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 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; -} diff --git a/apps/desktop/lib/src/controls/uosc/volume_flash.dart b/apps/desktop/lib/src/controls/uosc/volume_flash.dart deleted file mode 100644 index d9827df..0000000 --- a/apps/desktop/lib/src/controls/uosc/volume_flash.dart +++ /dev/null @@ -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; -} diff --git a/apps/desktop/lib/src/controls/volume.dart b/apps/desktop/lib/src/controls/volume.dart index d5b8743..a0d4d37 100644 --- a/apps/desktop/lib/src/controls/volume.dart +++ b/apps/desktop/lib/src/controls/volume.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:media_kit/media_kit.dart'; import '../playback/playback_controller.dart' show PlaybackController; import '../widgets/app_menu.dart' show AppGlass; @@ -90,7 +89,7 @@ class VolumeFlash extends StatefulWidget { final WidgetBuilder builder; const VolumeFlash({super.key, required this.trigger, required this.builder}); - /// uosc's `flash_duration`. + /// How long the readout stays after the last step. static const hold = Duration(seconds: 1); @override @@ -142,7 +141,7 @@ class _VolumeFlashState extends State { ); } -/// The standard design's volume readout for [VolumeFlash]: a glass pill +/// The volume readout for [VolumeFlash] (both skins): a glass pill /// (the player's pill look) with the icon, a [VolumeBar] and the /// percentage. class VolumeReadout extends StatelessWidget { @@ -159,11 +158,7 @@ class VolumeReadout extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - v <= 0 ? Icons.volume_off : (v < 50 ? Icons.volume_down : Icons.volume_up), - size: 20, - color: v > 100 ? boostColor : Colors.white, - ), + Icon(volumeIcon(v), size: 20, color: v > 100 ? boostColor : Colors.white), const SizedBox(width: 8), VolumeBar(volume: v, onChanged: (_) {}, width: 96), const SizedBox(width: 10), @@ -246,72 +241,88 @@ class _VolumePainter extends CustomPainter { bool shouldRepaint(_VolumePainter old) => old.volume != volume || old.color != color || old.trackColor != trackColor; } -/// Volume for the standard bar (instead of media_kit's, which stops at -/// 100): the icon mutes / unmutes, hovering shows a [VolumeBar], the -/// wheel steps [volumeStep]. -class VolumeButton extends StatefulWidget { - final Player player; - final FocusNode? focusNode; - const VolumeButton({super.key, required this.player, this.focusNode}); +/// The speaker icon for [volume]. +IconData volumeIcon(double volume) => + volume <= 0 ? Icons.volume_off_rounded : (volume < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded); + +/// Builds a skin's button for the [VolumeControl]. +typedef VolumeButtonBuilder = Widget Function( + BuildContext context, + IconData icon, + Color? color, + String tooltip, + VoidCallback onPressed, +); + +/// The bar's volume, in both skins: the skin's speaker button (click +/// mutes / unmutes), a [VolumeBar] sliding out on hover, the wheel on it +/// stepping [volumeStep] (claimed, so the player's wheel skips it). +class VolumeControl extends StatefulWidget { + /// 0..[maxVolume]. + final double volume; + final ValueChanged onChanged; + final VoidCallback onToggleMute; + final VolumeButtonBuilder button; + final Color sliderColor; + final Color trackColor; + final double sliderWidth; + const VolumeControl({ + super.key, + required this.volume, + required this.onChanged, + required this.onToggleMute, + required this.button, + this.sliderColor = Colors.white, + this.trackColor = Colors.white30, + this.sliderWidth = 72, + }); @override - State createState() => _VolumeButtonState(); + State createState() => _VolumeControlState(); } -class _VolumeButtonState extends State { +class _VolumeControlState extends State { final _steps = WheelSteps(); bool _hover = false; - double _beforeMute = 100; @override Widget build(BuildContext context) { - final player = widget.player; - return StreamBuilder( - stream: player.stream.volume, - initialData: player.state.volume, - builder: (context, snap) { - final v = snap.data ?? 100; - return MouseRegion( - onEnter: (_) => setState(() => _hover = true), - onExit: (_) => setState(() => _hover = false), - child: Listener( - // Claimed here, so the player's own wheel handler skips it. - onPointerSignal: (e) => - _steps.handle(e, (step) => player.setVolume(stepVolume(player.state.volume, step * volumeStep))), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 4), - IconButton( - key: const ValueKey('volume-button'), - focusNode: widget.focusNode, - tooltip: 'Volume ${v.round()} %', - iconSize: 22, - color: Colors.white, - onPressed: () { - if (v > 0) { - _beforeMute = v; - player.setVolume(0); - } else { - player.setVolume(_beforeMute <= 0 ? 100 : _beforeMute); - } - }, - icon: Icon(v <= 0 ? Icons.volume_off : (v < 50 ? Icons.volume_down : Icons.volume_up)), - ), - AnimatedSize( - duration: const Duration(milliseconds: 150), - child: _hover - ? Padding( - padding: const EdgeInsets.only(left: 4, right: 12), - child: VolumeBar(volume: v, onChanged: player.setVolume), - ) - : const SizedBox.shrink(), - ), - ], + final v = widget.volume; + return MouseRegion( + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + child: Listener( + onPointerSignal: (e) => _steps.handle(e, (step) => widget.onChanged(stepVolume(v, step * volumeStep))), + child: Row( + key: const ValueKey('volume-control'), + mainAxisSize: MainAxisSize.min, + children: [ + widget.button( + context, + volumeIcon(v), + v > 100 ? boostColor : null, + 'Volume ${v.round()} % (scroll, ↑ / ↓)', + widget.onToggleMute, ), - ), - ); - }, + AnimatedSize( + duration: const Duration(milliseconds: 150), + child: _hover + ? Padding( + padding: const EdgeInsets.only(left: 4, right: 8), + child: VolumeBar( + key: const ValueKey('volume-bar'), + volume: v, + onChanged: widget.onChanged, + width: widget.sliderWidth, + color: widget.sliderColor, + trackColor: widget.trackColor, + ), + ) + : const SizedBox.shrink(), + ), + ], + ), + ), ); } } diff --git a/apps/desktop/lib/src/playback/playback_controller.dart b/apps/desktop/lib/src/playback/playback_controller.dart index f4f70aa..ce4f507 100644 --- a/apps/desktop/lib/src/playback/playback_controller.dart +++ b/apps/desktop/lib/src/playback/playback_controller.dart @@ -149,6 +149,9 @@ class PlaybackController { /// `demuxer-cache-time`, media_kit's `buffer`), in 250 ms steps. final buffered = signal(Duration.zero); + /// The player's volume, 0..[maxVolume] (media_kit's `volume`). + final volume = signal(100.0); + /// The opening ([openingRange]: Intro segment, else an OP-titled /// chapter) and the ending ([endingRange]: Outro, else ED-titled). late final Computed opening = @@ -504,9 +507,11 @@ class PlaybackController { } } - // Position / duration from media_kit's streams; chapter-list and + // Position, duration and volume from media_kit's streams; chapter-list and // track-list observed in mpv (read back as JSON). void _watchMarks(SyncPlayer p) { + volume.value = p.state.volume; + _timeSubs.add(p.stream.volume.listen((v) => volume.value = v)); _timeSubs.add(p.stream.position.listen((d) { // At the end the bar shows full: mpv's last position is the last // frame's start (and the 250 ms steps round down), short of duration. diff --git a/apps/desktop/lib/src/queue/queue_ui.dart b/apps/desktop/lib/src/queue/queue_ui.dart index 50ea456..e422fd8 100644 --- a/apps/desktop/lib/src/queue/queue_ui.dart +++ b/apps/desktop/lib/src/queue/queue_ui.dart @@ -9,9 +9,7 @@ import 'package:signals/signals_flutter.dart'; import 'package:sync_relay_core/core.dart'; import '../controls/controls_probe.dart' show ControlsHold; -import '../controls/uosc/buttons.dart' show UoscButton; -import '../controls/uosc/controls.dart' show UoscControls; -import '../controls/uosc/style.dart'; +import '../controls/minimal/style.dart'; import '../files/scanner.dart' show FileKind, kindOf; import '../jellyfin/images.dart'; import '../jellyfin/session_media.dart' show EpisodeLink; @@ -28,16 +26,16 @@ import '../widgets/text_focus.dart' show KeyboardClaim; import 'queue_controller.dart'; import 'queue_logic.dart'; -// Up next in the player: its control in the top bar (with chat and -// fullscreen), the popover it opens (the queue, the continuation, a field +// Up next in the player: its control in the top bar (with the window +// controls), the popover it opens (the queue, the continuation, a field // and a drop zone to add), and the end-of-media card for the first item. // ---------------------------------------------------------------- button -/// Which controls design the Up next control sits in: it takes the look +/// Which controls skin the Up next control sits in: it takes the look /// of that bar's buttons. -enum UpNextLook { standard, uosc } +enum UpNextLook { standard, minimal } /// The next item's picture, "UP NEXT" over its title, a +n count and ⏭ /// (play it now) in the player's top bar, one flat control among its @@ -66,7 +64,7 @@ class _UpNextButtonState extends State { bool _hoverTitle = false; QueueController get _q => widget.queue; - bool get _uosc => widget.look == UpNextLook.uosc; + bool get _minimal => widget.look == UpNextLook.minimal; @override void didUpdateWidget(UpNextButton oldWidget) { @@ -135,16 +133,16 @@ class _UpNextButtonState extends State { @override Widget build(BuildContext context) { - // The bar's own metrics: uosc's flat 40 px boxes, or media_kit's - // round 44 px icon buttons. - final height = _uosc ? UoscControls.buttonRowHeight : 44.0; - final radius = _uosc ? UoscStyle.radius : height / 2; - final hover = _uosc ? UoscStyle.hover : Colors.white10; - final fontSize = _uosc ? UoscStyle.fontSize : 14.0; - final dim = _uosc ? UoscStyle.dim : Colors.white70; + // The bar's own metrics: the Minimal skin's rounded squares, or the + // Standard skin's round 44 px icon buttons. + final height = _minimal ? MinimalStyle.button + 2 * MinimalStyle.capsulePad : 44.0; + final radius = _minimal ? MinimalStyle.radius + MinimalStyle.capsulePad : height / 2; + final hover = _minimal ? MinimalStyle.hover : Colors.white10; + final fontSize = _minimal ? MinimalStyle.fontSize : 14.0; + final dim = _minimal ? MinimalStyle.dim : Colors.white70; // The picture fills the button's height but for a 7 px margin. final thumbWidth = (height - 14) * 16 / 9; - final thumbRadius = _uosc ? UoscStyle.radius : 6.0; + final thumbRadius = _minimal ? MinimalStyle.radius - 4 : 6.0; return SignalBuilder(builder: (context) { final items = _q.items.value; final cont = _q.shownContinuation.value; @@ -197,7 +195,7 @@ class _UpNextButtonState extends State { duration: const Duration(milliseconds: 150), height: height, // The picture clears a round button's curve. - padding: EdgeInsets.only(left: _uosc ? 7 : 12, right: 6), + padding: EdgeInsets.only(left: _minimal ? 7 : 12, right: 6), decoration: BoxDecoration( color: _hoverTitle || open ? hover : Colors.transparent, borderRadius: BorderRadius.circular(radius), @@ -234,7 +232,7 @@ class _UpNextButtonState extends State { const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), - decoration: BoxDecoration(color: UoscStyle.hover, borderRadius: BorderRadius.circular(999)), + decoration: BoxDecoration(color: MinimalStyle.hover, borderRadius: BorderRadius.circular(999)), child: Text('+$more', style: TextStyle(color: dim, fontSize: 11)), ), ], @@ -254,8 +252,8 @@ class _UpNextButtonState extends State { ? 'Play ${item!.title} now ($playNextKeys)' : 'Play ${episode == null ? '' : episode.label} now ($playNextKeys)'; final VoidCallback? play = queued ? _q.skip : (episode != null ? widget.onPlayNextEpisode : null); - final playButton = _uosc - ? UoscButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play) + final playButton = _minimal + ? MinimalButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play) : Tooltip( message: playTip, waitDuration: const Duration(milliseconds: 500), diff --git a/apps/desktop/lib/src/screens/home/about_page.dart b/apps/desktop/lib/src/screens/home/about_page.dart index 992bfcc..fdd1edc 100644 --- a/apps/desktop/lib/src/screens/home/about_page.dart +++ b/apps/desktop/lib/src/screens/home/about_page.dart @@ -12,6 +12,10 @@ import 'settings_page.dart'; import 'ui.dart'; import 'update_indicator.dart'; +/// The Minimal player skin's credit (README says the same). +const uoscCredit = 'Player timeline and controls inspired by uosc by tomasklaen, LGPL-2.1 ' + '(github.com/tomasklaen/uosc).'; + /// Name, version, update state, the source link and the logs (Settings → /// About). class AboutPanel extends StatelessWidget { @@ -48,6 +52,8 @@ class AboutPanel extends StatelessWidget { ), ], ), + const SizedBox(height: 4), + Text(uoscCredit, key: const ValueKey('uosc-credit'), style: t.bodySmall?.copyWith(color: Colors.grey)), const SizedBox(height: kGap), Text('Something went wrong? Copy diagnostics and send them to whoever helps you: ' 'versions and the last hours of the log, passwords, tokens and cookies left out. ' diff --git a/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart b/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart index be92fe2..a92cfaa 100644 --- a/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart +++ b/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart @@ -8,35 +8,6 @@ import 'player_controls.dart' show FloatingPill; // Jellyfin pieces of the session player. Plain widgets fed by the // coordinator's signals, so tests can pump them without the player. -/// Previous / next episode in the video controls. Nothing when there is -/// no such episode or this viewer cannot put it up (no account). -class EpisodeSkipButton extends StatelessWidget { - final EpisodeLink? episode; - final bool next; - final VoidCallback onPressed; - final FocusNode? focusNode; - const EpisodeSkipButton( - {super.key, required this.episode, required this.next, required this.onPressed, this.focusNode}); - - @override - Widget build(BuildContext context) { - final e = episode; - if (e == null) return const SizedBox.shrink(); - return Tooltip( - message: '${next ? 'Next' : 'Previous'}: ${e.label} (${next ? '> or Shift+N' : '< or Shift+P'})', - child: IconButton( - key: ValueKey(next ? 'episode-next' : 'episode-previous'), - focusNode: focusNode, - color: Colors.white, - iconSize: 22, - visualDensity: VisualDensity.compact, - icon: Icon(next ? Icons.skip_next_rounded : Icons.skip_previous_rounded), - onPressed: onPressed, - ), - ); - } -} - /// Floating "Next episode ›" from the ED (or the last two minutes), while /// the controls are up (`PillGate`): faint at rest, lit on hover. class NextEpisodePill extends StatelessWidget { diff --git a/apps/desktop/lib/src/screens/session/player_controls.dart b/apps/desktop/lib/src/screens/session/player_controls.dart index 77e0f06..61bdbe9 100644 --- a/apps/desktop/lib/src/screens/session/player_controls.dart +++ b/apps/desktop/lib/src/screens/session/player_controls.dart @@ -1,530 +1,11 @@ -import 'dart:async'; -import 'dart:math' as math; - import 'package:flutter/material.dart'; -import 'package:media_kit/media_kit.dart'; -import 'package:sync_relay_core/core.dart' show JellyfinStreamType; -import '../../jellyfin/track_prefs.dart' show MpvTrack; import '../../playback/media_marks.dart'; import '../../widgets/app_menu.dart' show AppGlass; -// Session control-bar pieces built on the playback signals (chapters, -// tracks, skip offers): the seek bar with chapter marks, the chapter and -// track menus, the 1:30 skip button and the floating skip pill. Plain -// widgets fed by values and callbacks, so tests pump them without a player. - -/// Keeps media_kit's desktop controls shown, as a mouse hover would. -/// While a seek bar drag runs no hover events arrive, and the controls -/// would hide (and unmount the bar) mid-drag. A guarded dynamic call on -/// the private controls state; nothing happens outside those controls. -void keepVideoControlsAwake(BuildContext context) { - context.visitAncestorElements((e) { - if (e is StatefulElement && - e.state.runtimeType.toString() == '_MaterialDesktopVideoControlsState') { - try { - (e.state as dynamic).onHover(); - } catch (_) {} - return false; - } - return true; - }); -} - -/// The seek bar: played / buffered track, a diamond at each chapter start -/// (not the one at 0), and on hover the time with the chapter title above -/// it. Like media_kit's bar it reports a seek on every drag move and on -/// release ([onSeek]); the session routes those through the user-seek path -/// (debounced, everyone follows). -class ChapterSeekBar extends StatefulWidget { - final Duration position; - final Duration duration; - final Duration buffer; - final List chapters; - final ValueChanged 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 createState() => _ChapterSeekBarState(); -} - -class _ChapterSeekBarState extends State { - 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 chapters; - const PlayerSeekBar({super.key, required this.player, required this.chapters}); - - @override - State createState() => _PlayerSeekBarState(); -} - -class _PlayerSeekBarState extends State { - final List> _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 chapters; - final int? current; - final ValueChanged onPick; - final FocusNode? focusNode; - final ValueChanged? 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 _open(BuildContext context) async { - final position = _menuPosition(context); - onMenuOpen?.call(true); - final pick = await showMenu( - 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 tracks; - final void Function(TrackPick pick) onPick; - final FocusNode? focusNode; - final ValueChanged? 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 _open(BuildContext context, List list) async { - final position = _menuPosition(context); - onMenuOpen?.call(true); - final pick = await showMenu( - context: context, - position: position, - popUpAnimationStyle: AnimationStyle.noAnimation, - constraints: const BoxConstraints(minWidth: 280, maxWidth: 460), - items: [ - if (!_audio) - CheckedPopupMenuItem( - value: (aid: null, sid: null, subtitlesOff: true), - checked: !list.any((t) => t.selected), - height: 36, - child: const Text('Off'), - ), - if (_audio && list.isEmpty) - const PopupMenuItem(enabled: false, height: 32, child: Text('None')), - for (final t in list) - CheckedPopupMenuItem( - value: _audio - ? (aid: t.id, sid: null, subtitlesOff: false) - : (aid: null, sid: t.id, subtitlesOff: false), - checked: t.selected, - height: 36, - child: Text(t.label, maxLines: 1, overflow: TextOverflow.ellipsis), - ), - ], - ); - onMenuOpen?.call(false); - if (pick != null) onPick(pick); - } -} - -/// "1:30" in the controls: skip forward [fallbackSkip] (Shift+Right). -class SkipForwardButton extends StatelessWidget { - final VoidCallback onPressed; - final FocusNode? focusNode; - const SkipForwardButton({super.key, required this.onPressed, this.focusNode}); - - @override - Widget build(BuildContext context) { - return Tooltip( - message: 'Skip 1:30 for everyone (Shift+Right)', - child: TextButton.icon( - key: const ValueKey('skip-forward-button'), - focusNode: focusNode, - style: TextButton.styleFrom( - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 8), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - onPressed: onPressed, - icon: const Icon(Icons.fast_forward_rounded, size: 16), - label: const Text('1:30', style: TextStyle(fontSize: 12)), - ), - ); - } -} +// The player's floating action pills (Skip OP / ED / 1:30, Next episode): +// plain widgets fed by values and callbacks, so tests pump them without a +// player. They live in the action area, outside the controls. /// The player's action pill (Next episode, Skip OP / ED / 1:30): the /// status pill's glass ([AppGlass.pill]) at [height], faint at rest, lit diff --git a/apps/desktop/lib/src/shaders/shader_menu.dart b/apps/desktop/lib/src/shaders/shader_menu.dart index 139d49b..daf7021 100644 --- a/apps/desktop/lib/src/shaders/shader_menu.dart +++ b/apps/desktop/lib/src/shaders/shader_menu.dart @@ -8,50 +8,6 @@ import 'shader_presets.dart'; /// A shader pick from the menu; a null quality keeps the saved one. typedef ShaderPick = (ShaderPreset, ShaderQuality?); -/// Compact shader button for the video controls bar (normal and -/// fullscreen). Opens a small menu: Off, the six Anime4K modes with -/// HQ / Fast, Custom. The menu is a route, so it outlives the controls -/// bar hiding under it; [onPick] runs on the caller's side. -class ShaderControlButton extends StatelessWidget { - final FocusNode focusNode; - final ReadonlySignal renderMode; - final void Function(ShaderPreset preset, ShaderQuality? quality) onPick; - final ValueChanged? onMenuOpen; - const ShaderControlButton({ - super.key, - required this.focusNode, - required this.renderMode, - required this.onPick, - this.onMenuOpen, - }); - - @override - Widget build(BuildContext context) { - final s = AppSettings.instance; - return SignalBuilder(builder: (context) { - final preset = s.shaderPreset.value; - final quality = s.shaderQuality.value; - final short = shaderShortLabel(preset, quality) ?? 'Shaders'; - return Tooltip( - message: 'Video shaders (Ctrl+1..6, Ctrl+0 off)', - child: TextButton.icon( - focusNode: focusNode, - style: TextButton.styleFrom( - foregroundColor: - preset == ShaderPreset.off ? Colors.white : Colors.amberAccent, - padding: const EdgeInsets.symmetric(horizontal: 8), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - onPressed: () => showShaderMenu(context, - renderMode: renderMode, onPick: onPick, onMenuOpen: onMenuOpen), - icon: const Icon(Icons.auto_awesome, size: 16), - label: Text(short, style: const TextStyle(fontSize: 12)), - ), - ); - }); - } -} - /// "A HQ", "C+A Fast", "Custom"; null when shaders are off. String? shaderShortLabel(ShaderPreset preset, ShaderQuality quality) => switch (preset) { @@ -61,7 +17,8 @@ String? shaderShortLabel(ShaderPreset preset, ShaderQuality quality) => }; /// The shader menu, anchored on [context]'s box (the button that opens -/// it): Off, the six Anime4K modes with HQ / Fast, Custom. +/// it; the controls' ⚙): Off, the six Anime4K modes with HQ / Fast, +/// Custom. A route, so it outlives the controls hiding under it. Future showShaderMenu( BuildContext context, { required ReadonlySignal renderMode, diff --git a/apps/desktop/lib/src/shell/app_shell.dart b/apps/desktop/lib/src/shell/app_shell.dart index 6be3545..0cad345 100644 --- a/apps/desktop/lib/src/shell/app_shell.dart +++ b/apps/desktop/lib/src/shell/app_shell.dart @@ -7,6 +7,7 @@ import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTar import 'package:signals/signals_flutter.dart'; import 'package:window_manager/window_manager.dart'; +import '../controls/frame.dart' show DoubleClickListener; import '../files/folder_store.dart'; import '../files/ui/files_page.dart' show showFilesFolder; import '../jellyfin/accounts.dart'; diff --git a/apps/desktop/lib/src/shell/session_player.dart b/apps/desktop/lib/src/shell/session_player.dart index ca49041..5fb4f7a 100644 --- a/apps/desktop/lib/src/shell/session_player.dart +++ b/apps/desktop/lib/src/shell/session_player.dart @@ -2,26 +2,20 @@ import 'dart:async'; import 'dart:math' as math; import 'package:flutter/foundation.dart' show ValueListenable; -import 'package:flutter/gestures.dart' show kPrimaryMouseButton; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:media_kit_video/media_kit_video.dart'; import 'package:signals/signals_flutter.dart'; -import 'package:sync_relay_core/core.dart'; import '../controls/controls_probe.dart'; -import '../controls/player_settings.dart'; -import '../controls/uosc/buttons.dart'; -import '../controls/uosc/controls.dart'; -import '../controls/uosc/timeline.dart'; -import '../controls/uosc/volume_flash.dart'; +import '../controls/model/controls_model.dart'; +import '../controls/session_controls.dart'; import '../controls/volume.dart'; import '../queue/queue_ui.dart'; import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock; import '../screens/session/jellyfin_widgets.dart'; import '../screens/session/player_controls.dart'; import '../session/session_coordinator.dart'; -import '../shaders/shader_menu.dart'; import '../shaders/shader_presets.dart'; import '../state/session_state.dart'; import '../state/settings.dart'; @@ -29,7 +23,6 @@ import '../widgets/app_keys.dart'; import '../widgets/text_focus.dart'; import '../widgets/window_buttons.dart'; import 'avatar.dart'; -import 'mark_problem.dart'; import 'motion.dart'; import 'now_playing_title.dart'; import 'player_status.dart'; @@ -46,8 +39,8 @@ enum PlayerMode { none, mini, full } typedef VideoViewBuilder = Widget Function(BuildContext context, VideoControlsBuilder? controls); /// The session's player: the one `Video` of the current player and -/// everything over it. Full: our bars on media_kit's controls or the uosc -/// set (Settings), the start card, the status pill, Jellyfin overlays and +/// everything over it. Full: the controls (one model, the skin picked in +/// Settings), the start card, the status pill, Jellyfin overlays and /// the player keys. Mini: hover controls and a progress line. The shell keeps this /// widget mounted at one place in the tree while the mode changes, so the /// `Video` is never mounted twice nor rebuilt for a move. @@ -99,21 +92,31 @@ class _SessionPlayerState extends State { final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false); DateTime? _lastKeyAt; late final void Function() _stopShaderWatch; - bool _shaderMenuOpen = false; // Own toast ids, negative: the coordinator counts up from 1. int _toastSeq = 0; int _shaderNoticeSeen = 0; bool _miniHover = false; - // Whether the controls are up: the standard ones tracked like media_kit - // does (shown on mount and on mouse movement, hidden 3 s later or on - // exit), the uosc ones reporting it. + // Whether the controls are up (their frame reports it). final _controlsShown = ValueNotifier(true); // The mouse on a floating element, or the Up next popover open, keeps // the controls up. final _hold = ControlsHold(); - // Ticks on each wheel / key volume step: flashes the volume readout. - final _volumeFlash = ValueNotifier(0); late final void Function() _stopPopoverWatch; + // The shell's state the controls show, as signals for the model. + late final _fullscreen = signal(widget.fullscreen); + late final _chatOpen = signal(widget.chatOpen); + late final _unread = signal(widget.unread); + late final PlayerControlsModel _model = PlayerControlsModel( + coord: _coord, + fullscreen: _fullscreen, + chatOpen: _chatOpen, + unread: _unread, + onToggleFullscreen: () => widget.onToggleFullscreen(), + onToggleChat: () => widget.onToggleChat(), + onChangeVideo: () => widget.onChangeVideo(), + onCollapse: () => widget.onCollapse(), + onLeave: () => widget.onLeave(), + ); SessionCoordinator get _coord => SessionState.instance.coordinator; bool get _full => widget.mode == PlayerMode.full; @@ -138,6 +141,14 @@ class _SessionPlayerState extends State { }); } + @override + void didUpdateWidget(SessionPlayer oldWidget) { + super.didUpdateWidget(oldWidget); + _fullscreen.value = widget.fullscreen; + _chatOpen.value = widget.chatOpen; + _unread.value = widget.unread; + } + @override void dispose() { HardwareKeyboard.instance.removeHandler(_globalKey); @@ -147,15 +158,10 @@ class _SessionPlayerState extends State { _noFocus.dispose(); _controlsShown.dispose(); _hold.dispose(); - _volumeFlash.dispose(); + _model.dispose(); super.dispose(); } - void _reloadVideo() { - _coord.markInput(); - _coord.reloadVideo(); - } - void _toast(String text) { _coord.toasts.value = [ ..._coord.toasts.peek(), @@ -163,22 +169,7 @@ class _SessionPlayerState extends State { ]; } - /// Menu, Ctrl+0..6: saved as the choice (also used on the next join) - /// and applied live. A null quality keeps the saved one. - void _pickShader(ShaderPreset preset, ShaderQuality? quality) => applyShaderChoice(preset, quality); - - /// One volume step up (+1) or down (-1), from the wheel or ↑ / ↓; - /// flashes the design's volume readout. - void _stepVolume(int step) { - final player = _coord.playback.player; - player.setVolume(stepVolume(player.state.volume, step * volumeStep)); - _volumeFlash.value++; - } - - void _toggleChat() { - _coord.markInput(); - widget.onToggleChat(); - } + void _toggleChat() => _model.toggleChat(); /// The player keys, while the player fills the body and nothing (a /// dialog, a card, a text field) is in front of it. Space and Tab are @@ -196,7 +187,7 @@ class _SessionPlayerState extends State { final route = ModalRoute.of(context); if (route != null && !route.isCurrent) { // A menu of ours is open: Esc closes it. - if (_shaderMenuOpen && event.logicalKey == LogicalKeyboardKey.escape) { + if (_model.menusOpen > 0 && event.logicalKey == LogicalKeyboardKey.escape) { Navigator.of(context, rootNavigator: true).maybePop(); return true; } @@ -207,62 +198,55 @@ class _SessionPlayerState extends State { if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) { return false; } - final coord = _coord; + final model = _model; final key = event.logicalKey; // Ctrl+0..6: Anime4K's own mpv keys (off, modes A..C+A). final digit = _shaderKeys.indexOf(key); if (digit >= 0 && HardwareKeyboard.instance.isControlPressed) { _lastKeyAt = now; - _pickShader(digit == 0 ? ShaderPreset.off : ShaderPreset.modes[digit - 1], null); + model.pickShader(digit == 0 ? ShaderPreset.off : ShaderPreset.modes[digit - 1], null); return true; } if (key == LogicalKeyboardKey.keyR && HardwareKeyboard.instance.isControlPressed) { _lastKeyAt = now; - _reloadVideo(); + model.reload(); return true; } // mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes. final shift = HardwareKeyboard.instance.isShiftPressed; if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) { // Up next first, then the next episode. - if (coord.queue.items.peek().isNotEmpty) { - _lastKeyAt = now; - coord.queue.skip(); - return true; - } - if (coord.nextEpisode.peek() == null) return false; + if (!model.next()) return false; _lastKeyAt = now; - coord.playNextEpisode(); return true; } if (event.character == '<' || (shift && key == LogicalKeyboardKey.keyP)) { - if (coord.previousEpisode.peek() == null) return false; + if (!model.previous()) return false; _lastKeyAt = now; - coord.playPreviousEpisode(); return true; } // mpv's chapter keys. if (key == LogicalKeyboardKey.pageDown || key == LogicalKeyboardKey.pageUp) { _lastKeyAt = now; - return coord.stepChapter(key == LogicalKeyboardKey.pageDown ? 1 : -1); + return model.stepChapter(key == LogicalKeyboardKey.pageDown ? 1 : -1); } if (shift && key == LogicalKeyboardKey.arrowRight) { _lastKeyAt = now; - coord.skipForward(); + model.skipForward(); return true; } if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.arrowDown) { - _stepVolume(key == LogicalKeyboardKey.arrowUp ? 1 : -1); + model.stepVolume(key == LogicalKeyboardKey.arrowUp ? 1 : -1); return true; } if (key == LogicalKeyboardKey.arrowRight) { _lastKeyAt = now; - coord.nudge(10); + model.nudge(10); return true; } if (key == LogicalKeyboardKey.arrowLeft) { _lastKeyAt = now; - coord.nudge(-10); + model.nudge(-10); return true; } if (key == LogicalKeyboardKey.escape) { @@ -312,13 +296,12 @@ class _SessionPlayerState extends State { // (lists, menus and popovers under the mouse scroll instead). return VolumeWheel( enabled: _full, - onStep: _stepVolume, + onStep: _model.stepVolume, child: Stack( fit: StackFit.expand, children: [ video, if (_full) ...[ - _progressLine(), _blockedCard(), _actionArea(), if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()], @@ -336,24 +319,12 @@ class _SessionPlayerState extends State { ); } - Widget _video(VideoControlsBuilder? controls) { - final theme = _controlsTheme(); - return MaterialDesktopVideoControlsTheme( - normal: theme, - fullscreen: theme, - child: Video( + Widget _video(VideoControlsBuilder? controls) => Video( key: _videoKey, controller: _coord.playback.video, subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false), controls: controls, - ), - ); - } - - static const _barHeight = 56.0; - /// The standard top bar: room for the cover (2:3, 60 px tall). - static const _topBarHeight = 72.0; - static const _controlsHide = Duration(seconds: 3); + ); /// Fullscreen hides the strip and its window buttons: the top bar /// carries them while the controls are up (restore leaves fullscreen). @@ -373,337 +344,18 @@ class _SessionPlayerState extends State { ); } - /// 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( - 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 _bottomButtons() { - final coord = _coord; - final playback = coord.playback; - void menuOpen(bool open) => _shaderMenuOpen = open; - void pickTrack(TrackPick p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff); - return [ - SignalBuilder( - builder: (context) => EpisodeSkipButton( - focusNode: _noFocus, - episode: coord.previousEpisode.value, - next: false, - onPressed: coord.playPreviousEpisode, - ), - ), - SignalBuilder( - builder: (context) => EpisodeSkipButton( - focusNode: _noFocus, - episode: coord.nextEpisode.value, - next: true, - onPressed: coord.playNextEpisode, - ), - ), - VolumeButton(player: playback.player, focusNode: _noFocus), - _ReadyControlButton(focusNode: _noFocus), - _BarTextButton( - focusNode: _noFocus, - label: 'Mark everyone ready', - onPressed: coord.forcePlayAll, - ), - _BarTextButton( - focusNode: _noFocus, - icon: Icons.refresh, - label: 'Reload video', - tooltip: 'Reload video (Ctrl+R) — fixes a frozen picture', - onPressed: _reloadVideo, - ), - const MaterialDesktopPositionIndicator(), - const Spacer(), - SkipForwardButton(focusNode: _noFocus, onPressed: coord.skipForward), - SignalBuilder( - builder: (context) => ChaptersControlButton( - focusNode: _noFocus, - chapters: playback.chapters.value, - current: playback.currentChapter.value, - onPick: (c) => coord.seekTo(c.start.inMicroseconds / 1e6), - onMenuOpen: menuOpen, - ), - ), - SignalBuilder( - builder: (context) => TrackMenuButton( - focusNode: _noFocus, - type: JellyfinStreamType.audio, - tracks: playback.tracks.value, - onPick: pickTrack, - onMenuOpen: menuOpen, - ), - ), - SignalBuilder( - builder: (context) => TrackMenuButton( - focusNode: _noFocus, - type: JellyfinStreamType.subtitle, - tracks: playback.tracks.value, - onPick: pickTrack, - onMenuOpen: menuOpen, - ), - ), - ShaderControlButton( - focusNode: _noFocus, - renderMode: playback.renderMode, - onPick: _pickShader, - onMenuOpen: menuOpen, - ), - PlayerSettingsButton(focusNode: _noFocus, renderMode: playback.renderMode), - MaterialDesktopCustomButton( - icon: Icon(widget.fullscreen ? Icons.fullscreen_exit : Icons.fullscreen), - onPressed: widget.onToggleFullscreen, - ), - ]; - } - Widget _sessionControls(VideoState state) { // The start card sits under the controls (they stay usable on it). - // The "Player design" setting swaps them live. - return Stack(fit: StackFit.expand, children: [const StartBanner(), _controlsSwitch(state)]); - } - - Widget _controlsSwitch(VideoState state) { - return SessionControlsSwitch( - material: (_) => ControlsHoverProbe( - shown: _controlsShown, - hideAfter: _controlsHide, - hold: _hold, - child: DoubleClickListener( + return Stack( + fit: StackFit.expand, + children: [ + const StartBanner(), + SessionControls( + model: _model, + shown: _controlsShown, + hold: _hold, 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: [ - UpNextButton( - queue: coord.queue, onPlayNextEpisode: coord.playNextEpisode, look: UpNextLook.uosc, hold: _hold), - UoscButton( - key: const ValueKey('uosc-chat'), - icon: widget.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded, - tooltip: 'Chat & activity (Tab)', - badge: widget.unread, - onPressed: _toggleChat, - ), - UoscButton( - icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded, - tooltip: widget.fullscreen ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)', - onPressed: widget.fullscreen ? widget.onToggleFullscreen : widget.onCollapse, - ), - ?_fullscreenWindowButtons(), - ], - ), - timeline: (context, expanded) => SignalBuilder( - builder: (context) => UoscTimeline( - position: playback.position.value, - duration: playback.duration.value, - buffered: playback.buffered.value, - chapters: playback.chapters.value, - expanded: expanded, - onSeek: (d) => playback.player.seek(d), - ), - ), - left: [ - UoscMainMenuButton( - onMenuOpen: menuOpen, - actions: [ - (title: 'Change video', hint: null, run: widget.onChangeVideo), - (title: 'Chat & activity', hint: 'Tab', run: _toggleChat), - (title: 'Reload video', hint: 'Ctrl+R', run: _reloadVideo), - (title: 'Mark a problem', hint: 'Ctrl+Shift+B', run: () => showMarkProblem(context)), - (title: 'Mark everyone ready', hint: null, run: coord.forcePlayAll), - (title: 'Fullscreen', hint: 'F', run: widget.onToggleFullscreen), - (title: 'Leave server', hint: 'Esc', run: widget.onLeave), - ], - ), - SignalBuilder( - builder: (context) => UoscTracksButton( - type: JellyfinStreamType.subtitle, - tracks: playback.tracks.value, - onPick: pickTrack, - onMenuOpen: menuOpen, - ), - ), - SignalBuilder( - builder: (context) => UoscTracksButton( - type: JellyfinStreamType.audio, - tracks: playback.tracks.value, - onPick: pickTrack, - onMenuOpen: menuOpen, - ), - ), - UoscShaderButton(renderMode: playback.renderMode, onPick: _pickShader, onMenuOpen: menuOpen), - const SizedBox(width: 12), - SignalBuilder(builder: (context) { - final ready = coord.selfMember.value?.ready ?? false; - return UoscButton( - key: const ValueKey('uosc-ready'), - icon: ready ? Icons.check_circle_rounded : Icons.radio_button_unchecked_rounded, - label: ready ? 'Ready' : 'Not ready', - color: ready ? Colors.greenAccent : null, - tooltip: 'Ready / not ready (Space)', - onPressed: coord.toggleReady, - ); - }), - UoscButton(icon: Icons.done_all_rounded, tooltip: 'Mark everyone ready', onPressed: coord.forcePlayAll), - UoscButton( - icon: Icons.refresh_rounded, - tooltip: 'Reload video (Ctrl+R) — fixes a frozen picture', - onPressed: _reloadVideo, - ), - ], - right: [ - UoscButton( - key: const ValueKey('uosc-skip-forward'), - icon: Icons.fast_forward_rounded, - label: '1:30', - tooltip: 'Skip 1:30 for everyone (Shift+Right)', - onPressed: coord.skipForward, - ), - SignalBuilder( - builder: (context) => UoscChaptersButton( - chapters: playback.chapters.value, - current: playback.currentChapter.value, - duration: playback.duration.value, - onPick: (c) => coord.seekTo(c.start.inMicroseconds / 1e6), - onMenuOpen: menuOpen, - ), - ), - episode(false), - episode(true), - const SizedBox(width: 12), - UoscButton(icon: Icons.video_library_outlined, tooltip: 'Change video', onPressed: widget.onChangeVideo), - StreamBuilder( - 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, + windowButtons: _fullscreenWindowButtons(), ), ], ); @@ -713,64 +365,26 @@ class _SessionPlayerState extends State { /// the gap between them and the controls. static const _edge = 16.0; - /// The bottom controls' height in the design in use. - static double _bottomControlsHeight(bool uosc) => uosc - ? UoscControls.buttonRowHeight + UoscControls.rowBottomPadding + UoscTimeline.height - : _barHeight + ChapterSeekBar.height; + /// The bottom controls' height in the skin and size in use. + static double _bottomControlsHeight() { + final s = AppSettings.instance; + return controlsBottomHeight(s.controlsSkin.value, s.controlsSize.value); + } - /// 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( - 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, right: 0, top: _topBarHeight + 8, child: Center( child: VolumeFlash( - trigger: _volumeFlash, - builder: (context) => withVolume((v) => VolumeReadout(volume: v)), + trigger: _model.volumeFlash, + builder: (context) => SignalBuilder(builder: (context) => VolumeReadout(volume: _model.volume.value)), ), ), ); - }); - } /// The action area: one band above the bottom controls for everything /// that floats there. The status pill (the ready prompt among it) @@ -781,7 +395,7 @@ class _SessionPlayerState extends State { /// ([ControlsHold]). Widget _actionArea() { return SignalBuilder(builder: (context) { - final controls = _bottomControlsHeight(AppSettings.instance.mpvControls.value); + final controls = _bottomControlsHeight(); return ValueListenableBuilder( valueListenable: _controlsShown, builder: (context, shown, child) => AnimatedPositioned( @@ -848,7 +462,7 @@ class _SessionPlayerState extends State { announce: skip.kind != SkipKind.ending, controlsShown: _controlsShown, hold: _hold, - child: SkipPill(offer: skip, onPressed: () => coord.skip(skip)), + child: SkipPill(offer: skip, onPressed: () => _model.skip(skip)), ), if (showNext) PillGate( @@ -1171,7 +785,7 @@ const _shaderKeys = [ class _MiniSeekBar extends StatelessWidget { final bool thin; final ValueChanged? onSeek; - const _MiniSeekBar({super.key, this.thin = false, this.onSeek}); + const _MiniSeekBar({this.thin = false, this.onSeek}); @override 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 createState() => _DoubleClickListenerState(); -} - -class _DoubleClickListenerState extends State { - 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); - } -} diff --git a/apps/desktop/lib/src/shell/settings_tab.dart b/apps/desktop/lib/src/shell/settings_tab.dart index 7c4a0cc..c153714 100644 --- a/apps/desktop/lib/src/shell/settings_tab.dart +++ b/apps/desktop/lib/src/shell/settings_tab.dart @@ -121,12 +121,16 @@ class SettingsTab extends StatelessWidget { return [ const SettingsHintCard( icon: Icons.tune, - text: "Player settings are also in the player's ⚙, at the right end of its controls.", + text: "Player settings are also in the player: ⚙ → Player settings, at the right of its controls.", ), SettingsPanel( title: 'Player', child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()), ), + SettingsPanel( + title: 'Controls', + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: controlsSettingRows()), + ), const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()), const SettingsPanel(title: 'yt-dlp', child: YtDlpSettings()), ]; diff --git a/apps/desktop/lib/src/state/settings.dart b/apps/desktop/lib/src/state/settings.dart index 4f7a66d..262f7d3 100644 --- a/apps/desktop/lib/src/state/settings.dart +++ b/apps/desktop/lib/src/state/settings.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:hive_ce/hive.dart'; import 'package:signals/signals_flutter.dart'; +import '../controls/model/controls_prefs.dart'; import '../shaders/shader_presets.dart'; // App preferences (Settings page), one Hive box. Signals start at their @@ -17,7 +18,14 @@ const _keyShaderPreset = 'shaderPreset'; const _keyShaderQuality = 'shaderQuality'; const _keyCustomShaders = 'customShaders'; const _keyAutoAdvance = 'autoAdvance'; +/// Legacy: true was the uosc design (now [ControlsSkin.minimal]); read +/// once when [_keyControlsSkin] is missing. const _keyMpvControls = 'mpvControls'; +const _keyControlsSkin = 'controlsSkin'; +const _keyTimeDisplay = 'timeDisplay'; +const _keyControlsSize = 'controlsSize'; +const _keyControlsHideSecs = 'controlsHideSecs'; +const _keyHiddenControls = 'hiddenControls'; const _keyHideReReleases = 'hideReReleases'; const _keyProgressLine = 'progressLine'; const _keySoundVolume = 'soundVolume'; @@ -62,14 +70,20 @@ class AppSettings { final unreadyOnSwap = signal(false); /// Session: the idle auto-unready sits this member out instead. final idleSitsOut = signal(false); - /// Player design: the uosc controls (`controls/uosc/`) instead of the - /// standard ones (media_kit's with our bars). Applies live. - final mpvControls = signal(false); + /// The player controls' skin (`controls/`). Applies live. + final controlsSkin = signal(ControlsSkin.standard); + /// The bar's clock: elapsed / total or the time left. + final timeDisplay = signal(TimeDisplay.elapsed); + final controlsSize = signal(ControlsSize.normal); + /// Seconds without mouse movement before the controls hide. + final controlsHideSecs = signal(defaultControlsHideSecs); + /// Optional bar buttons switched off (they stay in ⚙). + final hiddenControls = signal>(const {}); /// Library: leave out of "Latest in X" what was added long after it /// first aired (re-uploads, old seasons added late). final hideReReleases = signal(true); /// A 2 px progress line at the bottom of the video while the controls - /// are hidden (both designs). Applies live. + /// are hidden (both skins). Applies live. final progressLine = signal(true); /// Send this app's log lines to the server's session log while in a /// session (never chat). Off: nothing is sent but a note that it is off. @@ -127,8 +141,18 @@ class AppSettings { if (unready is bool) unreadyOnSwap.value = unready; final idleOut = box.get(_keyIdleSitsOut); if (idleOut is bool) idleSitsOut.value = idleOut; - final mpv = box.get(_keyMpvControls); - if (mpv is bool) mpvControls.value = mpv; + controlsSkin.value = + ControlsSkin.fromStored(box.get(_keyControlsSkin), legacyMpvControls: box.get(_keyMpvControls)); + final time = box.get(_keyTimeDisplay); + timeDisplay.value = TimeDisplay.values.where((t) => t.name == time).firstOrNull ?? TimeDisplay.elapsed; + final size = box.get(_keyControlsSize); + controlsSize.value = ControlsSize.values.where((t) => t.name == size).firstOrNull ?? ControlsSize.normal; + final hide = box.get(_keyControlsHideSecs); + if (hide is int && controlsHideChoices.contains(hide)) controlsHideSecs.value = hide; + final hidden = box.get(_keyHiddenControls); + if (hidden is List) { + hiddenControls.value = Set.unmodifiable(OptionalControl.values.where((c) => hidden.contains(c.name))); + } final hideRe = box.get(_keyHideReReleases); if (hideRe is bool) hideReReleases.value = hideRe; final line = box.get(_keyProgressLine); @@ -199,9 +223,31 @@ class AppSettings { _put(_keyIdleSitsOut, on); } - void setMpvControls(bool on) { - mpvControls.value = on; - _put(_keyMpvControls, on); + void setControlsSkin(ControlsSkin skin) { + controlsSkin.value = skin; + _put(_keyControlsSkin, skin.name); + } + + void setTimeDisplay(TimeDisplay t) { + timeDisplay.value = t; + _put(_keyTimeDisplay, t.name); + } + + void setControlsSize(ControlsSize size) { + controlsSize.value = size; + _put(_keyControlsSize, size.name); + } + + void setControlsHideSecs(int secs) { + controlsHideSecs.value = secs; + _put(_keyControlsHideSecs, secs); + } + + void setControlShown(OptionalControl c, bool on) { + final next = {...hiddenControls.value}; + on ? next.remove(c) : next.add(c); + hiddenControls.value = Set.unmodifiable(next); + _put(_keyHiddenControls, [for (final c in next) c.name]); } void setProgressLine(bool on) { diff --git a/apps/desktop/lib/src/widgets/app_menu.dart b/apps/desktop/lib/src/widgets/app_menu.dart index 73e9d34..0294a41 100644 --- a/apps/desktop/lib/src/widgets/app_menu.dart +++ b/apps/desktop/lib/src/widgets/app_menu.dart @@ -4,7 +4,7 @@ import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import '../controls/uosc/controls.dart'; +import '../controls/frame.dart'; import '../shell/motion.dart'; // The app's one menu: a frosted dark panel anchored under (or over) the @@ -158,9 +158,8 @@ Future _openMenu(BuildContext context, List> items, d minWidth: minWidth - shift, capturedThemes: InheritedTheme.capture(from: context, to: navigator.context), )); - // Over the uosc controls: keep them up while the menu is open. - final controls = UoscControls.maybeOf(context); - return controls == null ? open() : controls.holdDuring(open); + // Over the player's controls: keep them up while the menu is open. + return ControlsFrame.holdWhile(context, open); } class _AppMenuRoute extends PopupRoute { diff --git a/apps/desktop/test/controls_model_test.dart b/apps/desktop/test/controls_model_test.dart new file mode 100644 index 0000000..98a4cee --- /dev/null +++ b/apps/desktop/test/controls_model_test.dart @@ -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 = {}; + +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 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 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(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); + }); + }); +} diff --git a/apps/desktop/test/jellyfin_session_test.dart b/apps/desktop/test/jellyfin_session_test.dart index b31be24..16eb0b8 100644 --- a/apps/desktop/test/jellyfin_session_test.dart +++ b/apps/desktop/test/jellyfin_session_test.dart @@ -501,18 +501,6 @@ void main() { const JellyfinMedia( 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 { var play = 0, cancel = 0; await tester.pumpWidget(app(EndOfEpisodeCard( diff --git a/apps/desktop/test/minimal_controls_test.dart b/apps/desktop/test/minimal_controls_test.dart new file mode 100644 index 0000000..abc789c --- /dev/null +++ b/apps/desktop/test/minimal_controls_test.dart @@ -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(find.byKey(ValueKey(key))).data!; + +MinimalTimelinePainter painter(WidgetTester tester) => tester + .widget(find.descendant( + of: find.byKey(const ValueKey('minimal-timeline')), matching: find.byType(CustomPaint))) + .painter! as MinimalTimelinePainter; + +Future 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 = []; + 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 = []; + 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 = []; + 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 = []; + 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(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(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 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( + 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 = []; + 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 = []; + 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 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>(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>(design).value, ControlsSkin.minimal); + }); +} diff --git a/apps/desktop/test/player_controls_test.dart b/apps/desktop/test/player_controls_test.dart index 47f4032..adad2b0 100644 --- a/apps/desktop/test/player_controls_test.dart +++ b/apps/desktop/test/player_controls_test.dart @@ -2,11 +2,15 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; 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/playback/media_marks.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); @@ -18,9 +22,6 @@ final chapters = chaptersFromStarts([ ('Part B', m(21, 30)), ], m(24)); -Finder item(String text) => - find.ancestor(of: find.text(text), matching: find.byType(CheckedPopupMenuItem)); - Widget host(Widget child, {double width = 960}) => MaterialApp( home: Scaffold( backgroundColor: Colors.black, @@ -143,56 +144,30 @@ void main() { MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'), ]; - testWidgets('audio menu: count badge, labels, current checked, pick', (tester) async { - TrackPick? got; - await tester.pumpWidget(host(Row(children: [ - TrackMenuButton(type: JellyfinStreamType.audio, tracks: tracks, onPick: (p) => got = p), - TrackMenuButton(type: JellyfinStreamType.subtitle, tracks: tracks, onPick: (p) => got = p), - ]))); - 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>(find.byType(CheckedPopupMenuItem)); + test('audio list: counts, labels, mpv\'s selection checked', () { + expect(trackCount(tracks, JellyfinStreamType.audio), 2); + expect(trackCount(tracks, JellyfinStreamType.subtitle), 1); + final items = trackMenuItems(JellyfinStreamType.audio, tracks); + expect(items.map((i) => i.label), ['Japanese · E-AC3 2.0', 'English · AAC 5.1 · Dub']); expect(items.map((i) => i.checked), [true, false]); - expect(find.text('Japanese · E-AC3 2.0'), findsOneWidget); - await tester.tap(item('English · AAC 5.1 · Dub')); - await tester.pumpAndSettle(); - expect(got, (aid: 2, sid: null, subtitlesOff: false)); + expect(items[1].value, (aid: 2, sid: null, subtitlesOff: false)); + expect(trackMenuItems(JellyfinStreamType.audio, const []).single.enabled, isFalse, reason: 'None'); }); - testWidgets('subtitle menu has Off, checked when none is selected', (tester) async { - TrackPick? got; - await tester.pumpWidget(host(TrackMenuButton( - 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>(find.byType(CheckedPopupMenuItem)); + test('subtitle list starts with Off, checked when none is selected', () { + final items = trackMenuItems(JellyfinStreamType.subtitle, tracks); + expect(items.map((i) => i.label), ['Off', 'English · ASS · Full']); expect(items.map((i) => i.checked), [true, false]); - await tester.tap(item('Off')); - await tester.pumpAndSettle(); - expect(got, (aid: null, sid: null, subtitlesOff: true)); + expect(items.first.value, (aid: null, sid: null, subtitlesOff: true)); + expect(items.last.value, (aid: null, sid: 1, subtitlesOff: false)); }); - testWidgets('chapter menu: current checked, a pick seeks to its start', (tester) async { - MediaChapter? got; - 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>( - find.byType(CheckedPopupMenuItem)); + test('chapter list: start times, the current one checked', () { + final items = chapterMenuItems(chapters, 2); expect(items.map((i) => i.checked), [false, false, true, false, false]); - expect(find.text('20:00'), findsOneWidget); - await tester.tap(item('ED')); - await tester.pumpAndSettle(); - 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); + expect(items[3].label, 'ED'); + expect(items[3].hint, '20:00'); + expect(items[3].value.start, m(20)); }); }); } diff --git a/apps/desktop/test/player_settings_test.dart b/apps/desktop/test/player_settings_test.dart index 86bc3a2..462316a 100644 --- a/apps/desktop/test/player_settings_test.dart +++ b/apps/desktop/test/player_settings_test.dart @@ -1,13 +1,12 @@ import 'dart:io'; -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.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/uosc/controls.dart'; import 'package:watch_together/src/jellyfin/track_prefs.dart'; import 'package:watch_together/src/schedule/animeflow.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/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. Widget app(Widget child) => MaterialApp(theme: ThemeData.dark(), home: Scaffold(body: child)); @@ -41,7 +40,11 @@ void main() { final coord = SessionState.instance.coordinator; 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.shaderPreset.value = ShaderPreset.off; s.shaderQuality.value = ShaderQuality.hq; @@ -53,15 +56,23 @@ void main() { SessionState.instance.recents.value = const []; }); - group('⚙ player settings', () { - testWidgets('standard controls: the gear opens every player setting, changes apply live', (tester) async { + Widget opener() => Builder( + 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); coord.playback.tracks.value = const [ 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'), ]; - await tester.pumpWidget(app(const Align(alignment: Alignment.bottomRight, child: PlayerSettingsButton()))); - await tester.tap(find.byKey(const ValueKey('player-settings'))); + await tester.pumpWidget(app(Align(alignment: Alignment.bottomRight, child: opener()))); + await tester.tap(find.byKey(const ValueKey('open-settings'))); await tester.pumpAndSettle(); final card = find.byKey(const ValueKey('player-settings-card')); expect(card, findsOneWidget); @@ -81,8 +92,8 @@ void main() { expect(find.descendant(of: find.byKey(const ValueKey('audio-track')), matching: find.textContaining('Japanese')), findsOneWidget); - await pick(tester, 'player-design', 'uosc'); - expect(s.mpvControls.value, isTrue); + await pick(tester, 'player-design', 'Minimal'); + expect(s.controlsSkin.value, ControlsSkin.minimal); await tester.tap(find.byKey(const ValueKey('progress-line'))); await tester.pump(); expect(s.progressLine.value, isFalse); @@ -98,19 +109,17 @@ void main() { 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); - s.mpvControls.value = true; - await tester.pumpWidget(app(UoscControls( + await tester.pumpWidget(app(ControlsFrame( hideAfter: const Duration(seconds: 1), - timeline: (context, expanded) => const SizedBox(height: 30), - right: const [UoscPlayerSettingsButton()], + builder: (context, visible) => Align(alignment: Alignment.bottomRight, child: opener()), ))); - final controls = tester.state(find.byType(UoscControls)); - await tester.tap(find.byKey(const ValueKey('uosc-player-settings'))); + final controls = tester.state(find.byType(ControlsFrame)); + await tester.tap(find.byKey(const ValueKey('open-settings'))); await tester.pumpAndSettle(); 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)); expect(controls.visible, isTrue, reason: 'held while the card is open'); await tester.sendKeyEvent(LogicalKeyboardKey.escape); @@ -118,49 +127,6 @@ void main() { await tester.pump(const Duration(seconds: 2)); 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(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', () { @@ -195,16 +161,37 @@ void main() { expect(find.byIcon(Icons.hub_outlined), findsOneWidget); }); - testWidgets('Player: the same rows as the ⚙, plus a pointer to it', (tester) async { - await pumpSettings(tester, SettingsSection.player); - expect(find.textContaining("also in the player's ⚙"), findsOneWidget); - for (final key in ['player-design', 'progress-line', 'shader-preset', 'hwdec']) { + testWidgets('Player: the card\'s rows, a pointer to it, and the controls\' choices', (tester) async { + await pumpSettings(tester, SettingsSection.player, size: const Size(1280, 2400)); + expect(find.textContaining('also in the player: ⚙ → Player settings'), findsOneWidget); + 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(const ValueKey('audio-track')), findsNothing, reason: 'tracks only in the player'); expect(find.text('Standard'), findsOneWidget); - await pick(tester, 'player-design', 'uosc'); - expect(s.mpvControls.value, isTrue); + await pick(tester, 'player-design', 'Minimal'); + 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 { diff --git a/apps/desktop/test/player_status_test.dart b/apps/desktop/test/player_status_test.dart index a42ed7b..db021e5 100644 --- a/apps/desktop/test/player_status_test.dart +++ b/apps/desktop/test/player_status_test.dart @@ -3,9 +3,9 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sync_relay_core/core.dart'; -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/timeline.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/session/health.dart'; import 'package:watch_together/src/session/now_playing.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); }); - 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; await sized( tester, size, Stack(fit: StackFit.expand, children: [ StartCard(now: long), - UoscControls( - topLeft: NowPlayingBlock(now: long), - top: Row(mainAxisSize: MainAxisSize.min, children: [ - for (final i in [Icons.chat_bubble_outline, Icons.fullscreen, Icons.close]) - UoscButton(icon: i, tooltip: '', onPressed: () {}), + ControlsFrame( + builder: (context, visible) => Stack(children: [ + Positioned( + left: 16, + 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: (_) {}), ), ]), ); diff --git a/apps/desktop/test/uosc_controls_test.dart b/apps/desktop/test/uosc_controls_test.dart deleted file mode 100644 index 334b847..0000000 --- a/apps/desktop/test/uosc_controls_test.dart +++ /dev/null @@ -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(find.descendant(of: find.byKey(ValueKey(key)), matching: find.byType(Text))) - .data!; - -UoscTimelinePainter painter(WidgetTester tester) => tester - .widget(find.descendant( - of: find.byKey(const ValueKey('uosc-timeline')), matching: find.byType(CustomPaint))) - .painter! as UoscTimelinePainter; - -Future 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 = []; - 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 = []; - 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 = []; - 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 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(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), 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), 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(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(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(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(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 = []; - 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 = []; - 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 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(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(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 = []; - 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>(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>(design).value, isTrue); - }); -} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fc6b3c3..428c80b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -311,7 +311,7 @@ through the same dynamic `setProperty` and read back (`:` joined, `;` on Windows). One saved choice in `AppSettings` (`shaderPreset`, `shaderQuality`, `customShaders`): applied on every player 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 the presets use, plus its MIT LICENSE); mpv needs real paths, so `extractAnime4k` copies them once per version to @@ -400,7 +400,7 @@ Skip rules (pure, `media_marks.dart`): decides). Without any opening, without chapters (a chaptered file without an OP chapter has none on purpose) and with media over 20 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. - Chapter steps (PgUp / PgDn, `chapterStepTarget`): forward to the next start; back to the current start, or the previous one within 5 s of it @@ -863,10 +863,11 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`, - **Episodes**: with `OwnPlay` on an episode, `adjacentEpisodes` (one `/Shows/{id}/Episodes` call; `episodeBefore` / `episodeAfter`) gives the 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 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 (relay, blocked) see none of it. - **Auto-advance** (Settings, on by default): at `finished` with a known @@ -881,15 +882,15 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`, and with it every countdown. Only while Up next is empty and its continuation was not cleared (see "Up next"); a queued item replaces a countdown already running. -- **Tracks**: the controls' Audio and Subtitles buttons list - `PlaybackController.tracks`. A pick is applied, reported, and saved as a `TrackChoice` +- **Tracks**: the controls' Audio and Subtitles buttons (or ⚙ when + 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 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 matched with `matchTrack` against the source's streams (mapped to mpv ids with `mpvTrackId`) or mpv's track list, and selected locally. Nothing goes 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 through `ServerIsolate.setFile` (host) or the relay (`swapFile`). @@ -1015,8 +1016,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 next-episode countdown from starting. - **UI**: `UpNextButton` is one of the player's top-bar controls, before - chat (see "Player overlay" under Desktop UI), in the look of the - design's buttons (`UpNextLook`); it shows and hides with the controls, + the window controls (see "Player overlay" under Desktop UI), in the look + 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`, 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 @@ -1085,9 +1086,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 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 -menu. Material's own menus (the standard controls' track, chapter and -shader menus, the Jellyfin sort and server switch) get the same surface -from `AppMenuStyle.theme`; the uosc controls keep their own flat menus. +menu; the player controls' track, chapter and ⚙ menus are on it too. +Material's own menus (the shader menu, the Jellyfin sort and server +switch) get the same surface from `AppMenuStyle.theme`. Reusing what the app shows (`widgets/copy_open.dart`): one wording (`ReuseText`: Copy link, Copy title, Copy message, Open in browser, Open in Jellyfin) in every right-click menu that offers it, and a toast @@ -1232,7 +1233,8 @@ panel) and chat messages (Copy message, and a link in it). (icon chips under 760 px) over pages of rounded Material cards (`SettingsPanel`), one help line under every setting: profile, party & 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 `AnimeFlowClient.fromAccounts`, auto-advance), notifications & sounds (what pops up; no switches), updates, shortcuts, about. @@ -1289,7 +1291,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 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 - 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 `coordinator.nowPlaying`, art through `artBytes`; it fades through when the media changes); the mini shows title and episode without the cover. @@ -1313,28 +1315,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 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 - between kinds. The mini shows its short line in its hover bar. ⚙ in both - control designs opens the player settings card (`controls/ + between kinds. The mini shows its short line in its hover bar. ⚙ → + Player settings (both skins) opens the player settings card (`controls/ player_settings.dart`, `showPopover`, on `AppGlass`): player design, progress line when hidden, upscaling shader, hardware decoding (set live on mpv's `hwdec`), subtitle and audio track. The rows (`playerSettingRows`) are - the same widgets Settings → Player shows; all apply live. The progress - line (2 px, `AppSettings.progressLine`) is the collapsed uosc timeline; - for the standard design `ControlsHoverProbe` mirrors media_kit's - visibility rule (up on mount and mouse movement, down 3 s later or on - exit), as media_kit does not expose it. Fullscreen adds the + the same widgets Settings → Player shows; all apply live. Settings → + Player also has the controls' own choices (`controlsSettingRows`, see + "Player controls"). The progress line (`AppSettings.progressLine`) is + what the bottom controls leave while hidden: Standard a 2 px line, + Minimal its collapsed timeline. Fullscreen adds the `WindowButtons` to the top bar while the controls are up (restore 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 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 - button (`UpNextButton`), chat, back-to-library or exit fullscreen, - then the window buttons in fullscreen, all in that design's button + button (`UpNextButton`), back-to-library or exit fullscreen, + 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 avatars; with the controls down and unread chat, the unread chip top 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`, 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 @@ -1349,17 +1352,15 @@ panel) and chat messages (Copy message, and a link in it). tall like the status pill without avatars). The mouse on any floating element counts as the mouse on the controls: a `ControlsHold` (`controls/controls_probe.dart`) held by - `ControlsHoldRegion`s (and by the open Up next popover or its preview) keeps either - design's controls up (`UoscControls.holdBy`; `ControlsHoverProbe.hold`, - which re-wakes media_kit's controls through their private hover - handler, the same guarded dynamic call as `keepVideoControlsAwake`), - and lets them hide on their usual countdown once released. + `ControlsHoldRegion`s (and by the open Up next popover or its preview) keeps the + controls up (`ControlsFrame.holdBy`), and lets them hide on their + usual countdown once released. - **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 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 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 (`PlayerGlass`) top right shows it (while it plays nothing sits on the picture). @@ -1429,78 +1430,98 @@ Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client): video" opens the library on the series of the episode playing, its season selected and the episode highlighted. -Session controls (the Standard player design): media_kit's -`MaterialDesktopVideoControls` with our bars (`shell/session_player.dart`, pieces in -`screens/session/player_controls.dart`); its own double-click and keys are -off (its double-click pushes a fullscreen route with a second `Video`), the -picture's double-click is a raw pointer observer (`DoubleClickListener`). -The top bar holds the now-playing title, Up next, Change video, chat and back-to-library -(fullscreen: exit, then the window buttons); the bottom bar ends with the -shader button, ⚙ and fullscreen. Its seek bar is off; ours -(`ChapterSeekBar`: chapter diamonds, hover time with the chapter title) -sits in the bottom bar, which is taller by the bar's height (the top bar -keeps its row at the top). A drag sends no hover events, so the bar keeps -the controls up through a guarded dynamic `onHover()` on media_kit's -private controls state (`keepVideoControlsAwake`). Pills (Skip OP / ED / -1:30, Next episode) and cards live outside the auto-hiding controls, in -the action area ("Player overlay"). The -cards stay up; a pill (`PillGate`) shows only while the controls are up -(`ControlsHoverProbe` for this design, `UoscControls.shown` for uosc) 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 (`controls/volume.dart`, both -designs): 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 -buttons themselves take it first. A wheel or key step flashes the -design's readout for 1 s (`VolumeFlash`): uosc's vertical volume slider -at the right edge (`controls/uosc/volume_flash.dart`, after uosc's -`Volume.lua`), else a pill under the top bar (`VolumeReadout`). - -The uosc player design (`controls/uosc/`, after mpv's uosc theme; -"Player design" = `AppSettings.mpvControls`, default Standard): the `Video`'s -controls builder wraps both sets in `SessionControlsSwitch`, which follows -the setting live. `UoscControls` is our -own frame (no media_kit controls state): mouse movement shows it, 3 s -without any hides it and the cursor; `hold` keeps it up during a timeline -drag and while a menu route is open, double-click toggles fullscreen. -`UoscTimeline` ports uosc's `Timeline.lua` (style `line`, uosc's defaults -at scale 1, `UoscTimelineSpec`) across the full width at the bottom: a -40 px band (plus a 1 px top border) of black at 0.9; the position is a -2 px white line, no filled part, and times map around it -(`UoscTimelineGeometry`, uosc's `t2x` / `get_time_at_x`); the uncached -part (from `buffered`, or the position when that is behind, to the end) -carries uosc's stripe texture (white then black "/" stripes, 0.2 / 0.4 -strong); chapter diamonds sit on the top edge (white 0.8, 1 px black -border, doubled and clickable to jump while the mouse is within twice -their radius); elapsed time left and the time remaining ("-01:47:30") -right in 20 px outlined text, inverted where it crosses the line; while -less than 60 s is buffered ahead (and the cache does not reach the end) -a small "12s" rides right of the line. Hover: a faint 1 px line and a -black tooltip with the time (the chapter title, bold, above it). A press -seeks, every drag move seeks, and after release the line stays at the -target until the position catches up (at most 2 s). Hidden, it animates -down (100 ms) to uosc's progress line: 2 px with a 20 px position -marker, chapters as small diamonds (faded out when the progress line -setting is off). We know only the end of mpv's cache, so a dropped back -buffer is not hatched; uosc's pause while dragging, keyframe seeks for -fast drags, thumbnails and the fullscreen scale (1.3) are left out. Above it a row of flat buttons: menu (≡: -change video, chat & activity, reload, mark everyone ready, fullscreen, -leave), -subtitles and audio with track counts, a shader badge ("A HQ", opening the -shared `showShaderMenu`), ready, mark everyone ready, reload; 1:30, -chapters, previous / next episode, change video, volume, ⚙, fullscreen; the -now-playing title top left (`UoscControls.topLeft`, with a top gradient), Up next, chat -and back-to-library (fullscreen: exit) top right, centred on the title. Menus (`showUoscMenu`: a popup route above the button, the -current row highlighted) list tracks and chapters with start times. All -actions are the coordinator's (seeks through `player.seek`, the user path; -`selectTrack`, `skipForward`, `seekTo`); keys and the floating pills are -the same in both sets. +Player controls (`lib/src/controls/`): one controls model, two skins. +- **Model** (`model/`): `PlayerControlsModel` (built by `SessionPlayer`, + wired to the coordinator once) holds what the controls show, as + signals (`play`: `PlayState` playing / waiting (you are ready, the + others not) / paused / finished, from `state.transport` and your ready + flag; position, duration, buffered, chapters, current chapter, tracks, + volume, skip offer, render mode; the shell's fullscreen, chat open and + unread count) and what they do (`togglePlay` = ready, as Space; + `seek` through `player.seek`, the user path; `nudge`, `skipForward`, + `skip`, `stepChapter`, `pickChapter`; `next` = Up next's first item, + else the next episode, `previous`; volume set / step (flashes) / mute; + `pickTrack`, `pickShader`; time display; fullscreen, chat, change + video, collapse, leave, reload, mark everyone ready, link). The + player keys call the same actions. `ControlsSnapshot` (computed) is + what the layout depends on. +- **Layout spec** (`model/controls_layout.dart`, pure): `ControlsLayout` + is the bar's order, left cluster play / pause, previous / next episode, + previous / next chapter, volume, time; right cluster subtitles, audio, + ⚙, chat, fullscreen. `barControls` drops what does not apply (no + episode, no chapters) or what the user hid (`OptionalControl`: + chapter buttons, subtitles, audio, chat); the rest keep their order, so + play is always first and fullscreen last. `menuActions` is the ⚙ menu + (`ControlAction`, fixed order): chapters, the hidden track lists, + Skip 1:30, upscaling shader, the hidden chat, change video, copy link + (when `currentLink` has one), reload, mark everyone ready (not solo), + mark a problem, player settings, leave. Icons, tooltips (with keys) and + the clock text (`clockText`) are defined there too, once. +- **Shared pieces**: `ControlsFrame` (`frame.dart`): when the controls + are up (mouse movement shows them, `controlsHideSecs` without any or the + mouse leaving hides them and the cursor), holds (`hold` during a seek + drag, `holdDuring` / `holdWhile` around menus and popovers, `holdBy` + the session's `ControlsHold`), and the double-click on the picture + (`DoubleClickListener`, not on the bars) that toggles fullscreen; it + reports `shown` for the overlay. `control_menus.dart`: the track and + chapter lists and the ⚙ menu on `showAppMenu` (anchored on the button, + holding the controls), and `runControlAction`. `volume.dart`: + `VolumeControl` (the skin's speaker button, a `VolumeBar` sliding out + on hover, the wheel on it stepping), `VolumeReadout`. `SessionControls` + (`session_controls.dart`) puts the skin the settings pick into one + frame, live, and gives the overlay the bars' heights. +- **Standard skin** (`standard/`): Material-style. A top gradient with the + top bar (72 px); at the bottom a gradient with `ChapterSeekBar` (played + and buffered track in the theme's primary, a diamond at each chapter + start, hover tip with the time and the chapter title, a press holds the + frame and every drag move seeks) over a 56 px row of round white icon + buttons in the layout's order (badges for track counts and unread + chat, the clock as a text button). Hidden, a 2 px progress line. +- **Minimal skin** (`minimal/`, inspired by uosc, github.com/tomasklaen/uosc, + LGPL-2.1; the drawing and values are our own): the buttons sit in two + glass capsules (`MinimalCapsule`, the pills' tint and hairline; + `MinimalButton` rounded squares, the play button a filled accent + circle) above `MinimalTimeline`, a 36 px band across the bottom: the + played part tinted in the theme's primary, the position a white line, + the part not downloaded yet (from `buffered`, or the position when + that is behind, to the end) in thin stripes, chapter starts as dots on + the top edge with a dark cut through the band (a press within reach of + a dot jumps to it), elapsed time left and the total or the time left + right (the bar's clock lives here; a press on it switches), "12s" by + the position while under a minute is buffered ahead (and the cache does + not reach the end). Hover: a faint line and a glass tip with the time + (the chapter title above it). A press seeks, every drag move seeks, + after release the line stays at the target until the position catches + up (at most 2 s). Hidden, it shrinks (120 ms) to a 3 px progress line. + We know only the end of mpv's cache, so a dropped back buffer is not + shown. +- **Settings** (`AppSettings`, `model/controls_prefs.dart`, all live): + `controlsSkin` (`ControlsSkin` standard / minimal; a stored + `mpvControls: true` from before becomes minimal), `timeDisplay`, + `controlsSize` (normal / large: the bottom bar ×1.25), + `controlsHideSecs` (2 / 3 / 5), `hiddenControls`, `progressLine`. + The player settings card has the skin and the progress line; Settings → + 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 area and holds the min/max/close buttons (`widgets/window_buttons.dart`);