fix: route-covered pause via Route.isCurrent — secondaryAnimation never fires for bottom sheets (canTransitionFrom gate); tabular figures on scrub time text
This commit is contained in:
1 parent
764c512572
commit
9b1c8fa368
3 files changed
+152
-21
No files matched your search
@@ -192,7 +192,9 @@ class _LeafVideoControlsState extends State<LeafVideoControls> {
|
|||||||
stream: widget.adapter.positionStream,
|
stream: widget.adapter.positionStream,
|
||||||
builder: (context, _) => Text(
|
builder: (context, _) => Text(
|
||||||
'${_formatDuration(widget.adapter.position)} / ${_formatDuration(widget.adapter.duration)}',
|
'${_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),
|
if (widget.onFullscreenToggle != null) IconButton(icon: const Icon(Icons.fullscreen), onPressed: widget.onFullscreenToggle),
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class _LeafVideoPageState extends State<LeafVideoPage> with WidgetsBindingObserv
|
|||||||
bool _ready = false;
|
bool _ready = false;
|
||||||
|
|
||||||
bool _active = true;
|
bool _active = true;
|
||||||
bool _obscured = false;
|
bool _coveredByRoute = false;
|
||||||
bool _backgrounded = false;
|
bool _backgrounded = false;
|
||||||
bool _wasCovered = false;
|
bool _wasCovered = false;
|
||||||
bool _wasPlayingBeforeCover = false;
|
bool _wasPlayingBeforeCover = false;
|
||||||
@@ -76,7 +76,6 @@ class _LeafVideoPageState extends State<LeafVideoPage> with WidgetsBindingObserv
|
|||||||
LeafViewerChrome? _chrome;
|
LeafViewerChrome? _chrome;
|
||||||
int? _pageIndex;
|
int? _pageIndex;
|
||||||
ValueListenable<int>? _currentIndex;
|
ValueListenable<int>? _currentIndex;
|
||||||
Animation<double>? _routeAnimation;
|
|
||||||
final _subs = <StreamSubscription<void>>[];
|
final _subs = <StreamSubscription<void>>[];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -106,7 +105,7 @@ class _LeafVideoPageState extends State<LeafVideoPage> with WidgetsBindingObserv
|
|||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _loaded = true);
|
setState(() => _loaded = true);
|
||||||
if (_active && !_obscured && !_backgrounded) _adapter.play();
|
if (_active && !_coveredByRoute && !_backgrounded) _adapter.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -123,14 +122,14 @@ class _LeafVideoPageState extends State<LeafVideoPage> with WidgetsBindingObserv
|
|||||||
_evaluateActive();
|
_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 route = ModalRoute.of(context);
|
||||||
final animation = route?.secondaryAnimation;
|
_setCoveredByRoute(route != null && !route.isCurrent);
|
||||||
if (!identical(animation, _routeAnimation)) {
|
|
||||||
_routeAnimation?.removeStatusListener(_onRouteStatus);
|
|
||||||
_routeAnimation = animation;
|
|
||||||
_routeAnimation?.addStatusListener(_onRouteStatus);
|
|
||||||
_setObscured(_routeAnimation?.status != null && _routeAnimation!.status != AnimationStatus.dismissed);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -151,13 +150,9 @@ class _LeafVideoPageState extends State<LeafVideoPage> with WidgetsBindingObserv
|
|||||||
_updateCovered();
|
_updateCovered();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onRouteStatus(AnimationStatus status) {
|
void _setCoveredByRoute(bool covered) {
|
||||||
_setObscured(status != AnimationStatus.dismissed);
|
if (_coveredByRoute == covered) return;
|
||||||
}
|
_coveredByRoute = covered;
|
||||||
|
|
||||||
void _setObscured(bool obscured) {
|
|
||||||
if (_obscured == obscured) return;
|
|
||||||
_obscured = obscured;
|
|
||||||
_updateCovered();
|
_updateCovered();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,13 +168,13 @@ class _LeafVideoPageState extends State<LeafVideoPage> with WidgetsBindingObserv
|
|||||||
// owns it from here.
|
// owns it from here.
|
||||||
_chrome?.hold = false;
|
_chrome?.hold = false;
|
||||||
_adapter.pause();
|
_adapter.pause();
|
||||||
} else if (_loaded && !_failed && !_obscured && !_backgrounded) {
|
} else if (_loaded && !_failed && !_coveredByRoute && !_backgrounded) {
|
||||||
_adapter.play();
|
_adapter.play();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateCovered() {
|
void _updateCovered() {
|
||||||
final covered = _obscured || _backgrounded;
|
final covered = _coveredByRoute || _backgrounded;
|
||||||
if (covered) {
|
if (covered) {
|
||||||
if (_wasCovered) return;
|
if (_wasCovered) return;
|
||||||
_wasPlayingBeforeCover = _adapter.isPlaying;
|
_wasPlayingBeforeCover = _adapter.isPlaying;
|
||||||
@@ -214,7 +209,6 @@ class _LeafVideoPageState extends State<LeafVideoPage> with WidgetsBindingObserv
|
|||||||
sub.cancel();
|
sub.cancel();
|
||||||
}
|
}
|
||||||
_currentIndex?.removeListener(_onIndexChanged);
|
_currentIndex?.removeListener(_onIndexChanged);
|
||||||
_routeAnimation?.removeStatusListener(_onRouteStatus);
|
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_adapter.dispose();
|
_adapter.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|||||||
@@ -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 = <int>[];
|
||||||
|
final pauses = <int>[];
|
||||||
|
|
||||||
|
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() {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user