From b2e9b04659837e8f34912ed669c7d0e37f12f4a6 Mon Sep 17 00:00:00 2001 From: otsmr Date: Wed, 29 Apr 2026 16:58:32 +0200 Subject: [PATCH] Improved: Videos can now be paused --- CHANGELOG.md | 1 + lib/src/services/flame.service.dart | 3 +- .../visual/helpers/video_player.helper.dart | 120 +++++++++++++----- .../helpers/video_player_file.helper.dart | 60 +++++++++ .../visual/views/chats/media_viewer.view.dart | 29 +++-- .../views/shared/memory_item_slider.view.dart | 4 +- 6 files changed, 174 insertions(+), 43 deletions(-) create mode 100644 lib/src/visual/helpers/video_player_file.helper.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 9958a75c..32120721 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - New: Registration setup to configure the most important configurations - Improved: Show ⌛ instead of the flame icon when it is about to expire - Improved: FAQ is now in the app rather than opening in the browser +- Improved: Videos can now be paused - Fix: Many smaller issues ## 0.1.8 diff --git a/lib/src/services/flame.service.dart b/lib/src/services/flame.service.dart index 0ea211f4..8824ac7f 100644 --- a/lib/src/services/flame.service.dart +++ b/lib/src/services/flame.service.dart @@ -35,8 +35,9 @@ Future syncFlameCounters({String? forceForGroup}) async { final flameResult = getFlameCounterFromGroup(group); // only sync when flame counter is higher three or when they are bestFriends - if (flameResult.counter <= 2 && bestFriend.groupId != group.groupId) + if (flameResult.counter <= 2 && bestFriend.groupId != group.groupId) { continue; + } await sendCipherTextToGroup( group.groupId, diff --git a/lib/src/visual/helpers/video_player.helper.dart b/lib/src/visual/helpers/video_player.helper.dart index 5df1cd14..cf3048bb 100644 --- a/lib/src/visual/helpers/video_player.helper.dart +++ b/lib/src/visual/helpers/video_player.helper.dart @@ -1,59 +1,121 @@ -import 'dart:async'; -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; class VideoPlayerHelper extends StatefulWidget { const VideoPlayerHelper({ - required this.videoPath, + required this.controller, + this.onDoubleTap, super.key, }); - final File videoPath; + + final VideoPlayerController controller; + final VoidCallback? onDoubleTap; @override State createState() => _VideoPlayerHelperState(); } -class _VideoPlayerHelperState extends State { - late VideoPlayerController _controller; +class _VideoPlayerHelperState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _iconAnim; + late final Animation _opacity; + late final Animation _scale; + + bool _isPaused = false; @override void initState() { super.initState(); - _controller = VideoPlayerController.file( - widget.videoPath, - videoPlayerOptions: VideoPlayerOptions( - mixWithOthers: true, - ), + _iconAnim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), ); - unawaited( - _controller.initialize().then((_) async { - if (context.mounted) { - await _controller.setLooping(true); - await _controller.play(); - setState(() {}); - } - }), - ); + // Opacity: flash in quickly, hold, fade out + _opacity = TweenSequence([ + TweenSequenceItem(tween: Tween(begin: 0, end: 1), weight: 15), + TweenSequenceItem(tween: ConstantTween(1), weight: 30), + TweenSequenceItem(tween: Tween(begin: 1, end: 0), weight: 55), + ]).animate(_iconAnim); + + // Scale: pop in slightly over-sized then settle + _scale = TweenSequence([ + TweenSequenceItem(tween: Tween(begin: 0.6, end: 1.15), weight: 20), + TweenSequenceItem(tween: Tween(begin: 1.15, end: 1), weight: 15), + TweenSequenceItem(tween: ConstantTween(1), weight: 65), + ]).animate(CurvedAnimation(parent: _iconAnim, curve: Curves.easeOut)); + + widget.controller.addListener(_onControllerUpdate); + } + + void _onControllerUpdate() { + final paused = !widget.controller.value.isPlaying; + if (paused != _isPaused && mounted) { + setState(() => _isPaused = paused); + } } @override void dispose() { - unawaited(_controller.dispose()); + widget.controller.removeListener(_onControllerUpdate); + _iconAnim.dispose(); super.dispose(); } + void _togglePlayPause() { + if (widget.controller.value.isPlaying) { + widget.controller.pause(); + } else { + widget.controller.play(); + } + _iconAnim.forward(from: 0); + } + @override Widget build(BuildContext context) { - return Center( - child: _controller.value.isInitialized - ? AspectRatio( - aspectRatio: _controller.value.aspectRatio, - child: VideoPlayer(_controller), - ) - : const CircularProgressIndicator(), // Show loading indicator while initializing + return GestureDetector( + onTap: _togglePlayPause, + onDoubleTap: widget.onDoubleTap, + child: Stack( + alignment: Alignment.center, + children: [ + VideoPlayer(widget.controller), + AnimatedBuilder( + animation: _iconAnim, + builder: (context, _) { + // While paused and the flash has finished, show a dim persistent icon + final opacity = _iconAnim.isAnimating + ? _opacity.value + : (_isPaused ? 0.1 : 0.0); + final scale = _iconAnim.isAnimating ? _scale.value : 1.0; + + if (opacity == 0.0) return const SizedBox.shrink(); + + return Opacity( + opacity: opacity, + child: Transform.scale( + scale: scale, + child: Container( + width: 64, + height: 64, + decoration: const BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + child: Icon( + _isPaused + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + color: Colors.white, + size: 36, + ), + ), + ), + ); + }, + ), + ], + ), ); } } diff --git a/lib/src/visual/helpers/video_player_file.helper.dart b/lib/src/visual/helpers/video_player_file.helper.dart new file mode 100644 index 00000000..06931235 --- /dev/null +++ b/lib/src/visual/helpers/video_player_file.helper.dart @@ -0,0 +1,60 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:twonly/src/visual/helpers/video_player.helper.dart'; +import 'package:video_player/video_player.dart'; + +class VideoPlayerFileHelper extends StatefulWidget { + const VideoPlayerFileHelper({ + required this.videoPath, + super.key, + }); + final File videoPath; + + @override + State createState() => _VideoPlayerFileHelperState(); +} + +class _VideoPlayerFileHelperState extends State { + late VideoPlayerController _controller; + + @override + void initState() { + super.initState(); + _controller = VideoPlayerController.file( + widget.videoPath, + videoPlayerOptions: VideoPlayerOptions( + mixWithOthers: true, + ), + ); + + unawaited( + _controller.initialize().then((_) async { + if (context.mounted) { + await _controller.setLooping(true); + await _controller.play(); + setState(() {}); + } + }), + ); + } + + @override + void dispose() { + unawaited(_controller.dispose()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Center( + child: _controller.value.isInitialized + ? AspectRatio( + aspectRatio: _controller.value.aspectRatio, + child: VideoPlayerHelper(controller: _controller), + ) + : const CircularProgressIndicator(), + ); + } +} diff --git a/lib/src/visual/views/chats/media_viewer.view.dart b/lib/src/visual/views/chats/media_viewer.view.dart index ab2f2332..ee3c84cb 100644 --- a/lib/src/visual/views/chats/media_viewer.view.dart +++ b/lib/src/visual/views/chats/media_viewer.view.dart @@ -29,6 +29,7 @@ import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/animate_icon.comp.dart'; import 'package:twonly/src/visual/decorations/input_text.decoration.dart'; import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart'; +import 'package:twonly/src/visual/helpers/video_player.helper.dart'; import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart'; import 'package:twonly/src/visual/views/camera/camera_send_to.view.dart'; import 'package:twonly/src/visual/views/chats/media_viewer_components/additional_message_content.dart'; @@ -565,6 +566,17 @@ class _MediaViewerViewState extends State ); } + void onTap() { + if (showSendTextMessageInput) { + setState(() { + showShortReactions = false; + showSendTextMessageInput = false; + }); + return; + } + nextMediaOrExit(); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -575,16 +587,8 @@ class _MediaViewerViewState extends State if ((currentMedia != null || videoController != null) && (canBeSeenUntil == null || progress >= 0)) GestureDetector( - onTap: () { - if (showSendTextMessageInput) { - setState(() { - showShortReactions = false; - showSendTextMessageInput = false; - }); - return; - } - nextMediaOrExit(); - }, + onTap: onTap, + onDoubleTap: (videoController == null) ? null : onTap, child: MediaViewSizingHelper( bottomNavigation: bottomNavigation(), requiredHeight: 55, @@ -595,7 +599,10 @@ class _MediaViewerViewState extends State child: PhotoView.customChild( initialScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained, - child: VideoPlayer(videoController!), + child: VideoPlayerHelper( + controller: videoController!, + onDoubleTap: onTap, + ), ), ) else if (currentMedia != null && diff --git a/lib/src/visual/views/shared/memory_item_slider.view.dart b/lib/src/visual/views/shared/memory_item_slider.view.dart index a5aa2c54..1a13c11f 100644 --- a/lib/src/visual/views/shared/memory_item_slider.view.dart +++ b/lib/src/visual/views/shared/memory_item_slider.view.dart @@ -10,7 +10,7 @@ import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart'; import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart'; -import 'package:twonly/src/visual/helpers/video_player.helper.dart'; +import 'package:twonly/src/visual/helpers/video_player_file.helper.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/save_to_gallery.dart'; import 'package:twonly/src/visual/views/camera/share_image_editor.view.dart'; @@ -239,7 +239,7 @@ class _MemoriesPhotoSliderViewState extends State { return item.mediaService.mediaFile.type == MediaType.video ? PhotoViewGalleryPageOptions.customChild( - child: VideoPlayerHelper( + child: VideoPlayerFileHelper( videoPath: filePath, ), initialScale: PhotoViewComputedScale.contained,