twonly-app/lib/src/views/chats/media_viewer.view.dart
2025-11-07 17:44:51 +01:00

664 lines
22 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:lottie/lottie.dart';
import 'package:no_screenshot/no_screenshot.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart'
show DownloadState, MediaType;
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'
as pb;
import 'package:twonly/src/services/api/mediafiles/download.service.dart';
import 'package:twonly/src/services/api/messages.dart';
import 'package:twonly/src/services/api/utils.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/notifications/background.notifications.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/camera/camera_send_to_view.dart';
import 'package:twonly/src/views/chats/media_viewer_components/reaction_buttons.component.dart';
import 'package:twonly/src/views/components/animate_icon.dart';
import 'package:twonly/src/views/components/media_view_sizing.dart';
import 'package:video_player/video_player.dart';
final NoScreenshot _noScreenshot = NoScreenshot.instance;
class MediaViewerView extends StatefulWidget {
const MediaViewerView(this.group, {super.key, this.initialMessage});
final Group group;
final Message? initialMessage;
@override
State<MediaViewerView> createState() => _MediaViewerViewState();
}
class _MediaViewerViewState extends State<MediaViewerView> {
Timer? nextMediaTimer;
Timer? progressTimer;
bool showShortReactions = false;
double mediaViewerDistanceFromBottom = 0;
// current image related
VideoPlayerController? videoController;
MediaFileService? currentMedia;
Message? currentMessage;
DateTime? canBeSeenUntil;
double progress = 0;
bool showSendTextMessageInput = false;
final GlobalKey mediaWidgetKey = GlobalKey();
bool imageSaved = false;
bool imageSaving = false;
bool displayTwonlyPresent = true;
final emojiKey = GlobalKey<EmojiFloatWidgetState>();
StreamSubscription<MediaFile?>? downloadStateListener;
List<Message> allMediaFiles = [];
late StreamSubscription<List<Message>> _subscription;
TextEditingController textMessageController = TextEditingController();
@override
void initState() {
super.initState();
if (widget.initialMessage != null) {
allMediaFiles = [widget.initialMessage!];
}
asyncLoadNextMedia(true);
}
@override
void dispose() {
nextMediaTimer?.cancel();
progressTimer?.cancel();
_noScreenshot.screenshotOn();
_subscription.cancel();
downloadStateListener?.cancel();
videoController?.dispose();
videoController = null;
super.dispose();
}
Future<void> asyncLoadNextMedia(bool firstRun) async {
final messages =
twonlyDB.messagesDao.watchMediaNotOpened(widget.group.groupId);
_subscription = messages.listen((messages) async {
for (final msg in messages) {
if (msg.mediaId == currentMedia?.mediaFile.mediaId) {
// The update of the current Media in case of a download is done in loadCurrentMediaFile
continue;
}
/// If the messages was already there just replace it and go to the next...
final index =
allMediaFiles.indexWhere((m) => m.messageId == msg.messageId);
if (index >= 1) {
allMediaFiles[index] = msg;
} else if (index == -1) {
// If the message does not exist, add it
allMediaFiles.add(msg);
}
}
setState(() {});
if (firstRun) {
// ignore: parameter_assignments
firstRun = false;
await loadCurrentMediaFile();
}
});
}
Future<void> nextMediaOrExit() async {
/// Remove the current media file in case it is not set to unlimited
if (currentMedia != null) {
if (!imageSaved &&
currentMedia!.mediaFile.displayLimitInMilliseconds != null) {
currentMedia!.fullMediaRemoval();
}
}
await videoController?.dispose();
if (!mounted) return;
nextMediaTimer?.cancel();
progressTimer?.cancel();
if (allMediaFiles.isEmpty) {
Navigator.pop(context);
} else {
await loadCurrentMediaFile();
}
}
Future<void> loadCurrentMediaFile({bool showTwonly = false}) async {
if (!mounted || !context.mounted) return;
if (allMediaFiles.isEmpty || allMediaFiles.first.mediaId == null) {
return nextMediaOrExit();
}
await _noScreenshot.screenshotOff();
setState(() {
videoController = null;
currentMedia = null;
currentMessage = null;
canBeSeenUntil = null;
imageSaving = false;
imageSaved = false;
progress = 0;
showSendTextMessageInput = false;
});
// if (Platform.isAndroid) {
// await flutterLocalNotificationsPlugin
// .cancel(allMediaFiles.first.contactId);
// } else {
await flutterLocalNotificationsPlugin.cancelAll();
// }
final stream =
twonlyDB.mediaFilesDao.watchMedia(allMediaFiles.first.mediaId!);
var downloadTriggered = false;
await downloadStateListener?.cancel();
downloadStateListener = stream.listen((updated) async {
if (updated == null) return;
if (updated.downloadState != DownloadState.ready) {
if (!downloadTriggered) {
downloadTriggered = true;
final mediaFile = await twonlyDB.mediaFilesDao
.getMediaFileById(allMediaFiles.first.mediaId!);
if (mediaFile == null) return;
await startDownloadMedia(mediaFile, true);
unawaited(tryDownloadAllMediaFiles(force: true));
}
return;
}
await downloadStateListener?.cancel();
await handleNextDownloadedMedia(showTwonly);
// start downloading all the other possible missing media files.
});
}
Future<void> handleNextDownloadedMedia(
bool showTwonly,
) async {
if (allMediaFiles.isEmpty) return;
currentMessage = allMediaFiles.removeAt(0);
final currentMediaLocal =
await MediaFileService.fromMediaId(currentMessage!.mediaId!);
if (currentMediaLocal == null || !mounted) return;
if (currentMediaLocal.mediaFile.requiresAuthentication) {
if (!showTwonly) return;
final isAuth = await authenticateUser(
context.lang.mediaViewerAuthReason,
force: false,
);
if (!isAuth) {
await nextMediaOrExit();
return;
}
}
await notifyContactAboutOpeningMessage(
currentMessage!.senderId!,
[currentMessage!.messageId],
);
if (!currentMediaLocal.tempPath.existsSync()) {
Log.error('Temp media file not found...');
await handleMediaError(currentMediaLocal.mediaFile);
return nextMediaOrExit();
}
if (currentMediaLocal.mediaFile.type == MediaType.video) {
videoController = VideoPlayerController.file(currentMediaLocal.tempPath);
await videoController?.setLooping(
currentMediaLocal.mediaFile.displayLimitInMilliseconds == null,
);
await videoController?.initialize().then((_) {
if (videoController == null) return;
videoController?.play();
videoController?.addListener(() {
setState(() {
progress = 1 -
videoController!.value.position.inSeconds /
videoController!.value.duration.inSeconds;
});
if (currentMediaLocal.mediaFile.displayLimitInMilliseconds != null) {
if (videoController?.value.position ==
videoController?.value.duration) {
nextMediaOrExit();
}
}
});
// ignore: invalid_return_type_for_catch_error, argument_type_not_assignable_to_error_handler
}).catchError(Log.error);
} else {
if (currentMediaLocal.mediaFile.displayLimitInMilliseconds != null) {
canBeSeenUntil = DateTime.now().add(
Duration(
milliseconds:
currentMediaLocal.mediaFile.displayLimitInMilliseconds!,
),
);
startTimer();
}
}
setState(() {
currentMedia = currentMediaLocal;
});
}
void startTimer() {
nextMediaTimer?.cancel();
progressTimer?.cancel();
if (canBeSeenUntil != null) {
nextMediaTimer = Timer(canBeSeenUntil!.difference(DateTime.now()), () {
if (context.mounted) {
nextMediaOrExit();
}
});
progressTimer = Timer.periodic(const Duration(milliseconds: 10), (timer) {
if (currentMedia!.mediaFile.displayLimitInMilliseconds == null ||
canBeSeenUntil == null) {
return;
}
final difference = canBeSeenUntil!.difference(DateTime.now());
// Calculate the progress as a value between 0.0 and 1.0
progress = difference.inMilliseconds /
(currentMedia!.mediaFile.displayLimitInMilliseconds!);
setState(() {});
});
}
}
Future<void> onPressedSaveToGallery() async {
setState(() {
imageSaving = true;
});
await currentMedia!.storeMediaFile();
await sendCipherTextToGroup(
widget.group.groupId,
pb.EncryptedContent(
mediaUpdate: pb.EncryptedContent_MediaUpdate(
type: pb.EncryptedContent_MediaUpdate_Type.STORED,
targetMessageId: currentMessage!.messageId,
),
),
);
setState(() {
imageSaved = true;
});
if (gUser.storeMediaFilesInGallery) {
if (currentMedia!.mediaFile.type == MediaType.video) {
await saveVideoToGallery(currentMedia!.storedPath.path);
} else if (currentMedia!.mediaFile.type == MediaType.image ||
currentMedia!.mediaFile.type == MediaType.gif) {
final imageBytes = await currentMedia!.storedPath.readAsBytes();
await saveImageToGallery(imageBytes);
}
}
setState(() {
imageSaving = false;
});
}
void displayShortReactions() {
final renderBox =
mediaWidgetKey.currentContext!.findRenderObject() as RenderBox?;
setState(() {
showShortReactions = true;
if (renderBox != null) {
mediaViewerDistanceFromBottom = renderBox.size.height;
}
});
}
Widget bottomNavigation() {
return Row(
key: mediaWidgetKey,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (currentMedia != null &&
currentMedia!.mediaFile.displayLimitInMilliseconds == null)
OutlinedButton(
style: OutlinedButton.styleFrom(
iconColor: imageSaved
? Theme.of(context).colorScheme.outline
: Theme.of(context).colorScheme.primary,
foregroundColor: imageSaved
? Theme.of(context).colorScheme.outline
: Theme.of(context).colorScheme.primary,
),
onPressed: (currentMedia == null) ? null : onPressedSaveToGallery,
child: Row(
children: [
if (imageSaving)
const SizedBox(
width: 10,
height: 10,
child: CircularProgressIndicator(strokeWidth: 1),
)
else
imageSaved
? const Icon(Icons.check)
: const FaIcon(FontAwesomeIcons.floppyDisk),
],
),
),
const SizedBox(width: 10),
IconButton(
icon: SizedBox(
width: 30,
height: 30,
child: GridView.count(
crossAxisCount: 2,
children: List.generate(
4,
(index) {
return SizedBox(
width: 8,
height: 8,
child: Center(
child: EmojiAnimation(
emoji:
EmojiAnimation.animatedIcons.keys.toList()[index],
),
),
);
},
),
),
),
onPressed: () async {
if (!showShortReactions) {
displayShortReactions();
} else {
setState(() {
showShortReactions = false;
});
}
},
style: ButtonStyle(
padding: WidgetStateProperty.all<EdgeInsets>(
const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
),
),
),
const SizedBox(width: 10),
IconButton.outlined(
icon: const FaIcon(FontAwesomeIcons.message),
onPressed: () async {
displayShortReactions();
setState(() {
showSendTextMessageInput = true;
});
},
style: ButtonStyle(
padding: WidgetStateProperty.all<EdgeInsets>(
const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
),
),
),
const SizedBox(width: 10),
IconButton.outlined(
icon: const FaIcon(FontAwesomeIcons.camera),
onPressed: () async {
nextMediaTimer?.cancel();
progressTimer?.cancel();
await videoController?.pause();
if (!mounted) return;
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return CameraSendToView(widget.group);
},
),
);
if (mounted &&
currentMedia!.mediaFile.displayLimitInMilliseconds != null) {
await nextMediaOrExit();
} else {
await videoController?.play();
}
},
style: ButtonStyle(
padding: WidgetStateProperty.all<EdgeInsets>(
const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
),
),
),
],
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Stack(
fit: StackFit.expand,
children: [
if ((currentMedia != null || videoController != null) &&
(canBeSeenUntil == null || progress >= 0))
GestureDetector(
onTap: () {
if (showSendTextMessageInput) {
setState(() {
showShortReactions = false;
showSendTextMessageInput = false;
});
return;
}
nextMediaOrExit();
},
child: MediaViewSizing(
bottomNavigation: bottomNavigation(),
requiredHeight: 90,
child: Stack(
children: [
if (videoController != null)
Positioned.fill(
child: VideoPlayer(videoController!),
),
if (currentMedia != null &&
currentMedia!.mediaFile.type == MediaType.image ||
currentMedia!.mediaFile.type == MediaType.gif)
Positioned.fill(
child: Image.file(
currentMedia!.tempPath,
fit: BoxFit.contain,
),
),
],
),
),
),
if (currentMedia != null &&
currentMedia!.mediaFile.requiresAuthentication &&
displayTwonlyPresent)
Positioned.fill(
child: GestureDetector(
onTap: () {
loadCurrentMediaFile(showTwonly: true);
},
child: Column(
children: [
Expanded(
child: Lottie.asset(
'assets/animations/present.lottie.json',
),
),
Container(
padding: const EdgeInsets.only(bottom: 200),
child: Text(context.lang.mediaViewerTwonlyTapToOpen),
),
],
),
),
),
Positioned(
left: 10,
top: 10,
child: Row(
children: [
IconButton(
icon: const Icon(Icons.close, size: 30),
color: Colors.white,
onPressed: () async {
Navigator.pop(context);
},
),
],
),
),
if (currentMedia != null &&
currentMedia?.mediaFile.downloadState != DownloadState.ready)
const Positioned.fill(
child: Center(
child: SizedBox(
height: 60,
width: 60,
child: CircularProgressIndicator(
strokeWidth: 6,
),
),
),
),
if (canBeSeenUntil != null || progress >= 0)
Positioned(
right: 20,
top: 27,
child: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
value: progress,
strokeWidth: 2,
),
),
],
),
),
Positioned(
top: 10,
left: showSendTextMessageInput ? 0 : null,
right: showSendTextMessageInput ? 0 : 15,
child: Text(
widget.group.groupName,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: showSendTextMessageInput ? 24 : 14,
fontWeight: FontWeight.bold,
color: showSendTextMessageInput
? null
: const Color.fromARGB(255, 126, 126, 126),
shadows: const [
Shadow(
color: Color.fromARGB(122, 0, 0, 0),
blurRadius: 5,
),
],
),
),
),
if (showSendTextMessageInput)
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: [
IconButton(
icon: const FaIcon(FontAwesomeIcons.xmark),
onPressed: () {
setState(() {
showShortReactions = false;
showSendTextMessageInput = false;
});
},
),
Expanded(
child: TextField(
autofocus: true,
controller: textMessageController,
onEditingComplete: () {
setState(() {
showSendTextMessageInput = false;
showShortReactions = false;
});
},
decoration: inputTextMessageDeco(context),
),
),
IconButton(
icon: const FaIcon(FontAwesomeIcons.solidPaperPlane),
onPressed: () async {
if (textMessageController.text.isNotEmpty) {
await insertAndSendTextMessage(
widget.group.groupId,
textMessageController.text,
currentMessage!.messageId,
);
textMessageController.clear();
}
setState(() {
showSendTextMessageInput = false;
showShortReactions = false;
});
},
),
],
),
),
),
if (currentMedia != null)
ReactionButtons(
show: showShortReactions,
textInputFocused: showSendTextMessageInput,
mediaViewerDistanceFromBottom: mediaViewerDistanceFromBottom,
groupId: widget.group.groupId,
messageId: currentMessage!.messageId,
emojiKey: emojiKey,
hide: () {
setState(() {
showShortReactions = false;
showSendTextMessageInput = false;
});
},
),
Positioned.fill(
child: EmojiFloatWidget(key: emojiKey),
),
],
),
),
);
}
}