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;