feat: add video media attachment support (#574)

This commit is contained in:
Lee Salminen
2026-04-22 12:53:43 -04:00
committed by GitHub
parent ca85425958
commit 757cd9553c
49 changed files with 1333 additions and 111 deletions
+8 -8
View File
@@ -21,7 +21,7 @@ typedef MediaUploadState = ({
List<MediaUploadItem> items,
bool canSend,
List<MediaFile> uploadedFiles,
Future<void> Function() pickImages,
Future<void> Function() pickMedia,
void Function(String filePath) removeItem,
VoidCallback clearAll,
});
@@ -106,27 +106,27 @@ MediaUploadState useMediaUpload({
}
}
Future<void> pickImages() async {
_logger.info('pickImages groupId=$groupId');
final pickedFiles = await picker.pickMultiImage(
Future<void> 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,
);
+1
View File
@@ -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.",
+9
View File
@@ -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"
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -1,5 +1,6 @@
{
"photoCount": "{count, plural, =1{Фото} other{Фото}}",
"mediaCount": "{count, plural, =1{Медиа} other{Медиа}}",
"@@locale": "ru",
"appTitle": "White Noise",
"sloganFull": "Децентрализованный, Нецензурируемый,\nБезопасный Мессенджер.",
+1
View File
@@ -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.",
@@ -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:
@@ -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';
@@ -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';
@@ -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';
@@ -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';
@@ -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';
@@ -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';
@@ -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';
@@ -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';
+1 -1
View File
@@ -824,7 +824,7 @@ class _ChatInput extends StatelessWidget {
actionsEnabled: actionsEnabled,
onAddTap: () {
input.focusNode.unfocus();
mediaUpload.pickImages();
mediaUpload.pickMedia();
},
inputField: TextField(
controller: input.controller,
+25
View File
@@ -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;
}
+1 -1
View File
@@ -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'),
+29 -13
View File
@@ -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')),
],
);
}
+24 -2
View File
@@ -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,
+29 -8
View File
@@ -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')),
],
),
);
+22 -1
View File
@@ -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),
],
);
}
}
+185
View File
@@ -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<VideoPlayerController?>(null);
final controllerRef = useRef<VideoPlayerController?>(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<void> 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<void> 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,
),
),
),
),
);
}
}
+10 -1
View File
@@ -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,
);
},
+49
View File
@@ -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,
);
}
}
+60 -18
View File
@@ -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;
+11 -5
View File
@@ -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!,
),
),
],
@@ -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"))
}
+40
View File
@@ -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:
+2
View File
@@ -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
+118
View File
@@ -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 = <String>[];
final dataSources = <DataSource>[];
final streams = <int, StreamController<VideoEvent>>{};
bool forceInitError = false;
int nextPlayerId = 0;
@override
Future<void> init() async {
calls.add('init');
}
@override
Future<int?> createWithOptions(VideoCreationOptions options) async {
calls.add('createWithOptions');
final playerId = nextPlayerId++;
final stream = StreamController<VideoEvent>();
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<VideoEvent> videoEventsFor(int playerId) {
return streams[playerId]!.stream;
}
@override
Future<void> dispose(int playerId) async {
calls.add('dispose');
final controller = streams.remove(playerId);
await controller?.close();
}
@override
Future<void> setLooping(int playerId, bool looping) async {
calls.add('setLooping');
}
@override
Future<void> play(int playerId) async {
calls.add('play');
streams[playerId]?.add(
VideoEvent(
eventType: VideoEventType.isPlayingStateUpdate,
isPlaying: true,
),
);
}
@override
Future<void> pause(int playerId) async {
calls.add('pause');
streams[playerId]?.add(
VideoEvent(
eventType: VideoEventType.isPlayingStateUpdate,
isPlaying: false,
),
);
}
@override
Future<void> setVolume(int playerId, double volume) async {
calls.add('setVolume');
}
@override
Future<void> setPlaybackSpeed(int playerId, double speed) async {
calls.add('setPlaybackSpeed');
}
@override
Future<Duration> getPosition(int playerId) async {
calls.add('getPosition');
return Duration.zero;
}
@override
Future<void> 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;
}
+43 -31
View File
@@ -26,7 +26,7 @@ class _MockImagePicker extends ImagePicker {
int pickCallCount = 0;
@override
Future<List<XFile>> pickMultiImage({
Future<List<XFile>> 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<MediaFile>();
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<MediaFile>();
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);
+6 -1
View File
@@ -44,6 +44,11 @@ class _MockImagePickerPlatform extends ImagePickerPlatform with MockPlatformInte
}) async {
return filesToReturn;
}
@override
Future<List<XFile>> 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);
+57
View File
@@ -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);
});
});
}
+12 -12
View File
@@ -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<WnChatListItem>(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<WnChatListItem>(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<WnChatListItem>(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<WnChatListItem>(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<WnChatListItem>(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<WnChatListItem>(finder);
expect(item.subtitle, 'Photos');
expect(item.subtitle, 'Media items');
expect(item.subtitleIcon, isNotNull);
expect(find.byKey(const Key('media_subtitle_icon')), findsOneWidget);
});
+31 -2
View File
@@ -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<MediaFile>();
final tempDir = Directory.systemTemp.createTempSync('thumbnail_fade_test');
@@ -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(
+31 -2
View File
@@ -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<MediaFile>();
await mountWidget(
+22 -2
View File
@@ -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()),
+149
View File
@@ -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<void> pumpUntil(
WidgetTester tester,
bool Function() condition,
) async {
await tester.runAsync(() async {
for (var i = 0; i < 20; i++) {
if (condition()) return;
await Future<void>.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<AnimatedOpacity>(find.byType(AnimatedOpacity));
final container = tester.widget<Container>(find.byType(Container));
expect(opacity.opacity, 0);
expect(container.constraints?.maxWidth, 32);
});
});
}
+37 -3
View File
@@ -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(
+105
View File
@@ -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<MediaFile>? downloadCompleter;
bool shouldFail = false;
@override
Future<MediaFile> 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<MediaFile>();
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);
});
});
}
@@ -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]);
+13
View File
@@ -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'),
@@ -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"))
}
+56
View File
@@ -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: