From 635f92f0c1c145a314b3dc41b1ef6c549620d567 Mon Sep 17 00:00:00 2001 From: MoonlightWave-12 <123384363+MoonlightWave-12@users.noreply.github.com> Date: Mon, 28 Apr 2025 12:02:01 +0200 Subject: [PATCH 01/10] fix: Show WebP-images with a file-extension written in lower-case when choosing an image to send. --- lib/utils/file_selector.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/utils/file_selector.dart b/lib/utils/file_selector.dart index 8cc5e7552..9dfc5bdfa 100644 --- a/lib/utils/file_selector.dart +++ b/lib/utils/file_selector.dart @@ -59,8 +59,8 @@ enum FileSelectorType { extensions: ['png', 'PNG'], ), XTypeGroup( - label: 'WEBP', - extensions: ['WebP', 'WEBP'], + label: 'WebP', + extensions: ['webp', 'WebP', 'WEBP'], ), XTypeGroup( label: 'GIF', From 0c277571546cde05707b2f97c13f565b7f890ceb Mon Sep 17 00:00:00 2001 From: MoonlightWave-12 <123384363+MoonlightWave-12@users.noreply.github.com> Date: Mon, 28 Apr 2025 12:30:42 +0200 Subject: [PATCH 02/10] feat: Show WebM-videos when choosing video-files for sending --- lib/utils/file_selector.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/utils/file_selector.dart b/lib/utils/file_selector.dart index 9dfc5bdfa..1b6dca6d7 100644 --- a/lib/utils/file_selector.dart +++ b/lib/utils/file_selector.dart @@ -92,6 +92,10 @@ enum FileSelectorType { label: 'MP4', extensions: ['mp4', 'MP4'], ), + XTypeGroup( + label: 'WebM', + extensions: ['webm', 'WebM', 'WEBM'], + ), XTypeGroup( label: 'AVI', extensions: ['avi', 'AVI'], From 380639496da72e267760837ad6a777448434d29f Mon Sep 17 00:00:00 2001 From: MoonlightWave-12 <123384363+MoonlightWave-12@users.noreply.github.com> Date: Mon, 28 Apr 2025 12:31:34 +0200 Subject: [PATCH 03/10] feat: Show all supported image-/video-files when sending images or videos --- lib/utils/file_selector.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/utils/file_selector.dart b/lib/utils/file_selector.dart index 1b6dca6d7..13910cabb 100644 --- a/lib/utils/file_selector.dart +++ b/lib/utils/file_selector.dart @@ -50,6 +50,10 @@ enum FileSelectorType { any([], FileType.any, null), images( [ + XTypeGroup( + label: 'Images', + extensions: ['jpg', 'JPG', 'jpeg', 'JPEG', 'png', 'PNG', 'webp', 'WebP', 'WEBP', 'gif', 'GIF', 'bmp', 'BMP', 'tiff', 'TIFF', 'tif', 'TIF', 'heic', 'HEIC', 'svg', 'SVG'], + ), XTypeGroup( label: 'JPG', extensions: ['jpg', 'JPG', 'jpeg', 'JPEG'], @@ -88,6 +92,10 @@ enum FileSelectorType { ), videos( [ + XTypeGroup( + label: 'Videos', + extensions: ['mp4', 'MP4', 'avi', 'AVI', 'webm', 'WebM', 'WEBM', 'mov', 'MOV', 'mkv', 'MKV', 'wmv', 'WMV', 'flv', 'FLV', 'mpeg', 'MPEG', '3gp', '3GP', 'ogg', 'OGG'], + ), XTypeGroup( label: 'MP4', extensions: ['mp4', 'MP4'], From c4226f3fcf63fa0c5edb784f9cf5be844bdb5fec Mon Sep 17 00:00:00 2001 From: MoonlightWave-12 <123384363+MoonlightWave-12@users.noreply.github.com> Date: Mon, 28 Apr 2025 13:47:06 +0200 Subject: [PATCH 04/10] fix: Change `PNGs` to `PNG` for consistency in file-selector for consistency. --- lib/utils/file_selector.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/utils/file_selector.dart b/lib/utils/file_selector.dart index 13910cabb..8cc9fbeac 100644 --- a/lib/utils/file_selector.dart +++ b/lib/utils/file_selector.dart @@ -59,7 +59,7 @@ enum FileSelectorType { extensions: ['jpg', 'JPG', 'jpeg', 'JPEG'], ), XTypeGroup( - label: 'PNGs', + label: 'PNG', extensions: ['png', 'PNG'], ), XTypeGroup( From b0227413100a20e15b5580160cafd59ca293db39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Sun, 11 May 2025 10:46:39 +0200 Subject: [PATCH 05/10] chore: Follow up background audio player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian Kußowski --- lib/pages/chat/events/audio_player.dart | 75 ++++++++++++------------- 1 file changed, 36 insertions(+), 39 deletions(-) diff --git a/lib/pages/chat/events/audio_player.dart b/lib/pages/chat/events/audio_player.dart index 3d8aa0d11..764f437b1 100644 --- a/lib/pages/chat/events/audio_player.dart +++ b/lib/pages/chat/events/audio_player.dart @@ -1,23 +1,23 @@ import 'dart:async'; import 'dart:io'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - import 'package:async/async.dart'; -import 'package:flutter_linkify/flutter_linkify.dart'; -import 'package:just_audio/just_audio.dart'; -import 'package:matrix/matrix.dart'; -import 'package:opus_caf_converter_dart/opus_caf_converter_dart.dart'; -import 'package:path_provider/path_provider.dart'; - import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/utils/error_reporter.dart'; import 'package:fluffychat/utils/file_description.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/url_launcher.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:matrix/matrix.dart'; +import 'package:opus_caf_converter_dart/opus_caf_converter_dart.dart'; +import 'package:path_provider/path_provider.dart'; + import '../../../utils/matrix_sdk_extensions/event_extension.dart'; +import '../../../widgets/fluffy_chat_app.dart'; import '../../../widgets/matrix.dart'; class AudioPlayerWidget extends StatefulWidget { @@ -63,38 +63,35 @@ class AudioPlayerState extends State { ScaffoldMessenger.of(matrix.context).showMaterialBanner( MaterialBanner( padding: EdgeInsets.zero, - content: Row( - children: [ - StreamBuilder( - stream: audioPlayer.playerStateStream.asBroadcastStream(), - builder: (context, _) => IconButton( - onPressed: () { - if (audioPlayer.isAtEndPosition) { - audioPlayer.seek(Duration.zero); - } else if (audioPlayer.playing) { - audioPlayer.pause(); - } else { - audioPlayer.play(); - } - }, - icon: audioPlayer.playing && !audioPlayer.isAtEndPosition - ? const Icon(Icons.pause_outlined) - : const Icon(Icons.play_arrow_outlined), - ), + leading: StreamBuilder( + stream: audioPlayer.playerStateStream.asBroadcastStream(), + builder: (context, _) => IconButton( + onPressed: () { + if (audioPlayer.isAtEndPosition) { + audioPlayer.seek(Duration.zero); + } else if (audioPlayer.playing) { + audioPlayer.pause(); + } else { + audioPlayer.play(); + } + }, + icon: audioPlayer.playing && !audioPlayer.isAtEndPosition + ? const Icon(Icons.pause_outlined) + : const Icon(Icons.play_arrow_outlined), + ), + ), + content: StreamBuilder( + stream: audioPlayer.positionStream.asBroadcastStream(), + builder: (context, _) => GestureDetector( + onTap: () => FluffyChatApp.router.go( + '/rooms/${widget.event.room.id}?event=${widget.event.eventId}', ), - Expanded( - child: StreamBuilder( - stream: audioPlayer.positionStream.asBroadcastStream(), - builder: (context, _) { - return Text( - '🎙️ ${audioPlayer.position.minuteSecondString} / ${audioPlayer.duration?.minuteSecondString} - ${widget.event.senderFromMemoryOrFallback.calcDisplayname()}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); - }, - ), + child: Text( + '🎙️ ${audioPlayer.position.minuteSecondString} / ${audioPlayer.duration?.minuteSecondString} - ${widget.event.senderFromMemoryOrFallback.calcDisplayname()}', + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ], + ), ), actions: [ IconButton( From 55705d761db13a9c80f6afd79c429ebe4b2f33ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Sun, 11 May 2025 10:59:03 +0200 Subject: [PATCH 06/10] feat: Move videoplayer into multi image viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also fixes video thumbnails Signed-off-by: Christian Kußowski --- lib/pages/chat/events/audio_player.dart | 15 +- lib/pages/chat/events/message_content.dart | 7 +- lib/pages/chat/events/video_player.dart | 219 ++++++------------ lib/pages/image_viewer/image_viewer.dart | 8 +- lib/pages/image_viewer/image_viewer_view.dart | 61 +++-- lib/pages/image_viewer/video_player.dart | 152 ++++++++++++ lib/utils/platform_infos.dart | 3 + lib/widgets/mxc_image.dart | 2 +- 8 files changed, 292 insertions(+), 175 deletions(-) create mode 100644 lib/pages/image_viewer/video_player.dart diff --git a/lib/pages/chat/events/audio_player.dart b/lib/pages/chat/events/audio_player.dart index 764f437b1..9dff85028 100644 --- a/lib/pages/chat/events/audio_player.dart +++ b/lib/pages/chat/events/audio_player.dart @@ -1,21 +1,22 @@ import 'dart:async'; import 'dart:io'; -import 'package:async/async.dart'; -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/config/themes.dart'; -import 'package:fluffychat/utils/error_reporter.dart'; -import 'package:fluffychat/utils/file_description.dart'; -import 'package:fluffychat/utils/localized_exception_extension.dart'; -import 'package:fluffychat/utils/url_launcher.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; + +import 'package:async/async.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:just_audio/just_audio.dart'; import 'package:matrix/matrix.dart'; import 'package:opus_caf_converter_dart/opus_caf_converter_dart.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/utils/error_reporter.dart'; +import 'package:fluffychat/utils/file_description.dart'; +import 'package:fluffychat/utils/localized_exception_extension.dart'; +import 'package:fluffychat/utils/url_launcher.dart'; import '../../../utils/matrix_sdk_extensions/event_extension.dart'; import '../../../widgets/fluffy_chat_app.dart'; import '../../../widgets/matrix.dart'; diff --git a/lib/pages/chat/events/message_content.dart b/lib/pages/chat/events/message_content.dart index 8c72cae3b..0ea0b1ba1 100644 --- a/lib/pages/chat/events/message_content.dart +++ b/lib/pages/chat/events/message_content.dart @@ -168,7 +168,12 @@ class MessageContent extends StatelessWidget { linkColor: linkColor, ); case MessageTypes.Video: - return EventVideoPlayer(event, textColor: textColor); + return EventVideoPlayer( + event, + textColor: textColor, + linkColor: linkColor, + timeline: timeline, + ); case MessageTypes.File: return MessageDownloadContent( event, diff --git a/lib/pages/chat/events/video_player.dart b/lib/pages/chat/events/video_player.dart index e47979e80..b71b8b29d 100644 --- a/lib/pages/chat/events/video_player.dart +++ b/lib/pages/chat/events/video_player.dart @@ -1,136 +1,51 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:chewie/chewie.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:matrix/matrix.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:universal_html/html.dart' as html; -import 'package:video_player/video_player.dart'; import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/pages/chat/events/image_bubble.dart'; import 'package:fluffychat/utils/file_description.dart'; -import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/utils/url_launcher.dart'; import 'package:fluffychat/widgets/blur_hash.dart'; -import '../../../utils/error_reporter.dart'; +import 'package:fluffychat/widgets/mxc_image.dart'; +import '../../image_viewer/image_viewer.dart'; -class EventVideoPlayer extends StatefulWidget { +class EventVideoPlayer extends StatelessWidget { final Event event; + final Timeline? timeline; final Color? textColor; final Color? linkColor; const EventVideoPlayer( this.event, { + this.timeline, this.textColor, this.linkColor, super.key, }); - @override - EventVideoPlayerState createState() => EventVideoPlayerState(); -} - -class EventVideoPlayerState extends State { - ChewieController? _chewieController; - VideoPlayerController? _videoPlayerController; - bool _isDownloading = false; - - // The video_player package only doesn't support Windows and Linux. - final _supportsVideoPlayer = - !PlatformInfos.isWindows && !PlatformInfos.isLinux; - - void _downloadAction() async { - if (!_supportsVideoPlayer) { - widget.event.saveFile(context); - return; - } - - setState(() => _isDownloading = true); - - try { - final videoFile = await widget.event.downloadAndDecryptAttachment(); - - // Dispose the controllers if we already have them. - _disposeControllers(); - late VideoPlayerController videoPlayerController; - - // Create the VideoPlayerController from the contents of videoFile. - if (kIsWeb) { - final blob = html.Blob([videoFile.bytes]); - final networkUri = Uri.parse(html.Url.createObjectUrlFromBlob(blob)); - videoPlayerController = VideoPlayerController.networkUrl(networkUri); - } else { - final tempDir = await getTemporaryDirectory(); - final fileName = Uri.encodeComponent( - widget.event.attachmentOrThumbnailMxcUrl()!.pathSegments.last, - ); - 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. - _chewieController = ChewieController( - videoPlayerController: videoPlayerController, - useRootNavigator: !kIsWeb, - autoPlay: true, - autoInitialize: true, - ); - } on IOException catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(e.toLocalizedString(context)), - ), - ); - } catch (e, s) { - ErrorReporter(context, 'Unable to play video').onErrorCallback(e, s); - } finally { - setState(() => _isDownloading = false); - } - } - - void _disposeControllers() { - _chewieController?.dispose(); - _videoPlayerController?.dispose(); - _chewieController = null; - _videoPlayerController = null; - } - - @override - void dispose() { - _disposeControllers(); - super.dispose(); - } - static const String fallbackBlurHash = 'L5H2EC=PM+yV0g-mq.wG9c010J}I'; @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final supportsVideoPlayer = PlatformInfos.supportsVideoPlayer; - final hasThumbnail = widget.event.hasThumbnail; - final blurHash = (widget.event.infoMap as Map) + final blurHash = (event.infoMap as Map) .tryGet('xyz.amorgan.blurhash') ?? fallbackBlurHash; - final fileDescription = widget.event.fileDescription; - final textColor = widget.textColor; - final linkColor = widget.linkColor; + final fileDescription = event.fileDescription; + final infoMap = event.content.tryGetMap('info'); + final videoWidth = infoMap?.tryGet('w') ?? 400; + final videoHeight = infoMap?.tryGet('h') ?? 300; + const height = 300.0; + final width = videoWidth * (height / videoHeight); - const width = 300.0; + final durationInt = infoMap?.tryGet('duration'); + final duration = + durationInt == null ? null : Duration(milliseconds: durationInt); - final chewieController = _chewieController; return Column( mainAxisSize: MainAxisSize.min, spacing: 8, @@ -138,52 +53,66 @@ class EventVideoPlayerState extends State { Material( color: Colors.black, borderRadius: BorderRadius.circular(AppConfig.borderRadius), - child: SizedBox( - height: width, - child: chewieController != null - ? Center(child: Chewie(controller: chewieController)) - : Stack( - children: [ - if (hasThumbnail) - Center( - child: ImageBubble( - widget.event, - tapToView: false, - textColor: widget.textColor, - ), - ) - else - BlurHash( - blurhash: blurHash, - width: width, - height: width, - ), - Center( - child: IconButton( - style: IconButton.styleFrom( - backgroundColor: theme.colorScheme.surface, - ), - icon: _isDownloading - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator.adaptive( - strokeWidth: 2, - ), - ) - : _supportsVideoPlayer - ? const Icon(Icons.play_circle_outlined) - : const Icon(Icons.file_download_outlined), - tooltip: _isDownloading - ? L10n.of(context).loadingPleaseWait - : L10n.of(context).videoWithSize( - widget.event.sizeString ?? '?MB', - ), - onPressed: _isDownloading ? null : _downloadAction, + child: InkWell( + onTap: () => supportsVideoPlayer + ? showDialog( + context: context, + builder: (_) => ImageViewer( + event, + timeline: timeline, + outerContext: context, + ), + ) + : event.saveFile(context), + borderRadius: BorderRadius.circular(AppConfig.borderRadius), + child: SizedBox( + width: width, + height: height, + child: Stack( + children: [ + if (event.hasThumbnail) + MxcImage( + event: event, + isThumbnail: true, + width: width, + height: height, + fit: BoxFit.cover, + placeholder: (context) => BlurHash( + blurhash: blurHash, + width: width, + height: height, + fit: BoxFit.cover, + ), + ) + else + BlurHash( + blurhash: blurHash, + width: width, + height: height, + fit: BoxFit.cover, + ), + Center( + child: CircleAvatar( + child: supportsVideoPlayer + ? const Icon(Icons.play_arrow_outlined) + : const Icon(Icons.file_download_outlined), + ), + ), + if (duration != null) + Positioned( + bottom: 8, + left: 16, + child: Text( + '${duration.inMinutes.toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}', + style: TextStyle( + color: Colors.white, + backgroundColor: Colors.black.withAlpha(32), ), ), - ], - ), + ), + ], + ), + ), ), ), if (fileDescription != null && textColor != null && linkColor != null) diff --git a/lib/pages/image_viewer/image_viewer.dart b/lib/pages/image_viewer/image_viewer.dart index 80f9b371d..d42a845a7 100644 --- a/lib/pages/image_viewer/image_viewer.dart +++ b/lib/pages/image_viewer/image_viewer.dart @@ -33,7 +33,13 @@ class ImageViewerController extends State { void initState() { super.initState(); allEvents = widget.timeline?.events - .where((event) => event.messageType == MessageTypes.Image) + .where( + (event) => { + MessageTypes.Image, + MessageTypes.Sticker, + if (PlatformInfos.supportsVideoPlayer) MessageTypes.Video, + }.contains(event.messageType), + ) .toList() .reversed .toList() ?? diff --git a/lib/pages/image_viewer/image_viewer_view.dart b/lib/pages/image_viewer/image_viewer_view.dart index 997a9e6f9..983171be2 100644 --- a/lib/pages/image_viewer/image_viewer_view.dart +++ b/lib/pages/image_viewer/image_viewer_view.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; +import 'package:fluffychat/pages/image_viewer/video_player.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/hover_builder.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; @@ -75,27 +77,46 @@ class ImageViewerView extends StatelessWidget { child: PageView.builder( controller: controller.pageController, itemCount: controller.allEvents.length, - itemBuilder: (context, i) => InteractiveViewer( - minScale: 1.0, - maxScale: 10.0, - onInteractionEnd: controller.onInteractionEnds, - child: Center( - child: Hero( - tag: controller.allEvents[i].eventId, - child: GestureDetector( - // Ignore taps to not go back here: - onTap: () {}, - child: MxcImage( - key: ValueKey(controller.allEvents[i].eventId), - event: controller.allEvents[i], - fit: BoxFit.contain, - isThumbnail: false, - animated: true, + 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 && controller.canGoBack) diff --git a/lib/pages/image_viewer/video_player.dart b/lib/pages/image_viewer/video_player.dart new file mode 100644 index 000000000..f355433ae --- /dev/null +++ b/lib/pages/image_viewer/video_player.dart @@ -0,0 +1,152 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:chewie/chewie.dart'; +import 'package:matrix/matrix.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:universal_html/html.dart' as html; +import 'package:video_player/video_player.dart'; + +import 'package:fluffychat/utils/localized_exception_extension.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/widgets/blur_hash.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 { + ChewieController? _chewieController; + VideoPlayerController? _videoPlayerController; + + // The video_player package only doesn't support Windows and Linux. + final _supportsVideoPlayer = + !PlatformInfos.isWindows && !PlatformInfos.isLinux; + + void _downloadAction() async { + if (!_supportsVideoPlayer) { + widget.event.saveFile(context); + return; + } + + try { + final videoFile = await widget.event.downloadAndDecryptAttachment(); + + // Dispose the controllers if we already have them. + _disposeControllers(); + late VideoPlayerController videoPlayerController; + + // Create the VideoPlayerController from the contents of videoFile. + if (kIsWeb) { + final blob = html.Blob([videoFile.bytes]); + final networkUri = Uri.parse(html.Url.createObjectUrlFromBlob(blob)); + videoPlayerController = VideoPlayerController.networkUrl(networkUri); + } else { + final tempDir = await getTemporaryDirectory(); + final fileName = Uri.encodeComponent( + widget.event.attachmentOrThumbnailMxcUrl()!.pathSegments.last, + ); + 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. + _chewieController = ChewieController( + videoPlayerController: videoPlayerController, + useRootNavigator: !kIsWeb, + autoPlay: true, + autoInitialize: true, + looping: true, + ); + } on IOException catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toLocalizedString(context)), + ), + ); + } catch (e, s) { + 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) + .tryGet('xyz.amorgan.blurhash') ?? + fallbackBlurHash; + + const width = 300.0; + + final chewieController = _chewieController; + return chewieController != null + ? Center(child: Chewie(controller: chewieController)) + : Stack( + children: [ + Center( + child: hasThumbnail + ? MxcImage( + event: widget.event, + isThumbnail: true, + width: width, + fit: BoxFit.cover, + placeholder: (context) => BlurHash( + blurhash: blurHash, + width: width, + height: width, + fit: BoxFit.cover, + ), + ) + : BlurHash( + blurhash: blurHash, + width: width, + height: width, + ), + ), + const Center(child: CircularProgressIndicator.adaptive()), + ], + ); + } +} diff --git a/lib/utils/platform_infos.dart b/lib/utils/platform_infos.dart index c23548397..7e3364b87 100644 --- a/lib/utils/platform_infos.dart +++ b/lib/utils/platform_infos.dart @@ -29,6 +29,9 @@ abstract class PlatformInfos { static bool get usesTouchscreen => !isMobile; + static bool get supportsVideoPlayer => + !PlatformInfos.isWindows && !PlatformInfos.isLinux; + /// Web could also record in theory but currently only wav which is too large static bool get platformCanRecord => (isMobile || isMacOS); diff --git a/lib/widgets/mxc_image.dart b/lib/widgets/mxc_image.dart index bdc938e76..605d5078b 100644 --- a/lib/widgets/mxc_image.dart +++ b/lib/widgets/mxc_image.dart @@ -96,7 +96,7 @@ class _MxcImageState extends State { final data = await event.downloadAndDecryptAttachment( getThumbnail: widget.isThumbnail, ); - if (data.detectFileType is MatrixImageFile) { + if (data.detectFileType is MatrixImageFile || widget.isThumbnail) { if (!mounted) return; setState(() { _imageData = data.bytes; From f3c36e0b09365252ae097cfac4accdd0210bbffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Sun, 11 May 2025 11:31:18 +0200 Subject: [PATCH 07/10] chore: Follow up videoplayer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian Kußowski --- lib/pages/chat/events/video_player.dart | 75 ++++++++++++------------ lib/pages/image_viewer/video_player.dart | 65 ++++++++++++-------- 2 files changed, 79 insertions(+), 61 deletions(-) diff --git a/lib/pages/chat/events/video_player.dart b/lib/pages/chat/events/video_player.dart index b71b8b29d..856bda50c 100644 --- a/lib/pages/chat/events/video_player.dart +++ b/lib/pages/chat/events/video_player.dart @@ -68,49 +68,52 @@ class EventVideoPlayer extends StatelessWidget { child: SizedBox( width: width, height: height, - child: Stack( - children: [ - if (event.hasThumbnail) - MxcImage( - event: event, - isThumbnail: true, - width: width, - height: height, - fit: BoxFit.cover, - placeholder: (context) => BlurHash( + child: Hero( + tag: event.eventId, + child: Stack( + children: [ + if (event.hasThumbnail) + MxcImage( + event: event, + isThumbnail: true, + width: width, + height: height, + fit: BoxFit.cover, + placeholder: (context) => BlurHash( + blurhash: blurHash, + width: width, + height: height, + fit: BoxFit.cover, + ), + ) + else + BlurHash( blurhash: blurHash, width: width, height: height, fit: BoxFit.cover, ), - ) - else - BlurHash( - blurhash: blurHash, - width: width, - height: height, - fit: BoxFit.cover, - ), - Center( - child: CircleAvatar( - child: supportsVideoPlayer - ? const Icon(Icons.play_arrow_outlined) - : const Icon(Icons.file_download_outlined), - ), - ), - if (duration != null) - Positioned( - bottom: 8, - left: 16, - child: Text( - '${duration.inMinutes.toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}', - style: TextStyle( - color: Colors.white, - backgroundColor: Colors.black.withAlpha(32), - ), + Center( + child: CircleAvatar( + child: supportsVideoPlayer + ? const Icon(Icons.play_arrow_outlined) + : const Icon(Icons.file_download_outlined), ), ), - ], + if (duration != null) + Positioned( + bottom: 8, + left: 16, + child: Text( + '${duration.inMinutes.toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}', + style: TextStyle( + color: Colors.white, + backgroundColor: Colors.black.withAlpha(32), + ), + ), + ), + ], + ), ), ), ), diff --git a/lib/pages/image_viewer/video_player.dart b/lib/pages/image_viewer/video_player.dart index f355433ae..0962306e5 100644 --- a/lib/pages/image_viewer/video_player.dart +++ b/lib/pages/image_viewer/video_player.dart @@ -70,13 +70,15 @@ class EventVideoPlayerState extends State { await videoPlayerController.initialize(); // Create a ChewieController on top. - _chewieController = ChewieController( - videoPlayerController: videoPlayerController, - useRootNavigator: !kIsWeb, - autoPlay: true, - autoInitialize: true, - looping: true, - ); + setState(() { + _chewieController = ChewieController( + videoPlayerController: videoPlayerController, + useRootNavigator: !kIsWeb, + autoPlay: true, + autoInitialize: true, + looping: true, + ); + }); } on IOException catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -117,33 +119,46 @@ class EventVideoPlayerState extends State { final blurHash = (widget.event.infoMap as Map) .tryGet('xyz.amorgan.blurhash') ?? fallbackBlurHash; - - const width = 300.0; + final infoMap = widget.event.content.tryGetMap('info'); + final videoWidth = infoMap?.tryGet('w') ?? 400; + final videoHeight = infoMap?.tryGet('h') ?? 300; + final height = MediaQuery.of(context).size.height - 52; + final width = videoWidth * (height / videoHeight); final chewieController = _chewieController; return chewieController != null - ? Center(child: Chewie(controller: chewieController)) + ? Center( + child: SizedBox( + width: width, + height: height, + child: Chewie(controller: chewieController), + ), + ) : Stack( children: [ Center( - child: hasThumbnail - ? MxcImage( - event: widget.event, - isThumbnail: true, - width: width, - fit: BoxFit.cover, - placeholder: (context) => BlurHash( + 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: width, - fit: BoxFit.cover, + height: height, ), - ) - : BlurHash( - blurhash: blurHash, - width: width, - height: width, - ), + ), ), const Center(child: CircularProgressIndicator.adaptive()), ], From 2fb8156718d7f26abc9c9a57e96336fb3ff10e7e Mon Sep 17 00:00:00 2001 From: krille-chan Date: Sun, 11 May 2025 14:25:45 +0200 Subject: [PATCH 08/10] chore: Follow up videoplayer --- lib/pages/image_viewer/video_player.dart | 2 +- pubspec.lock | 4 ++-- pubspec.yaml | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/pages/image_viewer/video_player.dart b/lib/pages/image_viewer/video_player.dart index 0962306e5..b85d6799d 100644 --- a/lib/pages/image_viewer/video_player.dart +++ b/lib/pages/image_viewer/video_player.dart @@ -73,7 +73,7 @@ class EventVideoPlayerState extends State { setState(() { _chewieController = ChewieController( videoPlayerController: videoPlayerController, - useRootNavigator: !kIsWeb, + showControlsOnInitialize: false, autoPlay: true, autoInitialize: true, looping: true, diff --git a/pubspec.lock b/pubspec.lock index 084b31477..1590cc319 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -178,10 +178,10 @@ packages: dependency: "direct main" description: name: chewie - sha256: "0bf6f7692cb65f7b8f59a2a17025b9cbe8f75ab4251e66161a4fc86162475fb6" + sha256: "4d9554a8f87cc2dc6575dfd5ad20a4375015a29edd567fd6733febe6365e2566" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.11.3" cli_util: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 61efd69b9..5e5bae322 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: async: ^2.11.0 badges: ^3.1.2 blurhash_dart: ^1.2.1 - chewie: ^1.11.0 + chewie: ^1.11.3 collection: ^1.18.0 cross_file: ^0.3.4+2 cupertino_icons: any @@ -93,7 +93,7 @@ dependencies: universal_html: ^2.2.4 url_launcher: ^6.2.5 video_compress: ^3.1.4 - video_player: ^2.9.2 + video_player: ^2.9.5 wakelock_plus: ^1.2.2 webrtc_interface: ^1.0.13 From 51449fc7244ce20a24de19814a3883e52bb9cb87 Mon Sep 17 00:00:00 2001 From: MoonlightWave-12 <123384363+MoonlightWave-12@users.noreply.github.com> Date: Mon, 5 May 2025 21:05:23 +0200 Subject: [PATCH 09/10] chore: Format lib/utils/file_selector.dart --- lib/utils/file_selector.dart | 143 ++++++++++++++++------------------- 1 file changed, 65 insertions(+), 78 deletions(-) diff --git a/lib/utils/file_selector.dart b/lib/utils/file_selector.dart index 8cc9fbeac..fd35891af 100644 --- a/lib/utils/file_selector.dart +++ b/lib/utils/file_selector.dart @@ -30,17 +30,11 @@ Future> selectFiles( if (allowMultiple) { return await AppLock.of(context).pauseWhile( - openFiles( - confirmButtonText: title, - acceptedTypeGroups: type.groups, - ), + openFiles(confirmButtonText: title, acceptedTypeGroups: type.groups), ); } final file = await AppLock.of(context).pauseWhile( - openFile( - confirmButtonText: title, - acceptedTypeGroups: type.groups, - ), + openFile(confirmButtonText: title, acceptedTypeGroups: type.groups), ); if (file == null) return []; return [file]; @@ -52,40 +46,44 @@ enum FileSelectorType { [ XTypeGroup( label: 'Images', - extensions: ['jpg', 'JPG', 'jpeg', 'JPEG', 'png', 'PNG', 'webp', 'WebP', 'WEBP', 'gif', 'GIF', 'bmp', 'BMP', 'tiff', 'TIFF', 'tif', 'TIF', 'heic', 'HEIC', 'svg', 'SVG'], + extensions: [ + 'jpg', + 'JPG', + 'jpeg', + 'JPEG', + 'png', + 'PNG', + 'webp', + 'WebP', + 'WEBP', + 'gif', + 'GIF', + 'bmp', + 'BMP', + 'tiff', + 'TIFF', + 'tif', + 'TIF', + 'heic', + 'HEIC', + 'svg', + 'SVG', + ], ), XTypeGroup( label: 'JPG', extensions: ['jpg', 'JPG', 'jpeg', 'JPEG'], ), - XTypeGroup( - label: 'PNG', - extensions: ['png', 'PNG'], - ), - XTypeGroup( - label: 'WebP', - extensions: ['webp', 'WebP', 'WEBP'], - ), - XTypeGroup( - label: 'GIF', - extensions: ['gif', 'GIF'], - ), - XTypeGroup( - label: 'BMP', - extensions: ['bmp', 'BMP'], - ), + XTypeGroup(label: 'PNG', extensions: ['png', 'PNG']), + XTypeGroup(label: 'WebP', extensions: ['webp', 'WebP', 'WEBP']), + XTypeGroup(label: 'GIF', extensions: ['gif', 'GIF']), + XTypeGroup(label: 'BMP', extensions: ['bmp', 'BMP']), XTypeGroup( label: 'TIFF', extensions: ['tiff', 'TIFF', 'tif', 'TIF'], ), - XTypeGroup( - label: 'HEIC', - extensions: ['heic', 'HEIC'], - ), - XTypeGroup( - label: 'SVG', - extensions: ['svg', 'SVG'], - ), + XTypeGroup(label: 'HEIC', extensions: ['heic', 'HEIC']), + XTypeGroup(label: 'SVG', extensions: ['svg', 'SVG']), ], FileType.image, null, @@ -94,58 +92,47 @@ enum FileSelectorType { [ XTypeGroup( label: 'Videos', - extensions: ['mp4', 'MP4', 'avi', 'AVI', 'webm', 'WebM', 'WEBM', 'mov', 'MOV', 'mkv', 'MKV', 'wmv', 'WMV', 'flv', 'FLV', 'mpeg', 'MPEG', '3gp', '3GP', 'ogg', 'OGG'], - ), - XTypeGroup( - label: 'MP4', - extensions: ['mp4', 'MP4'], - ), - XTypeGroup( - label: 'WebM', - extensions: ['webm', 'WebM', 'WEBM'], - ), - XTypeGroup( - label: 'AVI', - extensions: ['avi', 'AVI'], - ), - XTypeGroup( - label: 'MOV', - extensions: ['mov', 'MOV'], - ), - XTypeGroup( - label: 'MKV', - extensions: ['mkv', 'MKV'], - ), - XTypeGroup( - label: 'WMV', - extensions: ['wmv', 'WMV'], - ), - XTypeGroup( - label: 'FLV', - extensions: ['flv', 'FLV'], - ), - XTypeGroup( - label: 'MPEG', - extensions: ['mpeg', 'MPEG'], - ), - XTypeGroup( - label: '3GP', - extensions: ['3gp', '3GP'], - ), - XTypeGroup( - label: 'OGG', - extensions: ['ogg', 'OGG'], + extensions: [ + 'mp4', + 'MP4', + 'avi', + 'AVI', + 'webm', + 'WebM', + 'WEBM', + 'mov', + 'MOV', + 'mkv', + 'MKV', + 'wmv', + 'WMV', + 'flv', + 'FLV', + 'mpeg', + 'MPEG', + '3gp', + '3GP', + 'ogg', + 'OGG', + ], ), + XTypeGroup(label: 'MP4', extensions: ['mp4', 'MP4']), + XTypeGroup(label: 'WebM', extensions: ['webm', 'WebM', 'WEBM']), + XTypeGroup(label: 'AVI', extensions: ['avi', 'AVI']), + XTypeGroup(label: 'MOV', extensions: ['mov', 'MOV']), + XTypeGroup(label: 'MKV', extensions: ['mkv', 'MKV']), + XTypeGroup(label: 'WMV', extensions: ['wmv', 'WMV']), + XTypeGroup(label: 'FLV', extensions: ['flv', 'FLV']), + XTypeGroup(label: 'MPEG', extensions: ['mpeg', 'MPEG']), + XTypeGroup(label: '3GP', extensions: ['3gp', '3GP']), + XTypeGroup(label: 'OGG', extensions: ['ogg', 'OGG']), ], FileType.video, null, ), zip( [ - XTypeGroup( - label: 'ZIP', - extensions: ['zip', 'ZIP'], - ), + XTypeGroup(label: 'ZIP', extensions: ['zip', 'ZIP']), ], FileType.custom, ['zip', 'ZIP'], From 13051712199e75f480bb45dfa1bc8c4990c48954 Mon Sep 17 00:00:00 2001 From: krille-chan Date: Mon, 12 May 2025 18:28:48 +0200 Subject: [PATCH 10/10] chore: Crop shortcut file on android and cache it --- .../chat_list/client_chooser_button.dart | 20 +++++------ lib/utils/push_helper.dart | 8 +++-- lib/utils/shortcut_memory_icon.dart | 36 +++++++++++++++++++ 3 files changed, 50 insertions(+), 14 deletions(-) create mode 100644 lib/utils/shortcut_memory_icon.dart diff --git a/lib/pages/chat_list/client_chooser_button.dart b/lib/pages/chat_list/client_chooser_button.dart index 68acc13cb..717837320 100644 --- a/lib/pages/chat_list/client_chooser_button.dart +++ b/lib/pages/chat_list/client_chooser_button.dart @@ -4,7 +4,6 @@ import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart'; -import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -67,17 +66,16 @@ class ClientChooserButton extends StatelessWidget { ], ), ), - if (!FluffyThemes.isColumnMode(context)) - PopupMenuItem( - value: SettingsAction.settings, - child: Row( - children: [ - const Icon(Icons.settings_outlined), - const SizedBox(width: 18), - Text(L10n.of(context).settings), - ], - ), + PopupMenuItem( + value: SettingsAction.settings, + child: Row( + children: [ + const Icon(Icons.settings_outlined), + const SizedBox(width: 18), + Text(L10n.of(context).settings), + ], ), + ), const PopupMenuDivider(), for (final bundle in bundles) ...[ if (matrix.accountBundles[bundle]!.length != 1 || diff --git a/lib/utils/push_helper.dart b/lib/utils/push_helper.dart index 6e242eedb..41684a3cc 100644 --- a/lib/utils/push_helper.dart +++ b/lib/utils/push_helper.dart @@ -15,6 +15,7 @@ import 'package:fluffychat/utils/client_download_content_extension.dart'; import 'package:fluffychat/utils/client_manager.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/utils/shortcut_memory_icon.dart'; Future pushHelper( PushNotification notification, { @@ -312,9 +313,10 @@ Future _setShortcut( action: AppConfig.inviteLinkPrefix + event.room.id, shortLabel: title, conversationShortcut: true, - icon: avatarFile == null - ? null - : ShortcutMemoryIcon(jpegImage: avatarFile).toString(), + icon: await avatarFile?.toShortcutMemoryIcon( + event.room.id, + event.room.client.database, + ), shortcutIconAsset: avatarFile == null ? ShortcutIconAsset.androidAsset : ShortcutIconAsset.memoryAsset, diff --git a/lib/utils/shortcut_memory_icon.dart b/lib/utils/shortcut_memory_icon.dart new file mode 100644 index 000000000..c1557e44a --- /dev/null +++ b/lib/utils/shortcut_memory_icon.dart @@ -0,0 +1,36 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:image/image.dart'; +import 'package:matrix/matrix.dart'; + +extension ShortcutMemoryIcon on Uint8List { + Future toShortcutMemoryIcon( + String roomId, + DatabaseApi? database, + ) async { + final cacheKey = Uri.parse('im.fluffychat://shortcuts/$roomId'); + final cachedFile = await database?.getFile(cacheKey); + if (cachedFile != null) return base64Encode(cachedFile); + + final image = decodeImage(this); + if (image == null) return null; + + final size = image.width < image.height ? image.width : image.height; + final x = (image.width - size) ~/ 2; + final y = (image.height - size) ~/ 2; + + final croppedImage = copyCrop( + image, + x: x, + y: y, + width: size, + height: size, + ); + + final bytes = croppedImage.toUint8List(); + await database?.storeFile(cacheKey, bytes, 0); + + return base64Encode(croppedImage.toUint8List()); + } +}