video: desktop controls, shared scrubber, lifecycle guard and first-frame gate; zoomable and adapter tweaks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-13 01:28:25 +03:00
1 parent 9b1c8fa368
commit c0ab4a828b
12 files changed
+936 -9

No files matched your search

+42
View File
@@ -128,3 +128,45 @@ LeafViewer(
- All viewer code lives in `lib/src/viewer/` — zoom math in
`leaf_zoom_controller.dart` (property-tested), gesture arbitration in
`leaf_pan_zoom_recognizer.dart` (ported from photo_view, MIT).
## Video
Player-agnostic video kit — the app picks an engine by implementing one
~80-line adapter; page, lifecycle, and controls come free.
- `VideoPlayerAdapter` (`lib/src/video/leaf_video_adapter.dart`) — the only
seam: `surface`, `load(url, headers)`, play/pause/seek, position/buffer/
playing/ready/error streams + sync getters, `dispose()`. Implementations
live outside this package so it stays dependency-free. The `HasVolume`
mixin adds volume for adapters that support it; controls show volume UI
only then.
- `LeafVideoPage` — owns the full lifecycle: neighbor pages preload paused,
settled page autoplays, route-cover/background pause with
resume-if-was-playing, error/retry, thumbnail until `readyStream`. Works
standalone (always-active, permanent controls), but single-active gating
needs a `LeafViewer` ancestor. `controlsBuilder` defaults to touch
controls on mobile, desktop controls on desktop.
- `LeafVideoControls` — touch-first: double-tap thirds seek, tap toggles
chrome, scrubber + time + fullscreen slot.
- `LeafDesktopVideoControls` — click toggles (immediate `onTapDown`, since
`onTap` would wait out the double-tap timeout on every click),
double-click fullscreen, self-hiding bar + cursor, hover time bubble,
volume slider when `HasVolume`.
- `LeafScrubber` — shared scrubber (buffer/position tracks, tap-to-seek,
drag-to-scrub, optional knob + hover preview); public for custom bars.
- `VideoLifecycleGuard` — background + route-cover pause for custom viewers
that don't use `LeafVideoPage`. Callback-driven (`isPlaying`/`onPause`/
`onResume`, `onResume` nullable for pause-only consumers). Cover detection
via `ModalRoute.isCurrent` (no `RouteObserver` needed); callbacks fire
post-frame so they may call ancestor `setState`.
- `FirstFrameGate` — caps concurrent first-frame decodes across feed tiles
(default 2). `await enter()` before opening media, `exit()` on frame,
playback start, or dispose. Pure logic, headless-tested.
Thumbnail is always app-provided (`thumbnail:` slot) — BlurHash, API
preview, or nothing (spinner). Volume persistence, engine toggles, and the
concrete adapters are app-side (see `video_adapter_media_kit`).
Tests: `test/video/` runs fake-adapter widget tests (gating, retry,
thumbnail-drop, cover flow, guard, desktop controls, gate). Fake-timer
note: pump a duration after taps — gesture deadlines don't advance alone.
+4
View File
@@ -7,8 +7,12 @@ export 'src/misc/animated_favorite_button.dart';
export 'src/misc/animated_star.dart';
export 'src/misc/leaf_async_chip.dart';
export 'src/popover/leaf_popover.dart';
export 'src/video/leaf_desktop_video_controls.dart';
export 'src/video/first_frame_gate.dart';
export 'src/video/leaf_scrubber.dart';
export 'src/video/leaf_video_adapter.dart';
export 'src/video/leaf_video_controls.dart';
export 'src/video/video_lifecycle.dart';
export 'src/video/leaf_video_page.dart';
export 'src/viewer/leaf_chrome.dart';
export 'src/viewer/leaf_viewer.dart';
+47
View File
@@ -0,0 +1,47 @@
import 'dart:async';
import 'dart:collection';
/// Caps concurrent first-frame decodes across feed tiles.
///
/// The expensive moment is a video's first decode + first texture present.
/// Preloading N tiles at build bursts N of those at once and drops scroll
/// frames. Tiles `await` [enter] before opening media; the gate lets
/// [maxConcurrent] through and queues the rest, releasing the next waiter on
/// [exit] (first frame shown, playback started, or tile disposed).
/// Player-agnostic: works for media_kit and native players alike.
class FirstFrameGate {
/// Maximum simultaneous first-frame decodes. Two suits midrange SoCs.
FirstFrameGate({this.maxConcurrent = 2}) : assert(maxConcurrent > 0);
final int maxConcurrent;
int _active = 0;
final _waiters = Queue<Completer<void>>();
/// Waits for a decode slot. Completes immediately when one is free.
Future<void> enter() {
if (_active < maxConcurrent) {
_active++;
return Future.value();
}
final waiter = Completer<void>();
_waiters.add(waiter);
return waiter.future;
}
/// Frees this holder's slot, handing it to the longest waiter if any.
/// Exiting without entering only clamps; it never goes negative.
void exit() {
if (_waiters.isNotEmpty) {
_waiters.removeFirst().complete();
return;
}
if (_active > 0) _active--;
}
/// Current holders, for diagnostics.
int get activeCount => _active;
/// Queued waiters, for diagnostics.
int get waitingCount => _waiters.length;
}
@@ -0,0 +1,224 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'leaf_scrubber.dart';
import 'leaf_video_adapter.dart';
/// Desktop control set for [LeafVideoPage]: single click anywhere toggles
/// playback, double-click goes fullscreen, the bar hides itself after
/// [hideDelay] of inactivity (and while the cursor is off the video),
/// the scrubber shows a hover time bubble before anything is clicked, and
/// volume appears only when the adapter implements [HasVolume].
///
/// Visibility is owned here, not by any viewer chrome.
class LeafDesktopVideoControls extends StatefulWidget {
const LeafDesktopVideoControls({super.key, required this.adapter, this.onFullscreenToggle, this.hideDelay = const Duration(seconds: 3)});
final VideoPlayerAdapter adapter;
/// Null hides the fullscreen button and makes double-click a no-op.
final VoidCallback? onFullscreenToggle;
/// Idle time while playing after which the bar (and cursor) hide.
final Duration hideDelay;
@override
State<LeafDesktopVideoControls> createState() => _LeafDesktopVideoControlsState();
}
class _LeafDesktopVideoControlsState extends State<LeafDesktopVideoControls> {
bool _visible = true;
Timer? _hideTimer;
StreamSubscription<bool>? _playingSub;
double _mutedVolume = 100;
@override
void initState() {
super.initState();
_playingSub = widget.adapter.playingStream.listen(_onPlayingChanged);
if (widget.adapter.isPlaying) _scheduleHide();
}
@override
void dispose() {
_hideTimer?.cancel();
_playingSub?.cancel();
super.dispose();
}
void _onPlayingChanged(bool playing) {
if (playing) {
_scheduleHide();
} else {
_show();
}
}
void _scheduleHide() {
_hideTimer?.cancel();
_hideTimer = Timer(widget.hideDelay, () {
if (!mounted || !widget.adapter.isPlaying) return;
setState(() => _visible = false);
});
}
void _show() {
_hideTimer?.cancel();
if (mounted && !_visible) setState(() => _visible = true);
}
void _userActivity() {
_show();
if (widget.adapter.isPlaying) _scheduleHide();
}
void _hideForExit() {
_hideTimer?.cancel();
if (mounted && _visible && widget.adapter.isPlaying) setState(() => _visible = false);
}
void _toggleFullscreen() => widget.onFullscreenToggle?.call();
@override
Widget build(BuildContext context) {
return Material(
type: MaterialType.transparency,
child: MouseRegion(
cursor: _visible ? SystemMouseCursors.basic : SystemMouseCursors.none,
onEnter: (_) => _userActivity(),
onHover: (_) => _userActivity(),
onExit: (_) => _hideForExit(),
child: Stack(
children: [
Positioned.fill(
// onTapDown, not onTap: with onDoubleTap set, onTap would wait
// out the double-tap timeout on every click. A double-click
// then toggles twice (net zero) plus fullscreen.
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => widget.adapter.playOrPause(),
onDoubleTap: _toggleFullscreen,
),
),
Align(
alignment: Alignment.bottomCenter,
child: SafeArea(
child: IgnorePointer(
ignoring: !_visible,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _visible ? 1.0 : 0.0,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {},
child: _buildBar(context),
),
),
),
),
),
Center(
child: IgnorePointer(
child: StreamBuilder<bool>(
stream: widget.adapter.playingStream,
initialData: widget.adapter.isPlaying,
builder: (context, snapshot) {
final playing = snapshot.data ?? true;
return AnimatedOpacity(
duration: const Duration(milliseconds: 200),
curve: Curves.fastEaseInToSlowEaseOut,
opacity: playing ? 0 : 1,
child: Container(
decoration: BoxDecoration(color: Colors.black38, borderRadius: BorderRadius.circular(100)),
padding: const EdgeInsets.all(20),
child: const Icon(Icons.play_arrow, size: 40),
),
);
},
),
),
),
],
),
),
);
}
Widget _buildBar(BuildContext context) {
final volume = widget.adapter is HasVolume ? widget.adapter as HasVolume : null;
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(colors: [Colors.black, Colors.transparent], begin: Alignment.bottomCenter, end: Alignment.topCenter),
),
padding: const EdgeInsets.fromLTRB(10, 2, 10, 10),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
LeafScrubber(widget.adapter, knob: true, hoverPreview: true),
Row(
children: [
StreamBuilder<bool>(
stream: widget.adapter.playingStream,
initialData: widget.adapter.isPlaying,
builder: (context, snapshot) => IconButton(
icon: Icon((snapshot.data ?? false) ? Icons.pause : Icons.play_arrow),
onPressed: widget.adapter.playOrPause,
),
),
if (volume != null) ...[
StreamBuilder<double>(
stream: volume.volumeStream,
initialData: volume.volume,
builder: (context, snapshot) {
final level = snapshot.data ?? 0;
return IconButton(
icon: Icon(level <= 0 ? Icons.volume_off : (level < 50 ? Icons.volume_down : Icons.volume_up)),
onPressed: () {
if (volume.volume > 0) {
_mutedVolume = volume.volume;
volume.setVolume(0);
} else {
volume.setVolume(_mutedVolume);
}
},
);
},
),
SizedBox(
width: 100,
child: StreamBuilder<double>(
stream: volume.volumeStream,
initialData: volume.volume,
builder: (context, snapshot) => Slider(
value: (snapshot.data ?? 0).clamp(0.0, 100.0),
min: 0,
max: 100,
onChanged: volume.setVolume,
),
),
),
],
StreamBuilder<Duration>(
stream: widget.adapter.positionStream,
builder: (context, _) => Text(
'${_formatDuration(widget.adapter.position)} / ${_formatDuration(widget.adapter.duration)}',
style: const TextStyle(fontSize: 12, fontFeatures: [FontFeature.tabularFigures()]),
),
),
const Spacer(),
if (widget.onFullscreenToggle != null) IconButton(icon: const Icon(Icons.fullscreen), onPressed: widget.onFullscreenToggle),
],
),
],
),
);
}
}
String _formatDuration(Duration d) {
final h = d.inHours;
final mm = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final ss = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return h > 0 ? '$h:$mm:$ss' : '$mm:$ss';
}
+179
View File
@@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import 'leaf_video_adapter.dart';
/// Shared scrubber: buffered + position tracks, tap-to-seek, drag-to-scrub
/// (pauses during the drag, resumes after when it was playing).
///
/// With [knob] the position gets an always-visible handle (desktop
/// affordance). With [hoverPreview] a time bubble follows the cursor before
/// anything is clicked.
class LeafScrubber extends StatefulWidget {
const LeafScrubber(this.adapter, {super.key, this.knob = false, this.hoverPreview = false});
final VideoPlayerAdapter adapter;
/// Always-visible position handle (desktop affordance).
final bool knob;
/// Time bubble following the cursor before anything is clicked (desktop).
final bool hoverPreview;
@override
State<LeafScrubber> createState() => _LeafScrubberState();
}
class _LeafScrubberState extends State<LeafScrubber> {
bool _wasPaused = false;
bool _dragging = false;
double? _hoverFraction;
double _fraction(Duration value, double totalMs) {
return totalMs > 0 ? (value.inMilliseconds / totalMs).clamp(0.0, 1.0) : 0.0;
}
void _seekFraction(double fraction) {
final totalMs = widget.adapter.duration.inMilliseconds;
widget.adapter.seek(Duration(milliseconds: (totalMs * fraction.clamp(0.0, 1.0)).round()));
}
double _dxToFraction(Offset localPosition, double width) {
return width > 0 ? (localPosition.dx / width).clamp(0.0, 1.0) : 0.0;
}
@override
Widget build(BuildContext context) {
final trackHeight = (_dragging || _hoverFraction != null) ? 6.0 : 4.0;
const radius = Radius.circular(3);
Widget track = LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final totalMs = widget.adapter.duration.inMilliseconds.toDouble();
final hover = _hoverFraction;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 22,
width: width,
child: Stack(
clipBehavior: Clip.none,
children: [
if (widget.hoverPreview && hover != null && !_dragging)
Positioned(
left: (hover * width).clamp(24.0, width - 24.0) - 24.0,
bottom: 0,
child: Container(
width: 48,
padding: const EdgeInsets.symmetric(vertical: 2),
decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(4)),
child: Text(
_formatDuration(Duration(milliseconds: (totalMs * hover).round())),
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 11, fontFeatures: [FontFeature.tabularFigures()]),
),
),
),
],
),
),
SizedBox(
height: trackHeight,
width: width,
child: ClipRRect(
borderRadius: const BorderRadius.all(radius),
child: Stack(
children: [
Container(color: Colors.white12),
StreamBuilder<Duration>(
stream: widget.adapter.bufferStream,
builder: (context, _) => AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.fastEaseInToSlowEaseOut,
width: (_fraction(widget.adapter.buffer, totalMs) * width).clamp(0.0, width),
color: Colors.white30,
),
),
StreamBuilder<Duration>(
stream: widget.adapter.positionStream,
builder: (context, _) {
final positionWidth = (_fraction(widget.adapter.position, totalMs) * width).clamp(0.0, width);
return Stack(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 150),
curve: Curves.fastEaseInToSlowEaseOut,
width: positionWidth,
color: Theme.of(context).colorScheme.primary,
),
if (widget.knob)
Positioned(
left: (positionWidth - 6).clamp(0.0, width - 12),
top: 0,
bottom: 0,
child: const Center(
child: SizedBox(
width: 12,
height: 12,
child: DecoratedBox(
decoration: BoxDecoration(color: Colors.white, shape: BoxShape.circle),
child: SizedBox.expand(),
),
),
),
),
],
);
},
),
],
),
),
),
],
);
},
);
track = GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (details) => _seekFraction(_dxToFraction(details.localPosition, (context.findRenderObject() as RenderBox).size.width)),
onHorizontalDragStart: (_) {
_wasPaused = !widget.adapter.isPlaying;
widget.adapter.pause();
setState(() => _dragging = true);
},
onHorizontalDragUpdate: (details) =>
_seekFraction(_dxToFraction(details.localPosition, (context.findRenderObject() as RenderBox).size.width)),
onHorizontalDragEnd: (_) {
setState(() => _dragging = false);
if (!_wasPaused) widget.adapter.play();
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: track,
),
);
if (!widget.hoverPreview) return track;
return MouseRegion(
onHover: (event) {
final box = context.findRenderObject() as RenderBox?;
if (box == null || !box.hasSize) return;
setState(() => _hoverFraction = _dxToFraction(event.localPosition, box.size.width));
},
onExit: (_) {
if (mounted) setState(() => _hoverFraction = null);
},
child: track,
);
}
}
String _formatDuration(Duration d) {
final h = d.inHours;
final mm = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final ss = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return h > 0 ? '$h:$mm:$ss' : '$mm:$ss';
}
+9
View File
@@ -37,3 +37,12 @@ abstract class VideoPlayerAdapter {
/// Releases the underlying player. The adapter is unusable afterwards.
void dispose();
}
/// Optional volume control. Adapters that can report and set output volume
/// implement this; controls show the volume UI only when present, so players
/// without volume support (or platforms where the system owns it) stay clean.
abstract mixin class HasVolume {
Stream<double> get volumeStream;
double get volume;
void setVolume(double volume);
}
+6 -2
View File
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import '../viewer/leaf_chrome.dart';
import '../viewer/leaf_viewer.dart';
import '../viewer/leaf_viewer_internal.dart';
import 'leaf_desktop_video_controls.dart';
import 'leaf_video_adapter.dart';
import 'leaf_video_controls.dart';
@@ -49,7 +50,8 @@ class LeafVideoPage extends StatefulWidget {
/// Shown until [VideoPlayerAdapter.readyStream] fires.
final Widget? thumbnail;
/// Defaults to [LeafVideoControls].
/// Defaults to [LeafVideoControls] on mobile, [LeafDesktopVideoControls]
/// on desktop.
final Widget Function(BuildContext context, VideoPlayerAdapter adapter)? controlsBuilder;
final Duration skipDuration;
@@ -221,7 +223,9 @@ class _LeafVideoPageState extends State<LeafVideoPage> with WidgetsBindingObserv
final thumbnail = widget.thumbnail;
final controls =
widget.controlsBuilder?.call(context, _adapter) ??
LeafVideoControls(adapter: _adapter, skipDuration: widget.skipDuration, onFullscreenToggle: widget.onFullscreenToggle);
(leafIsDesktopPlatform()
? LeafDesktopVideoControls(adapter: _adapter, onFullscreenToggle: widget.onFullscreenToggle)
: LeafVideoControls(adapter: _adapter, skipDuration: widget.skipDuration, onFullscreenToggle: widget.onFullscreenToggle));
return Stack(
fit: StackFit.expand,
+99
View File
@@ -0,0 +1,99 @@
import 'package:flutter/widgets.dart';
/// Pauses a playing child when the app backgrounds or the route is covered
/// (pushed route, dialog, sheet, menu), and asks to resume when it returns.
///
/// Player-agnostic: drives plain callbacks, so custom viewers that don't use
/// [LeafVideoPage] can share the policy. Cover detection uses
/// [ModalRoute.isCurrent] (no [RouteObserver] setup needed); background uses
/// [AppLifecycleState.paused] and [AppLifecycleState.hidden].
/// Callbacks fire post-frame, so they may call `setState` on ancestors.
class VideoLifecycleGuard extends StatefulWidget {
const VideoLifecycleGuard({
super.key,
required this.child,
required this.isPlaying,
this.onPause,
this.onResume,
});
final Widget child;
/// Current playing state. The guard only pauses (and later resumes) while
/// this is true; an idle child gets no calls.
final bool isPlaying;
/// Called once when coverage/background starts while [isPlaying].
final VoidCallback? onPause;
/// Called once when fully uncovered/returned, and only if [onPause] fired.
/// May be left null for pause-only consumers (play stays a tap away).
final VoidCallback? onResume;
@override
State<VideoLifecycleGuard> createState() => _VideoLifecycleGuardState();
}
class _VideoLifecycleGuardState extends State<VideoLifecycleGuard> with WidgetsBindingObserver {
bool _covered = false;
bool _backgrounded = false;
bool _holding = false;
bool _wasPlaying = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final route = ModalRoute.of(context);
_setCovered(route != null && !route.isCurrent);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_backgrounded = state == AppLifecycleState.paused || state == AppLifecycleState.hidden;
_evaluate();
}
void _setCovered(bool covered) {
if (_covered == covered) return;
_covered = covered;
_evaluate();
}
void _evaluate() {
final held = _covered || _backgrounded;
if (held == _holding) return;
_holding = held;
if (held) {
if (widget.isPlaying) {
_wasPlaying = true;
_deliver(widget.onPause);
}
} else if (_wasPlaying) {
_wasPlaying = false;
_deliver(widget.onResume);
}
}
void _deliver(VoidCallback? callback) {
if (callback == null) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
callback();
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}
+21 -7
View File
@@ -31,6 +31,8 @@ class LeafZoomable extends StatefulWidget {
this.originalSize,
this.maxScale = 5.0,
this.enabled = true,
this.onTap,
this.onZoomChanged,
}) : image = null;
const LeafZoomable.image({
@@ -38,6 +40,8 @@ class LeafZoomable extends StatefulWidget {
required Image image,
this.maxScale = 5.0,
this.enabled = true,
this.onTap,
this.onZoomChanged,
}) : child = image,
childSize = null,
originalSize = null,
@@ -53,6 +57,8 @@ class LeafZoomable extends StatefulWidget {
final double maxScale;
final bool enabled;
final VoidCallback? onTap;
final ValueChanged<bool>? onZoomChanged;
/// The contained (letterboxed) size of [source] inside [viewport].
static Size containSize(Size viewport, Size source) {
@@ -78,12 +84,22 @@ class _LeafZoomableState extends State<LeafZoomable> with TickerProviderStateMix
ImageStreamListener? _imageListener;
Size? _decodedSize;
bool _imageResolved = false;
bool _lastZoomed = false;
void _notifyZoom() {
final zoomed = _controller.zoomed;
if (zoomed == _lastZoomed) return;
_lastZoomed = zoomed;
widget.onZoomChanged?.call(zoomed);
}
@override
void initState() {
super.initState();
_controller = LeafZoomController(maxScale: widget.maxScale, vsync: this)
..onZoomLimit = () => HapticFeedback.mediumImpact();
_lastZoomed = _controller.zoomed;
_controller.addListener(_notifyZoom);
}
@override
@@ -150,6 +166,7 @@ class _LeafZoomableState extends State<LeafZoomable> with TickerProviderStateMix
_imageStream!.removeListener(_imageListener!);
}
_unregister();
_controller.removeListener(_notifyZoom);
_controller.dispose();
super.dispose();
}
@@ -221,21 +238,18 @@ class _LeafZoomableState extends State<LeafZoomable> with TickerProviderStateMix
..onEnd = _onScaleEnd,
),
};
if (LeafViewerScope.maybeOf(context)?.registry.swipeGesturesEnabled ?? false) {
if (LeafViewerScope.maybeOf(context)?.registry.swipeGesturesEnabled ?? true) {
gestures[DoubleTapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
() => DoubleTapGestureRecognizer(debugOwner: this),
(instance) => instance.onDoubleTapDown = (details) => _controller.doubleTap(details.localPosition),
);
}
// Chrome-enabled viewers: single tap toggles chrome. Registered on
// THIS detector so the tap/double-tap arena coordination holds —
// a cross-detector tap would fire on the first tap of every
// double-tap-zoom.
final chrome = LeafViewerScope.maybeOf(context)?.chrome;
if (chrome != null) {
final tapCallback = widget.onTap ?? chrome?.toggle;
if (tapCallback != null) {
gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(debugOwner: this),
(instance) => instance.onTap = chrome.toggle,
(instance) => instance.onTap = tapCallback,
);
}
return Listener(
+43
View File
@@ -0,0 +1,43 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:leaf_widgets/leaf_widgets.dart';
void main() {
test('immediate entry while slots are free', () async {
final gate = FirstFrameGate(maxConcurrent: 2);
await gate.enter();
await gate.enter();
expect(gate.activeCount, 2);
expect(gate.waitingCount, 0);
gate.exit();
gate.exit();
expect(gate.activeCount, 0);
});
test('third entrant waits, exit hands the slot over', () async {
final gate = FirstFrameGate(maxConcurrent: 2);
await gate.enter();
await gate.enter();
var thirdIn = false;
final pending = gate.enter().then((_) => thirdIn = true);
await Future<void>.delayed(Duration.zero);
expect(thirdIn, isFalse);
expect(gate.waitingCount, 1);
gate.exit();
await pending;
expect(thirdIn, isTrue);
expect(gate.activeCount, 2);
gate.exit();
gate.exit();
expect(gate.activeCount, 0);
});
test('exit without enter never goes negative', () {
final gate = FirstFrameGate(maxConcurrent: 1);
gate.exit();
expect(gate.activeCount, 0);
});
}
@@ -0,0 +1,167 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:leaf_widgets/leaf_widgets.dart';
class FakeAdapter extends VideoPlayerAdapter {
bool playing = false;
bool disposed = false;
final _positionCtrl = StreamController<Duration>.broadcast();
final _bufferCtrl = StreamController<Duration>.broadcast();
final _playingCtrl = StreamController<bool>.broadcast();
final _readyCtrl = StreamController<void>.broadcast();
final _errorCtrl = StreamController<Object>.broadcast();
@override
Widget get surface => const ColoredBox(color: Color(0xFF332211));
@override
Future<void> load({required String url, Map<String, String>? headers}) async {}
void emitPlaying(bool value) {
playing = value;
_playingCtrl.add(value);
}
@override
Stream<Duration> get positionStream => _positionCtrl.stream;
@override
Stream<Duration> get bufferStream => _bufferCtrl.stream;
@override
Stream<bool> get playingStream => _playingCtrl.stream;
@override
Stream<void> get readyStream => _readyCtrl.stream;
@override
Stream<Object> get errorStream => _errorCtrl.stream;
@override
Duration get position => Duration.zero;
@override
Duration get duration => const Duration(seconds: 60);
@override
Duration get buffer => Duration.zero;
@override
bool get isPlaying => playing;
@override
void play() => emitPlaying(true);
@override
void pause() => emitPlaying(false);
@override
void playOrPause() => playing ? pause() : play();
@override
void seek(Duration target) {}
@override
void dispose() => disposed = true;
}
class FakeVolumeAdapter extends FakeAdapter implements HasVolume {
double _volume = 80;
final setVolumes = <double>[];
final _volumeCtrl = StreamController<double>.broadcast();
@override
Stream<double> get volumeStream => _volumeCtrl.stream;
@override
double get volume => _volume;
@override
void setVolume(double volume) {
setVolumes.add(volume);
_volume = volume;
_volumeCtrl.add(volume);
}
}
Widget harness(VideoPlayerAdapter adapter, {Duration hideDelay = const Duration(milliseconds: 500)}) {
return MaterialApp(
home: Scaffold(
body: LeafDesktopVideoControls(adapter: adapter, hideDelay: hideDelay),
),
);
}
AnimatedOpacity barOf(WidgetTester tester) {
final found = find.byWidgetPredicate((w) => w is AnimatedOpacity && w.child is GestureDetector);
return tester.widget<AnimatedOpacity>(found);
}
void main() {
testWidgets('single click anywhere toggles playback', (tester) async {
final adapter = FakeAdapter();
await tester.pumpWidget(harness(adapter));
expect(adapter.playing, isFalse);
await tester.tap(find.byType(LeafDesktopVideoControls));
await tester.pump(const Duration(milliseconds: 500));
expect(adapter.playing, isTrue);
await tester.tap(find.byType(LeafDesktopVideoControls));
await tester.pump(const Duration(milliseconds: 500));
expect(adapter.playing, isFalse);
});
testWidgets('bar hides after inactivity while playing, shows on pause', (tester) async {
final adapter = FakeAdapter()..playing = true;
await tester.pumpWidget(harness(adapter));
expect(barOf(tester).opacity, 1.0);
await tester.pump(const Duration(milliseconds: 600));
expect(barOf(tester).opacity, 0.0);
adapter.emitPlaying(false);
await tester.pump();
expect(barOf(tester).opacity, 1.0);
});
testWidgets('bar stays up while the cursor is active', (tester) async {
final adapter = FakeAdapter()..playing = true;
await tester.pumpWidget(harness(adapter));
await tester.pump(const Duration(milliseconds: 400));
expect(barOf(tester).opacity, 1.0);
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
await mouse.moveTo(const Offset(400, 300));
await tester.pump(const Duration(milliseconds: 400));
expect(barOf(tester).opacity, 1.0);
await tester.pump(const Duration(milliseconds: 600));
expect(barOf(tester).opacity, 0.0);
});
testWidgets('volume UI only with HasVolume; mute toggles', (tester) async {
await tester.pumpWidget(harness(FakeAdapter()));
expect(find.byType(Slider), findsNothing);
final volume = FakeVolumeAdapter();
await tester.pumpWidget(harness(volume));
expect(find.byType(Slider), findsOneWidget);
expect(find.byIcon(Icons.volume_up), findsOneWidget);
await tester.tap(find.byIcon(Icons.volume_up));
await tester.pump();
expect(volume.setVolumes, contains(0.0));
expect(find.byIcon(Icons.volume_off), findsOneWidget);
await tester.tap(find.byIcon(Icons.volume_off));
await tester.pump();
expect(volume.setVolumes.last, 80.0);
});
testWidgets('hovering the scrubber shows the time bubble before clicking', (tester) async {
final adapter = FakeAdapter();
await tester.pumpWidget(harness(adapter));
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
await mouse.moveTo(tester.getCenter(find.byType(LeafScrubber)));
await tester.pump();
expect(find.text('00:30'), findsOneWidget);
await mouse.moveTo(Offset.zero);
await tester.pump();
expect(find.text('00:30'), findsNothing);
});
}
+95
View File
@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:leaf_widgets/leaf_widgets.dart';
void main() {
testWidgets('backgrounding pauses a playing child, foreground resumes it', (tester) async {
var pauses = 0;
var resumes = 0;
var playing = true;
await tester.pumpWidget(MaterialApp(
home: StatefulBuilder(
builder: (context, setState) => VideoLifecycleGuard(
isPlaying: playing,
onPause: () {
pauses++;
setState(() => playing = false);
},
onResume: () {
resumes++;
setState(() => playing = true);
},
child: const SizedBox.shrink(),
),
),
));
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await tester.pump();
expect(pauses, 1);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
expect(resumes, 1);
});
testWidgets('idle child gets no calls on background round-trip', (tester) async {
var pauses = 0;
var resumes = 0;
await tester.pumpWidget(MaterialApp(
home: VideoLifecycleGuard(
isPlaying: false,
onPause: () => pauses++,
onResume: () => resumes++,
child: const SizedBox.shrink(),
),
));
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await tester.pump();
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
expect(pauses, 0);
expect(resumes, 0);
});
testWidgets('covering route pauses, uncovering resumes', (tester) async {
var pauses = 0;
var resumes = 0;
var playing = true;
await tester.pumpWidget(MaterialApp(
home: StatefulBuilder(
builder: (context, setState) => VideoLifecycleGuard(
isPlaying: playing,
onPause: () {
pauses++;
setState(() => playing = false);
},
onResume: () {
resumes++;
setState(() => playing = true);
},
child: Builder(
builder: (context) => TextButton(
onPressed: () => showDialog(context: context, builder: (_) => const Text('cover')),
child: const Text('open'),
),
),
),
),
));
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.text('cover'), findsOneWidget);
expect(pauses, 1);
Navigator.of(tester.element(find.text('cover'))).pop();
await tester.pumpAndSettle();
expect(resumes, 1);
});
}