Skip to content

Commit c729b5e

Browse files
content: Implement inline video preview
Fixes: zulip#356
1 parent 3075f53 commit c729b5e

File tree

8 files changed

+451
-10
lines changed

8 files changed

+451
-10
lines changed

assets/l10n/app_en.arb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,10 @@
381381
"httpStatus": {"type": "int", "example": "500"}
382382
}
383383
},
384+
"errorVideoPlayerFailed": "Unable to play the video",
385+
"@errorVideoPlayerFailed": {
386+
"description": "Error message when a video fails to play."
387+
},
384388
"serverUrlValidationErrorEmpty": "Please enter a URL.",
385389
"@serverUrlValidationErrorEmpty": {
386390
"description": "Error message when URL is empty"

lib/widgets/content.dart

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,7 @@ class BlockContentList extends StatelessWidget {
9999
);
100100
return MessageImage(node: node);
101101
} 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);
102+
return MessageInlineVideo(node: node);
109103
} else if (node is EmbedVideoNode) {
110104
return MessageEmbedVideo(node: node);
111105
} else if (node is UnimplementedBlockContentNode) {
@@ -386,7 +380,10 @@ class MessageImage extends StatelessWidget {
386380
return MessageMediaContainer(
387381
onTap: resolvedSrc == null ? null : () { // TODO(log)
388382
Navigator.of(context).push(getLightboxRoute(
389-
context: context, message: message, src: resolvedSrc));
383+
context: context,
384+
message: message,
385+
src: resolvedSrc,
386+
mediaType: MediaType.image));
390387
},
391388
child: resolvedSrc == null ? null : LightboxHero(
392389
message: message,
@@ -397,6 +394,37 @@ class MessageImage extends StatelessWidget {
397394
}
398395
}
399396

397+
class MessageInlineVideo extends StatelessWidget {
398+
const MessageInlineVideo({super.key, required this.node});
399+
400+
final InlineVideoNode node;
401+
402+
@override
403+
Widget build(BuildContext context) {
404+
final message = InheritedMessage.of(context);
405+
final store = PerAccountStoreWidget.of(context);
406+
final resolvedSrc = store.tryResolveUrl(node.srcUrl);
407+
408+
return MessageMediaContainer(
409+
onTap: resolvedSrc == null ? null : () { // TODO(log)
410+
Navigator.of(context).push(getLightboxRoute(
411+
context: context,
412+
message: message,
413+
src: resolvedSrc,
414+
mediaType: MediaType.video));
415+
},
416+
child: Container(
417+
color: Colors.black,
418+
alignment: Alignment.center,
419+
// To avoid potentially confusing UX, do not show play icon as
420+
// we also disable onTap above.
421+
child: resolvedSrc == null ? null : const Icon( // TODO(log)
422+
Icons.play_arrow_rounded,
423+
color: Colors.white,
424+
size: 32)));
425+
}
426+
}
427+
400428
class MessageEmbedVideo extends StatelessWidget {
401429
const MessageEmbedVideo({super.key, required this.node});
402430

lib/widgets/dialog.dart

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,19 @@ Future<void> showErrorDialog({
1919
required BuildContext context,
2020
required String title,
2121
String? message,
22+
bool barrierDismissible = true,
23+
VoidCallback? onContinue,
2224
}) {
2325
final zulipLocalizations = ZulipLocalizations.of(context);
2426
return showDialog(
2527
context: context,
28+
barrierDismissible: barrierDismissible,
2629
builder: (BuildContext context) => AlertDialog(
2730
title: Text(title),
2831
content: message != null ? SingleChildScrollView(child: Text(message)) : null,
2932
actions: [
3033
TextButton(
31-
onPressed: () => Navigator.pop(context),
34+
onPressed: onContinue ?? () => Navigator.pop(context),
3235
child: _dialogActionText(zulipLocalizations.errorDialogContinue)),
3336
]));
3437
}

lib/widgets/lightbox.dart

Lines changed: 168 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import 'package:flutter/material.dart';
2+
import 'package:flutter/scheduler.dart';
23
import 'package:flutter/services.dart';
34
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
45
import 'package:intl/intl.dart';
6+
import 'package:video_player/video_player.dart';
57

8+
import '../api/core.dart';
69
import '../api/model/model.dart';
10+
import '../log.dart';
711
import 'content.dart';
12+
import 'dialog.dart';
813
import 'page.dart';
914
import 'clipboard.dart';
1015
import 'store.dart';
@@ -238,11 +243,164 @@ class _ImageLightboxPageState extends State<_ImageLightboxPage> {
238243
}
239244
}
240245

246+
class VideoLightboxPage extends StatefulWidget {
247+
const VideoLightboxPage({
248+
super.key,
249+
required this.routeEntranceAnimation,
250+
required this.message,
251+
required this.src,
252+
});
253+
254+
final Animation routeEntranceAnimation;
255+
final Message message;
256+
final Uri src;
257+
258+
@override
259+
State<VideoLightboxPage> createState() => _VideoLightboxPageState();
260+
}
261+
262+
class _VideoLightboxPageState extends State<VideoLightboxPage> {
263+
VideoPlayerController? _controller;
264+
265+
@override
266+
void didChangeDependencies() {
267+
super.didChangeDependencies();
268+
assert(debugLog('_VideoLightboxPageState($hashCode).didChangeDependencies'));
269+
270+
// We are pretty sure that the dependencies (PerAccountStore & ZulipLocalizations)
271+
// won't change while user is on the lightbox page but we still
272+
// handle the reinitialization for correctness.
273+
if (_controller != null) _deinitialize();
274+
_initialize();
275+
}
276+
277+
Future<void> _initialize() async {
278+
final store = PerAccountStoreWidget.of(context);
279+
final zulipLocalizations = ZulipLocalizations.of(context);
280+
281+
assert(debugLog('VideoPlayerController.networkUrl(${widget.src})'));
282+
_controller = VideoPlayerController.networkUrl(widget.src, httpHeaders: {
283+
if (widget.src.origin == store.account.realmUrl.origin) ...authHeader(
284+
email: store.account.email,
285+
apiKey: store.account.apiKey,
286+
),
287+
...userAgentHeader()
288+
});
289+
_controller!.addListener(_handleVideoControllerUpdates);
290+
291+
try {
292+
await _controller!.initialize();
293+
await _controller!.play();
294+
} catch (error) { // TODO(log)
295+
assert(debugLog("VideoPlayerController.initialize failed: $error"));
296+
if (mounted) {
297+
await showErrorDialog(
298+
context: context,
299+
title: zulipLocalizations.errorDialogTitle,
300+
message: zulipLocalizations.errorVideoPlayerFailed,
301+
// To avoid showing the disabled video lightbox for the unnsupported
302+
// video, we make sure user doesn't reach there by dismissing the dialog
303+
// by clicking around it, user must press the 'OK' button, which will
304+
// take user back to content message list.
305+
barrierDismissible: false,
306+
onContinue: () {
307+
Navigator.pop(context); // Pops the dialog
308+
Navigator.pop(context); // Pops the lightbox
309+
});
310+
}
311+
}
312+
}
313+
314+
@override
315+
void dispose() {
316+
_deinitialize();
317+
super.dispose();
318+
}
319+
320+
void _deinitialize() {
321+
_controller?.removeListener(_handleVideoControllerUpdates);
322+
_controller?.dispose();
323+
_controller = null;
324+
}
325+
326+
void _handleVideoControllerUpdates() {
327+
setState(() {});
328+
}
329+
330+
@override
331+
Widget build(BuildContext context) {
332+
return _LightboxPageLayout(
333+
routeEntranceAnimation: widget.routeEntranceAnimation,
334+
message: widget.message,
335+
buildBottomAppBar: (context, color, elevation) =>
336+
_controller == null
337+
? null
338+
: BottomAppBar(
339+
height: 150,
340+
color: color,
341+
elevation: elevation,
342+
child: Column(mainAxisAlignment: MainAxisAlignment.end, children: [
343+
Row(children: [
344+
Text(_formatDuration(_controller!.value.position),
345+
style: const TextStyle(color: Colors.white)),
346+
Expanded(child: Slider(
347+
value: _controller!.value.position.inSeconds.toDouble(),
348+
max: _controller!.value.duration.inSeconds.toDouble(),
349+
activeColor: Colors.white,
350+
onChanged: (value) {
351+
_controller!.seekTo(Duration(seconds: value.toInt()));
352+
})),
353+
Text(_formatDuration(_controller!.value.duration),
354+
style: const TextStyle(color: Colors.white)),
355+
]),
356+
IconButton(
357+
onPressed: () {
358+
if (_controller!.value.isPlaying) {
359+
_controller!.pause();
360+
} else {
361+
_controller!.play();
362+
}
363+
},
364+
icon: Icon(
365+
_controller!.value.isPlaying
366+
? Icons.pause_circle_rounded
367+
: Icons.play_circle_rounded,
368+
size: 50)),
369+
])),
370+
child: SafeArea(
371+
child: Center(
372+
child: Stack(alignment: Alignment.center, children: [
373+
if (_controller != null && _controller!.value.isInitialized)
374+
AspectRatio(
375+
aspectRatio: _controller!.value.aspectRatio,
376+
child: VideoPlayer(_controller!)),
377+
if (_controller == null || !_controller!.value.isInitialized || _controller!.value.isBuffering)
378+
const SizedBox(
379+
width: 32,
380+
height: 32,
381+
child: CircularProgressIndicator(color: Colors.white)),
382+
]))));
383+
}
384+
385+
String _formatDuration(Duration value) {
386+
final hours = value.inHours.toString().padLeft(2, '0');
387+
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
388+
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
389+
return '${hours == '00' ? '' : '$hours:'}$minutes:$seconds';
390+
}
391+
}
392+
393+
enum MediaType {
394+
video,
395+
image
396+
}
397+
241398
Route getLightboxRoute({
242399
int? accountId,
243400
BuildContext? context,
244401
required Message message,
245402
required Uri src,
403+
required MediaType mediaType,
246404
}) {
247405
return AccountPageRouteBuilder(
248406
accountId: accountId,
@@ -254,7 +412,16 @@ Route getLightboxRoute({
254412
Animation<double> secondaryAnimation,
255413
) {
256414
// TODO(#40): Drag down to close?
257-
return _ImageLightboxPage(routeEntranceAnimation: animation, message: message, src: src);
415+
return switch (mediaType) {
416+
MediaType.image => _ImageLightboxPage(
417+
routeEntranceAnimation: animation,
418+
message: message,
419+
src: src),
420+
MediaType.video => VideoLightboxPage(
421+
routeEntranceAnimation: animation,
422+
message: message,
423+
src: src),
424+
};
258425
},
259426
transitionsBuilder: (
260427
BuildContext context,

test/test_animation.dart

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import 'package:flutter/widgets.dart';
2+
3+
/// An animation that is always considered complete and
4+
/// immediately notifies listeners.
5+
///
6+
/// This is different from [kAlwaysCompleteAnimation] where
7+
/// it doesn't notify the listeners.
8+
class MockCompletedAnimation extends Animation<double> {
9+
const MockCompletedAnimation();
10+
11+
@override
12+
void addListener(VoidCallback listener) {
13+
listener();
14+
}
15+
16+
@override
17+
void removeListener(VoidCallback listener) { }
18+
19+
@override
20+
void addStatusListener(AnimationStatusListener listener) {
21+
listener(AnimationStatus.completed);
22+
}
23+
24+
@override
25+
void removeStatusListener(AnimationStatusListener listener) { }
26+
27+
@override
28+
AnimationStatus get status => AnimationStatus.completed;
29+
30+
@override
31+
double get value => 1.0;
32+
33+
@override
34+
String toString() => 'MockCompletedAnimation';
35+
}

0 commit comments

Comments
 (0)