Skip to content

Commit 41e4d3b

Browse files
content: Implement embed video preview
Partially implements zulip#356, provides video thumbnail previews for embedded external videos (Youtube & Vimeo).
1 parent 5bf37c2 commit 41e4d3b

File tree

2 files changed

+87
-7
lines changed

2 files changed

+87
-7
lines changed

lib/widgets/content.dart

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,13 +107,7 @@ class BlockContentList extends StatelessWidget {
107107
]),
108108
style: errorStyle);
109109
} else if (node is EmbedVideoNode) {
110-
return Text.rich(
111-
TextSpan(children: [
112-
const TextSpan(text: "(unimplemented:", style: errorStyle),
113-
TextSpan(text: node.debugHtmlText, style: errorCodeStyle),
114-
const TextSpan(text: ")", style: errorStyle),
115-
]),
116-
style: errorStyle);
110+
return MessageEmbedVideo(node: node);
117111
} else if (node is UnimplementedBlockContentNode) {
118112
return Text.rich(_errorUnimplemented(node));
119113
} else {
@@ -404,6 +398,35 @@ class MessageImage extends StatelessWidget {
404398
}
405399
}
406400

401+
class MessageEmbedVideo extends StatelessWidget {
402+
const MessageEmbedVideo({super.key, required this.node});
403+
404+
final EmbedVideoNode node;
405+
406+
@override
407+
Widget build(BuildContext context) {
408+
final store = PerAccountStoreWidget.of(context);
409+
final previewImageSrcUrl = store.tryResolveUrl(node.previewImageSrcUrl);
410+
411+
return MessageMediaContainer(
412+
onTap: () => _launchUrl(context, node.hrefUrl),
413+
child: Stack(
414+
alignment: Alignment.center,
415+
children: [
416+
if (previewImageSrcUrl != null) // TODO(log)
417+
RealmContentNetworkImage(
418+
previewImageSrcUrl,
419+
filterQuality: FilterQuality.medium),
420+
// Show the "play" icon even when previewImageSrcUrl didn't resolve;
421+
// the action uses hrefUrl, which might still work.
422+
const Icon(
423+
Icons.play_arrow_rounded,
424+
color: Colors.white,
425+
size: 32),
426+
]));
427+
}
428+
}
429+
407430
class MessageMediaContainer extends StatelessWidget {
408431
const MessageMediaContainer({
409432
super.key,

test/widgets/content_test.dart

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,63 @@ void main() {
328328
});
329329
});
330330

331+
group("MessageEmbedVideo", () {
332+
Future<void> prepareContent(WidgetTester tester, String html) async {
333+
addTearDown(testBinding.reset);
334+
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
335+
prepareBoringImageHttpClient();
336+
337+
await tester.pumpWidget(GlobalStoreWidget(child: MaterialApp(
338+
home: PerAccountStoreWidget(accountId: eg.selfAccount.id,
339+
child: MessageContent(
340+
message: eg.streamMessage(content: html),
341+
content: parseContent(html))))));
342+
await tester.pump(); // global store
343+
await tester.pump(); // per-account store
344+
debugNetworkImageHttpClientProvider = null;
345+
}
346+
347+
testWidgets('video preview for youtube embed', (tester) async {
348+
const example = ContentExample.videoEmbedYoutube;
349+
await prepareContent(tester, example.html);
350+
351+
final expectedVideo = example.expectedNodes[1] as EmbedVideoNode;
352+
final expectedResolvedPreviewUrl = eg
353+
.store()
354+
.tryResolveUrl(expectedVideo.previewImageSrcUrl)!;
355+
final image = tester.widget<RealmContentNetworkImage>(
356+
find.byType(RealmContentNetworkImage));
357+
check(image.src)
358+
.equals(expectedResolvedPreviewUrl);
359+
360+
final expectedLaunchUrl = expectedVideo.hrefUrl;
361+
await tester.tap(find.byIcon(Icons.play_arrow_rounded));
362+
check(testBinding.takeLaunchUrlCalls())
363+
.single.equals((url: Uri.parse(expectedLaunchUrl), mode: LaunchMode.platformDefault));
364+
});
365+
366+
testWidgets('video preview for vimeo embed', (tester) async {
367+
const example = ContentExample.videoEmbedVimeo;
368+
await prepareContent(tester, example.html);
369+
370+
final expectedTitle = (((example.expectedNodes[0] as ParagraphNode).nodes[0] as LinkNode).nodes[0] as TextNode).text;
371+
await tester.ensureVisible(find.text(expectedTitle));
372+
373+
final expectedVideo = example.expectedNodes[1] as EmbedVideoNode;
374+
final expectedResolvedUrl = eg.store().tryResolveUrl(expectedVideo.previewImageSrcUrl)!;
375+
final image = tester.widget<RealmContentNetworkImage>(
376+
find.byType(RealmContentNetworkImage));
377+
check(image.src)
378+
.equals(expectedResolvedUrl);
379+
380+
final expectedLaunchUrl = expectedVideo.hrefUrl;
381+
await tester.tap(find.byIcon(Icons.play_arrow_rounded));
382+
check(testBinding.takeLaunchUrlCalls())
383+
.single.equals((url: Uri.parse(expectedLaunchUrl), mode: LaunchMode.platformDefault));
384+
});
385+
});
386+
387+
331388
group("CodeBlock", () {
332389
testContentSmoke(ContentExample.codeBlockPlain);
333390
testContentSmoke(ContentExample.codeBlockHighlightedShort);

0 commit comments

Comments
 (0)