Experimental mpv-style (uosc) controls behind a Settings toggle

Thick timeline with hatched cache range, chapter diamonds and
chapter/time hover boxes; collapses to a thin progress line when
hidden. Flat icon row with track count badges, shader preset badge,
flat dark menus. Own show/hide state, live switch in window and
fullscreen. New buffered signal from demuxer-cache-time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-07 03:10:17 +03:00
1 parent bc98c5c665
commit 165cd2a7f9
14 files changed
+1863 -15

No files matched your search

+13 -2
View File
@@ -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 Profile, hardware decoding (auto, off or custom; the env var
`SYNC_RELAY_HWDEC` overrides it; applies on the next join or Reload video), `SYNC_RELAY_HWDEC` overrides it; applies on the next join or Reload video),
updates, playing the next Jellyfin episode automatically, video shaders "Experimental: mpv-style controls" (below), updates, playing the next
(see below), and the keyboard shortcuts (read-only). Jellyfin episode automatically, video shaders (see below), and the
keyboard shortcuts (read-only).
Integrations links Jellyfin servers (below). About shows the version. Integrations links Jellyfin servers (below). About shows the version.
Keys in a session: Space = ready / unready, Tab = side panel (people, log, 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 Subtitles buttons (with the track count) list tracks as "Japanese · E-AC3
2.0 · title"; subtitles have Off. 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 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 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 of the ending only; what comes after is yours to skip). The opening and
@@ -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<UoscButton> createState() => _UoscButtonState();
}
class _UoscButtonState extends State<UoscButton> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final w = widget;
final color = w.onPressed == null ? UoscStyle.dim.withValues(alpha: 0.4) : (w.color ?? UoscStyle.text);
final badge = w.badge;
return UoscTooltip(
message: w.tooltip,
child: MouseRegion(
cursor: w.onPressed == null ? SystemMouseCursors.basic : SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: w.onPressed,
child: Container(
height: UoscControls.buttonRowHeight,
constraints: const BoxConstraints(minWidth: UoscControls.buttonRowHeight),
padding: EdgeInsets.symmetric(horizontal: w.label == null ? 0 : 8),
decoration: BoxDecoration(
color: _hover && w.onPressed != null ? UoscStyle.hover : Colors.transparent,
borderRadius: BorderRadius.circular(UoscStyle.radius),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Stack(
clipBehavior: Clip.none,
children: [
Icon(w.icon, color: color, size: 22),
if (badge != null && badge > 0)
Positioned(
right: -8,
bottom: -5,
child: Container(
key: const ValueKey('uosc-badge'),
padding: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
color: UoscStyle.text,
borderRadius: BorderRadius.circular(3),
),
child: Text('$badge',
style: const TextStyle(
color: Colors.black, fontSize: 9, height: 1.3, fontWeight: FontWeight.w700)),
),
),
],
),
if (w.label != null) ...[
const SizedBox(width: 6),
Text(w.label!, style: TextStyle(color: color, fontSize: UoscStyle.fontSize)),
],
],
),
),
),
),
);
}
}
/// Badge-style text button ("HQ" in uosc): a small outlined box with a
/// short label, filled when [active].
class UoscBadgeButton extends StatefulWidget {
final String text;
final String tooltip;
final bool active;
final VoidCallback onPressed;
const UoscBadgeButton({
super.key,
required this.text,
required this.tooltip,
required this.active,
required this.onPressed,
});
@override
State<UoscBadgeButton> createState() => _UoscBadgeButtonState();
}
class _UoscBadgeButtonState extends State<UoscBadgeButton> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final w = widget;
return UoscTooltip(
message: w.tooltip,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: w.onPressed,
child: Container(
height: UoscControls.buttonRowHeight,
padding: const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
color: _hover ? UoscStyle.hover : Colors.transparent,
borderRadius: BorderRadius.circular(UoscStyle.radius),
),
alignment: Alignment.center,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: w.active ? UoscStyle.text : Colors.transparent,
border: Border.all(color: w.active ? UoscStyle.text : UoscStyle.dim, width: 1.4),
borderRadius: BorderRadius.circular(3),
),
child: Text(w.text,
style: TextStyle(
color: w.active ? Colors.black : UoscStyle.dim,
fontSize: 11,
fontWeight: FontWeight.w800,
letterSpacing: 0.3)),
),
),
),
),
);
}
}
/// Volume: click mutes / unmutes, the wheel steps 5 %.
class UoscVolumeButton extends StatefulWidget {
/// 0..100.
final double volume;
final ValueChanged<double> onChanged;
const UoscVolumeButton({super.key, required this.volume, required this.onChanged});
@override
State<UoscVolumeButton> createState() => _UoscVolumeButtonState();
}
class _UoscVolumeButtonState extends State<UoscVolumeButton> {
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<MpvTrack> tracks;
final void Function(TrackPick pick) onPick;
final ValueChanged<bool>? onMenuOpen;
const UoscTracksButton({super.key, required this.type, required this.tracks, required this.onPick, this.onMenuOpen});
@override
Widget build(BuildContext context) {
final audio = type == JellyfinStreamType.audio;
final count = tracks.where((t) => t.type == type).length;
return UoscButton(
key: ValueKey(audio ? 'uosc-audio' : 'uosc-subtitles'),
icon: audio ? Icons.graphic_eq_rounded : Icons.subtitles_outlined,
tooltip: audio ? 'Audio' : 'Subtitles',
badge: count,
onPressed: () async {
onMenuOpen?.call(true);
final pick = await showUoscMenu<TrackPick>(context,
title: audio ? 'Audio' : 'Subtitles', items: trackMenuItems(type, tracks));
onMenuOpen?.call(false);
if (pick != null) onPick(pick);
},
);
}
}
/// Chapters: the list with start times, the playing one active; a pick
/// jumps there. Nothing without chapters.
class UoscChaptersButton extends StatelessWidget {
final List<MediaChapter> chapters;
final int? current;
final Duration duration;
final ValueChanged<MediaChapter> onPick;
final ValueChanged<bool>? onMenuOpen;
const UoscChaptersButton({
super.key,
required this.chapters,
required this.current,
required this.duration,
required this.onPick,
this.onMenuOpen,
});
@override
Widget build(BuildContext context) {
if (chapters.isEmpty) return const SizedBox.shrink();
return UoscButton(
key: const ValueKey('uosc-chapters'),
icon: Icons.bookmarks_outlined,
tooltip: 'Chapters (PgUp / PgDn)',
onPressed: () async {
onMenuOpen?.call(true);
final pick = await showUoscMenu<MediaChapter>(context,
title: 'Chapters', items: chapterMenuItems(chapters, current, duration));
onMenuOpen?.call(false);
if (pick != null) onPick(pick);
},
);
}
}
/// Shaders as uosc's quality badge: the active Anime4K preset ("A HQ"),
/// "4K" dimmed when off. Opens the shader menu.
class UoscShaderButton extends StatelessWidget {
final ReadonlySignal<RenderMode> renderMode;
final void Function(ShaderPreset preset, ShaderQuality? quality) onPick;
final ValueChanged<bool>? onMenuOpen;
const UoscShaderButton({super.key, required this.renderMode, required this.onPick, this.onMenuOpen});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(builder: (context) {
final preset = s.shaderPreset.value;
final quality = s.shaderQuality.value;
final short = shaderShortLabel(preset, quality);
return Builder(
builder: (context) => UoscBadgeButton(
key: const ValueKey('uosc-shaders'),
text: short ?? '4K',
active: short != null,
tooltip: short == null
? 'Shaders: off (Ctrl+1..6)'
: 'Shaders: ${shaderLabel(preset, quality)} (Ctrl+0 off)',
onPressed: () {
final controls = UoscControls.maybeOf(context);
Future<void> open() =>
showShaderMenu(context, renderMode: renderMode, onPick: onPick, onMenuOpen: onMenuOpen);
controls == null ? open() : controls.holdDuring(open);
},
),
);
});
}
}
/// One entry of the ≡ menu.
typedef UoscAction = ({String title, String? hint, VoidCallback run});
/// ≡: the session's actions as a menu.
class UoscMainMenuButton extends StatelessWidget {
final List<UoscAction> actions;
final ValueChanged<bool>? onMenuOpen;
const UoscMainMenuButton({super.key, required this.actions, this.onMenuOpen});
@override
Widget build(BuildContext context) {
return UoscButton(
key: const ValueKey('uosc-menu-button'),
icon: Icons.menu_rounded,
tooltip: 'Menu',
onPressed: () async {
onMenuOpen?.call(true);
final pick = await showUoscMenu<UoscAction>(context, items: [
for (final a in actions) UoscMenuItem(value: a, title: a.title, hint: a.hint),
]);
onMenuOpen?.call(false);
pick?.run();
},
);
}
}
@@ -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<Widget> left;
final List<Widget> 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<UoscControlsState>();
@override
State<UoscControls> createState() => UoscControlsState();
}
class UoscControlsState extends State<UoscControls> {
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<T> holdDuring<T>(Future<T> Function() open) async {
hold(true);
try {
return await open();
} finally {
if (mounted) hold(false);
}
}
void _hideLater() {
_timer?.cancel();
_timer = Timer(widget.hideAfter, _hide);
}
void _hide() {
if (!mounted || _holds > 0 || !_visible) return;
setState(() => _visible = false);
}
@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),
);
}
}
@@ -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<T> {
final T value;
final String title;
final String? hint;
final bool active;
final bool enabled;
const UoscMenuItem({
required this.value,
required this.title,
this.hint,
this.active = false,
this.enabled = true,
});
}
/// A flat dark menu over the controls, above the widget of [context] (the
/// button that opens it), with an optional [title] row. Resolves to the
/// picked value, or null when dismissed (click outside, Esc). Keeps the
/// controls up while open.
Future<T?> showUoscMenu<T>(
BuildContext context, {
String? title,
required List<UoscMenuItem<T>> items,
}) {
final navigator = Navigator.of(context);
final overlay = navigator.overlay!.context.findRenderObject() as RenderBox;
final box = context.findRenderObject() as RenderBox;
final anchor = Rect.fromPoints(
box.localToGlobal(Offset.zero, ancestor: overlay),
box.localToGlobal(box.size.bottomRight(Offset.zero), ancestor: overlay),
);
Future<T?> open() => navigator.push<T>(_UoscMenuRoute<T>(anchor: anchor, title: title, items: items));
final controls = UoscControls.maybeOf(context);
return controls == null ? open() : controls.holdDuring(open);
}
class _UoscMenuRoute<T> extends PopupRoute<T> {
final Rect anchor;
final String? title;
final List<UoscMenuItem<T>> items;
_UoscMenuRoute({required this.anchor, required this.title, required this.items});
@override
Color? get barrierColor => null;
@override
bool get barrierDismissible => true;
@override
String? get barrierLabel => 'Close menu';
@override
Duration get transitionDuration => Duration.zero;
@override
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
return LayoutBuilder(builder: (context, constraints) {
const width = 360.0;
final screen = constraints.biggest;
final left = anchor.left.clamp(8.0, math.max(8.0, screen.width - width - 8));
final maxHeight = math.max(120.0, anchor.top - 16);
return Stack(
children: [
Positioned(
left: left.toDouble(),
bottom: screen.height - anchor.top + 6,
width: math.min(width, screen.width - 16),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight),
child: UoscMenuPanel<T>(
title: title,
items: items,
onPick: (v) => Navigator.of(context).pop(v),
),
),
),
],
);
});
}
}
/// The menu's panel alone (tests pump it without a route).
class UoscMenuPanel<T> extends StatefulWidget {
final String? title;
final List<UoscMenuItem<T>> items;
final ValueChanged<T> onPick;
const UoscMenuPanel({super.key, this.title, required this.items, required this.onPick});
static const rowHeight = 36.0;
@override
State<UoscMenuPanel<T>> createState() => _UoscMenuPanelState<T>();
}
class _UoscMenuPanelState<T> extends State<UoscMenuPanel<T>> {
// Long lists open on the current row.
late final _scroll = ScrollController(
initialScrollOffset: math.max(0, widget.items.indexWhere((i) => i.active) - 3) * UoscMenuPanel.rowHeight);
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final title = widget.title;
return Material(
key: const ValueKey('uosc-menu'),
color: UoscStyle.panel,
borderRadius: BorderRadius.circular(UoscStyle.radius * 2),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (title != null)
Container(
height: 34,
alignment: Alignment.center,
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0x22FFFFFF)))),
child: Text(title,
style: const TextStyle(color: UoscStyle.dim, fontSize: UoscStyle.fontSize, fontWeight: FontWeight.w600)),
),
Flexible(
child: ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.all(4),
controller: _scroll,
itemCount: widget.items.length,
itemExtent: UoscMenuPanel.rowHeight,
itemBuilder: (context, i) => _Row<T>(item: widget.items[i], onPick: widget.onPick),
),
),
],
),
);
}
}
class _Row<T> extends StatefulWidget {
final UoscMenuItem<T> item;
final ValueChanged<T> onPick;
const _Row({super.key, required this.item, required this.onPick});
@override
State<_Row<T>> createState() => _RowState<T>();
}
class _RowState<T> extends State<_Row<T>> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final item = widget.item;
final color = item.active
? UoscStyle.fill
: _hover && item.enabled
? UoscStyle.hover
: Colors.transparent;
final textColor = item.enabled ? UoscStyle.text : UoscStyle.dim.withValues(alpha: 0.4);
return MouseRegion(
cursor: item.enabled ? SystemMouseCursors.click : SystemMouseCursors.basic,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
key: ValueKey('uosc-menu-item-${item.title}'),
behavior: HitTestBehavior.opaque,
onTap: item.enabled ? () => widget.onPick(item.value) : null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(UoscStyle.radius)),
child: Row(
children: [
Expanded(
child: Text(item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: textColor,
fontSize: UoscStyle.fontSize,
fontWeight: item.active ? FontWeight.w600 : FontWeight.normal)),
),
if (item.hint != null) ...[
const SizedBox(width: 12),
Text(item.hint!,
style: TextStyle(
color: item.active ? UoscStyle.text : UoscStyle.dim,
fontSize: 12,
fontFeatures: const [FontFeature.tabularFigures()])),
],
],
),
),
),
);
}
}
/// Chapters with their start times, the playing one active.
List<UoscMenuItem<MediaChapter>> chapterMenuItems(List<MediaChapter> chapters, int? current, Duration duration) {
final withHours = duration >= const Duration(hours: 1);
return [
for (final c in chapters)
UoscMenuItem(
value: c,
title: c.title.isEmpty ? 'Chapter ${c.index + 1}' : c.title,
hint: formatUoscTime(c.start, withHours: withHours),
active: c.index == current,
),
];
}
/// The audio or subtitle tracks ("Japanese · E-AC3 2.0 · title"), mpv's
/// selection active; subtitles start with Off.
List<UoscMenuItem<TrackPick>> trackMenuItems(JellyfinStreamType type, List<MpvTrack> tracks) {
final audio = type == JellyfinStreamType.audio;
final list = tracks.where((t) => t.type == type).toList();
return [
if (!audio)
UoscMenuItem(
value: (aid: null, sid: null, subtitlesOff: true),
title: 'Off',
active: !list.any((t) => t.selected),
),
for (final t in list)
UoscMenuItem(
value: audio ? (aid: t.id, sid: null, subtitlesOff: false) : (aid: null, sid: t.id, subtitlesOff: false),
title: t.label,
hint: '#${t.id}',
active: t.selected,
),
];
}
@@ -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()])),
);
}
}
@@ -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<MediaChapter> chapters;
final bool expanded;
final ValueChanged<Duration> onSeek;
const UoscTimeline({
super.key,
required this.position,
required this.duration,
this.buffered = Duration.zero,
this.chapters = const [],
this.expanded = true,
required this.onSeek,
});
static const height = 30.0;
static const collapsedHeight = 3.0;
@override
State<UoscTimeline> createState() => _UoscTimelineState();
}
class _UoscTimelineState extends State<UoscTimeline> {
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;
}
@@ -101,6 +101,10 @@ class PlaybackController {
/// Duration of the open file (zero until known). /// Duration of the open file (zero until known).
final duration = signal(Duration.zero); 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 /// The opening ([openingRange]: Intro segment, else an OP-titled
/// chapter) and the ending ([endingRange]: Outro, else ED-titled). /// chapter) and the ending ([endingRange]: Outro, else ED-titled).
late final Computed<SkipRange?> opening = late final Computed<SkipRange?> opening =
@@ -409,6 +413,7 @@ class PlaybackController {
_chapterJson = ''; _chapterJson = '';
chapters.value = const []; chapters.value = const [];
tracks.value = const []; tracks.value = const [];
buffered.value = Duration.zero;
// An empty map, not null: media_kit caches headers per URI. // An empty map, not null: media_kit caches headers per URI.
await player.open(Media(url, httpHeaders: headers ?? const {}), play: false); await player.open(Media(url, httpHeaders: headers ?? const {}), play: false);
} catch (e, st) { } catch (e, st) {
@@ -424,6 +429,10 @@ class PlaybackController {
final ms = d.inMilliseconds; final ms = d.inMilliseconds;
position.value = Duration(milliseconds: ms - ms % 250); 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) { _timeSubs.add(p.stream.duration.listen((d) {
duration.value = d; duration.value = d;
// The last chapter ends at the duration. // The last chapter ends at the duration.
@@ -469,6 +478,7 @@ class PlaybackController {
tracks.value = const []; tracks.value = const [];
position.value = Duration.zero; position.value = Duration.zero;
duration.value = Duration.zero; duration.value = Duration.zero;
buffered.value = Duration.zero;
} }
/// Waits (bounded) until the open file's tracks are listed and mpv has /// Waits (bounded) until the open file's tracks are listed and mpv has
@@ -37,7 +37,13 @@ class SettingsPage extends StatelessWidget {
children: [ children: [
const PageTitle('Settings'), const PageTitle('Settings'),
const _Section(title: 'Profile', child: ProfileEditor()), 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()), const _Section(title: 'Jellyfin', child: _AutoAdvanceSetting()),
_Section(title: 'Updates', child: _UpdateSettings(controller: updates)), _Section(title: 'Updates', child: _UpdateSettings(controller: updates)),
const _Section(title: 'Keyboard shortcuts', child: _ShortcutList()), 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 { class _AutoAdvanceSetting extends StatelessWidget {
const _AutoAdvanceSetting(); const _AutoAdvanceSetting();
@@ -4,6 +4,9 @@ import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart'; import '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 '../playback/media_marks.dart' show SkipOffer;
import '../jellyfin/browse_scope.dart' show BrowseStart; import '../jellyfin/browse_scope.dart' show BrowseStart;
@@ -496,7 +499,11 @@ class _SessionScreenState extends State<SessionScreen> {
children: [ children: [
// Frozen-picture watchdog samples the route around this anchor. // Frozen-picture watchdog samples the route around this anchor.
_frameProbe.anchor(), _frameProbe.anchor(),
MaterialDesktopVideoControls(state), // Settings → "Experimental: mpv-style controls" swaps them live.
SessionControlsSwitch(
material: (_) => MaterialDesktopVideoControls(state),
mpv: (_) => _mpvControls(),
),
_healthOverlay(), _healthOverlay(),
_waitingOverlay(), _waitingOverlay(),
_jellyfinOverlay(), _jellyfinOverlay(),
@@ -523,6 +530,133 @@ class _SessionScreenState extends State<SessionScreen> {
); );
} }
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<double>(
stream: playback.player.stream.volume,
initialData: playback.player.state.volume,
builder: (context, v) => UoscVolumeButton(
volume: v.data ?? 100,
onChanged: (x) => playback.player.setVolume(x),
),
),
SignalBuilder(
builder: (context) => UoscButton(
icon: _isFullscreen.value ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded,
tooltip: 'Fullscreen (F)',
onPressed: _toggleFullscreen,
),
),
],
);
}
Widget _healthOverlay() { Widget _healthOverlay() {
final coord = SessionState.instance.coordinator; final coord = SessionState.instance.coordinator;
return Positioned( return Positioned(
+20 -8
View File
@@ -31,11 +31,7 @@ class ShaderControlButton extends StatelessWidget {
return SignalBuilder(builder: (context) { return SignalBuilder(builder: (context) {
final preset = s.shaderPreset.value; final preset = s.shaderPreset.value;
final quality = s.shaderQuality.value; final quality = s.shaderQuality.value;
final short = switch (preset) { final short = shaderShortLabel(preset, quality) ?? 'Shaders';
ShaderPreset.off => 'Shaders',
ShaderPreset.custom => 'Custom',
_ => '${preset.label.replaceFirst('Mode ', '')} ${quality.label}',
};
return Tooltip( return Tooltip(
message: 'Video shaders (Ctrl+1..6, Ctrl+0 off)', message: 'Video shaders (Ctrl+1..6, Ctrl+0 off)',
child: TextButton.icon( child: TextButton.icon(
@@ -46,15 +42,32 @@ class ShaderControlButton extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
tapTargetSize: MaterialTapTargetSize.shrinkWrap, tapTargetSize: MaterialTapTargetSize.shrinkWrap,
), ),
onPressed: () => _open(context), onPressed: () => showShaderMenu(context,
renderMode: renderMode, onPick: onPick, onMenuOpen: onMenuOpen),
icon: const Icon(Icons.auto_awesome, size: 16), icon: const Icon(Icons.auto_awesome, size: 16),
label: Text(short, style: const TextStyle(fontSize: 12)), label: Text(short, style: const TextStyle(fontSize: 12)),
), ),
); );
}); });
} }
}
Future<void> _open(BuildContext context) async { /// "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<void> showShaderMenu(
BuildContext context, {
required ReadonlySignal<RenderMode> renderMode,
required void Function(ShaderPreset preset, ShaderQuality? quality) onPick,
ValueChanged<bool>? onMenuOpen,
}) async {
final s = AppSettings.instance; final s = AppSettings.instance;
final preset = s.shaderPreset.peek(); final preset = s.shaderPreset.peek();
final quality = s.shaderQuality.peek(); final quality = s.shaderQuality.peek();
@@ -112,7 +125,6 @@ class ShaderControlButton extends StatelessWidget {
onMenuOpen?.call(false); onMenuOpen?.call(false);
if (pick != null) onPick(pick.$1, pick.$2); if (pick != null) onPick(pick.$1, pick.$2);
} }
}
class _Row extends StatelessWidget { class _Row extends StatelessWidget {
final String label; final String label;
+11
View File
@@ -17,6 +17,7 @@ const _keyShaderPreset = 'shaderPreset';
const _keyShaderQuality = 'shaderQuality'; const _keyShaderQuality = 'shaderQuality';
const _keyCustomShaders = 'customShaders'; const _keyCustomShaders = 'customShaders';
const _keyAutoAdvance = 'autoAdvance'; const _keyAutoAdvance = 'autoAdvance';
const _keyMpvControls = 'mpvControls';
enum HwdecMode { auto, off, custom } enum HwdecMode { auto, off, custom }
@@ -28,6 +29,9 @@ class AppSettings {
final autoCheckUpdates = signal(true); final autoCheckUpdates = signal(true);
/// Jellyfin episodes: at the end, count down 10 s and put up the next. /// Jellyfin episodes: at the end, count down 10 s and put up the next.
final autoAdvance = signal(true); 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 /// Video shaders: one choice, used on join and changed both here and
/// in the session (Ctrl+0..6, the shader button). /// in the session (Ctrl+0..6, the shader button).
@@ -64,6 +68,8 @@ class AppSettings {
if (auto is bool) autoCheckUpdates.value = auto; if (auto is bool) autoCheckUpdates.value = auto;
final advance = box.get(_keyAutoAdvance); final advance = box.get(_keyAutoAdvance);
if (advance is bool) autoAdvance.value = advance; if (advance is bool) autoAdvance.value = advance;
final mpv = box.get(_keyMpvControls);
if (mpv is bool) mpvControls.value = mpv;
final preset = box.get(_keyShaderPreset); final preset = box.get(_keyShaderPreset);
shaderPreset.value = shaderPreset.value =
ShaderPreset.values.where((p) => p.name == preset).firstOrNull ?? ShaderPreset.values.where((p) => p.name == preset).firstOrNull ??
@@ -101,6 +107,11 @@ class AppSettings {
_put(_keyAutoAdvance, on); _put(_keyAutoAdvance, on);
} }
void setMpvControls(bool on) {
mpvControls.value = on;
_put(_keyMpvControls, on);
}
void setShaderPreset(ShaderPreset preset) { void setShaderPreset(ShaderPreset preset) {
shaderPreset.value = preset; shaderPreset.value = preset;
_put(_keyShaderPreset, preset.name); _put(_keyShaderPreset, preset.name);
@@ -89,6 +89,12 @@ void main() {
expect(a.playback.ending.peek()!.start, const Duration(minutes: 20)); expect(a.playback.ending.peek()!.start, const Duration(minutes: 20));
expect(a.playback.segments.peek(), isEmpty, reason: 'not a Jellyfin item'); 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. // tracks signal: two audio, one subtitle; mpv's choice marked.
await _poll(() => a.playback.tracks.peek().length == 3, 'tracks'); await _poll(() => a.playback.tracks.peek().length == 3, 'tracks');
final tracks = a.playback.tracks.peek(); final tracks = a.playback.tracks.peek();
+469
View File
@@ -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<Text>(find.descendant(of: find.byKey(ValueKey(key)), matching: find.byType(Text)))
.data!;
UoscTimelinePainter painter(WidgetTester tester) => tester
.widget<CustomPaint>(find.descendant(
of: find.byKey(const ValueKey('uosc-timeline')), matching: find.byType(CustomPaint)))
.painter! as UoscTimelinePainter;
Future<TestGesture> mouseAt(WidgetTester tester, Offset at) async {
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
await mouse.addPointer(location: at);
addTearDown(mouse.removePointer);
return mouse;
}
const tracks = [
MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', codec: 'eac3', channels: 2, selected: true),
MpvTrack(id: 2, type: JellyfinStreamType.audio, language: 'eng', codec: 'aac', channels: 6, title: 'Dub'),
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'),
MpvTrack(id: 2, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Signs'),
];
void main() {
test('uosc clock: mm:ss, hh:mm:ss from an hour', () {
expect(formatUoscTime(Duration.zero), '00:00');
expect(formatUoscTime(m(1, 51)), '01:51');
expect(formatUoscTime(m(1, 51), withHours: true), '00:01:51');
expect(formatUoscTime(const Duration(hours: 1, minutes: 2, seconds: 3)), '01:02:03');
expect(formatUoscTime(const Duration(seconds: -4)), '00:00');
});
group('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 = <Duration>[];
await tester.pumpWidget(host(UoscTimeline(
position: m(12), duration: m(24), buffered: m(14), chapters: chapters, expanded: false, onSeek: seeks.add)));
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
expect(bar.height, UoscTimeline.collapsedHeight);
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 = <Duration>[];
await tester.pumpWidget(host(UoscTimeline(
position: m(1), duration: m(24), chapters: chapters, onSeek: seeks.add)));
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
final g = 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<Widget> left = const [], Duration hideAfter = const Duration(seconds: 3)}) => MaterialApp(
home: Scaffold(
backgroundColor: Colors.black,
body: UoscControls(
hideAfter: hideAfter,
left: left,
right: const [UoscButton(key: ValueKey('right'), icon: Icons.fullscreen, tooltip: 'Fullscreen', onPressed: null)],
timeline: (context, expanded) => UoscTimeline(
position: m(6), duration: m(24), chapters: chapters, expanded: expanded, onSeek: (_) {}),
),
),
);
double buttonsOpacity(WidgetTester tester) => tester
.widget<AnimatedOpacity>(find.descendant(
of: find.byKey(const ValueKey('uosc-buttons')), matching: find.byType(AnimatedOpacity)).first)
.opacity;
testWidgets('buttons above a full-width timeline; hide after the delay, mouse brings them back',
(tester) async {
await tester.pumpWidget(frame());
final screen = tester.getSize(find.byType(UoscControls));
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
expect(bar.width, screen.width);
expect(bar.bottom, screen.height);
expect(tester.getRect(find.byKey(const ValueKey('right'))).bottom, lessThanOrEqualTo(bar.top));
expect(buttonsOpacity(tester), 1);
await tester.pump(const Duration(seconds: 4));
await tester.pumpAndSettle();
expect(buttonsOpacity(tester), 0);
expect(tester.getRect(find.byKey(const ValueKey('uosc-timeline'))).height, UoscTimeline.collapsedHeight,
reason: 'still visible as a thin line');
final mouse = await mouseAt(tester, const Offset(400, 200));
await mouse.moveTo(const Offset(420, 220));
await tester.pump();
expect(buttonsOpacity(tester), 1);
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<TrackPick>), findsOneWidget);
await tester.pump(const Duration(seconds: 10));
expect(buttonsOpacity(tester), 1);
await tester.tapAt(const Offset(5, 5)); // outside: dismiss
await tester.pump();
expect(find.byType(UoscMenuPanel<TrackPick>), findsNothing);
await tester.pump(const Duration(seconds: 4));
await tester.pumpAndSettle();
expect(buttonsOpacity(tester), 0);
});
});
group('buttons', () {
testWidgets('track buttons carry the count; dark tooltip on hover', (tester) async {
await tester.pumpWidget(host(Row(children: [
UoscTracksButton(type: JellyfinStreamType.subtitle, tracks: tracks, onPick: (_) {}),
UoscTracksButton(type: JellyfinStreamType.audio, tracks: tracks.take(1).toList(), onPick: (_) {}),
const UoscButton(key: ValueKey('plain'), icon: Icons.menu, tooltip: 'Menu', onPressed: null),
])));
String badge(String key) => tester
.widget<Text>(find.descendant(
of: find.descendant(of: find.byKey(ValueKey(key)), matching: find.byKey(const ValueKey('uosc-badge'))),
matching: find.byType(Text)))
.data!;
expect(badge('uosc-subtitles'), '2');
expect(badge('uosc-audio'), '1');
expect(find.descendant(of: find.byKey(const ValueKey('plain')), matching: find.byKey(const ValueKey('uosc-badge'))),
findsNothing);
final mouse = await mouseAt(tester, Offset.zero);
await mouse.moveTo(tester.getCenter(find.byKey(const ValueKey('uosc-subtitles'))));
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(find.text('Subtitles'), findsOneWidget);
final tip = tester.widget<Tooltip>(find.descendant(of: find.byKey(const ValueKey('uosc-subtitles')), matching: find.byType(Tooltip)).first);
expect((tip.decoration as BoxDecoration).color, UoscStyle.panel);
});
testWidgets('shader badge shows the preset, dimmed "4K" when off', (tester) async {
final s = AppSettings.instance;
s.shaderPreset.value = ShaderPreset.off;
await tester.pumpWidget(host(Row(children: [
UoscShaderButton(renderMode: signal(RenderMode.gpu), onPick: (_, _) {}),
])));
expect(find.text('4K'), findsOneWidget);
expect(tester.widget<UoscBadgeButton>(find.byType(UoscBadgeButton)).active, isFalse);
s.shaderPreset.value = ShaderPreset.ca;
s.shaderQuality.value = ShaderQuality.fast;
await tester.pump();
expect(find.text('C+A Fast'), findsOneWidget);
expect(tester.widget<UoscBadgeButton>(find.byType(UoscBadgeButton)).active, isTrue);
s.shaderPreset.value = ShaderPreset.off;
s.shaderQuality.value = ShaderQuality.hq;
});
testWidgets('volume: click mutes and restores, wheel steps 5', (tester) async {
final got = <double>[];
var volume = 80.0;
await tester.pumpWidget(host(StatefulBuilder(
builder: (context, setState) => Row(children: [
UoscVolumeButton(volume: volume, onChanged: (v) => setState(() => got.add(volume = v))),
]),
)));
await tester.tap(find.byKey(const ValueKey('uosc-volume')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey('uosc-volume')));
await tester.pump();
await tester.sendEventToBinding(PointerScrollEvent(
kind: PointerDeviceKind.mouse,
position: tester.getCenter(find.byKey(const ValueKey('uosc-volume'))),
scrollDelta: const Offset(0, -20)));
await tester.pump();
expect(got, [0, 80, 85]);
});
});
group('menus', () {
Color rowColor(WidgetTester tester, String title) => (tester
.widget<Container>(find.descendant(
of: find.byKey(ValueKey('uosc-menu-item-$title')), matching: find.byType(Container)).first)
.decoration! as BoxDecoration)
.color!;
testWidgets('audio: flat dark list, current highlighted, pick selects', (tester) async {
TrackPick? got;
await tester.pumpWidget(host(Row(children: [
UoscTracksButton(type: JellyfinStreamType.audio, tracks: tracks, onPick: (p) => got = p),
])));
await tester.tap(find.byKey(const ValueKey('uosc-audio')));
await tester.pumpAndSettle();
final menu = find.byKey(const ValueKey('uosc-menu'));
expect(tester.widget<Material>(menu).color, UoscStyle.panel);
expect(tester.getRect(menu).bottom, lessThan(tester.getRect(find.byKey(const ValueKey('uosc-audio'))).top),
reason: 'opens above the button');
expect(find.text('Audio'), findsOneWidget, reason: 'title row');
expect(rowColor(tester, 'Japanese · E-AC3 2.0'), UoscStyle.fill);
expect(rowColor(tester, 'English · AAC 5.1 · Dub'), isNot(UoscStyle.fill));
await tester.tap(find.byKey(const ValueKey('uosc-menu-item-English · AAC 5.1 · Dub')));
await tester.pumpAndSettle();
expect(got, (aid: 2, sid: null, subtitlesOff: false));
expect(menu, findsNothing);
});
testWidgets('subtitles start with Off, active when none is selected', (tester) async {
TrackPick? got;
await tester.pumpWidget(host(Row(children: [
UoscTracksButton(type: JellyfinStreamType.subtitle, tracks: tracks, onPick: (p) => got = p),
])));
await tester.tap(find.byKey(const ValueKey('uosc-subtitles')));
await tester.pumpAndSettle();
expect(rowColor(tester, 'Off'), UoscStyle.fill);
await tester.tap(find.byKey(const ValueKey('uosc-menu-item-Off')));
await tester.pumpAndSettle();
expect(got, (aid: null, sid: null, subtitlesOff: true));
});
testWidgets('chapters: titles with start times, the playing one highlighted', (tester) async {
MediaChapter? got;
await tester.pumpWidget(host(Row(children: [
UoscChaptersButton(chapters: chapters, current: 2, duration: m(24), onPick: (c) => got = c),
])));
await tester.tap(find.byKey(const ValueKey('uosc-chapters')));
await tester.pumpAndSettle();
for (final c in chapters) {
expect(find.descendant(of: find.byKey(ValueKey('uosc-menu-item-${c.title}')), matching: find.text(formatUoscTime(c.start))),
findsOneWidget);
}
expect(rowColor(tester, 'Part A'), UoscStyle.fill);
expect(rowColor(tester, 'Opening'), isNot(UoscStyle.fill));
await tester.tap(find.byKey(const ValueKey('uosc-menu-item-Ending')));
await tester.pumpAndSettle();
expect(got, chapters[3]);
});
testWidgets('no chapters: no button', (tester) async {
await tester.pumpWidget(host(Row(children: [
UoscChaptersButton(chapters: const [], current: null, duration: m(24), onPick: (_) {}),
])));
expect(find.byKey(const ValueKey('uosc-chapters')), findsNothing);
});
testWidgets('main menu runs the picked action', (tester) async {
final ran = <String>[];
await tester.pumpWidget(host(Row(children: [
UoscMainMenuButton(actions: [
(title: 'Change video', hint: null, run: () => ran.add('swap')),
(title: 'Reload video', hint: 'Ctrl+R', run: () => ran.add('reload')),
]),
])));
await tester.tap(find.byKey(const ValueKey('uosc-menu-button')));
await tester.pumpAndSettle();
expect(find.text('Ctrl+R'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('uosc-menu-item-Reload video')));
await tester.pumpAndSettle();
expect(ran, ['reload']);
});
});
testWidgets('the setting picks the controls builder, live', (tester) async {
final s = AppSettings.instance;
s.mpvControls.value = false;
await tester.pumpWidget(MaterialApp(
home: SessionControlsSwitch(
material: (_) => const Text('material'),
mpv: (_) => const Text('mpv'),
),
));
expect(find.text('material'), findsOneWidget);
s.mpvControls.value = true;
await tester.pump();
expect(find.text('mpv'), findsOneWidget);
expect(find.text('material'), findsNothing);
s.mpvControls.value = false;
await tester.pump();
expect(find.text('material'), findsOneWidget);
});
testWidgets('Settings: "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<SwitchListTile>(toggle).value, isFalse);
await tester.tap(toggle);
await tester.pump();
expect(s.mpvControls.value, isTrue);
expect(tester.widget<SwitchListTile>(toggle).value, isTrue);
});
}
+27 -3
View File
@@ -273,8 +273,9 @@ Chapters, segments, tracks (`PlaybackController` signals, values from
- `tracks`: audio and subtitle `MpvTrack`s from mpv `track-list` (id, - `tracks`: audio and subtitle `MpvTrack`s from mpv `track-list` (id,
language, title, codec, channels, default / forced / hearing-impaired / language, title, codec, channels, default / forced / hearing-impaired /
external, `selected`), re-read after every selection. external, `selected`), re-read after every selection.
- `position` (250 ms steps), `duration`; computed `opening`, `ending` - `position` (250 ms steps), `duration`, `buffered` (end of the cached
(`SkipRange`) and `currentChapter`. range: mpv `demuxer-cache-time`, media_kit's `buffer`, 250 ms steps);
computed `opening`, `ending` (`SkipRange`) and `currentChapter`.
Skip rules (pure, `media_marks.dart`): Skip rules (pure, `media_marks.dart`):
- Opening = first Intro segment, else the first chapter whose title has a - 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 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, they stay compact on ultrawide screens. Submit state (busy, cancel,
per-tab errors, server crash) is `ConnectFlow`. Settings (profile, hardware 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 (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 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 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 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). 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 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 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`. `widgets/window_chrome_bar.dart`; both use `window_buttons.dart`); the session drag layer is `window_drag_layer.dart`.