From af4cf6497bc9961f020b38124f93ee8155223a40 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 13 Jul 2024 13:44:04 +0800 Subject: [PATCH 1/5] Add play/pause/prev/next control to slideshow viewer --- app/lib/widget/horizontal_page_viewer.dart | 11 ++ app/lib/widget/slideshow_viewer.g.dart | 50 +++++++- app/lib/widget/slideshow_viewer/bloc.dart | 102 +++++++++++++--- .../widget/slideshow_viewer/state_event.dart | 46 ++++++- app/lib/widget/slideshow_viewer/view.dart | 112 ++++++++++++++++-- 5 files changed, 287 insertions(+), 34 deletions(-) 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 { From 23930b317e84b76eafc59acb967cb6fb56d9c690 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 10 Aug 2024 01:51:57 +0800 Subject: [PATCH 2/5] Add timeline to slideshow --- app/lib/widget/slideshow_viewer.dart | 8 +- app/lib/widget/slideshow_viewer.g.dart | 32 ++++- app/lib/widget/slideshow_viewer/bloc.dart | 55 +++++---- .../widget/slideshow_viewer/state_event.dart | 27 ++++ app/lib/widget/slideshow_viewer/timeline.dart | 116 ++++++++++++++++++ app/lib/widget/slideshow_viewer/view.dart | 66 ++++++++-- 6 files changed, 260 insertions(+), 44 deletions(-) create mode 100644 app/lib/widget/slideshow_viewer/timeline.dart diff --git a/app/lib/widget/slideshow_viewer.dart b/app/lib/widget/slideshow_viewer.dart index 3d90b786..56e1fded 100644 --- a/app/lib/widget/slideshow_viewer.dart +++ b/app/lib/widget/slideshow_viewer.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:clock/clock.dart'; import 'package:copy_with/copy_with.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -15,6 +16,8 @@ import 'package:nc_photos/theme.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/network_thumbnail.dart'; +import 'package:nc_photos/widget/photo_list_item.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'; @@ -26,6 +29,7 @@ import 'package:to_string/to_string.dart'; part 'slideshow_viewer.g.dart'; part 'slideshow_viewer/bloc.dart'; part 'slideshow_viewer/state_event.dart'; +part 'slideshow_viewer/timeline.dart'; part 'slideshow_viewer/view.dart'; class SlideshowViewerArguments { @@ -122,12 +126,12 @@ class _WrappedSlideshowViewerState extends State<_WrappedSlideshowViewer> } // typedef _BlocBuilder = BlocBuilder<_Bloc, _State>; -// typedef _BlocListener = BlocListener<_Bloc, _State>; +typedef _BlocListener = BlocListener<_Bloc, _State>; typedef _BlocListenerT = BlocListenerT<_Bloc, _State, T>; typedef _BlocSelector = BlocSelector<_Bloc, _State, T>; extension on BuildContext { _Bloc get bloc => read<_Bloc>(); - // _State get state => bloc.state; + _State get state => bloc.state; void addEvent(_Event event) => bloc.add(event); } diff --git a/app/lib/widget/slideshow_viewer.g.dart b/app/lib/widget/slideshow_viewer.g.dart index b54bce4e..ee27cb64 100644 --- a/app/lib/widget/slideshow_viewer.g.dart +++ b/app/lib/widget/slideshow_viewer.g.dart @@ -17,12 +17,15 @@ abstract class $_StateCopyWithWorker { {bool? hasInit, int? page, int? nextPage, + bool? shouldAnimateNextPage, FileDescriptor? currentFile, bool? isShowUi, bool? isPlay, bool? isVideoCompleted, bool? hasPrev, - bool? hasNext}); + bool? hasNext, + bool? isShowTimeline, + bool? hasShownTimeline}); } class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { @@ -33,22 +36,29 @@ class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { {dynamic hasInit, dynamic page, dynamic nextPage, + dynamic shouldAnimateNextPage, dynamic currentFile, dynamic isShowUi, dynamic isPlay, dynamic isVideoCompleted, dynamic hasPrev, - dynamic hasNext}) { + dynamic hasNext, + dynamic isShowTimeline, + dynamic hasShownTimeline}) { return _State( hasInit: hasInit as bool? ?? that.hasInit, page: page as int? ?? that.page, nextPage: nextPage as int? ?? that.nextPage, + shouldAnimateNextPage: + shouldAnimateNextPage as bool? ?? that.shouldAnimateNextPage, currentFile: currentFile as FileDescriptor? ?? that.currentFile, 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); + hasNext: hasNext as bool? ?? that.hasNext, + isShowTimeline: isShowTimeline as bool? ?? that.isShowTimeline, + hasShownTimeline: hasShownTimeline as bool? ?? that.hasShownTimeline); } final _State that; @@ -91,7 +101,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, isPlay: $isPlay, isVideoCompleted: $isVideoCompleted, hasPrev: $hasPrev, hasNext: $hasNext}"; + return "_State {hasInit: $hasInit, page: $page, nextPage: $nextPage, shouldAnimateNextPage: $shouldAnimateNextPage, currentFile: ${currentFile.fdPath}, isShowUi: $isShowUi, isPlay: $isPlay, isVideoCompleted: $isVideoCompleted, hasPrev: $hasPrev, hasNext: $hasNext, isShowTimeline: $isShowTimeline, hasShownTimeline: $hasShownTimeline}"; } } @@ -164,3 +174,17 @@ extension _$_NextPageToString on _NextPage { return "_NextPage {value: $value}"; } } + +extension _$_ToggleTimelineToString on _ToggleTimeline { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_ToggleTimeline {}"; + } +} + +extension _$_RequestPageToString on _RequestPage { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_RequestPage {value: $value}"; + } +} diff --git a/app/lib/widget/slideshow_viewer/bloc.dart b/app/lib/widget/slideshow_viewer/bloc.dart index 330b5f1f..2561b5d3 100644 --- a/app/lib/widget/slideshow_viewer/bloc.dart +++ b/app/lib/widget/slideshow_viewer/bloc.dart @@ -20,12 +20,13 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger { on<_RequestNextPage>(_onRequestNextPage); on<_SetCurrentPage>(_onSetCurrentPage); on<_NextPage>(_onNextPage); + on<_ToggleTimeline>(_onToggleTimeline); + on<_RequestPage>(_onRequestPage); } @override Future close() { _pageChangeTimer?.cancel(); - _showUiTimer?.cancel(); for (final s in _subscriptions) { s.cancel(); } @@ -39,6 +40,9 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger { int convertPageToFileIndex(int pageIndex) => _shuffledIndex[pageIndex % files.length]; + FileDescriptor getFileByPageIndex(int pageIndex) => + files[convertPageToFileIndex(pageIndex)]; + void _onInit(_Init ev, Emitter<_State> emit) { _log.info(ev); final parsedConfig = _parseConfig( @@ -52,7 +56,7 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger { emit(state.copyWith( hasInit: true, page: initialPage, - currentFile: _getFileByPageIndex(initialPage), + currentFile: getFileByPageIndex(initialPage), hasPrev: initialPage > 0, hasNext: pageCount == null || initialPage < (pageCount! - 1), )); @@ -67,7 +71,6 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger { SystemUiMode.manual, overlays: SystemUiOverlay.values, ); - _restartUiCountdown(); } else { SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); } @@ -110,7 +113,6 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger { _pageChangeTimer?.cancel(); _pageChangeTimer = null; emit(state.copyWith(isPlay: false)); - _restartUiCountdown(); } void _onSetPlay(_SetPlay ev, Emitter<_State> emit) { @@ -126,26 +128,23 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger { _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) { _log.info(ev); emit(state.copyWith( page: ev.value, - currentFile: _getFileByPageIndex(ev.value), + currentFile: getFileByPageIndex(ev.value), isVideoCompleted: false, hasPrev: ev.value > 0, hasNext: pageCount == null || ev.value < (pageCount! - 1), @@ -157,7 +156,27 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger { void _onNextPage(_NextPage ev, Emitter<_State> emit) { _log.info(ev); - emit(state.copyWith(nextPage: ev.value)); + emit(state.copyWith( + nextPage: ev.value, + shouldAnimateNextPage: true, + )); + } + + void _onToggleTimeline(_ToggleTimeline ev, Emitter<_State> emit) { + _log.info(ev); + final next = !state.isShowTimeline; + emit(state.copyWith( + isShowTimeline: next, + hasShownTimeline: state.hasShownTimeline || next, + )); + } + + void _onRequestPage(_RequestPage ev, Emitter<_State> emit) { + _log.info(ev); + emit(state.copyWith( + nextPage: ev.value, + shouldAnimateNextPage: false, + )); } static ({List shuffled, int initial, int? count}) _parseConfig({ @@ -229,23 +248,6 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger { 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; @@ -257,5 +259,4 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger { 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 ba23f91e..00fd8a41 100644 --- a/app/lib/widget/slideshow_viewer/state_event.dart +++ b/app/lib/widget/slideshow_viewer/state_event.dart @@ -7,12 +7,15 @@ class _State { required this.hasInit, required this.page, required this.nextPage, + required this.shouldAnimateNextPage, required this.currentFile, required this.isShowUi, required this.isPlay, required this.isVideoCompleted, required this.hasPrev, required this.hasNext, + required this.isShowTimeline, + required this.hasShownTimeline, }); factory _State.init({ @@ -22,12 +25,15 @@ class _State { hasInit: false, page: 0, nextPage: 0, + shouldAnimateNextPage: true, currentFile: initialFile, isShowUi: false, isPlay: true, isVideoCompleted: false, hasPrev: false, hasNext: false, + isShowTimeline: false, + hasShownTimeline: false, ); @override @@ -36,12 +42,15 @@ class _State { final bool hasInit; final int page; final int nextPage; + final bool shouldAnimateNextPage; final FileDescriptor currentFile; final bool isShowUi; final bool isPlay; final bool isVideoCompleted; final bool hasPrev; final bool hasNext; + final bool isShowTimeline; + final bool hasShownTimeline; } abstract class _Event {} @@ -131,3 +140,21 @@ class _NextPage implements _Event { final int value; } + +@toString +class _ToggleTimeline implements _Event { + const _ToggleTimeline(); + + @override + String toString() => _$toString(); +} + +@toString +class _RequestPage implements _Event { + const _RequestPage(this.value); + + @override + String toString() => _$toString(); + + final int value; +} diff --git a/app/lib/widget/slideshow_viewer/timeline.dart b/app/lib/widget/slideshow_viewer/timeline.dart new file mode 100644 index 00000000..b37ea991 --- /dev/null +++ b/app/lib/widget/slideshow_viewer/timeline.dart @@ -0,0 +1,116 @@ +part of '../slideshow_viewer.dart'; + +class _Timeline extends StatefulWidget { + const _Timeline(); + + @override + State createState() => _TimelineState(); + + static const width = 96.0; +} + +class _TimelineState extends State<_Timeline> { + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MultiBlocListener( + listeners: [ + _BlocListenerT( + selector: (state) => state.page, + listener: (context, page) { + if (_lastInteraction == null || + clock.now().difference(_lastInteraction!) > + const Duration(seconds: 10)) { + _controller.animateTo( + page * _Timeline.width, + duration: k.animationDurationShort, + curve: Curves.easeOut, + ); + } + }, + ), + ], + child: Container( + width: _Timeline.width, + color: Colors.black.withOpacity(.65), + child: NotificationListener( + onNotification: (notification) { + _lastInteraction = clock.now(); + return false; + }, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + scrollbars: false, + overscroll: false, + ), + child: ListView.builder( + scrollDirection: Axis.vertical, + controller: _controller, + itemCount: context.bloc.pageCount, + itemBuilder: (context, i) => _BlocSelector( + selector: (state) => state.page, + builder: (context, page) => _TimelineItem( + index: i, + file: context.bloc.getFileByPageIndex(i), + isSelected: i == page, + ), + ), + ), + ), + ), + ), + ); + } + + late final _controller = ScrollController(); + DateTime? _lastInteraction; +} + +class _TimelineItem extends StatelessWidget { + const _TimelineItem({ + required this.index, + required this.file, + required this.isSelected, + }); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Container( + padding: const EdgeInsets.all(8), + color: isSelected + ? Theme.of(context).colorScheme.secondaryContainer + : Colors.transparent, + child: PhotoListImage( + account: context.bloc.account, + previewUrl: NetworkRectThumbnail.imageUrlForFile( + context.bloc.account, + file, + ), + ), + ), + if (!isSelected) + Positioned.fill( + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () { + context.addEvent(_RequestPage(index)); + }, + ), + ), + ), + ], + ); + } + + final int index; + final FileDescriptor file; + final bool isSelected; +} diff --git a/app/lib/widget/slideshow_viewer/view.dart b/app/lib/widget/slideshow_viewer/view.dart index f49650ed..2de11b1d 100644 --- a/app/lib/widget/slideshow_viewer/view.dart +++ b/app/lib/widget/slideshow_viewer/view.dart @@ -90,13 +90,13 @@ class _ControlBar extends StatelessWidget { onPressed: () { context.addEvent(const _SetPause()); }, - icon: const Icon(Icons.pause_outlined), + icon: const Icon(Icons.pause_outlined, size: 36), ) : IconButton( onPressed: () { context.addEvent(const _SetPlay()); }, - icon: const Icon(Icons.play_arrow_outlined), + icon: const Icon(Icons.play_arrow_outlined, size: 36), ), ), _BlocSelector( @@ -114,6 +114,24 @@ class _ControlBar extends StatelessWidget { ), ), ), + Positioned( + right: 16, + bottom: 0, + child: SizedBox( + height: kToolbarHeight, + child: IconButton( + onPressed: () { + context.addEvent(const _ToggleTimeline()); + }, + icon: _BlocSelector( + selector: (state) => state.isShowTimeline, + builder: (context, isShowTimeline) => isShowTimeline + ? const Icon(Icons.view_timeline) + : const Icon(Icons.view_timeline_outlined), + ), + ), + ), + ), ], ); } @@ -127,7 +145,11 @@ class _Body extends StatelessWidget { Widget build(BuildContext context) { return GestureDetector( onTap: () { - context.addEvent(const _ToggleShowUi()); + if (context.state.isShowTimeline) { + context.addEvent(const _ToggleTimeline()); + } else { + context.addEvent(const _ToggleShowUi()); + } }, child: Stack( fit: StackFit.expand, @@ -154,6 +176,23 @@ class _Body extends StatelessWidget { child: const _ControlBar(), ), ), + _BlocSelector( + selector: (state) => state.isShowTimeline, + builder: (context, isShowTimeline) => AnimatedPositionedDirectional( + top: 0, + bottom: 0, + end: isShowTimeline ? 0 : -_Timeline.width, + duration: k.animationDurationNormal, + // needed because Timeline rely on some late var + child: _BlocSelector( + selector: (state) => state.hasShownTimeline, + builder: (context, hasShownTimeline) => Visibility( + visible: hasShownTimeline, + child: const _Timeline(), + ), + ), + ), + ), ], ), ); @@ -172,14 +211,19 @@ class _PageViewerState extends State<_PageViewer> { Widget build(BuildContext context) { return MultiBlocListener( listeners: [ - _BlocListenerT( - selector: (state) => state.nextPage, - listener: (context, nextPage) { - _controller.animateToPage( - nextPage, - duration: k.animationDurationLong, - curve: Curves.easeInOut, - ); + _BlocListener( + listenWhen: (previous, current) => + previous.nextPage != current.nextPage, + listener: (context, state) { + if (state.shouldAnimateNextPage) { + _controller.animateToPage( + state.nextPage, + duration: k.animationDurationLong, + curve: Curves.easeInOut, + ); + } else { + _controller.jumpToPage(state.nextPage); + } }, ), ], From 00a0b8feed7375214863109d8abdcd7c8c5ec955 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 10 Aug 2024 02:08:40 +0800 Subject: [PATCH 3/5] Fix weird color behind slideshow timeline --- app/lib/widget/slideshow_viewer/view.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/app/lib/widget/slideshow_viewer/view.dart b/app/lib/widget/slideshow_viewer/view.dart index 2de11b1d..d70f31b4 100644 --- a/app/lib/widget/slideshow_viewer/view.dart +++ b/app/lib/widget/slideshow_viewer/view.dart @@ -24,6 +24,7 @@ class _AppBar extends StatelessWidget { AppBar( backgroundColor: Colors.transparent, elevation: 0, + scrolledUnderElevation: 0, leading: IconButton( icon: const Icon(Icons.close), tooltip: MaterialLocalizations.of(context).closeButtonTooltip, From 43ddf28d56e91c1b1356bb6c5bcfdd07e474f4c1 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 10 Aug 2024 02:09:10 +0800 Subject: [PATCH 4/5] Refactor: improve widget tree --- app/lib/widget/slideshow_viewer/view.dart | 119 +++++++++++----------- 1 file changed, 58 insertions(+), 61 deletions(-) diff --git a/app/lib/widget/slideshow_viewer/view.dart b/app/lib/widget/slideshow_viewer/view.dart index d70f31b4..cb60f4f8 100644 --- a/app/lib/widget/slideshow_viewer/view.dart +++ b/app/lib/widget/slideshow_viewer/view.dart @@ -5,35 +5,37 @@ class _AppBar extends StatelessWidget { @override Widget build(BuildContext context) { - return Stack( - children: [ - Container( - // + status bar height - height: kToolbarHeight + MediaQuery.of(context).padding.top, - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color.fromARGB(192, 0, 0, 0), - Color.fromARGB(0, 0, 0, 0), - ], + return SizedBox( + // + status bar height + height: kToolbarHeight + MediaQuery.of(context).padding.top, + child: Stack( + children: [ + Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color.fromARGB(192, 0, 0, 0), + Color.fromARGB(0, 0, 0, 0), + ], + ), ), ), - ), - AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - scrolledUnderElevation: 0, - leading: IconButton( - icon: const Icon(Icons.close), - tooltip: MaterialLocalizations.of(context).closeButtonTooltip, - onPressed: () { - Navigator.of(context).pop(); - }, + AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + leading: IconButton( + icon: const Icon(Icons.close), + tooltip: MaterialLocalizations.of(context).closeButtonTooltip, + onPressed: () { + Navigator.of(context).pop(); + }, + ), ), - ), - ], + ], + ), ); } } @@ -43,14 +45,11 @@ class _ControlBar extends StatelessWidget { @override Widget build(BuildContext context) { - return Stack( - children: [ - Positioned( - left: 0, - right: 0, - bottom: 0, - child: Container( - height: kToolbarHeight, + return SizedBox( + height: kToolbarHeight, + child: Stack( + children: [ + Container( decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, @@ -62,14 +61,7 @@ class _ControlBar extends StatelessWidget { ), ), ), - ), - Positioned( - left: 0, - right: 0, - bottom: 0, - child: Container( - height: kToolbarHeight, - alignment: Alignment.center, + Center( child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -114,26 +106,25 @@ class _ControlBar extends StatelessWidget { ], ), ), - ), - Positioned( - right: 16, - bottom: 0, - child: SizedBox( - height: kToolbarHeight, - child: IconButton( - onPressed: () { - context.addEvent(const _ToggleTimeline()); - }, - icon: _BlocSelector( - selector: (state) => state.isShowTimeline, - builder: (context, isShowTimeline) => isShowTimeline - ? const Icon(Icons.view_timeline) - : const Icon(Icons.view_timeline_outlined), + Align( + alignment: AlignmentDirectional.centerEnd, + child: Padding( + padding: const EdgeInsetsDirectional.only(end: 16), + child: IconButton( + onPressed: () { + context.addEvent(const _ToggleTimeline()); + }, + icon: _BlocSelector( + selector: (state) => state.isShowTimeline, + builder: (context, isShowTimeline) => isShowTimeline + ? const Icon(Icons.view_timeline) + : const Icon(Icons.view_timeline_outlined), + ), ), ), ), - ), - ], + ], + ), ); } } @@ -166,7 +157,10 @@ class _Body extends StatelessWidget { builder: (context, isShowUi) => AnimatedVisibility( opacity: isShowUi ? 1 : 0, duration: k.animationDurationNormal, - child: const _AppBar(), + child: const Align( + alignment: Alignment.topCenter, + child: _AppBar(), + ), ), ), _BlocSelector( @@ -174,7 +168,10 @@ class _Body extends StatelessWidget { builder: (context, isShowUi) => AnimatedVisibility( opacity: isShowUi ? 1 : 0, duration: k.animationDurationNormal, - child: const _ControlBar(), + child: const Align( + alignment: Alignment.bottomCenter, + child: _ControlBar(), + ), ), ), _BlocSelector( From fd57dd562f1e8a0704affb32667cd3dcdcec3a7a Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 10 Aug 2024 02:17:09 +0800 Subject: [PATCH 5/5] Fix navigation bar color in slideshow viewer --- app/lib/widget/slideshow_viewer.dart | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/lib/widget/slideshow_viewer.dart b/app/lib/widget/slideshow_viewer.dart index 56e1fded..da3ca0e5 100644 --- a/app/lib/widget/slideshow_viewer.dart +++ b/app/lib/widget/slideshow_viewer.dart @@ -118,8 +118,14 @@ class _WrappedSlideshowViewerState extends State<_WrappedSlideshowViewer> Widget build(BuildContext context) { return Theme( data: buildDarkTheme(context), - child: const Scaffold( - body: _Body(), + child: const AnnotatedRegion( + value: SystemUiOverlayStyle( + systemNavigationBarColor: Colors.black, + systemNavigationBarIconBrightness: Brightness.dark, + ), + child: Scaffold( + body: _Body(), + ), ), ); }