Mini player cleanup: no full-player controls left under it, the full player's play button, a plain progress bar

media_kit keeps a Video's last controls when given null, so after the
full player the mini kept the Minimal bars (dark bands above and below
the picture, a second hatched line). The mini now passes an empty
builder. Its hover bar uses the full player's play button and time,
the status on its own line, Minimal buttons, and a painted bar instead
of Material's progress indicator (its gap and stop dot at the corner).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-16 08:14:48 +03:00
1 parent 211ba65e5e
commit 4c8e40769a
3 files changed
+133 -46

No files matched your search

+107 -45
View File
@@ -8,6 +8,8 @@ import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import '../controls/controls_probe.dart'; import '../controls/controls_probe.dart';
import '../controls/minimal/style.dart';
import '../controls/model/controls_layout.dart';
import '../controls/model/controls_model.dart'; import '../controls/model/controls_model.dart';
import '../controls/session_controls.dart'; import '../controls/session_controls.dart';
import '../controls/volume.dart'; import '../controls/volume.dart';
@@ -291,7 +293,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
_videoGen = gen; _videoGen = gen;
_videoKey = GlobalKey<VideoState>(); _videoKey = GlobalKey<VideoState>();
} }
final VideoControlsBuilder? controls = _full ? _sessionControls : null; // Never null for the mini: media_kit keeps the last controls
// when given null, so the full player's would stay under it.
final VideoControlsBuilder controls = _full ? _sessionControls : _noControls;
final video = widget.videoBuilder?.call(context, controls) ?? _video(controls); final video = widget.videoBuilder?.call(context, controls) ?? _video(controls);
// The wheel anywhere on the full player steps the volume // The wheel anywhere on the full player steps the volume
// (lists, menus and popovers under the mouse scroll instead). // (lists, menus and popovers under the mouse scroll instead).
@@ -568,11 +572,13 @@ class _SessionPlayerState extends State<SessionPlayer> {
), ),
); );
/// Mini: title, expand and close on hover; ready, time and a seekable /// Mini: title, expand and close on hover; the status, play (the full
/// bar at the bottom. A thin progress line stays while not hovered. /// player's play button: ready in a server) and the time, and a bar that
/// seeks. A thin progress line stays while not hovered.
Widget _miniOverlay() { Widget _miniOverlay() {
final coord = _coord; final coord = _coord;
final playback = coord.playback; final playback = coord.playback;
const scale = 0.8;
return MouseRegion( return MouseRegion(
onEnter: (_) => setState(() => _miniHover = true), onEnter: (_) => setState(() => _miniHover = true),
onExit: (_) => setState(() => _miniHover = false), onExit: (_) => setState(() => _miniHover = false),
@@ -589,55 +595,73 @@ class _SessionPlayerState extends State<SessionPlayer> {
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [Colors.black54, Colors.transparent, Colors.black87], stops: [0, 0.35, 0.55, 1],
colors: [Color(0x99000000), Color(0x00000000), Color(0x00000000), Color(0xCC000000)],
), ),
), ),
child: Column( child: Column(
children: [ children: [
Row( Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 6, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SizedBox(width: 10), const Expanded(
const Expanded(child: NowPlayingTitle(cover: false, compact: true)), child: Padding(
IconButton( padding: EdgeInsets.only(top: 4),
focusNode: _noFocus, child: NowPlayingTitle(cover: false, compact: true),
onPressed: widget.onExpand, ),
),
MinimalButton(
icon: Icons.open_in_full_rounded,
tooltip: 'Expand', tooltip: 'Expand',
icon: const Icon(Icons.open_in_full, color: Colors.white, size: 18), scale: scale,
), onPressed: widget.onExpand),
IconButton( MinimalButton(
focusNode: _noFocus, icon: Icons.close_rounded,
onPressed: widget.onClose,
tooltip: 'Sit out (stay in the server)', tooltip: 'Sit out (stay in the server)',
icon: const Icon(Icons.close, color: Colors.white, size: 18), scale: scale,
), onPressed: widget.onClose),
], ],
), ),
),
const Spacer(), const Spacer(),
SignalBuilder(builder: (context) { SignalBuilder(builder: (context) {
final ready = coord.selfMember.value?.ready ?? false; final snap = _model.snapshot.value;
final pos = playback.position.value, dur = playback.duration.value; final pos = playback.position.value, dur = playback.duration.value;
return Row( final status = playerStatusNow(coord)?.short;
return Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 12, 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
IconButton( if (status != null && status.isNotEmpty)
focusNode: _noFocus, Padding(
tooltip: ready ? 'Ready (click: not ready)' : 'Not ready (click: ready)', padding: const EdgeInsets.fromLTRB(4, 0, 0, 2),
onPressed: coord.toggleReady, child: Text(status,
icon: Icon(
ready ? Icons.check_circle : Icons.radio_button_unchecked,
color: ready ? Colors.greenAccent : Colors.white,
),
),
Text('${formatClock(pos)} / ${formatClock(dur)}',
style: const TextStyle(color: Colors.white70, fontSize: 12)),
const Spacer(),
Flexible(
child: Text(playerStatusNow(_coord)?.short ?? '',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.amber, fontSize: 12)), style: const TextStyle(color: Color(0xFFFFC857), fontSize: 12)),
), ),
const SizedBox(width: 10), Row(
children: [
MinimalButton(
icon: controlIcon(BarControl.playPause, snap),
tooltip: controlTooltip(BarControl.playPause, snap),
accent: true,
scale: scale,
onPressed: _model.togglePlay,
),
const SizedBox(width: 8),
Text('${formatClock(pos)} / ${formatClock(dur)}',
style: const TextStyle(
color: MinimalStyle.dim,
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()])),
], ],
),
],
),
); );
}), }),
_MiniSeekBar(onSeek: playback.userSeek), _MiniSeekBar(onSeek: playback.userSeek),
@@ -654,6 +678,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
} }
} }
/// The mini player's empty controls slot (see [SessionPlayer]'s video).
Widget _noControls(VideoState state) => const SizedBox.shrink();
/// A floating pill's visibility: only while the controls are up (the /// A floating pill's visibility: only while the controls are up (the
/// mouse moved) or the mouse is on it. With [announce] it is also up for /// mouse moved) or the mouse is on it. With [announce] it is also up for
/// [firstShown] whenever [kind] changes (a new offer: Skip OP, Skip 1:30). /// [firstShown] whenever [kind] changes (a new offer: Skip OP, Skip 1:30).
@@ -768,7 +795,9 @@ const _shaderKeys = [
LogicalKeyboardKey.digit6, LogicalKeyboardKey.digit6,
]; ];
/// Mini player progress: a 2 px line, or (hovered) a 4 px bar that seeks. /// Mini player progress across the bottom edge: a 3 px line, or (hovered)
/// a 4 px bar with its track and the cached part that seeks (taller to
/// hit). Plain rectangles: the player's rounded clip shapes the ends.
class _MiniSeekBar extends StatelessWidget { class _MiniSeekBar extends StatelessWidget {
final bool thin; final bool thin;
final ValueChanged<Duration>? onSeek; final ValueChanged<Duration>? onSeek;
@@ -777,26 +806,59 @@ class _MiniSeekBar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final playback = AppScope.of(context).coordinator.playback; final playback = AppScope.of(context).coordinator.playback;
final accent = Theme.of(context).colorScheme.primary;
return SignalBuilder(builder: (context) { return SignalBuilder(builder: (context) {
final d = playback.duration.value; final d = playback.duration.value;
final p = playback.position.value; double f(Duration x) => d.inMilliseconds == 0 ? 0.0 : (x.inMilliseconds / d.inMilliseconds).clamp(0.0, 1.0);
final v = d.inMilliseconds == 0 ? 0.0 : (p.inMilliseconds / d.inMilliseconds).clamp(0.0, 1.0); final bar = SizedBox(
final bar = LinearProgressIndicator( height: thin ? 3 : 4,
value: v, width: double.infinity,
minHeight: thin ? 2 : 4, child: CustomPaint(
backgroundColor: thin ? Colors.transparent : Colors.white24, painter: _MiniBarPainter(
played: f(playback.position.value),
cached: thin ? null : f(playback.buffered.value),
accent: accent,
),
),
); );
if (thin || onSeek == null || d == Duration.zero) return bar; if (thin || onSeek == null || d == Duration.zero) return bar;
void seekAt(double x, double width) => onSeek!(d * (x / width).clamp(0.0, 1.0)); void seekAt(double x, double width) => onSeek!(d * (x / width).clamp(0.0, 1.0));
return LayoutBuilder( return LayoutBuilder(
builder: (context, box) => GestureDetector( builder: (context, box) => MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTapDown: (e) => seekAt(e.localPosition.dx, box.maxWidth), onTapDown: (e) => seekAt(e.localPosition.dx, box.maxWidth),
onHorizontalDragEnd: null,
onHorizontalDragUpdate: (e) => seekAt(e.localPosition.dx, box.maxWidth), onHorizontalDragUpdate: (e) => seekAt(e.localPosition.dx, box.maxWidth),
child: Padding(padding: const EdgeInsets.symmetric(vertical: 6), child: bar), child: Padding(padding: const EdgeInsets.only(top: 8), child: bar),
),
), ),
); );
}); });
} }
} }
class _MiniBarPainter extends CustomPainter {
final double played;
/// End of the cache ahead (a track is drawn only with it).
final double? cached;
final Color accent;
const _MiniBarPainter({required this.played, required this.cached, required this.accent});
@override
void paint(Canvas canvas, Size size) {
final w = size.width, h = size.height;
final c = cached;
if (c != null) {
canvas.drawRect(Offset.zero & size, Paint()..color = Colors.white.withValues(alpha: 0.18));
if (c > played) {
canvas.drawRect(Rect.fromLTRB(w * played, 0, w * c, h), Paint()..color = Colors.white.withValues(alpha: 0.3));
}
}
canvas.drawRect(Rect.fromLTRB(0, 0, w * played, h), Paint()..color = accent);
}
@override
bool shouldRepaint(_MiniBarPainter old) => old.played != played || old.cached != cached || old.accent != accent;
}
+19
View File
@@ -418,6 +418,25 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets('the mini player clears the full player\'s controls (media_kit keeps the last ones on null)',
(tester) async {
coord.playback.alive.value = true;
final given = <PlayerMode, Object?>{};
PlayerMode mode() => tester.state<AppShellState>(find.byType(AppShell)).mode;
await pumpShell(tester, const Size(1280, 800), video: (context, controls) {
given[mode()] = controls;
return const ColoredBox(color: Color(0xFF7A3B2E));
});
online(party);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('tab-settings')));
await tester.pumpAndSettle();
expect(mode(), PlayerMode.mini);
expect(given[PlayerMode.full], isNotNull);
expect(given[PlayerMode.mini], isNotNull, reason: 'null would keep the full controls');
expect(given[PlayerMode.mini], isNot(same(given[PlayerMode.full])));
});
testWidgets('status pill: who is not ready, with the ready count', (tester) async { testWidgets('status pill: who is not ready, with the ready count', (tester) async {
coord.playback.alive.value = true; coord.playback.alive.value = true;
await pumpShell(tester, const Size(1280, 800)); await pumpShell(tester, const Size(1280, 800));
+7 -1
View File
@@ -1958,7 +1958,13 @@ panel) and chat messages (Copy message, and a link in it).
snaps to the nearest corner with the shared curve). A mode change fades snaps to the nearest corner with the shared curve). A mode change fades
the layer out, moves it while invisible and fades it in (with the tabs the layer out, moves it while invisible and fades it in (with the tabs
when the full player comes or goes), so the video is never mounted twice when the full player comes or goes), so the video is never mounted twice
nor remounted. A tab sends the full player to mini; × on the mini sits nor remounted. The mini has no control skin: its `Video` gets an empty
controls builder (never null: media_kit keeps the last controls on
null, which left the full player's bars under the mini). Hovered it
shows the title with expand and ×, the status line, the full player's
play button (`controlIcon`/`togglePlay`: ready in a server) with the
time, and a 4 px bar (track, cached, played) that seeks; otherwise a
3 px played line along the bottom edge. A tab sends the full player to mini; × on the mini sits
out (the player unloads and shows the join-back card, `SitOutCard`, over out (the player unloads and shows the join-back card, `SitOutCard`, over
the still mounted `Video`); × on that card closes the player, the strip the still mounted `Video`); × on that card closes the player, the strip
brings it back, and joining back with it closed shows the mini. Fullscreen brings it back, and joining back with it closed shows the mini. Fullscreen