forked from Foxyz/sync_replay_project
Player controls: one model, two skins (Standard, Minimal); conventional layout; controls settings
PlayerControlsModel holds the controls' state and actions once; the Standard skin (our own, no more media_kit controls or private-state reach-ins) and Minimal (the former uosc design, made the app's own and credited) only draw it. One layout spec for both: play first, then previous/next, volume, time; subtitles, audio, settings, chat, fullscreen last; everything else in the settings menu. Settings -> Player -> Controls: time display, size, hide delay, optional buttons. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
959eed38a4
commit
fd8064611a
40 files changed
+3820
-3975
No files matched your search
@@ -0,0 +1,250 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
||||
|
||||
import '../../queue/queue_ui.dart';
|
||||
import '../../shell/now_playing_title.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../control_menus.dart';
|
||||
import '../controls_probe.dart' show ControlsHold;
|
||||
import '../frame.dart';
|
||||
import '../model/controls_layout.dart';
|
||||
import '../model/controls_model.dart';
|
||||
import '../model/controls_prefs.dart';
|
||||
import '../volume.dart';
|
||||
import 'style.dart';
|
||||
import 'timeline.dart';
|
||||
|
||||
// The Minimal skin (inspired by mpv's uosc): a timeline band across the
|
||||
// bottom that carries the clock, and above it the buttons in two glass
|
||||
// capsules, in the layout's order. Top: what plays, Up next and the window
|
||||
// controls. Hidden, the band shrinks to a thin progress line (unless
|
||||
// switched off).
|
||||
|
||||
class MinimalControls extends StatelessWidget {
|
||||
final PlayerControlsModel model;
|
||||
final bool visible;
|
||||
final double scale;
|
||||
final ControlsHold? hold;
|
||||
final Widget? windowButtons;
|
||||
const MinimalControls({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.visible,
|
||||
this.scale = 1,
|
||||
this.hold,
|
||||
this.windowButtons,
|
||||
});
|
||||
|
||||
static const _rowGap = 8.0;
|
||||
|
||||
/// The button row's height at [scale] (a capsule).
|
||||
static double rowHeight(double scale) => MinimalStyle.button * scale + 2 * MinimalStyle.capsulePad;
|
||||
|
||||
/// Buttons and timeline at [scale].
|
||||
static double bottomHeight(double scale) => rowHeight(scale) + _rowGap + MinimalTimeline.heightFor(scale);
|
||||
|
||||
/// The top bar's height.
|
||||
static const topHeight = 72.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const fade = ControlsFrame.fade;
|
||||
Widget shown(Widget child, {Key? key}) => IgnorePointer(
|
||||
key: key,
|
||||
ignoring: !visible,
|
||||
child: AnimatedOpacity(duration: fade, opacity: visible ? 1 : 0, child: child),
|
||||
);
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: shown(
|
||||
key: const ValueKey('minimal-top'),
|
||||
DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x99000000), Color(0x00000000)],
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: topHeight + 24,
|
||||
child: Padding(padding: const EdgeInsets.fromLTRB(16, 0, 10, 24), child: _top()),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
shown(
|
||||
key: const ValueKey('minimal-buttons'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, _rowGap),
|
||||
child: SizedBox(
|
||||
height: rowHeight(scale),
|
||||
child: SignalBuilder(
|
||||
builder: (context) {
|
||||
final s = model.snapshot.value;
|
||||
final layout = model.bar.value;
|
||||
// The clock lives on the timeline.
|
||||
List<Widget> cluster(List<BarControl> cs) => [
|
||||
for (final c in cs)
|
||||
if (c != BarControl.time) _control(context, c, s),
|
||||
];
|
||||
return Row(
|
||||
children: [
|
||||
MinimalCapsule(scale: scale, children: cluster(layout.left)),
|
||||
const Spacer(),
|
||||
MinimalCapsule(scale: scale, children: cluster(layout.right)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SignalBuilder(
|
||||
builder: (context) {
|
||||
final line = AppSettings.instance.progressLine.value;
|
||||
return AnimatedOpacity(
|
||||
key: const ValueKey('minimal-timeline-wrap'),
|
||||
duration: fade,
|
||||
opacity: visible || line ? 1 : 0,
|
||||
child: MinimalTimeline(
|
||||
position: model.position.value,
|
||||
duration: model.duration.value,
|
||||
buffered: model.buffered.value,
|
||||
chapters: model.chapters.value,
|
||||
expanded: visible,
|
||||
remaining: model.settings.timeDisplay.value == TimeDisplay.remaining,
|
||||
scale: scale,
|
||||
onSeek: model.seek,
|
||||
onToggleTime: model.toggleTimeDisplay,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _top() {
|
||||
final m = model;
|
||||
return Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Align(alignment: AlignmentDirectional.centerStart, child: NowPlayingTitle()),
|
||||
),
|
||||
UpNextButton(queue: m.queue, onPlayNextEpisode: m.playNextEpisode, look: UpNextLook.minimal, hold: hold),
|
||||
const SizedBox(width: 4),
|
||||
SignalBuilder(
|
||||
builder: (context) {
|
||||
final full = m.fullscreen.value;
|
||||
return MinimalButton(
|
||||
key: const ValueKey('minimal-collapse'),
|
||||
icon: full ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
|
||||
tooltip: full ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
|
||||
onPressed: full ? m.toggleFullscreen : m.collapse,
|
||||
);
|
||||
},
|
||||
),
|
||||
?windowButtons,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _control(BuildContext context, BarControl c, ControlsSnapshot s) {
|
||||
final m = model;
|
||||
final key = ValueKey('control-${c.name}');
|
||||
final enabled = controlEnabled(c, s);
|
||||
Widget button(VoidCallback? onPressed, {int? badge, bool accent = false}) => MinimalButton(
|
||||
key: key,
|
||||
icon: controlIcon(c, s),
|
||||
tooltip: controlTooltip(c, s),
|
||||
badge: badge,
|
||||
accent: accent,
|
||||
scale: scale,
|
||||
onPressed: enabled ? onPressed : null,
|
||||
);
|
||||
switch (c) {
|
||||
case BarControl.playPause:
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(right: 4 * scale),
|
||||
child: button(m.togglePlay, accent: true),
|
||||
);
|
||||
case BarControl.previous:
|
||||
return button(m.previous);
|
||||
case BarControl.next:
|
||||
return button(m.next);
|
||||
case BarControl.chapterPrevious:
|
||||
return button(() => m.stepChapter(-1));
|
||||
case BarControl.chapterNext:
|
||||
return button(() => m.stepChapter(1));
|
||||
case BarControl.volume:
|
||||
return SignalBuilder(
|
||||
key: key,
|
||||
builder: (context) => VolumeControl(
|
||||
volume: m.volume.value,
|
||||
onChanged: m.setVolume,
|
||||
onToggleMute: m.toggleMute,
|
||||
sliderColor: Theme.of(context).colorScheme.primary,
|
||||
trackColor: MinimalStyle.dim.withValues(alpha: 0.4),
|
||||
button: (context, i, color, tip, onPressed) =>
|
||||
MinimalButton(icon: i, tooltip: tip, color: color, scale: scale, onPressed: onPressed),
|
||||
),
|
||||
);
|
||||
case BarControl.time:
|
||||
return const SizedBox.shrink();
|
||||
case BarControl.subtitles || BarControl.audio:
|
||||
final type = c == BarControl.audio ? JellyfinStreamType.audio : JellyfinStreamType.subtitle;
|
||||
return SignalBuilder(
|
||||
key: key,
|
||||
builder: (context) => Builder(
|
||||
builder: (context) => MinimalButton(
|
||||
icon: controlIcon(c, s),
|
||||
tooltip: controlTooltip(c, s),
|
||||
badge: trackCount(m.tracks.value, type),
|
||||
scale: scale,
|
||||
onPressed: () => openTrackMenu(context, m, type),
|
||||
),
|
||||
),
|
||||
);
|
||||
case BarControl.settings:
|
||||
return Builder(
|
||||
key: key,
|
||||
builder: (context) => MinimalButton(
|
||||
icon: controlIcon(c, s),
|
||||
tooltip: controlTooltip(c, s),
|
||||
scale: scale,
|
||||
onPressed: () => openControlsMenu(context, m),
|
||||
),
|
||||
);
|
||||
case BarControl.chat:
|
||||
return SignalBuilder(
|
||||
key: key,
|
||||
builder: (context) => MinimalButton(
|
||||
icon: controlIcon(c, s),
|
||||
tooltip: controlTooltip(c, s),
|
||||
badge: m.unread.value,
|
||||
scale: scale,
|
||||
onPressed: m.toggleChat,
|
||||
),
|
||||
);
|
||||
case BarControl.fullscreen:
|
||||
return button(m.toggleFullscreen);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../widgets/app_menu.dart' show AppMenuStyle;
|
||||
|
||||
// The Minimal skin's look: the app's glass and accent (the theme's primary)
|
||||
// on a thick timeline band, with soft rounded buttons in two glass capsules.
|
||||
|
||||
abstract final class MinimalStyle {
|
||||
/// The timeline band (no blur: it spans the width over the picture).
|
||||
static const band = Color(0xCC0E0E12);
|
||||
|
||||
/// The capsules holding the buttons: the player pills' glass.
|
||||
static const capsule = AppMenuStyle.pillGlass;
|
||||
static const hover = Color(0x1FFFFFFF);
|
||||
static const text = Colors.white;
|
||||
static const dim = Color(0xB3FFFFFF);
|
||||
static const radius = 10.0;
|
||||
static const fontSize = 13.0;
|
||||
|
||||
/// The tooltips and the timeline's hover tip.
|
||||
static const tip = Color(0xEB18181C);
|
||||
|
||||
/// A button's box at scale 1.
|
||||
static const button = 38.0;
|
||||
|
||||
/// The capsules' padding around the buttons.
|
||||
static const capsulePad = 4.0;
|
||||
}
|
||||
|
||||
/// The Minimal skin's tooltip: the app's dark glass colour, above.
|
||||
class MinimalTooltip extends StatelessWidget {
|
||||
final String message;
|
||||
final Widget child;
|
||||
const MinimalTooltip({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: 28,
|
||||
waitDuration: const Duration(milliseconds: 300),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: MinimalStyle.tip,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppMenuStyle.border, width: 0.5),
|
||||
),
|
||||
textStyle: const TextStyle(color: MinimalStyle.text, fontSize: 12),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A Minimal button: a white icon on a rounded square that lights on hover
|
||||
/// ([accent]: filled with the theme's primary, the play button), an
|
||||
/// optional [label] and a count [badge]. Never takes focus.
|
||||
class MinimalButton extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback? onPressed;
|
||||
final String? label;
|
||||
final int? badge;
|
||||
final bool accent;
|
||||
final Color? color;
|
||||
final double scale;
|
||||
const MinimalButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.onPressed,
|
||||
this.label,
|
||||
this.badge,
|
||||
this.accent = false,
|
||||
this.color,
|
||||
this.scale = 1,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MinimalButton> createState() => _MinimalButtonState();
|
||||
}
|
||||
|
||||
class _MinimalButtonState extends State<MinimalButton> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final w = widget;
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
final onPrimary = Theme.of(context).colorScheme.onPrimary;
|
||||
final enabled = w.onPressed != null;
|
||||
final size = MinimalStyle.button * w.scale;
|
||||
final fg = !enabled
|
||||
? MinimalStyle.dim.withValues(alpha: 0.35)
|
||||
: w.accent
|
||||
? onPrimary
|
||||
: (w.color ?? MinimalStyle.text);
|
||||
final bg = w.accent
|
||||
? (_hover ? Color.lerp(primary, Colors.white, 0.18)! : primary)
|
||||
: (_hover && enabled ? MinimalStyle.hover : Colors.transparent);
|
||||
final badge = w.badge;
|
||||
return MinimalTooltip(
|
||||
message: w.tooltip,
|
||||
child: MouseRegion(
|
||||
cursor: enabled ? SystemMouseCursors.click : SystemMouseCursors.basic,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: w.onPressed,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
height: size,
|
||||
constraints: BoxConstraints(minWidth: size),
|
||||
padding: EdgeInsets.symmetric(horizontal: w.label == null ? 0 : 10 * w.scale),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(w.accent ? size / 2 : MinimalStyle.radius * w.scale),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Icon(w.icon, color: fg, size: (w.accent ? 26 : 22) * w.scale),
|
||||
if (badge != null && badge > 0)
|
||||
Positioned(
|
||||
right: -7,
|
||||
top: -5,
|
||||
child: Container(
|
||||
key: const ValueKey('minimal-badge'),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(color: primary, borderRadius: BorderRadius.circular(999)),
|
||||
child: Text(
|
||||
'$badge',
|
||||
style: TextStyle(color: onPrimary, fontSize: 9, height: 1.35, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (w.label != null) ...[
|
||||
SizedBox(width: 6 * w.scale),
|
||||
Text(
|
||||
w.label!,
|
||||
style: TextStyle(
|
||||
color: fg,
|
||||
fontSize: MinimalStyle.fontSize * w.scale,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A glass capsule around a row of Minimal buttons.
|
||||
class MinimalCapsule extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
final double scale;
|
||||
const MinimalCapsule({super.key, required this.children, this.scale = 1});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final r = BorderRadius.circular(MinimalStyle.radius * scale + MinimalStyle.capsulePad);
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: MinimalStyle.capsule,
|
||||
borderRadius: r,
|
||||
border: Border.all(color: AppMenuStyle.border, width: 0.5),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(MinimalStyle.capsulePad),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, spacing: 2, children: children),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../playback/media_marks.dart';
|
||||
import '../frame.dart';
|
||||
import 'style.dart';
|
||||
|
||||
// The Minimal skin's timeline: a band across the bottom with the played
|
||||
// part tinted in the accent and the position as a line, the part not
|
||||
// downloaded yet striped, chapter starts as dots on the top edge (click one
|
||||
// to jump), the elapsed time left and the total or the time left right,
|
||||
// the buffered seconds by the position while under a minute, and a tip
|
||||
// with the time (and the chapter title) under the mouse. Hidden, it shrinks
|
||||
// to a thin progress line.
|
||||
//
|
||||
// Inspired by the timeline of uosc (github.com/tomasklaen/uosc, LGPL-2.1,
|
||||
// by tomasklaen): the striped unbuffered part, the remaining time and the
|
||||
// thin line when hidden come from there. The drawing, geometry and values
|
||||
// here are our own.
|
||||
|
||||
/// Sizes at scale 1.
|
||||
abstract final class MinimalTimelineSpec {
|
||||
/// The band's height while the controls are up.
|
||||
static const size = 36.0;
|
||||
|
||||
/// What is left of it while they are hidden.
|
||||
static const lineSize = 3.0;
|
||||
|
||||
/// The position line.
|
||||
static const lineWidth = 2.0;
|
||||
static const fontSize = 15.0;
|
||||
|
||||
/// The times' inset from the ends.
|
||||
static const inset = 12.0;
|
||||
|
||||
/// A chapter dot's radius (doubled-ish while hovered).
|
||||
static const dot = 3.5;
|
||||
|
||||
/// The cache label shows while less than this is buffered ahead.
|
||||
static const bufferedThreshold = Duration(seconds: 60);
|
||||
|
||||
/// Collapse / grow.
|
||||
static const animation = Duration(milliseconds: 120);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// Where the part not downloaded yet starts (it runs to the end), as a
|
||||
/// fraction; null when nothing known is missing. We know only the end of
|
||||
/// mpv's cache ahead ([buffered]); a cache within half a second of the end
|
||||
/// leaves nothing.
|
||||
double? uncachedFrom({required Duration position, required Duration buffered, required Duration duration}) {
|
||||
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
|
||||
final from = buffered > position ? buffered : position;
|
||||
if (duration - from <= const Duration(milliseconds: 500)) return null;
|
||||
return timelineFraction(from, duration);
|
||||
}
|
||||
|
||||
/// The cache label by the position ("12s"): the seconds buffered ahead,
|
||||
/// while under [MinimalTimelineSpec.bufferedThreshold] and the cache does
|
||||
/// not reach the end.
|
||||
String? cacheLabel({required Duration position, required Duration buffered, required Duration duration}) {
|
||||
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
|
||||
if (duration - buffered <= const Duration(milliseconds: 500)) return null;
|
||||
final ahead = buffered > position ? buffered - position : Duration.zero;
|
||||
if (ahead >= MinimalTimelineSpec.bufferedThreshold) return null;
|
||||
return '${(ahead.inMilliseconds / 1000).round()}s';
|
||||
}
|
||||
|
||||
/// The band's height while it animates between hidden (0) and shown (1).
|
||||
double minimalBandSize(double visibility, double scale) =>
|
||||
MinimalTimelineSpec.lineSize +
|
||||
((MinimalTimelineSpec.size * scale - MinimalTimelineSpec.lineSize) * visibility).roundToDouble();
|
||||
|
||||
/// The Minimal timeline. [expanded] false (the controls hidden) shrinks it,
|
||||
/// animated, to the progress line. Seeks ([onSeek]) on press, on every drag
|
||||
/// move and on release; a press on a hovered chapter dot jumps to that
|
||||
/// chapter, one on the right-hand time switches it ([onToggleTime]).
|
||||
class MinimalTimeline extends StatefulWidget {
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
|
||||
/// End of the cached range.
|
||||
final Duration buffered;
|
||||
final List<MediaChapter> chapters;
|
||||
final bool expanded;
|
||||
|
||||
/// The right-hand time: the time left instead of the total.
|
||||
final bool remaining;
|
||||
final double scale;
|
||||
final ValueChanged<Duration> onSeek;
|
||||
final VoidCallback? onToggleTime;
|
||||
const MinimalTimeline({
|
||||
super.key,
|
||||
required this.position,
|
||||
required this.duration,
|
||||
this.buffered = Duration.zero,
|
||||
this.chapters = const [],
|
||||
this.expanded = true,
|
||||
this.remaining = true,
|
||||
this.scale = 1,
|
||||
required this.onSeek,
|
||||
this.onToggleTime,
|
||||
});
|
||||
|
||||
static double heightFor(double scale) => MinimalTimelineSpec.size * scale;
|
||||
static const collapsedHeight = MinimalTimelineSpec.lineSize;
|
||||
|
||||
@override
|
||||
State<MinimalTimeline> createState() => _MinimalTimelineState();
|
||||
}
|
||||
|
||||
class _MinimalTimelineState extends State<MinimalTimeline> {
|
||||
Offset? _hover;
|
||||
bool _dragging = false;
|
||||
double _dragFraction = 0;
|
||||
Duration? _lastSent;
|
||||
// A released seek, shown until the position catches up (else the line
|
||||
// jumps back to the old position for a moment).
|
||||
double? _pending;
|
||||
Timer? _pendingTimer;
|
||||
|
||||
double get _scale => widget.scale;
|
||||
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
|
||||
|
||||
void _send(Duration d) {
|
||||
if (d == _lastSent) return;
|
||||
_lastSent = d;
|
||||
widget.onSeek(d);
|
||||
}
|
||||
|
||||
void _drag(bool on) {
|
||||
if (_dragging == on) return;
|
||||
setState(() => _dragging = on);
|
||||
ControlsFrame.maybeOf(context)?.hold(on);
|
||||
}
|
||||
|
||||
void _settle(double fraction) {
|
||||
_pending = fraction;
|
||||
_pendingTimer?.cancel();
|
||||
_pendingTimer = Timer(const Duration(seconds: 2), () {
|
||||
if (mounted) setState(() => _pending = null);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(MinimalTimeline old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (!widget.expanded && old.expanded) _hover = null;
|
||||
final p = _pending;
|
||||
if (p != null && widget.duration > Duration.zero) {
|
||||
final off = (widget.position - _at(p)).abs();
|
||||
if (off < const Duration(milliseconds: 1500)) {
|
||||
_pending = null;
|
||||
_pendingTimer?.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pendingTimer?.cancel();
|
||||
// A drag cut short by a rebuild must not keep the controls up.
|
||||
if (_dragging) ControlsFrame.maybeOf(context)?.hold(false);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double _dotRadius(bool hovered) => MinimalTimelineSpec.dot * _scale * (hovered ? 1.7 : 1);
|
||||
|
||||
/// The chapter whose dot is within reach of [at].
|
||||
int? _hoveredChapter(double width, List<MediaChapter> marks, Offset? at) {
|
||||
if (at == null || _dragging) return null;
|
||||
final reach = _dotRadius(false) * 2.5;
|
||||
int? best;
|
||||
var bestD = double.infinity;
|
||||
for (var i = 0; i < marks.length; i++) {
|
||||
final cx = width * timelineFraction(marks[i].start, widget.duration);
|
||||
final d = (Offset(cx, 0) - at).distance;
|
||||
if (d <= reach && d < bestD) {
|
||||
best = i;
|
||||
bestD = d;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
String? _rightText(Duration shown) {
|
||||
final d = widget.duration;
|
||||
if (d <= Duration.zero) return null;
|
||||
if (!widget.remaining) return formatClock(d);
|
||||
final left = d - shown;
|
||||
return '-${formatClock(left.isNegative ? Duration.zero : left)}';
|
||||
}
|
||||
|
||||
// The painter's text takes the app's font (a TextPainter does not
|
||||
// inherit it).
|
||||
String? _font;
|
||||
|
||||
TextStyle get _timeStyle => TextStyle(
|
||||
fontFamily: _font,
|
||||
color: MinimalStyle.text,
|
||||
fontSize: MinimalTimelineSpec.fontSize * _scale,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
);
|
||||
|
||||
/// Where the right-hand time sits (a press there switches it).
|
||||
bool _onRightTime(Offset at, double width, String? text) {
|
||||
if (text == null || widget.onToggleTime == null) return false;
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(text: text, style: _timeStyle),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
return at.dx >= width - MinimalTimelineSpec.inset * _scale - tp.width - 6;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_font = DefaultTextStyle.of(context).style.fontFamily ?? Theme.of(context).textTheme.bodyMedium?.fontFamily;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: widget.expanded ? 1 : 0),
|
||||
duration: MinimalTimelineSpec.animation,
|
||||
builder: (context, visibility, _) => _band(context, constraints.maxWidth, visibility),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _band(BuildContext context, double width, double visibility) {
|
||||
final expanded = widget.expanded;
|
||||
final duration = widget.duration;
|
||||
final size = minimalBandSize(visibility, _scale);
|
||||
final progress = _dragging ? _dragFraction : (_pending ?? timelineFraction(widget.position, duration));
|
||||
final shown = _at(progress);
|
||||
final marks = duration > Duration.zero ? widget.chapters : const <MediaChapter>[];
|
||||
final hover = expanded ? _hover : null;
|
||||
final hovered = _hoveredChapter(width, marks, hover);
|
||||
final hoverFraction = hovered != null
|
||||
? timelineFraction(marks[hovered].start, duration)
|
||||
: (hover == null || duration <= Duration.zero || width <= 0 ? null : (hover.dx / width).clamp(0.0, 1.0));
|
||||
final hoverAt = hoverFraction == null ? null : _at(hoverFraction);
|
||||
final cursorX = hoverFraction == null ? null : width * hoverFraction;
|
||||
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
|
||||
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
|
||||
// Text fades out before it would overflow the shrinking band.
|
||||
final minText = MinimalTimelineSpec.fontSize * _scale;
|
||||
final textOpacity = ((size - minText) / (minText / 2)).clamp(0.0, 1.0);
|
||||
final right = _rightText(shown);
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
final painter = MinimalTimelinePainter(
|
||||
progress: progress,
|
||||
visibility: visibility,
|
||||
accent: primary,
|
||||
scale: _scale,
|
||||
uncached: uncachedFrom(position: widget.position, buffered: widget.buffered, duration: duration),
|
||||
chapters: [for (final c in marks) timelineFraction(c.start, duration)],
|
||||
hoveredChapter: hovered,
|
||||
hoverX: hovered == null ? cursorX : null,
|
||||
textOpacity: textOpacity,
|
||||
elapsed: duration > Duration.zero ? formatClock(shown) : null,
|
||||
right: right,
|
||||
cache: cacheLabel(position: widget.position, buffered: widget.buffered, duration: duration),
|
||||
timeStyle: _timeStyle,
|
||||
);
|
||||
final bar = SizedBox(
|
||||
key: const ValueKey('minimal-timeline'),
|
||||
width: width,
|
||||
height: size,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: RepaintBoundary(child: CustomPaint(painter: painter)),
|
||||
),
|
||||
if (hoverAt != null && cursorX != null)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: size + 8,
|
||||
height: 120,
|
||||
child: IgnorePointer(
|
||||
child: CustomMultiChildLayout(
|
||||
delegate: _TipLayout(x: cursorX),
|
||||
children: [
|
||||
LayoutId(
|
||||
id: #tip,
|
||||
child: _HoverTip(time: formatClock(hoverAt), chapter: hoverTitle, scale: _scale),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (!expanded) return IgnorePointer(child: bar);
|
||||
Offset clampTo(Offset p) => Offset(p.dx.clamp(0.0, width), p.dy);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (e) => setState(() => _hover = clampTo(e.localPosition)),
|
||||
onHover: (e) => setState(() => _hover = clampTo(e.localPosition)),
|
||||
onExit: (_) {
|
||||
if (!_dragging) setState(() => _hover = null);
|
||||
},
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: (e) {
|
||||
if (width <= 0) return;
|
||||
final at = clampTo(e.localPosition);
|
||||
if (_onRightTime(at, width, right)) {
|
||||
widget.onToggleTime?.call();
|
||||
return;
|
||||
}
|
||||
if (duration <= Duration.zero) return;
|
||||
final chapter = _hoveredChapter(width, marks, at);
|
||||
_lastSent = null;
|
||||
if (chapter != null) {
|
||||
_settle(timelineFraction(marks[chapter].start, duration));
|
||||
_send(marks[chapter].start);
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
_pending = null;
|
||||
_dragFraction = (at.dx / width).clamp(0.0, 1.0);
|
||||
_drag(true);
|
||||
_send(_at(_dragFraction));
|
||||
},
|
||||
onPointerMove: (e) {
|
||||
if (!_dragging) return;
|
||||
final at = clampTo(e.localPosition);
|
||||
setState(() {
|
||||
_hover = at;
|
||||
_dragFraction = (at.dx / width).clamp(0.0, 1.0);
|
||||
});
|
||||
_send(_at(_dragFraction));
|
||||
},
|
||||
onPointerUp: (e) {
|
||||
if (!_dragging) return;
|
||||
_send(_at(_dragFraction));
|
||||
_settle(_dragFraction);
|
||||
final p = e.localPosition;
|
||||
if (p.dx < 0 || p.dx > width || p.dy < 0 || p.dy > size) _hover = null;
|
||||
_drag(false);
|
||||
},
|
||||
onPointerCancel: (_) => _drag(false),
|
||||
child: bar,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HoverTip extends StatelessWidget {
|
||||
final String time;
|
||||
final String chapter;
|
||||
final double scale;
|
||||
const _HoverTip({required this.time, required this.chapter, required this.scale});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
padding: EdgeInsets.symmetric(horizontal: 10 * scale, vertical: 5 * scale),
|
||||
decoration: BoxDecoration(
|
||||
color: MinimalStyle.tip,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0x1FFFFFFF), width: 0.5),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (chapter.isNotEmpty)
|
||||
Text(
|
||||
chapter,
|
||||
key: const ValueKey('minimal-hover-chapter'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: MinimalStyle.text, fontSize: 13 * scale, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
time,
|
||||
key: const ValueKey('minimal-hover-time'),
|
||||
style: TextStyle(
|
||||
color: chapter.isEmpty ? MinimalStyle.text : MinimalStyle.dim,
|
||||
fontSize: 13 * scale,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The hover tip centred on [x], kept inside the band's ends.
|
||||
class _TipLayout extends MultiChildLayoutDelegate {
|
||||
final double x;
|
||||
_TipLayout({required this.x});
|
||||
|
||||
@override
|
||||
void performLayout(Size size) {
|
||||
const gap = 4.0;
|
||||
final tip = layoutChild(#tip, BoxConstraints.loose(size));
|
||||
final left = (x - tip.width / 2).clamp(gap, math.max(gap, size.width - gap - tip.width)).toDouble();
|
||||
positionChild(#tip, Offset(left, size.height - tip.height));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_TipLayout old) => old.x != x;
|
||||
}
|
||||
|
||||
/// Paints the band: background, the played tint and the (white) position
|
||||
/// line, the stripes over the part not downloaded, chapter marks, the
|
||||
/// times, the hover line.
|
||||
class MinimalTimelinePainter extends CustomPainter {
|
||||
/// The playing position, 0..1.
|
||||
final double progress;
|
||||
|
||||
/// 0 hidden .. 1 shown.
|
||||
final double visibility;
|
||||
final Color accent;
|
||||
final double scale;
|
||||
|
||||
/// Start of the part not downloaded (to the end), as a fraction.
|
||||
final double? uncached;
|
||||
|
||||
/// Chapter starts, as fractions.
|
||||
final List<double> chapters;
|
||||
final int? hoveredChapter;
|
||||
final double? hoverX;
|
||||
final double textOpacity;
|
||||
final String? elapsed;
|
||||
final String? right;
|
||||
final String? cache;
|
||||
final TextStyle timeStyle;
|
||||
const MinimalTimelinePainter({
|
||||
required this.progress,
|
||||
this.visibility = 1,
|
||||
this.accent = Colors.white,
|
||||
this.scale = 1,
|
||||
this.uncached,
|
||||
this.chapters = const [],
|
||||
this.hoveredChapter,
|
||||
this.hoverX,
|
||||
this.textOpacity = 1,
|
||||
this.elapsed,
|
||||
this.right,
|
||||
this.cache,
|
||||
this.timeStyle = const TextStyle(fontSize: MinimalTimelineSpec.fontSize),
|
||||
});
|
||||
|
||||
/// x of the position line's centre.
|
||||
double lineX(double width) {
|
||||
final lw = MinimalTimelineSpec.lineWidth * scale;
|
||||
return (width * progress).clamp(lw / 2, math.max(lw / 2, width - lw / 2));
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final w = size.width, h = size.height;
|
||||
final x = lineX(w);
|
||||
final band = Offset.zero & size;
|
||||
// The band, darker as it grows; hidden, a faint track.
|
||||
canvas.drawRect(band, Paint()..color = Color.lerp(const Color(0x40FFFFFF), MinimalStyle.band, visibility)!);
|
||||
// Played: an accent tint, solid once the band is a line.
|
||||
canvas.drawRect(
|
||||
Rect.fromLTRB(0, 0, x, h),
|
||||
Paint()..color = accent.withValues(alpha: 0.28 + 0.72 * (1 - visibility)),
|
||||
);
|
||||
_stripes(canvas, w, h);
|
||||
_chapters(canvas, w, h);
|
||||
if (visibility > 0) {
|
||||
final lw = MinimalTimelineSpec.lineWidth * scale;
|
||||
canvas.drawRect(
|
||||
Rect.fromLTRB(x - lw / 2, 0, x + lw / 2, h),
|
||||
Paint()..color = Colors.white.withValues(alpha: visibility),
|
||||
);
|
||||
}
|
||||
if (textOpacity > 0) _times(canvas, w, h, x);
|
||||
final hx = hoverX;
|
||||
if (hx != null) {
|
||||
canvas.drawRect(Rect.fromLTRB(hx - 0.5, 0, hx + 0.5, h), Paint()..color = Colors.white.withValues(alpha: 0.45));
|
||||
}
|
||||
}
|
||||
|
||||
/// Thin 45° stripes over the part not downloaded yet.
|
||||
void _stripes(Canvas canvas, double w, double h) {
|
||||
final from = uncached;
|
||||
if (from == null) return;
|
||||
final ax = (w * from).floorToDouble();
|
||||
if (ax >= w) return;
|
||||
final period = 7.0 * scale;
|
||||
canvas.save();
|
||||
canvas.clipRect(Rect.fromLTRB(ax, 0, w, h));
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.10 + 0.08 * (1 - visibility))
|
||||
..strokeWidth = 1.5 * scale;
|
||||
for (var c = (ax / period).floorToDouble() * period - h; c <= w; c += period) {
|
||||
canvas.drawLine(Offset(c, h), Offset(c + h, 0), paint);
|
||||
}
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
void _chapters(Canvas canvas, double w, double h) {
|
||||
final cut = Paint()..color = Colors.black.withValues(alpha: 0.55);
|
||||
for (final f in chapters) {
|
||||
if (f <= 0) continue;
|
||||
final cx = w * f;
|
||||
canvas.drawRect(Rect.fromLTRB(cx - 0.5, 0, cx + 0.5, h), cut);
|
||||
}
|
||||
if (visibility <= 0) return;
|
||||
final r = MinimalTimelineSpec.dot * scale * visibility;
|
||||
void dot(double f, double radius, Color fill) {
|
||||
final c = Offset(w * f, 0);
|
||||
canvas.drawCircle(c, radius + 1, Paint()..color = Colors.black.withValues(alpha: 0.7));
|
||||
canvas.drawCircle(c, radius, Paint()..color = fill);
|
||||
}
|
||||
|
||||
// Not at 0: the band's start says that already.
|
||||
for (var i = 0; i < chapters.length; i++) {
|
||||
if (i != hoveredChapter && chapters[i] > 0) dot(chapters[i], r, Colors.white.withValues(alpha: 0.9));
|
||||
}
|
||||
final hv = hoveredChapter;
|
||||
if (hv != null && hv < chapters.length) dot(chapters[hv], r * 1.7, accent);
|
||||
}
|
||||
|
||||
TextPainter _text(String s, TextStyle style) => TextPainter(
|
||||
text: TextSpan(text: s, style: style),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
|
||||
/// White text with a soft shadow, so it reads over the accent tint too.
|
||||
void _label(Canvas canvas, String s, TextStyle style, Offset at) {
|
||||
final o = textOpacity;
|
||||
_text(
|
||||
s,
|
||||
style.copyWith(
|
||||
color: (style.color ?? Colors.white).withValues(alpha: o),
|
||||
shadows: [Shadow(color: Colors.black.withValues(alpha: 0.8 * o), blurRadius: 3)],
|
||||
),
|
||||
).paint(canvas, at);
|
||||
}
|
||||
|
||||
void _times(Canvas canvas, double w, double h, double lineX) {
|
||||
final inset = MinimalTimelineSpec.inset * scale;
|
||||
final fs = timeStyle.fontSize ?? MinimalTimelineSpec.fontSize;
|
||||
final cy = h / 2;
|
||||
final e = elapsed, r = right;
|
||||
final ew = e == null ? 0.0 : _text(e, timeStyle).width;
|
||||
final rw = r == null ? 0.0 : _text(r, timeStyle).width;
|
||||
final c = cache;
|
||||
if (c != null) {
|
||||
final cs = timeStyle.copyWith(fontSize: fs * 0.8, color: MinimalStyle.dim);
|
||||
final cw = _text(c, cs).width;
|
||||
const margin = 6.0;
|
||||
var x = lineX + margin;
|
||||
final minX = inset + margin + ew, maxX = w - inset - margin - rw;
|
||||
if (x < minX) {
|
||||
x = minX;
|
||||
} else if (x + cw > maxX) {
|
||||
x = maxX - cw;
|
||||
}
|
||||
_label(canvas, c, cs, Offset(x, cy - fs * 0.4));
|
||||
}
|
||||
if (e != null) _label(canvas, e, timeStyle, Offset(inset, cy - fs / 2));
|
||||
if (r != null) _label(canvas, r, timeStyle, Offset(w - inset - rw, cy - fs / 2));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(MinimalTimelinePainter old) => true;
|
||||
}
|
||||
Reference in new issue
Block a user