feat: add video media attachment support (#574)
This commit is contained in:
@@ -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,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,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,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,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,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,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,5 +1,6 @@
|
||||
{
|
||||
"photoCount": "{count, plural, =1{Фото} other{Фото}}",
|
||||
"mediaCount": "{count, plural, =1{Медиа} other{Медиа}}",
|
||||
"@@locale": "ru",
|
||||
"appTitle": "White Noise",
|
||||
"sloganFull": "Децентрализованный, Нецензурируемый,\nБезопасный Мессенджер.",
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -824,7 +824,7 @@ class _ChatInput extends StatelessWidget {
|
||||
actionsEnabled: actionsEnabled,
|
||||
onAddTap: () {
|
||||
input.focusNode.unfocus();
|
||||
mediaUpload.pickImages();
|
||||
mediaUpload.pickMedia();
|
||||
},
|
||||
inputField: TextField(
|
||||
controller: input.controller,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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')),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user