Player overlay as one piece, Up next always warm and on every source, Jellyfin never unmarks a watched episode
- Overlay: one layout for both designs (top bar with Up next as one of its buttons; one action band above the controls for the status pill, skips, next episode and end cards), one glass recipe (AppGlass) and one pill (FloatingPill); hovering any of them, or the open queue, holds the controls up. - Up next: the first item (or the first continuation episode) is kept resolved on the app that puts it up, refreshed before it expires; Schedule and drop/paste can queue too. - Jellyfin: WatchSession (core) counts real watched coverage; an episode is watched at 80 % or at its ending after half; a played one is re-marked after Playing (10.11 unmarks on open) and never gets a position; a peek leaves the old resume point; part-way resume points stay under 85 %. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6b10cae4e6
commit
771b961b32
27 files changed
+1396
-558
No files matched your search
@@ -2,16 +2,92 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// What keeps the player's controls up besides the mouse on them: the
|
||||
/// mouse on a floating element over the picture (a pill, a card, the
|
||||
/// status pill: [ControlsHoldRegion]) or an open popover. While anything
|
||||
/// holds, the controls stay up; on release they hide on their usual
|
||||
/// countdown. Both designs follow it ([ControlsHoverProbe] with
|
||||
/// media_kit's controls, `UoscControls`).
|
||||
class ControlsHold extends ChangeNotifier {
|
||||
final _by = <Object>{};
|
||||
bool _told = false;
|
||||
bool _queued = false;
|
||||
bool _disposed = false;
|
||||
|
||||
bool get held => _told;
|
||||
|
||||
/// [who] holds ([on]) or lets go. Listeners hear it in a microtask, never
|
||||
/// during a build or a dispose.
|
||||
void set(Object who, bool on) {
|
||||
if (on ? !_by.add(who) : !_by.remove(who)) return;
|
||||
if (_queued) return;
|
||||
_queued = true;
|
||||
scheduleMicrotask(() {
|
||||
_queued = false;
|
||||
if (_disposed) return;
|
||||
final now = _by.isNotEmpty;
|
||||
if (now == _told) return;
|
||||
_told = now;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// The mouse on [child] counts as the mouse on the controls: it holds
|
||||
/// [hold] while it is there.
|
||||
class ControlsHoldRegion extends StatefulWidget {
|
||||
final ControlsHold? hold;
|
||||
final Widget child;
|
||||
const ControlsHoldRegion({super.key, required this.hold, required this.child});
|
||||
|
||||
@override
|
||||
State<ControlsHoldRegion> createState() => _ControlsHoldRegionState();
|
||||
}
|
||||
|
||||
class _ControlsHoldRegionState extends State<ControlsHoldRegion> {
|
||||
@override
|
||||
void didUpdateWidget(ControlsHoldRegion oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.hold != widget.hold) oldWidget.hold?.set(this, false);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.hold?.set(this, false);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hold = widget.hold;
|
||||
if (hold == null) return widget.child;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => hold.set(this, true),
|
||||
onExit: (_) => hold.set(this, false),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Follows whether media_kit's desktop controls around [child] are up,
|
||||
/// by the same rule they use: up on mount and on any mouse movement,
|
||||
/// down [hideAfter] later or when the mouse leaves. media_kit does not
|
||||
/// expose its visibility; this lets the player draw a progress line while
|
||||
/// they are down. Never takes input.
|
||||
/// they are down. With [hold] held it keeps them (and itself) up. Never
|
||||
/// takes input.
|
||||
class ControlsHoverProbe extends StatefulWidget {
|
||||
final ValueNotifier<bool> shown;
|
||||
final Duration hideAfter;
|
||||
final ControlsHold? hold;
|
||||
final Widget child;
|
||||
const ControlsHoverProbe({super.key, required this.shown, required this.hideAfter, required this.child});
|
||||
const ControlsHoverProbe(
|
||||
{super.key, required this.shown, required this.hideAfter, this.hold, required this.child});
|
||||
|
||||
@override
|
||||
State<ControlsHoverProbe> createState() => _ControlsHoverProbeState();
|
||||
@@ -19,22 +95,67 @@ class ControlsHoverProbe extends StatefulWidget {
|
||||
|
||||
class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
|
||||
Timer? _timer;
|
||||
// While held: media_kit's own countdown is restarted before it ends.
|
||||
Timer? _keep;
|
||||
|
||||
bool get _held => widget.hold?.held ?? false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.hold?.addListener(_holdChanged);
|
||||
// Not during this build: the line listening to [shown] may be built.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _wake();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ControlsHoverProbe oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.hold != widget.hold) {
|
||||
oldWidget.hold?.removeListener(_holdChanged);
|
||||
widget.hold?.addListener(_holdChanged);
|
||||
_holdChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.hold?.removeListener(_holdChanged);
|
||||
_timer?.cancel();
|
||||
_keep?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _holdChanged() {
|
||||
if (!mounted) return;
|
||||
_keep?.cancel();
|
||||
_keep = null;
|
||||
if (_held) {
|
||||
_keep = Timer.periodic(widget.hideAfter * 0.5, (_) => _pokeControls());
|
||||
}
|
||||
_pokeControls();
|
||||
_wake();
|
||||
}
|
||||
|
||||
/// media_kit's controls state is private: a guarded dynamic call to its
|
||||
/// hover handler, as a mouse movement on it would make.
|
||||
void _pokeControls() {
|
||||
if (!mounted) return;
|
||||
void visit(Element e) {
|
||||
if (e is StatefulElement && e.state.runtimeType.toString() == '_MaterialDesktopVideoControlsState') {
|
||||
try {
|
||||
(e.state as dynamic).onHover();
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
e.visitChildElements(visit);
|
||||
}
|
||||
|
||||
context.visitChildElements(visit);
|
||||
}
|
||||
|
||||
void _wake() {
|
||||
widget.shown.value = true;
|
||||
_timer?.cancel();
|
||||
@@ -43,7 +164,8 @@ class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
|
||||
|
||||
void _hide() {
|
||||
_timer?.cancel();
|
||||
if (mounted) widget.shown.value = false;
|
||||
if (!mounted || _held) return;
|
||||
widget.shown.value = false;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -52,7 +174,9 @@ class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
|
||||
hitTestBehavior: HitTestBehavior.translucent,
|
||||
onEnter: (_) => _wake(),
|
||||
onHover: (_) => _wake(),
|
||||
onExit: (_) => _hide(),
|
||||
// Held (the mouse went onto a pill or the popover): media_kit's
|
||||
// controls just hid on their own exit; bring them straight back.
|
||||
onExit: (_) => _held ? _pokeControls() : _hide(),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
@@ -312,19 +312,18 @@ class PlayerSettingsCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
// The app's glass, like the Up next popover and the menus.
|
||||
return AppGlass(
|
||||
key: const ValueKey('player-settings-card'),
|
||||
elevation: 12,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: SizedBox(
|
||||
width: 420,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 10),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text('Player', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 16)),
|
||||
const Text('Player', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
||||
const SizedBox(height: 4),
|
||||
...playerSettingRows(renderMode: renderMode),
|
||||
if (tracks) ...[
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../../state/settings.dart';
|
||||
import '../controls_probe.dart' show ControlsHold;
|
||||
|
||||
// The mpv-style (uosc) controls frame: a full-width timeline at the
|
||||
// bottom with a row of flat buttons above it, the title top left and a
|
||||
@@ -28,6 +29,8 @@ class UoscControls extends StatefulWidget {
|
||||
final Duration hideAfter;
|
||||
/// Follows whether the controls are up (for overlays outside them).
|
||||
final ValueNotifier<bool>? shown;
|
||||
/// Keeps them up while held (the mouse on an overlay, an open popover).
|
||||
final ControlsHold? holdBy;
|
||||
const UoscControls({
|
||||
super.key,
|
||||
required this.timeline,
|
||||
@@ -38,9 +41,12 @@ class UoscControls extends StatefulWidget {
|
||||
this.onDoubleTap,
|
||||
this.hideAfter = const Duration(seconds: 3),
|
||||
this.shown,
|
||||
this.holdBy,
|
||||
});
|
||||
|
||||
static const buttonRowHeight = 40.0;
|
||||
/// Under the button row, above the timeline.
|
||||
static const rowBottomPadding = 4.0;
|
||||
|
||||
/// The controls around [context], for widgets that need them kept up
|
||||
/// (a timeline drag, an open menu).
|
||||
@@ -54,6 +60,7 @@ class UoscControls extends StatefulWidget {
|
||||
class UoscControlsState extends State<UoscControls> {
|
||||
bool _visible = true;
|
||||
int _holds = 0;
|
||||
bool _heldBy = false;
|
||||
Timer? _timer;
|
||||
|
||||
bool get visible => _visible;
|
||||
@@ -61,6 +68,7 @@ class UoscControlsState extends State<UoscControls> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.holdBy?.addListener(_holdByChanged);
|
||||
// Not during this build: an overlay listening to [shown] may be built.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) widget.shown?.value = true;
|
||||
@@ -68,12 +76,30 @@ class UoscControlsState extends State<UoscControls> {
|
||||
_hideLater();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(UoscControls oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.holdBy != widget.holdBy) {
|
||||
oldWidget.holdBy?.removeListener(_holdByChanged);
|
||||
widget.holdBy?.addListener(_holdByChanged);
|
||||
_holdByChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.holdBy?.removeListener(_holdByChanged);
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _holdByChanged() {
|
||||
final on = widget.holdBy?.held ?? false;
|
||||
if (on == _heldBy || !mounted) return;
|
||||
_heldBy = on;
|
||||
hold(on);
|
||||
}
|
||||
|
||||
/// Show the controls and restart the hide countdown.
|
||||
void wake() {
|
||||
if (!_visible && mounted) {
|
||||
@@ -185,8 +211,9 @@ class UoscControlsState extends State<UoscControls> {
|
||||
child: AnimatedOpacity(
|
||||
duration: fade,
|
||||
opacity: visible ? 1 : 0,
|
||||
// The buttons centred on the title, like the standard
|
||||
// design's top bar.
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
@@ -196,7 +223,8 @@ class UoscControlsState extends State<UoscControls> {
|
||||
: Align(alignment: AlignmentDirectional.centerStart, child: widget.topLeft),
|
||||
),
|
||||
),
|
||||
?widget.top,
|
||||
if (widget.top != null)
|
||||
Padding(padding: const EdgeInsets.fromLTRB(0, 12, 6, 0), child: widget.top),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -217,7 +245,7 @@ class UoscControlsState extends State<UoscControls> {
|
||||
duration: fade,
|
||||
opacity: visible ? 1 : 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(6, 0, 6, 4),
|
||||
padding: const EdgeInsets.fromLTRB(6, 0, 6, UoscControls.rowBottomPadding),
|
||||
child: SizedBox(
|
||||
height: UoscControls.buttonRowHeight,
|
||||
child: Row(children: [...widget.left, const Spacer(), ...widget.right]),
|
||||
|
||||
Reference in new issue
Block a user