Initial commit
Some checks failed
Main Deploy Workflow / deploy_web (push) Has been cancelled
Main Deploy Workflow / deploy_playstore_internal (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2026-09-11 15:59:56 -03:00
commit 0037b0b9cf
651 changed files with 199705 additions and 0 deletions

View File

@@ -0,0 +1,134 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/config/themes.dart';
import 'package:tatchat/pages/image_viewer/image_viewer_view.dart';
import 'package:tatchat/utils/platform_infos.dart';
import 'package:tatchat/utils/show_scaffold_dialog.dart';
import 'package:tatchat/widgets/share_scaffold_dialog.dart';
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import '../../utils/matrix_sdk_extensions/event_extension.dart';
class ImageViewer extends StatefulWidget {
final Event event;
final Timeline? timeline;
final BuildContext outerContext;
const ImageViewer(
this.event, {
required this.outerContext,
this.timeline,
super.key,
});
@override
ImageViewerController createState() => ImageViewerController();
}
class ImageViewerController extends State<ImageViewer> {
final FocusNode focusNode = FocusNode();
@override
void initState() {
super.initState();
allEvents =
widget.timeline?.events
.where(
(event) => {
MessageTypes.Image,
MessageTypes.Sticker,
if (PlatformInfos.supportsVideoPlayer) MessageTypes.Video,
}.contains(event.messageType),
)
.toList()
.reversed
.toList() ??
[widget.event];
var index = allEvents.indexWhere(
(event) => event.eventId == widget.event.eventId,
);
if (index < 0) index = 0;
pageController = PageController(initialPage: index);
}
late final PageController pageController;
late final List<Event> allEvents;
void onKeyEvent(KeyEvent event) {
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowUp:
if (canGoBack) prevImage();
break;
case LogicalKeyboardKey.arrowDown:
if (canGoNext) nextImage();
break;
}
}
Future<void> prevImage() async {
await pageController.previousPage(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
);
if (!mounted) return;
setState(() {});
}
Future<void> nextImage() async {
await pageController.nextPage(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
);
if (!mounted) return;
setState(() {});
}
int get _index => pageController.page?.toInt() ?? 0;
Event get currentEvent => allEvents[_index];
bool get canGoNext => _index < allEvents.length - 1;
bool get canGoBack => _index > 0;
/// Forward this image to another room.
void forwardAction() => showScaffoldDialog(
context: context,
builder: (context) =>
ShareScaffoldDialog(items: [ContentShareItem(currentEvent.content)]),
);
/// Save this file with a system call.
void saveFileAction(BuildContext context) => currentEvent.saveFile(context);
/// Save this file with a system call.
void shareFileAction(BuildContext context) => currentEvent.shareFile(context);
static const maxScaleFactor = 1.5;
/// Go back if user swiped it away
void onInteractionEnds(ScaleEndDetails endDetails) {
if (PlatformInfos.usesTouchscreen == false) {
if (endDetails.velocity.pixelsPerSecond.dy >
MediaQuery.sizeOf(context).height * maxScaleFactor) {
Navigator.of(context, rootNavigator: false).pop();
}
}
}
@override
void dispose() {
focusNode.dispose();
pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => ImageViewerView(this);
}

View File

@@ -0,0 +1,162 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/pages/image_viewer/video_player.dart';
import 'package:tatchat/utils/platform_infos.dart';
import 'package:tatchat/widgets/hover_builder.dart';
import 'package:tatchat/widgets/mxc_image.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import 'image_viewer.dart';
class ImageViewerView extends StatelessWidget {
final ImageViewerController controller;
const ImageViewerView(this.controller, {super.key});
@override
Widget build(BuildContext context) {
final iconButtonStyle = IconButton.styleFrom(
backgroundColor: Colors.black.withAlpha(200),
foregroundColor: Colors.white,
);
return GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Scaffold(
backgroundColor: Colors.black.withAlpha(128),
extendBodyBehindAppBar: true,
appBar: AppBar(
elevation: 0,
leading: IconButton(
style: iconButtonStyle,
icon: const Icon(Icons.close),
onPressed: Navigator.of(context).pop,
color: Colors.white,
tooltip: L10n.of(context).close,
),
backgroundColor: Colors.transparent,
actions: [
IconButton(
style: iconButtonStyle,
icon: const Icon(Icons.reply_outlined),
onPressed: controller.forwardAction,
color: Colors.white,
tooltip: L10n.of(context).share,
),
const SizedBox(width: 8),
IconButton(
style: iconButtonStyle,
icon: const Icon(Icons.download_outlined),
onPressed: () => controller.saveFileAction(context),
color: Colors.white,
tooltip: L10n.of(context).downloadFile,
),
const SizedBox(width: 8),
if (PlatformInfos.isMobile)
// Use builder context to correctly position the share dialog on iPad
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Builder(
builder: (context) => IconButton(
style: iconButtonStyle,
onPressed: () => controller.shareFileAction(context),
tooltip: L10n.of(context).share,
color: Colors.white,
icon: Icon(Icons.adaptive.share_outlined),
),
),
),
],
),
body: HoverBuilder(
builder: (context, hovered) => Stack(
children: [
KeyboardListener(
focusNode: controller.focusNode,
onKeyEvent: controller.onKeyEvent,
child: PageView.builder(
scrollDirection: Axis.vertical,
controller: controller.pageController,
itemCount: controller.allEvents.length,
itemBuilder: (context, i) {
final event = controller.allEvents[i];
switch (event.messageType) {
case MessageTypes.Video:
return Padding(
padding: const EdgeInsets.only(top: 52.0),
child: Center(
child: GestureDetector(
// Ignore taps to not go back here:
onTap: () {},
child: EventVideoPlayer(event),
),
),
);
case MessageTypes.Image:
case MessageTypes.Sticker:
default:
return InteractiveViewer(
minScale: 1.0,
maxScale: 10.0,
onInteractionEnd: controller.onInteractionEnds,
child: Center(
child: Hero(
tag: event.eventId,
child: GestureDetector(
// Ignore taps to not go back here:
onTap: () {},
child: MxcImage(
key: ValueKey(event.eventId),
event: event,
fit: BoxFit.contain,
isThumbnail: false,
animated: true,
),
),
),
),
);
}
},
),
),
if (hovered)
Align(
alignment: Alignment.centerRight,
child: Column(
mainAxisSize: .min,
children: [
if (controller.canGoBack)
Padding(
padding: const EdgeInsets.all(12.0),
child: IconButton(
style: iconButtonStyle,
tooltip: L10n.of(context).previous,
icon: const Icon(Icons.arrow_upward_outlined),
onPressed: controller.prevImage,
),
),
if (controller.canGoNext)
Padding(
padding: const EdgeInsets.all(12.0),
child: IconButton(
style: iconButtonStyle,
tooltip: L10n.of(context).next,
icon: const Icon(Icons.arrow_downward_outlined),
onPressed: controller.nextImage,
),
),
],
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,191 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'dart:io';
import 'package:chewie/chewie.dart';
import 'package:tatchat/utils/localized_exception_extension.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/event_extension.dart';
import 'package:tatchat/utils/platform_infos.dart';
import 'package:tatchat/widgets/blur_hash.dart';
import 'package:flutter/foundation.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import 'package:path_provider/path_provider.dart';
import 'package:video_player/video_player.dart';
import '../../../utils/error_reporter.dart';
import '../../widgets/mxc_image.dart';
class EventVideoPlayer extends StatefulWidget {
final Event event;
const EventVideoPlayer(this.event, {super.key});
@override
EventVideoPlayerState createState() => EventVideoPlayerState();
}
class EventVideoPlayerState extends State<EventVideoPlayer> {
ChewieController? _chewieController;
VideoPlayerController? _videoPlayerController;
double? _downloadProgress;
// The video_player package only doesn't support Windows and Linux.
final _supportsVideoPlayer =
!PlatformInfos.isWindows && !PlatformInfos.isLinux;
Future<void> _downloadAction() async {
if (!_supportsVideoPlayer) {
widget.event.saveFile(context);
return;
}
try {
final fileSize = widget.event.content
.tryGetMap<String, Object?>('info')
?.tryGet<int>('size');
final videoFile = await widget.event.downloadAndDecryptAttachment(
onDownloadProgress: fileSize == null
? null
: (progress) {
final progressPercentage = progress / fileSize;
setState(() {
_downloadProgress = progressPercentage < 1
? progressPercentage
: null;
});
},
);
// Dispose the controllers if we already have them.
_disposeControllers();
late VideoPlayerController videoPlayerController;
// Create the VideoPlayerController from the contents of videoFile.
if (kIsWeb) {
videoPlayerController = VideoPlayerController.networkUrl(
Uri.dataFromBytes(videoFile.bytes, mimeType: videoFile.mimeType),
);
} else {
final tempDir = await getTemporaryDirectory();
final fileNameStr =
widget.event.attachmentMxcUrl?.pathSegments.last ??
widget.event.body;
final fileName = Uri.encodeComponent(fileNameStr);
final file = File('${tempDir.path}/${fileName}_${videoFile.name}');
if (await file.exists() == false) {
await file.writeAsBytes(videoFile.bytes);
}
videoPlayerController = VideoPlayerController.file(file);
}
_videoPlayerController = videoPlayerController;
await videoPlayerController.initialize();
// Create a ChewieController on top.
setState(() {
_chewieController = ChewieController(
videoPlayerController: videoPlayerController,
showControlsOnInitialize: false,
autoPlay: true,
autoInitialize: true,
looping: true,
aspectRatio: _videoPlayerController?.value.aspectRatio,
);
});
} on IOException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.toLocalizedString(context))));
} catch (e, s) {
if (!mounted) return;
ErrorReporter(context, 'Unable to play video').onErrorCallback(e, s);
}
}
void _disposeControllers() {
_chewieController?.dispose();
_videoPlayerController?.dispose();
_chewieController = null;
_videoPlayerController = null;
}
@override
void dispose() {
_disposeControllers();
super.dispose();
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_downloadAction();
});
}
static const String fallbackBlurHash = 'L5H2EC=PM+yV0g-mq.wG9c010J}I';
@override
Widget build(BuildContext context) {
final hasThumbnail = widget.event.hasThumbnail;
final blurHash =
(widget.event.infoMap as Map<String, dynamic>).tryGet<String>(
'xyz.amorgan.blurhash',
) ??
fallbackBlurHash;
final infoMap = widget.event.content.tryGetMap<String, Object?>('info');
final videoWidth = infoMap?.tryGet<int>('w') ?? 400;
final videoHeight = infoMap?.tryGet<int>('h') ?? 300;
final height = MediaQuery.sizeOf(context).height - 52;
final width = videoWidth * (height / videoHeight);
final chewieController = _chewieController;
return chewieController != null
? Center(
child: SizedBox(
width: width,
height: height,
child: Chewie(controller: chewieController),
),
)
: Stack(
children: [
Center(
child: Hero(
tag: widget.event.eventId,
child: hasThumbnail
? MxcImage(
event: widget.event,
isThumbnail: true,
width: width,
height: height,
fit: BoxFit.cover,
placeholder: (context) => BlurHash(
blurhash: blurHash,
width: width,
height: height,
fit: BoxFit.cover,
),
)
: BlurHash(
blurhash: blurHash,
width: width,
height: height,
),
),
),
Center(
child: CircularProgressIndicator.adaptive(
value: _downloadProgress,
),
),
],
);
}
}