diff --git a/README.md b/README.md index e31423e..7c1bb87 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/leaf_widgets.dart b/lib/leaf_widgets.dart index 1482ea7..c335266 100644 --- a/lib/leaf_widgets.dart +++ b/lib/leaf_widgets.dart @@ -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'; diff --git a/lib/src/video/first_frame_gate.dart b/lib/src/video/first_frame_gate.dart new file mode 100644 index 0000000..aae0467 --- /dev/null +++ b/lib/src/video/first_frame_gate.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>(); + + /// Waits for a decode slot. Completes immediately when one is free. + Future enter() { + if (_active < maxConcurrent) { + _active++; + return Future.value(); + } + final waiter = Completer(); + _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; +} diff --git a/lib/src/video/leaf_desktop_video_controls.dart b/lib/src/video/leaf_desktop_video_controls.dart new file mode 100644 index 0000000..b77831d --- /dev/null +++ b/lib/src/video/leaf_desktop_video_controls.dart @@ -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 createState() => _LeafDesktopVideoControlsState(); +} + +class _LeafDesktopVideoControlsState extends State { + bool _visible = true; + Timer? _hideTimer; + StreamSubscription? _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( + 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( + 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( + 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( + 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( + 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'; +} diff --git a/lib/src/video/leaf_scrubber.dart b/lib/src/video/leaf_scrubber.dart new file mode 100644 index 0000000..36f2079 --- /dev/null +++ b/lib/src/video/leaf_scrubber.dart @@ -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 createState() => _LeafScrubberState(); +} + +class _LeafScrubberState extends State { + 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( + 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( + 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'; +} diff --git a/lib/src/video/leaf_video_adapter.dart b/lib/src/video/leaf_video_adapter.dart index e862425..3b5c59a 100644 --- a/lib/src/video/leaf_video_adapter.dart +++ b/lib/src/video/leaf_video_adapter.dart @@ -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 get volumeStream; + double get volume; + void setVolume(double volume); +} diff --git a/lib/src/video/leaf_video_page.dart b/lib/src/video/leaf_video_page.dart index c22d27e..274bcd9 100644 --- a/lib/src/video/leaf_video_page.dart +++ b/lib/src/video/leaf_video_page.dart @@ -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 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, diff --git a/lib/src/video/video_lifecycle.dart b/lib/src/video/video_lifecycle.dart new file mode 100644 index 0000000..30f0fe9 --- /dev/null +++ b/lib/src/video/video_lifecycle.dart @@ -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 createState() => _VideoLifecycleGuardState(); +} + +class _VideoLifecycleGuardState extends State 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; +} diff --git a/lib/src/viewer/leaf_zoomable.dart b/lib/src/viewer/leaf_zoomable.dart index b9cfa4f..e810d70 100644 --- a/lib/src/viewer/leaf_zoomable.dart +++ b/lib/src/viewer/leaf_zoomable.dart @@ -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? onZoomChanged; /// The contained (letterboxed) size of [source] inside [viewport]. static Size containSize(Size viewport, Size source) { @@ -78,12 +84,22 @@ class _LeafZoomableState extends State 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 with TickerProviderStateMix _imageStream!.removeListener(_imageListener!); } _unregister(); + _controller.removeListener(_notifyZoom); _controller.dispose(); super.dispose(); } @@ -221,21 +238,18 @@ class _LeafZoomableState extends State with TickerProviderStateMix ..onEnd = _onScaleEnd, ), }; - if (LeafViewerScope.maybeOf(context)?.registry.swipeGesturesEnabled ?? false) { + if (LeafViewerScope.maybeOf(context)?.registry.swipeGesturesEnabled ?? true) { gestures[DoubleTapGestureRecognizer] = GestureRecognizerFactoryWithHandlers( () => 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(debugOwner: this), - (instance) => instance.onTap = chrome.toggle, + (instance) => instance.onTap = tapCallback, ); } return Listener( diff --git a/test/video/first_frame_gate_test.dart b/test/video/first_frame_gate_test.dart new file mode 100644 index 0000000..195cfb6 --- /dev/null +++ b/test/video/first_frame_gate_test.dart @@ -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.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); + }); +} diff --git a/test/video/leaf_desktop_video_controls_test.dart b/test/video/leaf_desktop_video_controls_test.dart new file mode 100644 index 0000000..67388a5 --- /dev/null +++ b/test/video/leaf_desktop_video_controls_test.dart @@ -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.broadcast(); + final _bufferCtrl = StreamController.broadcast(); + final _playingCtrl = StreamController.broadcast(); + final _readyCtrl = StreamController.broadcast(); + final _errorCtrl = StreamController.broadcast(); + + @override + Widget get surface => const ColoredBox(color: Color(0xFF332211)); + + @override + Future load({required String url, Map? headers}) async {} + + void emitPlaying(bool value) { + playing = value; + _playingCtrl.add(value); + } + + @override + Stream get positionStream => _positionCtrl.stream; + @override + Stream get bufferStream => _bufferCtrl.stream; + @override + Stream get playingStream => _playingCtrl.stream; + @override + Stream get readyStream => _readyCtrl.stream; + @override + Stream 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 = []; + final _volumeCtrl = StreamController.broadcast(); + + @override + Stream 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(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); + }); +} diff --git a/test/video/video_lifecycle_test.dart b/test/video/video_lifecycle_test.dart new file mode 100644 index 0000000..c5b6bce --- /dev/null +++ b/test/video/video_lifecycle_test.dart @@ -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); + }); +}