Merge branch 'slideshow-control'

This commit is contained in:
Ming Ming 2024-08-10 02:17:19 +08:00
commit 06b2c2189b
7 changed files with 544 additions and 71 deletions

View file

@ -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);
}

View file

@ -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 {
@ -114,20 +118,26 @@ class _WrappedSlideshowViewerState extends State<_WrappedSlideshowViewer>
Widget build(BuildContext context) {
return Theme(
data: buildDarkTheme(context),
child: const Scaffold(
body: _Body(),
child: const AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle(
systemNavigationBarColor: Colors.black,
systemNavigationBarIconBrightness: Brightness.dark,
),
child: Scaffold(
body: _Body(),
),
),
);
}
}
// typedef _BlocBuilder = BlocBuilder<_Bloc, _State>;
// typedef _BlocListener = BlocListener<_Bloc, _State>;
typedef _BlocListener = BlocListener<_Bloc, _State>;
typedef _BlocListenerT<T> = BlocListenerT<_Bloc, _State, T>;
typedef _BlocSelector<T> = 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);
}

View file

@ -17,8 +17,15 @@ abstract class $_StateCopyWithWorker {
{bool? hasInit,
int? page,
int? nextPage,
bool? shouldAnimateNextPage,
FileDescriptor? currentFile,
bool? isShowUi});
bool? isShowUi,
bool? isPlay,
bool? isVideoCompleted,
bool? hasPrev,
bool? hasNext,
bool? isShowTimeline,
bool? hasShownTimeline});
}
class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker {
@ -29,14 +36,29 @@ class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker {
{dynamic hasInit,
dynamic page,
dynamic nextPage,
dynamic shouldAnimateNextPage,
dynamic currentFile,
dynamic isShowUi}) {
dynamic isShowUi,
dynamic isPlay,
dynamic isVideoCompleted,
dynamic hasPrev,
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);
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,
isShowTimeline: isShowTimeline as bool? ?? that.isShowTimeline,
hasShownTimeline: hasShownTimeline as bool? ?? that.hasShownTimeline);
}
final _State that;
@ -79,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}";
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}";
}
}
@ -107,7 +129,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 {}";
}
}
@ -124,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}";
}
}

View file

@ -14,13 +14,19 @@ 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);
on<_ToggleTimeline>(_onToggleTimeline);
on<_RequestPage>(_onRequestPage);
}
@override
Future<void> close() {
_showUiTimer?.cancel();
_pageChangeTimer?.cancel();
for (final s in _subscriptions) {
s.cancel();
}
@ -34,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(
@ -47,7 +56,9 @@ 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),
));
_prepareNextPage();
}
@ -60,15 +71,6 @@ 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());
}
},
);
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
}
@ -99,6 +101,41 @@ 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));
}
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));
}
void _onRequestPrevPage(_RequestPrevPage ev, Emitter<_State> emit) {
_log.info(ev);
_gotoPrevPage();
}
void _onRequestNextPage(_RequestNextPage ev, Emitter<_State> emit) {
_log.info(ev);
_gotoNextPage();
}
@ -107,14 +144,39 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger {
_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),
));
_prepareNextPage();
if (state.isPlay) {
_prepareNextPage();
}
}
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<int> shuffled, int initial, int? count}) _parseConfig({
@ -152,8 +214,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 +240,14 @@ 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)];
final Account account;
final List<FileDescriptor> files;
final int startIndex;
@ -181,7 +256,7 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger {
late final List<int> _shuffledIndex;
late final int initialPage;
late final int? pageCount;
Timer? _pageChangeTimer;
final _subscriptions = <StreamSubscription>[];
Timer? _showUiTimer;
}

View file

@ -7,8 +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({
@ -18,8 +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
@ -28,8 +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 {}
@ -62,12 +83,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
@ -89,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;
}

View file

@ -0,0 +1,116 @@
part of '../slideshow_viewer.dart';
class _Timeline extends StatefulWidget {
const _Timeline();
@override
State<StatefulWidget> 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<UserScrollNotification>(
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<int>(
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;
}

View file

@ -5,34 +5,126 @@ 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();
},
),
),
],
),
);
}
}
class _ControlBar extends StatelessWidget {
const _ControlBar();
@override
Widget build(BuildContext context) {
return SizedBox(
height: kToolbarHeight,
child: Stack(
children: [
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color.fromARGB(0, 0, 0, 0),
Color.fromARGB(192, 0, 0, 0),
],
),
),
),
Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_BlocSelector<bool>(
selector: (state) => state.hasPrev,
builder: (context, hasPrev) => IconButton(
onPressed: hasPrev
? () {
context.addEvent(const _RequestPrevPage());
}
: null,
icon: const Icon(Icons.skip_previous_outlined),
),
),
_BlocSelector<bool>(
selector: (state) => state.isPlay,
builder: (context, isPlay) => isPlay
? IconButton(
onPressed: () {
context.addEvent(const _SetPause());
},
icon: const Icon(Icons.pause_outlined, size: 36),
)
: IconButton(
onPressed: () {
context.addEvent(const _SetPlay());
},
icon: const Icon(Icons.play_arrow_outlined, size: 36),
),
),
_BlocSelector<bool>(
selector: (state) => state.hasNext,
builder: (context, hasNext) => IconButton(
onPressed: hasNext
? () {
context.addEvent(const _RequestNextPage());
}
: null,
icon: const Icon(Icons.skip_next_outlined),
),
),
],
),
),
),
AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.close),
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
onPressed: () {
Navigator.of(context).pop();
},
Align(
alignment: AlignmentDirectional.centerEnd,
child: Padding(
padding: const EdgeInsetsDirectional.only(end: 16),
child: IconButton(
onPressed: () {
context.addEvent(const _ToggleTimeline());
},
icon: _BlocSelector<bool>(
selector: (state) => state.isShowTimeline,
builder: (context, isShowTimeline) => isShowTimeline
? const Icon(Icons.view_timeline)
: const Icon(Icons.view_timeline_outlined),
),
),
),
),
),
],
],
),
);
}
}
@ -45,7 +137,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,
@ -67,6 +163,34 @@ class _Body extends StatelessWidget {
),
),
),
_BlocSelector<bool>(
selector: (state) => state.isShowUi,
builder: (context, isShowUi) => AnimatedVisibility(
opacity: isShowUi ? 1 : 0,
duration: k.animationDurationNormal,
child: const Align(
alignment: Alignment.bottomCenter,
child: _ControlBar(),
),
),
),
_BlocSelector<bool>(
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<bool>(
selector: (state) => state.hasShownTimeline,
builder: (context, hasShownTimeline) => Visibility(
visible: hasShownTimeline,
child: const _Timeline(),
),
),
),
),
],
),
);
@ -85,24 +209,28 @@ class _PageViewerState extends State<_PageViewer> {
Widget build(BuildContext context) {
return MultiBlocListener(
listeners: [
_BlocListenerT(
selector: (state) => state.nextPage,
_BlocListener(
listenWhen: (previous, current) =>
previous.nextPage != current.nextPage,
listener: (context, state) {
_controller.nextPage(
duration: k.animationDurationLong,
curve: Curves.easeInOut,
);
if (state.shouldAnimateNextPage) {
_controller.animateToPage(
state.nextPage,
duration: k.animationDurationLong,
curve: Curves.easeInOut,
);
} else {
_controller.jumpToPage(state.nextPage);
}
},
),
],
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 +271,7 @@ class _PageView extends StatelessWidget {
return _VideoPageView(
file: file,
onCompleted: () {
context.addEvent(_VideoCompleted(page));
context.addEvent(const _VideoCompleted());
},
);
} else {