Skip to content

Commit

Permalink
content: Implement inline video preview
Browse files Browse the repository at this point in the history
Fixes: zulip#356
  • Loading branch information
rajveermalviya committed May 5, 2024
1 parent 3075f53 commit c729b5e
Show file tree
Hide file tree
Showing 8 changed files with 451 additions and 10 deletions.
4 changes: 4 additions & 0 deletions assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,10 @@
"httpStatus": {"type": "int", "example": "500"}
}
},
"errorVideoPlayerFailed": "Unable to play the video",
"@errorVideoPlayerFailed": {
"description": "Error message when a video fails to play."
},
"serverUrlValidationErrorEmpty": "Please enter a URL.",
"@serverUrlValidationErrorEmpty": {
"description": "Error message when URL is empty"
Expand Down
44 changes: 36 additions & 8 deletions lib/widgets/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,7 @@ class BlockContentList extends StatelessWidget {
);
return MessageImage(node: node);
} else if (node is InlineVideoNode) {
return Text.rich(
TextSpan(children: [
const TextSpan(text: "(unimplemented:", style: errorStyle),
TextSpan(text: node.debugHtmlText, style: errorCodeStyle),
const TextSpan(text: ")", style: errorStyle),
]),
style: errorStyle);
return MessageInlineVideo(node: node);
} else if (node is EmbedVideoNode) {
return MessageEmbedVideo(node: node);
} else if (node is UnimplementedBlockContentNode) {
Expand Down Expand Up @@ -386,7 +380,10 @@ class MessageImage extends StatelessWidget {
return MessageMediaContainer(
onTap: resolvedSrc == null ? null : () { // TODO(log)
Navigator.of(context).push(getLightboxRoute(
context: context, message: message, src: resolvedSrc));
context: context,
message: message,
src: resolvedSrc,
mediaType: MediaType.image));
},
child: resolvedSrc == null ? null : LightboxHero(
message: message,
Expand All @@ -397,6 +394,37 @@ class MessageImage extends StatelessWidget {
}
}

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

final InlineVideoNode node;

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

return MessageMediaContainer(
onTap: resolvedSrc == null ? null : () { // TODO(log)
Navigator.of(context).push(getLightboxRoute(
context: context,
message: message,
src: resolvedSrc,
mediaType: MediaType.video));
},
child: Container(
color: Colors.black,
alignment: Alignment.center,
// To avoid potentially confusing UX, do not show play icon as
// we also disable onTap above.
child: resolvedSrc == null ? null : const Icon( // TODO(log)
Icons.play_arrow_rounded,
color: Colors.white,
size: 32)));
}
}

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

Expand Down
5 changes: 4 additions & 1 deletion lib/widgets/dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,19 @@ Future<void> showErrorDialog({
required BuildContext context,
required String title,
String? message,
bool barrierDismissible = true,
VoidCallback? onContinue,
}) {
final zulipLocalizations = ZulipLocalizations.of(context);
return showDialog(
context: context,
barrierDismissible: barrierDismissible,
builder: (BuildContext context) => AlertDialog(
title: Text(title),
content: message != null ? SingleChildScrollView(child: Text(message)) : null,
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
onPressed: onContinue ?? () => Navigator.pop(context),
child: _dialogActionText(zulipLocalizations.errorDialogContinue)),
]));
}
Expand Down
169 changes: 168 additions & 1 deletion lib/widgets/lightbox.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
import 'package:intl/intl.dart';
import 'package:video_player/video_player.dart';

import '../api/core.dart';
import '../api/model/model.dart';
import '../log.dart';
import 'content.dart';
import 'dialog.dart';
import 'page.dart';
import 'clipboard.dart';
import 'store.dart';
Expand Down Expand Up @@ -238,11 +243,164 @@ class _ImageLightboxPageState extends State<_ImageLightboxPage> {
}
}

class VideoLightboxPage extends StatefulWidget {
const VideoLightboxPage({
super.key,
required this.routeEntranceAnimation,
required this.message,
required this.src,
});

final Animation routeEntranceAnimation;
final Message message;
final Uri src;

@override
State<VideoLightboxPage> createState() => _VideoLightboxPageState();
}

class _VideoLightboxPageState extends State<VideoLightboxPage> {
VideoPlayerController? _controller;

@override
void didChangeDependencies() {
super.didChangeDependencies();
assert(debugLog('_VideoLightboxPageState($hashCode).didChangeDependencies'));

// We are pretty sure that the dependencies (PerAccountStore & ZulipLocalizations)
// won't change while user is on the lightbox page but we still
// handle the reinitialization for correctness.
if (_controller != null) _deinitialize();
_initialize();
}

Future<void> _initialize() async {
final store = PerAccountStoreWidget.of(context);
final zulipLocalizations = ZulipLocalizations.of(context);

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

try {
await _controller!.initialize();
await _controller!.play();
} catch (error) { // TODO(log)
assert(debugLog("VideoPlayerController.initialize failed: $error"));
if (mounted) {
await showErrorDialog(
context: context,
title: zulipLocalizations.errorDialogTitle,
message: zulipLocalizations.errorVideoPlayerFailed,
// To avoid showing the disabled video lightbox for the unnsupported
// video, we make sure user doesn't reach there by dismissing the dialog
// by clicking around it, user must press the 'OK' button, which will
// take user back to content message list.
barrierDismissible: false,
onContinue: () {
Navigator.pop(context); // Pops the dialog
Navigator.pop(context); // Pops the lightbox
});
}
}
}

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

void _deinitialize() {
_controller?.removeListener(_handleVideoControllerUpdates);
_controller?.dispose();
_controller = null;
}

void _handleVideoControllerUpdates() {
setState(() {});
}

@override
Widget build(BuildContext context) {
return _LightboxPageLayout(
routeEntranceAnimation: widget.routeEntranceAnimation,
message: widget.message,
buildBottomAppBar: (context, color, elevation) =>
_controller == null
? null
: BottomAppBar(
height: 150,
color: color,
elevation: elevation,
child: Column(mainAxisAlignment: MainAxisAlignment.end, children: [
Row(children: [
Text(_formatDuration(_controller!.value.position),
style: const TextStyle(color: Colors.white)),
Expanded(child: Slider(
value: _controller!.value.position.inSeconds.toDouble(),
max: _controller!.value.duration.inSeconds.toDouble(),
activeColor: Colors.white,
onChanged: (value) {
_controller!.seekTo(Duration(seconds: value.toInt()));
})),
Text(_formatDuration(_controller!.value.duration),
style: const TextStyle(color: Colors.white)),
]),
IconButton(
onPressed: () {
if (_controller!.value.isPlaying) {
_controller!.pause();
} else {
_controller!.play();
}
},
icon: Icon(
_controller!.value.isPlaying
? Icons.pause_circle_rounded
: Icons.play_circle_rounded,
size: 50)),
])),
child: SafeArea(
child: Center(
child: Stack(alignment: Alignment.center, children: [
if (_controller != null && _controller!.value.isInitialized)
AspectRatio(
aspectRatio: _controller!.value.aspectRatio,
child: VideoPlayer(_controller!)),
if (_controller == null || !_controller!.value.isInitialized || _controller!.value.isBuffering)
const SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(color: Colors.white)),
]))));
}

String _formatDuration(Duration value) {
final hours = value.inHours.toString().padLeft(2, '0');
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
return '${hours == '00' ? '' : '$hours:'}$minutes:$seconds';
}
}

enum MediaType {
video,
image
}

Route getLightboxRoute({
int? accountId,
BuildContext? context,
required Message message,
required Uri src,
required MediaType mediaType,
}) {
return AccountPageRouteBuilder(
accountId: accountId,
Expand All @@ -254,7 +412,16 @@ Route getLightboxRoute({
Animation<double> secondaryAnimation,
) {
// TODO(#40): Drag down to close?
return _ImageLightboxPage(routeEntranceAnimation: animation, message: message, src: src);
return switch (mediaType) {
MediaType.image => _ImageLightboxPage(
routeEntranceAnimation: animation,
message: message,
src: src),
MediaType.video => VideoLightboxPage(
routeEntranceAnimation: animation,
message: message,
src: src),
};
},
transitionsBuilder: (
BuildContext context,
Expand Down
35 changes: 35 additions & 0 deletions test/test_animation.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import 'package:flutter/widgets.dart';

/// 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> {
const MockCompletedAnimation();

@override
void addListener(VoidCallback listener) {
listener();
}

@override
void removeListener(VoidCallback listener) { }

@override
void addStatusListener(AnimationStatusListener listener) {
listener(AnimationStatus.completed);
}

@override
void removeStatusListener(AnimationStatusListener listener) { }

@override
AnimationStatus get status => AnimationStatus.completed;

@override
double get value => 1.0;

@override
String toString() => 'MockCompletedAnimation';
}
Loading

0 comments on commit c729b5e

Please sign in to comment.