mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-06-25 11:14:07 +00:00
split up the media viewer into statless widgets
This commit is contained in:
parent
40b645e803
commit
0007ba62a0
5 changed files with 369 additions and 287 deletions
|
|
@ -1,15 +1,12 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:clock/clock.dart';
|
import 'package:clock/clock.dart';
|
||||||
import 'package:drift/drift.dart' show Value;
|
import 'package:drift/drift.dart' show Value;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:lottie/lottie.dart';
|
|
||||||
import 'package:mutex/mutex.dart';
|
import 'package:mutex/mutex.dart';
|
||||||
import 'package:photo_view/photo_view.dart';
|
|
||||||
import 'package:screen_protector/screen_protector.dart';
|
import 'package:screen_protector/screen_protector.dart';
|
||||||
import 'package:twonly/globals.dart';
|
import 'package:twonly/globals.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
|
|
@ -29,12 +26,14 @@ 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/animate_icon.comp.dart';
|
import 'package:twonly/src/visual/components/animate_icon.comp.dart';
|
||||||
import 'package:twonly/src/visual/elements/my_icon_button.element.dart';
|
import 'package:twonly/src/visual/elements/my_icon_button.element.dart';
|
||||||
import 'package:twonly/src/visual/elements/my_input.element.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/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';
|
||||||
|
import 'package:twonly/src/visual/views/chats/media_viewer_components/media_content_renderer.comp.dart';
|
||||||
|
import 'package:twonly/src/visual/views/chats/media_viewer_components/media_viewer_message_input.comp.dart';
|
||||||
import 'package:twonly/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart';
|
import 'package:twonly/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart';
|
||||||
|
import 'package:twonly/src/visual/views/chats/media_viewer_components/twonly_present_overlay.comp.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
class MediaViewerView extends StatefulWidget {
|
class MediaViewerView extends StatefulWidget {
|
||||||
|
|
@ -64,6 +63,8 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
DateTime? canBeSeenUntil;
|
DateTime? canBeSeenUntil;
|
||||||
final ValueNotifier<double> progress = ValueNotifier(0);
|
final ValueNotifier<double> progress = ValueNotifier(0);
|
||||||
bool showSendTextMessageInput = false;
|
bool showSendTextMessageInput = false;
|
||||||
|
double maxBottomInset = 0;
|
||||||
|
DateTime? _lastTimeInputClosed;
|
||||||
final GlobalKey mediaWidgetKey = GlobalKey();
|
final GlobalKey mediaWidgetKey = GlobalKey();
|
||||||
|
|
||||||
bool imageSaved = false;
|
bool imageSaved = false;
|
||||||
|
|
@ -82,9 +83,6 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
final HashSet<String> _alreadyOpenedMediaIds = HashSet();
|
final HashSet<String> _alreadyOpenedMediaIds = HashSet();
|
||||||
|
|
||||||
bool _isTransitioning = false;
|
bool _isTransitioning = false;
|
||||||
bool _isZoomed = false;
|
|
||||||
late PageController _verticalPager;
|
|
||||||
final ValueNotifier<double> _backdropOpacityNotifier = ValueNotifier(1);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -95,12 +93,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
allMediaFiles = [widget.initialMessage!];
|
allMediaFiles = [widget.initialMessage!];
|
||||||
}
|
}
|
||||||
|
|
||||||
_verticalPager = PageController(initialPage: 1);
|
listenForUnopenedMedia(true);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted) _verticalPager.addListener(_onVerticalScrollUpdated);
|
|
||||||
});
|
|
||||||
|
|
||||||
asyncLoadNextMedia(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -127,21 +120,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
);
|
);
|
||||||
textMessageController.dispose();
|
textMessageController.dispose();
|
||||||
|
|
||||||
_verticalPager
|
|
||||||
..removeListener(_onVerticalScrollUpdated)
|
|
||||||
..dispose();
|
|
||||||
_backdropOpacityNotifier.dispose();
|
|
||||||
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onVerticalScrollUpdated() {
|
|
||||||
if (!_verticalPager.hasClients) return;
|
|
||||||
final page = _verticalPager.page ?? 1.0;
|
|
||||||
final linearFraction = min(1, max(0, page)).toDouble();
|
|
||||||
_backdropOpacityNotifier.value = linearFraction * linearFraction;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _disposeVideoController() {
|
void _disposeVideoController() {
|
||||||
final listener = _videoListener;
|
final listener = _videoListener;
|
||||||
final controller = videoController;
|
final controller = videoController;
|
||||||
|
|
@ -160,7 +141,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
(ModalRoute.of(context)?.isCurrent ?? false);
|
(ModalRoute.of(context)?.isCurrent ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> asyncLoadNextMedia(bool firstRun) async {
|
Future<void> listenForUnopenedMedia(bool firstRun) async {
|
||||||
_subscription = twonlyDB.messagesDao
|
_subscription = twonlyDB.messagesDao
|
||||||
.watchMediaNotOpened(widget.group.groupId)
|
.watchMediaNotOpened(widget.group.groupId)
|
||||||
.listen((messages) async {
|
.listen((messages) async {
|
||||||
|
|
@ -174,7 +155,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.mediaId == currentMedia?.mediaFile.mediaId) {
|
if (msg.mediaId == currentMedia?.mediaFile.mediaId) {
|
||||||
// The update of the current Media in case of a download is done in loadCurrentMediaFile
|
// The update of the current Media in case of a download is done in loadAndDownloadCurrentMedia
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,13 +188,13 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
if (firstRun) {
|
if (firstRun) {
|
||||||
firstRun = false;
|
firstRun = false;
|
||||||
await loadCurrentMediaFile();
|
await loadAndDownloadCurrentMedia();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> nextMediaOrExit() async {
|
Future<void> advanceToNextMediaOrExit() async {
|
||||||
if (_isTransitioning) return;
|
if (_isTransitioning) return;
|
||||||
_isTransitioning = true;
|
_isTransitioning = true;
|
||||||
|
|
||||||
|
|
@ -245,17 +226,17 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await loadCurrentMediaFile();
|
await loadAndDownloadCurrentMedia();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) _isTransitioning = false;
|
if (mounted) _isTransitioning = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadCurrentMediaFile({bool showTwonly = false}) async {
|
Future<void> loadAndDownloadCurrentMedia({bool showTwonly = false}) async {
|
||||||
if (!mounted || !context.mounted) return;
|
if (!mounted || !context.mounted) return;
|
||||||
if (allMediaFiles.isEmpty || allMediaFiles.first.mediaId == null) {
|
if (allMediaFiles.isEmpty || allMediaFiles.first.mediaId == null) {
|
||||||
return nextMediaOrExit();
|
return advanceToNextMediaOrExit();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -293,7 +274,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
// Media file record no longer exists — skip to next or exit rather
|
// Media file record no longer exists — skip to next or exit rather
|
||||||
// than leaving the screen permanently black with no content/loader.
|
// than leaving the screen permanently black with no content/loader.
|
||||||
await downloadStateListener?.cancel();
|
await downloadStateListener?.cancel();
|
||||||
await nextMediaOrExit();
|
await advanceToNextMediaOrExit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (updated.downloadState != DownloadState.ready) {
|
if (updated.downloadState != DownloadState.ready) {
|
||||||
|
|
@ -308,7 +289,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
if (mediaFile == null) {
|
if (mediaFile == null) {
|
||||||
// DB record gone — skip to next or exit.
|
// DB record gone — skip to next or exit.
|
||||||
await downloadStateListener?.cancel();
|
await downloadStateListener?.cancel();
|
||||||
await nextMediaOrExit();
|
await advanceToNextMediaOrExit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await startDownloadMedia(mediaFile, true);
|
await startDownloadMedia(mediaFile, true);
|
||||||
|
|
@ -319,18 +300,16 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
|
|
||||||
await downloadStateListener?.cancel();
|
await downloadStateListener?.cancel();
|
||||||
try {
|
try {
|
||||||
await handleNextDownloadedMedia(showTwonly);
|
await initializeAndDisplayCurrentMedia(showTwonly);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
Log.error('handleNextDownloadedMedia failed: $e\n$st');
|
Log.error('initializeAndDisplayCurrentMedia failed: $e\n$st');
|
||||||
await nextMediaOrExit();
|
await advanceToNextMediaOrExit();
|
||||||
}
|
}
|
||||||
// start downloading all the other possible missing media files.
|
// start downloading all the other possible missing media files.
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> handleNextDownloadedMedia(
|
Future<void> initializeAndDisplayCurrentMedia(bool showTwonly) async {
|
||||||
bool showTwonly,
|
|
||||||
) async {
|
|
||||||
if (allMediaFiles.isEmpty) return;
|
if (allMediaFiles.isEmpty) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_showDownloadingLoader = false;
|
_showDownloadingLoader = false;
|
||||||
|
|
@ -355,7 +334,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
if (!isAuth) {
|
if (!isAuth) {
|
||||||
await nextMediaOrExit();
|
await advanceToNextMediaOrExit();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
displayTwonlyPresent = false;
|
displayTwonlyPresent = false;
|
||||||
|
|
@ -372,25 +351,55 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
displayTwonlyPresent = false;
|
displayTwonlyPresent = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!widget.group.isDirectChat) {
|
await _updateSenderInfo();
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
await _notifyMessageOpened(currentMediaLocal);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (!currentMediaLocal.tempPath.existsSync()) {
|
||||||
|
Log.error('Temp media file not found...');
|
||||||
|
await handleMediaError(currentMediaLocal.mediaFile);
|
||||||
|
return advanceToNextMediaOrExit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server can now delete the encrypted bytes, as the users has sucessfully opened it.
|
||||||
|
unawaited(
|
||||||
|
apiService.downloadDone(currentMediaLocal.mediaFile.downloadToken!),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentMediaLocal.mediaFile.type == MediaType.video) {
|
||||||
|
await _setupVideoPlayer(currentMediaLocal);
|
||||||
|
} else {
|
||||||
|
_setupImageTimer(currentMediaLocal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
currentMedia = currentMediaLocal;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _updateSenderInfo() async {
|
||||||
|
if (currentMessage == null || widget.group.isDirectChat) return;
|
||||||
final sender = await twonlyDB.contactsDao.getContactById(
|
final sender = await twonlyDB.contactsDao.getContactById(
|
||||||
currentMessage!.senderId!,
|
currentMessage!.senderId!,
|
||||||
);
|
);
|
||||||
|
if (mounted && sender != null) {
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
if (sender != null) {
|
|
||||||
_currentMediaSender =
|
_currentMediaSender =
|
||||||
'${getContactDisplayName(sender)} (${widget.group.groupName})';
|
'${getContactDisplayName(sender)} (${widget.group.groupName})';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _notifyMessageOpened(MediaFileService mediaLocal) async {
|
||||||
|
if (currentMessage == null) return;
|
||||||
var markAsOpenMessageIDs = [currentMessage!.messageId];
|
var markAsOpenMessageIDs = [currentMessage!.messageId];
|
||||||
|
|
||||||
if (userService.currentUser.automaticallyMarkEqualMediaFilesAsOpened &&
|
if (userService.currentUser.automaticallyMarkEqualMediaFilesAsOpened &&
|
||||||
currentMediaLocal.mediaFile.storedFileHash != null) {
|
mediaLocal.mediaFile.storedFileHash != null) {
|
||||||
final messageIds = await twonlyDB.mediaFilesDao.getMessageIdsByMediaHash(
|
final messageIds = await twonlyDB.mediaFilesDao.getMessageIdsByMediaHash(
|
||||||
currentMediaLocal.mediaFile.storedFileHash!,
|
mediaLocal.mediaFile.storedFileHash!,
|
||||||
currentMessage!.senderId!,
|
currentMessage!.senderId!,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -408,35 +417,18 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
currentMessage!.senderId!,
|
currentMessage!.senderId!,
|
||||||
markAsOpenMessageIDs,
|
markAsOpenMessageIDs,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
if (!currentMediaLocal.tempPath.existsSync()) {
|
|
||||||
Log.error('Temp media file not found...');
|
|
||||||
await handleMediaError(currentMediaLocal.mediaFile);
|
|
||||||
return nextMediaOrExit();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The server can now delete the encrypted bytes, as the users has sucessfully opened it.
|
Future<void> _setupVideoPlayer(MediaFileService mediaLocal) async {
|
||||||
unawaited(
|
|
||||||
apiService.downloadDone(currentMediaLocal.mediaFile.downloadToken!),
|
|
||||||
);
|
|
||||||
|
|
||||||
var timerRequired = false;
|
|
||||||
|
|
||||||
if (currentMediaLocal.mediaFile.type == MediaType.video) {
|
|
||||||
final controller = VideoPlayerController.file(
|
final controller = VideoPlayerController.file(
|
||||||
currentMediaLocal.tempPath,
|
mediaLocal.tempPath,
|
||||||
videoPlayerOptions: VideoPlayerOptions(
|
videoPlayerOptions: VideoPlayerOptions(
|
||||||
// only mix in case the video can be played multiple times,
|
mixWithOthers: mediaLocal.mediaFile.displayLimitInMilliseconds == null,
|
||||||
// otherwise stop the background music in case the video contains audio
|
|
||||||
mixWithOthers:
|
|
||||||
currentMediaLocal.mediaFile.displayLimitInMilliseconds == null,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.setLooping(
|
await controller.setLooping(
|
||||||
currentMediaLocal.mediaFile.displayLimitInMilliseconds == null,
|
mediaLocal.mediaFile.displayLimitInMilliseconds == null,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
|
|
@ -462,10 +454,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
progress.value = 1 - ctrl.value.position.inSeconds / duration;
|
progress.value = 1 - ctrl.value.position.inSeconds / duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentMediaLocal.mediaFile.displayLimitInMilliseconds !=
|
if (mediaLocal.mediaFile.displayLimitInMilliseconds != null) {
|
||||||
null) {
|
|
||||||
if (ctrl.value.position == ctrl.value.duration) {
|
if (ctrl.value.position == ctrl.value.duration) {
|
||||||
nextMediaOrExit();
|
advanceToNextMediaOrExit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -476,38 +467,32 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
..addListener(listener)
|
..addListener(listener)
|
||||||
..play();
|
..play();
|
||||||
})
|
})
|
||||||
// ignore: argument_type_not_assignable_to_error_handler, invalid_return_type_for_catch_error
|
.catchError((Object err, StackTrace st) {
|
||||||
.catchError(Log.error);
|
Log.error('Video player initialization error', err, st);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
void _setupImageTimer(MediaFileService mediaLocal) {
|
||||||
} else {
|
if (mediaLocal.mediaFile.displayLimitInMilliseconds != null) {
|
||||||
if (currentMediaLocal.mediaFile.displayLimitInMilliseconds != null) {
|
|
||||||
canBeSeenUntil = clock.now().add(
|
canBeSeenUntil = clock.now().add(
|
||||||
Duration(
|
Duration(
|
||||||
milliseconds:
|
milliseconds: mediaLocal.mediaFile.displayLimitInMilliseconds!,
|
||||||
currentMediaLocal.mediaFile.displayLimitInMilliseconds!,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
timerRequired = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
startProgressTimer();
|
||||||
currentMedia = currentMediaLocal;
|
|
||||||
});
|
|
||||||
if (timerRequired) {
|
|
||||||
startTimer();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void startTimer() {
|
void startProgressTimer() {
|
||||||
nextMediaTimer?.cancel();
|
nextMediaTimer?.cancel();
|
||||||
progressTimer?.cancel();
|
progressTimer?.cancel();
|
||||||
if (canBeSeenUntil != null) {
|
if (canBeSeenUntil != null) {
|
||||||
nextMediaTimer = Timer(canBeSeenUntil!.difference(clock.now()), () {
|
nextMediaTimer = Timer(canBeSeenUntil!.difference(clock.now()), () {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
nextMediaOrExit();
|
advanceToNextMediaOrExit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
progressTimer = Timer.periodic(const Duration(milliseconds: 10), (timer) {
|
progressTimer = Timer.periodic(const Duration(milliseconds: 10), (timer) {
|
||||||
|
|
@ -653,7 +638,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
);
|
);
|
||||||
if (mounted &&
|
if (mounted &&
|
||||||
currentMedia!.mediaFile.displayLimitInMilliseconds != null) {
|
currentMedia!.mediaFile.displayLimitInMilliseconds != null) {
|
||||||
await nextMediaOrExit();
|
await advanceToNextMediaOrExit();
|
||||||
} else {
|
} else {
|
||||||
await videoController?.play();
|
await videoController?.play();
|
||||||
}
|
}
|
||||||
|
|
@ -677,19 +662,43 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void onTap() {
|
void onScreenTapped() {
|
||||||
|
if (_lastTimeInputClosed != null &&
|
||||||
|
clock.now().difference(_lastTimeInputClosed!) <
|
||||||
|
const Duration(milliseconds: 300)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (showSendTextMessageInput) {
|
if (showSendTextMessageInput) {
|
||||||
setState(() {
|
setState(() {
|
||||||
showShortReactions = false;
|
showShortReactions = false;
|
||||||
showSendTextMessageInput = false;
|
showSendTextMessageInput = false;
|
||||||
|
_lastTimeInputClosed = clock.now();
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
nextMediaOrExit();
|
advanceToNextMediaOrExit();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||||
|
if (bottomInset > maxBottomInset) {
|
||||||
|
maxBottomInset = bottomInset;
|
||||||
|
} else if (bottomInset == 0 && maxBottomInset > 0) {
|
||||||
|
maxBottomInset = 0;
|
||||||
|
if (showSendTextMessageInput) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
showSendTextMessageInput = false;
|
||||||
|
showShortReactions = false;
|
||||||
|
_lastTimeInputClosed = clock.now();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Stack(
|
child: Stack(
|
||||||
|
|
@ -699,93 +708,21 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
if ((currentMedia != null || videoController != null) &&
|
if ((currentMedia != null || videoController != null) &&
|
||||||
(canBeSeenUntil == null || progress.value >= 0))
|
(canBeSeenUntil == null || progress.value >= 0))
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onScreenTapped,
|
||||||
onDoubleTap: (videoController == null) ? null : onTap,
|
onDoubleTap: (videoController == null) ? null : onScreenTapped,
|
||||||
child: MediaViewSizingHelper(
|
child: MediaViewSizingHelper(
|
||||||
bottomNavigation: bottomNavigation(),
|
bottomNavigation: bottomNavigation(),
|
||||||
requiredHeight: 55,
|
requiredHeight: 55,
|
||||||
child: Stack(
|
child: MediaContentRenderer(
|
||||||
children: [
|
currentMedia: currentMedia,
|
||||||
if (videoController != null)
|
videoController: videoController,
|
||||||
Positioned.fill(
|
loader: _loader(),
|
||||||
child: PhotoView.customChild(
|
|
||||||
initialScale: PhotoViewComputedScale.contained,
|
|
||||||
minScale: PhotoViewComputedScale.contained,
|
|
||||||
backgroundDecoration: const BoxDecoration(
|
|
||||||
color: Colors.transparent,
|
|
||||||
),
|
|
||||||
scaleStateChangedCallback: (state) {
|
|
||||||
final zoomed =
|
|
||||||
state != PhotoViewScaleState.initial;
|
|
||||||
if (_isZoomed != zoomed) {
|
|
||||||
setState(() {
|
|
||||||
_isZoomed = zoomed;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: VideoPlayer(
|
|
||||||
videoController!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else if (currentMedia != null &&
|
|
||||||
(currentMedia!.mediaFile.type == MediaType.image ||
|
|
||||||
currentMedia!.mediaFile.type == MediaType.gif))
|
|
||||||
Positioned.fill(
|
|
||||||
child: PhotoView(
|
|
||||||
imageProvider: FileImage(
|
|
||||||
currentMedia!.tempPath,
|
|
||||||
),
|
|
||||||
loadingBuilder: (context, event) => _loader(),
|
|
||||||
backgroundDecoration: const BoxDecoration(
|
|
||||||
color: Colors.transparent,
|
|
||||||
),
|
|
||||||
initialScale: PhotoViewComputedScale.contained,
|
|
||||||
minScale: PhotoViewComputedScale.contained,
|
|
||||||
scaleStateChangedCallback: (state) {
|
|
||||||
final zoomed =
|
|
||||||
state != PhotoViewScaleState.initial;
|
|
||||||
if (_isZoomed != zoomed) {
|
|
||||||
setState(() {
|
|
||||||
_isZoomed = zoomed;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
errorBuilder: (context, error, stackTrace) {
|
|
||||||
return const Center(
|
|
||||||
child: Icon(
|
|
||||||
Icons.broken_image_outlined,
|
|
||||||
color: Colors.white38,
|
|
||||||
size: 64,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (displayTwonlyPresent)
|
if (displayTwonlyPresent)
|
||||||
Positioned.fill(
|
TwonlyPresentOverlay(
|
||||||
child: GestureDetector(
|
onTap: () => loadAndDownloadCurrentMedia(showTwonly: true),
|
||||||
onTap: () => loadCurrentMediaFile(showTwonly: true),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Lottie.asset(
|
|
||||||
'assets/animations/present.lottie.lottie',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.only(bottom: 200),
|
|
||||||
child: Text(
|
|
||||||
context.lang.mediaViewerTwonlyTapToOpen,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (currentMedia != null &&
|
if (currentMedia != null &&
|
||||||
currentMedia?.mediaFile.downloadState != DownloadState.ready)
|
currentMedia?.mediaFile.downloadState != DownloadState.ready)
|
||||||
|
|
@ -835,44 +772,16 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (showSendTextMessageInput)
|
if (showSendTextMessageInput)
|
||||||
Positioned(
|
MediaViewerMessageInput(
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
child: Container(
|
|
||||||
color: context.color.surface,
|
|
||||||
padding: const EdgeInsets.only(
|
|
||||||
bottom: 10,
|
|
||||||
left: 20,
|
|
||||||
right: 20,
|
|
||||||
top: 10,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: MyInput(
|
|
||||||
dense: true,
|
|
||||||
autofocus: true,
|
|
||||||
controller: textMessageController,
|
controller: textMessageController,
|
||||||
hintText: context.lang.chatListDetailInput,
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() {});
|
|
||||||
},
|
|
||||||
onSubmitted: (value) {
|
onSubmitted: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
showSendTextMessageInput = false;
|
showSendTextMessageInput = false;
|
||||||
showShortReactions = false;
|
showShortReactions = false;
|
||||||
|
_lastTimeInputClosed = clock.now();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
onSendPressed: () {
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
MyIconButton(
|
|
||||||
icon: const FaIcon(
|
|
||||||
FontAwesomeIcons.solidPaperPlane,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
onPressed: () async {
|
|
||||||
if (textMessageController.text.isNotEmpty) {
|
if (textMessageController.text.isNotEmpty) {
|
||||||
unawaited(
|
unawaited(
|
||||||
insertAndSendTextMessage(
|
insertAndSendTextMessage(
|
||||||
|
|
@ -886,13 +795,10 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
setState(() {
|
setState(() {
|
||||||
showSendTextMessageInput = false;
|
showSendTextMessageInput = false;
|
||||||
showShortReactions = false;
|
showShortReactions = false;
|
||||||
|
_lastTimeInputClosed = clock.now();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (currentMessage != null)
|
if (currentMessage != null)
|
||||||
AdditionalMessageContent(currentMessage!),
|
AdditionalMessageContent(currentMessage!),
|
||||||
if (currentMedia != null)
|
if (currentMedia != null)
|
||||||
|
|
@ -907,6 +813,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
setState(() {
|
setState(() {
|
||||||
showShortReactions = false;
|
showShortReactions = false;
|
||||||
showSendTextMessageInput = false;
|
showSendTextMessageInput = false;
|
||||||
|
_lastTimeInputClosed = clock.now();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:photo_view/photo_view.dart';
|
||||||
|
import 'package:twonly/src/database/tables/mediafiles.table.dart'
|
||||||
|
show MediaType;
|
||||||
|
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||||
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
|
class MediaContentRenderer extends StatelessWidget {
|
||||||
|
const MediaContentRenderer({
|
||||||
|
required this.currentMedia,
|
||||||
|
required this.videoController,
|
||||||
|
required this.loader,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final MediaFileService? currentMedia;
|
||||||
|
final VideoPlayerController? videoController;
|
||||||
|
final Widget loader;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
if (videoController != null)
|
||||||
|
Positioned.fill(
|
||||||
|
child: PhotoView.customChild(
|
||||||
|
initialScale: PhotoViewComputedScale.contained,
|
||||||
|
minScale: PhotoViewComputedScale.contained,
|
||||||
|
backgroundDecoration: const BoxDecoration(
|
||||||
|
color: Colors.transparent,
|
||||||
|
),
|
||||||
|
child: VideoPlayer(
|
||||||
|
videoController!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (currentMedia != null &&
|
||||||
|
(currentMedia!.mediaFile.type == MediaType.image ||
|
||||||
|
currentMedia!.mediaFile.type == MediaType.gif))
|
||||||
|
Positioned.fill(
|
||||||
|
child: PhotoView(
|
||||||
|
imageProvider: FileImage(
|
||||||
|
currentMedia!.tempPath,
|
||||||
|
),
|
||||||
|
loadingBuilder: (context, event) => loader,
|
||||||
|
backgroundDecoration: const BoxDecoration(
|
||||||
|
color: Colors.transparent,
|
||||||
|
),
|
||||||
|
initialScale: PhotoViewComputedScale.contained,
|
||||||
|
minScale: PhotoViewComputedScale.contained,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return const Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.broken_image_outlined,
|
||||||
|
color: Colors.white38,
|
||||||
|
size: 64,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
|
import 'package:twonly/src/visual/elements/my_icon_button.element.dart';
|
||||||
|
import 'package:twonly/src/visual/elements/my_input.element.dart';
|
||||||
|
|
||||||
|
class MediaViewerMessageInput extends StatelessWidget {
|
||||||
|
const MediaViewerMessageInput({
|
||||||
|
required this.controller,
|
||||||
|
required this.onSubmitted,
|
||||||
|
required this.onSendPressed,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
final ValueChanged<String> onSubmitted;
|
||||||
|
final VoidCallback onSendPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Container(
|
||||||
|
color: context.color.surface,
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
bottom: 10,
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
top: 10,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: MyInput(
|
||||||
|
dense: true,
|
||||||
|
autofocus: true,
|
||||||
|
controller: controller,
|
||||||
|
hintText: context.lang.chatListDetailInput,
|
||||||
|
onChanged: (value) {},
|
||||||
|
onSubmitted: onSubmitted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
MyIconButton(
|
||||||
|
icon: const FaIcon(
|
||||||
|
FontAwesomeIcons.solidPaperPlane,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
onPressed: onSendPressed,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -37,7 +37,6 @@ class ReactionButtons extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ReactionButtonsState extends State<ReactionButtons> {
|
class _ReactionButtonsState extends State<ReactionButtons> {
|
||||||
int selectedShortReaction = -1;
|
|
||||||
final GlobalKey _keyEmojiPicker = GlobalKey();
|
final GlobalKey _keyEmojiPicker = GlobalKey();
|
||||||
bool _renderAnimations = false;
|
bool _renderAnimations = false;
|
||||||
|
|
||||||
|
|
@ -74,23 +73,40 @@ class _ReactionButtonsState extends State<ReactionButtons> {
|
||||||
? selectedEmojis.skip(6).toList()
|
? selectedEmojis.skip(6).toList()
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
final show = widget.show;
|
||||||
|
|
||||||
|
final targetBottom = widget.textInputFocused
|
||||||
|
? 50.0
|
||||||
|
: widget.mediaViewerDistanceFromBottom;
|
||||||
|
|
||||||
|
final bottomPosition = show
|
||||||
|
? targetBottom
|
||||||
|
: widget.mediaViewerDistanceFromBottom - 20;
|
||||||
|
|
||||||
|
final targetOpacity = show ? 1.0 : 0.0;
|
||||||
|
final isIgnoring = !show;
|
||||||
|
|
||||||
|
final positionDuration = show
|
||||||
|
? const Duration(milliseconds: 200)
|
||||||
|
: Duration.zero;
|
||||||
|
|
||||||
|
final opacityDuration = show
|
||||||
|
? const Duration(milliseconds: 150)
|
||||||
|
: Duration.zero;
|
||||||
|
|
||||||
return AnimatedPositioned(
|
return AnimatedPositioned(
|
||||||
duration: const Duration(milliseconds: 200), // Animation duration
|
duration: positionDuration, // Animation duration
|
||||||
bottom: widget.show
|
bottom: bottomPosition,
|
||||||
? (widget.textInputFocused
|
|
||||||
? 50
|
|
||||||
: widget.mediaViewerDistanceFromBottom)
|
|
||||||
: widget.mediaViewerDistanceFromBottom - 20,
|
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
curve: Curves.linearToEaseOut,
|
curve: Curves.linearToEaseOut,
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
ignoring: !widget.show,
|
ignoring: isIgnoring,
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
opacity: widget.show ? 1.0 : 0.0, // Fade in/out
|
opacity: targetOpacity, // Fade in/out
|
||||||
duration: Duration(milliseconds: widget.show ? 150 : 50),
|
duration: opacityDuration,
|
||||||
child: Container(
|
child: Container(
|
||||||
color: widget.show ? Colors.black.withAlpha(0) : Colors.transparent,
|
color: Colors.transparent,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lottie/lottie.dart';
|
||||||
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
|
|
||||||
|
class TwonlyPresentOverlay extends StatelessWidget {
|
||||||
|
const TwonlyPresentOverlay({
|
||||||
|
required this.onTap,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Lottie.asset(
|
||||||
|
'assets/animations/present.lottie.lottie',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.only(bottom: 200),
|
||||||
|
child: Text(
|
||||||
|
context.lang.mediaViewerTwonlyTapToOpen,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue