mirror of
https://gitlab.com/nkming2/nc-photos.git
synced 2025-03-27 17:34:44 +01:00
Slideshow
This commit is contained in:
parent
26da1ec582
commit
ec930361cd
12 changed files with 788 additions and 97 deletions
|
@ -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": {
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
203
lib/widget/slideshow_dialog.dart
Normal file
203
lib/widget/slideshow_dialog.dart
Normal 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;
|
||||
}
|
291
lib/widget/slideshow_viewer.dart
Normal file
291
lib/widget/slideshow_viewer.dart
Normal 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;
|
||||
}
|
38
lib/widget/switch_form_field.dart
Normal file
38
lib/widget/switch_form_field.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
105
lib/widget/viewer_mixin.dart
Normal file
105
lib/widget/viewer_mixin.dart
Normal 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;
|
||||
}
|
Loading…
Add table
Reference in a new issue