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 2, 2024
1 parent d6f68c7 commit 871df2a
Show file tree
Hide file tree
Showing 7 changed files with 529 additions and 17 deletions.
42 changes: 34 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,35 @@ 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,
child: resolvedSrc == null ? null : const Icon(
Icons.play_arrow_rounded,
color: Colors.white,
size: 32)));
}
}

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

Expand Down
8 changes: 4 additions & 4 deletions lib/widgets/dialog.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';

Widget _dialogActionText(String text) {
Widget dialogActionText(String text) {
return Text(
text,

Expand Down Expand Up @@ -29,7 +29,7 @@ Future<void> showErrorDialog({
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: _dialogActionText(zulipLocalizations.errorDialogContinue)),
child: dialogActionText(zulipLocalizations.errorDialogContinue)),
]));
}

Expand All @@ -49,9 +49,9 @@ void showSuggestedActionDialog({
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: _dialogActionText(zulipLocalizations.dialogCancel)),
child: dialogActionText(zulipLocalizations.dialogCancel)),
TextButton(
onPressed: onActionButtonPress,
child: _dialogActionText(actionButtonText ?? zulipLocalizations.dialogContinue)),
child: dialogActionText(actionButtonText ?? zulipLocalizations.dialogContinue)),
]));
}
257 changes: 252 additions & 5 deletions 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 @@ -83,8 +88,8 @@ class _CopyLinkButton extends StatelessWidget {
}
}

class _LightboxPage extends StatefulWidget {
const _LightboxPage({
class _ImageLightboxPage extends StatefulWidget {
const _ImageLightboxPage({
required this.routeEntranceAnimation,
required this.message,
required this.src,
Expand All @@ -95,10 +100,10 @@ class _LightboxPage extends StatefulWidget {
final Uri src;

@override
State<_LightboxPage> createState() => _LightboxPageState();
State<_ImageLightboxPage> createState() => _ImageLightboxPageState();
}

class _LightboxPageState extends State<_LightboxPage> {
class _ImageLightboxPageState extends State<_ImageLightboxPage> {
// TODO(#38): Animate entrance/exit of header and footer
bool _headerFooterVisible = false;

Expand Down Expand Up @@ -208,11 +213,244 @@ class _LightboxPageState extends State<_LightboxPage> {
}
}

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> {
// TODO(#38): Animate entrance/exit of header and footer
bool _headerFooterVisible = false;

VideoPlayerController? _controller;

@override
void initState() {
super.initState();
widget.routeEntranceAnimation.addStatusListener(_handleRouteEntranceAnimationStatusChange);
// We delay initialization by a single frame to make sure the
// BuildContext is a valid context, as its needed to retrieve
// the PerAccountStore & ZulipLocalizations during initialization.
SchedulerBinding.instance.addPostFrameCallback((_) => _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 showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext context) => AlertDialog(
title: const Text('Unable to play video'), // TODO(i18n)
actions: [
TextButton(
onPressed: () => Navigator.popUntil(context, (route) => route is MaterialAccountPageRoute),
child: dialogActionText(zulipLocalizations.errorDialogContinue)),
]));
}
}
}

@override
void dispose() {
_controller?.removeListener(_handleVideoControllerUpdates);
_controller?.dispose();
widget.routeEntranceAnimation.removeStatusListener(_handleRouteEntranceAnimationStatusChange);
super.dispose();
}

void _handleRouteEntranceAnimationStatusChange(AnimationStatus status) {
final entranceAnimationComplete = status == AnimationStatus.completed;
setState(() {
_headerFooterVisible = entranceAnimationComplete;
});
}

void _handleTap() {
setState(() {
_headerFooterVisible = !_headerFooterVisible;
});
}

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

@override
Widget build(BuildContext context) {
final themeData = Theme.of(context);

final appBarBackgroundColor = Colors.grey.shade900.withOpacity(0.87);
const appBarForegroundColor = Colors.white;
const appBarElevation = 0.0;

PreferredSizeWidget? appBar;
if (_headerFooterVisible) {
// TODO(#45): Format with e.g. "Yesterday at 4:47 PM"
final timestampText = DateFormat
.yMMMd(/* TODO(#278): Pass selected language here, I think? */)
.add_Hms()
.format(DateTime.fromMillisecondsSinceEpoch(widget.message.timestamp * 1000));

appBar = AppBar(
centerTitle: false,
foregroundColor: appBarForegroundColor,
backgroundColor: appBarBackgroundColor,
shape: const Border(), // Remove bottom border from [AppBarTheme]
elevation: appBarElevation,

// TODO(#41): Show message author's avatar
title: RichText(
text: TextSpan(children: [
TextSpan(
text: '${widget.message.senderFullName}\n',

// Restate default
style: themeData.textTheme.titleLarge!.copyWith(color: appBarForegroundColor)),
TextSpan(
text: timestampText,

// Make smaller, like a subtitle
style: themeData.textTheme.titleSmall!.copyWith(color: appBarForegroundColor)),
])));
}

Widget? bottomAppBar;
if (_controller != null && _headerFooterVisible) {
bottomAppBar = BottomAppBar(
height: 150,
color: appBarBackgroundColor,
elevation: appBarElevation,
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Row(
children: [
Text(
_controller!.value.position.formatHHMMSS(),
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(
_controller!.value.duration.formatHHMMSS(),
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,
)),
]));
}

return Theme(
data: themeData.copyWith(
iconTheme: themeData.iconTheme.copyWith(color: appBarForegroundColor)),
child: Scaffold(
backgroundColor: Colors.black,
extendBody: true, // For the BottomAppBar
extendBodyBehindAppBar: true, // For the AppBar
appBar: appBar,
bottomNavigationBar: bottomAppBar,
body: MediaQuery(
// Clobber the MediaQueryData prepared by Scaffold with one that's not
// affected by the app bars. On this screen, the app bars are
// translucent, dismissible overlays above the pan-zoom layer in the
// Z direction, so the pan-zoom layer doesn't need avoid them in the Y
// direction.
data: MediaQuery.of(context),

child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: _handleTap,
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))
]),
))))));
}
}

extension DurationFormatting on Duration {
String formatHHMMSS() {
final hoursString = inHours.toString().padLeft(2, '0');
final minutesString = inMinutes.remainder(60).toString().padLeft(2, '0');
final secondsString = inSeconds.remainder(60).toString().padLeft(2, '0');

return '${hoursString == '00' ? '' : '$hoursString:'}$minutesString:$secondsString';
}
}

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 @@ -224,7 +462,16 @@ Route getLightboxRoute({
Animation<double> secondaryAnimation,
) {
// TODO(#40): Drag down to close?
return _LightboxPage(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
Loading

0 comments on commit 871df2a

Please sign in to comment.