Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

content: Handle video previews #587

Merged
merged 11 commits into from May 10, 2024
Merged

Conversation

rajveermalviya
Copy link
Collaborator

@rajveermalviya rajveermalviya commented Mar 24, 2024

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:

Fixes: #356

video-content.mp4

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting! Looking forward to this feature.

This will need tests, naturally. I'm guessing that's the main reason you've marked it as draft.

I'll wait to do a detailed review until you've said the PR is ready for one. But from a quick skim just now, here's a few high-level points.

@@ -65,6 +65,7 @@ dependencies:
url_launcher: ^6.1.11
url_launcher_android: ">=6.1.0"
sqlite3: ^2.4.0
video_player: ^2.8.3
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dependency should be added in its own commit. See git log --stat -p --full-diff pubspec.* for examples.

Comment on lines 169 to 176
COCOAPODS: 1.13.0
COCOAPODS: 1.15.0
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This CocoaPods bump is fine, but should be its own commit because it's logically independent of the other things happening. See git log --stat -p -G COCOAPODS for past examples.

I suspect that on your machine if you just do flutter run for iOS and macOS targets, starting from main, it'll already make these two changes.

@@ -1024,6 +1083,10 @@ class _ZulipContentParser {
return parseImageNode(element);
}

if (localName == 'div' && classes.containsAll(['message_inline_image', 'message_inline_video'])) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll want to use className rather than classes; we made that switch earlier this year. See git log -p -S classes lib/model/content.dart for examples and motivation.

This one will be a bit nontrivial (because there's more than one class), but see the latest couple of commits matched by that filter for examples of similar complexity.

@rajveermalviya rajveermalviya force-pushed the pr-video-content branch 2 times, most recently from e2882a7 to 2842628 Compare March 26, 2024 01:27
@rajveermalviya
Copy link
Collaborator Author

Thanks for the initial comments @gnprice! It should be ready for the first round of review.

For the two iOS bugs, we could just move the initialization into the lightbox on iOS and don't care about first frame preview, but then we loose the ability to check if video is supported or not when user taps on play, if that happens when already in the lightbox then we will have to show a dialog that video's unsupported with a button to open it in webview, pretty weird UX.

Or just always open video in webview on iOS.

Or ofcourse get these bugs fixed, & live with them temporarily.

@rajveermalviya rajveermalviya marked this pull request as ready for review March 26, 2024 01:49
Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @rajveermalviya! I've read through the parsing commit in detail, and it generally looks good; comments below. The two deps commits also look good. I haven't yet read through the widgets/playback commit.

Those two upstream issues are interesting. Are you observing their symptoms?

In issue

it kind of sounds like flutter/packages#3826 may have fixed the issue last July. The original reporter (who had a Vimeo example) didn't reply after that. There's one person who showed up in January reporting similar symptoms, but it's not clear that their issue wasn't some specific interaction with the CDN they were using.

In issue

there are several reports that things work fine in a Flutter iOS app and that the only trouble is in Flutter web on iOS browsers, and the issue is tagged that way. The thread is a bit scattered, as there are also reports of trouble on Flutter iOS.

const VideoNode({
super.debugHtmlNode,
required this.srcUrl,
this.previewImageUrl,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
this.previewImageUrl,
required this.previewImageUrl,

IOW let's treat this the same as our API types in lib/api/ as described at https://github.com/zulip/zulip-flutter#editing-api-types :

In our API types, constructors should generally avoid default values for their parameters, even null. This means writing e.g. required this.foo rather than just this.foo, even when foo is nullable. This is because it's common in the Zulip API for a null or missing value to be quite salient in meaning, and not a boring value appropriate for a default, so that it's best to ensure callers make an explicit choice. If passing explicit values in tests is cumbersome, a factory function in test/example_data.dart is an appropriate way to share defaults.

Comment on lines 726 to 727
const sourceType = r"(message_inline_video)?(youtube-video)?(embed-video)?";
return RegExp("^message_inline_image $sourceType|$sourceType message_inline_image\$");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will match e.g. message_inline_image youtube-videoembed-video, which seems unintended.

Try the regexp "alternation" operator | instead:

Suggested change
const sourceType = r"(message_inline_video)?(youtube-video)?(embed-video)?";
return RegExp("^message_inline_image $sourceType|$sourceType message_inline_image\$");
const sourceType = r"(message_inline_video|youtube-video|embed-video)";
return RegExp("^message_inline_image $sourceType|$sourceType message_inline_image\$");

Comment on lines 730 to 733
bool _isVideoClassNameInlineVideo(String input) {
if (!_videoClassNameRegexp.hasMatch(input)) return false;
final groups = _videoClassNameRegexp.firstMatch(input)!.groups([1, 4]);
return groups.nonNulls.firstOrNull == 'message_inline_video';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this logic will get simpler with the (a|b|c) rather than (a)?(b)?(c)? regexp, too.

Comment on lines 742 to 791
bool _isVideoClassNameEmbedVideo(String input) {
if (!_videoClassNameRegexp.hasMatch(input)) return false;
final groups = _videoClassNameRegexp.firstMatch(input)!.groups([3, 6]);
return groups.nonNulls.firstOrNull == 'embed-video';
}

InlineContentNode parseInlineContent(dom.Node node) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The existing regexps above this are here because they're helpers for parseInlineContent. These new members are helpers instead for parseVideoNode and its friends, so let's put them just above there.

Comment on lines 1008 to 979
return _parseInlineVideo(divElement);
}
if (_isVideoClassNameYoutubeVideo(divElement.className)
|| _isVideoClassNameEmbedVideo(divElement.className)) {
return _parseEmbedVideoWithPreviewImage(divElement);
}
return UnimplementedBlockContentNode(htmlNode: divElement);
}

BlockContentNode _parseInlineVideo(dom.Element divElement) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: In this parsing code, the ordering is that specific pieces and building blocks go above, and pieces that are more general or sit higher in the ContentNode tree go below. Here _parseInlineVideo is a more-specific helper for parseVideoNode, so it should go above it.


static const videoEmbedYoutubeClassNameReordered = ContentExample(
'video preview for youtube embed with thumbnail, (hypothetical) class name reorder',
null,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Fine to have hypothetical variations like this one, though.)

Oh but let's maintain a convention of, when this markdown field is null, having a comment right at this line that explains why. That helps maintain a practice of consistently including the Markdown source when there isn't a good reason we can't.

Fine for the comment to be terse, when the reason is a recurring straightforward one like here. See e.g. emojiUnicodeClassesFlipped.

),
]);

static const videoEmbedYoutubeClassNameReordered = ContentExample(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this name is fine intrinsically, but it's useful to express the same idea consistently between different names that belong to the same list — so, following the existing emojiUnicodeClassesFlipped:

Suggested change
static const videoEmbedYoutubeClassNameReordered = ContentExample(
static const videoEmbedYoutubeClassesFlipped = ContentExample(

Comment on lines 561 to 657
static const videoEmbedVimeoPreviewDisabled = ContentExample(
'video preview for vimeo embed with realm link previews disabled',
null,
'<p>'
'<a href="https://vimeo.com/1084537">https://vimeo.com/1084537</a></p>', [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't actually embed any video, right? It's just a link.

Fine to have it here as an extra example — could be helpful for someone trying to reproduce the videoEmbedVimeoPreviewEnabled case below, if the server they're using has realm link previews disabled. But let's make the story clear in the description. For example:

Suggested change
static const videoEmbedVimeoPreviewDisabled = ContentExample(
'video preview for vimeo embed with realm link previews disabled',
null,
'<p>'
'<a href="https://vimeo.com/1084537">https://vimeo.com/1084537</a></p>', [
static const videoEmbedVimeoPreviewDisabled = ContentExample(
'video non-preview for attempted vimeo embed with realm link previews disabled',
null,
'<p>'
'<a href="https://vimeo.com/1084537">https://vimeo.com/1084537</a></p>', [

'<a href="https://vimeo.com/1084537">Vimeo - Big Buck Bunny</a>'
'</p>\n'
'<div class="embed-video message_inline_image">'
'<a data-id="&lt;iframe src=&quot;https://player.vimeo.com/video/1084537?app_id=122963&quot; width=&quot;640&quot; height=&quot;360&quot; frameborder=&quot;0&quot; allow=&quot;autoplay; fullscreen; picture-in-picture; clipboard-write&quot; title=&quot;Big Buck Bunny&quot;&gt;&lt;/iframe&gt;" href="https://vimeo.com/1084537" title="Big Buck Bunny">'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The value of the data-id attribute is a blob of HTML? For an iframe element? Wacky. That's not much of an "ID".

If the Zulip server produces this, or has produced it in the past, then it's good to have in a test case. But it smells rather like a bug. It'd be good to report it in a chat thread in #issues and see what people say.

This has enough resemblance to a potential HTML injection that I waffled on whether to treat it as a potential security issue (which should be sent to security@zulip.com rather than discussed in public). So I poked around in the server implementation, and the logic for this is pretty explicit in zerver/lib/markdown/__init__.py:

        elif extracted_data.type == "video":
            self.add_a(
                # …
                class_attr="embed-video message_inline_image",
                data_id=extracted_data.html,

That makes it look intentional enough that I'm OK with keeping the discussion public. But I'd still be interested in what the story is with calling this "id" when it's actually some HTML for an iframe.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

]),
]);

static const videoEmbedVimeoPreviewEnabled = ContentExample(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
static const videoEmbedVimeoPreviewEnabled = ContentExample(
static const videoEmbedVimeo = ContentExample(

Given that the preview-disabled case isn't really a "video embed" at all, we can treat this as the main Vimeo example and give it a shorter name.

@rajveermalviya rajveermalviya force-pushed the pr-video-content branch 2 times, most recently from bf4e818 to b78af2f Compare March 26, 2024 22:06
@rajveermalviya
Copy link
Collaborator Author

Thanks @gnprice for the review! Rebased with comments addressed.

Are you observing their symptoms?

Yes I am able to reproduce them atleast on macOS and iOS Simulator, but haven't tested if this could be a CDN issue. I will test it using bare AVPlayer to see if it really is something to fix in Zulip's CDN config.

@rajveermalviya
Copy link
Collaborator Author

(rebased to main)

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @rajveermalviya for the revision! Here's a fresh round of review on the parsing commit.

I'll aim to get to the widgets/playback commit soon, too; but there's an unusually large rate of PRs coming in right now, so I'll cycle through some of the others first.

assert(divElement.localName == 'div'
&& _videoClassNameRegexp.hasMatch(divElement.className));

final match = _videoClassNameRegexp.firstMatch(divElement.className)!;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will still mean running the regexp a second time, after doing so once in the condition at the call site. Let's get it down to just once (at least outside of asserts).

See above: #587 (comment)

Comment on lines 980 to 1050
const sourceType = r"(message_inline_video|youtube-video|embed-video)?";
return RegExp("^message_inline_image $sourceType|$sourceType message_inline_image\$");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const sourceType = r"(message_inline_video|youtube-video|embed-video)?";
return RegExp("^message_inline_image $sourceType|$sourceType message_inline_image\$");
const sourceType = r"(message_inline_video|youtube-video|embed-video)";
return RegExp("^message_inline_image $sourceType|$sourceType message_inline_image\$");

Otherwise this matches message_inline_image and message_inline_image, which I don't think is intended.

Comment on lines 1054 to 1055
return switch (match.groups([1, 2])) {
['message_inline_video', null] || [null, 'message_inline_video']
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love groups here because it's allocating a list, and there isn't fundamentally a need for a list because we know there should be exactly one of these groups that matched.

In particular, after removing the ? as in my comment above, it's an invariant of the regexp that if the regexp as a whole has a match, then it will always involve exactly one of those groups matching.

So we can use that to write:

Suggested change
return switch (match.groups([1, 2])) {
['message_inline_video', null] || [null, 'message_inline_video']
final videoClass = match.group(1) ?? match.group(2)!;
return switch (videoClass) {
'message_inline_video'

=> _parseInlineVideo(divElement),
['youtube-video' || 'embed-video', null] || [null, 'youtube-video' || 'embed-video']
=> _parseEmbedVideoWithPreviewImage(divElement),
_ => UnimplementedBlockContentNode(htmlNode: divElement),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, it's an invariant of the regexp that this case can't happen. So we can add an assertion to that effect.

@@ -948,6 +976,90 @@ class _ZulipContentParser {
return ImageNode(srcUrl: src, debugHtmlNode: debugHtmlNode);
}

static final _videoClassNameRegexp = () {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can move this down to just above parseVideoNode, past _parseInlineVideo and its sibling, since it's really a helper for the former and it isn't used by the latter

Comment on lines 543 to 545
static const videoEmbedYoutubeClassesFlipped = ContentExample(
'video preview for youtube embed with thumbnail, (hypothetical) class name reorder',
'https://www.youtube.com/watch?v=aqz-KE-bpKQ',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(continuing from #587 (comment) )

This isn't the server's real output for this Markdown source, is it? Rather the output above is — that's why this one is hypothetical.

Again, see emojiUnicodeClassesFlipped for an example:

  static final emojiUnicodeClassesFlipped = ContentExample.inline(
    'Unicode emoji, encoded in span element, class order reversed',
    null, // ":thumbs_up:" (hypothetical server variation)

Comment on lines 574 to 580
// Note that the server generates "data-id" attribute with value of a raw HTML,
// web client uses this to show Vimeo's video player in a sandbox iframe.
// The HTML blob is provided by Vimeo and them changing it wouldn't break the
// web client because it relies on this dynamic value.
//
// Discussion:
// https://chat.zulip.org/#narrow/stream/9-issues/topic/Vimeo.20link.20previews.20HTML.20.22data-id.22.20isn't.20a.20.20Vimeo.20video.20ID/near/1767563
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for writing this down! Here's a slightly tightened-up version (plus the explicit warning that we shouldn't try to parse this attribute, even though web's use of it is OK):

Suggested change
// Note that the server generates "data-id" attribute with value of a raw HTML,
// web client uses this to show Vimeo's video player in a sandbox iframe.
// The HTML blob is provided by Vimeo and them changing it wouldn't break the
// web client because it relies on this dynamic value.
//
// Discussion:
// https://chat.zulip.org/#narrow/stream/9-issues/topic/Vimeo.20link.20previews.20HTML.20.22data-id.22.20isn't.20a.20.20Vimeo.20video.20ID/near/1767563
// The server really does generate an attribute called "data-id" whose value
// is a blob of HTML. The web client uses this to show Vimeo's video player
// inside a sandbox iframe. The HTML comes from Vimeo and may change form;
// that's OK the way web uses it, but we shouldn't try to parse it. See:
// https://chat.zulip.org/#narrow/stream/9-issues/topic/Vimeo.20link.20previews.20HTML.20.22data-id.22.20isn't.20a.20.20Vimeo.20video.20ID/near/1767563

On "note that", see: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#avoid-empty-prose

@rajveermalviya
Copy link
Collaborator Author

@gnprice thanks for the review! Pushed a new revision.

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Just one comment on the parsing commit in this revision.

Comment on lines 1141 to 1144
{
final match = _videoClassNameRegexp.firstMatch(className);
if (localName == 'div' && match != null) {
return parseVideoNode(element, match);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Continuing on the theme of staying paranoid about performance within this parsing code: this version now runs the regexp before checking the cheap localName condition. I'd like to keep the more expensive condition saved for after we know the other condition passes.

There's a few ways that could be arranged in the code. Here's one:

Suggested change
{
final match = _videoClassNameRegexp.firstMatch(className);
if (localName == 'div' && match != null) {
return parseVideoNode(element, match);
final RegExpMatch? videoMatch;
if (localName == 'div'
&& ((videoMatch = _videoClassNameRegexp.firstMatch(className)) != null)) {
return parseVideoNode(element, videoMatch!);

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, and here's a first review of the widgets/playback code!

I haven't yet looked at the lightbox, or at most of the stateful logic of MessageInlineVideoPreview. But I think this is enough for one round. There are a couple of aspects that would be good to restructure, which may cause some changes in the other code anyway.

Comment on lines 101 to 103
return MessageImage(node: node);
} else if (node is VideoNode) {
return MessageVideo(node: node);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this case gets added in one commit as a stub, then moved around in a later commit; instead when first added it should go in the place we intend to leave it, so the later commit doesn't have to move it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relatedly: in the intermediate state after the first commit and before the second, instead of a Container (which is likely invisible) we should show some version of the "unimplemented" display, since at that stage this feature is still unimplemented. This is part of the general principle that each commit should be coherent on its own:
https://zulip.readthedocs.io/en/latest/contributing/commit-discipline.html#each-commit-must-be-coherent

Doesn't need to be particularly well abstracted, since it is temporary. In particular it needn't find a way to call _errorUnimplemented, and instead can just use errorStyle directly to make a Text widget that looks similar to something _errorUnimplemented might do.

Comment on lines 407 to 410
final resolvedSrc = store.tryResolveUrl(node.srcUrl);
return resolvedSrc != null
? MessageInlineVideoPreview(src: resolvedSrc)
: Container();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Container seems a bit too quiet if something's wrong — it could make it easy to not realize anything was supposed to be there in the first place.

In MessageImage if we can't resolve the URL I believe we end up showing a 150x100 rectangle that's just blank. That can be improved (as the comment there says), but would be adequate here.

Comment on lines 536 to 515
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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a lot of details here that seem like they get duplicated into three places, namely MessageImage and the two video types:

  • a GestureDetector
  • contains an UnconstrainedBox aligned centerLeft
  • contains certain 5px padding, with a TODO comment
  • then there's a certain 150x100 shape.

It'd be great to abstract some of those so that they can be shared. I think it's not a coincidence these are similar — they're designed to look similar ­— so it's good to arrange the code in a way that will help them naturally remain similar as we make changes.

Comment on lines 553 to 556
AspectRatio(
aspectRatio: _controller!.value.aspectRatio,
child: VideoPlayer(_controller!)),
Container(color: const Color.fromRGBO(0, 0, 0, 0.30)),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh, if I'm reading this right, it puts a 30%-opacity black layer in front of the video (by putting it later in a Stack's children).

Is that intended? It doesn't seem like what we'd want.

Comment on lines 559 to 531
const Icon(
Icons.play_arrow_rounded,
color: Colors.white,
size: 25)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How did you choose 25px for the size?

It appears to be 32px on web and in zulip-mobile. (For the latter, see src/webview/static/base.css.)

Comment on lines 399 to 403
// For YouTube and Vimeo links, display a widget with a thumbnail.
// When the thumbnail is tapped, open the video link in an external browser or
// a supported external app.
if (node.previewImageUrl != null) {
return MessageEmbedVideoPreview(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The information in this comment would be good to turn into a short dartdoc for the MessageEmbedVideoPreview class.

child: Stack(
alignment: Alignment.center,
children: [
if (previewImageUrl != null) ...[
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar comment as on the Container fallback for the node.srcUrl case above.

Comment on lines 414 to 418
class MessageEmbedVideoPreview extends StatelessWidget {
const MessageEmbedVideoPreview({
super.key,
required this.previewImage,
required this.src,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at these two widget classes for the two kinds of videos, and at MessageVideo which dispatches to them, I think it'd probably be best to have two different ContentNode subclasses for them.

In particular there's a very important difference in semantics between these two, if I'm correctly understanding how these work:

  • For "inline" videos, node.srcUrl is a URL on the Zulip server, and so it's fine to load it when the user just scrolls passively past that message (and doing so is helpful for the reasons you mention in the PR description).
  • For "embed" videos, node.srcUrl is a URL on some other external service. For privacy reasons it's therefore important that we not go and talk to that service unless the user actually chooses to interact with the video. OTOH node.previewImageUrl is a URL back on the user's Zulip server (provided by Camo), and safe to eagerly fetch from.

I believe this PR already handles that distinction correctly. But in this version it's pretty subtle in the code, and so it'd be easy for some future change to break that privacy property without realizing there was something important there. We should therefore arrange the code so that the distinction is clear. As part of that, we should avoid having these two kinds of URLs with very different privacy properties both live as values of the same field on the same class.

Conversely, MessageVideo is doing very little, really just dispatching to these two related classes that have pretty different logic from each other. So even apart from the privacy issue, it feels like having two different ContentNode subclasses would better match up with how the logic actually works.

If there's anyplace where it would be helpful to do so, those two ContentNode subclasses could always have a common base class with a name like VideoNode.

Comment on lines 528 to 491
Navigator.of(context).push(getLightboxRoute(
context: context,
message: message,
src: widget.src,
videoController: _controller,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this means that playback in the lightbox picks up from right where it was. Neat!

@rajveermalviya
Copy link
Collaborator Author

@gnprice, thank you for your review. I've just pushed a new revision. Apologies for the delay

@gnprice
Copy link
Member

gnprice commented Apr 5, 2024

Thanks for the revision!

I'm just about to head out on vacation, so it'll be a couple of weeks before I'm able to give this PR another review. But I'll look forward to picking it up when I'm back.

@chrisbobbe if you find some time to help move this forward with a review while I'm away, the key thing I'd highlight from the above review threads is this point about the important privacy-related difference between inline vs. embedded videos.

@gnprice
Copy link
Member

gnprice commented May 6, 2024

Thanks for the revision! Comments above. Generally this structure all looks good, so this continues to get closer; the one area of code I haven't yet reviewed remains the tests in the last/main commit.

I've also just pushed a couple of added commits on top, described in my comments above. One is meant to remain a separate commit on top, and the other to have its changes squashed into a couple of earlier commits.

@rajveermalviya
Copy link
Collaborator Author

Thanks for the review! Pushed a new revision.

@rajveermalviya
Copy link
Collaborator Author

(rebased to main)

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the revision!

With this review I've gotten through the whole thing now, including those widget tests toward the end. Various comments below; outside those newly-reviewed tests, we're down to just a couple of them. So this is quite close.

As mentioned below, I'll also push an added commit on top, meant to be squashed in.

super.dispose();
}

void _handleVideoControllerUpdates() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
void _handleVideoControllerUpdates() {
void _handleVideoControllerUpdate() {

(it handles each update one at a time; this is the same way we name other "handle" methods, like handleEvent)

Comment on lines +281 to +291
// After 'controller.seekTo' is called in 'Slider.onChangeEnd' the
// position indicator switches back to the actual controller's position
// but since the call 'seekTo' completes before the actual controller
// updates are notified, the position indicator that switches to controller's
// position can show the older position before the call to 'seekTo' for a
// single frame, resulting in a glichty UX.
//
// To avoid that, we delay the position indicator switch from '_sliderValue' to
// happen when we are notified of the controller update.
if (_isSliderDragging && _sliderValue == widget.controller.value.position) {
_isSliderDragging = false;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm interesting, I see. (This follows the subthread at #587 (comment) .)

Cool — let's run with this logic, then.

// to display an error dialog if the video loading fails.
final zulipLocalizations = ZulipLocalizations.of(context);

_initialize(store, zulipLocalizations);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, but this does reinitialize, right? It looks like it'll initialize unconditionally on any call to onNewStore.

I'll push an added commit on top that arranges this initialization the way I think we want — please take a look, and then squash it in.

For the localizations, that commit also just moves the ZulipLocalizations.of down to just before we actually use its data, with no await in between. That's the ideal behavior, and if we make a practice of doing that then I think it can go without comment.

home: PerAccountStoreWidget(
accountId: eg.selfAccount.id,
child: VideoLightboxPage(
routeEntranceAnimation: const MockCompletedAnimation(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/// An animation that is always considered complete and
/// immediately notifies listeners.
///
/// This is different from [kAlwaysCompleteAnimation] where
/// it doesn't notify the listeners.
class MockCompletedAnimation extends Animation<double> {

Hmm interesting. Is this the right scenario, though, then?

If kAlwaysCompleteAnimation in the framework doesn't notify listeners (because nothing changes), then maybe that's the same thing that would happen if for whatever reason our page doesn't get built until the animation is already complete. (E.g., if the device is super overloaded and a bunch of frames get dropped.)

Can we adjust the app code so the test passes with kAlwaysCompleteAnimation here? I think it may just be a matter of calling _handleRouteEntranceAnimationStatusChange once in initState.


group("VideoLightboxPage", () {
// Setup the mock for video_player plugin
setupMockVideoPlayerPlatform();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Let's make this a static on MockVideoPlayerPlatform. That groups it nicely under the class as a namespace.

Looking at call sites of VideoPlayerPlatform.instance=, a conventional name for this would be:

Suggested change
setupMockVideoPlayerPlatform();
MockVideoPlayerPlatform.registerWith();

VideoPlayerPlatform.instance = MockVideoPlayerPlatform();
}

class MockVideoPlayerPlatform extends Fake
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
class MockVideoPlayerPlatform extends Fake
class FakeVideoPlayerPlatform extends Fake

That is, let's call this a "fake" instead of a "mock".

Broadly, a mock's method implementations do nothing except record they were called, in a log the test will then inspect. A fake's method implementations do some facsimile of what a real implementation would do, just with all the expensive parts replaced by shortcuts.

This is already more of a fake than a mock, because e.g. the test looks at isPlaying rather than counting calls to play and pause. And that's a good thing — generally fakes are preferable to mocks. They make the tests less coupled to accidental implementation details of the code under test, and better aligned to the code's actual interface and spec.

Comment on lines 352 to 354
final expectedResolvedPreviewUrl = eg
.store()
.tryResolveUrl(expectedVideo.previewImageSrcUrl)!;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
final expectedResolvedPreviewUrl = eg
.store()
.tryResolveUrl(expectedVideo.previewImageSrcUrl)!;
final expectedResolvedPreviewUrl = eg.store()
.tryResolveUrl(expectedVideo.previewImageSrcUrl)!;

("eg" alone means very little, so keep it together with the name of what we're pulling off it)

const example = ContentExample.videoEmbedVimeo;
await prepareContent(tester, example.html);

final expectedTitle = (((example.expectedNodes[0] as ParagraphNode).nodes[0] as LinkNode).nodes[0] as TextNode).text;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: line very long

Suggested change
final expectedTitle = (((example.expectedNodes[0] as ParagraphNode).nodes[0] as LinkNode).nodes[0] as TextNode).text;
final expectedTitle = (((example.expectedNodes[0] as ParagraphNode)
.nodes.single as LinkNode).nodes.single as TextNode).text;

Also .single is good when applicable, as it makes more of the expectations clear.

await prepareContent(tester, example.html);

final expectedTitle = (((example.expectedNodes[0] as ParagraphNode).nodes[0] as LinkNode).nodes[0] as TextNode).text;
await tester.ensureVisible(find.text(expectedTitle));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh interesting — why is this needed in this test but not in the previous one?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More generally, if these test cases work exactly the same way, it'd be nice to express that — basically factor out most of their guts, after the await prepareContent line, into a local helper like checkEmbedVideo. Which maybe takes the title string and the EmbedVideoNode as arguments.

});
});

group("MessageInlineVideo", () {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: put these tests in same order as the code they're testing

@rajveermalviya rajveermalviya force-pushed the pr-video-content branch 2 times, most recently from 8acc35f to 6ac5a44 Compare May 10, 2024 19:07
@rajveermalviya
Copy link
Collaborator Author

Thanks for the review @gnprice, new revision pushed!

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @rajveermalviya for all your work on this feature! This all looks great — merging 🎉

There's one comment I remember writing yesterday but that somehow got lost on GitHub; not sure how. The comment was to ask for a few more tests, specifically some tests of the time slider. I'll write up a new version of that comment, but there's no need for it to block merging — please write those tests as a follow-up PR instead.

rajveermalviya and others added 11 commits May 10, 2024 12:26
Used in tests for mocking video_player platform interface
Used in tests for mocking video_player platform interface
Partially implements zulip#356, provides video thumbnail previews
for embedded external videos (Youtube & Vimeo).
Based on the reasoning at the one place we're using either of
these options so far, it seems like we'll always want them
coupled this way.  (At least until some other reason comes along,
and in that case we can revise this logic again.)
@gnprice gnprice merged commit 33b97ef into zulip:main May 10, 2024
1 check passed
await tester.ensureVisible(find.byType(VideoPlayer));
});

testWidgets('toggles between play and pause', (tester) async {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's have a couple of tests of the slider mechanism, too. In particular:

  • Slider starts at zero; let half the time pass, slider is at halfway point; let more than the full time pass, slider is at end. (E.g. it didn't jump back to zero.)

    To manipulate time in a test, use our awaitFakeAsync helper; see existing examples. Then call FakeAsync.elapse.

  • Start dragging slider; drag it back and forth a few times; finish dragging. At each step, check its displayed position is the place you just dragged it to. Then at the end, check seekTo was only called once.
    I.e., a regression test for the lag-causing issue you described avoiding at content: Handle video previews #587 (comment) .

  • Drag slider from one point to another. Check the slider appears at the place you dragged it to now, and at every frame until it starts advancing again because the video is playing. I.e., a regression test for the bug that the _isSliderDragging and _sliderValue logic is avoiding as explained in your comment quoted at content: Handle video previews #587 (comment) .

As described above at #587 (review) , please do those as a follow-up PR. Thanks!

@gnprice
Copy link
Member

gnprice commented May 10, 2024

So let's file a followup issue for adding those, and link to your comment #587 (comment) — that will help us pick that work up later.

Filed this as #656.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Handle video previews
2 participants