video kit: VideoPlayerAdapter seam, LeafVideoPage lifecycle (active/route/lifecycle gating, prebuffer, retry), LeafVideoControls
This commit is contained in:
1 parent
3cc02c9bbe
commit
bdbd3090f1
5 files changed
+763
No files matched your search
@@ -7,6 +7,9 @@ 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_video_adapter.dart';
|
||||||
|
export 'src/video/leaf_video_controls.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';
|
||||||
export 'src/viewer/leaf_zoom_controller.dart';
|
export 'src/viewer/leaf_zoom_controller.dart';
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// Player-agnostic contract between [LeafVideoPage]/[LeafVideoControls] and
|
||||||
|
/// whichever player the host app wires in (a Media3 wrapper, media_kit, ...).
|
||||||
|
/// This is the only seam that knows a player exists; implementations live
|
||||||
|
/// app-side so the package stays dependency-free.
|
||||||
|
abstract class VideoPlayerAdapter {
|
||||||
|
/// The player's rendering surface, inserted into the page's stack.
|
||||||
|
Widget get surface;
|
||||||
|
|
||||||
|
/// Loads [url]. Completes when the player may start playback; throws on
|
||||||
|
/// load failure. Safe to call before the surface is laid out.
|
||||||
|
Future<void> load({required String url, Map<String, String>? headers});
|
||||||
|
|
||||||
|
Stream<Duration> get positionStream;
|
||||||
|
Stream<Duration> get bufferStream;
|
||||||
|
Stream<bool> get playingStream;
|
||||||
|
|
||||||
|
/// Fires when the first frame is actually rendered — the safe signal to
|
||||||
|
/// drop the thumbnail (a `loaded` state is not; it precedes the first
|
||||||
|
/// frame and produces a black flash).
|
||||||
|
Stream<void> get readyStream;
|
||||||
|
|
||||||
|
/// Fires on playback errors after a successful [load].
|
||||||
|
Stream<Object> get errorStream;
|
||||||
|
|
||||||
|
Duration get position;
|
||||||
|
Duration get duration;
|
||||||
|
Duration get buffer;
|
||||||
|
bool get isPlaying;
|
||||||
|
|
||||||
|
void play();
|
||||||
|
void pause();
|
||||||
|
void playOrPause();
|
||||||
|
void seek(Duration target);
|
||||||
|
|
||||||
|
/// Releases the underlying player. The adapter is unusable afterwards.
|
||||||
|
void dispose();
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../viewer/leaf_viewer.dart';
|
||||||
|
import '../viewer/leaf_viewer_internal.dart';
|
||||||
|
import 'leaf_video_adapter.dart';
|
||||||
|
|
||||||
|
enum _SkipDirection { backward, forward }
|
||||||
|
|
||||||
|
/// Default touch-first control set for [LeafVideoPage]: double-tap thirds to
|
||||||
|
/// seek (middle toggles playback), single tap toggles the viewer's chrome,
|
||||||
|
/// bottom bar with scrubber and a fullscreen slot.
|
||||||
|
///
|
||||||
|
/// Visibility is the viewer's chrome state, never owned here — this widget
|
||||||
|
/// only renders. Outside a chrome-enabled viewer it renders permanently
|
||||||
|
/// visible and single taps do nothing.
|
||||||
|
class LeafVideoControls extends StatefulWidget {
|
||||||
|
const LeafVideoControls({super.key, required this.adapter, this.skipDuration = const Duration(seconds: 10), this.onFullscreenToggle});
|
||||||
|
|
||||||
|
final VideoPlayerAdapter adapter;
|
||||||
|
final Duration skipDuration;
|
||||||
|
|
||||||
|
/// Null hides the fullscreen button.
|
||||||
|
final VoidCallback? onFullscreenToggle;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LeafVideoControls> createState() => _LeafVideoControlsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LeafVideoControlsState extends State<LeafVideoControls> {
|
||||||
|
static const _feedbackDuration = Duration(milliseconds: 600);
|
||||||
|
|
||||||
|
Offset? _doubleTapPos;
|
||||||
|
Timer? _skipFeedbackTimer;
|
||||||
|
_SkipDirection? _skipFeedback;
|
||||||
|
Duration? _skipAmount;
|
||||||
|
|
||||||
|
bool get _mobile => !leafIsDesktopPlatform();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_skipFeedbackTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTap() {
|
||||||
|
LeafViewer.chromeOf(context)?.toggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDoubleTapDown(TapDownDetails details) {
|
||||||
|
_doubleTapPos = details.localPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDoubleTap() {
|
||||||
|
if (_doubleTapPos == null) return;
|
||||||
|
final adapter = widget.adapter;
|
||||||
|
if (!_mobile) {
|
||||||
|
adapter.playOrPause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final box = context.findRenderObject() as RenderBox;
|
||||||
|
final third = box.size.width / 3;
|
||||||
|
final x = _doubleTapPos!.dx;
|
||||||
|
final skip = widget.skipDuration;
|
||||||
|
|
||||||
|
if (x < third) {
|
||||||
|
final target = adapter.position - skip;
|
||||||
|
adapter.seek(target < Duration.zero ? Duration.zero : target);
|
||||||
|
_accumulate(_SkipDirection.backward, skip);
|
||||||
|
} else if (x > third * 2) {
|
||||||
|
final target = adapter.position + skip;
|
||||||
|
adapter.seek(target > adapter.duration ? adapter.duration : target);
|
||||||
|
_accumulate(_SkipDirection.forward, skip);
|
||||||
|
} else {
|
||||||
|
adapter.playOrPause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _accumulate(_SkipDirection direction, Duration skip) {
|
||||||
|
final amount = _skipFeedback == direction ? _skipAmount! + skip : skip;
|
||||||
|
_skipFeedbackTimer?.cancel();
|
||||||
|
setState(() {
|
||||||
|
_skipFeedback = direction;
|
||||||
|
_skipAmount = amount;
|
||||||
|
});
|
||||||
|
_skipFeedbackTimer = Timer(_feedbackDuration, () {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_skipFeedback = null;
|
||||||
|
_skipAmount = null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final chrome = LeafViewer.chromeOf(context);
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
type: MaterialType.transparency,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: _handleTap,
|
||||||
|
onDoubleTapDown: _handleDoubleTapDown,
|
||||||
|
onDoubleTap: _handleDoubleTap,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Bottom bar — fades with the viewer's chrome state.
|
||||||
|
if (chrome != null)
|
||||||
|
ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: chrome.visibleListenable,
|
||||||
|
builder: (context, visible, _) => IgnorePointer(
|
||||||
|
ignoring: !visible,
|
||||||
|
child: AnimatedOpacity(duration: const Duration(milliseconds: 200), opacity: visible ? 1.0 : 0.0, child: _buildBar(context)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
_buildBar(context),
|
||||||
|
// Center play badge while paused.
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_skipFeedback != null && _skipAmount != null)
|
||||||
|
IgnorePointer(
|
||||||
|
child: Align(
|
||||||
|
alignment: _skipFeedback == _SkipDirection.backward ? Alignment.centerLeft : Alignment.centerRight,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(100)),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(_skipFeedback == _SkipDirection.backward ? Icons.fast_rewind : Icons.fast_forward, size: 20),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text('${_skipFeedback == _SkipDirection.forward ? '+' : '-'}${_skipAmount!.inSeconds}s'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBar(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(colors: [Colors.black, Colors.transparent], begin: Alignment.bottomCenter, end: Alignment.topCenter),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 50),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _Scrubber(widget.adapter)),
|
||||||
|
StreamBuilder<Duration>(
|
||||||
|
stream: widget.adapter.positionStream,
|
||||||
|
builder: (context, _) => Text(
|
||||||
|
'${_formatDuration(widget.adapter.position)} / ${_formatDuration(widget.adapter.duration)}',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.onFullscreenToggle != null) IconButton(icon: const Icon(Icons.fullscreen), onPressed: widget.onFullscreenToggle),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Scrubber extends StatefulWidget {
|
||||||
|
const _Scrubber(this.adapter);
|
||||||
|
|
||||||
|
final VideoPlayerAdapter adapter;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_Scrubber> createState() => _ScrubberState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScrubberState extends State<_Scrubber> {
|
||||||
|
bool _wasPaused = false;
|
||||||
|
|
||||||
|
double _fraction(Duration value) {
|
||||||
|
final duration = widget.adapter.duration;
|
||||||
|
return duration.inMilliseconds > 0 ? (value.inMilliseconds / duration.inMilliseconds).clamp(0.0, 1.0) : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _seekFraction(double fraction) {
|
||||||
|
widget.adapter.seek(Duration(milliseconds: (widget.adapter.duration.inMilliseconds * fraction).round()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
const height = 6.0;
|
||||||
|
const radius = Radius.circular(3);
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTapDown: (details) => _seekFraction(details.localPosition.dx / (context.findRenderObject() as RenderBox).size.width),
|
||||||
|
onHorizontalDragStart: (_) {
|
||||||
|
_wasPaused = !widget.adapter.isPlaying;
|
||||||
|
widget.adapter.pause();
|
||||||
|
},
|
||||||
|
onHorizontalDragUpdate: (details) => _seekFraction(details.localPosition.dx / (context.findRenderObject() as RenderBox).size.width),
|
||||||
|
onHorizontalDragEnd: (_) {
|
||||||
|
if (!_wasPaused) widget.adapter.play();
|
||||||
|
},
|
||||||
|
child: SizedBox(
|
||||||
|
height: 20,
|
||||||
|
child: Center(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final width = constraints.maxWidth;
|
||||||
|
return SizedBox(
|
||||||
|
height: height,
|
||||||
|
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) * width).clamp(0.0, width),
|
||||||
|
color: Colors.white30,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
StreamBuilder<Duration>(
|
||||||
|
stream: widget.adapter.positionStream,
|
||||||
|
builder: (context, _) => AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
curve: Curves.fastEaseInToSlowEaseOut,
|
||||||
|
width: (_fraction(widget.adapter.position) * width).clamp(0.0, width),
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,258 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../viewer/leaf_chrome.dart';
|
||||||
|
import '../viewer/leaf_viewer.dart';
|
||||||
|
import '../viewer/leaf_viewer_internal.dart';
|
||||||
|
import 'leaf_video_adapter.dart';
|
||||||
|
import 'leaf_video_controls.dart';
|
||||||
|
|
||||||
|
/// A video page for a [LeafViewer]: owns the full player lifecycle so host
|
||||||
|
/// apps don't rewrite it per project.
|
||||||
|
///
|
||||||
|
/// Playback gating — the part every app gets wrong:
|
||||||
|
/// - built as a neighbour page → loads and buffers, stays paused (prebuffer)
|
||||||
|
/// - becomes the settled page → plays
|
||||||
|
/// - stops being it, or the route gets covered, or the app backgrounds → pauses
|
||||||
|
/// - uncovers / returns → resumes only if it was playing before; re-activating
|
||||||
|
/// (swiping back) always resumes, TikTok-style
|
||||||
|
///
|
||||||
|
/// Chrome policy on a chrome-enabled viewer: while paused, chrome is held up
|
||||||
|
/// (and shown on the pause transition); while playing, the viewer's auto-hide
|
||||||
|
/// timer runs.
|
||||||
|
class LeafVideoPage extends StatefulWidget {
|
||||||
|
const LeafVideoPage({
|
||||||
|
super.key,
|
||||||
|
required this.createAdapter,
|
||||||
|
required this.url,
|
||||||
|
this.headers,
|
||||||
|
this.aspectRatio,
|
||||||
|
this.thumbnail,
|
||||||
|
this.controlsBuilder,
|
||||||
|
this.skipDuration = const Duration(seconds: 10),
|
||||||
|
this.onFullscreenToggle,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Creates the (unloaded) adapter. Called once per page state; the page owns
|
||||||
|
/// and disposes it. Route by codec here — the closure can capture the post.
|
||||||
|
final VideoPlayerAdapter Function() createAdapter;
|
||||||
|
|
||||||
|
final String url;
|
||||||
|
final Map<String, String>? headers;
|
||||||
|
|
||||||
|
/// Video aspect ratio; when set, the surface and thumbnail are centered at
|
||||||
|
/// that ratio while gestures keep working across the whole page.
|
||||||
|
final double? aspectRatio;
|
||||||
|
|
||||||
|
/// Shown until [VideoPlayerAdapter.readyStream] fires.
|
||||||
|
final Widget? thumbnail;
|
||||||
|
|
||||||
|
/// Defaults to [LeafVideoControls].
|
||||||
|
final Widget Function(BuildContext context, VideoPlayerAdapter adapter)? controlsBuilder;
|
||||||
|
final Duration skipDuration;
|
||||||
|
|
||||||
|
/// Passed through to the default controls; null hides the button. Provide
|
||||||
|
/// it only for videos where fullscreen makes sense (landscape ones).
|
||||||
|
final VoidCallback? onFullscreenToggle;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LeafVideoPage> createState() => _LeafVideoPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LeafVideoPageState extends State<LeafVideoPage> with WidgetsBindingObserver {
|
||||||
|
late final VideoPlayerAdapter _adapter;
|
||||||
|
bool _loaded = false;
|
||||||
|
bool _failed = false;
|
||||||
|
bool _ready = false;
|
||||||
|
|
||||||
|
bool _active = true;
|
||||||
|
bool _obscured = false;
|
||||||
|
bool _backgrounded = false;
|
||||||
|
bool _wasCovered = false;
|
||||||
|
bool _wasPlayingBeforeCover = false;
|
||||||
|
|
||||||
|
LeafViewerChrome? _chrome;
|
||||||
|
int? _pageIndex;
|
||||||
|
ValueListenable<int>? _currentIndex;
|
||||||
|
Animation<double>? _routeAnimation;
|
||||||
|
final _subs = <StreamSubscription<void>>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
_adapter = widget.createAdapter();
|
||||||
|
_subs.add(_adapter.playingStream.listen(_onPlayingChanged));
|
||||||
|
_subs.add(_adapter.readyStream.listen((_) {
|
||||||
|
if (mounted) setState(() => _ready = true);
|
||||||
|
}));
|
||||||
|
_subs.add(_adapter.errorStream.listen((error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _failed = true);
|
||||||
|
_adapter.pause();
|
||||||
|
}));
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
try {
|
||||||
|
await _adapter.load(url: widget.url, headers: widget.headers);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _failed = true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _loaded = true);
|
||||||
|
if (_active && !_obscured && !_backgrounded) _adapter.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
_chrome = LeafViewer.chromeOf(context);
|
||||||
|
_pageIndex = LeafPageIndexScope.maybeOf(context);
|
||||||
|
|
||||||
|
final currentIndex = LeafViewer.currentIndexListOf(context);
|
||||||
|
if (!identical(currentIndex, _currentIndex)) {
|
||||||
|
_currentIndex?.removeListener(_onIndexChanged);
|
||||||
|
_currentIndex = currentIndex;
|
||||||
|
_currentIndex?.addListener(_onIndexChanged);
|
||||||
|
_evaluateActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(LeafVideoPage oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.url != widget.url || oldWidget.headers != widget.headers) {
|
||||||
|
_loaded = false;
|
||||||
|
_ready = false;
|
||||||
|
_failed = false;
|
||||||
|
setState(() {});
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
_backgrounded = state == AppLifecycleState.paused || state == AppLifecycleState.hidden;
|
||||||
|
_updateCovered();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onRouteStatus(AnimationStatus status) {
|
||||||
|
_setObscured(status != AnimationStatus.dismissed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setObscured(bool obscured) {
|
||||||
|
if (_obscured == obscured) return;
|
||||||
|
_obscured = obscured;
|
||||||
|
_updateCovered();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onIndexChanged() => _evaluateActive();
|
||||||
|
|
||||||
|
void _evaluateActive() {
|
||||||
|
final index = _pageIndex;
|
||||||
|
final active = index == null || (_currentIndex?.value == index);
|
||||||
|
if (active == _active) return;
|
||||||
|
_active = active;
|
||||||
|
if (!active) {
|
||||||
|
// Release hold for the page leaving the stage; the active page's policy
|
||||||
|
// owns it from here.
|
||||||
|
_chrome?.hold = false;
|
||||||
|
_adapter.pause();
|
||||||
|
} else if (_loaded && !_failed && !_obscured && !_backgrounded) {
|
||||||
|
_adapter.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateCovered() {
|
||||||
|
final covered = _obscured || _backgrounded;
|
||||||
|
if (covered) {
|
||||||
|
if (_wasCovered) return;
|
||||||
|
_wasPlayingBeforeCover = _adapter.isPlaying;
|
||||||
|
_adapter.pause();
|
||||||
|
} else {
|
||||||
|
if (!_wasCovered) return;
|
||||||
|
if (_wasPlayingBeforeCover && _active && _loaded && !_failed) _adapter.play();
|
||||||
|
}
|
||||||
|
_wasCovered = covered;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPlayingChanged(bool playing) {
|
||||||
|
if (!_active) return;
|
||||||
|
final chrome = _chrome;
|
||||||
|
if (chrome == null) return;
|
||||||
|
if (playing) {
|
||||||
|
chrome.hold = false;
|
||||||
|
} else {
|
||||||
|
chrome.hold = true;
|
||||||
|
chrome.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _retry() async {
|
||||||
|
setState(() => _failed = false);
|
||||||
|
await _load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final sub in _subs) {
|
||||||
|
sub.cancel();
|
||||||
|
}
|
||||||
|
_currentIndex?.removeListener(_onIndexChanged);
|
||||||
|
_routeAnimation?.removeStatusListener(_onRouteStatus);
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
_adapter.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final ratio = widget.aspectRatio;
|
||||||
|
final surface = ratio != null ? Center(child: AspectRatio(aspectRatio: ratio, child: _adapter.surface)) : _adapter.surface;
|
||||||
|
final thumbnail = widget.thumbnail;
|
||||||
|
final controls =
|
||||||
|
widget.controlsBuilder?.call(context, _adapter) ??
|
||||||
|
LeafVideoControls(adapter: _adapter, skipDuration: widget.skipDuration, onFullscreenToggle: widget.onFullscreenToggle);
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
surface,
|
||||||
|
if (!_ready && thumbnail != null)
|
||||||
|
IgnorePointer(
|
||||||
|
child: ratio != null ? Center(child: AspectRatio(aspectRatio: ratio, child: thumbnail)) : Center(child: thumbnail),
|
||||||
|
),
|
||||||
|
if (!_loaded && !_failed && !_ready && thumbnail == null)
|
||||||
|
const Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
Positioned.fill(child: controls),
|
||||||
|
if (_failed)
|
||||||
|
ColoredBox(
|
||||||
|
color: Colors.black54,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.broken_image, color: Colors.white38, size: 40),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(onPressed: _retry, child: const Text('Tap to retry', style: TextStyle(color: Colors.white38))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:leaf_widgets/leaf_widgets.dart';
|
||||||
|
|
||||||
|
/// Scripted fake player: records commands, lets tests push stream events.
|
||||||
|
class FakeAdapter extends VideoPlayerAdapter {
|
||||||
|
bool failLoad = false;
|
||||||
|
bool disposed = false;
|
||||||
|
bool playing = 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 {
|
||||||
|
if (failLoad) throw Exception('load failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
void emitPlaying(bool value) {
|
||||||
|
playing = value;
|
||||||
|
_playingCtrl.add(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void emitReady() => _readyCtrl.add(null);
|
||||||
|
|
||||||
|
@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() {
|
||||||
|
late PageController controller;
|
||||||
|
final adapters = <int, FakeAdapter>{};
|
||||||
|
BuildContext? pageContext;
|
||||||
|
|
||||||
|
Widget harness({required int pageCount, bool enableChrome = false, Map<int, FakeAdapter> inject = const {}}) {
|
||||||
|
adapters.clear();
|
||||||
|
pageContext = null;
|
||||||
|
controller = PageController();
|
||||||
|
return MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: LeafViewer(
|
||||||
|
pageCount: pageCount,
|
||||||
|
enableChrome: enableChrome,
|
||||||
|
pageController: controller,
|
||||||
|
pageBuilder: (context, i) {
|
||||||
|
pageContext ??= context;
|
||||||
|
final adapter = adapters.putIfAbsent(i, () => inject[i] ?? FakeAdapter());
|
||||||
|
return LeafVideoPage(
|
||||||
|
key: ValueKey(i),
|
||||||
|
createAdapter: () => adapter,
|
||||||
|
url: 'https://example.com/$i.mp4',
|
||||||
|
thumbnail: Text('thumb $i'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('neighbour pages buffer but stay silent; settled page plays', (tester) async {
|
||||||
|
await tester.pumpWidget(harness(pageCount: 2));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(adapters[0]!.plays.length, 1);
|
||||||
|
expect(adapters[1], isNotNull);
|
||||||
|
expect(adapters[1]!.plays, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('swiping pauses the old page and plays the new one; swiping back resumes', (tester) async {
|
||||||
|
await tester.pumpWidget(harness(pageCount: 2));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(adapters[0]!.plays.length, 1);
|
||||||
|
|
||||||
|
controller.jumpToPage(1);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(adapters[0]!.playing, isFalse);
|
||||||
|
expect(adapters[1]!.playing, isTrue);
|
||||||
|
|
||||||
|
controller.jumpToPage(0);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(adapters[1]!.playing, isFalse);
|
||||||
|
expect(adapters[0]!.playing, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('ready event drops the thumbnail', (tester) async {
|
||||||
|
await tester.pumpWidget(harness(pageCount: 1));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('thumb 0'), findsOneWidget);
|
||||||
|
|
||||||
|
adapters[0]!.emitReady();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('thumb 0'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('load failure shows retry UI; retry reloads and plays', (tester) async {
|
||||||
|
await tester.pumpWidget(harness(pageCount: 1, inject: {0: FakeAdapter()..failLoad = true}));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Tap to retry'), findsOneWidget);
|
||||||
|
|
||||||
|
adapters[0]!.failLoad = false;
|
||||||
|
await tester.tap(find.text('Tap to retry'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Tap to retry'), findsNothing);
|
||||||
|
expect(adapters[0]!.plays.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('chrome policy: pause shows and holds chrome, playing releases the hold', (tester) async {
|
||||||
|
await tester.pumpWidget(harness(pageCount: 1, enableChrome: true));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final chrome = LeafViewer.chromeOf(pageContext!)!;
|
||||||
|
|
||||||
|
adapters[0]!.emitPlaying(true);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
adapters[0]!.emitPlaying(false);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(chrome.visible, isTrue);
|
||||||
|
await tester.pump(const Duration(seconds: 4));
|
||||||
|
expect(chrome.visible, isTrue, reason: 'paused video must hold chrome up');
|
||||||
|
|
||||||
|
adapters[0]!.emitPlaying(true);
|
||||||
|
await tester.pump(const Duration(seconds: 4));
|
||||||
|
expect(chrome.visible, isFalse, reason: 'playing releases the hold; timer hides chrome');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('disposing the page disposes the adapter', (tester) async {
|
||||||
|
await tester.pumpWidget(harness(pageCount: 1));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
expect(adapters[0]!.disposed, isTrue);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user