diff --git a/app/lib/widget/horizontal_page_viewer.dart b/app/lib/widget/horizontal_page_viewer.dart index 78d6ef25..e04a306e 100644 --- a/app/lib/widget/horizontal_page_viewer.dart +++ b/app/lib/widget/horizontal_page_viewer.dart @@ -278,6 +278,17 @@ class HorizontalPageViewerController { curve: curve, ); + void animateToPage( + int page, { + required Duration duration, + required Curve curve, + }) => + _pageController.animateToPage( + page, + duration: duration, + curve: curve, + ); + void jumpToPage(int page) { _pageController.jumpToPage(page); } diff --git a/app/lib/widget/slideshow_viewer.g.dart b/app/lib/widget/slideshow_viewer.g.dart index 141a168c..b54bce4e 100644 --- a/app/lib/widget/slideshow_viewer.g.dart +++ b/app/lib/widget/slideshow_viewer.g.dart @@ -18,7 +18,11 @@ abstract class $_StateCopyWithWorker { int? page, int? nextPage, FileDescriptor? currentFile, - bool? isShowUi}); + bool? isShowUi, + bool? isPlay, + bool? isVideoCompleted, + bool? hasPrev, + bool? hasNext}); } class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { @@ -30,13 +34,21 @@ class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { dynamic page, dynamic nextPage, dynamic currentFile, - dynamic isShowUi}) { + dynamic isShowUi, + dynamic isPlay, + dynamic isVideoCompleted, + dynamic hasPrev, + dynamic hasNext}) { return _State( hasInit: hasInit as bool? ?? that.hasInit, page: page as int? ?? that.page, nextPage: nextPage as int? ?? that.nextPage, currentFile: currentFile as FileDescriptor? ?? that.currentFile, - isShowUi: isShowUi as bool? ?? that.isShowUi); + isShowUi: isShowUi as bool? ?? that.isShowUi, + isPlay: isPlay as bool? ?? that.isPlay, + isVideoCompleted: isVideoCompleted as bool? ?? that.isVideoCompleted, + hasPrev: hasPrev as bool? ?? that.hasPrev, + hasNext: hasNext as bool? ?? that.hasNext); } final _State that; @@ -79,7 +91,7 @@ extension _$_PageViewNpLog on _PageView { extension _$_StateToString on _State { String _$toString() { // ignore: unnecessary_string_interpolations - return "_State {hasInit: $hasInit, page: $page, nextPage: $nextPage, currentFile: ${currentFile.fdPath}, isShowUi: $isShowUi}"; + return "_State {hasInit: $hasInit, page: $page, nextPage: $nextPage, currentFile: ${currentFile.fdPath}, isShowUi: $isShowUi, isPlay: $isPlay, isVideoCompleted: $isVideoCompleted, hasPrev: $hasPrev, hasNext: $hasNext}"; } } @@ -107,7 +119,35 @@ extension _$_PreloadSidePagesToString on _PreloadSidePages { extension _$_VideoCompletedToString on _VideoCompleted { String _$toString() { // ignore: unnecessary_string_interpolations - return "_VideoCompleted {page: $page}"; + return "_VideoCompleted {}"; + } +} + +extension _$_SetPauseToString on _SetPause { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetPause {}"; + } +} + +extension _$_SetPlayToString on _SetPlay { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetPlay {}"; + } +} + +extension _$_RequestPrevPageToString on _RequestPrevPage { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_RequestPrevPage {}"; + } +} + +extension _$_RequestNextPageToString on _RequestNextPage { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_RequestNextPage {}"; } } diff --git a/app/lib/widget/slideshow_viewer/bloc.dart b/app/lib/widget/slideshow_viewer/bloc.dart index 14d617f9..330b5f1f 100644 --- a/app/lib/widget/slideshow_viewer/bloc.dart +++ b/app/lib/widget/slideshow_viewer/bloc.dart @@ -14,12 +14,17 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger { on<_ToggleShowUi>(_onToggleShowUi); on<_PreloadSidePages>(_onPreloadSidePages); on<_VideoCompleted>(_onVideoCompleted); + on<_SetPause>(_onSetPause); + on<_SetPlay>(_onSetPlay); + on<_RequestPrevPage>(_onRequestPrevPage); + on<_RequestNextPage>(_onRequestNextPage); on<_SetCurrentPage>(_onSetCurrentPage); on<_NextPage>(_onNextPage); } @override Future close() { + _pageChangeTimer?.cancel(); _showUiTimer?.cancel(); for (final s in _subscriptions) { s.cancel(); @@ -48,6 +53,8 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger { hasInit: true, page: initialPage, currentFile: _getFileByPageIndex(initialPage), + hasPrev: initialPage > 0, + hasNext: pageCount == null || initialPage < (pageCount! - 1), )); _prepareNextPage(); } @@ -60,15 +67,7 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger { SystemUiMode.manual, overlays: SystemUiOverlay.values, ); - _showUiTimer?.cancel(); - _showUiTimer = Timer( - const Duration(seconds: 3), - () { - if (state.isShowUi) { - add(const _ToggleShowUi()); - } - }, - ); + _restartUiCountdown(); } else { SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); } @@ -99,8 +98,47 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger { } void _onVideoCompleted(_VideoCompleted ev, Emitter<_State> emit) { + _log.info(ev); + emit(state.copyWith(isVideoCompleted: true)); + if (state.isPlay) { + _gotoNextPage(); + } + } + + void _onSetPause(_SetPause ev, Emitter<_State> emit) { + _log.info(ev); + _pageChangeTimer?.cancel(); + _pageChangeTimer = null; + emit(state.copyWith(isPlay: false)); + _restartUiCountdown(); + } + + void _onSetPlay(_SetPlay ev, Emitter<_State> emit) { + _log.info(ev); + if (file_util.isSupportedVideoFormat(state.currentFile)) { + // only start the countdown if the video completed + if (state.isVideoCompleted) { + _pageChangeTimer?.cancel(); + _pageChangeTimer = Timer(config.duration, _gotoNextPage); + } + } else { + _pageChangeTimer?.cancel(); + _pageChangeTimer = Timer(config.duration, _gotoNextPage); + } + emit(state.copyWith(isPlay: true)); + _restartUiCountdown(); + } + + void _onRequestPrevPage(_RequestPrevPage ev, Emitter<_State> emit) { + _log.info(ev); + _gotoPrevPage(); + _restartUiCountdown(); + } + + void _onRequestNextPage(_RequestNextPage ev, Emitter<_State> emit) { _log.info(ev); _gotoNextPage(); + _restartUiCountdown(); } void _onSetCurrentPage(_SetCurrentPage ev, Emitter<_State> emit) { @@ -108,8 +146,13 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger { emit(state.copyWith( page: ev.value, currentFile: _getFileByPageIndex(ev.value), + isVideoCompleted: false, + hasPrev: ev.value > 0, + hasNext: pageCount == null || ev.value < (pageCount! - 1), )); - _prepareNextPage(); + if (state.isPlay) { + _prepareNextPage(); + } } void _onNextPage(_NextPage ev, Emitter<_State> emit) { @@ -152,8 +195,23 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger { return; } // for photos, we wait for a fixed amount of time defined in config - await Future.delayed(config.duration); - _gotoNextPage(); + _pageChangeTimer?.cancel(); + _pageChangeTimer = Timer(config.duration, _gotoNextPage); + } + + void _gotoPrevPage() { + if (isClosed) { + return; + } + final nextPage = state.page - 1; + if (nextPage < 0) { + // end reached + _log.info("[_gotoPrevPage] Reached the end"); + return; + } + _log.info("[_gotoPrevPage] To page: $nextPage"); + _pageChangeTimer?.cancel(); + add(_NextPage(nextPage)); } void _gotoNextPage() { @@ -163,16 +221,31 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger { final nextPage = state.page + 1; if (pageCount != null && nextPage >= pageCount!) { // end reached - _log.info("[_gotoNextSlide] Reached the end"); + _log.info("[_gotoNextPage] Reached the end"); return; } - _log.info("[_gotoNextSlide] Next page: $nextPage"); + _log.info("[_gotoNextPage] To page: $nextPage"); + _pageChangeTimer?.cancel(); add(_NextPage(nextPage)); } FileDescriptor _getFileByPageIndex(int pageIndex) => files[convertPageToFileIndex(pageIndex)]; + /// Restart the timer to hide the UI, mainly after user interacted with the + /// UI elements + void _restartUiCountdown() { + _showUiTimer?.cancel(); + _showUiTimer = Timer( + const Duration(seconds: 3), + () { + if (state.isShowUi) { + add(const _ToggleShowUi()); + } + }, + ); + } + final Account account; final List files; final int startIndex; @@ -181,6 +254,7 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger { late final List _shuffledIndex; late final int initialPage; late final int? pageCount; + Timer? _pageChangeTimer; final _subscriptions = []; Timer? _showUiTimer; diff --git a/app/lib/widget/slideshow_viewer/state_event.dart b/app/lib/widget/slideshow_viewer/state_event.dart index 09043039..ba23f91e 100644 --- a/app/lib/widget/slideshow_viewer/state_event.dart +++ b/app/lib/widget/slideshow_viewer/state_event.dart @@ -9,6 +9,10 @@ class _State { required this.nextPage, required this.currentFile, required this.isShowUi, + required this.isPlay, + required this.isVideoCompleted, + required this.hasPrev, + required this.hasNext, }); factory _State.init({ @@ -20,6 +24,10 @@ class _State { nextPage: 0, currentFile: initialFile, isShowUi: false, + isPlay: true, + isVideoCompleted: false, + hasPrev: false, + hasNext: false, ); @override @@ -30,6 +38,10 @@ class _State { final int nextPage; final FileDescriptor currentFile; final bool isShowUi; + final bool isPlay; + final bool isVideoCompleted; + final bool hasPrev; + final bool hasNext; } abstract class _Event {} @@ -62,12 +74,42 @@ class _PreloadSidePages implements _Event { @toString class _VideoCompleted implements _Event { - const _VideoCompleted(this.page); + const _VideoCompleted(); @override String toString() => _$toString(); +} - final int page; +@toString +class _SetPause implements _Event { + const _SetPause(); + + @override + String toString() => _$toString(); +} + +@toString +class _SetPlay implements _Event { + const _SetPlay(); + + @override + String toString() => _$toString(); +} + +@toString +class _RequestPrevPage implements _Event { + const _RequestPrevPage(); + + @override + String toString() => _$toString(); +} + +@toString +class _RequestNextPage implements _Event { + const _RequestNextPage(); + + @override + String toString() => _$toString(); } @toString diff --git a/app/lib/widget/slideshow_viewer/view.dart b/app/lib/widget/slideshow_viewer/view.dart index 3e136849..f49650ed 100644 --- a/app/lib/widget/slideshow_viewer/view.dart +++ b/app/lib/widget/slideshow_viewer/view.dart @@ -37,6 +37,88 @@ class _AppBar extends StatelessWidget { } } +class _ControlBar extends StatelessWidget { + const _ControlBar(); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + height: kToolbarHeight, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color.fromARGB(0, 0, 0, 0), + Color.fromARGB(192, 0, 0, 0), + ], + ), + ), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + height: kToolbarHeight, + alignment: Alignment.center, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _BlocSelector( + selector: (state) => state.hasPrev, + builder: (context, hasPrev) => IconButton( + onPressed: hasPrev + ? () { + context.addEvent(const _RequestPrevPage()); + } + : null, + icon: const Icon(Icons.skip_previous_outlined), + ), + ), + _BlocSelector( + selector: (state) => state.isPlay, + builder: (context, isPlay) => isPlay + ? IconButton( + onPressed: () { + context.addEvent(const _SetPause()); + }, + icon: const Icon(Icons.pause_outlined), + ) + : IconButton( + onPressed: () { + context.addEvent(const _SetPlay()); + }, + icon: const Icon(Icons.play_arrow_outlined), + ), + ), + _BlocSelector( + selector: (state) => state.hasNext, + builder: (context, hasNext) => IconButton( + onPressed: hasNext + ? () { + context.addEvent(const _RequestNextPage()); + } + : null, + icon: const Icon(Icons.skip_next_outlined), + ), + ), + ], + ), + ), + ), + ], + ); + } +} + @npLog class _Body extends StatelessWidget { const _Body(); @@ -61,10 +143,15 @@ class _Body extends StatelessWidget { builder: (context, isShowUi) => AnimatedVisibility( opacity: isShowUi ? 1 : 0, duration: k.animationDurationNormal, - child: const Align( - alignment: Alignment.topCenter, - child: _AppBar(), - ), + child: const _AppBar(), + ), + ), + _BlocSelector( + selector: (state) => state.isShowUi, + builder: (context, isShowUi) => AnimatedVisibility( + opacity: isShowUi ? 1 : 0, + duration: k.animationDurationNormal, + child: const _ControlBar(), ), ), ], @@ -87,8 +174,9 @@ class _PageViewerState extends State<_PageViewer> { listeners: [ _BlocListenerT( selector: (state) => state.nextPage, - listener: (context, state) { - _controller.nextPage( + listener: (context, nextPage) { + _controller.animateToPage( + nextPage, duration: k.animationDurationLong, curve: Curves.easeInOut, ); @@ -97,12 +185,10 @@ class _PageViewerState extends State<_PageViewer> { ], child: HorizontalPageViewer( pageCount: context.bloc.pageCount, - pageBuilder: (context, index) { - return FractionallySizedBox( - widthFactor: 1 / _viewportFraction, - child: _PageView.ofPage(context, index), - ); - }, + pageBuilder: (context, index) => FractionallySizedBox( + widthFactor: 1 / _viewportFraction, + child: _PageView.ofPage(context, index), + ), initialPage: context.bloc.initialPage, controller: _controller, viewportFraction: _viewportFraction, @@ -143,7 +229,7 @@ class _PageView extends StatelessWidget { return _VideoPageView( file: file, onCompleted: () { - context.addEvent(_VideoCompleted(page)); + context.addEvent(const _VideoCompleted()); }, ); } else {