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:
1 parent
9b1c8fa368
commit
c0ab4a828b
12 files changed
+936
-9
No files matched your search
@@ -128,3 +128,45 @@ LeafViewer(
|
|||||||
- All viewer code lives in `lib/src/viewer/` — zoom math in
|
- All viewer code lives in `lib/src/viewer/` — zoom math in
|
||||||
`leaf_zoom_controller.dart` (property-tested), gesture arbitration in
|
`leaf_zoom_controller.dart` (property-tested), gesture arbitration in
|
||||||
`leaf_pan_zoom_recognizer.dart` (ported from photo_view, MIT).
|
`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.
|
||||||
@@ -7,8 +7,12 @@ export 'src/misc/animated_favorite_button.dart';
|
|||||||
export 'src/misc/animated_star.dart';
|
export 'src/misc/animated_star.dart';
|
||||||
export 'src/misc/leaf_async_chip.dart';
|
export 'src/misc/leaf_async_chip.dart';
|
||||||
export 'src/popover/leaf_popover.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_adapter.dart';
|
||||||
export 'src/video/leaf_video_controls.dart';
|
export 'src/video/leaf_video_controls.dart';
|
||||||
|
export 'src/video/video_lifecycle.dart';
|
||||||
export 'src/video/leaf_video_page.dart';
|
export 'src/video/leaf_video_page.dart';
|
||||||
export 'src/viewer/leaf_chrome.dart';
|
export 'src/viewer/leaf_chrome.dart';
|
||||||
export 'src/viewer/leaf_viewer.dart';
|
export 'src/viewer/leaf_viewer.dart';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -37,3 +37,12 @@ abstract class VideoPlayerAdapter {
|
|||||||
/// Releases the underlying player. The adapter is unusable afterwards.
|
/// Releases the underlying player. The adapter is unusable afterwards.
|
||||||
void dispose();
|
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,6 +6,7 @@ import 'package:flutter/material.dart';
|
|||||||
import '../viewer/leaf_chrome.dart';
|
import '../viewer/leaf_chrome.dart';
|
||||||
import '../viewer/leaf_viewer.dart';
|
import '../viewer/leaf_viewer.dart';
|
||||||
import '../viewer/leaf_viewer_internal.dart';
|
import '../viewer/leaf_viewer_internal.dart';
|
||||||
|
import 'leaf_desktop_video_controls.dart';
|
||||||
import 'leaf_video_adapter.dart';
|
import 'leaf_video_adapter.dart';
|
||||||
import 'leaf_video_controls.dart';
|
import 'leaf_video_controls.dart';
|
||||||
|
|
||||||
@@ -49,7 +50,8 @@ class LeafVideoPage extends StatefulWidget {
|
|||||||
/// Shown until [VideoPlayerAdapter.readyStream] fires.
|
/// Shown until [VideoPlayerAdapter.readyStream] fires.
|
||||||
final Widget? thumbnail;
|
final Widget? thumbnail;
|
||||||
|
|
||||||
/// Defaults to [LeafVideoControls].
|
/// Defaults to [LeafVideoControls] on mobile, [LeafDesktopVideoControls]
|
||||||
|
/// on desktop.
|
||||||
final Widget Function(BuildContext context, VideoPlayerAdapter adapter)? controlsBuilder;
|
final Widget Function(BuildContext context, VideoPlayerAdapter adapter)? controlsBuilder;
|
||||||
final Duration skipDuration;
|
final Duration skipDuration;
|
||||||
|
|
||||||
@@ -221,7 +223,9 @@ class _LeafVideoPageState extends State<LeafVideoPage> with WidgetsBindingObserv
|
|||||||
final thumbnail = widget.thumbnail;
|
final thumbnail = widget.thumbnail;
|
||||||
final controls =
|
final controls =
|
||||||
widget.controlsBuilder?.call(context, _adapter) ??
|
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(
|
return Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ class LeafZoomable extends StatefulWidget {
|
|||||||
this.originalSize,
|
this.originalSize,
|
||||||
this.maxScale = 5.0,
|
this.maxScale = 5.0,
|
||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
|
this.onTap,
|
||||||
|
this.onZoomChanged,
|
||||||
}) : image = null;
|
}) : image = null;
|
||||||
|
|
||||||
const LeafZoomable.image({
|
const LeafZoomable.image({
|
||||||
@@ -38,6 +40,8 @@ class LeafZoomable extends StatefulWidget {
|
|||||||
required Image image,
|
required Image image,
|
||||||
this.maxScale = 5.0,
|
this.maxScale = 5.0,
|
||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
|
this.onTap,
|
||||||
|
this.onZoomChanged,
|
||||||
}) : child = image,
|
}) : child = image,
|
||||||
childSize = null,
|
childSize = null,
|
||||||
originalSize = null,
|
originalSize = null,
|
||||||
@@ -53,6 +57,8 @@ class LeafZoomable extends StatefulWidget {
|
|||||||
|
|
||||||
final double maxScale;
|
final double maxScale;
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final ValueChanged<bool>? onZoomChanged;
|
||||||
|
|
||||||
/// The contained (letterboxed) size of [source] inside [viewport].
|
/// The contained (letterboxed) size of [source] inside [viewport].
|
||||||
static Size containSize(Size viewport, Size source) {
|
static Size containSize(Size viewport, Size source) {
|
||||||
@@ -78,12 +84,22 @@ class _LeafZoomableState extends State<LeafZoomable> with TickerProviderStateMix
|
|||||||
ImageStreamListener? _imageListener;
|
ImageStreamListener? _imageListener;
|
||||||
Size? _decodedSize;
|
Size? _decodedSize;
|
||||||
bool _imageResolved = false;
|
bool _imageResolved = false;
|
||||||
|
bool _lastZoomed = false;
|
||||||
|
|
||||||
|
void _notifyZoom() {
|
||||||
|
final zoomed = _controller.zoomed;
|
||||||
|
if (zoomed == _lastZoomed) return;
|
||||||
|
_lastZoomed = zoomed;
|
||||||
|
widget.onZoomChanged?.call(zoomed);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_controller = LeafZoomController(maxScale: widget.maxScale, vsync: this)
|
_controller = LeafZoomController(maxScale: widget.maxScale, vsync: this)
|
||||||
..onZoomLimit = () => HapticFeedback.mediumImpact();
|
..onZoomLimit = () => HapticFeedback.mediumImpact();
|
||||||
|
_lastZoomed = _controller.zoomed;
|
||||||
|
_controller.addListener(_notifyZoom);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -150,6 +166,7 @@ class _LeafZoomableState extends State<LeafZoomable> with TickerProviderStateMix
|
|||||||
_imageStream!.removeListener(_imageListener!);
|
_imageStream!.removeListener(_imageListener!);
|
||||||
}
|
}
|
||||||
_unregister();
|
_unregister();
|
||||||
|
_controller.removeListener(_notifyZoom);
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -221,21 +238,18 @@ class _LeafZoomableState extends State<LeafZoomable> with TickerProviderStateMix
|
|||||||
..onEnd = _onScaleEnd,
|
..onEnd = _onScaleEnd,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
if (LeafViewerScope.maybeOf(context)?.registry.swipeGesturesEnabled ?? false) {
|
if (LeafViewerScope.maybeOf(context)?.registry.swipeGesturesEnabled ?? true) {
|
||||||
gestures[DoubleTapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
|
gestures[DoubleTapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
|
||||||
() => DoubleTapGestureRecognizer(debugOwner: this),
|
() => DoubleTapGestureRecognizer(debugOwner: this),
|
||||||
(instance) => instance.onDoubleTapDown = (details) => _controller.doubleTap(details.localPosition),
|
(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;
|
final chrome = LeafViewerScope.maybeOf(context)?.chrome;
|
||||||
if (chrome != null) {
|
final tapCallback = widget.onTap ?? chrome?.toggle;
|
||||||
|
if (tapCallback != null) {
|
||||||
gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
|
gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
|
||||||
() => TapGestureRecognizer(debugOwner: this),
|
() => TapGestureRecognizer(debugOwner: this),
|
||||||
(instance) => instance.onTap = chrome.toggle,
|
(instance) => instance.onTap = tapCallback,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Listener(
|
return Listener(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user