-
Notifications
You must be signed in to change notification settings - Fork 309
content: Handle video previews #587
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
Conversation
There was a problem hiding this 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 |
There was a problem hiding this comment.
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.
macos/Podfile.lock
Outdated
COCOAPODS: 1.13.0 | ||
COCOAPODS: 1.15.0 |
There was a problem hiding this comment.
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.
lib/model/content.dart
Outdated
@@ -1024,6 +1083,10 @@ class _ZulipContentParser { | |||
return parseImageNode(element); | |||
} | |||
|
|||
if (localName == 'div' && classes.containsAll(['message_inline_image', 'message_inline_video'])) { |
There was a problem hiding this comment.
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.
e2882a7
to
2842628
Compare
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. |
There was a problem hiding this 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.
lib/model/content.dart
Outdated
const VideoNode({ | ||
super.debugHtmlNode, | ||
required this.srcUrl, | ||
this.previewImageUrl, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
lib/model/content.dart
Outdated
const sourceType = r"(message_inline_video)?(youtube-video)?(embed-video)?"; | ||
return RegExp("^message_inline_image $sourceType|$sourceType message_inline_image\$"); |
There was a problem hiding this comment.
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:
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\$"); |
lib/model/content.dart
Outdated
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'; |
There was a problem hiding this comment.
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.
lib/model/content.dart
Outdated
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) { |
There was a problem hiding this comment.
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.
lib/model/content.dart
Outdated
return _parseInlineVideo(divElement); | ||
} | ||
if (_isVideoClassNameYoutubeVideo(divElement.className) | ||
|| _isVideoClassNameEmbedVideo(divElement.className)) { | ||
return _parseEmbedVideoWithPreviewImage(divElement); | ||
} | ||
return UnimplementedBlockContentNode(htmlNode: divElement); | ||
} | ||
|
||
BlockContentNode _parseInlineVideo(dom.Element divElement) { |
There was a problem hiding this comment.
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.
test/model/content_test.dart
Outdated
|
||
static const videoEmbedYoutubeClassNameReordered = ContentExample( | ||
'video preview for youtube embed with thumbnail, (hypothetical) class name reorder', | ||
null, |
There was a problem hiding this comment.
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
.
test/model/content_test.dart
Outdated
), | ||
]); | ||
|
||
static const videoEmbedYoutubeClassNameReordered = ContentExample( |
There was a problem hiding this comment.
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
:
static const videoEmbedYoutubeClassNameReordered = ContentExample( | |
static const videoEmbedYoutubeClassesFlipped = ContentExample( |
test/model/content_test.dart
Outdated
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>', [ |
There was a problem hiding this comment.
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:
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="<iframe src="https://player.vimeo.com/video/1084537?app_id=122963" width="640" height="360" frameborder="0" allow="autoplay; fullscreen; picture-in-picture; clipboard-write" title="Big Buck Bunny"></iframe>" href="https://vimeo.com/1084537" title="Big Buck Bunny">' |
There was a problem hiding this comment.
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 [email protected] 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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
test/model/content_test.dart
Outdated
]), | ||
]); | ||
|
||
static const videoEmbedVimeoPreviewEnabled = ContentExample( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
bf4e818
to
b78af2f
Compare
Thanks @gnprice for the review! Rebased with comments addressed.
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. |
b78af2f
to
af94ffd
Compare
(rebased to main) |
There was a problem hiding this 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.
lib/model/content.dart
Outdated
assert(divElement.localName == 'div' | ||
&& _videoClassNameRegexp.hasMatch(divElement.className)); | ||
|
||
final match = _videoClassNameRegexp.firstMatch(divElement.className)!; |
There was a problem hiding this comment.
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)
lib/model/content.dart
Outdated
const sourceType = r"(message_inline_video|youtube-video|embed-video)?"; | ||
return RegExp("^message_inline_image $sourceType|$sourceType message_inline_image\$"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
lib/model/content.dart
Outdated
return switch (match.groups([1, 2])) { | ||
['message_inline_video', null] || [null, 'message_inline_video'] |
There was a problem hiding this comment.
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:
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' |
lib/model/content.dart
Outdated
=> _parseInlineVideo(divElement), | ||
['youtube-video' || 'embed-video', null] || [null, 'youtube-video' || 'embed-video'] | ||
=> _parseEmbedVideoWithPreviewImage(divElement), | ||
_ => UnimplementedBlockContentNode(htmlNode: divElement), |
There was a problem hiding this comment.
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 = () { |
There was a problem hiding this comment.
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
test/model/content_test.dart
Outdated
static const videoEmbedYoutubeClassesFlipped = ContentExample( | ||
'video preview for youtube embed with thumbnail, (hypothetical) class name reorder', | ||
'https://www.youtube.com/watch?v=aqz-KE-bpKQ', |
There was a problem hiding this comment.
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)
test/model/content_test.dart
Outdated
// 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 |
There was a problem hiding this comment.
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):
// 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
af94ffd
to
8a1a4ff
Compare
@gnprice thanks for the review! Pushed a new revision. |
There was a problem hiding this 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.
lib/model/content.dart
Outdated
{ | ||
final match = _videoClassNameRegexp.firstMatch(className); | ||
if (localName == 'div' && match != null) { | ||
return parseVideoNode(element, match); |
There was a problem hiding this comment.
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:
{ | |
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!); |
There was a problem hiding this 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.
lib/widgets/content.dart
Outdated
return MessageImage(node: node); | ||
} else if (node is VideoNode) { | ||
return MessageVideo(node: node); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
lib/widgets/content.dart
Outdated
final resolvedSrc = store.tryResolveUrl(node.srcUrl); | ||
return resolvedSrc != null | ||
? MessageInlineVideoPreview(src: resolvedSrc) | ||
: Container(); |
There was a problem hiding this comment.
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.
lib/widgets/content.dart
Outdated
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, |
There was a problem hiding this comment.
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.
lib/widgets/content.dart
Outdated
AspectRatio( | ||
aspectRatio: _controller!.value.aspectRatio, | ||
child: VideoPlayer(_controller!)), | ||
Container(color: const Color.fromRGBO(0, 0, 0, 0.30)), |
There was a problem hiding this comment.
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.
lib/widgets/content.dart
Outdated
const Icon( | ||
Icons.play_arrow_rounded, | ||
color: Colors.white, | ||
size: 25) |
There was a problem hiding this comment.
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
.)
lib/widgets/content.dart
Outdated
// 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( |
There was a problem hiding this comment.
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.
lib/widgets/content.dart
Outdated
child: Stack( | ||
alignment: Alignment.center, | ||
children: [ | ||
if (previewImageUrl != null) ...[ |
There was a problem hiding this comment.
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.
lib/widgets/content.dart
Outdated
class MessageEmbedVideoPreview extends StatelessWidget { | ||
const MessageEmbedVideoPreview({ | ||
super.key, | ||
required this.previewImage, | ||
required this.src, |
There was a problem hiding this comment.
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. OTOHnode.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
.
lib/widgets/content.dart
Outdated
Navigator.of(context).push(getLightboxRoute( | ||
context: context, | ||
message: message, | ||
src: widget.src, | ||
videoController: _controller, |
There was a problem hiding this comment.
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!
a9c7f70
to
f7face3
Compare
@gnprice, thank you for your review. I've just pushed a new revision. Apologies for the delay |
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. |
f7face3
to
cac2d42
Compare
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.)
await tester.ensureVisible(find.byType(VideoPlayer)); | ||
}); | ||
|
||
testWidgets('toggles between play and pause', (tester) async { |
There was a problem hiding this comment.
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 callFakeAsync.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!
Filed this as #656. |
Introduce tests for each of the cases mentioned here: zulip#587 (comment)
Introduce tests for each of the cases mentioned here: zulip#587 (comment)
Introduce tests for each of the cases mentioned here: zulip#587 (comment)
Introduce tests for each of the cases mentioned here: zulip#587 (comment)
Introduce tests for each of the cases mentioned here: zulip#587 (comment)
Introduce tests for each of the cases mentioned here: zulip#587 (comment)
Introduce tests for each of the cases mentioned here: zulip#587 (comment)
Introduce tests for each of the cases mentioned here: zulip#587 (comment)
Introduce tests for each of the cases mentioned here: zulip#587 (comment)
Introduce tests for each of the cases mentioned here: zulip#587 (comment)
`showDialog` returns a `Future` that resolves when the dialog is dismissed. This refactors `showErrorDialog` to stop overriding `onPressed` and `barrierIsDismissable`, and make the caller call Navigator.pop one more time once the dialog is closed. Note that we check for `mounted` again because there has been an asynchronous gap. See also: zulip#587 (comment) Signed-off-by: Zixuan James Li <[email protected]>
`showDialog` returns a `Future` that resolves when the dialog is dismissed. This refactors `showErrorDialog` to stop overriding `onPressed` and `barrierIsDismissable`, and make the caller call Navigator.pop one more time once the dialog is closed. Note that we check for `mounted` again because there has been an asynchronous gap. See also: zulip#587 (comment) Signed-off-by: Zixuan James Li <[email protected]>
`showDialog` returns a `Future` that resolves when the dialog is dismissed. This refactors `showErrorDialog` to stop overriding `onPressed` and `barrierIsDismissable`, and make the caller call Navigator.pop one more time once the dialog is closed. This is not an nfc because now the user can tap the barrier (darkened area around the dialog) to close it. Note that we check for `mounted` again because there has been an asynchronous gap. See also: zulip#587 (comment) Signed-off-by: Zixuan James Li <[email protected]>
`showDialog` returns a `Future` that resolves when the dialog is dismissed. This refactors `showErrorDialog` to stop overriding `onPressed` and `barrierDismissible`, and make the caller call Navigator.pop one more time once the dialog is closed. This is not an nfc because now the user can tap the barrier (darkened area around the dialog) to close it. Note that we check for `mounted` again because there has been an asynchronous gap. See also: zulip#587 (comment) Signed-off-by: Zixuan James Li <[email protected]>
`showDialog` returns a `Future` that resolves when the dialog is dismissed. This refactors `showErrorDialog` to stop overriding `onPressed` and `barrierDismissible`, and make the caller call `Navigator.pop` one more time once the dialog is closed. This is not an nfc because now the user can tap the barrier (darkened area around the dialog) to close it. Note that we check for `mounted` again because there has been an asynchronous gap. See also: zulip#587 (comment) Signed-off-by: Zixuan James Li <[email protected]>
`showDialog` returns a `Future` that resolves when the dialog is dismissed. This refactors `showErrorDialog` to stop overriding `onPressed` and `barrierIsDismissable`, and make the caller call Navigator.pop one more time once the dialog is closed. Note that we check for `mounted` again because there has been an asynchronous gap. See also: zulip#587 (comment) Signed-off-by: Zixuan James Li <[email protected]>
The `showDialog` function returns a future that resolves when the dialog is dismissed, and showErrorDialog returns that future. The one caller of showErrorDialog that was passing an onDismiss callback can therefore await that future instead. Do that, and eliminate the parameter. This is not an NFC change, because now the user can tap the barrier (the darkened area around the dialog) to close it. Discussion from when this parameter was introduced: zulip#587 (comment) Signed-off-by: Zixuan James Li <[email protected]>
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: [video_player] iOS seems to downloads entire file before playing flutter/flutter#126760
On iOS/macOS, unlike on Android the first frame is not shown after
initialization: First Frame of Video Once VideoPlayer Controller doesn't show in iOS Safari/Chrome 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
video-content.mp4