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
+1863
-15
No files matched your search
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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`.
|
||||||
|
|||||||
Reference in new issue
Block a user