diff --git a/lib/src/video/leaf_video_controls.dart b/lib/src/video/leaf_video_controls.dart index 6eb0d93..8be773e 100644 --- a/lib/src/video/leaf_video_controls.dart +++ b/lib/src/video/leaf_video_controls.dart @@ -192,7 +192,9 @@ class _LeafVideoControlsState extends State { stream: widget.adapter.positionStream, builder: (context, _) => Text( '${_formatDuration(widget.adapter.position)} / ${_formatDuration(widget.adapter.duration)}', - style: const TextStyle(fontSize: 12), + // Tabular digits: every-second width jitter would resize the + // scrubber next to it. + style: const TextStyle(fontSize: 12, fontFeatures: [FontFeature.tabularFigures()]), ), ), if (widget.onFullscreenToggle != null) IconButton(icon: const Icon(Icons.fullscreen), onPressed: widget.onFullscreenToggle), diff --git a/lib/src/video/leaf_video_page.dart b/lib/src/video/leaf_video_page.dart index 87efdee..c22d27e 100644 --- a/lib/src/video/leaf_video_page.dart +++ b/lib/src/video/leaf_video_page.dart @@ -68,7 +68,7 @@ class _LeafVideoPageState extends State with WidgetsBindingObserv bool _ready = false; bool _active = true; - bool _obscured = false; + bool _coveredByRoute = false; bool _backgrounded = false; bool _wasCovered = false; bool _wasPlayingBeforeCover = false; @@ -76,7 +76,6 @@ class _LeafVideoPageState extends State with WidgetsBindingObserv LeafViewerChrome? _chrome; int? _pageIndex; ValueListenable? _currentIndex; - Animation? _routeAnimation; final _subs = >[]; @override @@ -106,7 +105,7 @@ class _LeafVideoPageState extends State with WidgetsBindingObserv } if (!mounted) return; setState(() => _loaded = true); - if (_active && !_obscured && !_backgrounded) _adapter.play(); + if (_active && !_coveredByRoute && !_backgrounded) _adapter.play(); } @override @@ -123,14 +122,14 @@ class _LeafVideoPageState extends State with WidgetsBindingObserv _evaluateActive(); } + // "Something is pushed on top of me" — sheets, pages, dialogs, anything. + // Route.isCurrent is the only reliable signal: a route's secondaryAnimation + // is not driven by bottom sheets (ModalBottomSheetRoute.canTransitionFrom + // only accepts other sheets), so status listeners on it stay silent for + // the most common cover in gallery apps. _ModalScopeStatus notifies on + // isCurrent changes, so this re-runs from didChangeDependencies. final route = ModalRoute.of(context); - final animation = route?.secondaryAnimation; - if (!identical(animation, _routeAnimation)) { - _routeAnimation?.removeStatusListener(_onRouteStatus); - _routeAnimation = animation; - _routeAnimation?.addStatusListener(_onRouteStatus); - _setObscured(_routeAnimation?.status != null && _routeAnimation!.status != AnimationStatus.dismissed); - } + _setCoveredByRoute(route != null && !route.isCurrent); } @override @@ -151,13 +150,9 @@ class _LeafVideoPageState extends State with WidgetsBindingObserv _updateCovered(); } - void _onRouteStatus(AnimationStatus status) { - _setObscured(status != AnimationStatus.dismissed); - } - - void _setObscured(bool obscured) { - if (_obscured == obscured) return; - _obscured = obscured; + void _setCoveredByRoute(bool covered) { + if (_coveredByRoute == covered) return; + _coveredByRoute = covered; _updateCovered(); } @@ -173,13 +168,13 @@ class _LeafVideoPageState extends State with WidgetsBindingObserv // owns it from here. _chrome?.hold = false; _adapter.pause(); - } else if (_loaded && !_failed && !_obscured && !_backgrounded) { + } else if (_loaded && !_failed && !_coveredByRoute && !_backgrounded) { _adapter.play(); } } void _updateCovered() { - final covered = _obscured || _backgrounded; + final covered = _coveredByRoute || _backgrounded; if (covered) { if (_wasCovered) return; _wasPlayingBeforeCover = _adapter.isPlaying; @@ -214,7 +209,6 @@ class _LeafVideoPageState extends State with WidgetsBindingObserv sub.cancel(); } _currentIndex?.removeListener(_onIndexChanged); - _routeAnimation?.removeStatusListener(_onRouteStatus); WidgetsBinding.instance.removeObserver(this); _adapter.dispose(); super.dispose(); diff --git a/test/video/leaf_video_cover_test.dart b/test/video/leaf_video_cover_test.dart new file mode 100644 index 0000000..762ed53 --- /dev/null +++ b/test/video/leaf_video_cover_test.dart @@ -0,0 +1,135 @@ +import 'dart:async'; + +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 plays = []; + final pauses = []; + + 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() { + playing = true; + plays.add(plays.length); + } + + @override + void pause() { + playing = false; + pauses.add(pauses.length); + } + + @override + void playOrPause() => playing ? pause() : play(); + @override + void seek(Duration target) {} + @override + void dispose() => disposed = true; +} + +void main() { + testWidgets('the exact tree flow: viewer → sheet → opaque route → back → sheet close', (tester) async { + final adapter = FakeAdapter(); + final viewerKey = GlobalKey(); + + await tester.pumpWidget(MaterialApp( + home: Builder( + builder: (context) => Center( + child: TextButton( + onPressed: () => Navigator.of(context).push( + PageRouteBuilder( + opaque: false, + pageBuilder: (_, _, _) => LeafViewer( + key: viewerKey, + pageCount: 1, + enableChrome: true, + pageBuilder: (context, i) => LeafVideoPage( + createAdapter: () => adapter, + url: 'https://example.com/v.mp4', + thumbnail: const Text('thumb'), + ), + ), + ), + ), + child: const Text('open viewer'), + ), + ), + ), + )); + await tester.tap(find.text('open viewer')); + await tester.pumpAndSettle(); + expect(adapter.playing, isTrue, reason: 'settled video page plays'); + + // Open the details sheet over the viewer. + final viewerContext = viewerKey.currentContext!; + showModalBottomSheet( + context: viewerContext, + isScrollControlled: true, + builder: (sheetContext) => TextButton( + onPressed: () => Navigator.of(sheetContext).push( + MaterialPageRoute(builder: (_) => const Scaffold(body: Text('the tree'))), + ), + child: const Text('tap a tag'), + ), + ); + await tester.pumpAndSettle(); + expect(adapter.playing, isFalse, reason: 'sheet covering the viewer pauses the video'); + + // Go down the tree: opaque route pushed on top of the sheet. + await tester.tap(find.text('tap a tag')); + await tester.pumpAndSettle(); + expect(find.text('the tree'), findsOneWidget); + expect(adapter.playing, isFalse, reason: 'opaque route over the sheet must keep the video paused'); + + // Back out of the tree: sheet still open, still covered. + Navigator.of(viewerContext).pop(); + await tester.pumpAndSettle(); + expect(adapter.playing, isFalse, reason: 'back under the sheet the video stays paused'); + + // Close the sheet: resume. + Navigator.of(viewerContext).pop(); + await tester.pumpAndSettle(); + expect(adapter.playing, isTrue, reason: 'closing the last cover resumes playback'); + }); +}