diff --git a/lib/hooks/use_media_upload.dart b/lib/hooks/use_media_upload.dart index 2196934..10d521a 100644 --- a/lib/hooks/use_media_upload.dart +++ b/lib/hooks/use_media_upload.dart @@ -21,7 +21,7 @@ typedef MediaUploadState = ({ List items, bool canSend, List uploadedFiles, - Future Function() pickImages, + Future Function() pickMedia, void Function(String filePath) removeItem, VoidCallback clearAll, }); @@ -106,27 +106,27 @@ MediaUploadState useMediaUpload({ } } - Future pickImages() async { - _logger.info('pickImages groupId=$groupId'); - final pickedFiles = await picker.pickMultiImage( + Future pickMedia() async { + _logger.info('pickMedia groupId=$groupId'); + final pickedFiles = await picker.pickMultipleMedia( maxWidth: 1920, maxHeight: 1920, imageQuality: 85, ); if (pickedFiles.isEmpty) { - _logger.info('pickImages no files selected'); + _logger.info('pickMedia no files selected'); return; } final existingPaths = items.value.map((item) => item.filePath).toSet(); final uniqueFiles = pickedFiles.where((xFile) => !existingPaths.contains(xFile.path)).toList(); if (uniqueFiles.isEmpty) { - _logger.info('pickImages all ${pickedFiles.length} files already queued, skipping'); + _logger.info('pickMedia all ${pickedFiles.length} files already queued, skipping'); return; } _logger.info( - 'pickImages picked=${pickedFiles.length} unique=${uniqueFiles.length} groupId=$groupId', + 'pickMedia picked=${pickedFiles.length} unique=${uniqueFiles.length} groupId=$groupId', ); final newItems = uniqueFiles.map((xFile) { @@ -167,7 +167,7 @@ MediaUploadState useMediaUpload({ items: items.value, canSend: canSend, uploadedFiles: uploadedFiles, - pickImages: pickImages, + pickMedia: pickMedia, removeItem: removeItem, clearAll: clearAll, ); diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 05e744b..3828ff7 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,5 +1,6 @@ { "photoCount": "{count, plural, =1{Foto} other{Fotos}}", + "mediaCount": "{count, plural, =1{Medium} other{Medien}}", "@@locale": "de", "appTitle": "White Noise", "sloganFull": "Dezentralisiert, Unzensierbar,\nSichere Nachrichten.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c331be8..bffa9d3 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -9,6 +9,15 @@ } } }, + "mediaCount": "{count, plural, =1{Media item} other{Media items}}", + "@mediaCount": { + "description": "Label shown in chat list when the last message contains media attachments", + "placeholders": { + "count": { + "type": "int" + } + } + }, "appTitle": "White Noise", "@appTitle": { "description": "The application title displayed on home screen" diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 7dbf9d6..5fbb350 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1,5 +1,6 @@ { "photoCount": "{count, plural, =1{Foto} other{Fotos}}", + "mediaCount": "{count, plural, =1{Multimedia} other{Multimedia}}", "@@locale": "es", "appTitle": "White Noise", "sloganFull": "Descentralizado, Incensurable,\nMensajería Segura.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index c0a3723..2895d43 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1,5 +1,6 @@ { "photoCount": "{count, plural, =1{Photo} other{Photos}}", + "mediaCount": "{count, plural, =1{Média} other{Médias}}", "@@locale": "fr", "appTitle": "White Noise", "sloganFull": "Décentralisé, Incensurable,\nMessagerie Sécurisée.", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 74ed3a1..bc3062b 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1,5 +1,6 @@ { "photoCount": "{count, plural, =1{Foto} other{Foto}}", + "mediaCount": "{count, plural, =1{Media} other{Media}}", "@@locale": "it", "appTitle": "White Noise", "sloganFull": "Decentralizzato, Incensurabile,\nMessaggistica Sicura.", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index aef90ef..6be4b66 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1,5 +1,6 @@ { "photoCount": "{count, plural, =1{Foto} other{Fotos}}", + "mediaCount": "{count, plural, =1{Mídia} other{Mídias}}", "@@locale": "pt", "appTitle": "White Noise", "sloganFull": "Descentralizado, Incensurável,\nMensagens Seguras.", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index e15acb8..2cb3d26 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1,5 +1,6 @@ { "photoCount": "{count, plural, =1{Фото} other{Фото}}", + "mediaCount": "{count, plural, =1{Медиа} other{Медиа}}", "@@locale": "ru", "appTitle": "White Noise", "sloganFull": "Децентрализованный, Нецензурируемый,\nБезопасный Мессенджер.", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index fbacac0..5249911 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -1,5 +1,6 @@ { "photoCount": "{count, plural, =1{Fotoğraf} other{Fotoğraflar}}", + "mediaCount": "{count, plural, =1{Medya} other{Medya}}", "@@locale": "tr", "appTitle": "White Noise", "sloganFull": "Merkezi Olmayan, Sansürlenemez,\nGüvenli Mesajlaşma.", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index e5b6152..9653466 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -114,6 +114,12 @@ abstract class AppLocalizations { /// **'{count, plural, =1{Photo} other{Photos}}'** String photoCount(int count); + /// Label shown in chat list when the last message contains media attachments + /// + /// In en, this message translates to: + /// **'{count, plural, =1{Media item} other{Media items}}'** + String mediaCount(int count); + /// The application title displayed on home screen /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 8477691..391d373 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -19,6 +19,17 @@ class AppLocalizationsDe extends AppLocalizations { return '$_temp0'; } + @override + String mediaCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Medien', + one: 'Medium', + ); + return '$_temp0'; + } + @override String get appTitle => 'White Noise'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 94aba09..49fed24 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -19,6 +19,17 @@ class AppLocalizationsEn extends AppLocalizations { return '$_temp0'; } + @override + String mediaCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Media items', + one: 'Media item', + ); + return '$_temp0'; + } + @override String get appTitle => 'White Noise'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index c636778..fdc1427 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -19,6 +19,17 @@ class AppLocalizationsEs extends AppLocalizations { return '$_temp0'; } + @override + String mediaCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Multimedia', + one: 'Multimedia', + ); + return '$_temp0'; + } + @override String get appTitle => 'White Noise'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 3c88f20..74025eb 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -19,6 +19,17 @@ class AppLocalizationsFr extends AppLocalizations { return '$_temp0'; } + @override + String mediaCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Médias', + one: 'Média', + ); + return '$_temp0'; + } + @override String get appTitle => 'White Noise'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 2c506ea..2419c6f 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -19,6 +19,17 @@ class AppLocalizationsIt extends AppLocalizations { return '$_temp0'; } + @override + String mediaCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Media', + one: 'Media', + ); + return '$_temp0'; + } + @override String get appTitle => 'White Noise'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index ece66c1..f32fa3d 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -19,6 +19,17 @@ class AppLocalizationsPt extends AppLocalizations { return '$_temp0'; } + @override + String mediaCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Mídias', + one: 'Mídia', + ); + return '$_temp0'; + } + @override String get appTitle => 'White Noise'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 84c95ae..a77fb6a 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -19,6 +19,17 @@ class AppLocalizationsRu extends AppLocalizations { return '$_temp0'; } + @override + String mediaCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Медиа', + one: 'Медиа', + ); + return '$_temp0'; + } + @override String get appTitle => 'White Noise'; diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index 28ba552..b65fcb6 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -19,6 +19,17 @@ class AppLocalizationsTr extends AppLocalizations { return '$_temp0'; } + @override + String mediaCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Medya', + one: 'Medya', + ); + return '$_temp0'; + } + @override String get appTitle => 'White Noise'; diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index b01bbff..4b5494b 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -824,7 +824,7 @@ class _ChatInput extends StatelessWidget { actionsEnabled: actionsEnabled, onAddTap: () { input.focusNode.unfocus(); - mediaUpload.pickImages(); + mediaUpload.pickMedia(); }, inputField: TextField( controller: input.controller, diff --git a/lib/utils/media_type.dart b/lib/utils/media_type.dart new file mode 100644 index 0000000..f591eb3 --- /dev/null +++ b/lib/utils/media_type.dart @@ -0,0 +1,25 @@ +import 'package:mime/mime.dart' show lookupMimeType; +import 'package:whitenoise/src/rust/api/media_files.dart'; + +const _videoExtensions = {'.mp4', '.mov', '.m4v', '.webm', '.mkv', '.avi'}; + +bool isVideoMediaFile(MediaFile mediaFile) { + return _isVideoValue(mediaFile.mediaType) || _isVideoValue(mediaFile.mimeType); +} + +bool isVideoFilePath(String filePath) { + final path = filePath.toLowerCase(); + final mimeType = lookupMimeType(filePath); + return _isVideoValue(mimeType) || _videoExtensions.any(path.endsWith); +} + +bool _isVideoValue(String? value) { + final trimmed = value?.trim().toLowerCase(); + if (trimmed == null || trimmed.isEmpty) return false; + final normalized = trimmed.split(';').first.trim(); + if (normalized.isEmpty) return false; + if (normalized == 'video') return true; + + final parts = normalized.split('/'); + return parts.length == 2 && parts[0] == 'video' && parts[1].isNotEmpty; +} diff --git a/lib/widgets/chat_list_tile.dart b/lib/widgets/chat_list_tile.dart index 3b806de..f64f912 100644 --- a/lib/widgets/chat_list_tile.dart +++ b/lib/widgets/chat_list_tile.dart @@ -32,7 +32,7 @@ final _logger = Logger('ChatListTile'); return null; } return ( - subtitle: context.l10n.photoCount(lastMessage.mediaAttachmentCount.toInt()), + subtitle: context.l10n.mediaCount(lastMessage.mediaAttachmentCount.toInt()), icon: WnIcon( WnIcons.image, key: const Key('media_subtitle_icon'), diff --git a/lib/widgets/chat_media_thumbnail.dart b/lib/widgets/chat_media_thumbnail.dart index 65a35c5..eca5814 100644 --- a/lib/widgets/chat_media_thumbnail.dart +++ b/lib/widgets/chat_media_thumbnail.dart @@ -5,6 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:whitenoise/hooks/use_media_download.dart'; import 'package:whitenoise/src/rust/api/media_files.dart'; +import 'package:whitenoise/utils/media_type.dart'; +import 'package:whitenoise/widgets/local_video_player.dart'; import 'package:whitenoise/widgets/wn_media_placeholder.dart'; import 'package:whitenoise/widgets/wn_media_thumbnail.dart'; @@ -53,6 +55,7 @@ class ChatMediaThumbnail extends HookWidget { thumbHash: thumbHash, blurhash: blurhash, thumbnailSize: thumbnailSize, + isVideo: isVideoMediaFile(mediaFile), ); } @@ -82,6 +85,7 @@ class _MediaContent extends StatelessWidget { required this.thumbHash, required this.blurhash, required this.thumbnailSize, + required this.isVideo, }); final MediaDownloadStatus status; @@ -90,6 +94,7 @@ class _MediaContent extends StatelessWidget { final String? thumbHash; final String? blurhash; final double thumbnailSize; + final bool isVideo; @override Widget build(BuildContext context) { @@ -104,22 +109,33 @@ class _MediaContent extends StatelessWidget { height: thumbnailSize, ), if (status == MediaDownloadStatus.success) - FadeTransition( - key: const Key('fade_transition'), - opacity: fadeController, - child: Image.file( - File(localPath!), - key: const Key('thumbnail_image'), + if (isVideo) + LocalVideoPlayer( + key: const Key('thumbnail_video'), + filePath: localPath!, + thumbHash: thumbHash, + blurhash: blurhash, fit: BoxFit.cover, - errorBuilder: (_, _, _) => WnMediaPlaceholder( - key: const Key('thumbnail_error_fallback'), - thumbHash: thumbHash, - blurhash: blurhash, - width: thumbnailSize, - height: thumbnailSize, + showControls: false, + ) + else + FadeTransition( + key: const Key('fade_transition'), + opacity: fadeController, + child: Image.file( + File(localPath!), + key: const Key('thumbnail_image'), + fit: BoxFit.cover, + errorBuilder: (_, _, _) => WnMediaPlaceholder( + key: const Key('thumbnail_error_fallback'), + thumbHash: thumbHash, + blurhash: blurhash, + width: thumbnailSize, + height: thumbnailSize, + ), ), ), - ), + if (isVideo) const VideoPlayIndicator(key: Key('thumbnail_video_indicator')), ], ); } diff --git a/lib/widgets/chat_media_upload_preview.dart b/lib/widgets/chat_media_upload_preview.dart index 060512a..8192218 100644 --- a/lib/widgets/chat_media_upload_preview.dart +++ b/lib/widgets/chat_media_upload_preview.dart @@ -5,6 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:whitenoise/hooks/use_media_upload.dart' show MediaUploadItem, MediaUploadStatus; import 'package:whitenoise/theme.dart'; +import 'package:whitenoise/utils/media_type.dart'; +import 'package:whitenoise/widgets/local_video_player.dart'; import 'package:whitenoise/widgets/wn_icon.dart'; import 'package:whitenoise/widgets/wn_media_preview.dart'; import 'package:whitenoise/widgets/wn_spinner.dart'; @@ -70,7 +72,7 @@ class _MediaPreviewWithOverlay extends StatelessWidget { selectedIndex: selectedIndex, onSelectedChanged: onSelectedChanged, onDelete: onDelete, - children: items.map((item) => _buildImageTile(item, colors)).toList(), + children: items.map((item) => _buildMediaTile(item, colors)).toList(), ), if (currentItem.status == MediaUploadStatus.uploading) const Positioned.fill( @@ -87,7 +89,27 @@ class _MediaPreviewWithOverlay extends StatelessWidget { ); } - Widget _buildImageTile(MediaUploadItem item, SemanticColors colors) { + Widget _buildMediaTile(MediaUploadItem item, SemanticColors colors) { + final uploadedFile = item.file; + final isVideo = uploadedFile != null + ? isVideoMediaFile(uploadedFile) + : isVideoFilePath(item.filePath); + + if (isVideo) { + return Stack( + fit: StackFit.expand, + children: [ + LocalVideoPlayer( + key: const Key('video_tile_player'), + filePath: item.filePath, + fit: BoxFit.cover, + showControls: false, + ), + const VideoPlayIndicator(key: Key('video_tile_indicator')), + ], + ); + } + return Image.file( File(item.filePath), fit: BoxFit.cover, diff --git a/lib/widgets/chat_message_media.dart b/lib/widgets/chat_message_media.dart index 01cd669..4935dec 100644 --- a/lib/widgets/chat_message_media.dart +++ b/lib/widgets/chat_message_media.dart @@ -5,6 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:whitenoise/hooks/use_media_download.dart'; import 'package:whitenoise/src/rust/api/media_files.dart'; +import 'package:whitenoise/utils/media_type.dart'; +import 'package:whitenoise/widgets/local_video_player.dart'; import 'package:whitenoise/widgets/wn_media_error_placeholder.dart'; import 'package:whitenoise/widgets/wn_media_placeholder.dart'; import 'package:whitenoise/widgets/wn_message_media.dart'; @@ -35,6 +37,7 @@ class _ChatMessageMediaTile extends HookWidget { final fadeController = useAnimationController( duration: const Duration(milliseconds: 300), ); + final isVideo = isVideoMediaFile(mediaFile); useEffect(() { if (status == MediaDownloadStatus.success) { @@ -68,15 +71,33 @@ class _ChatMessageMediaTile extends HookWidget { blurhash: mediaFile.fileMetadata?.blurhash, ), if (status == MediaDownloadStatus.success) - FadeTransition( - key: const Key('fade_transition'), - opacity: fadeController, - child: Image.file( - File(localPath!), - key: const Key('media_image'), - fit: BoxFit.cover, + if (isVideo) + Stack( + fit: StackFit.expand, + children: [ + LocalVideoPlayer( + key: const Key('media_video'), + filePath: localPath!, + thumbHash: mediaFile.fileMetadata?.thumbhash, + blurhash: mediaFile.fileMetadata?.blurhash, + fit: BoxFit.cover, + showControls: false, + ), + const VideoPlayIndicator(key: Key('media_video_indicator')), + ], + ) + else + FadeTransition( + key: const Key('fade_transition'), + opacity: fadeController, + child: Image.file( + File(localPath!), + key: const Key('media_image'), + fit: BoxFit.cover, + ), ), - ), + if (isVideo && status != MediaDownloadStatus.success) + const VideoPlayIndicator(key: Key('media_video_loading_indicator')), ], ), ); diff --git a/lib/widgets/chat_message_quote.dart b/lib/widgets/chat_message_quote.dart index 57c8f07..5eebd54 100644 --- a/lib/widgets/chat_message_quote.dart +++ b/lib/widgets/chat_message_quote.dart @@ -2,11 +2,15 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:whitenoise/hooks/use_chat_messages.dart' show ChatMessageQuoteData; import 'package:whitenoise/hooks/use_media_download.dart'; import 'package:whitenoise/l10n/l10n.dart'; import 'package:whitenoise/src/rust/api/media_files.dart'; +import 'package:whitenoise/theme.dart'; +import 'package:whitenoise/utils/media_type.dart'; import 'package:whitenoise/utils/metadata.dart'; +import 'package:whitenoise/widgets/local_video_player.dart'; import 'package:whitenoise/widgets/wn_message_quote.dart'; class ChatMessageQuote extends StatelessWidget { @@ -78,9 +82,10 @@ class _ChatMessageQuoteWithMedia extends HookWidget { @override Widget build(BuildContext context) { final download = useMediaDownload(mediaFile: mediaFile); + final isVideo = isVideoMediaFile(mediaFile); ImageProvider? image; - if (download.status == MediaDownloadStatus.success && download.localPath != null) { + if (!isVideo && download.status == MediaDownloadStatus.success && download.localPath != null) { image = FileImage(File(download.localPath!)); } @@ -88,9 +93,25 @@ class _ChatMessageQuoteWithMedia extends HookWidget { author: author, text: text, image: image, + mediaThumbnail: isVideo ? const _VideoQuoteThumbnail() : null, onTap: onTap, onCancel: onCancel, authorColor: authorColor, ); } } + +class _VideoQuoteThumbnail extends StatelessWidget { + const _VideoQuoteThumbnail(); + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + ColoredBox(color: context.colors.fillSecondary), + VideoPlayIndicator(key: const Key('video_play_indicator'), size: 24.w), + ], + ); + } +} diff --git a/lib/widgets/local_video_player.dart b/lib/widgets/local_video_player.dart new file mode 100644 index 0000000..8ce0d96 --- /dev/null +++ b/lib/widgets/local_video_player.dart @@ -0,0 +1,185 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:logging/logging.dart'; +import 'package:video_player/video_player.dart'; +import 'package:whitenoise/theme.dart'; +import 'package:whitenoise/widgets/wn_media_placeholder.dart'; + +final _logger = Logger('LocalVideoPlayer'); + +class LocalVideoPlayer extends HookWidget { + const LocalVideoPlayer({ + super.key, + required this.filePath, + this.thumbHash, + this.blurhash, + this.fit = BoxFit.contain, + this.showControls = true, + this.autoplay = false, + }); + + final String filePath; + final String? thumbHash; + final String? blurhash; + final BoxFit fit; + final bool showControls; + final bool autoplay; + + @override + Widget build(BuildContext context) { + final controller = useState(null); + final controllerRef = useRef(null); + final generation = useRef(0); + final isInitialized = useState(false); + final hasError = useState(false); + + useEffect(() { + return () { + final activeController = controllerRef.value; + controllerRef.value = null; + if (activeController != null) { + unawaited(activeController.dispose()); + } + }; + }, const []); + + useEffect(() { + generation.value++; + final currentGeneration = generation.value; + + var cancelled = false; + isInitialized.value = false; + hasError.value = false; + + Future initialize() async { + final previousController = controllerRef.value; + if (previousController != null) { + controllerRef.value = null; + controller.value = null; + await previousController.dispose(); + } + + if (cancelled || generation.value != currentGeneration) return; + + final nextController = VideoPlayerController.file(File(filePath)); + controllerRef.value = nextController; + controller.value = nextController; + + try { + await nextController.initialize(); + await nextController.setLooping(false); + if (autoplay) await nextController.play(); + if (!cancelled && generation.value == currentGeneration) { + isInitialized.value = true; + } + } catch (error, stackTrace) { + _logger.warning('Failed to initialize local video player', error, stackTrace); + if (!cancelled && generation.value == currentGeneration) { + hasError.value = true; + } + } + } + + unawaited(initialize()); + return () { + cancelled = true; + }; + }, [filePath, autoplay]); + + final activeController = useListenable(controller.value); + + Future togglePlayback() async { + if (activeController == null || !isInitialized.value || hasError.value) return; + if (activeController.value.isPlaying) { + await activeController.pause(); + } else { + await activeController.play(); + } + } + + return GestureDetector( + key: const Key('local_video_tap_area'), + behavior: HitTestBehavior.opaque, + onTap: showControls ? () => unawaited(togglePlayback()) : null, + child: Stack( + fit: StackFit.expand, + children: [ + if (!isInitialized.value || hasError.value) + WnMediaPlaceholder( + key: hasError.value + ? const Key('video_error_placeholder') + : const Key('video_loading_placeholder'), + thumbHash: thumbHash, + blurhash: blurhash, + width: double.infinity, + height: double.infinity, + ), + if (activeController != null && isInitialized.value && !hasError.value) + FittedBox( + fit: fit, + child: SizedBox( + width: activeController.value.size.width, + height: activeController.value.size.height, + child: VideoPlayer( + activeController, + key: const Key('video_player'), + ), + ), + ), + if (showControls || hasError.value) + VideoPlayIndicator( + key: const Key('video_play_indicator'), + visible: + !isInitialized.value || + hasError.value || + activeController?.value.isPlaying != true, + ), + ], + ), + ); + } +} + +class VideoPlayIndicator extends StatelessWidget { + const VideoPlayIndicator({ + super.key, + this.visible = true, + this.size, + }); + + final bool visible; + final double? size; + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final resolvedSize = size ?? 48.w; + + return IgnorePointer( + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: visible ? 1 : 0, + child: Center( + child: Container( + width: resolvedSize, + height: resolvedSize, + decoration: BoxDecoration( + color: colors.overlayTertiary, + borderRadius: BorderRadius.circular(8.r), + ), + child: Icon( + Icons.play_arrow_rounded, + key: const Key('video_play_icon'), + color: colors.fillContentQuaternary, + size: resolvedSize * 0.72, + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/media_modal.dart b/lib/widgets/media_modal.dart index 54fb95e..f579970 100644 --- a/lib/widgets/media_modal.dart +++ b/lib/widgets/media_modal.dart @@ -6,8 +6,10 @@ import 'package:whitenoise/l10n/l10n.dart'; import 'package:whitenoise/providers/locale_provider.dart'; import 'package:whitenoise/src/rust/api/media_files.dart'; import 'package:whitenoise/theme.dart'; +import 'package:whitenoise/utils/media_type.dart'; import 'package:whitenoise/widgets/chat_media_thumbnail.dart'; import 'package:whitenoise/widgets/media_image.dart'; +import 'package:whitenoise/widgets/media_video.dart'; import 'package:whitenoise/widgets/wn_avatar.dart'; import 'package:whitenoise/widgets/wn_icon.dart'; import 'package:whitenoise/widgets/wn_overlay.dart'; @@ -166,9 +168,16 @@ class _MediaContent extends StatelessWidget { physics: isZoomed ? const NeverScrollableScrollPhysics() : const PageScrollPhysics(), onPageChanged: onPageChanged, itemBuilder: (_, index) { + final mediaFile = mediaFiles[index]; + if (isVideoMediaFile(mediaFile)) { + return MediaVideo( + key: Key('media_video_$index'), + mediaFile: mediaFile, + ); + } return MediaImage( key: Key('media_image_$index'), - mediaFile: mediaFiles[index], + mediaFile: mediaFile, onZoomChanged: onZoomChanged, ); }, diff --git a/lib/widgets/media_video.dart b/lib/widgets/media_video.dart new file mode 100644 index 0000000..274f758 --- /dev/null +++ b/lib/widgets/media_video.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:whitenoise/hooks/use_media_download.dart'; +import 'package:whitenoise/src/rust/api/media_files.dart'; +import 'package:whitenoise/widgets/local_video_player.dart'; +import 'package:whitenoise/widgets/wn_media_error_placeholder.dart'; +import 'package:whitenoise/widgets/wn_media_placeholder.dart'; + +class MediaVideo extends HookWidget { + const MediaVideo({ + super.key, + required this.mediaFile, + }); + + final MediaFile mediaFile; + + @override + Widget build(BuildContext context) { + final (:status, :localPath, :retry) = useMediaDownload(mediaFile: mediaFile); + final thumbHash = mediaFile.fileMetadata?.thumbhash; + final blurhash = mediaFile.fileMetadata?.blurhash; + + if (status == MediaDownloadStatus.error) { + return WnMediaErrorPlaceholder( + key: const Key('media_video_error'), + onRetry: retry!, + thumbHash: thumbHash, + blurhash: blurhash, + ); + } + + if (status != MediaDownloadStatus.success) { + return WnMediaPlaceholder( + key: const Key('media_video_loading'), + thumbHash: thumbHash, + blurhash: blurhash, + width: double.infinity, + height: double.infinity, + ); + } + + return LocalVideoPlayer( + key: const Key('media_video_player'), + filePath: localPath!, + thumbHash: thumbHash, + blurhash: blurhash, + ); + } +} diff --git a/lib/widgets/wn_media_upload_preview.dart b/lib/widgets/wn_media_upload_preview.dart index 79f7457..81cff52 100644 --- a/lib/widgets/wn_media_upload_preview.dart +++ b/lib/widgets/wn_media_upload_preview.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:whitenoise/hooks/use_media_upload.dart' show MediaUploadItem, MediaUploadStatus; import 'package:whitenoise/theme.dart'; +import 'package:whitenoise/utils/media_type.dart'; +import 'package:whitenoise/widgets/local_video_player.dart'; import 'package:whitenoise/widgets/wn_icon.dart'; import 'package:whitenoise/widgets/wn_spinner.dart'; @@ -76,24 +78,7 @@ class _ThumbnailItem extends StatelessWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(8.r), - child: Image.file( - File(item.filePath), - key: const Key('thumbnail_image'), - width: size, - height: size, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => Container( - key: const Key('thumbnail_error_placeholder'), - color: colors.fillSecondary, - child: Center( - child: WnIcon( - WnIcons.image, - color: colors.backgroundContentTertiary, - size: 20.sp, - ), - ), - ), - ), + child: _ThumbnailMedia(item: item, size: size, colors: colors), ), if (item.status == MediaUploadStatus.uploading) Positioned.fill( @@ -155,6 +140,63 @@ class _ThumbnailItem extends StatelessWidget { } } +class _ThumbnailMedia extends StatelessWidget { + const _ThumbnailMedia({ + required this.item, + required this.size, + required this.colors, + }); + + final MediaUploadItem item; + final double size; + final SemanticColors colors; + + @override + Widget build(BuildContext context) { + final uploadedFile = item.file; + final isVideo = uploadedFile != null + ? isVideoMediaFile(uploadedFile) + : isVideoFilePath(item.filePath); + + if (isVideo) { + return Stack( + fit: StackFit.expand, + children: [ + LocalVideoPlayer( + key: const Key('thumbnail_video_player'), + filePath: item.filePath, + fit: BoxFit.cover, + showControls: false, + ), + VideoPlayIndicator( + key: const Key('thumbnail_video_indicator'), + size: 24.w, + ), + ], + ); + } + + return Image.file( + File(item.filePath), + key: const Key('thumbnail_image'), + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Container( + key: const Key('thumbnail_error_placeholder'), + color: colors.fillSecondary, + child: Center( + child: WnIcon( + WnIcons.image, + color: colors.backgroundContentTertiary, + size: 20.sp, + ), + ), + ), + ); + } +} + class _AddMoreButton extends StatelessWidget { final double size; final VoidCallback onTap; diff --git a/lib/widgets/wn_message_quote.dart b/lib/widgets/wn_message_quote.dart index a90e481..993d055 100644 --- a/lib/widgets/wn_message_quote.dart +++ b/lib/widgets/wn_message_quote.dart @@ -12,6 +12,7 @@ class WnMessageQuote extends StatelessWidget { this.onCancel, this.onTap, this.image, + this.mediaThumbnail, this.authorColor, }); @@ -20,6 +21,7 @@ class WnMessageQuote extends StatelessWidget { final VoidCallback? onCancel; final VoidCallback? onTap; final ImageProvider? image; + final Widget? mediaThumbnail; final Color? authorColor; @override @@ -84,16 +86,20 @@ class WnMessageQuote extends StatelessWidget { ), ), ), - if (image != null) ...[ + if (image != null || mediaThumbnail != null) ...[ Gap(10.w), ClipRRect( borderRadius: BorderRadius.circular(4.r), - child: Image( + child: SizedBox( key: const Key('quote_thumbnail'), - image: image!, - fit: BoxFit.cover, width: 40.w, - height: 40.w, + height: 40.h, + child: image != null + ? Image( + image: image!, + fit: BoxFit.cover, + ) + : mediaThumbnail!, ), ), ], diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index f30fc2c..48df31d 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -17,6 +17,7 @@ import share_plus import shared_preferences_foundation import sqflite_darwin import url_launcher_macos +import video_player_avfoundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) @@ -31,4 +32,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index abf2fa4..bedc33b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1505,6 +1505,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: "877a6c7ba772456077d7bfd71314629b3fe2b73733ce503fc77c3314d43a0ca0" + url: "https://pub.dev" + source: hosted + version: "2.9.5" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: af0e5b8a7a4876fb37e7cc8cb2a011e82bb3ecfa45844ef672e32cb14a1f259e + url: "https://pub.dev" + source: hosted + version: "2.9.4" + video_player_platform_interface: + dependency: "direct dev" + description: + name: video_player_platform_interface + sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec" + url: "https://pub.dev" + source: hosted + version: "6.6.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.dev" + source: hosted + version: "2.4.0" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 585d991..c6d4353 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -56,6 +56,7 @@ dependencies: hooks_riverpod: ^3.1.0 path_provider: ^2.1.5 image_picker: ^1.1.2 + video_player: ^2.10.1 mime: ^2.0.0 qr_flutter: ^4.1.0 cached_network_image: ^3.4.1 @@ -77,6 +78,7 @@ dev_dependencies: sdk: flutter flutter_native_splash: ^2.4.7 image_picker_platform_interface: ^2.11.1 + video_player_platform_interface: ^6.6.0 plugin_platform_interface: ^2.1.8 flutter_secure_storage_platform_interface: ^2.0.1 url_launcher_platform_interface: ^2.3.2 diff --git a/test/fakes/fake_video_player_platform.dart b/test/fakes/fake_video_player_platform.dart new file mode 100644 index 0000000..175eb7c --- /dev/null +++ b/test/fakes/fake_video_player_platform.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:video_player_platform_interface/video_player_platform_interface.dart'; + +class FakeVideoPlayerPlatform extends VideoPlayerPlatform { + final calls = []; + final dataSources = []; + final streams = >{}; + + bool forceInitError = false; + int nextPlayerId = 0; + + @override + Future init() async { + calls.add('init'); + } + + @override + Future createWithOptions(VideoCreationOptions options) async { + calls.add('createWithOptions'); + final playerId = nextPlayerId++; + final stream = StreamController(); + streams[playerId] = stream; + dataSources.add(options.dataSource); + + if (forceInitError) { + stream.addError( + PlatformException( + code: 'VideoError', + message: 'Video player failed to initialize', + ), + ); + } else { + stream.add( + VideoEvent( + eventType: VideoEventType.initialized, + size: const Size(640, 360), + duration: const Duration(seconds: 3), + ), + ); + } + + return playerId; + } + + @override + Stream videoEventsFor(int playerId) { + return streams[playerId]!.stream; + } + + @override + Future dispose(int playerId) async { + calls.add('dispose'); + final controller = streams.remove(playerId); + await controller?.close(); + } + + @override + Future setLooping(int playerId, bool looping) async { + calls.add('setLooping'); + } + + @override + Future play(int playerId) async { + calls.add('play'); + streams[playerId]?.add( + VideoEvent( + eventType: VideoEventType.isPlayingStateUpdate, + isPlaying: true, + ), + ); + } + + @override + Future pause(int playerId) async { + calls.add('pause'); + streams[playerId]?.add( + VideoEvent( + eventType: VideoEventType.isPlayingStateUpdate, + isPlaying: false, + ), + ); + } + + @override + Future setVolume(int playerId, double volume) async { + calls.add('setVolume'); + } + + @override + Future setPlaybackSpeed(int playerId, double speed) async { + calls.add('setPlaybackSpeed'); + } + + @override + Future getPosition(int playerId) async { + calls.add('getPosition'); + return Duration.zero; + } + + @override + Future seekTo(int playerId, Duration position) async { + calls.add('seekTo'); + } + + @override + Widget buildView(int playerId) { + return Texture(textureId: playerId); + } +} + +FakeVideoPlayerPlatform setUpFakeVideoPlayerPlatform() { + final fake = FakeVideoPlayerPlatform(); + VideoPlayerPlatform.instance = fake; + return fake; +} diff --git a/test/hooks/use_media_upload_test.dart b/test/hooks/use_media_upload_test.dart index 4b9f9d7..ea5be0f 100644 --- a/test/hooks/use_media_upload_test.dart +++ b/test/hooks/use_media_upload_test.dart @@ -26,7 +26,7 @@ class _MockImagePicker extends ImagePicker { int pickCallCount = 0; @override - Future> pickMultiImage({ + Future> pickMultipleMedia({ double? maxWidth, double? maxHeight, int? imageQuality, @@ -100,12 +100,12 @@ void main() { expect(getResult().uploadedFiles, isEmpty); }); - group('pickImages', () { - testWidgets('calls image picker', (tester) async { + group('pickMedia', () { + testWidgets('calls media picker', (tester) async { final getResult = await pump(tester); mockPicker.filesToReturn = []; - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pump(); expect(mockPicker.pickCallCount, 1); @@ -115,7 +115,7 @@ void main() { final getResult = await pump(tester); mockPicker.filesToReturn = []; - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pump(); expect(getResult().items, isEmpty); @@ -125,7 +125,7 @@ void main() { final getResult = await pump(tester); mockPicker.filesToReturn = [XFile('/path/to/image.jpg')]; - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pump(); expect(getResult().items.length, 1); @@ -133,6 +133,18 @@ void main() { expect(getResult().items.first.filePath, '/path/to/image.jpg'); }); + testWidgets('adds item in uploading state when video is selected', (tester) async { + final getResult = await pump(tester); + mockPicker.filesToReturn = [XFile('/path/to/video.mp4')]; + + await getResult().pickMedia(); + await tester.pump(); + + expect(getResult().items.length, 1); + expect(getResult().items.first.status, MediaUploadStatus.uploading); + expect(getResult().items.first.filePath, '/path/to/video.mp4'); + }); + testWidgets('adds multiple items when multiple images selected', (tester) async { final getResult = await pump(tester); mockPicker.filesToReturn = [ @@ -140,7 +152,7 @@ void main() { XFile('/path/to/image2.jpg'), ]; - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pump(); expect(getResult().items.length, 2); @@ -154,7 +166,7 @@ void main() { ]; uploadCompleter.complete(_mediaFile()); - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pumpAndSettle(); expect(uploadCallCount, 2); @@ -167,14 +179,14 @@ void main() { uploadCompleter.complete(_mediaFile()); mockPicker.filesToReturn = [XFile('/path/to/image.jpg')]; - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pumpAndSettle(); expect(getResult().items.length, 1); final uploadsAfterFirst = uploadCallCount; mockPicker.filesToReturn = [XFile('/path/to/image.jpg')]; - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pumpAndSettle(); expect(getResult().items.length, 1); @@ -186,11 +198,11 @@ void main() { uploadCompleter.complete(_mediaFile()); mockPicker.filesToReturn = [XFile('/path/to/image1.jpg')]; - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pumpAndSettle(); mockPicker.filesToReturn = [XFile('/path/to/image2.jpg')]; - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pumpAndSettle(); expect(getResult().items.length, 2); @@ -203,7 +215,7 @@ void main() { mockPicker.filesToReturn = [XFile('/path/to/image.jpg')]; uploadCompleter.complete(_mediaFile()); - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pumpAndSettle(); expect(getResult().items.first.status, MediaUploadStatus.uploaded); @@ -214,7 +226,7 @@ void main() { mockPicker.filesToReturn = [XFile('/path/to/image.jpg')]; uploadCompleter.complete(_mediaFile()); - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pumpAndSettle(); expect(getResult().items.first.file, isNotNull); @@ -226,7 +238,7 @@ void main() { mockPicker.filesToReturn = [XFile('/path/to/image.jpg')]; uploadCompleter.complete(_mediaFile()); - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pumpAndSettle(); expect(getResult().canSend, isTrue); @@ -237,7 +249,7 @@ void main() { mockPicker.filesToReturn = [XFile('/path/to/image.jpg')]; uploadCompleter.complete(_mediaFile()); - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pumpAndSettle(); expect(getResult().uploadedFiles.length, 1); @@ -250,7 +262,7 @@ void main() { final getResult = await pump(tester); mockPicker.filesToReturn = [XFile('/path/to/image.jpg')]; - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pumpAndSettle(); expect(getResult().items.first.status, MediaUploadStatus.error); @@ -261,7 +273,7 @@ void main() { final getResult = await pump(tester); mockPicker.filesToReturn = [XFile('/path/to/image.jpg')]; - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pumpAndSettle(); expect(getResult().items.first.retry, isNotNull); @@ -272,7 +284,7 @@ void main() { final getResult = await pump(tester); mockPicker.filesToReturn = [XFile('/path/to/image.jpg')]; - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pumpAndSettle(); expect(getResult().canSend, isFalse); @@ -283,7 +295,7 @@ void main() { final getResult = await pump(tester); mockPicker.filesToReturn = [XFile('/path/to/image.jpg')]; - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pumpAndSettle(); expect(getResult().items.first.status, MediaUploadStatus.error); @@ -300,7 +312,7 @@ void main() { final getResult = await pump(tester); mockPicker.filesToReturn = [XFile('/path/to/image.jpg')]; - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pumpAndSettle(); final initialUploadCount = uploadCallCount; @@ -324,7 +336,7 @@ void main() { ]; uploadCompleter.complete(_mediaFile()); - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pumpAndSettle(); expect(getResult().items.length, 2); @@ -340,13 +352,13 @@ void main() { mockPicker.filesToReturn = [XFile('/path/to/good.jpg')]; uploadCompleter.complete(_mediaFile()); - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pumpAndSettle(); shouldFailUpload = true; uploadCompleter = Completer(); mockPicker.filesToReturn = [XFile('/path/to/bad.jpg')]; - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pumpAndSettle(); expect(getResult().canSend, isFalse); @@ -367,7 +379,7 @@ void main() { ]; uploadCompleter.complete(_mediaFile()); - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pumpAndSettle(); expect(getResult().items.length, 2); @@ -382,7 +394,7 @@ void main() { mockPicker.filesToReturn = [XFile('/path/to/image.jpg')]; uploadCompleter.complete(_mediaFile()); - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pumpAndSettle(); expect(getResult().canSend, isTrue); @@ -397,7 +409,7 @@ void main() { mockPicker.filesToReturn = [XFile('/path/to/image.jpg')]; uploadCompleter.complete(_mediaFile()); - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pumpAndSettle(); expect(getResult().uploadedFiles, isNotEmpty); @@ -413,7 +425,7 @@ void main() { final getResult = await pump(tester); mockPicker.filesToReturn = [XFile('/path/to/image.jpg')]; - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pump(); expect(getResult().items.first.status, MediaUploadStatus.uploading); @@ -425,13 +437,13 @@ void main() { mockPicker.filesToReturn = [XFile('/path/to/good.jpg')]; uploadCompleter.complete(_mediaFile()); - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pumpAndSettle(); shouldFailUpload = true; uploadCompleter = Completer(); mockPicker.filesToReturn = [XFile('/path/to/bad.jpg')]; - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pumpAndSettle(); expect(getResult().canSend, isFalse); @@ -445,7 +457,7 @@ void main() { ]; uploadCompleter.complete(_mediaFile()); - await getResult().pickImages(); + await getResult().pickMedia(); await tester.pumpAndSettle(); expect(getResult().canSend, isTrue); diff --git a/test/screens/chat_screen_test.dart b/test/screens/chat_screen_test.dart index ce62084..82cb1b2 100644 --- a/test/screens/chat_screen_test.dart +++ b/test/screens/chat_screen_test.dart @@ -44,6 +44,11 @@ class _MockImagePickerPlatform extends ImagePickerPlatform with MockPlatformInte }) async { return filesToReturn; } + + @override + Future> getMedia({required MediaOptions options}) async { + return filesToReturn; + } } class _MockTag implements Tag { @@ -2033,7 +2038,7 @@ void main() { ImagePickerPlatform.instance = mockImagePicker; }); - testWidgets('shows media upload preview when images are picked', (tester) async { + testWidgets('shows media upload preview when media is picked', (tester) async { mockImagePicker.filesToReturn = [XFile('/tmp/test_image.jpg')]; _api.initialMessages = [_message('m1', DateTime(2024))]; await pumpChatScreen(tester); diff --git a/test/utils/media_type_test.dart b/test/utils/media_type_test.dart new file mode 100644 index 0000000..b8ee9b0 --- /dev/null +++ b/test/utils/media_type_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:whitenoise/src/rust/api/media_files.dart'; +import 'package:whitenoise/utils/media_type.dart'; + +import '../test_helpers.dart'; + +MediaFile _mediaFile({ + String mimeType = 'image/jpeg', + String mediaType = 'image', +}) => MediaFile( + id: 'media1', + mlsGroupId: testGroupId, + accountPubkey: testPubkeyA, + filePath: '/path/to/file', + originalFileHash: 'hash123', + encryptedFileHash: 'encrypted123', + mimeType: mimeType, + mediaType: mediaType, + blossomUrl: 'https://example.com/media', + nostrKey: 'nostr123', + createdAt: DateTime(2024), +); + +void main() { + group('media type helpers', () { + test('detects video media type', () { + expect(isVideoMediaFile(_mediaFile(mediaType: 'video')), isTrue); + }); + + test('detects video mime type', () { + expect(isVideoMediaFile(_mediaFile(mimeType: 'video/mp4')), isTrue); + }); + + test('detects video mime type with parameters', () { + expect(isVideoMediaFile(_mediaFile(mimeType: 'video/mp4; codecs=h264')), isTrue); + }); + + test('does not treat images as video', () { + expect(isVideoMediaFile(_mediaFile()), isFalse); + }); + + test('does not treat malformed media values as video', () { + expect(isVideoMediaFile(_mediaFile(mimeType: 'video/', mediaType: '')), isFalse); + expect(isVideoMediaFile(_mediaFile(mimeType: 'notvideo/mp4', mediaType: '')), isFalse); + expect(isVideoMediaFile(_mediaFile(mimeType: '', mediaType: '')), isFalse); + }); + + test('detects common video file extensions', () { + expect(isVideoFilePath('/path/to/clip.mp4'), isTrue); + expect(isVideoFilePath('/path/to/clip.mov'), isTrue); + }); + + test('does not treat image file paths as video', () { + expect(isVideoFilePath('/path/to/photo.jpg'), isFalse); + }); + }); +} diff --git a/test/widgets/chat_list_tile_test.dart b/test/widgets/chat_list_tile_test.dart index 42d5d9d..7c5c1e9 100644 --- a/test/widgets/chat_list_tile_test.dart +++ b/test/widgets/chat_list_tile_test.dart @@ -193,7 +193,7 @@ void main() { expect(item.subtitle, 'Hello from pending chat'); }); - testWidgets('shows photo subtitle and icon for media-only message', (tester) async { + testWidgets('shows media subtitle and icon for media-only message', (tester) async { await pumpTile( tester, _chatSummary( @@ -204,12 +204,12 @@ void main() { ); final finder = find.byType(WnChatListItem); final item = tester.widget(finder); - expect(item.subtitle, 'Photo'); + expect(item.subtitle, 'Media item'); expect(item.subtitleIcon, isNotNull); expect(find.byKey(const Key('media_subtitle_icon')), findsOneWidget); }); - testWidgets('shows photos subtitle for multiple media-only message', (tester) async { + testWidgets('shows media subtitle for multiple media-only message', (tester) async { await pumpTile( tester, _chatSummary( @@ -220,7 +220,7 @@ void main() { ); final finder = find.byType(WnChatListItem); final item = tester.widget(finder); - expect(item.subtitle, 'Photos'); + expect(item.subtitle, 'Media items'); expect(item.subtitleIcon, isNotNull); }); @@ -328,7 +328,7 @@ void main() { expect(item.subtitle, 'Group message in pending chat'); }); - testWidgets('shows photo subtitle and icon for media-only message', (tester) async { + testWidgets('shows media subtitle and icon for media-only message', (tester) async { await pumpTile( tester, _chatSummary( @@ -338,12 +338,12 @@ void main() { ); final finder = find.byType(WnChatListItem); final item = tester.widget(finder); - expect(item.subtitle, 'Photo'); + expect(item.subtitle, 'Media item'); expect(item.subtitleIcon, isNotNull); expect(find.byKey(const Key('media_subtitle_icon')), findsOneWidget); }); - testWidgets('shows photos subtitle for multiple media-only message', (tester) async { + testWidgets('shows media subtitle for multiple media-only message', (tester) async { await pumpTile( tester, _chatSummary( @@ -353,7 +353,7 @@ void main() { ); final finder = find.byType(WnChatListItem); final item = tester.widget(finder); - expect(item.subtitle, 'Photos'); + expect(item.subtitle, 'Media items'); expect(item.subtitleIcon, isNotNull); }); @@ -501,7 +501,7 @@ void main() { expect(item.subtitle, ''); }); - testWidgets('shows "Photo" and image icon when single media-only message', ( + testWidgets('shows "Media item" and media icon when single media-only message', ( tester, ) async { await pumpTile( @@ -510,12 +510,12 @@ void main() { ); final finder = find.byType(WnChatListItem); final item = tester.widget(finder); - expect(item.subtitle, 'Photo'); + expect(item.subtitle, 'Media item'); expect(item.subtitleIcon, isNotNull); expect(find.byKey(const Key('media_subtitle_icon')), findsOneWidget); }); - testWidgets('shows "Photos" and image icon when multiple media-only message', ( + testWidgets('shows "Media items" and media icon when multiple media-only message', ( tester, ) async { await pumpTile( @@ -524,7 +524,7 @@ void main() { ); final finder = find.byType(WnChatListItem); final item = tester.widget(finder); - expect(item.subtitle, 'Photos'); + expect(item.subtitle, 'Media items'); expect(item.subtitleIcon, isNotNull); expect(find.byKey(const Key('media_subtitle_icon')), findsOneWidget); }); diff --git a/test/widgets/chat_media_thumbnail_test.dart b/test/widgets/chat_media_thumbnail_test.dart index a9474f9..5316c4e 100644 --- a/test/widgets/chat_media_thumbnail_test.dart +++ b/test/widgets/chat_media_thumbnail_test.dart @@ -8,6 +8,7 @@ import 'package:whitenoise/src/rust/frb_generated.dart'; import 'package:whitenoise/widgets/chat_media_thumbnail.dart'; import 'package:whitenoise/widgets/wn_media_thumbnail.dart'; +import '../fakes/fake_video_player_platform.dart'; import '../mocks/mock_wn_api.dart'; import '../test_helpers.dart'; @@ -17,6 +18,8 @@ MediaFile _mediaFile({ String? originalFileHash = 'hash123', String? blurhash, String? thumbhash, + String mimeType = 'image/jpeg', + String mediaType = 'image', }) => MediaFile( id: id, mlsGroupId: testGroupId, @@ -24,8 +27,8 @@ MediaFile _mediaFile({ filePath: filePath, originalFileHash: originalFileHash, encryptedFileHash: 'encrypted123', - mimeType: 'image/jpeg', - mediaType: 'image', + mimeType: mimeType, + mediaType: mediaType, blossomUrl: 'https://example.com/media', nostrKey: 'nostr123', createdAt: DateTime(2024), @@ -104,6 +107,32 @@ void main() { expect(find.byKey(const Key('thumbnail_image')), findsOneWidget); }); + testWidgets('shows video thumbnail with play indicator when file exists locally', ( + tester, + ) async { + setUpFakeVideoPlayerPlatform(); + final tempDir = Directory.systemTemp.createTempSync('thumbnail_video_test'); + final tempFile = File('${tempDir.path}/test.mp4'); + tempFile.writeAsBytesSync([0, 0, 0, 0]); + addTearDown(() => tempDir.deleteSync(recursive: true)); + + await mountWidget( + ChatMediaThumbnail( + mediaFile: _mediaFile( + filePath: tempFile.path, + mimeType: 'video/mp4', + mediaType: 'video', + ), + ), + tester, + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('thumbnail_video')), findsOneWidget); + expect(find.byKey(const Key('thumbnail_video_indicator')), findsOneWidget); + expect(find.byKey(const Key('thumbnail_image')), findsNothing); + }); + testWidgets('fade transition animates from 0 to 1', (tester) async { _api.downloadCompleter = Completer(); final tempDir = Directory.systemTemp.createTempSync('thumbnail_fade_test'); diff --git a/test/widgets/chat_media_upload_preview_test.dart b/test/widgets/chat_media_upload_preview_test.dart index f2fd710..0e3557d 100644 --- a/test/widgets/chat_media_upload_preview_test.dart +++ b/test/widgets/chat_media_upload_preview_test.dart @@ -7,12 +7,14 @@ import 'package:whitenoise/widgets/chat_media_upload_preview.dart'; import 'package:whitenoise/widgets/wn_media_preview.dart'; import 'package:whitenoise/widgets/wn_spinner.dart'; +import '../fakes/fake_video_player_platform.dart'; import '../test_helpers.dart'; void main() { late Directory tempDir; late File testImageFile; late File testImageFile2; + late File testVideoFile; setUpAll(() { tempDir = Directory.systemTemp.createTempSync('chat_media_preview_test'); @@ -20,6 +22,8 @@ void main() { testImageFile.writeAsBytesSync([0xFF, 0xD8, 0xFF, 0xE0]); testImageFile2 = File('${tempDir.path}/test2.jpg'); testImageFile2.writeAsBytesSync([0xFF, 0xD8, 0xFF, 0xE0]); + testVideoFile = File('${tempDir.path}/test.mp4'); + testVideoFile.writeAsBytesSync([0, 0, 0, 0]); }); tearDownAll(() { @@ -62,6 +66,22 @@ void main() { expect(find.byKey(const Key('chat_media_upload_preview')), findsOneWidget); }); + testWidgets('renders video tile for video files', (tester) async { + setUpFakeVideoPlayerPlatform(); + + await mountWidget( + ChatMediaUploadPreview( + items: [createItem(filePath: testVideoFile.path)], + onRemove: (_) {}, + ), + tester, + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('video_tile_player')), findsOneWidget); + expect(find.byKey(const Key('video_tile_indicator')), findsOneWidget); + }); + testWidgets('shows uploading overlay when current item is uploading', (tester) async { await mountWidget( ChatMediaUploadPreview( diff --git a/test/widgets/chat_message_media_test.dart b/test/widgets/chat_message_media_test.dart index f8699ba..3fd4a0c 100644 --- a/test/widgets/chat_message_media_test.dart +++ b/test/widgets/chat_message_media_test.dart @@ -8,6 +8,7 @@ import 'package:whitenoise/src/rust/frb_generated.dart'; import 'package:whitenoise/widgets/chat_message_media.dart'; import 'package:whitenoise/widgets/wn_message_media.dart'; +import '../fakes/fake_video_player_platform.dart'; import '../mocks/mock_wn_api.dart'; import '../test_helpers.dart'; @@ -17,6 +18,8 @@ MediaFile _mediaFile({ String? originalFileHash = 'hash123', String? blurhash, String? thumbhash, + String mimeType = 'image/jpeg', + String mediaType = 'image', }) => MediaFile( id: id, mlsGroupId: testGroupId, @@ -24,8 +27,8 @@ MediaFile _mediaFile({ filePath: filePath, originalFileHash: originalFileHash, encryptedFileHash: 'encrypted123', - mimeType: 'image/jpeg', - mediaType: 'image', + mimeType: mimeType, + mediaType: mediaType, blossomUrl: 'https://example.com/media', nostrKey: 'nostr123', createdAt: DateTime(2024), @@ -232,6 +235,32 @@ void main() { expect(find.byKey(const Key('media_image')), findsOneWidget); }); + testWidgets('shows video preview with play indicator on success', (tester) async { + setUpFakeVideoPlayerPlatform(); + final tempDir = Directory.systemTemp.createTempSync('chat_media_video_test'); + final tempFile = File('${tempDir.path}/test.mp4'); + tempFile.writeAsBytesSync([0, 0, 0, 0]); + addTearDown(() => tempDir.deleteSync(recursive: true)); + + await mountWidget( + ChatMessageMedia( + mediaFiles: [ + _mediaFile( + filePath: tempFile.path, + mimeType: 'video/mp4', + mediaType: 'video', + ), + ], + ), + tester, + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('media_video')), findsOneWidget); + expect(find.byKey(const Key('media_video_indicator')), findsOneWidget); + expect(find.byKey(const Key('media_image')), findsNothing); + }); + testWidgets('shows blurhash placeholder when blurhash provided', (tester) async { _api.downloadCompleter = Completer(); await mountWidget( diff --git a/test/widgets/chat_message_quote_test.dart b/test/widgets/chat_message_quote_test.dart index 427f6a1..432052c 100644 --- a/test/widgets/chat_message_quote_test.dart +++ b/test/widgets/chat_message_quote_test.dart @@ -86,6 +86,8 @@ MediaFile _mediaFile({ String id = 'media1', String filePath = '', String? originalFileHash = 'hash123', + String mimeType = 'image/jpeg', + String mediaType = 'image', }) => MediaFile( id: id, mlsGroupId: testGroupId, @@ -93,8 +95,8 @@ MediaFile _mediaFile({ filePath: filePath, originalFileHash: originalFileHash, encryptedFileHash: 'encrypted123', - mimeType: 'image/jpeg', - mediaType: 'image', + mimeType: mimeType, + mediaType: mediaType, blossomUrl: 'https://example.com/media', nostrKey: 'nostr123', createdAt: DateTime(2024), @@ -342,6 +344,24 @@ void main() { expect(find.byKey(const Key('quote_thumbnail')), findsOneWidget); }); + testWidgets('shows video thumbnail for video media', (tester) async { + await mountWidget( + ChatMessageQuote( + data: _quoteData( + mediaFile: _mediaFile( + mimeType: 'video/mp4', + mediaType: 'video', + ), + ), + ), + tester, + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('quote_thumbnail')), findsOneWidget); + expect(find.byKey(const Key('video_play_indicator')), findsOneWidget); + }); + testWidgets('does not show thumbnail when no media file', (tester) async { await mountWidget( ChatMessageQuote(data: _quoteData()), diff --git a/test/widgets/local_video_player_test.dart b/test/widgets/local_video_player_test.dart new file mode 100644 index 0000000..09244e1 --- /dev/null +++ b/test/widgets/local_video_player_test.dart @@ -0,0 +1,149 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:whitenoise/widgets/local_video_player.dart'; + +import '../fakes/fake_video_player_platform.dart'; +import '../test_helpers.dart'; + +Future pumpUntil( + WidgetTester tester, + bool Function() condition, +) async { + await tester.runAsync(() async { + for (var i = 0; i < 20; i++) { + if (condition()) return; + await Future.delayed(const Duration(milliseconds: 10)); + } + }); + await tester.pumpAndSettle(); +} + +void main() { + late FakeVideoPlayerPlatform fakeVideoPlatform; + late Directory tempDir; + late File videoFile; + + setUp(() { + fakeVideoPlatform = setUpFakeVideoPlayerPlatform(); + tempDir = Directory.systemTemp.createTempSync('local_video_player_test'); + videoFile = File('${tempDir.path}/clip.mp4')..writeAsBytesSync([0, 0, 0, 0]); + }); + + tearDown(() { + tempDir.deleteSync(recursive: true); + }); + + group('LocalVideoPlayer', () { + testWidgets('initializes a file video player', (tester) async { + await mountWidget( + LocalVideoPlayer(filePath: videoFile.path), + tester, + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('video_player')), findsOneWidget); + expect(fakeVideoPlatform.calls, contains('createWithOptions')); + expect(fakeVideoPlatform.dataSources.first.uri, startsWith('file://')); + }); + + testWidgets('shows play indicator while paused', (tester) async { + await mountWidget( + LocalVideoPlayer(filePath: videoFile.path), + tester, + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('video_play_indicator')), findsOneWidget); + expect(find.byKey(const Key('video_play_icon')), findsOneWidget); + }); + + testWidgets('toggles playback when tapped', (tester) async { + await mountWidget( + LocalVideoPlayer(filePath: videoFile.path), + tester, + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('local_video_tap_area'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('local_video_tap_area'))); + await tester.pump(); + + expect(fakeVideoPlatform.calls, contains('play')); + expect(fakeVideoPlatform.calls, contains('pause')); + }); + + testWidgets('shows error placeholder when initialization fails', (tester) async { + fakeVideoPlatform.forceInitError = true; + + await mountWidget( + LocalVideoPlayer(filePath: videoFile.path), + tester, + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('video_error_placeholder')), findsOneWidget); + expect(find.byKey(const Key('video_player')), findsNothing); + }); + + testWidgets('hides controls when showControls is false', (tester) async { + await mountWidget( + LocalVideoPlayer(filePath: videoFile.path, showControls: false), + tester, + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('video_play_indicator')), findsNothing); + }); + + testWidgets('disposes and reinitializes when filePath changes', (tester) async { + final secondVideoFile = File('${tempDir.path}/clip2.mp4')..writeAsBytesSync([0, 0, 0, 0]); + var currentPath = videoFile.path; + late StateSetter setState; + + await mountWidget( + StatefulBuilder( + builder: (context, setStateCallback) { + setState = setStateCallback; + return LocalVideoPlayer(filePath: currentPath); + }, + ), + tester, + ); + await tester.pumpAndSettle(); + + setState(() => currentPath = secondVideoFile.path); + await tester.pumpAndSettle(); + await pumpUntil( + tester, + () => fakeVideoPlatform.calls.where((call) => call == 'createWithOptions').length == 2, + ); + + expect(fakeVideoPlatform.calls.where((call) => call == 'createWithOptions'), hasLength(2)); + final firstCreateIndex = fakeVideoPlatform.calls.indexOf('createWithOptions'); + final disposeIndex = fakeVideoPlatform.calls.indexOf('dispose'); + final secondCreateIndex = fakeVideoPlatform.calls.lastIndexOf('createWithOptions'); + + expect(disposeIndex, greaterThan(firstCreateIndex)); + expect(disposeIndex, lessThan(secondCreateIndex)); + expect(fakeVideoPlatform.dataSources.last.uri, contains('clip2.mp4')); + }); + }); + + group('VideoPlayIndicator', () { + testWidgets('honors visible and size parameters', (tester) async { + await mountWidget( + const VideoPlayIndicator(visible: false, size: 32), + tester, + ); + + final opacity = tester.widget(find.byType(AnimatedOpacity)); + final container = tester.widget(find.byType(Container)); + + expect(opacity.opacity, 0); + expect(container.constraints?.maxWidth, 32); + }); + }); +} diff --git a/test/widgets/media_modal_test.dart b/test/widgets/media_modal_test.dart index cbf005a..5abc559 100644 --- a/test/widgets/media_modal_test.dart +++ b/test/widgets/media_modal_test.dart @@ -4,21 +4,28 @@ import 'package:whitenoise/src/rust/api/media_files.dart'; import 'package:whitenoise/src/rust/frb_generated.dart'; import 'package:whitenoise/widgets/media_image.dart'; import 'package:whitenoise/widgets/media_modal.dart'; +import 'package:whitenoise/widgets/media_video.dart'; import 'package:whitenoise/widgets/wn_avatar.dart'; import 'package:whitenoise/widgets/wn_overlay.dart'; import '../mocks/mock_wn_api.dart'; import '../test_helpers.dart'; -MediaFile _mediaFile(String id, {String filePath = '', String? blurhash}) => MediaFile( +MediaFile _mediaFile( + String id, { + String filePath = '', + String? blurhash, + String mimeType = 'image/jpeg', + String mediaType = 'image', +}) => MediaFile( id: id, mlsGroupId: testGroupId, accountPubkey: testPubkeyA, filePath: filePath, originalFileHash: 'hash$id', encryptedFileHash: 'encrypted$id', - mimeType: 'image/jpeg', - mediaType: 'image', + mimeType: mimeType, + mediaType: mediaType, blossomUrl: 'https://example.com/$id', nostrKey: 'nostr$id', createdAt: DateTime(2024), @@ -71,6 +78,33 @@ void main() { expect(find.byKey(const Key('media_thumbnail_strip')), findsNothing); }); + testWidgets('renders video media with video viewer', (tester) async { + await mountWidget( + Builder( + builder: (context) => ElevatedButton( + onPressed: () => MediaModal.show( + context: context, + mediaFiles: [ + _mediaFile( + '1', + mimeType: 'video/mp4', + mediaType: 'video', + ), + ], + ), + child: const Text('Open'), + ), + ), + tester, + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.byType(MediaVideo), findsOneWidget); + expect(find.byKey(const Key('media_image_0')), findsNothing); + }); + testWidgets('renders thumbnail strip for multiple media', (tester) async { await mountWidget( Builder( diff --git a/test/widgets/media_video_test.dart b/test/widgets/media_video_test.dart new file mode 100644 index 0000000..dda54cf --- /dev/null +++ b/test/widgets/media_video_test.dart @@ -0,0 +1,105 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:whitenoise/src/rust/api/media_files.dart'; +import 'package:whitenoise/src/rust/frb_generated.dart'; +import 'package:whitenoise/widgets/media_video.dart'; + +import '../fakes/fake_video_player_platform.dart'; +import '../mocks/mock_wn_api.dart'; +import '../test_helpers.dart'; + +MediaFile _mediaFile({ + String id = 'media1', + String filePath = '', + String? originalFileHash = 'hash123', + String? blurhash, +}) => MediaFile( + id: id, + mlsGroupId: testGroupId, + accountPubkey: testPubkeyA, + filePath: filePath, + originalFileHash: originalFileHash, + encryptedFileHash: 'encrypted123', + mimeType: 'video/mp4', + mediaType: 'video', + blossomUrl: 'https://example.com/media', + nostrKey: 'nostr123', + createdAt: DateTime(2024), + fileMetadata: blurhash != null ? FileMetadata(blurhash: blurhash) : null, +); + +class _MockApi extends MockWnApi { + Completer? downloadCompleter; + bool shouldFail = false; + + @override + Future crateApiMediaFilesDownloadChatMedia({ + required String accountPubkey, + required String groupId, + required String originalFileHash, + }) async { + if (shouldFail) throw Exception('Download failed'); + if (downloadCompleter != null) return downloadCompleter!.future; + return _mediaFile(filePath: '/downloaded/path.mp4'); + } +} + +final _api = _MockApi(); + +void main() { + setUpAll(() => RustLib.initMock(api: _api)); + + setUp(() { + _api.downloadCompleter = null; + _api.shouldFail = false; + }); + + group('MediaVideo', () { + testWidgets('shows loading placeholder while downloading', (tester) async { + _api.downloadCompleter = Completer(); + + await mountWidget( + MediaVideo( + mediaFile: _mediaFile(blurhash: 'LEHV6nWB2yk8pyo0adR*.7kCMdnj'), + ), + tester, + ); + + expect(find.byKey(const Key('media_video_loading')), findsOneWidget); + expect(find.byKey(const Key('blurhash_placeholder')), findsOneWidget); + }); + + testWidgets('shows error placeholder when download fails', (tester) async { + _api.shouldFail = true; + + await mountWidget( + MediaVideo(mediaFile: _mediaFile()), + tester, + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('media_video_error')), findsOneWidget); + expect(find.byKey(const Key('media_video_player')), findsNothing); + }); + + testWidgets('shows local video player when file exists locally', (tester) async { + setUpFakeVideoPlayerPlatform(); + final tempDir = Directory.systemTemp.createTempSync('media_video_test'); + final tempFile = File('${tempDir.path}/test.mp4'); + tempFile.writeAsBytesSync([0, 0, 0, 0]); + addTearDown(() => tempDir.deleteSync(recursive: true)); + + await mountWidget( + MediaVideo(mediaFile: _mediaFile(filePath: tempFile.path)), + tester, + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('media_video_player')), findsOneWidget); + expect(find.byKey(const Key('video_player')), findsOneWidget); + }); + }); +} diff --git a/test/widgets/wn_media_upload_preview_test.dart b/test/widgets/wn_media_upload_preview_test.dart index 259038e..eb08721 100644 --- a/test/widgets/wn_media_upload_preview_test.dart +++ b/test/widgets/wn_media_upload_preview_test.dart @@ -6,16 +6,20 @@ import 'package:whitenoise/hooks/use_media_upload.dart' show MediaUploadItem, Me import 'package:whitenoise/widgets/wn_media_upload_preview.dart'; import 'package:whitenoise/widgets/wn_spinner.dart'; +import '../fakes/fake_video_player_platform.dart'; import '../test_helpers.dart'; void main() { late Directory tempDir; late File testImageFile; + late File testVideoFile; setUpAll(() { tempDir = Directory.systemTemp.createTempSync('media_preview_test'); testImageFile = File('${tempDir.path}/test.jpg'); testImageFile.writeAsBytesSync([0xFF, 0xD8, 0xFF, 0xE0]); + testVideoFile = File('${tempDir.path}/test.mp4'); + testVideoFile.writeAsBytesSync([0, 0, 0, 0]); }); tearDownAll(() { @@ -79,6 +83,25 @@ void main() { expect(find.byKey(Key('thumbnail_${testImageFile.path}')), findsOneWidget); }); + testWidgets('displays video thumbnail for video items', (tester) async { + setUpFakeVideoPlayerPlatform(); + + await mountWidget( + WnMediaUploadPreview( + items: [ + createItem(filePath: testVideoFile.path, status: MediaUploadStatus.uploaded), + ], + onRemove: (_) {}, + onAddMore: () {}, + ), + tester, + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('thumbnail_video_player')), findsOneWidget); + expect(find.byKey(const Key('thumbnail_video_indicator')), findsOneWidget); + }); + testWidgets('displays multiple thumbnails', (tester) async { final file1 = File('${tempDir.path}/test1.jpg')..writeAsBytesSync([0xFF, 0xD8, 0xFF, 0xE0]); final file2 = File('${tempDir.path}/test2.jpg')..writeAsBytesSync([0xFF, 0xD8, 0xFF, 0xE0]); diff --git a/test/widgets/wn_message_quote_test.dart b/test/widgets/wn_message_quote_test.dart index 904bd7c..c08b882 100644 --- a/test/widgets/wn_message_quote_test.dart +++ b/test/widgets/wn_message_quote_test.dart @@ -115,6 +115,19 @@ void main() { expect(find.byKey(const Key('quote_thumbnail')), findsOneWidget); }); + testWidgets('shows thumbnail when mediaThumbnail is provided', (tester) async { + await mountWidget( + const WnMessageQuote( + author: 'Alice', + text: 'Hello', + mediaThumbnail: ColoredBox(color: Colors.black), + ), + tester, + ); + + expect(find.byKey(const Key('quote_thumbnail')), findsOneWidget); + }); + testWidgets('hides thumbnail when image is null', (tester) async { await mountWidget( const WnMessageQuote(author: 'Alice', text: 'Hello'), diff --git a/widgetbook/macos/Flutter/GeneratedPluginRegistrant.swift b/widgetbook/macos/Flutter/GeneratedPluginRegistrant.swift index 6830c4e..4cae43e 100644 --- a/widgetbook/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/widgetbook/macos/Flutter/GeneratedPluginRegistrant.swift @@ -16,6 +16,7 @@ import share_plus import shared_preferences_foundation import sqflite_darwin import url_launcher_macos +import video_player_avfoundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) @@ -29,4 +30,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) } diff --git a/widgetbook/pubspec.lock b/widgetbook/pubspec.lock index 5a05308..9439ed5 100644 --- a/widgetbook/pubspec.lock +++ b/widgetbook/pubspec.lock @@ -233,6 +233,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" cupertino_icons: dependency: transitive description: @@ -581,6 +589,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.1" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: transitive description: @@ -1465,6 +1481,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + video_player: + dependency: transitive + description: + name: video_player + sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: "877a6c7ba772456077d7bfd71314629b3fe2b73733ce503fc77c3314d43a0ca0" + url: "https://pub.dev" + source: hosted + version: "2.9.5" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: af0e5b8a7a4876fb37e7cc8cb2a011e82bb3ecfa45844ef672e32cb14a1f259e + url: "https://pub.dev" + source: hosted + version: "2.9.4" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec" + url: "https://pub.dev" + source: hosted + version: "6.6.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.dev" + source: hosted + version: "2.4.0" vm_service: dependency: transitive description: