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' ;
@@ -238,11 +243,156 @@ class _ImageLightboxPageState extends State<_ImageLightboxPage> {
238
243
}
239
244
}
240
245
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 initState () {
267
+ super .initState ();
268
+ // We delay initialization by a single frame to make sure the
269
+ // BuildContext is a valid context, as its needed to retrieve
270
+ // the PerAccountStore & ZulipLocalizations during initialization.
271
+ SchedulerBinding .instance.addPostFrameCallback ((_) => _initialize ());
272
+ }
273
+
274
+ Future <void > _initialize () async {
275
+ final store = PerAccountStoreWidget .of (context);
276
+ final zulipLocalizations = ZulipLocalizations .of (context);
277
+
278
+ assert (debugLog ('VideoPlayerController.networkUrl(${widget .src })' ));
279
+ _controller = VideoPlayerController .networkUrl (widget.src, httpHeaders: {
280
+ if (widget.src.origin == store.account.realmUrl.origin) ...authHeader (
281
+ email: store.account.email,
282
+ apiKey: store.account.apiKey,
283
+ ),
284
+ ...userAgentHeader ()
285
+ });
286
+ _controller! .addListener (_handleVideoControllerUpdates);
287
+
288
+ try {
289
+ await _controller! .initialize ();
290
+ await _controller! .play ();
291
+ } catch (error) { // TODO(log)
292
+ assert (debugLog ("VideoPlayerController.initialize failed: $error " ));
293
+ if (mounted) {
294
+ await showErrorDialog (
295
+ context: context,
296
+ title: zulipLocalizations.errorDialogTitle,
297
+ message: zulipLocalizations.errorVideoPlayerFailed,
298
+ // To avoid showing the disabled video lightbox for the unnsupported
299
+ // video, we make sure user doesn't reach there by dismissing the dialog
300
+ // by clicking around it, user must press the 'OK' button, which will
301
+ // take user back to content message list.
302
+ barrierDismissible: false ,
303
+ onContinue: () {
304
+ Navigator .pop (context); // Pops the dialog
305
+ Navigator .pop (context); // Pops the lightbox
306
+ });
307
+ }
308
+ }
309
+ }
310
+
311
+ @override
312
+ void dispose () {
313
+ _controller? .removeListener (_handleVideoControllerUpdates);
314
+ _controller? .dispose ();
315
+ super .dispose ();
316
+ }
317
+
318
+ void _handleVideoControllerUpdates () {
319
+ setState (() {});
320
+ }
321
+
322
+ @override
323
+ Widget build (BuildContext context) {
324
+ return _LightboxPageLayout (
325
+ routeEntranceAnimation: widget.routeEntranceAnimation,
326
+ message: widget.message,
327
+ buildBottomAppBar: (context, color, elevation) =>
328
+ _controller == null
329
+ ? null
330
+ : BottomAppBar (
331
+ height: 150 ,
332
+ color: color,
333
+ elevation: elevation,
334
+ child: Column (mainAxisAlignment: MainAxisAlignment .end, children: [
335
+ Row (children: [
336
+ Text (_formatDuration (_controller! .value.position),
337
+ style: const TextStyle (color: Colors .white)),
338
+ Expanded (child: Slider (
339
+ value: _controller! .value.position.inSeconds.toDouble (),
340
+ max: _controller! .value.duration.inSeconds.toDouble (),
341
+ activeColor: Colors .white,
342
+ onChanged: (value) {
343
+ _controller! .seekTo (Duration (seconds: value.toInt ()));
344
+ })),
345
+ Text (_formatDuration (_controller! .value.duration),
346
+ style: const TextStyle (color: Colors .white)),
347
+ ]),
348
+ IconButton (
349
+ onPressed: () {
350
+ if (_controller! .value.isPlaying) {
351
+ _controller! .pause ();
352
+ } else {
353
+ _controller! .play ();
354
+ }
355
+ },
356
+ icon: Icon (
357
+ _controller! .value.isPlaying
358
+ ? Icons .pause_circle_rounded
359
+ : Icons .play_circle_rounded,
360
+ size: 50 )),
361
+ ])),
362
+ child: SafeArea (
363
+ child: Center (
364
+ child: Stack (alignment: Alignment .center, children: [
365
+ if (_controller != null && _controller! .value.isInitialized)
366
+ AspectRatio (
367
+ aspectRatio: _controller! .value.aspectRatio,
368
+ child: VideoPlayer (_controller! )),
369
+ if (_controller == null || ! _controller! .value.isInitialized || _controller! .value.isBuffering)
370
+ const SizedBox (
371
+ width: 32 ,
372
+ height: 32 ,
373
+ child: CircularProgressIndicator (color: Colors .white)),
374
+ ]))));
375
+ }
376
+
377
+ String _formatDuration (Duration value) {
378
+ final hours = value.inHours.toString ().padLeft (2 , '0' );
379
+ final minutes = value.inMinutes.remainder (60 ).toString ().padLeft (2 , '0' );
380
+ final seconds = value.inSeconds.remainder (60 ).toString ().padLeft (2 , '0' );
381
+ return '${hours == '00' ? '' : '$hours :' }$minutes :$seconds ' ;
382
+ }
383
+ }
384
+
385
+ enum MediaType {
386
+ video,
387
+ image
388
+ }
389
+
241
390
Route getLightboxRoute ({
242
391
int ? accountId,
243
392
BuildContext ? context,
244
393
required Message message,
245
394
required Uri src,
395
+ required MediaType mediaType,
246
396
}) {
247
397
return AccountPageRouteBuilder (
248
398
accountId: accountId,
@@ -254,7 +404,16 @@ Route getLightboxRoute({
254
404
Animation <double > secondaryAnimation,
255
405
) {
256
406
// TODO(#40): Drag down to close?
257
- return _ImageLightboxPage (routeEntranceAnimation: animation, message: message, src: src);
407
+ return switch (mediaType) {
408
+ MediaType .image => _ImageLightboxPage (
409
+ routeEntranceAnimation: animation,
410
+ message: message,
411
+ src: src),
412
+ MediaType .video => VideoLightboxPage (
413
+ routeEntranceAnimation: animation,
414
+ message: message,
415
+ src: src),
416
+ };
258
417
},
259
418
transitionsBuilder: (
260
419
BuildContext context,
0 commit comments