Skip to content

Commit 871df2a

Browse files
content: Implement inline video preview
Fixes: zulip#356
1 parent d6f68c7 commit 871df2a

File tree

7 files changed

+529
-17
lines changed

7 files changed

+529
-17
lines changed

lib/widgets/content.dart

Lines changed: 34 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,35 @@ 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+
child: resolvedSrc == null ? null : const Icon(
420+
Icons.play_arrow_rounded,
421+
color: Colors.white,
422+
size: 32)));
423+
}
424+
}
425+
400426
class MessageEmbedVideo extends StatelessWidget {
401427
const MessageEmbedVideo({super.key, required this.node});
402428

lib/widgets/dialog.dart

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import 'package:flutter/material.dart';
22
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
33

4-
Widget _dialogActionText(String text) {
4+
Widget dialogActionText(String text) {
55
return Text(
66
text,
77

@@ -29,7 +29,7 @@ Future<void> showErrorDialog({
2929
actions: [
3030
TextButton(
3131
onPressed: () => Navigator.pop(context),
32-
child: _dialogActionText(zulipLocalizations.errorDialogContinue)),
32+
child: dialogActionText(zulipLocalizations.errorDialogContinue)),
3333
]));
3434
}
3535

@@ -49,9 +49,9 @@ void showSuggestedActionDialog({
4949
actions: [
5050
TextButton(
5151
onPressed: () => Navigator.pop(context),
52-
child: _dialogActionText(zulipLocalizations.dialogCancel)),
52+
child: dialogActionText(zulipLocalizations.dialogCancel)),
5353
TextButton(
5454
onPressed: onActionButtonPress,
55-
child: _dialogActionText(actionButtonText ?? zulipLocalizations.dialogContinue)),
55+
child: dialogActionText(actionButtonText ?? zulipLocalizations.dialogContinue)),
5656
]));
5757
}

lib/widgets/lightbox.dart

Lines changed: 252 additions & 5 deletions
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';
@@ -83,8 +88,8 @@ class _CopyLinkButton extends StatelessWidget {
8388
}
8489
}
8590

86-
class _LightboxPage extends StatefulWidget {
87-
const _LightboxPage({
91+
class _ImageLightboxPage extends StatefulWidget {
92+
const _ImageLightboxPage({
8893
required this.routeEntranceAnimation,
8994
required this.message,
9095
required this.src,
@@ -95,10 +100,10 @@ class _LightboxPage extends StatefulWidget {
95100
final Uri src;
96101

97102
@override
98-
State<_LightboxPage> createState() => _LightboxPageState();
103+
State<_ImageLightboxPage> createState() => _ImageLightboxPageState();
99104
}
100105

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

@@ -208,11 +213,244 @@ class _LightboxPageState extends State<_LightboxPage> {
208213
}
209214
}
210215

216+
class VideoLightboxPage extends StatefulWidget {
217+
const VideoLightboxPage({
218+
super.key,
219+
required this.routeEntranceAnimation,
220+
required this.message,
221+
required this.src,
222+
});
223+
224+
final Animation routeEntranceAnimation;
225+
final Message message;
226+
final Uri src;
227+
228+
@override
229+
State<VideoLightboxPage> createState() => _VideoLightboxPageState();
230+
}
231+
232+
class _VideoLightboxPageState extends State<VideoLightboxPage> {
233+
// TODO(#38): Animate entrance/exit of header and footer
234+
bool _headerFooterVisible = false;
235+
236+
VideoPlayerController? _controller;
237+
238+
@override
239+
void initState() {
240+
super.initState();
241+
widget.routeEntranceAnimation.addStatusListener(_handleRouteEntranceAnimationStatusChange);
242+
// We delay initialization by a single frame to make sure the
243+
// BuildContext is a valid context, as its needed to retrieve
244+
// the PerAccountStore & ZulipLocalizations during initialization.
245+
SchedulerBinding.instance.addPostFrameCallback((_) => _initialize());
246+
}
247+
248+
Future<void> _initialize() async {
249+
final store = PerAccountStoreWidget.of(context);
250+
final zulipLocalizations = ZulipLocalizations.of(context);
251+
252+
assert(debugLog('VideoPlayerController.networkUrl(${widget.src})'));
253+
_controller = VideoPlayerController.networkUrl(widget.src, httpHeaders: {
254+
if (widget.src.origin == store.account.realmUrl.origin) ...authHeader(
255+
email: store.account.email,
256+
apiKey: store.account.apiKey,
257+
),
258+
...userAgentHeader()
259+
});
260+
_controller!.addListener(_handleVideoControllerUpdates);
261+
262+
try {
263+
await _controller!.initialize();
264+
await _controller!.play();
265+
} catch (error) { // TODO(log)
266+
assert(debugLog("VideoPlayerController.initialize failed: $error"));
267+
if (mounted) {
268+
await showDialog(
269+
context: context,
270+
barrierDismissible: false,
271+
builder: (BuildContext context) => AlertDialog(
272+
title: const Text('Unable to play video'), // TODO(i18n)
273+
actions: [
274+
TextButton(
275+
onPressed: () => Navigator.popUntil(context, (route) => route is MaterialAccountPageRoute),
276+
child: dialogActionText(zulipLocalizations.errorDialogContinue)),
277+
]));
278+
}
279+
}
280+
}
281+
282+
@override
283+
void dispose() {
284+
_controller?.removeListener(_handleVideoControllerUpdates);
285+
_controller?.dispose();
286+
widget.routeEntranceAnimation.removeStatusListener(_handleRouteEntranceAnimationStatusChange);
287+
super.dispose();
288+
}
289+
290+
void _handleRouteEntranceAnimationStatusChange(AnimationStatus status) {
291+
final entranceAnimationComplete = status == AnimationStatus.completed;
292+
setState(() {
293+
_headerFooterVisible = entranceAnimationComplete;
294+
});
295+
}
296+
297+
void _handleTap() {
298+
setState(() {
299+
_headerFooterVisible = !_headerFooterVisible;
300+
});
301+
}
302+
303+
void _handleVideoControllerUpdates() {
304+
setState(() {});
305+
}
306+
307+
@override
308+
Widget build(BuildContext context) {
309+
final themeData = Theme.of(context);
310+
311+
final appBarBackgroundColor = Colors.grey.shade900.withOpacity(0.87);
312+
const appBarForegroundColor = Colors.white;
313+
const appBarElevation = 0.0;
314+
315+
PreferredSizeWidget? appBar;
316+
if (_headerFooterVisible) {
317+
// TODO(#45): Format with e.g. "Yesterday at 4:47 PM"
318+
final timestampText = DateFormat
319+
.yMMMd(/* TODO(#278): Pass selected language here, I think? */)
320+
.add_Hms()
321+
.format(DateTime.fromMillisecondsSinceEpoch(widget.message.timestamp * 1000));
322+
323+
appBar = AppBar(
324+
centerTitle: false,
325+
foregroundColor: appBarForegroundColor,
326+
backgroundColor: appBarBackgroundColor,
327+
shape: const Border(), // Remove bottom border from [AppBarTheme]
328+
elevation: appBarElevation,
329+
330+
// TODO(#41): Show message author's avatar
331+
title: RichText(
332+
text: TextSpan(children: [
333+
TextSpan(
334+
text: '${widget.message.senderFullName}\n',
335+
336+
// Restate default
337+
style: themeData.textTheme.titleLarge!.copyWith(color: appBarForegroundColor)),
338+
TextSpan(
339+
text: timestampText,
340+
341+
// Make smaller, like a subtitle
342+
style: themeData.textTheme.titleSmall!.copyWith(color: appBarForegroundColor)),
343+
])));
344+
}
345+
346+
Widget? bottomAppBar;
347+
if (_controller != null && _headerFooterVisible) {
348+
bottomAppBar = BottomAppBar(
349+
height: 150,
350+
color: appBarBackgroundColor,
351+
elevation: appBarElevation,
352+
child: Column(
353+
mainAxisAlignment: MainAxisAlignment.end,
354+
children: [
355+
Row(
356+
children: [
357+
Text(
358+
_controller!.value.position.formatHHMMSS(),
359+
style: const TextStyle(color: Colors.white),
360+
),
361+
Expanded(
362+
child: Slider(
363+
value: _controller!.value.position.inSeconds.toDouble(),
364+
max: _controller!.value.duration.inSeconds.toDouble(),
365+
activeColor: Colors.white,
366+
onChanged: (value) {
367+
_controller!.seekTo(Duration(seconds: value.toInt()));
368+
},
369+
),
370+
),
371+
Text(
372+
_controller!.value.duration.formatHHMMSS(),
373+
style: const TextStyle(color: Colors.white),
374+
),
375+
],
376+
),
377+
IconButton(
378+
onPressed: () {
379+
if (_controller!.value.isPlaying) {
380+
_controller!.pause();
381+
} else {
382+
_controller!.play();
383+
}
384+
},
385+
icon: Icon(
386+
_controller!.value.isPlaying
387+
? Icons.pause_circle_rounded
388+
: Icons.play_circle_rounded,
389+
size: 50,
390+
)),
391+
]));
392+
}
393+
394+
return Theme(
395+
data: themeData.copyWith(
396+
iconTheme: themeData.iconTheme.copyWith(color: appBarForegroundColor)),
397+
child: Scaffold(
398+
backgroundColor: Colors.black,
399+
extendBody: true, // For the BottomAppBar
400+
extendBodyBehindAppBar: true, // For the AppBar
401+
appBar: appBar,
402+
bottomNavigationBar: bottomAppBar,
403+
body: MediaQuery(
404+
// Clobber the MediaQueryData prepared by Scaffold with one that's not
405+
// affected by the app bars. On this screen, the app bars are
406+
// translucent, dismissible overlays above the pan-zoom layer in the
407+
// Z direction, so the pan-zoom layer doesn't need avoid them in the Y
408+
// direction.
409+
data: MediaQuery.of(context),
410+
411+
child: GestureDetector(
412+
behavior: HitTestBehavior.translucent,
413+
onTap: _handleTap,
414+
child: SafeArea(
415+
child: Center(
416+
child: Stack(
417+
alignment: Alignment.center,
418+
children: [
419+
if (_controller != null && _controller!.value.isInitialized)
420+
AspectRatio(
421+
aspectRatio: _controller!.value.aspectRatio,
422+
child: VideoPlayer(_controller!)),
423+
if (_controller == null || !_controller!.value.isInitialized || _controller!.value.isBuffering)
424+
const SizedBox(
425+
width: 32,
426+
height: 32,
427+
child: CircularProgressIndicator(color: Colors.white))
428+
]),
429+
))))));
430+
}
431+
}
432+
433+
extension DurationFormatting on Duration {
434+
String formatHHMMSS() {
435+
final hoursString = inHours.toString().padLeft(2, '0');
436+
final minutesString = inMinutes.remainder(60).toString().padLeft(2, '0');
437+
final secondsString = inSeconds.remainder(60).toString().padLeft(2, '0');
438+
439+
return '${hoursString == '00' ? '' : '$hoursString:'}$minutesString:$secondsString';
440+
}
441+
}
442+
443+
enum MediaType {
444+
video,
445+
image
446+
}
447+
211448
Route getLightboxRoute({
212449
int? accountId,
213450
BuildContext? context,
214451
required Message message,
215452
required Uri src,
453+
required MediaType mediaType,
216454
}) {
217455
return AccountPageRouteBuilder(
218456
accountId: accountId,
@@ -224,7 +462,16 @@ Route getLightboxRoute({
224462
Animation<double> secondaryAnimation,
225463
) {
226464
// TODO(#40): Drag down to close?
227-
return _LightboxPage(routeEntranceAnimation: animation, message: message, src: src);
465+
return switch (mediaType) {
466+
MediaType.image => _ImageLightboxPage(
467+
routeEntranceAnimation: animation,
468+
message: message,
469+
src: src),
470+
MediaType.video => VideoLightboxPage(
471+
routeEntranceAnimation: animation,
472+
message: message,
473+
src: src),
474+
};
228475
},
229476
transitionsBuilder: (
230477
BuildContext context,

0 commit comments

Comments
 (0)