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
+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'),