Skip to content

Commit

Permalink
content: Implement inline video preview
Browse files Browse the repository at this point in the history
Fixes: zulip#356
  • Loading branch information
rajveermalviya committed Apr 6, 2024
1 parent d8dd5e3 commit 05a4aa5
Show file tree
Hide file tree
Showing 4 changed files with 416 additions and 12 deletions.
141 changes: 134 additions & 7 deletions lib/widgets/content.dart
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:html/dom.dart' as dom;
import 'package:intl/intl.dart';
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
import 'package:video_player/video_player.dart';

import '../api/core.dart';
import '../api/model/model.dart';
import '../log.dart';
import '../model/avatar_url.dart';
import '../model/binding.dart';
import '../model/content.dart';
Expand Down Expand Up @@ -99,13 +102,7 @@ class BlockContentList extends StatelessWidget {
);
return MessageImage(node: node);
} else if (node is InlineVideoNode) {
return Text.rich(
TextSpan(children: [
const TextSpan(text: "(unimplemented:", style: errorStyle),
TextSpan(text: node.debugHtmlText, style: errorCodeStyle),
const TextSpan(text: ")", style: errorStyle),
]),
style: errorStyle);
return MessageInlineVideo(node: node);
} else if (node is EmbedVideoNode) {
return MessageEmbedVideo(node: node);
} else if (node is UnimplementedBlockContentNode) {
Expand Down Expand Up @@ -397,6 +394,136 @@ class MessageImage extends StatelessWidget {
}
}

class MessageInlineVideo extends StatefulWidget {
const MessageInlineVideo({super.key, required this.node});

final InlineVideoNode node;

@override
State<MessageInlineVideo> createState() => _MessageInlineVideoState();
}

class _MessageInlineVideoState extends State<MessageInlineVideo> {
Uri? _resolvedSrcUrl;

VideoPlayerController? _controller;
bool _didAttemptInitialization = false;
bool _hasNonPlatformError = false;

@override
void initState() {
// We delay initialization by a single frame to make sure the
// BuildContext is a valid context as its needed to retrieve
// the PerAccountStore during initialization.
SchedulerBinding.instance.addPostFrameCallback((_) => _initialize());
super.initState();
}

Future<void> _initialize() async {
try {
final store = PerAccountStoreWidget.of(context);
_resolvedSrcUrl = store.tryResolveUrl(widget.node.srcUrl);
if (_resolvedSrcUrl == null) {
return; // TODO(log)
}

assert(debugLog('VideoPlayerController.networkUrl($_resolvedSrcUrl)'));
_controller = VideoPlayerController.networkUrl(_resolvedSrcUrl!, httpHeaders: {
if (_resolvedSrcUrl!.origin == store.account.realmUrl.origin) ...authHeader(
email: store.account.email,
apiKey: store.account.apiKey,
),
...userAgentHeader()
});

await _controller!.initialize();
_controller!.addListener(_handleVideoControllerUpdates);
} on PlatformException catch (error) {
// VideoPlayerController.initialize throws a PlatformException
// if the provide video source is unsupported by the player.
// In which case we fallback to opening the video externally
// when user clicks on the play button, precondition is determined
// by '_controller.value.hasError'.
assert(debugLog("PlatformException: VideoPlayerController.initialize failed: $error"));
} catch (error) {
_hasNonPlatformError = true;
assert(debugLog("VideoPlayerController.initialize failed: $error"));
} finally {
if (mounted) setState(() { _didAttemptInitialization = true; });
}
}

@override
void dispose() {
_controller?.removeListener(_handleVideoControllerUpdates);
_controller?.dispose();
super.dispose();
}

void _handleVideoControllerUpdates() {
assert(debugLog("Video buffered: ${_controller?.value.buffered}"));
assert(debugLog("Video max duration: ${_controller?.value.duration}"));
}

@override
Widget build(BuildContext context) {
final message = InheritedMessage.of(context);

return MessageMediaContainer(
onTap: !_didAttemptInitialization
? null
: () { // TODO(log)
if (_resolvedSrcUrl == null || _hasNonPlatformError) {
showErrorDialog(context: context,
title: 'Unable to open video',
message: 'Video could not be opened: $_resolvedSrcUrl');
return;
}

if (_controller!.value.hasError) {
// TODO use webview instead, to support auth headers
_launchUrl(context, widget.node.srcUrl);
} else {
Navigator.of(context).push(getLightboxRoute(
context: context,
message: message,
src: _resolvedSrcUrl!,
videoController: _controller,
));
}
},
child: !_didAttemptInitialization
? Container(
color: Colors.black,
child: const Center(
child: SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2))))
: Stack(
alignment: Alignment.center,
children: [
if (_resolvedSrcUrl == null || _controller!.value.hasError)
Container(color: Colors.black)
else
LightboxHero(
message: message,
src: _resolvedSrcUrl!,
child: AspectRatio(
aspectRatio: _controller!.value.aspectRatio,
child: VideoPlayer(_controller!))),
const Icon(
Icons.play_arrow_rounded,
color: Colors.white,
size: 32)
]));
}
}

/// MessageEmbedVideo opens the video href externally, and
/// a preview image is visible in the content message UI.
class MessageEmbedVideo extends StatelessWidget {
const MessageEmbedVideo({super.key, required this.node});

Expand Down
Loading

0 comments on commit 05a4aa5

Please sign in to comment.