mirror of
https://gitlab.com/nkming2/nc-photos.git
synced 2025-01-22 16:56:19 +01:00
Rewrite slideshow viewer
This commit is contained in:
parent
90a426b562
commit
559af7f129
6 changed files with 653 additions and 230 deletions
|
@ -12,7 +12,7 @@ part 'slideshow_dialog.g.dart';
|
||||||
|
|
||||||
@toString
|
@toString
|
||||||
class SlideshowConfig {
|
class SlideshowConfig {
|
||||||
SlideshowConfig({
|
const SlideshowConfig({
|
||||||
required this.duration,
|
required this.duration,
|
||||||
required this.isShuffle,
|
required this.isShuffle,
|
||||||
required this.isRepeat,
|
required this.isRepeat,
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:copy_with/copy_with.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:nc_photos/account.dart';
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/bloc_util.dart';
|
||||||
|
import 'package:nc_photos/controller/account_controller.dart';
|
||||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||||
import 'package:nc_photos/k.dart' as k;
|
import 'package:nc_photos/k.dart' as k;
|
||||||
|
@ -17,24 +21,28 @@ import 'package:nc_photos/widget/viewer_mixin.dart';
|
||||||
import 'package:nc_photos/widget/wakelock_util.dart';
|
import 'package:nc_photos/widget/wakelock_util.dart';
|
||||||
import 'package:np_codegen/np_codegen.dart';
|
import 'package:np_codegen/np_codegen.dart';
|
||||||
import 'package:np_ui/np_ui.dart';
|
import 'package:np_ui/np_ui.dart';
|
||||||
|
import 'package:to_string/to_string.dart';
|
||||||
|
|
||||||
part 'slideshow_viewer.g.dart';
|
part 'slideshow_viewer.g.dart';
|
||||||
|
part 'slideshow_viewer/bloc.dart';
|
||||||
|
part 'slideshow_viewer/state_event.dart';
|
||||||
|
part 'slideshow_viewer/view.dart';
|
||||||
|
|
||||||
class SlideshowViewerArguments {
|
class SlideshowViewerArguments {
|
||||||
SlideshowViewerArguments(
|
const SlideshowViewerArguments(
|
||||||
this.account,
|
this.account,
|
||||||
this.streamFiles,
|
this.files,
|
||||||
this.startIndex,
|
this.startIndex,
|
||||||
this.config,
|
this.config,
|
||||||
);
|
);
|
||||||
|
|
||||||
final Account account;
|
final Account account;
|
||||||
final List<FileDescriptor> streamFiles;
|
final List<FileDescriptor> files;
|
||||||
final int startIndex;
|
final int startIndex;
|
||||||
final SlideshowConfig config;
|
final SlideshowConfig config;
|
||||||
}
|
}
|
||||||
|
|
||||||
class SlideshowViewer extends StatefulWidget {
|
class SlideshowViewer extends StatelessWidget {
|
||||||
static const routeName = "/slideshow-viewer";
|
static const routeName = "/slideshow-viewer";
|
||||||
|
|
||||||
static Route buildRoute(SlideshowViewerArguments args) => MaterialPageRoute(
|
static Route buildRoute(SlideshowViewerArguments args) => MaterialPageRoute(
|
||||||
|
@ -44,7 +52,7 @@ class SlideshowViewer extends StatefulWidget {
|
||||||
const SlideshowViewer({
|
const SlideshowViewer({
|
||||||
super.key,
|
super.key,
|
||||||
required this.account,
|
required this.account,
|
||||||
required this.streamFiles,
|
required this.files,
|
||||||
required this.startIndex,
|
required this.startIndex,
|
||||||
required this.config,
|
required this.config,
|
||||||
});
|
});
|
||||||
|
@ -53,44 +61,49 @@ class SlideshowViewer extends StatefulWidget {
|
||||||
: this(
|
: this(
|
||||||
key: key,
|
key: key,
|
||||||
account: args.account,
|
account: args.account,
|
||||||
streamFiles: args.streamFiles,
|
files: args.files,
|
||||||
startIndex: args.startIndex,
|
startIndex: args.startIndex,
|
||||||
config: args.config,
|
config: args.config,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
createState() => _SlideshowViewerState();
|
Widget build(BuildContext context) {
|
||||||
|
return BlocProvider(
|
||||||
|
create: (context) => _Bloc(
|
||||||
|
account: context.read<AccountController>().account,
|
||||||
|
files: files,
|
||||||
|
startIndex: startIndex,
|
||||||
|
config: config,
|
||||||
|
)..add(const _Init()),
|
||||||
|
child: const _WrappedSlideshowViewer(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final Account account;
|
final Account account;
|
||||||
final List<FileDescriptor> streamFiles;
|
final List<FileDescriptor> files;
|
||||||
final int startIndex;
|
final int startIndex;
|
||||||
final SlideshowConfig config;
|
final SlideshowConfig config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@npLog
|
class _WrappedSlideshowViewer extends StatefulWidget {
|
||||||
class _SlideshowViewerState extends State<SlideshowViewer>
|
const _WrappedSlideshowViewer();
|
||||||
with
|
|
||||||
DisposableManagerMixin<SlideshowViewer>,
|
|
||||||
ViewerControllersMixin<SlideshowViewer> {
|
|
||||||
@override
|
@override
|
||||||
initState() {
|
State<StatefulWidget> createState() => _WrappedSlideshowViewerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WrappedSlideshowViewerState extends State<_WrappedSlideshowViewer>
|
||||||
|
with
|
||||||
|
DisposableManagerMixin<_WrappedSlideshowViewer>,
|
||||||
|
ViewerControllersMixin<_WrappedSlideshowViewer> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_shuffledIndex = () {
|
|
||||||
final index = [for (var i = 0; i < widget.streamFiles.length; ++i) i];
|
|
||||||
if (widget.config.isShuffle) {
|
|
||||||
return index..shuffle();
|
|
||||||
} else if (widget.config.isReverse) {
|
|
||||||
return index.reversed.toList();
|
|
||||||
} else {
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
}();
|
|
||||||
_initSlideshow();
|
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
initDisposables() {
|
List<Disposable> initDisposables() {
|
||||||
return [
|
return [
|
||||||
...super.initDisposables(),
|
...super.initDisposables(),
|
||||||
WakelockControllerDisposable(),
|
WakelockControllerDisposable(),
|
||||||
|
@ -98,210 +111,23 @@ class _SlideshowViewerState extends State<SlideshowViewer>
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Theme(
|
return Theme(
|
||||||
data: buildDarkTheme(context),
|
data: buildDarkTheme(context),
|
||||||
child: Scaffold(
|
child: const Scaffold(
|
||||||
body: Builder(
|
body: _Body(),
|
||||||
builder: _buildContent,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Widget _buildContent(BuildContext context) {
|
|
||||||
final int initialPage;
|
// typedef _BlocBuilder = BlocBuilder<_Bloc, _State>;
|
||||||
if (widget.config.isShuffle) {
|
// typedef _BlocListener = BlocListener<_Bloc, _State>;
|
||||||
// the original order is meaningless after shuffled
|
typedef _BlocListenerT<T> = BlocListenerT<_Bloc, _State, T>;
|
||||||
initialPage = 0;
|
typedef _BlocSelector<T> = BlocSelector<_Bloc, _State, T>;
|
||||||
} else if (widget.config.isReverse) {
|
|
||||||
initialPage = widget.streamFiles.length - 1 - widget.startIndex;
|
extension on BuildContext {
|
||||||
} else {
|
_Bloc get bloc => read<_Bloc>();
|
||||||
initialPage = widget.startIndex;
|
// _State get state => bloc.state;
|
||||||
}
|
void addEvent(_Event event) => bloc.add(event);
|
||||||
return GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
setState(() {
|
|
||||||
_setShowActionBar(!_isShowAppBar);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
Container(color: Colors.black),
|
|
||||||
HorizontalPageViewer(
|
|
||||||
pageCount:
|
|
||||||
widget.config.isRepeat ? null : widget.streamFiles.length,
|
|
||||||
pageBuilder: _buildPage,
|
|
||||||
initialPage: initialPage,
|
|
||||||
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,
|
|
||||||
elevation: 0,
|
|
||||||
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.fdMime}");
|
|
||||||
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,
|
|
||||||
canLoop: 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<void> _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<int> _shuffledIndex;
|
|
||||||
|
|
||||||
static const _viewportFraction = 1.05;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,13 +2,125 @@
|
||||||
|
|
||||||
part of 'slideshow_viewer.dart';
|
part of 'slideshow_viewer.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// CopyWithLintRuleGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// ignore_for_file: library_private_types_in_public_api, duplicate_ignore
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// CopyWithGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
abstract class $_StateCopyWithWorker {
|
||||||
|
_State call(
|
||||||
|
{bool? hasInit,
|
||||||
|
int? page,
|
||||||
|
int? nextPage,
|
||||||
|
FileDescriptor? currentFile,
|
||||||
|
bool? isShowUi});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker {
|
||||||
|
_$_StateCopyWithWorkerImpl(this.that);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_State call(
|
||||||
|
{dynamic hasInit,
|
||||||
|
dynamic page,
|
||||||
|
dynamic nextPage,
|
||||||
|
dynamic currentFile,
|
||||||
|
dynamic isShowUi}) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
final _State that;
|
||||||
|
}
|
||||||
|
|
||||||
|
extension $_StateCopyWith on _State {
|
||||||
|
$_StateCopyWithWorker get copyWith => _$copyWith;
|
||||||
|
$_StateCopyWithWorker get _$copyWith => _$_StateCopyWithWorkerImpl(this);
|
||||||
|
}
|
||||||
|
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
// NpLogGenerator
|
// NpLogGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
extension _$_SlideshowViewerStateNpLog on _SlideshowViewerState {
|
extension _$_BlocNpLog on _Bloc {
|
||||||
// ignore: unused_element
|
// ignore: unused_element
|
||||||
Logger get _log => log;
|
Logger get _log => log;
|
||||||
|
|
||||||
static final log = Logger("widget.slideshow_viewer._SlideshowViewerState");
|
static final log = Logger("widget.slideshow_viewer._Bloc");
|
||||||
|
}
|
||||||
|
|
||||||
|
extension _$_BodyNpLog on _Body {
|
||||||
|
// ignore: unused_element
|
||||||
|
Logger get _log => log;
|
||||||
|
|
||||||
|
static final log = Logger("widget.slideshow_viewer._Body");
|
||||||
|
}
|
||||||
|
|
||||||
|
extension _$_PageViewNpLog on _PageView {
|
||||||
|
// ignore: unused_element
|
||||||
|
Logger get _log => log;
|
||||||
|
|
||||||
|
static final log = Logger("widget.slideshow_viewer._PageView");
|
||||||
|
}
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// ToStringGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
extension _$_StateToString on _State {
|
||||||
|
String _$toString() {
|
||||||
|
// ignore: unnecessary_string_interpolations
|
||||||
|
return "_State {hasInit: $hasInit, page: $page, nextPage: $nextPage, currentFile: ${currentFile.fdPath}, isShowUi: $isShowUi}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension _$_InitToString on _Init {
|
||||||
|
String _$toString() {
|
||||||
|
// ignore: unnecessary_string_interpolations
|
||||||
|
return "_Init {}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension _$_ToggleShowUiToString on _ToggleShowUi {
|
||||||
|
String _$toString() {
|
||||||
|
// ignore: unnecessary_string_interpolations
|
||||||
|
return "_ToggleShowUi {}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension _$_PreloadSidePagesToString on _PreloadSidePages {
|
||||||
|
String _$toString() {
|
||||||
|
// ignore: unnecessary_string_interpolations
|
||||||
|
return "_PreloadSidePages {center: $center}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension _$_VideoCompletedToString on _VideoCompleted {
|
||||||
|
String _$toString() {
|
||||||
|
// ignore: unnecessary_string_interpolations
|
||||||
|
return "_VideoCompleted {page: $page}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension _$_SetCurrentPageToString on _SetCurrentPage {
|
||||||
|
String _$toString() {
|
||||||
|
// ignore: unnecessary_string_interpolations
|
||||||
|
return "_SetCurrentPage {value: $value}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension _$_NextPageToString on _NextPage {
|
||||||
|
String _$toString() {
|
||||||
|
// ignore: unnecessary_string_interpolations
|
||||||
|
return "_NextPage {value: $value}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
187
app/lib/widget/slideshow_viewer/bloc.dart
Normal file
187
app/lib/widget/slideshow_viewer/bloc.dart
Normal file
|
@ -0,0 +1,187 @@
|
||||||
|
part of '../slideshow_viewer.dart';
|
||||||
|
|
||||||
|
@npLog
|
||||||
|
class _Bloc extends Bloc<_Event, _State> with BlocLogger {
|
||||||
|
_Bloc({
|
||||||
|
required this.account,
|
||||||
|
required this.files,
|
||||||
|
required this.startIndex,
|
||||||
|
required this.config,
|
||||||
|
}) : super(_State.init(
|
||||||
|
initialFile: files[startIndex],
|
||||||
|
)) {
|
||||||
|
on<_Init>(_onInit);
|
||||||
|
on<_ToggleShowUi>(_onToggleShowUi);
|
||||||
|
on<_PreloadSidePages>(_onPreloadSidePages);
|
||||||
|
on<_VideoCompleted>(_onVideoCompleted);
|
||||||
|
on<_SetCurrentPage>(_onSetCurrentPage);
|
||||||
|
on<_NextPage>(_onNextPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() {
|
||||||
|
_showUiTimer?.cancel();
|
||||||
|
for (final s in _subscriptions) {
|
||||||
|
s.cancel();
|
||||||
|
}
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tag => _log.fullName;
|
||||||
|
|
||||||
|
/// Convert the page index to the corresponding item index
|
||||||
|
int convertPageToFileIndex(int pageIndex) =>
|
||||||
|
_shuffledIndex[pageIndex % files.length];
|
||||||
|
|
||||||
|
void _onInit(_Init ev, Emitter<_State> emit) {
|
||||||
|
_log.info(ev);
|
||||||
|
final parsedConfig = _parseConfig(
|
||||||
|
files: files,
|
||||||
|
startIndex: startIndex,
|
||||||
|
config: config,
|
||||||
|
);
|
||||||
|
_shuffledIndex = parsedConfig.shuffled;
|
||||||
|
initialPage = parsedConfig.initial;
|
||||||
|
pageCount = parsedConfig.count;
|
||||||
|
emit(state.copyWith(
|
||||||
|
hasInit: true,
|
||||||
|
page: initialPage,
|
||||||
|
currentFile: _getFileByPageIndex(initialPage),
|
||||||
|
));
|
||||||
|
_prepareNextPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onToggleShowUi(_ToggleShowUi ev, Emitter<_State> emit) {
|
||||||
|
_log.info(ev);
|
||||||
|
emit(state.copyWith(isShowUi: !state.isShowUi));
|
||||||
|
if (state.isShowUi) {
|
||||||
|
SystemChrome.setEnabledSystemUIMode(
|
||||||
|
SystemUiMode.manual,
|
||||||
|
overlays: SystemUiOverlay.values,
|
||||||
|
);
|
||||||
|
_showUiTimer?.cancel();
|
||||||
|
_showUiTimer = Timer(
|
||||||
|
const Duration(seconds: 3),
|
||||||
|
() {
|
||||||
|
if (state.isShowUi) {
|
||||||
|
add(const _ToggleShowUi());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPreloadSidePages(_PreloadSidePages ev, Emitter<_State> emit) {
|
||||||
|
_log.info(ev);
|
||||||
|
// currently pageview doesn't pre-load pages, we do it manually
|
||||||
|
// don't pre-load if user already navigated away
|
||||||
|
if (state.page != ev.center) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_log.info("[_onPreloadSidePages] Pre-loading nearby images");
|
||||||
|
if (ev.center > 0) {
|
||||||
|
final fileIndex = convertPageToFileIndex(ev.center - 1);
|
||||||
|
final prevFile = files[fileIndex];
|
||||||
|
if (file_util.isSupportedImageFormat(prevFile)) {
|
||||||
|
RemoteImageViewer.preloadImage(account, prevFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pageCount == null || ev.center + 1 < pageCount!) {
|
||||||
|
final fileIndex = convertPageToFileIndex(ev.center + 1);
|
||||||
|
final nextFile = files[fileIndex];
|
||||||
|
if (file_util.isSupportedImageFormat(nextFile)) {
|
||||||
|
RemoteImageViewer.preloadImage(account, nextFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onVideoCompleted(_VideoCompleted ev, Emitter<_State> emit) {
|
||||||
|
_log.info(ev);
|
||||||
|
_gotoNextPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSetCurrentPage(_SetCurrentPage ev, Emitter<_State> emit) {
|
||||||
|
_log.info(ev);
|
||||||
|
emit(state.copyWith(
|
||||||
|
page: ev.value,
|
||||||
|
currentFile: _getFileByPageIndex(ev.value),
|
||||||
|
));
|
||||||
|
_prepareNextPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onNextPage(_NextPage ev, Emitter<_State> emit) {
|
||||||
|
_log.info(ev);
|
||||||
|
emit(state.copyWith(nextPage: ev.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
static ({List<int> shuffled, int initial, int? count}) _parseConfig({
|
||||||
|
required List<FileDescriptor> files,
|
||||||
|
required int startIndex,
|
||||||
|
required SlideshowConfig config,
|
||||||
|
}) {
|
||||||
|
final index = [for (var i = 0; i < files.length; ++i) i];
|
||||||
|
final count = config.isRepeat ? null : files.length;
|
||||||
|
if (config.isShuffle) {
|
||||||
|
return (
|
||||||
|
shuffled: index..shuffle(),
|
||||||
|
initial: 0,
|
||||||
|
count: count,
|
||||||
|
);
|
||||||
|
} else if (config.isReverse) {
|
||||||
|
return (
|
||||||
|
shuffled: index.reversed.toList(),
|
||||||
|
initial: files.length - 1 - startIndex,
|
||||||
|
count: count,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
shuffled: index,
|
||||||
|
initial: startIndex,
|
||||||
|
count: count,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _prepareNextPage() async {
|
||||||
|
final file = state.currentFile;
|
||||||
|
if (file_util.isSupportedVideoFormat(file)) {
|
||||||
|
// for videos, we need to wait until it's ended
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// for photos, we wait for a fixed amount of time defined in config
|
||||||
|
await Future.delayed(config.duration);
|
||||||
|
_gotoNextPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _gotoNextPage() {
|
||||||
|
if (isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final nextPage = state.page + 1;
|
||||||
|
if (pageCount != null && nextPage >= pageCount!) {
|
||||||
|
// end reached
|
||||||
|
_log.info("[_gotoNextSlide] Reached the end");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_log.info("[_gotoNextSlide] Next page: $nextPage");
|
||||||
|
add(_NextPage(nextPage));
|
||||||
|
}
|
||||||
|
|
||||||
|
FileDescriptor _getFileByPageIndex(int pageIndex) =>
|
||||||
|
files[convertPageToFileIndex(pageIndex)];
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
final List<FileDescriptor> files;
|
||||||
|
final int startIndex;
|
||||||
|
final SlideshowConfig config;
|
||||||
|
|
||||||
|
late final List<int> _shuffledIndex;
|
||||||
|
late final int initialPage;
|
||||||
|
late final int? pageCount;
|
||||||
|
|
||||||
|
final _subscriptions = <StreamSubscription>[];
|
||||||
|
Timer? _showUiTimer;
|
||||||
|
}
|
91
app/lib/widget/slideshow_viewer/state_event.dart
Normal file
91
app/lib/widget/slideshow_viewer/state_event.dart
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
part of '../slideshow_viewer.dart';
|
||||||
|
|
||||||
|
@genCopyWith
|
||||||
|
@toString
|
||||||
|
class _State {
|
||||||
|
const _State({
|
||||||
|
required this.hasInit,
|
||||||
|
required this.page,
|
||||||
|
required this.nextPage,
|
||||||
|
required this.currentFile,
|
||||||
|
required this.isShowUi,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory _State.init({
|
||||||
|
required FileDescriptor initialFile,
|
||||||
|
}) =>
|
||||||
|
_State(
|
||||||
|
hasInit: false,
|
||||||
|
page: 0,
|
||||||
|
nextPage: 0,
|
||||||
|
currentFile: initialFile,
|
||||||
|
isShowUi: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => _$toString();
|
||||||
|
|
||||||
|
final bool hasInit;
|
||||||
|
final int page;
|
||||||
|
final int nextPage;
|
||||||
|
final FileDescriptor currentFile;
|
||||||
|
final bool isShowUi;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _Event {}
|
||||||
|
|
||||||
|
@toString
|
||||||
|
class _Init implements _Event {
|
||||||
|
const _Init();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => _$toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@toString
|
||||||
|
class _ToggleShowUi implements _Event {
|
||||||
|
const _ToggleShowUi();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => _$toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@toString
|
||||||
|
class _PreloadSidePages implements _Event {
|
||||||
|
const _PreloadSidePages(this.center);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => _$toString();
|
||||||
|
|
||||||
|
final int center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@toString
|
||||||
|
class _VideoCompleted implements _Event {
|
||||||
|
const _VideoCompleted(this.page);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => _$toString();
|
||||||
|
|
||||||
|
final int page;
|
||||||
|
}
|
||||||
|
|
||||||
|
@toString
|
||||||
|
class _SetCurrentPage implements _Event {
|
||||||
|
const _SetCurrentPage(this.value);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => _$toString();
|
||||||
|
|
||||||
|
final int value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@toString
|
||||||
|
class _NextPage implements _Event {
|
||||||
|
const _NextPage(this.value);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => _$toString();
|
||||||
|
|
||||||
|
final int value;
|
||||||
|
}
|
207
app/lib/widget/slideshow_viewer/view.dart
Normal file
207
app/lib/widget/slideshow_viewer/view.dart
Normal file
|
@ -0,0 +1,207 @@
|
||||||
|
part of '../slideshow_viewer.dart';
|
||||||
|
|
||||||
|
class _AppBar extends StatelessWidget {
|
||||||
|
const _AppBar();
|
||||||
|
|
||||||
|
@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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@npLog
|
||||||
|
class _Body extends StatelessWidget {
|
||||||
|
const _Body();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
context.addEvent(const _ToggleShowUi());
|
||||||
|
},
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
const ColoredBox(color: Colors.black),
|
||||||
|
_BlocSelector<bool>(
|
||||||
|
selector: (state) => state.hasInit,
|
||||||
|
builder: (context, hasInit) =>
|
||||||
|
hasInit ? const _PageViewer() : const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
_BlocSelector<bool>(
|
||||||
|
selector: (state) => state.isShowUi,
|
||||||
|
builder: (context, isShowUi) => AnimatedVisibility(
|
||||||
|
opacity: isShowUi ? 1 : 0,
|
||||||
|
duration: k.animationDurationNormal,
|
||||||
|
child: const Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: _AppBar(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PageViewer extends StatefulWidget {
|
||||||
|
const _PageViewer();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatefulWidget> createState() => _PageViewerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PageViewerState extends State<_PageViewer> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MultiBlocListener(
|
||||||
|
listeners: [
|
||||||
|
_BlocListenerT(
|
||||||
|
selector: (state) => state.nextPage,
|
||||||
|
listener: (context, state) {
|
||||||
|
_controller.nextPage(
|
||||||
|
duration: k.animationDurationLong,
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: HorizontalPageViewer(
|
||||||
|
pageCount: context.bloc.pageCount,
|
||||||
|
pageBuilder: (context, index) {
|
||||||
|
return FractionallySizedBox(
|
||||||
|
widthFactor: 1 / _viewportFraction,
|
||||||
|
child: _PageView.ofPage(context, index),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initialPage: context.bloc.initialPage,
|
||||||
|
controller: _controller,
|
||||||
|
viewportFraction: _viewportFraction,
|
||||||
|
canSwitchPage: false,
|
||||||
|
onPageChanged: (from, to) {
|
||||||
|
context.addEvent(_SetCurrentPage(to));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final _controller = HorizontalPageViewerController();
|
||||||
|
}
|
||||||
|
|
||||||
|
@npLog
|
||||||
|
class _PageView extends StatelessWidget {
|
||||||
|
const _PageView._({
|
||||||
|
required this.page,
|
||||||
|
required this.itemIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory _PageView.ofPage(BuildContext context, int page) => _PageView._(
|
||||||
|
page: page,
|
||||||
|
itemIndex: context.bloc.convertPageToFileIndex(page),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final file = context.bloc.files[itemIndex];
|
||||||
|
if (file_util.isSupportedImageFormat(file)) {
|
||||||
|
return _ImagePageView(
|
||||||
|
file: file,
|
||||||
|
onLoaded: () {
|
||||||
|
context.addEvent(_PreloadSidePages(page));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else if (file_util.isSupportedVideoFormat(file)) {
|
||||||
|
return _VideoPageView(
|
||||||
|
file: file,
|
||||||
|
onCompleted: () {
|
||||||
|
context.addEvent(_VideoCompleted(page));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_log.shout("[build] Unknown file format: ${file.fdMime}");
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final int page;
|
||||||
|
final int itemIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImagePageView extends StatelessWidget {
|
||||||
|
const _ImagePageView({
|
||||||
|
required this.file,
|
||||||
|
this.onLoaded,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return RemoteImageViewer(
|
||||||
|
account: context.bloc.account,
|
||||||
|
file: file,
|
||||||
|
canZoom: false,
|
||||||
|
onLoaded: onLoaded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final FileDescriptor file;
|
||||||
|
final VoidCallback? onLoaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VideoPageView extends StatelessWidget {
|
||||||
|
const _VideoPageView({
|
||||||
|
required this.file,
|
||||||
|
this.onCompleted,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return VideoViewer(
|
||||||
|
account: context.bloc.account,
|
||||||
|
file: file,
|
||||||
|
onLoadFailure: () {
|
||||||
|
// error, next
|
||||||
|
Future.delayed(const Duration(seconds: 2), onCompleted);
|
||||||
|
},
|
||||||
|
onPause: () {
|
||||||
|
// video ended
|
||||||
|
Future.delayed(const Duration(seconds: 2), onCompleted);
|
||||||
|
},
|
||||||
|
isControlVisible: false,
|
||||||
|
canLoop: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final FileDescriptor file;
|
||||||
|
final VoidCallback? onCompleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _viewportFraction = 1.05;
|
Loading…
Reference in a new issue