video kit: VideoPlayerAdapter seam, LeafVideoPage lifecycle (active/route/lifecycle gating, prebuffer, retry), LeafVideoControls

This commit is contained in:
foxyz committed 2026-09-02 22:41:55 +03:00
1 parent 3cc02c9bbe
commit bdbd3090f1
5 files changed
+763

No files matched your search

+39
View File
@@ -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();
}
+285
View File
@@ -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';
}
+258
View File
@@ -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))),
],
),
),
],
);
}
}