Skip to content

Commit

Permalink
draft
Browse files Browse the repository at this point in the history
  • Loading branch information
rajveermalviya committed Mar 24, 2024
1 parent 9044a9a commit 1a09cf5
Show file tree
Hide file tree
Showing 7 changed files with 441 additions and 6 deletions.
63 changes: 63 additions & 0 deletions lib/model/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,30 @@ class ImageNode extends BlockContentNode {
}
}

class VideoNode extends BlockContentNode {
const VideoNode({
super.debugHtmlNode,
required this.srcUrl,
});

final String srcUrl;
// TODO: add fields indicating other video sources (Youtube)

@override
bool operator ==(Object other) {
return other is VideoNode && other.srcUrl == srcUrl;
}

@override
int get hashCode => Object.hash('VideoNode', srcUrl);

@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(StringProperty('srcUrl', srcUrl));
}
}

/// A content node that expects an inline layout context from its parent.
///
/// When rendered into a Flutter widget tree, an inline content node
Expand Down Expand Up @@ -948,6 +972,40 @@ class _ZulipContentParser {
return ImageNode(srcUrl: src, debugHtmlNode: debugHtmlNode);
}

BlockContentNode parseVideoNode(dom.Element divElement) {
assert(_debugParserContext == _ParserContext.block);
// TODO: handle parsing other video sources (Youtube)
final videoElement = () {
assert(divElement.localName == 'div'
&& divElement.classes.containsAll(['message_inline_image', 'message_inline_video']));

if (divElement.nodes.length != 1) return null;
final child = divElement.nodes[0];
if (child is! dom.Element) return null;
if (child.localName != 'a') return null;
if (child.className.isNotEmpty) return null;

if (child.nodes.length != 1) return null;
final grandchild = child.nodes[0];
if (grandchild is! dom.Element) return null;
if (grandchild.localName != 'video') return null;
if (grandchild.className.isNotEmpty) return null;
return grandchild;
}();

final debugHtmlNode = kDebugMode ? divElement : null;
if (videoElement == null) {
return UnimplementedBlockContentNode(htmlNode: divElement);
}

final src = videoElement.attributes['src'];
if (src == null) {
return UnimplementedBlockContentNode(htmlNode: divElement);
}

return VideoNode(srcUrl: src, debugHtmlNode: debugHtmlNode);
}

BlockContentNode parseBlockContent(dom.Node node) {
assert(_debugParserContext == _ParserContext.block);
final debugHtmlNode = kDebugMode ? node : null;
Expand All @@ -957,6 +1015,7 @@ class _ZulipContentParser {
final element = node;
final localName = element.localName;
final className = element.className;
final classes = element.classes;

if (localName == 'br' && className.isEmpty) {
return LineBreakNode(debugHtmlNode: debugHtmlNode);
Expand Down Expand Up @@ -1024,6 +1083,10 @@ class _ZulipContentParser {
return parseImageNode(element);
}

if (localName == 'div' && classes.containsAll(['message_inline_image', 'message_inline_video'])) {
return parseVideoNode(element);
}

// TODO more types of node
return UnimplementedBlockContentNode(htmlNode: node);
}
Expand Down
123 changes: 123 additions & 0 deletions lib/widgets/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ 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';
import '../model/internal_link.dart';
import '../model/store.dart';
import 'code_block.dart';
import 'dialog.dart';
import 'icons.dart';
Expand Down Expand Up @@ -96,6 +99,8 @@ class BlockContentList extends StatelessWidget {
"It should be wrapped in [ImageNodeList]."
);
return MessageImage(node: node);
} else if (node is VideoNode) {
return MessageVideo(node: node);
} else if (node is UnimplementedBlockContentNode) {
return Text.rich(_errorUnimplemented(node));
} else {
Expand Down Expand Up @@ -382,6 +387,124 @@ class MessageImage extends StatelessWidget {
}
}

class MessageVideo extends StatelessWidget {
const MessageVideo({super.key, required this.node});

final VideoNode node;

@override
Widget build(BuildContext context) {
final store = PerAccountStoreWidget.of(context);
final resolvedSrc = store.tryResolveUrl(node.srcUrl);

// TODO: handle other video sources (Youtube)
return resolvedSrc != null
? MessageVideoPreview(src: resolvedSrc, store: store)
: Container();
}
}

class MessageVideoPreview extends StatefulWidget {
const MessageVideoPreview({super.key, required this.src, required this.store});

final Uri src;
final PerAccountStore store;

@override
State<MessageVideoPreview> createState() => _MessageVideoPreviewState();
}

class _MessageVideoPreviewState extends State<MessageVideoPreview> {
late VideoPlayerController _controller;
bool _initialized = false;

@override
void initState() {
_asyncInitState();
super.initState();
}

Future<void> _asyncInitState() async {
try {
assert(debugLog("VideoPlayerController.networkUrl(${widget.src})"));
_controller = VideoPlayerController.networkUrl(widget.src, httpHeaders: {
if (widget.src.origin == widget.store.account.realmUrl.origin) ...authHeader(
email: widget.store.account.email, apiKey: widget.store.account.apiKey,
),
...userAgentHeader()
});
await _controller.initialize();
} catch (error) {
assert(debugLog("VideoPlayerController.initialize failed: $error"));
} finally {
setState(() {
_initialized = true;
});
}
}

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

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

return GestureDetector(
onTap: !_initialized
? null
: () { // TODO(log)
if (_controller.value.hasError) {
ZulipBinding.instance.launchUrl(widget.src);
} else {
Navigator.of(context).push(getLightboxRoute(
context: context,
message: message,
src: widget.src,
videoController: _controller,
));
}
},
child: UnconstrainedBox(
alignment: Alignment.centerLeft,
child: Padding(
// TODO clean up this padding by imitating web less precisely;
// in particular, avoid adding loose whitespace at end of message.
padding: const EdgeInsets.only(right: 5, bottom: 5),
child: LightboxHero(
message: message,
src: widget.src,
child: Container(
height: 100,
width: 150,
color: Colors.black,
child: Stack(
alignment: Alignment.center,
children: [
if (_initialized && !_controller.value.hasError)
AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: VideoPlayer(_controller)),
Container(color: const Color.fromRGBO(0, 0, 0, 0.30)),
if (_initialized)
const Icon(
Icons.play_arrow_rounded,
color: Colors.white,
size: 25,
)
else
const SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2),
),
]))))));
}
}

class CodeBlock extends StatelessWidget {
const CodeBlock({super.key, required this.node});

Expand Down
Loading

0 comments on commit 1a09cf5

Please sign in to comment.