diff --git a/lib/leaf_widgets.dart b/lib/leaf_widgets.dart index b99bdf8..1482ea7 100644 --- a/lib/leaf_widgets.dart +++ b/lib/leaf_widgets.dart @@ -7,6 +7,9 @@ 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_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_viewer.dart'; export 'src/viewer/leaf_zoom_controller.dart'; diff --git a/lib/src/video/leaf_video_adapter.dart b/lib/src/video/leaf_video_adapter.dart new file mode 100644 index 0000000..e862425 --- /dev/null +++ b/lib/src/video/leaf_video_adapter.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 load({required String url, Map? headers}); + + Stream get positionStream; + Stream get bufferStream; + Stream 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 get readyStream; + + /// Fires on playback errors after a successful [load]. + Stream 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(); +} diff --git a/lib/src/video/leaf_video_controls.dart b/lib/src/video/leaf_video_controls.dart new file mode 100644 index 0000000..3421219 --- /dev/null +++ b/lib/src/video/leaf_video_controls.dart @@ -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 createState() => _LeafVideoControlsState(); +} + +class _LeafVideoControlsState extends State { + 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( + 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( + 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( + 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( + 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( + 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'; +} diff --git a/lib/src/video/leaf_video_page.dart b/lib/src/video/leaf_video_page.dart new file mode 100644 index 0000000..87efdee --- /dev/null +++ b/lib/src/video/leaf_video_page.dart @@ -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? 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 createState() => _LeafVideoPageState(); +} + +class _LeafVideoPageState extends State 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? _currentIndex; + Animation? _routeAnimation; + final _subs = >[]; + + @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 _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 _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))), + ], + ), + ), + ], + ); + } +} diff --git a/test/video/leaf_video_page_test.dart b/test/video/leaf_video_page_test.dart new file mode 100644 index 0000000..352fce0 --- /dev/null +++ b/test/video/leaf_video_page_test.dart @@ -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 = []; + 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 { + if (failLoad) throw Exception('load failed'); + } + + void emitPlaying(bool value) { + playing = value; + _playingCtrl.add(value); + } + + void emitReady() => _readyCtrl.add(null); + + @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() { + late PageController controller; + final adapters = {}; + BuildContext? pageContext; + + Widget harness({required int pageCount, bool enableChrome = false, Map 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); + }); +}