Improved: Videos can now be paused

This commit is contained in:
otsmr 2026-04-29 16:58:32 +02:00
parent 9289def783
commit b2e9b04659
6 changed files with 174 additions and 43 deletions

View file

@ -7,6 +7,7 @@
- New: Registration setup to configure the most important configurations - New: Registration setup to configure the most important configurations
- Improved: Show ⌛ instead of the flame icon when it is about to expire - 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: FAQ is now in the app rather than opening in the browser
- Improved: Videos can now be paused
- Fix: Many smaller issues - Fix: Many smaller issues
## 0.1.8 ## 0.1.8

View file

@ -35,8 +35,9 @@ Future<void> syncFlameCounters({String? forceForGroup}) async {
final flameResult = getFlameCounterFromGroup(group); final flameResult = getFlameCounterFromGroup(group);
// only sync when flame counter is higher three or when they are bestFriends // 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; continue;
}
await sendCipherTextToGroup( await sendCipherTextToGroup(
group.groupId, group.groupId,

View file

@ -1,59 +1,121 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
class VideoPlayerHelper extends StatefulWidget { class VideoPlayerHelper extends StatefulWidget {
const VideoPlayerHelper({ const VideoPlayerHelper({
required this.videoPath, required this.controller,
this.onDoubleTap,
super.key, super.key,
}); });
final File videoPath;
final VideoPlayerController controller;
final VoidCallback? onDoubleTap;
@override @override
State<VideoPlayerHelper> createState() => _VideoPlayerHelperState(); State<VideoPlayerHelper> createState() => _VideoPlayerHelperState();
} }
class _VideoPlayerHelperState extends State<VideoPlayerHelper> { class _VideoPlayerHelperState extends State<VideoPlayerHelper>
late VideoPlayerController _controller; with SingleTickerProviderStateMixin {
late final AnimationController _iconAnim;
late final Animation<double> _opacity;
late final Animation<double> _scale;
bool _isPaused = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller = VideoPlayerController.file( _iconAnim = AnimationController(
widget.videoPath, vsync: this,
videoPlayerOptions: VideoPlayerOptions( duration: const Duration(milliseconds: 800),
mixWithOthers: true,
),
); );
unawaited( // Opacity: flash in quickly, hold, fade out
_controller.initialize().then((_) async { _opacity = TweenSequence<double>([
if (context.mounted) { TweenSequenceItem(tween: Tween(begin: 0, end: 1), weight: 15),
await _controller.setLooping(true); TweenSequenceItem(tween: ConstantTween(1), weight: 30),
await _controller.play(); TweenSequenceItem(tween: Tween(begin: 1, end: 0), weight: 55),
setState(() {}); ]).animate(_iconAnim);
}
}), // Scale: pop in slightly over-sized then settle
); _scale = TweenSequence<double>([
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 @override
void dispose() { void dispose() {
unawaited(_controller.dispose()); widget.controller.removeListener(_onControllerUpdate);
_iconAnim.dispose();
super.dispose(); super.dispose();
} }
void _togglePlayPause() {
if (widget.controller.value.isPlaying) {
widget.controller.pause();
} else {
widget.controller.play();
}
_iconAnim.forward(from: 0);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Center( return GestureDetector(
child: _controller.value.isInitialized onTap: _togglePlayPause,
? AspectRatio( onDoubleTap: widget.onDoubleTap,
aspectRatio: _controller.value.aspectRatio, child: Stack(
child: VideoPlayer(_controller), alignment: Alignment.center,
) children: [
: const CircularProgressIndicator(), // Show loading indicator while initializing 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,
),
),
),
);
},
),
],
),
); );
} }
} }

View file

@ -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<VideoPlayerFileHelper> createState() => _VideoPlayerFileHelperState();
}
class _VideoPlayerFileHelperState extends State<VideoPlayerFileHelper> {
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(),
);
}
}

View file

@ -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/components/animate_icon.comp.dart';
import 'package:twonly/src/visual/decorations/input_text.decoration.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/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/loader/three_rotating_dots.loader.dart';
import 'package:twonly/src/visual/views/camera/camera_send_to.view.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'; import 'package:twonly/src/visual/views/chats/media_viewer_components/additional_message_content.dart';
@ -565,6 +566,17 @@ class _MediaViewerViewState extends State<MediaViewerView>
); );
} }
void onTap() {
if (showSendTextMessageInput) {
setState(() {
showShortReactions = false;
showSendTextMessageInput = false;
});
return;
}
nextMediaOrExit();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -575,16 +587,8 @@ class _MediaViewerViewState extends State<MediaViewerView>
if ((currentMedia != null || videoController != null) && if ((currentMedia != null || videoController != null) &&
(canBeSeenUntil == null || progress >= 0)) (canBeSeenUntil == null || progress >= 0))
GestureDetector( GestureDetector(
onTap: () { onTap: onTap,
if (showSendTextMessageInput) { onDoubleTap: (videoController == null) ? null : onTap,
setState(() {
showShortReactions = false;
showSendTextMessageInput = false;
});
return;
}
nextMediaOrExit();
},
child: MediaViewSizingHelper( child: MediaViewSizingHelper(
bottomNavigation: bottomNavigation(), bottomNavigation: bottomNavigation(),
requiredHeight: 55, requiredHeight: 55,
@ -595,7 +599,10 @@ class _MediaViewerViewState extends State<MediaViewerView>
child: PhotoView.customChild( child: PhotoView.customChild(
initialScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained,
child: VideoPlayer(videoController!), child: VideoPlayerHelper(
controller: videoController!,
onDoubleTap: onTap,
),
), ),
) )
else if (currentMedia != null && else if (currentMedia != null &&

View file

@ -10,7 +10,7 @@ import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/alert.dialog.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/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/camera_preview_components/save_to_gallery.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor.view.dart'; import 'package:twonly/src/visual/views/camera/share_image_editor.view.dart';
@ -239,7 +239,7 @@ class _MemoriesPhotoSliderViewState extends State<MemoriesPhotoSliderView> {
return item.mediaService.mediaFile.type == MediaType.video return item.mediaService.mediaFile.type == MediaType.video
? PhotoViewGalleryPageOptions.customChild( ? PhotoViewGalleryPageOptions.customChild(
child: VideoPlayerHelper( child: VideoPlayerFileHelper(
videoPath: filePath, videoPath: filePath,
), ),
initialScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained,