Skip to content

Commit bf4e818

Browse files
content: Handle video previews
Implement thumbnail image based video previews for Youtube & Vimeo video links, and inline video player preview using the video_player package for user uploaded videos. For user uploaded video, current implementation will fetch the metadata when the message containing the video comes into view. This metadata is used by the player to determine if video is supported on the device. If it isn't then there will be no preview, and tapping on the play button will open the video externally. If the video is supported, then the first frame of the video will be presented as a preview in the message container while tapping on the play button will start buffering and playing the video in the lightbox. There are still some quirks with the current implementation: - On iOS/macOS, there is a bug where whole video is downloaded before playing: flutter/flutter#126760 - On iOS/macOS, unlike on Android the first frame is not shown after initialization: flutter/flutter#139107 - Current implementation uses url_launcher for fallback in case video is not supported by video_player, we should switch to webview instead to correctly handle auth headers for private videos. Fixes #356
1 parent f80a686 commit bf4e818

File tree

3 files changed

+466
-7
lines changed

3 files changed

+466
-7
lines changed

lib/widgets/content.dart

Lines changed: 187 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import 'package:flutter/foundation.dart';
22
import 'package:flutter/gestures.dart';
33
import 'package:flutter/material.dart';
4+
import 'package:flutter/scheduler.dart';
45
import 'package:flutter/services.dart';
56
import 'package:html/dom.dart' as dom;
67
import 'package:intl/intl.dart';
78
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
9+
import 'package:video_player/video_player.dart';
810

911
import '../api/core.dart';
1012
import '../api/model/model.dart';
13+
import '../log.dart';
1114
import '../model/avatar_url.dart';
1215
import '../model/binding.dart';
1316
import '../model/content.dart';
@@ -90,14 +93,14 @@ class BlockContentList extends StatelessWidget {
9093
return MathBlock(node: node);
9194
} else if (node is ImageNodeList) {
9295
return MessageImageList(node: node);
93-
} else if (node is VideoNode) {
94-
return Container();
9596
} else if (node is ImageNode) {
9697
assert(false,
9798
"[ImageNode] not allowed in [BlockContentList]. "
9899
"It should be wrapped in [ImageNodeList]."
99100
);
100101
return MessageImage(node: node);
102+
} else if (node is VideoNode) {
103+
return MessageVideo(node: node);
101104
} else if (node is UnimplementedBlockContentNode) {
102105
return Text.rich(_errorUnimplemented(node));
103106
} else {
@@ -384,6 +387,188 @@ class MessageImage extends StatelessWidget {
384387
}
385388
}
386389

390+
class MessageVideo extends StatelessWidget {
391+
const MessageVideo({super.key, required this.node});
392+
393+
final VideoNode node;
394+
395+
@override
396+
Widget build(BuildContext context) {
397+
final store = PerAccountStoreWidget.of(context);
398+
399+
// For YouTube and Vimeo links, display a widget with a thumbnail.
400+
// When the thumbnail is tapped, open the video link in an external browser or
401+
// a supported external app.
402+
if (node.previewImageUrl != null) {
403+
return MessageEmbedVideoPreview(
404+
src: node.srcUrl, previewImage: node.previewImageUrl!);
405+
}
406+
407+
final resolvedSrc = store.tryResolveUrl(node.srcUrl);
408+
return resolvedSrc != null
409+
? MessageInlineVideoPreview(src: resolvedSrc)
410+
: Container();
411+
}
412+
}
413+
414+
class MessageEmbedVideoPreview extends StatelessWidget {
415+
const MessageEmbedVideoPreview({
416+
super.key,
417+
required this.previewImage,
418+
required this.src,
419+
});
420+
421+
final String previewImage;
422+
final String src;
423+
424+
@override
425+
Widget build(BuildContext context) {
426+
final store = PerAccountStoreWidget.of(context);
427+
final previewImageUrl = store.tryResolveUrl(previewImage);
428+
429+
return GestureDetector(
430+
onTap: () {
431+
_launchUrl(context, src);
432+
},
433+
child: UnconstrainedBox(
434+
alignment: Alignment.centerLeft,
435+
child: Padding(
436+
// TODO clean up this padding by imitating web less precisely;
437+
// in particular, avoid adding loose whitespace at end of message.
438+
padding: const EdgeInsets.only(right: 5, bottom: 5),
439+
child: Container(
440+
height: 100,
441+
width: 150,
442+
color: Colors.black,
443+
child: Stack(
444+
alignment: Alignment.center,
445+
children: [
446+
if (previewImageUrl != null) ...[
447+
RealmContentNetworkImage(
448+
previewImageUrl,
449+
filterQuality: FilterQuality.medium),
450+
Container(color: const Color.fromRGBO(0, 0, 0, 0.30)),
451+
],
452+
const Icon(
453+
Icons.play_arrow_rounded,
454+
color: Colors.white,
455+
size: 25),
456+
])))));
457+
}
458+
}
459+
460+
class MessageInlineVideoPreview extends StatefulWidget {
461+
const MessageInlineVideoPreview({super.key, required this.src});
462+
463+
final Uri src;
464+
465+
@override
466+
State<MessageInlineVideoPreview> createState() => _MessageInlineVideoPreviewState();
467+
}
468+
469+
class _MessageInlineVideoPreviewState extends State<MessageInlineVideoPreview> {
470+
VideoPlayerController? _controller;
471+
bool _initialized = false;
472+
473+
@override
474+
void initState() {
475+
SchedulerBinding.instance.addPostFrameCallback((_) {
476+
_asyncInitState();
477+
});
478+
super.initState();
479+
}
480+
481+
Future<void> _asyncInitState() async {
482+
try {
483+
final store = PerAccountStoreWidget.of(context);
484+
assert(debugLog('VideoPlayerController.networkUrl(${widget.src})'));
485+
_controller = VideoPlayerController.networkUrl(widget.src, httpHeaders: {
486+
if (widget.src.origin == store.account.realmUrl.origin) ...authHeader(
487+
email: store.account.email,
488+
apiKey: store.account.apiKey,
489+
),
490+
...userAgentHeader()
491+
});
492+
493+
await _controller!.initialize();
494+
_controller!.addListener(_handleVideoControllerUpdates);
495+
} catch (error) {
496+
assert(debugLog("VideoPlayerController.initialize failed: $error"));
497+
} finally {
498+
if (mounted) {
499+
setState(() { _initialized = true; });
500+
}
501+
}
502+
}
503+
504+
@override
505+
void dispose() {
506+
_controller?.removeListener(_handleVideoControllerUpdates);
507+
_controller?.dispose();
508+
super.dispose();
509+
}
510+
511+
void _handleVideoControllerUpdates() {
512+
assert(debugLog("Video buffered: ${_controller?.value.buffered}"));
513+
assert(debugLog("Video max duration: ${_controller?.value.duration}"));
514+
}
515+
516+
@override
517+
Widget build(BuildContext context) {
518+
final message = InheritedMessage.of(context);
519+
520+
return GestureDetector(
521+
onTap: !_initialized
522+
? null
523+
: () { // TODO(log)
524+
if (_controller!.value.hasError) {
525+
// TODO use webview instead, to support auth headers
526+
_launchUrl(context, widget.src.toString());
527+
} else {
528+
Navigator.of(context).push(getLightboxRoute(
529+
context: context,
530+
message: message,
531+
src: widget.src,
532+
videoController: _controller,
533+
));
534+
}
535+
},
536+
child: UnconstrainedBox(
537+
alignment: Alignment.centerLeft,
538+
child: Padding(
539+
// TODO clean up this padding by imitating web less precisely;
540+
// in particular, avoid adding loose whitespace at end of message.
541+
padding: const EdgeInsets.only(right: 5, bottom: 5),
542+
child: LightboxHero(
543+
message: message,
544+
src: widget.src,
545+
child: Container(
546+
height: 100,
547+
width: 150,
548+
color: Colors.black,
549+
child: Stack(
550+
alignment: Alignment.center,
551+
children: [
552+
if (_initialized && !_controller!.value.hasError) ...[
553+
AspectRatio(
554+
aspectRatio: _controller!.value.aspectRatio,
555+
child: VideoPlayer(_controller!)),
556+
Container(color: const Color.fromRGBO(0, 0, 0, 0.30)),
557+
],
558+
if (_initialized)
559+
const Icon(
560+
Icons.play_arrow_rounded,
561+
color: Colors.white,
562+
size: 25)
563+
else
564+
const SizedBox(
565+
height: 14,
566+
width: 14,
567+
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)),
568+
]))))));
569+
}
570+
}
571+
387572
class CodeBlock extends StatelessWidget {
388573
const CodeBlock({super.key, required this.node});
389574

0 commit comments

Comments
 (0)