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:
1 parent
bc98c5c665
commit
165cd2a7f9
14 files changed
+1917
-69
No files matched your search
@@ -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).
|
||||
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<SkipRange?> 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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<SessionScreen> {
|
||||
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<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() {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
return Positioned(
|
||||
|
||||
@@ -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<void> _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<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 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<ShaderPick>(
|
||||
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<ShaderPick>(
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user