Skip to content

Commit cac2d42

Browse files
content: Implement inline video preview
Fixes: #356
1 parent 63f65a4 commit cac2d42

File tree

4 files changed

+416
-12
lines changed

4 files changed

+416
-12
lines changed

lib/widgets/content.dart

Lines changed: 134 additions & 7 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';
@@ -99,13 +102,7 @@ class BlockContentList extends StatelessWidget {
99102
);
100103
return MessageImage(node: node);
101104
} else if (node is InlineVideoNode) {
102-
return Text.rich(
103-
TextSpan(children: [
104-
const TextSpan(text: "(unimplemented:", style: errorStyle),
105-
TextSpan(text: node.debugHtmlText, style: errorCodeStyle),
106-
const TextSpan(text: ")", style: errorStyle),
107-
]),
108-
style: errorStyle);
105+
return MessageInlineVideo(node: node);
109106
} else if (node is EmbedVideoNode) {
110107
return MessageEmbedVideo(node: node);
111108
} else if (node is UnimplementedBlockContentNode) {
@@ -397,6 +394,136 @@ class MessageImage extends StatelessWidget {
397394
}
398395
}
399396

397+
class MessageInlineVideo extends StatefulWidget {
398+
const MessageInlineVideo({super.key, required this.node});
399+
400+
final InlineVideoNode node;
401+
402+
@override
403+
State<MessageInlineVideo> createState() => _MessageInlineVideoState();
404+
}
405+
406+
class _MessageInlineVideoState extends State<MessageInlineVideo> {
407+
Uri? _resolvedSrcUrl;
408+
409+
VideoPlayerController? _controller;
410+
bool _didAttemptInitialization = false;
411+
bool _hasNonPlatformError = false;
412+
413+
@override
414+
void initState() {
415+
// We delay initialization by a single frame to make sure the
416+
// BuildContext is a valid context as its needed to retrieve
417+
// the PerAccountStore during initialization.
418+
SchedulerBinding.instance.addPostFrameCallback((_) => _initialize());
419+
super.initState();
420+
}
421+
422+
Future<void> _initialize() async {
423+
try {
424+
final store = PerAccountStoreWidget.of(context);
425+
_resolvedSrcUrl = store.tryResolveUrl(widget.node.srcUrl);
426+
if (_resolvedSrcUrl == null) {
427+
return; // TODO(log)
428+
}
429+
430+
assert(debugLog('VideoPlayerController.networkUrl($_resolvedSrcUrl)'));
431+
_controller = VideoPlayerController.networkUrl(_resolvedSrcUrl!, httpHeaders: {
432+
if (_resolvedSrcUrl!.origin == store.account.realmUrl.origin) ...authHeader(
433+
email: store.account.email,
434+
apiKey: store.account.apiKey,
435+
),
436+
...userAgentHeader()
437+
});
438+
439+
await _controller!.initialize();
440+
_controller!.addListener(_handleVideoControllerUpdates);
441+
} on PlatformException catch (error) {
442+
// VideoPlayerController.initialize throws a PlatformException
443+
// if the provide video source is unsupported by the player.
444+
// In which case we fallback to opening the video externally
445+
// when user clicks on the play button, precondition is determined
446+
// by '_controller.value.hasError'.
447+
assert(debugLog("PlatformException: VideoPlayerController.initialize failed: $error"));
448+
} catch (error) {
449+
_hasNonPlatformError = true;
450+
assert(debugLog("VideoPlayerController.initialize failed: $error"));
451+
} finally {
452+
if (mounted) setState(() { _didAttemptInitialization = true; });
453+
}
454+
}
455+
456+
@override
457+
void dispose() {
458+
_controller?.removeListener(_handleVideoControllerUpdates);
459+
_controller?.dispose();
460+
super.dispose();
461+
}
462+
463+
void _handleVideoControllerUpdates() {
464+
assert(debugLog("Video buffered: ${_controller?.value.buffered}"));
465+
assert(debugLog("Video max duration: ${_controller?.value.duration}"));
466+
}
467+
468+
@override
469+
Widget build(BuildContext context) {
470+
final message = InheritedMessage.of(context);
471+
472+
return MessageMediaContainer(
473+
onTap: !_didAttemptInitialization
474+
? null
475+
: () { // TODO(log)
476+
if (_resolvedSrcUrl == null || _hasNonPlatformError) {
477+
showErrorDialog(context: context,
478+
title: 'Unable to open video',
479+
message: 'Video could not be opened: $_resolvedSrcUrl');
480+
return;
481+
}
482+
483+
if (_controller!.value.hasError) {
484+
// TODO use webview instead, to support auth headers
485+
_launchUrl(context, widget.node.srcUrl);
486+
} else {
487+
Navigator.of(context).push(getLightboxRoute(
488+
context: context,
489+
message: message,
490+
src: _resolvedSrcUrl!,
491+
videoController: _controller,
492+
));
493+
}
494+
},
495+
child: !_didAttemptInitialization
496+
? Container(
497+
color: Colors.black,
498+
child: const Center(
499+
child: SizedBox(
500+
height: 16,
501+
width: 16,
502+
child: CircularProgressIndicator(
503+
color: Colors.white,
504+
strokeWidth: 2))))
505+
: Stack(
506+
alignment: Alignment.center,
507+
children: [
508+
if (_resolvedSrcUrl == null || _controller!.value.hasError)
509+
Container(color: Colors.black)
510+
else
511+
LightboxHero(
512+
message: message,
513+
src: _resolvedSrcUrl!,
514+
child: AspectRatio(
515+
aspectRatio: _controller!.value.aspectRatio,
516+
child: VideoPlayer(_controller!))),
517+
const Icon(
518+
Icons.play_arrow_rounded,
519+
color: Colors.white,
520+
size: 32)
521+
]));
522+
}
523+
}
524+
525+
/// MessageEmbedVideo opens the video href externally, and
526+
/// a preview image is visible in the content message UI.
400527
class MessageEmbedVideo extends StatelessWidget {
401528
const MessageEmbedVideo({super.key, required this.node});
402529

0 commit comments

Comments
 (0)