diff --git a/README.md b/README.md index 4e5abfb..d9107fe 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,9 @@ x to forget it. Ctrl+1 / Ctrl+2 switch between Join and Host. Profile, hardware decoding (auto, off or custom; the env var `SYNC_RELAY_HWDEC` overrides it; applies on the next join or Reload video), -updates, playing the next Jellyfin episode automatically, video shaders -(see below), and the keyboard shortcuts (read-only). +"Experimental: mpv-style controls" (below), updates, playing the next +Jellyfin episode automatically, video shaders (see below), and the +keyboard shortcuts (read-only). Integrations links Jellyfin servers (below). About shows the version. Keys in a session: Space = ready / unready, Tab = side panel (people, log, @@ -101,6 +102,16 @@ shows the time and the chapter title. The chapters button lists them Subtitles buttons (with the track count) list tracks as "Japanese · E-AC3 2.0 · title"; subtitles have Off. +Experimental mpv-style controls (Settings → Player, off by default, +switches right away): video controls modeled on mpv's uosc theme. A thick +timeline across the bottom shows the played part, the cached part ahead +(hatched), chapter starts as diamonds and the time; hovering it shows the +chapter title and the time there. With the controls hidden it stays as a +thin line. 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, people, +volume and fullscreen. Keys and skip buttons work the same. + Skipping: during the opening a "Skip OP" button floats in the corner (it jumps to the end of the opening), during the ending "Skip ED" (to the end of the ending only; what comes after is yours to skip). The opening and diff --git a/apps/desktop/lib/src/controls/uosc/buttons.dart b/apps/desktop/lib/src/controls/uosc/buttons.dart new file mode 100644 index 0000000..ce83abc --- /dev/null +++ b/apps/desktop/lib/src/controls/uosc/buttons.dart @@ -0,0 +1,334 @@ +import 'package:flutter/gestures.dart'; +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 '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, the wheel steps 5 %. +class UoscVolumeButton extends StatefulWidget { + /// 0..100. + final double volume; + final ValueChanged onChanged; + const UoscVolumeButton({super.key, required this.volume, required this.onChanged}); + + @override + State createState() => _UoscVolumeButtonState(); +} + +class _UoscVolumeButtonState extends State { + double _beforeMute = 100; + + @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 Listener( + onPointerSignal: (e) { + if (e is PointerScrollEvent && e.scrollDelta.dy != 0) { + widget.onChanged((v + (e.scrollDelta.dy < 0 ? 5 : -5)).clamp(0.0, 100.0)); + } + }, + child: UoscButton( + key: const ValueKey('uosc-volume'), + icon: icon, + 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 new file mode 100644 index 0000000..4ae467a --- /dev/null +++ b/apps/desktop/lib/src/controls/uosc/controls.dart @@ -0,0 +1,196 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:signals/signals_flutter.dart'; + +import '../../state/settings.dart'; + +// The experimental mpv-style (uosc) controls frame: a full-width timeline +// at the bottom with a row of flat buttons above it, window buttons top +// right. Mouse movement shows them; after [UoscControls.hideAfter] without +// any they fade out, the timeline shrinks to a thin line and the cursor +// hides. Everything lives here because media_kit's fullscreen route +// renders only the video and its controls. + +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 (window buttons), shown with the controls. + final Widget? top; + final VoidCallback? onDoubleTap; + final Duration hideAfter; + const UoscControls({ + super.key, + required this.timeline, + this.left = const [], + this.right = const [], + this.top, + this.onDoubleTap, + this.hideAfter = const Duration(seconds: 3), + }); + + static const buttonRowHeight = 40.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; + Timer? _timer; + + bool get visible => _visible; + + @override + void initState() { + super.initState(); + _hideLater(); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + /// Show the controls and restart the hide countdown. + void wake() { + if (!_visible && mounted) setState(() => _visible = 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); + } + + @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.top != null) + Positioned( + top: 0, + right: 0, + child: IgnorePointer( + ignoring: !visible, + child: AnimatedOpacity(duration: fade, opacity: visible ? 1 : 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, 4), + child: SizedBox( + height: UoscControls.buttonRowHeight, + child: Row(children: [...widget.left, const Spacer(), ...widget.right]), + ), + ), + ), + ), + widget.timeline(context, visible), + ], + ), + ), + ], + ), + ); + } +} + +/// The session's controls: media_kit's ([material]) or, with Settings → +/// "Experimental: mpv-style controls", 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 new file mode 100644 index 0000000..9ee15df --- /dev/null +++ b/apps/desktop/lib/src/controls/uosc/menu.dart @@ -0,0 +1,249 @@ +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 new file mode 100644 index 0000000..906927d --- /dev/null +++ b/apps/desktop/lib/src/controls/uosc/style.dart @@ -0,0 +1,79 @@ +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 { + /// Timeline background and the boxes on it. + static const bar = Color(0xCC000000); + /// Played part of the timeline; also 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 times, the +/// hover 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 new file mode 100644 index 0000000..19856a1 --- /dev/null +++ b/apps/desktop/lib/src/controls/uosc/timeline.dart @@ -0,0 +1,287 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../playback/media_marks.dart'; +import 'controls.dart'; +import 'style.dart'; + +/// 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); +} + +/// The cached-ahead part of a timeline of [size]: from the playing +/// position to the end of mpv's cache. Null when nothing is cached ahead. +Rect? cacheRect(Size size, {required Duration position, required Duration buffered, required Duration duration}) { + final from = timelineFraction(position, duration) * size.width; + final to = timelineFraction(buffered, duration) * size.width; + if (to - from < 0.5) return null; + return Rect.fromLTRB(from, 0, to, size.height); +} + +/// The hatching of [r]: 45° strokes from bottom-left to top-right, +/// [spacing] apart along x, across all of [r] (the painter clips them). +/// The first starts half a step in, so none just grazes the corner. +List<(Offset, Offset)> hatchLines(Rect r, {double spacing = 6}) { + if (r.isEmpty || spacing <= 0) return const []; + final h = r.height; + return [ + for (var x = r.left - h + spacing / 2; x < r.right; x += spacing) (Offset(x, r.bottom), Offset(x + h, r.top)), + ]; +} + +/// The uosc timeline: full width, elapsed part filled, the cached range +/// ahead hatched, chapter starts as white diamonds on the top edge, the +/// current time in a box at the left and the duration at the right. +/// Hover shows the chapter title over the hover time. When the controls +/// hide ([expanded] false) it shrinks to a thin line with chapter ticks. +/// Like the media_kit bar it reports a seek on every drag move and on +/// release ([onSeek]); the session sends those through the user-seek path. +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 = 30.0; + static const collapsedHeight = 3.0; + + @override + State createState() => _UoscTimelineState(); +} + +class _UoscTimelineState extends State { + double? _hoverX; + bool _dragging = false; + double _dragFraction = 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)); + } + + void _drag(bool on) { + if (_dragging == on) return; + setState(() => _dragging = on); + UoscControls.maybeOf(context)?.hold(on); + } + + @override + void didUpdateWidget(UoscTimeline old) { + super.didUpdateWidget(old); + if (!widget.expanded && old.expanded) _hoverX = null; + } + + @override + void dispose() { + // A drag cut short by a rebuild must not keep the controls up. + if (_dragging) UoscControls.maybeOf(context)?.hold(false); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (context, constraints) { + final width = constraints.maxWidth; + final expanded = widget.expanded; + final h = expanded ? UoscTimeline.height : UoscTimeline.collapsedHeight; + final played = _dragging ? _dragFraction : timelineFraction(widget.position, widget.duration); + final hoverX = expanded ? _hoverX : null; + 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; + final withHours = widget.duration >= const Duration(hours: 1); + final marks = [ + for (final c in widget.chapters) + if (c.start > Duration.zero && widget.duration > Duration.zero) c, + ]; + final bar = SizedBox( + key: const ValueKey('uosc-timeline'), + width: width, + height: h, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: CustomPaint( + painter: UoscTimelinePainter( + played: played, + cache: (size) => cacheRect(size, + position: widget.position, buffered: widget.buffered, duration: widget.duration), + cacheKey: (widget.position, widget.buffered, widget.duration), + hoverX: _dragging ? null : hoverX, + hatched: expanded, + ), + ), + ), + for (final c in marks) + if (expanded) + Positioned( + key: ValueKey('uosc-chapter-${c.index}'), + left: width * timelineFraction(c.start, widget.duration) - 4, + top: -4, + width: 8, + height: 8, + child: IgnorePointer( + child: Transform.rotate( + angle: math.pi / 4, + child: Container( + margin: const EdgeInsets.all(1.2), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.black54, width: 0.6), + ), + ), + ), + ), + ) + else + Positioned( + key: ValueKey('uosc-chapter-tick-${c.index}'), + left: width * timelineFraction(c.start, widget.duration) - 1, + top: -2, + width: 2, + height: h + 2, + child: const IgnorePointer(child: ColoredBox(color: Colors.white70)), + ), + if (expanded) ...[ + Positioned( + left: 4, + top: 0, + bottom: 0, + child: IgnorePointer( + child: Center( + child: UoscBox(formatUoscTime(_at(played), withHours: withHours), + key: const ValueKey('uosc-time'), color: UoscStyle.bar), + ), + ), + ), + Positioned( + right: 4, + top: 0, + bottom: 0, + child: IgnorePointer( + child: Center( + child: UoscBox(formatUoscTime(widget.duration, withHours: withHours), + key: const ValueKey('uosc-duration'), color: UoscStyle.bar), + ), + ), + ), + ], + if (hoverAt != null) + Positioned( + // Kept clear of the window edges (the tip is centred). + left: hoverX!.clamp(math.min(60.0, width / 2), math.max(width - 60, width / 2)), + bottom: h + 8, + child: IgnorePointer( + child: FractionalTranslation( + translation: const Offset(-0.5, 0), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + if (hoverTitle.isNotEmpty) UoscBox(hoverTitle, key: const ValueKey('uosc-hover-chapter')), + UoscBox(formatUoscTime(hoverAt, withHours: withHours), + key: const ValueKey('uosc-hover-time')), + ], + ), + ), + ), + ), + ], + ), + ); + if (!expanded) return IgnorePointer(child: bar); + 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: bar, + ), + ); + }); + } +} + +/// Background, cached range (hatched, or a faint band when collapsed), +/// the played part and the hover line. +class UoscTimelinePainter extends CustomPainter { + final double played; + final Rect? Function(Size size) cache; + /// What [cache] depends on (repaint when it changes). + final Object cacheKey; + final double? hoverX; + final bool hatched; + const UoscTimelinePainter({ + required this.played, + required this.cache, + required this.cacheKey, + this.hoverX, + this.hatched = true, + }); + + @override + void paint(Canvas canvas, Size size) { + final all = Offset.zero & size; + canvas.drawRect(all, Paint()..color = UoscStyle.bar); + final c = cache(size); + if (c != null) { + if (hatched) { + canvas.save(); + canvas.clipRect(c); + final stroke = Paint() + ..color = const Color(0x40FFFFFF) + ..strokeWidth = 1.5; + for (final (a, b) in hatchLines(c)) { + canvas.drawLine(a, b, stroke); + } + canvas.restore(); + } else { + canvas.drawRect(c, Paint()..color = const Color(0x40FFFFFF)); + } + } + canvas.drawRect(Rect.fromLTWH(0, 0, size.width * played, size.height), Paint()..color = UoscStyle.fill); + final x = hoverX; + if (x != null) { + canvas.drawRect(Rect.fromLTWH(x - 0.5, 0, 1, size.height), Paint()..color = Colors.white70); + } + } + + @override + bool shouldRepaint(UoscTimelinePainter old) => + old.played != played || old.cacheKey != cacheKey || old.hoverX != hoverX || old.hatched != hatched; +} diff --git a/apps/desktop/lib/src/playback/playback_controller.dart b/apps/desktop/lib/src/playback/playback_controller.dart index c2fd45f..9c50ea7 100644 --- a/apps/desktop/lib/src/playback/playback_controller.dart +++ b/apps/desktop/lib/src/playback/playback_controller.dart @@ -101,6 +101,10 @@ class PlaybackController { /// Duration of the open file (zero until known). final duration = signal(Duration.zero); + /// End of the cached range ahead of the position (mpv + /// `demuxer-cache-time`, media_kit's `buffer`), in 250 ms steps. + final buffered = signal(Duration.zero); + /// The opening ([openingRange]: Intro segment, else an OP-titled /// chapter) and the ending ([endingRange]: Outro, else ED-titled). late final Computed opening = @@ -409,6 +413,7 @@ class PlaybackController { _chapterJson = ''; chapters.value = const []; tracks.value = const []; + buffered.value = Duration.zero; // An empty map, not null: media_kit caches headers per URI. await player.open(Media(url, httpHeaders: headers ?? const {}), play: false); } catch (e, st) { @@ -424,6 +429,10 @@ class PlaybackController { final ms = d.inMilliseconds; position.value = Duration(milliseconds: ms - ms % 250); })); + _timeSubs.add(p.stream.buffer.listen((d) { + final ms = d.inMilliseconds; + buffered.value = Duration(milliseconds: ms - ms % 250); + })); _timeSubs.add(p.stream.duration.listen((d) { duration.value = d; // The last chapter ends at the duration. @@ -469,6 +478,7 @@ class PlaybackController { tracks.value = const []; position.value = Duration.zero; duration.value = Duration.zero; + buffered.value = Duration.zero; } /// Waits (bounded) until the open file's tracks are listed and mpv has diff --git a/apps/desktop/lib/src/screens/home/settings_page.dart b/apps/desktop/lib/src/screens/home/settings_page.dart index 36be894..753bc2a 100644 --- a/apps/desktop/lib/src/screens/home/settings_page.dart +++ b/apps/desktop/lib/src/screens/home/settings_page.dart @@ -37,7 +37,13 @@ class SettingsPage extends StatelessWidget { children: [ const PageTitle('Settings'), const _Section(title: 'Profile', child: ProfileEditor()), - const _Section(title: 'Player', child: _HwdecSetting()), + const _Section( + title: 'Player', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [_HwdecSetting(), SizedBox(height: 12), _MpvControlsSetting()], + ), + ), const _Section(title: 'Jellyfin', child: _AutoAdvanceSetting()), _Section(title: 'Updates', child: _UpdateSettings(controller: updates)), const _Section(title: 'Keyboard shortcuts', child: _ShortcutList()), @@ -295,6 +301,26 @@ class _UpdateSettings extends StatelessWidget { } } +class _MpvControlsSetting extends StatelessWidget { + const _MpvControlsSetting(); + + @override + Widget build(BuildContext context) { + final s = AppSettings.instance; + return SignalBuilder( + builder: (context) => SwitchListTile( + key: const ValueKey('mpv-controls'), + contentPadding: EdgeInsets.zero, + title: const Text('Experimental: mpv-style controls'), + subtitle: const Text('Video controls modeled on mpv\'s uosc theme: a thick timeline with ' + 'chapters and the cached range, flat buttons and menus. Applies right away.'), + value: s.mpvControls.value, + onChanged: s.setMpvControls, + ), + ); + } +} + class _AutoAdvanceSetting extends StatelessWidget { const _AutoAdvanceSetting(); diff --git a/apps/desktop/lib/src/screens/session_screen.dart b/apps/desktop/lib/src/screens/session_screen.dart index 58713dc..84b05ca 100644 --- a/apps/desktop/lib/src/screens/session_screen.dart +++ b/apps/desktop/lib/src/screens/session_screen.dart @@ -4,6 +4,9 @@ import 'package:media_kit_video/media_kit_video.dart'; import 'package:signals/signals_flutter.dart'; import 'package:sync_relay_core/core.dart'; +import '../controls/uosc/buttons.dart'; +import '../controls/uosc/controls.dart'; +import '../controls/uosc/timeline.dart'; import '../playback/media_marks.dart' show SkipOffer; import '../jellyfin/browse_scope.dart' show BrowseStart; @@ -496,7 +499,11 @@ class _SessionScreenState extends State { children: [ // Frozen-picture watchdog samples the route around this anchor. _frameProbe.anchor(), - MaterialDesktopVideoControls(state), + // Settings → "Experimental: mpv-style controls" swaps them live. + SessionControlsSwitch( + material: (_) => MaterialDesktopVideoControls(state), + mpv: (_) => _mpvControls(), + ), _healthOverlay(), _waitingOverlay(), _jellyfinOverlay(), @@ -523,6 +530,133 @@ class _SessionScreenState extends State { ); } + void _openSheet() { + _sheetOpen.value = true; + SessionState.instance.coordinator.markInput(); + } + + /// The experimental uosc-style controls: same actions as the bars above, + /// all through the coordinator (seeks via `player.seek`, the user path). + Widget _mpvControls() { + final coord = SessionState.instance.coordinator; + 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( + onDoubleTap: _toggleFullscreen, + top: SignalBuilder( + builder: (context) => + WindowButtons(focusNode: _noFocus, fullscreen: _isFullscreen.value, onToggleFullscreen: _toggleFullscreen), + ), + 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: _openSwap), + (title: 'People & chat', hint: 'Tab', run: _openSheet), + (title: 'Reload video', hint: 'Ctrl+R', run: _reloadVideo), + (title: 'Mark everyone ready', hint: null, run: coord.forcePlayAll), + (title: 'Fullscreen', hint: 'F', run: _toggleFullscreen), + (title: 'Leave server', hint: 'Esc', run: () => _confirmLeave.value = true), + ], + ), + 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: _openSwap), + UoscButton(icon: Icons.group_outlined, tooltip: 'People & chat (Tab)', onPressed: _openSheet), + 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), + ), + ), + SignalBuilder( + builder: (context) => UoscButton( + icon: _isFullscreen.value ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded, + tooltip: 'Fullscreen (F)', + onPressed: _toggleFullscreen, + ), + ), + ], + ); + } + Widget _healthOverlay() { final coord = SessionState.instance.coordinator; return Positioned( diff --git a/apps/desktop/lib/src/shaders/shader_menu.dart b/apps/desktop/lib/src/shaders/shader_menu.dart index 5beac7f..139d49b 100644 --- a/apps/desktop/lib/src/shaders/shader_menu.dart +++ b/apps/desktop/lib/src/shaders/shader_menu.dart @@ -31,11 +31,7 @@ class ShaderControlButton extends StatelessWidget { return SignalBuilder(builder: (context) { final preset = s.shaderPreset.value; final quality = s.shaderQuality.value; - final short = switch (preset) { - ShaderPreset.off => 'Shaders', - ShaderPreset.custom => 'Custom', - _ => '${preset.label.replaceFirst('Mode ', '')} ${quality.label}', - }; + final short = shaderShortLabel(preset, quality) ?? 'Shaders'; return Tooltip( message: 'Video shaders (Ctrl+1..6, Ctrl+0 off)', child: TextButton.icon( @@ -46,72 +42,88 @@ class ShaderControlButton extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 8), tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), - onPressed: () => _open(context), + 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)), ), ); }); } +} - Future _open(BuildContext context) async { - final s = AppSettings.instance; - final preset = s.shaderPreset.peek(); - final quality = s.shaderQuality.peek(); - final hasCustom = s.customShaders.peek().isNotEmpty; - final button = context.findRenderObject() as RenderBox; - final overlay = - Navigator.of(context).overlay!.context.findRenderObject() as RenderBox; - final position = RelativeRect.fromRect( - Rect.fromPoints( - button.localToGlobal(Offset.zero, ancestor: overlay), - button.localToGlobal(button.size.bottomRight(Offset.zero), - ancestor: overlay), +/// "A HQ", "C+A Fast", "Custom"; null when shaders are off. +String? shaderShortLabel(ShaderPreset preset, ShaderQuality quality) => + switch (preset) { + ShaderPreset.off => null, + ShaderPreset.custom => 'Custom', + _ => '${preset.label.replaceFirst('Mode ', '')} ${quality.label}', + }; + +/// The shader menu, anchored on [context]'s box (the button that opens +/// it): Off, the six Anime4K modes with HQ / Fast, Custom. +Future showShaderMenu( + BuildContext context, { + required ReadonlySignal renderMode, + required void Function(ShaderPreset preset, ShaderQuality? quality) onPick, + ValueChanged? onMenuOpen, +}) async { + final s = AppSettings.instance; + final preset = s.shaderPreset.peek(); + final quality = s.shaderQuality.peek(); + final hasCustom = s.customShaders.peek().isNotEmpty; + final button = context.findRenderObject() as RenderBox; + final overlay = + Navigator.of(context).overlay!.context.findRenderObject() as RenderBox; + final position = RelativeRect.fromRect( + Rect.fromPoints( + button.localToGlobal(Offset.zero, ancestor: overlay), + button.localToGlobal(button.size.bottomRight(Offset.zero), + ancestor: overlay), + ), + Offset.zero & overlay.size, + ); + final software = renderMode.peek() == RenderMode.software; + onMenuOpen?.call(true); + final pick = await showMenu( + context: context, + position: position, + popUpAnimationStyle: AnimationStyle.noAnimation, + // Wider than Material's 280 px cap: the mode rows hold two chips. + constraints: const BoxConstraints(minWidth: 340, maxWidth: 340), + items: [ + if (software) + const PopupMenuItem( + enabled: false, + height: 32, + child: Text('Software rendering: shaders have no effect', + style: TextStyle(fontSize: 12, color: Colors.orange)), + ), + CheckedPopupMenuItem( + value: (ShaderPreset.off, null), + checked: preset == ShaderPreset.off, + height: 36, + child: const _Row(label: 'Off', keys: 'Ctrl+0'), ), - Offset.zero & overlay.size, - ); - final software = renderMode.peek() == RenderMode.software; - onMenuOpen?.call(true); - final pick = await showMenu( - context: context, - position: position, - popUpAnimationStyle: AnimationStyle.noAnimation, - // Wider than Material's 280 px cap: the mode rows hold two chips. - constraints: const BoxConstraints(minWidth: 340, maxWidth: 340), - items: [ - if (software) - const PopupMenuItem( - enabled: false, - height: 32, - child: Text('Software rendering: shaders have no effect', - style: TextStyle(fontSize: 12, color: Colors.orange)), - ), - CheckedPopupMenuItem( - value: (ShaderPreset.off, null), - checked: preset == ShaderPreset.off, - height: 36, - child: const _Row(label: 'Off', keys: 'Ctrl+0'), + for (final (i, mode) in ShaderPreset.modes.indexed) + _ModeEntry( + mode: mode, + keys: 'Ctrl+${i + 1}', + current: preset == mode ? quality : null, ), - for (final (i, mode) in ShaderPreset.modes.indexed) - _ModeEntry( - mode: mode, - keys: 'Ctrl+${i + 1}', - current: preset == mode ? quality : null, - ), - CheckedPopupMenuItem( - value: (ShaderPreset.custom, null), - checked: preset == ShaderPreset.custom, - enabled: hasCustom, - height: 36, - child: Text(hasCustom - ? 'Custom shaders' - : 'Custom shaders (pick files in Settings)'), - ), - ], - ); - onMenuOpen?.call(false); - if (pick != null) onPick(pick.$1, pick.$2); - } + CheckedPopupMenuItem( + value: (ShaderPreset.custom, null), + checked: preset == ShaderPreset.custom, + enabled: hasCustom, + height: 36, + child: Text(hasCustom + ? 'Custom shaders' + : 'Custom shaders (pick files in Settings)'), + ), + ], + ); + onMenuOpen?.call(false); + if (pick != null) onPick(pick.$1, pick.$2); } class _Row extends StatelessWidget { diff --git a/apps/desktop/lib/src/state/settings.dart b/apps/desktop/lib/src/state/settings.dart index 6c181d3..42af939 100644 --- a/apps/desktop/lib/src/state/settings.dart +++ b/apps/desktop/lib/src/state/settings.dart @@ -17,6 +17,7 @@ const _keyShaderPreset = 'shaderPreset'; const _keyShaderQuality = 'shaderQuality'; const _keyCustomShaders = 'customShaders'; const _keyAutoAdvance = 'autoAdvance'; +const _keyMpvControls = 'mpvControls'; enum HwdecMode { auto, off, custom } @@ -28,6 +29,9 @@ class AppSettings { final autoCheckUpdates = signal(true); /// Jellyfin episodes: at the end, count down 10 s and put up the next. final autoAdvance = signal(true); + /// Experimental: the session uses the mpv-style (uosc) controls + /// (`controls/uosc/`) instead of media_kit's. Applies live. + final mpvControls = signal(false); /// Video shaders: one choice, used on join and changed both here and /// in the session (Ctrl+0..6, the shader button). @@ -64,6 +68,8 @@ class AppSettings { if (auto is bool) autoCheckUpdates.value = auto; final advance = box.get(_keyAutoAdvance); if (advance is bool) autoAdvance.value = advance; + final mpv = box.get(_keyMpvControls); + if (mpv is bool) mpvControls.value = mpv; final preset = box.get(_keyShaderPreset); shaderPreset.value = ShaderPreset.values.where((p) => p.name == preset).firstOrNull ?? @@ -101,6 +107,11 @@ class AppSettings { _put(_keyAutoAdvance, on); } + void setMpvControls(bool on) { + mpvControls.value = on; + _put(_keyMpvControls, on); + } + void setShaderPreset(ShaderPreset preset) { shaderPreset.value = preset; _put(_keyShaderPreset, preset.name); diff --git a/apps/desktop/test/chapters_mpv_e2e_test.dart b/apps/desktop/test/chapters_mpv_e2e_test.dart index fb4573c..8d50cfb 100644 --- a/apps/desktop/test/chapters_mpv_e2e_test.dart +++ b/apps/desktop/test/chapters_mpv_e2e_test.dart @@ -89,6 +89,12 @@ void main() { expect(a.playback.ending.peek()!.start, const Duration(minutes: 20)); expect(a.playback.segments.peek(), isEmpty, reason: 'not a Jellyfin item'); + // buffered signal: mpv's demuxer cache (the timeline's hatching) + // runs ahead of the position. + await _poll(() => a.playback.buffered.peek() > a.playback.position.peek() + const Duration(seconds: 5), + 'cache ahead'); + expect(a.playback.buffered.peek(), lessThanOrEqualTo(a.playback.duration.peek())); + // tracks signal: two audio, one subtitle; mpv's choice marked. await _poll(() => a.playback.tracks.peek().length == 3, 'tracks'); final tracks = a.playback.tracks.peek(); diff --git a/apps/desktop/test/uosc_controls_test.dart b/apps/desktop/test/uosc_controls_test.dart new file mode 100644 index 0000000..13a3463 --- /dev/null +++ b/apps/desktop/test/uosc_controls_test.dart @@ -0,0 +1,469 @@ +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/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/home/settings_page.dart'; +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/state/settings.dart'; +import 'package:watch_together/src/update/update_controller.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('cache hatching math', () { + test('cached range runs from the position to the cache end', () { + const size = Size(1000, 30); + final r = cacheRect(size, position: m(6), buffered: m(9), duration: m(24))!; + expect(r.left, 250); + expect(r.right, 375); + expect(r.top, 0); + expect(r.bottom, 30); + expect(cacheRect(size, position: m(6), buffered: m(6), duration: m(24)), isNull); + expect(cacheRect(size, position: m(6), buffered: m(5), duration: m(24)), isNull, reason: 'behind'); + expect(cacheRect(size, position: m(6), buffered: m(9), duration: Duration.zero), isNull); + // Clamped to the bar. + expect(cacheRect(size, position: m(20), buffered: m(40), duration: m(24))!.right, 1000); + }); + + test('45° strokes, evenly spaced, covering the whole range', () { + const r = Rect.fromLTRB(100, 0, 160, 30); + final lines = hatchLines(r, spacing: 6); + for (final (a, b) in lines) { + expect(a.dy, r.bottom); + expect(b.dy, r.top); + expect(b.dx - a.dx, r.height, reason: '45°'); + // Every stroke crosses the range. + expect(b.dx, greaterThan(r.left)); + expect(a.dx, lessThan(r.right)); + } + for (var i = 1; i < lines.length; i++) { + expect(lines[i].$1.dx - lines[i - 1].$1.dx, 6); + } + // No gap at either edge: the first stroke starts left of the range's + // bottom-left corner diagonal, the last one within a step of the end. + expect(lines.first.$1.dx, lessThanOrEqualTo(r.left - r.height + 6)); + expect(lines.last.$1.dx, greaterThan(r.right - 6)); + expect(hatchLines(Rect.zero), isEmpty); + }); + }); + + group('timeline', () { + testWidgets('elapsed part filled, time boxes inside the bar', (tester) async { + await tester.pumpWidget(host(UoscTimeline( + position: m(6), duration: m(24), buffered: m(8), 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.hatched, isTrue); + expect(p.cache(bar.size), cacheRect(bar.size, position: m(6), buffered: m(8), duration: m(24))); + expect(boxText(tester, 'uosc-time'), '06:00'); + expect(boxText(tester, 'uosc-duration'), '24:00'); + final time = tester.getRect(find.byKey(const ValueKey('uosc-time'))); + expect(time.left, greaterThanOrEqualTo(bar.left)); + expect(time.top, greaterThanOrEqualTo(bar.top)); + expect(time.bottom, lessThanOrEqualTo(bar.bottom)); + expect(time.center.dx, lessThan(bar.left + 80), reason: 'at the left'); + }); + + testWidgets('a diamond on the top edge at each chapter start but the first', (tester) async { + await tester.pumpWidget(host(UoscTimeline( + position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {}))); + expect(find.byKey(const ValueKey('uosc-chapter-0')), findsNothing, reason: 'starts at 0'); + final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline'))); + for (final c in chapters.skip(1)) { + final mark = tester.getCenter(find.byKey(ValueKey('uosc-chapter-${c.index}'))); + expect(mark.dx, moreOrLessEquals(bar.left + bar.width * c.start.inSeconds / m(24).inSeconds, epsilon: 0.5), + reason: c.title); + expect(mark.dy, moreOrLessEquals(bar.top, epsilon: 0.5), reason: 'on the top edge'); + } + }); + + 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); + // 01:51 of 24:00: inside "Opening". + await mouse.moveTo(Offset(bar.left + bar.width * (111 / (24 * 60)), 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('hidden controls: a thin line with chapter ticks, no boxes, 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); + expect(painter(tester).played, 0.5); + expect(painter(tester).hatched, isFalse); + expect(find.byKey(const ValueKey('uosc-chapter-1')), findsNothing); + for (final c in chapters.skip(1)) { + final tick = tester.getRect(find.byKey(ValueKey('uosc-chapter-tick-${c.index}'))); + expect(tick.center.dx, moreOrLessEquals(bar.left + bar.width * c.start.inSeconds / m(24).inSeconds, epsilon: 0.5)); + expect(tick.width, lessThanOrEqualTo(2)); + } + expect(find.byKey(const ValueKey('uosc-time')), findsNothing); + 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('drag seeks on every move and on release', (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 = await tester.startGesture(Offset(bar.left + bar.width / 4, bar.center.dy), kind: PointerDeviceKind.mouse); + expect(seeks, isEmpty, reason: 'press alone does not seek'); + await g.moveTo(Offset(bar.left + bar.width / 2, bar.center.dy)); + await tester.pump(); + expect(boxText(tester, 'uosc-time'), '12:00', reason: 'follows the drag'); + await g.moveTo(Offset(bar.left + bar.width * 3 / 4, bar.center.dy)); + await g.up(); + expect(seeks, [m(12), m(18), m(18)]); + }); + }); + + 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); + expect(tester.getRect(find.byKey(const ValueKey('uosc-timeline'))).height, UoscTimeline.height); + }); + + 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, -20))); + await tester.pump(); + expect(got, [0, 80, 85]); + }); + }); + + 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: "Experimental: mpv-style controls", off 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: SettingsPage(updates: UpdateController(currentVersion: '0.0.0-dev'))), + )); + final toggle = find.byKey(const ValueKey('mpv-controls')); + expect(find.text('Experimental: mpv-style controls'), findsOneWidget); + expect(tester.widget(toggle).value, isFalse); + await tester.tap(toggle); + await tester.pump(); + expect(s.mpvControls.value, isTrue); + expect(tester.widget(toggle).value, isTrue); + }); +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7a03d47..a29b29c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -273,8 +273,9 @@ Chapters, segments, tracks (`PlaybackController` signals, values from - `tracks`: audio and subtitle `MpvTrack`s from mpv `track-list` (id, language, title, codec, channels, default / forced / hearing-impaired / external, `selected`), re-read after every selection. -- `position` (250 ms steps), `duration`; computed `opening`, `ending` - (`SkipRange`) and `currentChapter`. +- `position` (250 ms steps), `duration`, `buffered` (end of the cached + range: mpv `demuxer-cache-time`, media_kit's `buffer`, 250 ms steps); + computed `opening`, `ending` (`SkipRange`) and `currentChapter`. Skip rules (pure, `media_marks.dart`): - Opening = first Intro segment, else the first chapter whose title has a @@ -442,7 +443,8 @@ Jellyfin source the browser fills the height and can be enlarged, which turns the column into a one-row bar under it. Text fields have fixed or capped widths (720 px max) so they stay compact on ultrawide screens. Submit state (busy, cancel, per-tab errors, server crash) is `ConnectFlow`. Settings (profile, hardware -decoding, Jellyfin auto-advance, updates, video shaders, shortcuts), Integrations +decoding, experimental mpv-style controls, Jellyfin auto-advance, updates, +video shaders, shortcuts), Integrations (linked Jellyfin servers) and About open in the same body. Settings persist in two Hive boxes: `connect_form` (last form and recents; saved even when a join fails) and `settings`. Link media names are derived from the URL path @@ -495,6 +497,28 @@ private controls state (`keepVideoControlsAwake`). Pills (Skip OP / ED / 1:30, Next episode) and cards live outside the auto-hiding controls, so they show in fullscreen (whose route renders only the video and controls). +Experimental mpv-style controls (`controls/uosc/`, after mpv's uosc theme; +Settings toggle `AppSettings.mpvControls`, default off): the `Video`'s +controls builder wraps both sets in `SessionControlsSwitch`, which follows +the setting live, in the window and in fullscreen. `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` runs the full width at the bottom: played part filled, +`cacheRect` (position to `buffered`) hatched (`hatchLines`), chapter +diamonds on the top edge, current time and duration in boxes inside it, a +hover tip with the chapter title over the hover time; hidden, it shrinks +to a 3 px line with chapter ticks. Above it a row of flat buttons: menu (≡: +change video, people, 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, people, volume, +fullscreen. 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. + Window chrome is frameless (`window_manager`): every screen brings its own drag area and min/max/close buttons (Home's top bar; the session uses `widgets/window_chrome_bar.dart`; both use `window_buttons.dart`); the session drag layer is `window_drag_layer.dart`.