1
1
import 'package:flutter/material.dart' ;
2
+ import 'package:flutter/scheduler.dart' ;
2
3
import 'package:flutter/services.dart' ;
3
4
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart' ;
4
5
import 'package:intl/intl.dart' ;
6
+ import 'package:video_player/video_player.dart' ;
5
7
8
+ import '../api/core.dart' ;
6
9
import '../api/model/model.dart' ;
10
+ import '../log.dart' ;
7
11
import 'content.dart' ;
12
+ import 'dialog.dart' ;
8
13
import 'page.dart' ;
9
14
import 'clipboard.dart' ;
10
15
import 'store.dart' ;
@@ -83,8 +88,8 @@ class _CopyLinkButton extends StatelessWidget {
83
88
}
84
89
}
85
90
86
- class _LightboxPage extends StatefulWidget {
87
- const _LightboxPage ({
91
+ class _ImageLightboxPage extends StatefulWidget {
92
+ const _ImageLightboxPage ({
88
93
required this .routeEntranceAnimation,
89
94
required this .message,
90
95
required this .src,
@@ -95,10 +100,10 @@ class _LightboxPage extends StatefulWidget {
95
100
final Uri src;
96
101
97
102
@override
98
- State <_LightboxPage > createState () => _LightboxPageState ();
103
+ State <_ImageLightboxPage > createState () => _ImageLightboxPageState ();
99
104
}
100
105
101
- class _LightboxPageState extends State <_LightboxPage > {
106
+ class _ImageLightboxPageState extends State <_ImageLightboxPage > {
102
107
// TODO(#38): Animate entrance/exit of header and footer
103
108
bool _headerFooterVisible = false ;
104
109
@@ -208,11 +213,244 @@ class _LightboxPageState extends State<_LightboxPage> {
208
213
}
209
214
}
210
215
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
+
211
448
Route getLightboxRoute ({
212
449
int ? accountId,
213
450
BuildContext ? context,
214
451
required Message message,
215
452
required Uri src,
453
+ required MediaType mediaType,
216
454
}) {
217
455
return AccountPageRouteBuilder (
218
456
accountId: accountId,
@@ -224,7 +462,16 @@ Route getLightboxRoute({
224
462
Animation <double > secondaryAnimation,
225
463
) {
226
464
// 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
+ };
228
475
},
229
476
transitionsBuilder: (
230
477
BuildContext context,
0 commit comments