Slideshow

This commit is contained in:
Ming Ming 2021-09-15 05:00:24 +08:00
parent 26da1ec582
commit ec930361cd
12 changed files with 788 additions and 97 deletions

View file

@ -488,6 +488,7 @@
"dateDayInputHint": "Day",
"timeHourInputHint": "Hour",
"timeMinuteInputHint": "Minute",
"timeSecondInputHint": "Second",
"dateTimeInputInvalid": "Invalid value",
"@dateTimeInputInvalid": {
"description": "Invalid date/time input (e.g., non-numeric characters)"
@ -800,6 +801,26 @@
}
}
},
"slideshowTooltip": "Slideshow",
"@slideshowTooltip": {
"description": "A button to start a slideshow from the current collection"
},
"slideshowSetupDialogTitle": "Setup slideshow",
"@slideshowSetupDialogTitle": {
"description": "Setup slideshow before starting"
},
"slideshowSetupDialogDurationTitle": "Image duration (MM:SS)",
"@slideshowSetupDialogDurationTitle": {
"description": "Set the duration of each image in MM:SS format. This setting is ignored for videos"
},
"slideshowSetupDialogShuffleTitle": "Shuffle",
"@slideshowSetupDialogShuffleTitle": {
"description": "Whether to shuffle the collection"
},
"slideshowSetupDialogRepeatTitle": "Repeat",
"@slideshowSetupDialogRepeatTitle": {
"description": "Whether to restart the slideshow from the beginning after the last slide"
},
"errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues",
"@errorUnauthenticated": {

View file

@ -1,4 +1,13 @@
{
"de": [
"timeSecondInputHint",
"slideshowTooltip",
"slideshowSetupDialogTitle",
"slideshowSetupDialogDurationTitle",
"slideshowSetupDialogShuffleTitle",
"slideshowSetupDialogRepeatTitle"
],
"el": [
"collectionsTooltip",
"settingsViewerTitle",
@ -19,6 +28,7 @@
"settingsCaptureLogsDescription",
"captureLogDetails",
"captureLogSuccessNotification",
"timeSecondInputHint",
"sortOptionAlbumNameLabel",
"sortOptionAlbumNameDescendingLabel",
"listEmptyText",
@ -54,7 +64,21 @@
"muteTooltip",
"unmuteTooltip",
"collectionPeopleLabel",
"personPhotoCountText"
"personPhotoCountText",
"slideshowTooltip",
"slideshowSetupDialogTitle",
"slideshowSetupDialogDurationTitle",
"slideshowSetupDialogShuffleTitle",
"slideshowSetupDialogRepeatTitle"
],
"es": [
"timeSecondInputHint",
"slideshowTooltip",
"slideshowSetupDialogTitle",
"slideshowSetupDialogDurationTitle",
"slideshowSetupDialogShuffleTitle",
"slideshowSetupDialogRepeatTitle"
],
"fr": [
@ -77,6 +101,7 @@
"settingsCaptureLogsDescription",
"captureLogDetails",
"captureLogSuccessNotification",
"timeSecondInputHint",
"sortOptionAlbumNameLabel",
"sortOptionAlbumNameDescendingLabel",
"helpTooltip",
@ -92,7 +117,12 @@
"muteTooltip",
"unmuteTooltip",
"collectionPeopleLabel",
"personPhotoCountText"
"personPhotoCountText",
"slideshowTooltip",
"slideshowSetupDialogTitle",
"slideshowSetupDialogDurationTitle",
"slideshowSetupDialogShuffleTitle",
"slideshowSetupDialogRepeatTitle"
],
"ru": [
@ -100,9 +130,15 @@
"settingsCaptureLogsDescription",
"captureLogDetails",
"captureLogSuccessNotification",
"timeSecondInputHint",
"sortOptionAlbumNameLabel",
"sortOptionAlbumNameDescendingLabel",
"collectionPeopleLabel",
"personPhotoCountText"
"personPhotoCountText",
"slideshowTooltip",
"slideshowSetupDialogTitle",
"slideshowSetupDialogDurationTitle",
"slideshowSetupDialogShuffleTitle",
"slideshowSetupDialogRepeatTitle"
]
}

View file

@ -97,6 +97,23 @@ class Pref {
int getLanguageOr(int def) => getLanguage() ?? def;
Future<bool> setLanguage(int value) => _setInt(PrefKey.language, value);
int? getSlideshowDuration() =>
_pref.getInt(_toKey(PrefKey.slideshowDuration));
int getSlideshowDurationOr(int def) => getSlideshowDuration() ?? def;
Future<bool> setSlideshowDuration(int value) =>
_setInt(PrefKey.slideshowDuration, value);
bool? isSlideshowShuffle() =>
_pref.getBool(_toKey(PrefKey.isSlideshowShuffle));
bool isSlideshowShuffleOr(bool def) => isSlideshowShuffle() ?? def;
Future<bool> setSlideshowShuffle(bool value) =>
_setBool(PrefKey.isSlideshowShuffle, value);
bool? isSlideshowRepeat() => _pref.getBool(_toKey(PrefKey.isSlideshowRepeat));
bool isSlideshowRepeatOr(bool def) => isSlideshowRepeat() ?? def;
Future<bool> setSlideshowRepeat(bool value) =>
_setBool(PrefKey.isSlideshowRepeat, value);
bool? hasNewSharedAlbum() => _pref.getBool(_toKey(PrefKey.newSharedAlbum));
bool hasNewSharedAlbumOr(bool def) => hasNewSharedAlbum() ?? def;
Future<bool> setNewSharedAlbum(bool value) =>
@ -173,6 +190,12 @@ class Pref {
return "isLabEnableSharedAlbum";
case PrefKey.labEnablePeople:
return "isLabEnablePeople";
case PrefKey.slideshowDuration:
return "slideshowDuration";
case PrefKey.isSlideshowShuffle:
return "isSlideshowShuffle";
case PrefKey.isSlideshowRepeat:
return "isSlideshowRepeat";
}
}
@ -198,6 +221,9 @@ enum PrefKey {
newSharedAlbum,
labEnableSharedAlbum,
labEnablePeople,
slideshowDuration,
isSlideshowShuffle,
isSlideshowRepeat,
}
extension PrefExtension on Pref {

View file

@ -9,7 +9,7 @@ import 'package:nc_photos/widget/page_changed_listener.dart';
class HorizontalPageViewer extends StatefulWidget {
HorizontalPageViewer({
Key? key,
required this.pageCount,
this.pageCount,
required this.pageBuilder,
this.initialPage = 0,
HorizontalPageViewerController? controller,
@ -23,7 +23,7 @@ class HorizontalPageViewer extends StatefulWidget {
createState() => _HorizontalPageViewerState();
final int initialPage;
final int pageCount;
final int? pageCount;
final Widget Function(BuildContext context, int index) pageBuilder;
final HorizontalPageViewerController controller;
final double viewportFraction;
@ -226,7 +226,7 @@ class _HorizontalPageViewerState extends State<HorizontalPageViewer> {
if (!platform_k.isWeb) {
return;
}
final hasNext = page < widget.pageCount - 1;
final hasNext = widget.pageCount == null || page < widget.pageCount! - 1;
final hasPrev = page > 0;
final hasLeft =
Directionality.of(context) == TextDirection.ltr ? hasPrev : hasNext;
@ -262,6 +262,15 @@ class _HorizontalPageViewerState extends State<HorizontalPageViewer> {
}
class HorizontalPageViewerController {
Future<void> nextPage({
required Duration duration,
required Curve curve,
}) =>
_pageController.nextPage(
duration: duration,
curve: curve,
);
int get currentPage => _pageController.hasClients
? _pageController.page!.round()
: _pageController.initialPage;

View file

@ -22,6 +22,7 @@ import 'package:nc_photos/widget/root_picker.dart';
import 'package:nc_photos/widget/settings.dart';
import 'package:nc_photos/widget/setup.dart';
import 'package:nc_photos/widget/sign_in.dart';
import 'package:nc_photos/widget/slideshow_viewer.dart';
import 'package:nc_photos/widget/splash.dart';
import 'package:nc_photos/widget/trashbin_browser.dart';
import 'package:nc_photos/widget/trashbin_viewer.dart';
@ -136,6 +137,7 @@ class _MyAppState extends State<MyApp> implements SnackBarHandler {
route ??= _handlePendingAlbumsRoute(settings);
route ??= _handlePeopleBrowserRoute(settings);
route ??= _handlePersonBrowserRoute(settings);
route ??= _handleSlideshowViewerRoute(settings);
return route;
}
@ -353,6 +355,20 @@ class _MyAppState extends State<MyApp> implements SnackBarHandler {
return null;
}
Route<dynamic>? _handleSlideshowViewerRoute(RouteSettings settings) {
try {
if (settings.name == SlideshowViewer.routeName &&
settings.arguments != null) {
final args = settings.arguments as SlideshowViewerArguments;
return SlideshowViewer.buildRoute(args);
}
} catch (e) {
_log.severe(
"[_handleSlideshowViewerRoute] Failed while handling route", e);
}
return null;
}
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
late AppEventListener<ThemeChangedEvent> _themeChangedListener;

View file

@ -0,0 +1,203 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/num_extension.dart';
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/widget/switch_form_field.dart';
class SlideshowConfig {
SlideshowConfig({
required this.duration,
required this.isShuffle,
required this.isRepeat,
});
@override
toString() {
return "$runtimeType {"
"duration: $duration, "
"isShuffle: $isShuffle, "
"isRepeat: $isRepeat, "
"}";
}
/// Time where each item is shown
final Duration duration;
/// Whether to shuffle the items
final bool isShuffle;
/// Whether to repeat the slideshow after finishing
final bool isRepeat;
}
class SlideshowDialog extends StatefulWidget {
SlideshowDialog({
Key? key,
required this.duration,
required this.isShuffle,
required this.isRepeat,
}) : super(key: key);
@override
createState() => _SlideshowDialogState();
final Duration duration;
final bool isShuffle;
final bool isRepeat;
}
class _SlideshowDialogState extends State<SlideshowDialog> {
@override
initState() {
super.initState();
_durationSecond = widget.duration.inSeconds % 60;
_durationMinute = widget.duration.inMinutes;
}
@override
build(BuildContext context) {
return AlertDialog(
title: Text(L10n.global().slideshowSetupDialogTitle),
content: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
L10n.global().slideshowSetupDialogDurationTitle,
style: Theme.of(context).textTheme.subtitle2,
),
Row(
children: [
Flexible(
child: TextFormField(
decoration: InputDecoration(
hintText: L10n.global().timeMinuteInputHint,
),
keyboardType: TextInputType.number,
validator: (value) {
if (_durationSecond == 0 && int.tryParse(value!) == 0) {
return L10n.global().dateTimeInputInvalid;
}
if (int.tryParse(value!)?.inRange(0, 59) == true) {
return null;
}
return L10n.global().dateTimeInputInvalid;
},
onSaved: (value) {
_formValue.minute = int.parse(value!);
},
onChanged: (value) {
try {
_durationMinute = int.parse(value);
} catch (_) {}
},
initialValue:
widget.duration.inMinutes.toString().padLeft(2, "0"),
),
flex: 1,
),
const SizedBox(width: 4),
Text(":"),
const SizedBox(width: 4),
Flexible(
child: TextFormField(
decoration: InputDecoration(
hintText: L10n.global().timeSecondInputHint,
),
keyboardType: TextInputType.number,
validator: (value) {
if (_durationMinute == 0 && int.tryParse(value!) == 0) {
return L10n.global().dateTimeInputInvalid;
}
if (int.tryParse(value!)?.inRange(0, 59) == true) {
return null;
}
return L10n.global().dateTimeInputInvalid;
},
onSaved: (value) {
_formValue.second = int.parse(value!);
},
onChanged: (value) {
try {
_durationSecond = int.parse(value);
} catch (_) {}
},
initialValue: (widget.duration.inSeconds % 60)
.toString()
.padLeft(2, "0"),
),
flex: 1,
),
],
),
SwitchFormField(
title: Text(L10n.global().slideshowSetupDialogShuffleTitle),
onSaved: (value) {
_formValue.isShuffle = value!;
},
initialValue: widget.isShuffle,
),
SwitchFormField(
title: Text(L10n.global().slideshowSetupDialogRepeatTitle),
onSaved: (value) {
_formValue.isRepeat = value!;
},
initialValue: widget.isRepeat,
),
],
),
),
actions: [
TextButton(
onPressed: () => _onOkPressed(context),
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
);
}
void _onOkPressed(BuildContext context) {
if (_formKey.currentState?.validate() == true) {
_formKey.currentState!.save();
if (_formValue.minute == 0 && _formValue.second == 0) {
// invalid
SnackBarManager().showSnackBar(SnackBar(
content: Text(L10n.global().dateTimeInputInvalid),
duration: k.snackBarDurationNormal,
));
return;
}
final product = SlideshowConfig(
duration: Duration(
minutes: _formValue.minute,
seconds: _formValue.second,
),
isShuffle: _formValue.isShuffle,
isRepeat: _formValue.isRepeat,
);
_log.info("[_onOkPressed] Config: $product");
Navigator.of(context).pop(product);
}
}
final _formKey = GlobalKey<FormState>();
final _formValue = _FormValue();
late int _durationSecond;
late int _durationMinute;
static final _log = Logger("widget.slideshow_dialog._SlideshowDialog");
}
class _FormValue {
late int minute;
late int second;
late bool isShuffle;
late bool isRepeat;
}

View file

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

View file

@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
class SwitchFormField extends FormField<bool> {
SwitchFormField({
Key? key,
required bool initialValue,
Widget? title,
Widget? subtitle,
Widget? subtitleTrue,
Widget? subtitleFalse,
bool? dense,
FormFieldSetter<bool>? onSaved,
FormFieldValidator<bool>? validator,
bool enabled = true,
AutovalidateMode? autovalidateMode,
}) : super(
key: key,
onSaved: onSaved,
validator: validator,
initialValue: initialValue,
enabled: enabled,
autovalidateMode: autovalidateMode,
builder: (field) {
final value = field.value ?? initialValue;
return SwitchListTile(
value: value,
contentPadding: const EdgeInsets.all(0),
title: title,
subtitle: value
? (subtitleTrue ?? subtitle)
: (subtitleFalse ?? subtitle),
dense: dense,
onChanged: field.didChange,
);
},
);
}

View file

@ -22,6 +22,7 @@ class VideoViewer extends StatefulWidget {
required this.account,
required this.file,
this.onLoaded,
this.onLoadFailure,
this.onHeightChanged,
this.onPlay,
this.onPause,
@ -35,6 +36,7 @@ class VideoViewer extends StatefulWidget {
final Account account;
final File file;
final VoidCallback? onLoaded;
final VoidCallback? onLoadFailure;
final ValueChanged<double>? onHeightChanged;
final VoidCallback? onPlay;
final VoidCallback? onPause;
@ -57,6 +59,7 @@ class _VideoViewerState extends State<VideoViewer>
content: Text(exception_util.toUserString(e)),
duration: k.snackBarDurationNormal,
));
widget.onLoadFailure?.call();
});
}
@ -117,6 +120,7 @@ class _VideoViewerState extends State<VideoViewer>
content: Text(exception_util.toUserString(e)),
duration: k.snackBarDurationNormal,
));
widget.onLoadFailure?.call();
}
}

View file

@ -6,7 +6,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:logging/logging.dart';
import 'package:native_device_orientation/native_device_orientation.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/debug_util.dart';
@ -18,8 +17,6 @@ import 'package:nc_photos/exception.dart';
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/mobile/notification.dart';
import 'package:nc_photos/mobile/platform.dart'
if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform;
import 'package:nc_photos/platform/k.dart' as platform_k;
import 'package:nc_photos/pref.dart';
import 'package:nc_photos/share_handler.dart';
@ -31,10 +28,12 @@ import 'package:nc_photos/widget/animated_visibility.dart';
import 'package:nc_photos/widget/disposable.dart';
import 'package:nc_photos/widget/horizontal_page_viewer.dart';
import 'package:nc_photos/widget/image_viewer.dart';
import 'package:nc_photos/widget/slideshow_dialog.dart';
import 'package:nc_photos/widget/slideshow_viewer.dart';
import 'package:nc_photos/widget/video_viewer.dart';
import 'package:nc_photos/widget/viewer_bottom_app_bar.dart';
import 'package:nc_photos/widget/viewer_detail_pane.dart';
import 'package:screen_brightness/screen_brightness.dart';
import 'package:nc_photos/widget/viewer_mixin.dart';
class ViewerArguments {
ViewerArguments(
@ -85,20 +84,8 @@ class Viewer extends StatefulWidget {
final Album? album;
}
class _ViewerState extends State<Viewer> with DisposableManagerMixin<Viewer> {
@override
initDisposables() {
return [
...super.initDisposables(),
if (platform_k.isMobile) _ViewerBrightnessController(),
_ViewerSystemUiResetter(),
if (platform_k.isMobile && Pref.inst().isViewerForceRotationOr(false))
_ViewerOrientationController(
onChanged: _onOrientationChanged,
),
];
}
class _ViewerState extends State<Viewer>
with DisposableManagerMixin<Viewer>, ViewerControllersMixin<Viewer> {
@override
build(BuildContext context) {
return AppTheme(
@ -286,6 +273,7 @@ class _ViewerState extends State<Viewer> with DisposableManagerMixin<Viewer> {
account: widget.account,
file: widget.streamFiles[index],
album: widget.album,
onSlideshowPressed: _onSlideshowPressed,
),
),
),
@ -555,30 +543,27 @@ class _ViewerState extends State<Viewer> with DisposableManagerMixin<Viewer> {
}
}
void _onOrientationChanged(NativeDeviceOrientation orientation) {
_log.info("[_onOrientationChanged] $orientation");
if (!mounted) {
void _onSlideshowPressed() async {
final result = await showDialog<SlideshowConfig>(
context: context,
builder: (_) => SlideshowDialog(
duration: Duration(seconds: Pref.inst().getSlideshowDurationOr(5)),
isShuffle: Pref.inst().isSlideshowShuffleOr(false),
isRepeat: Pref.inst().isSlideshowRepeatOr(false),
),
);
if (result == null) {
return;
}
final List<DeviceOrientation> prefer;
switch (orientation) {
case NativeDeviceOrientation.portraitDown:
prefer = [DeviceOrientation.portraitDown];
break;
case NativeDeviceOrientation.landscapeLeft:
prefer = [DeviceOrientation.landscapeLeft];
break;
case NativeDeviceOrientation.landscapeRight:
prefer = [DeviceOrientation.landscapeRight];
break;
case NativeDeviceOrientation.portraitUp:
default:
prefer = [DeviceOrientation.portraitUp];
break;
}
SystemChrome.setPreferredOrientations(prefer);
Pref.inst()
..setSlideshowDuration(result.duration.inSeconds)
..setSlideshowShuffle(result.isShuffle)
..setSlideshowRepeat(result.isRepeat);
Navigator.of(context).pushNamed(
SlideshowViewer.routeName,
arguments: SlideshowViewerArguments(widget.account, widget.streamFiles,
_viewerController.currentPage, result),
);
}
double _calcDetailPaneOffset(int index) {
@ -681,54 +666,3 @@ class _PageState {
double? itemHeight;
bool hasLoaded = false;
}
/// Control the screen brightness according to the settings
class _ViewerBrightnessController implements Disposable {
@override
init(State state) {
final brightness = Pref.inst().getViewerScreenBrightness();
if (brightness != null && brightness >= 0) {
ScreenBrightness.setScreenBrightness(brightness / 100.0);
}
}
@override
dispose(State state) {
ScreenBrightness.resetScreenBrightness();
}
}
/// Make sure the system UI overlay is reset on dispose
class _ViewerSystemUiResetter implements Disposable {
@override
init(State state) {}
@override
dispose(State state) {
SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values);
}
}
class _ViewerOrientationController implements Disposable {
_ViewerOrientationController({
this.onChanged,
});
@override
init(State state) {
_subscription = NativeDeviceOrientationCommunicator()
.onOrientationChanged(useSensor: true)
.listen((orientation) {
onChanged?.call(orientation);
});
}
@override
dispose(State state) {
_subscription.cancel();
SystemChrome.setPreferredOrientations([]);
}
ValueChanged<NativeDeviceOrientation>? onChanged;
late final StreamSubscription<NativeDeviceOrientation> _subscription;
}

View file

@ -41,6 +41,7 @@ class ViewerDetailPane extends StatefulWidget {
required this.account,
required this.file,
this.album,
this.onSlideshowPressed,
}) : super(key: key);
@override
@ -51,6 +52,8 @@ class ViewerDetailPane extends StatefulWidget {
/// The album this file belongs to, or null
final Album? album;
final VoidCallback? onSlideshowPressed;
}
class _ViewerDetailPaneState extends State<ViewerDetailPane> {
@ -153,6 +156,11 @@ class _ViewerDetailPaneState extends State<ViewerDetailPane> {
label: L10n.global().archiveTooltip,
onPressed: () => _onArchivePressed(context),
),
_DetailPaneButton(
icon: Icons.slideshow_outlined,
label: L10n.global().slideshowTooltip,
onPressed: widget.onSlideshowPressed,
),
],
),
),

View file

@ -0,0 +1,105 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:logging/logging.dart';
import 'package:native_device_orientation/native_device_orientation.dart';
import 'package:nc_photos/platform/k.dart' as platform_k;
import 'package:nc_photos/pref.dart';
import 'package:nc_photos/widget/disposable.dart';
import 'package:screen_brightness/screen_brightness.dart';
mixin ViewerControllersMixin<T extends StatefulWidget>
on DisposableManagerMixin<T> {
@override
initDisposables() {
return [
...super.initDisposables(),
if (platform_k.isMobile) _ViewerBrightnessController(),
_ViewerSystemUiResetter(),
if (platform_k.isMobile && Pref.inst().isViewerForceRotationOr(false))
_ViewerOrientationController(
onChanged: _onOrientationChanged,
),
];
}
void _onOrientationChanged(NativeDeviceOrientation orientation) {
_log.info("[_onOrientationChanged] $orientation");
if (!mounted) {
return;
}
final List<DeviceOrientation> prefer;
switch (orientation) {
case NativeDeviceOrientation.portraitDown:
prefer = [DeviceOrientation.portraitDown];
break;
case NativeDeviceOrientation.landscapeLeft:
prefer = [DeviceOrientation.landscapeLeft];
break;
case NativeDeviceOrientation.landscapeRight:
prefer = [DeviceOrientation.landscapeRight];
break;
case NativeDeviceOrientation.portraitUp:
default:
prefer = [DeviceOrientation.portraitUp];
break;
}
SystemChrome.setPreferredOrientations(prefer);
}
static final _log = Logger("widget.viewer_mixin.ViewerControllersMixin");
}
/// Control the screen brightness according to the settings
class _ViewerBrightnessController implements Disposable {
@override
init(State state) {
final brightness = Pref.inst().getViewerScreenBrightness();
if (brightness != null && brightness >= 0) {
ScreenBrightness.setScreenBrightness(brightness / 100.0);
}
}
@override
dispose(State state) {
ScreenBrightness.resetScreenBrightness();
}
}
/// Make sure the system UI overlay is reset on dispose
class _ViewerSystemUiResetter implements Disposable {
@override
init(State state) {}
@override
dispose(State state) {
SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values);
}
}
class _ViewerOrientationController implements Disposable {
_ViewerOrientationController({
this.onChanged,
});
@override
init(State state) {
_subscription = NativeDeviceOrientationCommunicator()
.onOrientationChanged(useSensor: true)
.listen((orientation) {
onChanged?.call(orientation);
});
}
@override
dispose(State state) {
_subscription.cancel();
SystemChrome.setPreferredOrientations([]);
}
ValueChanged<NativeDeviceOrientation>? onChanged;
late final StreamSubscription<NativeDeviceOrientation> _subscription;
}