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:
1 parent
211ba65e5e
commit
4c8e40769a
3 files changed
+133
-46
No files matched your search
@@ -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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user