import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/theme.dart'; import 'package:nc_photos/widget/animated_visibility.dart'; import 'package:nc_photos/widget/disposable.dart'; import 'package:nc_photos/widget/horizontal_page_viewer.dart'; import 'package:nc_photos/widget/image_viewer.dart'; import 'package:nc_photos/widget/slideshow_dialog.dart'; import 'package:nc_photos/widget/video_viewer.dart'; import 'package:nc_photos/widget/viewer_mixin.dart'; import 'package:nc_photos/widget/wakelock_util.dart'; class SlideshowViewerArguments { SlideshowViewerArguments( this.account, this.streamFiles, this.startIndex, this.config, ); final Account account; final List streamFiles; final int startIndex; final SlideshowConfig config; } class SlideshowViewer extends StatefulWidget { static const routeName = "/slideshow-viewer"; static Route buildRoute(SlideshowViewerArguments args) => MaterialPageRoute( builder: (context) => SlideshowViewer.fromArgs(args), ); const SlideshowViewer({ Key? key, required this.account, required this.streamFiles, required this.startIndex, required this.config, }) : super(key: key); SlideshowViewer.fromArgs(SlideshowViewerArguments args, {Key? key}) : this( key: key, account: args.account, streamFiles: args.streamFiles, startIndex: args.startIndex, config: args.config, ); @override createState() => _SlideshowViewerState(); final Account account; final List streamFiles; final int startIndex; final SlideshowConfig config; } class _SlideshowViewerState extends State with DisposableManagerMixin, ViewerControllersMixin { @override initState() { super.initState(); _shuffledIndex = () { final index = [for (var i = 0; i < widget.streamFiles.length; ++i) i]; if (widget.config.isShuffle) { return index..shuffle(); } else { return index; } }(); _initSlideshow(); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); } @override initDisposables() { return [ ...super.initDisposables(), WakelockControllerDisposable(), ]; } @override build(BuildContext context) { return AppTheme( child: Scaffold( body: Builder( builder: _buildContent, ), ), ); } Widget _buildContent(BuildContext context) { return GestureDetector( onTap: () { setState(() { _setShowActionBar(!_isShowAppBar); }); }, child: Stack( children: [ Container(color: Colors.black), HorizontalPageViewer( pageCount: widget.config.isRepeat ? null : widget.streamFiles.length, pageBuilder: _buildPage, // the original order is meaningless after shuffled initialPage: widget.config.isShuffle ? 0 : widget.startIndex, controller: _viewerController, viewportFraction: _viewportFraction, canSwitchPage: false, ), _buildAppBar(context), ], ), ); } Widget _buildAppBar(BuildContext context) { return Wrap( children: [ AnimatedVisibility( opacity: _isShowAppBar ? 1.0 : 0.0, duration: k.animationDurationNormal, child: Stack( children: [ Container( // + status bar height height: kToolbarHeight + MediaQuery.of(context).padding.top, decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment(0, -1), end: Alignment(0, 1), colors: [ Color.fromARGB(192, 0, 0, 0), Color.fromARGB(0, 0, 0, 0), ], ), ), ), AppBar( backgroundColor: Colors.transparent, shadowColor: Colors.transparent, foregroundColor: Colors.white.withOpacity(.87), leading: IconButton( icon: const Icon(Icons.close), tooltip: MaterialLocalizations.of(context).closeButtonTooltip, onPressed: () { Navigator.of(context).pop(); }, ), ), ], ), ), ], ); } Widget _buildPage(BuildContext context, int index) { final itemIndex = _transformIndex(index); _log.info("[_buildPage] Page: $index, item: $itemIndex"); return FractionallySizedBox( widthFactor: 1 / _viewportFraction, child: _buildItemView(context, itemIndex), ); } Widget _buildItemView(BuildContext context, int index) { final file = widget.streamFiles[index]; if (file_util.isSupportedImageFormat(file)) { return _buildImageView(context, index); } else if (file_util.isSupportedVideoFormat(file)) { return _buildVideoView(context, index); } else { _log.shout("[_buildItemView] Unknown file format: ${file.contentType}"); return Container(); } } Widget _buildImageView(BuildContext context, int index) { return RemoteImageViewer( account: widget.account, file: widget.streamFiles[index], canZoom: false, onLoaded: () => _onImageLoaded(index), ); } Widget _buildVideoView(BuildContext context, int index) { return VideoViewer( account: widget.account, file: widget.streamFiles[index], onLoadFailure: () { // error, next Future.delayed(const Duration(seconds: 2), _onSlideshowTick); }, onPause: () { // video ended Future.delayed(const Duration(seconds: 2), _onSlideshowTick); }, isControlVisible: false, ); } void _onImageLoaded(int index) { // currently pageview doesn't pre-load pages, we do it manually // don't pre-load if user already navigated away if (_viewerController.currentPage == index) { _log.info("[_onImageLoaded] Pre-loading nearby images"); if (index > 0) { final prevFile = widget.streamFiles[index - 1]; if (file_util.isSupportedImageFormat(prevFile)) { RemoteImageViewer.preloadImage(widget.account, prevFile); } } if (index + 1 < widget.streamFiles.length) { final nextFile = widget.streamFiles[index + 1]; if (file_util.isSupportedImageFormat(nextFile)) { RemoteImageViewer.preloadImage(widget.account, nextFile); } } } } void _initSlideshow() { _setupSlideTransition(widget.startIndex); } Future _onSlideshowTick() async { if (!mounted) { return; } _log.info("[_onSlideshowTick] Next item"); final page = _viewerController.currentPage; await _viewerController.nextPage( duration: k.animationDurationLong, curve: Curves.easeInOut); final newPage = _viewerController.currentPage; if (page == newPage) { // end reached _log.info("[_onSlideshowTick] Reached the end"); return; } _setupSlideTransition(newPage); unawaited(SystemChrome.restoreSystemUIOverlays()); } void _setupSlideTransition(int index) { final itemIndex = _transformIndex(index); final item = widget.streamFiles[itemIndex]; if (file_util.isSupportedVideoFormat(item)) { // for videos, we need to wait until it's ended } else { Future.delayed(widget.config.duration, _onSlideshowTick); } } void _setShowActionBar(bool flag) { _isShowAppBar = flag; if (flag) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values); } else { SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); } } /// Return the page index to the corresponding item index int _transformIndex(int pageIndex) => _shuffledIndex[pageIndex % widget.streamFiles.length]; var _isShowAppBar = false; final _viewerController = HorizontalPageViewerController(); // late final _SlideshowController _slideshowController; late final List _shuffledIndex; static final _log = Logger("widget.slideshow_viewer._SlideshowViewerState"); static const _viewportFraction = 1.05; }