diff --git a/mobile/lib/ui/viewer/file/native_video_player_controls/play_pause_button.dart b/mobile/lib/ui/viewer/file/native_video_player_controls/play_pause_button.dart new file mode 100644 index 0000000000..9ad34fc4e8 --- /dev/null +++ b/mobile/lib/ui/viewer/file/native_video_player_controls/play_pause_button.dart @@ -0,0 +1,63 @@ +import "package:flutter/material.dart"; +import "package:native_video_player/native_video_player.dart"; + +class PlayPauseButton extends StatefulWidget { + final NativeVideoPlayerController? controller; + const PlayPauseButton(this.controller, {super.key}); + + @override + State createState() => _PlayPauseButtonState(); +} + +class _PlayPauseButtonState extends State { + bool _isPlaying = true; + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (_playbackStatus == PlaybackStatus.playing) { + widget.controller?.pause(); + setState(() { + _isPlaying = false; + }); + } else { + widget.controller?.play(); + setState(() { + _isPlaying = true; + }); + } + }, + child: Container( + width: 54, + height: 54, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.3), + shape: BoxShape.circle, + ), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + transitionBuilder: (Widget child, Animation animation) { + return ScaleTransition(scale: animation, child: child); + }, + switchInCurve: Curves.easeInOutQuart, + switchOutCurve: Curves.easeInOutQuart, + child: _isPlaying + ? const Icon( + Icons.pause, + size: 32, + key: ValueKey("pause"), + ) + : const Icon( + Icons.play_arrow, + size: 36, + key: ValueKey("play"), + ), + ), + ), + ); + } + + PlaybackStatus? get _playbackStatus => + widget.controller?.playbackInfo?.status; +} diff --git a/mobile/lib/ui/viewer/file/native_video_player_controls/seek_bar.dart b/mobile/lib/ui/viewer/file/native_video_player_controls/seek_bar.dart new file mode 100644 index 0000000000..84b5ddf1e2 --- /dev/null +++ b/mobile/lib/ui/viewer/file/native_video_player_controls/seek_bar.dart @@ -0,0 +1,151 @@ +import "dart:async"; + +import "package:flutter/material.dart"; +import "package:native_video_player/native_video_player.dart"; + +class SeekBar extends StatefulWidget { + final NativeVideoPlayerController controller; + final int? duration; + const SeekBar(this.controller, this.duration, {super.key}); + + @override + State createState() => _SeekBarState(); +} + +class _SeekBarState extends State with SingleTickerProviderStateMixin { + late final AnimationController _animationController; + double _prevPositionFraction = 0.0; + + @override + void initState() { + super.initState(); + + _animationController = AnimationController( + vsync: this, + value: 0, + ); + + widget.controller.onPlaybackStatusChanged.addListener( + _onPlaybackStatusChanged, + ); + widget.controller.onPlaybackPositionChanged.addListener( + _onPlaybackPositionChanged, + ); + + _startMovingSeekbar(); + } + + @override + void dispose() { + _animationController.dispose(); + widget.controller.onPlaybackStatusChanged.removeListener( + _onPlaybackStatusChanged, + ); + widget.controller.onPlaybackPositionChanged.removeListener( + _onPlaybackPositionChanged, + ); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animationController, + builder: (_, __) { + return SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8.0), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 14.0), + activeTrackColor: Colors.red, + inactiveTrackColor: Colors.grey, + thumbColor: Colors.red, + overlayColor: Colors.red.withOpacity(0.4), + ), + child: Slider( + min: 0.0, + max: 1.0, + value: _animationController.value, + onChanged: (value) {}, + divisions: 4500, + onChangeEnd: (value) { + widget.controller.seekTo((value * widget.duration!).round()); + _animationController.animateTo( + value, + duration: const Duration(microseconds: 0), + ); + }, + allowedInteraction: SliderInteraction.tapAndSlide, + ), + ); + }, + ); + } + + void _startMovingSeekbar() { + //Video starts playing after a slight delay. This delay is to ensure that + //the seek bar animation starts after the video starts playing. + Future.delayed(const Duration(milliseconds: 700), () { + if (widget.duration != null) { + unawaited( + _animationController.animateTo( + (1 / widget.duration!), + duration: const Duration(seconds: 1), + ), + ); + } else { + unawaited( + _animationController.animateTo( + 0, + duration: const Duration(seconds: 1), + ), + ); + } + }); + } + + void _onPlaybackStatusChanged() { + if (widget.controller.playbackInfo?.status == PlaybackStatus.paused) { + _animationController.stop(); + } + } + + void _onPlaybackPositionChanged() async { + if (widget.controller.playbackInfo?.status == PlaybackStatus.paused) { + return; + } + final target = widget.controller.playbackInfo?.positionFraction ?? 0; + + //To immediately set the position to 0 when the ends when playing in loop + if (_prevPositionFraction == 1.0 && target == 0.0) { + unawaited( + _animationController.animateTo(0, duration: const Duration(seconds: 0)), + ); + } + + //There is a slight delay (around 350 ms) for the event being listened to + //by this listener on the next target (target that comes after 0). Adding + //this buffer to keep the seek bar animation smooth. + if (target == 0) { + await Future.delayed(const Duration(milliseconds: 450)); + } + + if (widget.duration != null) { + unawaited( + _animationController.animateTo( + target + (1 / widget.duration!), + duration: const Duration(seconds: 1), + ), + ); + } else { + unawaited( + _animationController.animateTo( + target, + duration: const Duration(seconds: 1), + ), + ); + } + + _prevPositionFraction = target; + } +} diff --git a/mobile/lib/ui/viewer/file/video_widget_native.dart b/mobile/lib/ui/viewer/file/video_widget_native.dart index cfbc75452d..646cafba59 100644 --- a/mobile/lib/ui/viewer/file/video_widget_native.dart +++ b/mobile/lib/ui/viewer/file/video_widget_native.dart @@ -14,6 +14,8 @@ import "package:photos/models/file/extensions/file_props.dart"; import "package:photos/models/file/file.dart"; import "package:photos/services/files_service.dart"; import "package:photos/ui/actions/file/file_actions.dart"; +import "package:photos/ui/viewer/file/native_video_player_controls/play_pause_button.dart"; +import "package:photos/ui/viewer/file/native_video_player_controls/seek_bar.dart"; import "package:photos/ui/viewer/file/thumbnail_widget.dart"; import "package:photos/utils/dialog_util.dart"; import "package:photos/utils/exif_util.dart"; @@ -177,7 +179,7 @@ class _VideoWidgetNativeState extends State child: ValueListenableBuilder( builder: (BuildContext context, bool value, _) { return value - ? _SeekBar(_controller!, duration) + ? SeekBar(_controller!, duration) : const SizedBox(); }, valueListenable: _isControllerInitialized, @@ -346,212 +348,3 @@ class _VideoWidgetNativeState extends State } } } - -class PlayPauseButton extends StatefulWidget { - final NativeVideoPlayerController? controller; - const PlayPauseButton(this.controller, {super.key}); - - @override - State createState() => _PlayPauseButtonState(); -} - -class _PlayPauseButtonState extends State { - bool _isPlaying = true; - @override - Widget build(BuildContext context) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () { - if (_playbackStatus == PlaybackStatus.playing) { - widget.controller?.pause(); - setState(() { - _isPlaying = false; - }); - } else { - widget.controller?.play(); - setState(() { - _isPlaying = true; - }); - } - }, - child: Container( - width: 54, - height: 54, - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.3), - shape: BoxShape.circle, - ), - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 250), - transitionBuilder: (Widget child, Animation animation) { - return ScaleTransition(scale: animation, child: child); - }, - switchInCurve: Curves.easeInOutQuart, - switchOutCurve: Curves.easeInOutQuart, - child: _isPlaying - ? const Icon( - Icons.pause, - size: 32, - key: ValueKey("pause"), - ) - : const Icon( - Icons.play_arrow, - size: 36, - key: ValueKey("play"), - ), - ), - ), - ); - } - - PlaybackStatus? get _playbackStatus => - widget.controller?.playbackInfo?.status; -} - -class _SeekBar extends StatefulWidget { - final NativeVideoPlayerController controller; - final int? duration; - const _SeekBar(this.controller, this.duration); - - @override - State<_SeekBar> createState() => _SeekBarState(); -} - -class _SeekBarState extends State<_SeekBar> - with SingleTickerProviderStateMixin { - late final AnimationController _animationController; - double _prevPositionFraction = 0.0; - - @override - void initState() { - super.initState(); - - _animationController = AnimationController( - vsync: this, - value: 0, - ); - - widget.controller.onPlaybackStatusChanged.addListener( - _onPlaybackStatusChanged, - ); - widget.controller.onPlaybackPositionChanged.addListener( - _onPlaybackPositionChanged, - ); - - _startMovingSeekbar(); - } - - @override - void dispose() { - _animationController.dispose(); - widget.controller.onPlaybackStatusChanged.removeListener( - _onPlaybackStatusChanged, - ); - widget.controller.onPlaybackPositionChanged.removeListener( - _onPlaybackPositionChanged, - ); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _animationController, - builder: (_, __) { - return SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: 2.0, - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8.0), - overlayShape: const RoundSliderOverlayShape(overlayRadius: 14.0), - activeTrackColor: Colors.red, - inactiveTrackColor: Colors.grey, - thumbColor: Colors.red, - overlayColor: Colors.red.withOpacity(0.4), - ), - child: Slider( - min: 0.0, - max: 1.0, - value: _animationController.value, - onChanged: (value) {}, - divisions: 4500, - onChangeEnd: (value) { - widget.controller.seekTo((value * widget.duration!).round()); - _animationController.animateTo( - value, - duration: const Duration(microseconds: 0), - ); - }, - allowedInteraction: SliderInteraction.tapAndSlide, - ), - ); - }, - ); - } - - void _startMovingSeekbar() { - //Video starts playing after a slight delay. This delay is to ensure that - //the seek bar animation starts after the video starts playing. - Future.delayed(const Duration(milliseconds: 700), () { - if (widget.duration != null) { - unawaited( - _animationController.animateTo( - (1 / widget.duration!), - duration: const Duration(seconds: 1), - ), - ); - } else { - unawaited( - _animationController.animateTo( - 0, - duration: const Duration(seconds: 1), - ), - ); - } - }); - } - - void _onPlaybackStatusChanged() { - if (widget.controller.playbackInfo?.status == PlaybackStatus.paused) { - _animationController.stop(); - } - } - - void _onPlaybackPositionChanged() async { - if (widget.controller.playbackInfo?.status == PlaybackStatus.paused) { - return; - } - final target = widget.controller.playbackInfo?.positionFraction ?? 0; - - //To immediately set the position to 0 when the ends when playing in loop - if (_prevPositionFraction == 1.0 && target == 0.0) { - unawaited( - _animationController.animateTo(0, duration: const Duration(seconds: 0)), - ); - } - - //There is a slight delay (around 350 ms) for the event being listened to - //by this listener on the next target (target that comes after 0). Adding - //this buffer to keep the seek bar animation smooth. - if (target == 0) { - await Future.delayed(const Duration(milliseconds: 450)); - } - - if (widget.duration != null) { - unawaited( - _animationController.animateTo( - target + (1 / widget.duration!), - duration: const Duration(seconds: 1), - ), - ); - } else { - unawaited( - _animationController.animateTo( - target, - duration: const Duration(seconds: 1), - ), - ); - } - - _prevPositionFraction = target; - } -}