diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e879f3f7..de0bf5eb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. ### Added +- slideshow - set wallpaper from any media - optional dynamic accent color on Android 12+ - support Android 13 (API 33) diff --git a/lib/app_mode.dart b/lib/app_mode.dart index bc104ab5b..0c4840ba3 100644 --- a/lib/app_mode.dart +++ b/lib/app_mode.dart @@ -1,4 +1,13 @@ -enum AppMode { main, pickSingleMediaExternal, pickMultipleMediaExternal, pickMediaInternal, pickFilterInternal, setWallpaper, view } +enum AppMode { + main, + pickSingleMediaExternal, + pickMultipleMediaExternal, + pickMediaInternal, + pickFilterInternal, + setWallpaper, + slideshow, + view, +} extension ExtraAppMode on AppMode { bool get canSearch => this == AppMode.main || this == AppMode.pickSingleMediaExternal || this == AppMode.pickMultipleMediaExternal; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4c56cb836..7b7014a28 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -109,6 +109,9 @@ "videoActionSetSpeed": "Playback speed", "videoActionSettings": "Settings", + "slideshowActionResume": "Resume", + "slideshowActionShowInCollection": "Show in Collection", + "entryInfoActionEditDate": "Edit date & time", "entryInfoActionEditLocation": "Edit location", "entryInfoActionEditRating": "Edit rating", @@ -185,10 +188,19 @@ "displayRefreshRatePreferHighest": "Highest rate", "displayRefreshRatePreferLowest": "Lowest rate", + "slideshowVideoPlaybackSkip": "Skip", + "slideshowVideoPlaybackMuted": "Play muted", + "slideshowVideoPlaybackWithSound": "Play with sound", + "themeBrightnessLight": "Light", "themeBrightnessDark": "Dark", "themeBrightnessBlack": "Black", + "viewerTransitionFade": "Fade", + "viewerTransitionFadeZoomIn": "Fade & zoom in", + "viewerTransitionParallax": "Parallax", + "viewerTransitionSlide": "Slide", + "wallpaperTargetHome": "Home screen", "wallpaperTargetLock": "Lock screen", "wallpaperTargetHomeLock": "Home and lock screens", @@ -397,6 +409,7 @@ "menuActionSelectAll": "Select all", "menuActionSelectNone": "Select none", "menuActionMap": "Map", + "menuActionSlideshow": "Slideshow", "menuActionStats": "Stats", "viewDialogTabSort": "Sort", @@ -665,6 +678,17 @@ "settingsViewerShowOverlayThumbnails": "Show thumbnails", "settingsViewerEnableOverlayBlurEffect": "Blur effect", + "settingsViewerSlideshowTile": "Slideshow", + "settingsViewerSlideshowTitle": "Slideshow", + "settingsSlideshowRepeat": "Repeat", + "settingsSlideshowShuffle": "Shuffle", + "settingsSlideshowTransitionTile": "Transition", + "settingsSlideshowTransitionTitle": "Transition", + "settingsSlideshowIntervalTile": "Interval", + "settingsSlideshowIntervalTitle": "Interval", + "settingsSlideshowVideoPlaybackTile": "Video playback", + "settingsSlideshowVideoPlaybackTitle": "Video Playback", + "settingsVideoPageTitle": "Video Settings", "settingsSectionVideo": "Video", "settingsVideoShowVideos": "Show videos", diff --git a/lib/model/actions/chip_set_actions.dart b/lib/model/actions/chip_set_actions.dart index f78daabeb..43fb11ecf 100644 --- a/lib/model/actions/chip_set_actions.dart +++ b/lib/model/actions/chip_set_actions.dart @@ -13,6 +13,7 @@ enum ChipSetAction { createAlbum, // browsing or selecting map, + slideshow, stats, // selecting (single/multiple filters) delete, @@ -36,6 +37,7 @@ class ChipSetActions { ChipSetAction.search, ChipSetAction.createAlbum, ChipSetAction.map, + ChipSetAction.slideshow, ChipSetAction.stats, ]; @@ -47,6 +49,7 @@ class ChipSetActions { ChipSetAction.rename, ChipSetAction.hide, ChipSetAction.map, + ChipSetAction.slideshow, ChipSetAction.stats, ]; } @@ -71,6 +74,8 @@ extension ExtraChipSetAction on ChipSetAction { // browsing or selecting case ChipSetAction.map: return context.l10n.menuActionMap; + case ChipSetAction.slideshow: + return context.l10n.menuActionSlideshow; case ChipSetAction.stats: return context.l10n.menuActionStats; // selecting (single/multiple filters) @@ -111,6 +116,8 @@ extension ExtraChipSetAction on ChipSetAction { // browsing or selecting case ChipSetAction.map: return AIcons.map; + case ChipSetAction.slideshow: + return AIcons.slideshow; case ChipSetAction.stats: return AIcons.stats; // selecting (single/multiple filters) diff --git a/lib/model/actions/entry_set_actions.dart b/lib/model/actions/entry_set_actions.dart index 1a9ad51b5..997f819ec 100644 --- a/lib/model/actions/entry_set_actions.dart +++ b/lib/model/actions/entry_set_actions.dart @@ -15,6 +15,7 @@ enum EntrySetAction { emptyBin, // browsing or selecting map, + slideshow, stats, rescan, // selecting @@ -48,6 +49,7 @@ class EntrySetActions { EntrySetAction.toggleTitleSearch, EntrySetAction.addShortcut, EntrySetAction.map, + EntrySetAction.slideshow, EntrySetAction.stats, EntrySetAction.rescan, EntrySetAction.emptyBin, @@ -59,6 +61,7 @@ class EntrySetActions { EntrySetAction.toggleTitleSearch, EntrySetAction.addShortcut, EntrySetAction.map, + EntrySetAction.slideshow, EntrySetAction.stats, EntrySetAction.rescan, ]; @@ -72,6 +75,7 @@ class EntrySetActions { EntrySetAction.rename, EntrySetAction.toggleFavourite, EntrySetAction.map, + EntrySetAction.slideshow, EntrySetAction.stats, EntrySetAction.rescan, // editing actions are in their subsection @@ -86,6 +90,7 @@ class EntrySetActions { EntrySetAction.rename, EntrySetAction.toggleFavourite, EntrySetAction.map, + EntrySetAction.slideshow, EntrySetAction.stats, EntrySetAction.rescan, // editing actions are in their subsection @@ -125,6 +130,8 @@ extension ExtraEntrySetAction on EntrySetAction { // browsing or selecting case EntrySetAction.map: return context.l10n.menuActionMap; + case EntrySetAction.slideshow: + return context.l10n.menuActionSlideshow; case EntrySetAction.stats: return context.l10n.menuActionStats; case EntrySetAction.rescan: @@ -190,6 +197,8 @@ extension ExtraEntrySetAction on EntrySetAction { // browsing or selecting case EntrySetAction.map: return AIcons.map; + case EntrySetAction.slideshow: + return AIcons.slideshow; case EntrySetAction.stats: return AIcons.stats; case EntrySetAction.rescan: diff --git a/lib/model/actions/slideshow_actions.dart b/lib/model/actions/slideshow_actions.dart new file mode 100644 index 000000000..6e0b51fd8 --- /dev/null +++ b/lib/model/actions/slideshow_actions.dart @@ -0,0 +1,30 @@ +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/material.dart'; + +enum SlideshowAction { + resume, + showInCollection, +} + +extension ExtraSlideshowAction on SlideshowAction { + String getText(BuildContext context) { + switch (this) { + case SlideshowAction.resume: + return context.l10n.slideshowActionResume; + case SlideshowAction.showInCollection: + return context.l10n.slideshowActionShowInCollection; + } + } + + Widget getIcon() => Icon(_getIconData()); + + IconData _getIconData() { + switch (this) { + case SlideshowAction.resume: + return AIcons.play; + case SlideshowAction.showInCollection: + return AIcons.allCollection; + } + } +} diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart index 9f1be6762..473e1fbb7 100644 --- a/lib/model/settings/defaults.dart +++ b/lib/model/settings/defaults.dart @@ -125,6 +125,13 @@ class SettingsDefaults { // file picker static const filePickerShowHiddenFiles = false; + // slideshow + static const slideshowRepeat = false; + static const slideshowShuffle = false; + static const slideshowTransition = ViewerTransition.fade; + static const slideshowVideoPlayback = SlideshowVideoPlayback.playMuted; + static const slideshowInterval = SlideshowInterval.s5; + // platform settings static const isRotationLocked = false; static const areAnimationsRemoved = false; diff --git a/lib/model/settings/enums/enums.dart b/lib/model/settings/enums/enums.dart index 6ec4c8714..6347ef29a 100644 --- a/lib/model/settings/enums/enums.dart +++ b/lib/model/settings/enums/enums.dart @@ -2,24 +2,30 @@ enum AccessibilityAnimations { system, disabled, enabled } enum AccessibilityTimeout { system, appDefault, s3, s10, s30, s60, s120 } -enum AvesThemeColorMode { monochrome, polychrome } - enum AvesThemeBrightness { system, light, dark, black } +enum AvesThemeColorMode { monochrome, polychrome } + enum ConfirmationDialog { deleteForever, moveToBin, moveUndatedItems } enum CoordinateFormat { dms, decimal } +enum DisplayRefreshRateMode { auto, highest, lowest } + enum EntryBackground { black, white, checkered } enum HomePageSetting { collection, albums } enum KeepScreenOn { never, viewerOnly, always } -enum DisplayRefreshRateMode { auto, highest, lowest } +enum SlideshowInterval { s3, s5, s10, s30, s60 } + +enum SlideshowVideoPlayback { skip, playMuted, playWithSound } enum UnitSystem { metric, imperial } +enum VideoControls { play, playSeek, playOutside, none } + enum VideoLoopMode { never, shortOnly, always } -enum VideoControls { play, playSeek, playOutside, none } +enum ViewerTransition { slide, parallax, fade, fadeZoomIn } diff --git a/lib/model/settings/enums/slideshow_interval.dart b/lib/model/settings/enums/slideshow_interval.dart new file mode 100644 index 000000000..ad9562d29 --- /dev/null +++ b/lib/model/settings/enums/slideshow_interval.dart @@ -0,0 +1,36 @@ +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/widgets.dart'; + +import 'enums.dart'; + +extension ExtraSlideshowInterval on SlideshowInterval { + String getName(BuildContext context) { + switch (this) { + case SlideshowInterval.s3: + return context.l10n.timeSeconds(3); + case SlideshowInterval.s5: + return context.l10n.timeSeconds(5); + case SlideshowInterval.s10: + return context.l10n.timeSeconds(10); + case SlideshowInterval.s30: + return context.l10n.timeSeconds(30); + case SlideshowInterval.s60: + return context.l10n.timeMinutes(1); + } + } + + Duration getDuration() { + switch (this) { + case SlideshowInterval.s3: + return const Duration(seconds: 3); + case SlideshowInterval.s5: + return const Duration(seconds: 5); + case SlideshowInterval.s10: + return const Duration(seconds: 10); + case SlideshowInterval.s30: + return const Duration(seconds: 30); + case SlideshowInterval.s60: + return const Duration(minutes: 1); + } + } +} diff --git a/lib/model/settings/enums/slideshow_video_playback.dart b/lib/model/settings/enums/slideshow_video_playback.dart new file mode 100644 index 000000000..beda9a052 --- /dev/null +++ b/lib/model/settings/enums/slideshow_video_playback.dart @@ -0,0 +1,17 @@ +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/widgets.dart'; + +import 'enums.dart'; + +extension ExtraSlideshowVideoPlayback on SlideshowVideoPlayback { + String getName(BuildContext context) { + switch (this) { + case SlideshowVideoPlayback.skip: + return context.l10n.slideshowVideoPlaybackSkip; + case SlideshowVideoPlayback.playMuted: + return context.l10n.slideshowVideoPlaybackMuted; + case SlideshowVideoPlayback.playWithSound: + return context.l10n.slideshowVideoPlaybackWithSound; + } + } +} diff --git a/lib/model/settings/enums/viewer_transition.dart b/lib/model/settings/enums/viewer_transition.dart new file mode 100644 index 000000000..a08ff0ea4 --- /dev/null +++ b/lib/model/settings/enums/viewer_transition.dart @@ -0,0 +1,33 @@ +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/viewer/controller.dart'; +import 'package:flutter/widgets.dart'; + +import 'enums.dart'; + +extension ExtraViewerTransition on ViewerTransition { + String getName(BuildContext context) { + switch (this) { + case ViewerTransition.fade: + return context.l10n.viewerTransitionFade; + case ViewerTransition.fadeZoomIn: + return context.l10n.viewerTransitionFadeZoomIn; + case ViewerTransition.parallax: + return context.l10n.viewerTransitionParallax; + case ViewerTransition.slide: + return context.l10n.viewerTransitionSlide; + } + } + + TransitionBuilder builder(PageController pageController, int index) { + switch (this) { + case ViewerTransition.slide: + return PageTransitionEffects.slide(pageController, index, parallax: false); + case ViewerTransition.parallax: + return PageTransitionEffects.slide(pageController, index, parallax: true); + case ViewerTransition.fade: + return PageTransitionEffects.fade(pageController, index, zoomIn: false); + case ViewerTransition.fadeZoomIn: + return PageTransitionEffects.fade(pageController, index, zoomIn: true); + } + } +} diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 460dced32..624c359b4 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -138,6 +138,13 @@ class Settings extends ChangeNotifier { // file picker static const filePickerShowHiddenFilesKey = 'file_picker_show_hidden_files'; + // slideshow + static const slideshowRepeatKey = 'slideshow_loop'; + static const slideshowShuffleKey = 'slideshow_shuffle'; + static const slideshowTransitionKey = 'slideshow_transition'; + static const slideshowVideoPlaybackKey = 'slideshow_video_playback'; + static const slideshowIntervalKey = 'slideshow_interval'; + // platform settings // cf Android `Settings.System.ACCELEROMETER_ROTATION` static const platformAccelerometerRotationKey = 'accelerometer_rotation'; @@ -576,6 +583,28 @@ class Settings extends ChangeNotifier { set filePickerShowHiddenFiles(bool newValue) => setAndNotify(filePickerShowHiddenFilesKey, newValue); + // slideshow + + bool get slideshowRepeat => getBoolOrDefault(slideshowRepeatKey, SettingsDefaults.slideshowRepeat); + + set slideshowRepeat(bool newValue) => setAndNotify(slideshowRepeatKey, newValue); + + bool get slideshowShuffle => getBoolOrDefault(slideshowShuffleKey, SettingsDefaults.slideshowShuffle); + + set slideshowShuffle(bool newValue) => setAndNotify(slideshowShuffleKey, newValue); + + ViewerTransition get slideshowTransition => getEnumOrDefault(slideshowTransitionKey, SettingsDefaults.slideshowTransition, ViewerTransition.values); + + set slideshowTransition(ViewerTransition newValue) => setAndNotify(slideshowTransitionKey, newValue.toString()); + + SlideshowVideoPlayback get slideshowVideoPlayback => getEnumOrDefault(slideshowVideoPlaybackKey, SettingsDefaults.slideshowVideoPlayback, SlideshowVideoPlayback.values); + + set slideshowVideoPlayback(SlideshowVideoPlayback newValue) => setAndNotify(slideshowVideoPlaybackKey, newValue.toString()); + + SlideshowInterval get slideshowInterval => getEnumOrDefault(slideshowIntervalKey, SettingsDefaults.slideshowInterval, SlideshowInterval.values); + + set slideshowInterval(SlideshowInterval newValue) => setAndNotify(slideshowIntervalKey, newValue.toString()); + // convenience methods int? getInt(String key) => settingsStore.getInt(key); @@ -734,6 +763,8 @@ class Settings extends ChangeNotifier { case subtitleShowOutlineKey: case saveSearchHistoryKey: case filePickerShowHiddenFilesKey: + case slideshowRepeatKey: + case slideshowShuffleKey: if (newValue is bool) { settingsStore.setBool(key, newValue); } else { @@ -761,6 +792,9 @@ class Settings extends ChangeNotifier { case unitSystemKey: case accessibilityAnimationsKey: case timeToTakeActionKey: + case slideshowTransitionKey: + case slideshowVideoPlaybackKey: + case slideshowIntervalKey: if (newValue is String) { settingsStore.setString(key, newValue); } else { diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 95eb0a33e..d0598213e 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -32,7 +32,7 @@ class CollectionLens with ChangeNotifier { final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortSectionChangeNotifier = AChangeNotifier(); final List _subscriptions = []; int? id; - bool listenToSource, groupBursts; + bool listenToSource, groupBursts, fixedSort; List? fixedSelection; List _filteredSortedEntries = []; @@ -45,6 +45,7 @@ class CollectionLens with ChangeNotifier { this.id, this.listenToSource = true, this.groupBursts = true, + this.fixedSort = false, this.fixedSelection, }) : filters = (filters ?? {}).whereNotNull().toSet(), sectionFactor = settings.collectionSectionFactor, @@ -203,6 +204,8 @@ class CollectionLens with ChangeNotifier { } void _applySort() { + if (fixedSort) return; + switch (sortFactor) { case EntrySortFactor.date: _filteredSortedEntries.sort(AvesEntry.compareByDate); @@ -220,37 +223,43 @@ class CollectionLens with ChangeNotifier { } void _applySection() { - switch (sortFactor) { - case EntrySortFactor.date: - switch (sectionFactor) { - case EntryGroupFactor.album: - sections = groupBy(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory)); - break; - case EntryGroupFactor.month: - sections = groupBy(_filteredSortedEntries, (entry) => EntryDateSectionKey(entry.monthTaken)); - break; - case EntryGroupFactor.day: - sections = groupBy(_filteredSortedEntries, (entry) => EntryDateSectionKey(entry.dayTaken)); - break; - case EntryGroupFactor.none: - sections = Map.fromEntries([ - MapEntry(const SectionKey(), _filteredSortedEntries), - ]); - break; - } - break; - case EntrySortFactor.name: - final byAlbum = groupBy(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory)); - sections = SplayTreeMap>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.directory!, b.directory!)); - break; - case EntrySortFactor.rating: - sections = groupBy(_filteredSortedEntries, (entry) => EntryRatingSectionKey(entry.rating)); - break; - case EntrySortFactor.size: - sections = Map.fromEntries([ - MapEntry(const SectionKey(), _filteredSortedEntries), - ]); - break; + if (fixedSort) { + sections = Map.fromEntries([ + MapEntry(const SectionKey(), _filteredSortedEntries), + ]); + } else { + switch (sortFactor) { + case EntrySortFactor.date: + switch (sectionFactor) { + case EntryGroupFactor.album: + sections = groupBy(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory)); + break; + case EntryGroupFactor.month: + sections = groupBy(_filteredSortedEntries, (entry) => EntryDateSectionKey(entry.monthTaken)); + break; + case EntryGroupFactor.day: + sections = groupBy(_filteredSortedEntries, (entry) => EntryDateSectionKey(entry.dayTaken)); + break; + case EntryGroupFactor.none: + sections = Map.fromEntries([ + MapEntry(const SectionKey(), _filteredSortedEntries), + ]); + break; + } + break; + case EntrySortFactor.name: + final byAlbum = groupBy(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory)); + sections = SplayTreeMap>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.directory!, b.directory!)); + break; + case EntrySortFactor.rating: + sections = groupBy(_filteredSortedEntries, (entry) => EntryRatingSectionKey(entry.rating)); + break; + case EntrySortFactor.size: + sections = Map.fromEntries([ + MapEntry(const SectionKey(), _filteredSortedEntries), + ]); + break; + } } sections = Map.unmodifiable(sections); _sortedEntries = null; diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 3bc9c645f..f4179c89f 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -104,6 +104,7 @@ class AIcons { static const IconData setCover = MdiIcons.imageEditOutline; static const IconData share = Icons.share_outlined; static const IconData show = Icons.visibility_outlined; + static const IconData slideshow = Icons.slideshow_outlined; static const IconData speed = Icons.speed_outlined; static const IconData stats = Icons.pie_chart_outline_outlined; static const IconData streams = Icons.translate_outlined; diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index 61c22b4b5..5b7c64661 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -231,6 +231,7 @@ class _AvesAppState extends State with WidgetsBindingObserver { case AppMode.pickMediaInternal: case AppMode.pickFilterInternal: case AppMode.setWallpaper: + case AppMode.slideshow: case AppMode.view: break; } diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index d531a1438..0bbcdb4a4 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -477,6 +477,7 @@ class _CollectionAppBarState extends State with SingleTickerPr case EntrySetAction.addShortcut: // browsing or selecting case EntrySetAction.map: + case EntrySetAction.slideshow: case EntrySetAction.stats: case EntrySetAction.rescan: case EntrySetAction.emptyBin: diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index b839a0843..09a719122 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -35,6 +35,7 @@ import 'package:aves/widgets/dialogs/entry_editors/rename_entry_set_dialog.dart' import 'package:aves/widgets/map/map_page.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:aves/widgets/stats/stats_page.dart'; +import 'package:aves/widgets/viewer/slideshow_page.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -73,6 +74,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware return appMode == AppMode.main && isTrash; // browsing or selecting case EntrySetAction.map: + case EntrySetAction.slideshow: case EntrySetAction.stats: return appMode == AppMode.main; case EntrySetAction.rescan: @@ -124,6 +126,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware case EntrySetAction.emptyBin: return !isSelecting && hasItems; case EntrySetAction.map: + case EntrySetAction.slideshow: case EntrySetAction.stats: case EntrySetAction.rescan: return (!isSelecting && hasItems) || (isSelecting && hasSelection); @@ -169,6 +172,9 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware case EntrySetAction.map: _goToMap(context); break; + case EntrySetAction.slideshow: + _goToSlideshow(context); + break; case EntrySetAction.stats: _goToStats(context); break; @@ -543,6 +549,27 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware ); } + void _goToSlideshow(BuildContext context) { + final collection = context.read(); + final entries = _getTargetItems(context); + + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: SlideshowPage.routeName), + builder: (context) { + return SlideshowPage( + collection: CollectionLens( + source: collection.source, + filters: collection.filters, + fixedSelection: entries.toList(), + ), + ); + }, + ), + ); + } + void _goToStats(BuildContext context) { final collection = context.read(); final entries = _getTargetItems(context); diff --git a/lib/widgets/collection/grid/tile.dart b/lib/widgets/collection/grid/tile.dart index a2acb6744..3eef2ef9c 100644 --- a/lib/widgets/collection/grid/tile.dart +++ b/lib/widgets/collection/grid/tile.dart @@ -55,6 +55,7 @@ class InteractiveTile extends StatelessWidget { break; case AppMode.pickFilterInternal: case AppMode.setWallpaper: + case AppMode.slideshow: case AppMode.view: break; } diff --git a/lib/widgets/common/aves_highlight.dart b/lib/widgets/common/aves_highlight.dart index 2f1894ce4..3a2f4f0c6 100644 --- a/lib/widgets/common/aves_highlight.dart +++ b/lib/widgets/common/aves_highlight.dart @@ -38,7 +38,7 @@ class AvesHighlightView extends StatelessWidget { this.padding, this.textStyle, int tabSize = 8, // TODO: https://github.com/flutter/flutter/issues/50087 - }) : source = input.replaceAll('\t', ' ' * tabSize); + }) : source = input.replaceAll('\t', ' ' * tabSize); List _convert(List nodes) { final spans = []; diff --git a/lib/widgets/common/basic/reselectable_radio_list_tile.dart b/lib/widgets/common/basic/reselectable_radio_list_tile.dart index 5d88833c3..b3d456627 100644 --- a/lib/widgets/common/basic/reselectable_radio_list_tile.dart +++ b/lib/widgets/common/basic/reselectable_radio_list_tile.dart @@ -35,7 +35,7 @@ class ReselectableRadioListTile extends StatelessWidget { this.selected = false, this.controlAffinity = ListTileControlAffinity.platform, this.autofocus = false, - }) : assert(!isThreeLine || subtitle != null); + }) : assert(!isThreeLine || subtitle != null); @override Widget build(BuildContext context) { diff --git a/lib/widgets/common/identity/aves_expansion_tile.dart b/lib/widgets/common/identity/aves_expansion_tile.dart index c929a1b73..ac09287ea 100644 --- a/lib/widgets/common/identity/aves_expansion_tile.dart +++ b/lib/widgets/common/identity/aves_expansion_tile.dart @@ -23,7 +23,7 @@ class AvesExpansionTile extends StatelessWidget { this.initiallyExpanded = false, this.showHighlight = true, required this.children, - }) : value = value ?? title; + }) : value = value ?? title; @override Widget build(BuildContext context) { diff --git a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart index f6c0b626b..45bbc0831 100644 --- a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart @@ -22,6 +22,7 @@ import 'package:aves/widgets/dialogs/tile_view_dialog.dart'; import 'package:aves/widgets/map/map_page.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:aves/widgets/stats/stats_page.dart'; +import 'package:aves/widgets/viewer/slideshow_page.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -65,6 +66,7 @@ abstract class ChipSetActionDelegate with FeedbackMi return false; // browsing or selecting case ChipSetAction.map: + case ChipSetAction.slideshow: case ChipSetAction.stats: return appMode == AppMode.main; // selecting (single/multiple filters) @@ -106,6 +108,7 @@ abstract class ChipSetActionDelegate with FeedbackMi return true; // browsing or selecting case ChipSetAction.map: + case ChipSetAction.slideshow: case ChipSetAction.stats: return (!isSelecting && hasItems) || (isSelecting && hasSelection); // selecting (single/multiple filters) @@ -146,6 +149,9 @@ abstract class ChipSetActionDelegate with FeedbackMi case ChipSetAction.map: _goToMap(context, filters); break; + case ChipSetAction.slideshow: + _goToSlideshow(context, filters); + break; case ChipSetAction.stats: _goToStats(context, filters); break; @@ -227,6 +233,23 @@ abstract class ChipSetActionDelegate with FeedbackMi ); } + void _goToSlideshow(BuildContext context, Set filters) { + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: SlideshowPage.routeName), + builder: (context) { + return SlideshowPage( + collection: CollectionLens( + source: context.read(), + fixedSelection: _selectedEntries(context, filters).toList(), + ), + ); + }, + ), + ); + } + void _goToStats(BuildContext context, Set filters) { Navigator.push( context, diff --git a/lib/widgets/filter_grids/common/filter_tile.dart b/lib/widgets/filter_grids/common/filter_tile.dart index 2e5cb2c3a..906fd1400 100644 --- a/lib/widgets/filter_grids/common/filter_tile.dart +++ b/lib/widgets/filter_grids/common/filter_tile.dart @@ -64,6 +64,7 @@ class _InteractiveFilterTileState extends State { case AppMode.pickMediaInternal: case AppMode.pickFilterInternal: case AppMode.setWallpaper: + case AppMode.slideshow: break; } @@ -286,7 +287,7 @@ class _HomePageState extends State { default: return DirectMaterialPageRoute( settings: const RouteSettings(name: CollectionPage.routeName), - builder: (_) => CollectionPage( + builder: (context) => CollectionPage( source: source, filters: filters, ), diff --git a/lib/widgets/map/map_page.dart b/lib/widgets/map/map_page.dart index 955cddb05..e8ba5b67f 100644 --- a/lib/widgets/map/map_page.dart +++ b/lib/widgets/map/map_page.dart @@ -414,12 +414,10 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin context, MaterialPageRoute( settings: const RouteSettings(name: CollectionPage.routeName), - builder: (context) { - return CollectionPage( - source: openingCollection.source, - filters: {...openingCollection.filters, filter}, - ); - }, + builder: (context) => CollectionPage( + source: openingCollection.source, + filters: {...openingCollection.filters, filter}, + ), ), (route) => false, ); diff --git a/lib/widgets/settings/display/display.dart b/lib/widgets/settings/display/display.dart index 78599c3cf..69a4e36f3 100644 --- a/lib/widgets/settings/display/display.dart +++ b/lib/widgets/settings/display/display.dart @@ -70,10 +70,10 @@ class SettingsTileDisplayEnableDynamicColor extends SettingsTile { @override Widget build(BuildContext context) => SettingsSwitchListTile( - selector: (context, s) => s.enableDynamicColor, - onChanged: (v) => settings.enableDynamicColor = v, - title: title(context), - ); + selector: (context, s) => s.enableDynamicColor, + onChanged: (v) => settings.enableDynamicColor = v, + title: title(context), + ); } class SettingsTileDisplayEnableBlurEffect extends SettingsTile { @@ -82,10 +82,10 @@ class SettingsTileDisplayEnableBlurEffect extends SettingsTile { @override Widget build(BuildContext context) => SettingsSwitchListTile( - selector: (context, s) => s.enableBlurEffect, - onChanged: (v) => settings.enableBlurEffect = v, - title: title(context), - ); + selector: (context, s) => s.enableBlurEffect, + onChanged: (v) => settings.enableBlurEffect = v, + title: title(context), + ); } class SettingsTileDisplayDisplayRefreshRateMode extends SettingsTile { diff --git a/lib/widgets/settings/viewer/overlay.dart b/lib/widgets/settings/viewer/overlay.dart index 431050a89..b41f34d6c 100644 --- a/lib/widgets/settings/viewer/overlay.dart +++ b/lib/widgets/settings/viewer/overlay.dart @@ -6,7 +6,7 @@ import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; class ViewerOverlayPage extends StatelessWidget { - static const routeName = '/settings/viewer_overlay'; + static const routeName = '/settings/viewer/overlay'; const ViewerOverlayPage({super.key}); diff --git a/lib/widgets/settings/viewer/slideshow.dart b/lib/widgets/settings/viewer/slideshow.dart new file mode 100644 index 000000000..71bb3d811 --- /dev/null +++ b/lib/widgets/settings/viewer/slideshow.dart @@ -0,0 +1,63 @@ +import 'package:aves/model/settings/enums/enums.dart'; +import 'package:aves/model/settings/enums/slideshow_interval.dart'; +import 'package:aves/model/settings/enums/slideshow_video_playback.dart'; +import 'package:aves/model/settings/enums/viewer_transition.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/settings/common/tiles.dart'; +import 'package:flutter/material.dart'; + +class ViewerSlideshowPage extends StatelessWidget { + static const routeName = '/settings/viewer/slideshow'; + + const ViewerSlideshowPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(context.l10n.settingsViewerSlideshowTitle), + ), + body: SafeArea( + child: ListView( + children: [ + SettingsSwitchListTile( + selector: (context, s) => s.slideshowRepeat, + onChanged: (v) => settings.slideshowRepeat = v, + title: context.l10n.settingsSlideshowRepeat, + ), + SettingsSwitchListTile( + selector: (context, s) => s.slideshowShuffle, + onChanged: (v) => settings.slideshowShuffle = v, + title: context.l10n.settingsSlideshowShuffle, + ), + SettingsSelectionListTile( + values: ViewerTransition.values, + getName: (context, v) => v.getName(context), + selector: (context, s) => s.slideshowTransition, + onSelection: (v) => settings.slideshowTransition = v, + tileTitle: context.l10n.settingsSlideshowTransitionTile, + dialogTitle: context.l10n.settingsSlideshowTransitionTitle, + ), + SettingsSelectionListTile( + values: SlideshowInterval.values, + getName: (context, v) => v.getName(context), + selector: (context, s) => s.slideshowInterval, + onSelection: (v) => settings.slideshowInterval = v, + tileTitle: context.l10n.settingsSlideshowIntervalTile, + dialogTitle: context.l10n.settingsSlideshowIntervalTitle, + ), + SettingsSelectionListTile( + values: SlideshowVideoPlayback.values, + getName: (context, v) => v.getName(context), + selector: (context, s) => s.slideshowVideoPlayback, + onSelection: (v) => settings.slideshowVideoPlayback = v, + tileTitle: context.l10n.settingsSlideshowVideoPlaybackTile, + dialogTitle: context.l10n.settingsSlideshowVideoPlaybackTitle, + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/settings/viewer/viewer.dart b/lib/widgets/settings/viewer/viewer.dart index 35176bd92..a59c9e19a 100644 --- a/lib/widgets/settings/viewer/viewer.dart +++ b/lib/widgets/settings/viewer/viewer.dart @@ -11,6 +11,7 @@ import 'package:aves/widgets/settings/common/tiles.dart'; import 'package:aves/widgets/settings/settings_definition.dart'; import 'package:aves/widgets/settings/viewer/entry_background.dart'; import 'package:aves/widgets/settings/viewer/overlay.dart'; +import 'package:aves/widgets/settings/viewer/slideshow.dart'; import 'package:aves/widgets/settings/viewer/viewer_actions_editor.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -34,6 +35,7 @@ class ViewerSection extends SettingsSection { return [ SettingsTileViewerQuickActions(), SettingsTileViewerOverlay(), + SettingsTileViewerSlideshow(), if (canSetCutoutMode) SettingsTileViewerCutoutMode(), SettingsTileViewerMaxBrightness(), SettingsTileViewerMotionPhotoAutoPlay(), @@ -66,6 +68,18 @@ class SettingsTileViewerOverlay extends SettingsTile { ); } +class SettingsTileViewerSlideshow extends SettingsTile { + @override + String title(BuildContext context) => context.l10n.settingsViewerSlideshowTile; + + @override + Widget build(BuildContext context) => SettingsSubPageTile( + title: title(context), + routeName: ViewerSlideshowPage.routeName, + builder: (context) => const ViewerSlideshowPage(), + ); +} + class SettingsTileViewerCutoutMode extends SettingsTile { @override String title(BuildContext context) => context.l10n.settingsViewerUseCutout; diff --git a/lib/widgets/settings/viewer/viewer_actions_editor.dart b/lib/widgets/settings/viewer/viewer_actions_editor.dart index 795a2eddd..d98fbc985 100644 --- a/lib/widgets/settings/viewer/viewer_actions_editor.dart +++ b/lib/widgets/settings/viewer/viewer_actions_editor.dart @@ -5,7 +5,7 @@ import 'package:aves/widgets/settings/common/quick_actions/editor_page.dart'; import 'package:flutter/material.dart'; class ViewerActionEditorPage extends StatelessWidget { - static const routeName = '/settings/viewer_actions'; + static const routeName = '/settings/viewer/actions'; const ViewerActionEditorPage({super.key}); diff --git a/lib/widgets/viewer/controller.dart b/lib/widgets/viewer/controller.dart new file mode 100644 index 000000000..5b102e906 --- /dev/null +++ b/lib/widgets/viewer/controller.dart @@ -0,0 +1,119 @@ +import 'dart:async'; + +import 'package:aves/model/entry.dart'; +import 'package:aves/model/settings/enums/enums.dart'; +import 'package:flutter/widgets.dart'; + +class ViewerController { + final ValueNotifier entryNotifier = ValueNotifier(null); + final ViewerTransition transition; + final Duration? autopilotInterval; + final bool repeat; + + late final ValueNotifier _autopilotNotifier; + Timer? _playTimer; + final StreamController _streamController = StreamController.broadcast(); + + Stream get _events => _streamController.stream; + + Stream get showNextCommands => _events.where((event) => event is ViewerShowNextEvent).cast(); + + Stream get overlayCommands => _events.where((event) => event is ViewerOverlayToggleEvent).cast(); + + bool get autopilot => _autopilotNotifier.value; + + set autopilot(bool enabled) => _autopilotNotifier.value = enabled; + + ViewerController({ + this.transition = ViewerTransition.parallax, + this.repeat = false, + bool autopilot = false, + this.autopilotInterval, + }) { + _autopilotNotifier = ValueNotifier(autopilot); + _autopilotNotifier.addListener(_onAutopilotChange); + _onAutopilotChange(); + } + + void dispose() { + _autopilotNotifier.removeListener(_onAutopilotChange); + _stopPlayTimer(); + _streamController.close(); + } + + void _stopPlayTimer() { + _playTimer?.cancel(); + } + + void _onAutopilotChange() { + _stopPlayTimer(); + if (autopilot && autopilotInterval != null) { + _playTimer = Timer.periodic(autopilotInterval!, (_) => _streamController.add(ViewerShowNextEvent())); + _streamController.add(const ViewerOverlayToggleEvent(visible: false)); + } + } +} + +@immutable +class ViewerShowNextEvent {} + +@immutable +class ViewerOverlayToggleEvent { + final bool? visible; + + const ViewerOverlayToggleEvent({required this.visible}); +} + +class PageTransitionEffects { + static TransitionBuilder fade( + PageController pageController, + int index, { + required bool zoomIn, + }) => + (context, child) { + double opacity = 0; + double dx = 0; + double scale = 1; + if (pageController.hasClients && pageController.position.haveDimensions) { + final position = (pageController.page! - index).clamp(-1.0, 1.0); + final width = pageController.position.viewportDimension; + opacity = (1 - position.abs()).clamp(0, 1); + dx = position * width; + if (zoomIn) { + scale = 1 + position; + } + } + return Opacity( + opacity: opacity, + child: Transform.translate( + offset: Offset(dx, 0), + child: Transform.scale( + scale: scale, + child: child, + ), + ), + ); + }; + + static TransitionBuilder slide( + PageController pageController, + int index, { + required bool parallax, + }) => + (context, child) { + double dx = 0; + if (pageController.hasClients && pageController.position.haveDimensions) { + final position = (pageController.page! - index).clamp(-1.0, 1.0); + final width = pageController.position.viewportDimension; + if (parallax) { + dx = position * width / 2; + } + } + return ClipRect( + child: Transform.translate( + offset: Offset(dx, 0), + child: child, + ), + ); + }; +} diff --git a/lib/widgets/viewer/entry_horizontal_pager.dart b/lib/widgets/viewer/entry_horizontal_pager.dart index 1c27b0c03..a25307be6 100644 --- a/lib/widgets/viewer/entry_horizontal_pager.dart +++ b/lib/widgets/viewer/entry_horizontal_pager.dart @@ -1,9 +1,11 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/enums/accessibility_animations.dart'; +import 'package:aves/model/settings/enums/viewer_transition.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart'; import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart'; +import 'package:aves/widgets/viewer/controller.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart'; import 'package:aves/widgets/viewer/page_entry_builder.dart'; import 'package:aves/widgets/viewer/visual/entry_page_view.dart'; @@ -13,6 +15,7 @@ import 'package:provider/provider.dart'; class MultiEntryScroller extends StatefulWidget { final CollectionLens collection; + final ViewerController viewerController; final PageController pageController; final ValueChanged onPageChanged; final void Function(AvesEntry mainEntry, AvesEntry? pageEntry) onViewDisposed; @@ -20,6 +23,7 @@ class MultiEntryScroller extends StatefulWidget { const MultiEntryScroller({ super.key, required this.collection, + required this.viewerController, required this.pageController, required this.onPageChanged, required this.onViewDisposed, @@ -32,6 +36,8 @@ class MultiEntryScroller extends StatefulWidget { class _MultiEntryScrollerState extends State with AutomaticKeepAliveClientMixin { List get entries => widget.collection.sortedEntries; + ViewerController get viewerController => widget.viewerController; + PageController get pageController => widget.pageController; @override @@ -51,45 +57,29 @@ class _MultiEntryScrollerState extends State with AutomaticK ), onPageChanged: widget.onPageChanged, itemBuilder: (context, index) { - final mainEntry = entries[index]; + final mainEntry = entries[index % entries.length]; - var child = mainEntry.isMultiPage + final child = mainEntry.isMultiPage ? PageEntryBuilder( multiPageController: context.read().getController(mainEntry), builder: (pageEntry) => _buildViewer(mainEntry, pageEntry: pageEntry), ) : _buildViewer(mainEntry); - child = Selector( + return Selector( selector: (context, s) => s.accessibilityAnimations.animate, builder: (context, animate, child) { - return animate - ? AnimatedBuilder( - animation: pageController, - builder: (context, child) { - // parallax scrolling - double dx = 0; - if (pageController.hasClients && pageController.position.haveDimensions) { - final delta = pageController.page! - index; - dx = delta * pageController.position.viewportDimension / 2; - } - return Transform.translate( - offset: Offset(dx, 0), - child: child, - ); - }, - child: child, - ) - : child!; + if (!animate) return child!; + return AnimatedBuilder( + animation: pageController, + builder: viewerController.transition.builder(pageController, index), + child: child, + ); }, child: child, ); - - return ClipRect( - child: child, - ); }, - itemCount: entries.length, + itemCount: viewerController.repeat ? null : entries.length, ), ); } diff --git a/lib/widgets/viewer/entry_vertical_pager.dart b/lib/widgets/viewer/entry_vertical_pager.dart index ed08f48ae..b7e310155 100644 --- a/lib/widgets/viewer/entry_vertical_pager.dart +++ b/lib/widgets/viewer/entry_vertical_pager.dart @@ -2,14 +2,17 @@ import 'dart:async'; import 'dart:math'; import 'dart:ui'; +import 'package:aves/app_mode.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart'; +import 'package:aves/widgets/viewer/controller.dart'; import 'package:aves/widgets/viewer/entry_horizontal_pager.dart'; import 'package:aves/widgets/viewer/info/info_page.dart'; import 'package:aves/widgets/viewer/notifications.dart'; +import 'package:aves/widgets/viewer/overlay/notifications.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -19,6 +22,7 @@ import 'package:screen_brightness/screen_brightness.dart'; class ViewerVerticalPageView extends StatefulWidget { final CollectionLens? collection; final ValueNotifier entryNotifier; + final ViewerController viewerController; final PageController horizontalPager, verticalPager; final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged; final VoidCallback onImagePageRequested; @@ -28,6 +32,7 @@ class ViewerVerticalPageView extends StatefulWidget { super.key, required this.collection, required this.entryNotifier, + required this.viewerController, required this.verticalPager, required this.horizontalPager, required this.onVerticalPageChanged, @@ -41,6 +46,7 @@ class ViewerVerticalPageView extends StatefulWidget { } class _ViewerVerticalPageViewState extends State { + final List _subscriptions = []; final ValueNotifier _backgroundOpacityNotifier = ValueNotifier(1); final ValueNotifier _isVerticallyScrollingNotifier = ValueNotifier(false); Timer? _verticalScrollMonitoringTimer; @@ -80,12 +86,21 @@ class _ViewerVerticalPageViewState extends State { } void _registerWidget(ViewerVerticalPageView widget) { + _subscriptions.add(widget.viewerController.showNextCommands.listen((event) { + _goToHorizontalPage(1, animate: true); + })); + _subscriptions.add(widget.viewerController.overlayCommands.listen((event) { + ToggleOverlayNotification(visible: event.visible).dispatch(context); + })); widget.verticalPager.addListener(_onVerticalPageControllerChanged); widget.entryNotifier.addListener(_onEntryChanged); if (_oldEntry != entry) _onEntryChanged(); } void _unregisterWidget(ViewerVerticalPageView widget) { + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); widget.verticalPager.removeListener(_onVerticalPageControllerChanged); widget.entryNotifier.removeListener(_onEntryChanged); _oldEntry?.imageChangeNotifier.removeListener(_onImageChanged); @@ -96,34 +111,36 @@ class _ViewerVerticalPageViewState extends State { // fake page for opacity transition between collection and viewer const transitionPage = SizedBox(); - final imagePage = _buildImagePage(); - - final infoPage = NotificationListener( - onNotification: (notification) { - widget.onImagePageRequested(); - return true; - }, - child: AnimatedBuilder( - animation: widget.verticalPager, - builder: (context, child) { - return Visibility( - visible: widget.verticalPager.page! > 1, - child: child!, - ); - }, - child: InfoPage( - collection: collection, - entryNotifier: widget.entryNotifier, - isScrollingNotifier: _isVerticallyScrollingNotifier, - ), - ), - ); - final pages = [ transitionPage, - imagePage, - infoPage, + _buildImagePage(), ]; + + if (context.read>().value != AppMode.slideshow) { + final infoPage = NotificationListener( + onNotification: (notification) { + widget.onImagePageRequested(); + return true; + }, + child: AnimatedBuilder( + animation: widget.verticalPager, + builder: (context, child) { + return Visibility( + visible: widget.verticalPager.page! > 1, + child: child!, + ); + }, + child: InfoPage( + collection: collection, + entryNotifier: widget.entryNotifier, + isScrollingNotifier: _isVerticallyScrollingNotifier, + ), + ), + ); + + pages.add(infoPage); + } + return ValueListenableBuilder( valueListenable: _backgroundOpacityNotifier, builder: (context, backgroundOpacity, child) { @@ -155,6 +172,7 @@ class _ViewerVerticalPageViewState extends State { if (hasCollection) { child = MultiEntryScroller( collection: collection!, + viewerController: widget.viewerController, pageController: widget.horizontalPager, onPageChanged: widget.onHorizontalPageChanged, onViewDisposed: widget.onViewDisposed, @@ -179,8 +197,8 @@ class _ViewerVerticalPageViewState extends State { autofocus: true, shortcuts: shortcuts, actions: { - ShowPreviousIntent: CallbackAction(onInvoke: (intent) => _jumpHorizontalPage(-1)), - ShowNextIntent: CallbackAction(onInvoke: (intent) => _jumpHorizontalPage(1)), + ShowPreviousIntent: CallbackAction(onInvoke: (intent) => _goToHorizontalPage(-1, animate: false)), + ShowNextIntent: CallbackAction(onInvoke: (intent) => _goToHorizontalPage(1, animate: false)), LeaveIntent: CallbackAction(onInvoke: (intent) => Navigator.pop(context)), ShowInfoIntent: CallbackAction(onInvoke: (intent) => ShowInfoNotification().dispatch(context)), }, @@ -190,13 +208,24 @@ class _ViewerVerticalPageViewState extends State { return const SizedBox(); } - void _jumpHorizontalPage(int delta) { + void _goToHorizontalPage(int delta, {required bool animate}) { final pageController = widget.horizontalPager; final page = pageController.page?.round(); final _collection = collection; if (page != null && _collection != null) { - final target = (page + delta).clamp(0, _collection.entryCount - 1); - pageController.jumpToPage(target); + var target = page + delta; + if (!widget.viewerController.repeat) { + target = target.clamp(0, _collection.entryCount - 1); + } + if (animate) { + pageController.animateToPage( + target, + duration: const Duration(seconds: 1), + curve: Curves.easeInOutCubic, + ); + } else { + pageController.jumpToPage(target); + } } } diff --git a/lib/widgets/viewer/entry_viewer_page.dart b/lib/widgets/viewer/entry_viewer_page.dart index 814e1b703..c990971b5 100644 --- a/lib/widgets/viewer/entry_viewer_page.dart +++ b/lib/widgets/viewer/entry_viewer_page.dart @@ -2,6 +2,7 @@ import 'package:aves/app_mode.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/viewer/controller.dart'; import 'package:aves/widgets/viewer/entry_viewer_stack.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart'; import 'package:aves/widgets/viewer/overlay/bottom.dart'; @@ -10,7 +11,7 @@ import 'package:aves/widgets/viewer/visual/conductor.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -class EntryViewerPage extends StatelessWidget { +class EntryViewerPage extends StatefulWidget { static const routeName = '/viewer'; final CollectionLens? collection; @@ -22,6 +23,23 @@ class EntryViewerPage extends StatelessWidget { required this.initialEntry, }); + @override + State createState() => _EntryViewerPageState(); + + static EdgeInsets snackBarMargin(BuildContext context) { + return EdgeInsets.only(bottom: ViewerBottomOverlay.actionSafeHeight(context)); + } +} + +class _EntryViewerPageState extends State { + final ViewerController _viewerController = ViewerController(); + + @override + void dispose() { + _viewerController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return MediaQueryDataProvider( @@ -30,8 +48,9 @@ class EntryViewerPage extends StatelessWidget { child: VideoConductorProvider( child: MultiPageConductorProvider( child: EntryViewerStack( - collection: collection, - initialEntry: initialEntry, + collection: widget.collection, + initialEntry: widget.initialEntry, + viewerController: _viewerController, ), ), ), @@ -45,10 +64,6 @@ class EntryViewerPage extends StatelessWidget { ), ); } - - static EdgeInsets snackBarMargin(BuildContext context) { - return EdgeInsets.only(bottom: ViewerBottomOverlay.actionSafeHeight(context)); - } } class ViewStateConductorProvider extends StatelessWidget { diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 22d8e9047..12b9eee24 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -16,6 +16,7 @@ import 'package:aves/widgets/aves_app.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/basic/insets.dart'; +import 'package:aves/widgets/viewer/controller.dart'; import 'package:aves/widgets/viewer/entry_vertical_pager.dart'; import 'package:aves/widgets/viewer/hero.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart'; @@ -23,6 +24,7 @@ import 'package:aves/widgets/viewer/notifications.dart'; import 'package:aves/widgets/viewer/overlay/bottom.dart'; import 'package:aves/widgets/viewer/overlay/notifications.dart'; import 'package:aves/widgets/viewer/overlay/panorama.dart'; +import 'package:aves/widgets/viewer/overlay/slideshow_buttons.dart'; import 'package:aves/widgets/viewer/overlay/top.dart'; import 'package:aves/widgets/viewer/overlay/video/video.dart'; import 'package:aves/widgets/viewer/page_entry_builder.dart'; @@ -42,11 +44,13 @@ import 'package:screen_brightness/screen_brightness.dart'; class EntryViewerStack extends StatefulWidget { final CollectionLens? collection; final AvesEntry initialEntry; + final ViewerController viewerController; const EntryViewerStack({ super.key, this.collection, required this.initialEntry, + required this.viewerController, }); @override @@ -54,7 +58,7 @@ class EntryViewerStack extends StatefulWidget { } class _EntryViewerStackState extends State with EntryViewControllerMixin, FeedbackMixin, SingleTickerProviderStateMixin, WidgetsBindingObserver { - late int _currentHorizontalPage; + late int _currentEntryIndex; late ValueNotifier _currentVerticalPage; late PageController _horizontalPager, _verticalPager; final AChangeNotifier _verticalScrollNotifier = AChangeNotifier(); @@ -68,7 +72,9 @@ class _EntryViewerStackState extends State with EntryViewContr bool _isEntryTracked = true; @override - final ValueNotifier entryNotifier = ValueNotifier(null); + late final ValueNotifier entryNotifier; + + ViewerController get viewerController => widget.viewerController; CollectionLens? get collection => widget.collection; @@ -103,10 +109,11 @@ class _EntryViewerStackState extends State with EntryViewContr final entry = entries.firstWhereOrNull((entry) => entry.id == initialEntry.id) ?? entries.firstOrNull; // opening hero, with viewer as target _heroInfoNotifier.value = HeroInfo(collection?.id, entry); + entryNotifier = viewerController.entryNotifier; entryNotifier.value = entry; - _currentHorizontalPage = max(0, entry != null ? entries.indexOf(entry) : -1); + _currentEntryIndex = max(0, entry != null ? entries.indexOf(entry) : -1); _currentVerticalPage = ValueNotifier(imagePage); - _horizontalPager = PageController(initialPage: _currentHorizontalPage); + _horizontalPager = PageController(initialPage: _currentEntryIndex); _verticalPager = PageController(initialPage: _currentVerticalPage.value)..addListener(_onVerticalPageControllerChange); _overlayAnimationController = AnimationController( duration: context.read().viewerOverlayAnimation, @@ -126,7 +133,7 @@ class _EntryViewerStackState extends State with EntryViewContr parent: _overlayAnimationController, curve: Curves.easeOutQuad, )); - _overlayVisible.value = settings.showOverlayOnOpening; + _overlayVisible.value = settings.showOverlayOnOpening && !viewerController.autopilot; _overlayVisible.addListener(_onOverlayVisibleChange); _videoActionDelegate = VideoActionDelegate( collection: collection, @@ -233,7 +240,7 @@ class _EntryViewerStackState extends State with EntryViewContr _goToVerticalPage(infoPage); } else if (notification is ViewEntryNotification) { final index = notification.index; - if (_currentHorizontalPage != index) { + if (_currentEntryIndex != index) { _horizontalPager.jumpToPage(index); } } else if (notification is VideoActionNotification) { @@ -250,6 +257,7 @@ class _EntryViewerStackState extends State with EntryViewContr ViewerVerticalPageView( collection: collection, entryNotifier: entryNotifier, + viewerController: viewerController, verticalPager: _verticalPager, horizontalPager: _horizontalPager, onVerticalPageChanged: _onVerticalPageChanged, @@ -257,8 +265,7 @@ class _EntryViewerStackState extends State with EntryViewContr onImagePageRequested: () => _goToVerticalPage(imagePage), onViewDisposed: (mainEntry, pageEntry) => viewStateConductor.reset(pageEntry ?? mainEntry), ), - _buildTopOverlay(), - _buildBottomOverlay(), + ..._buildOverlays(), const SideGestureAreaProtector(), const BottomGestureAreaProtector(), ], @@ -268,7 +275,40 @@ class _EntryViewerStackState extends State with EntryViewContr ); } - Widget _buildTopOverlay() { + List _buildOverlays() { + if (context.read>().value == AppMode.slideshow) { + return [_buildSlideshowBottomOverlay()]; + } + + return [ + _buildViewerTopOverlay(), + _buildViewerBottomOverlay(), + ]; + } + + Widget _buildSlideshowBottomOverlay() { + return Selector( + selector: (context, mq) => mq.size, + builder: (context, mqSize, child) { + return SizedBox.fromSize( + size: mqSize, + child: Align( + alignment: AlignmentDirectional.bottomEnd, + child: TooltipTheme( + data: TooltipTheme.of(context).copyWith( + preferBelow: false, + ), + child: SlideshowButtons( + scale: _overlayButtonScale, + ), + ), + ), + ); + }, + ); + } + + Widget _buildViewerTopOverlay() { Widget child = ValueListenableBuilder( valueListenable: entryNotifier, builder: (context, mainEntry, child) { @@ -278,7 +318,7 @@ class _EntryViewerStackState extends State with EntryViewContr position: _overlayTopOffset, child: ViewerTopOverlay( entries: entries, - index: _currentHorizontalPage, + index: _currentEntryIndex, hasCollection: hasCollection, mainEntry: mainEntry, scale: _overlayButtonScale, @@ -314,7 +354,7 @@ class _EntryViewerStackState extends State with EntryViewContr return child; } - Widget _buildBottomOverlay() { + Widget _buildViewerBottomOverlay() { Widget child = ValueListenableBuilder( valueListenable: entryNotifier, builder: (context, mainEntry, child) { @@ -378,7 +418,7 @@ class _EntryViewerStackState extends State with EntryViewContr if (extraBottomOverlay != null) extraBottomOverlay, ViewerBottomOverlay( entries: entries, - index: _currentHorizontalPage, + index: _currentEntryIndex, hasCollection: hasCollection, animationController: _overlayAnimationController, viewInsets: _frozenViewInsets, @@ -400,7 +440,7 @@ class _EntryViewerStackState extends State with EntryViewContr return AnimatedBuilder( animation: _verticalScrollNotifier, builder: (context, child) => Positioned( - bottom: (_verticalPager.position.hasPixels ? _verticalPager.offset : 0) - mqHeight, + bottom: (_verticalPager.hasClients && _verticalPager.position.hasPixels ? _verticalPager.offset : 0) - mqHeight, child: child!, ), child: child, @@ -422,7 +462,7 @@ class _EntryViewerStackState extends State with EntryViewContr } void _onVerticalPageControllerChange() { - if (!_isEntryTracked && _verticalPager.page?.floor() == transitionPage) { + if (!_isEntryTracked && _verticalPager.hasClients && _verticalPager.page?.floor() == transitionPage) { _trackEntry(); } _verticalScrollNotifier.notify(); @@ -440,12 +480,10 @@ class _EntryViewerStackState extends State with EntryViewContr context, MaterialPageRoute( settings: const RouteSettings(name: CollectionPage.routeName), - builder: (context) { - return CollectionPage( - source: baseCollection.source, - filters: {...baseCollection.filters, filter}, - ); - }, + builder: (context) => CollectionPage( + source: baseCollection.source, + filters: {...baseCollection.filters, filter}, + ), ), (route) => false, ); @@ -477,7 +515,10 @@ class _EntryViewerStackState extends State with EntryViewContr } void _onHorizontalPageChanged(int page) { - _currentHorizontalPage = page; + _currentEntryIndex = page; + if (viewerController.repeat) { + _currentEntryIndex %= entries.length; + } _updateEntry(); } @@ -521,14 +562,14 @@ class _EntryViewerStackState extends State with EntryViewContr } Future _updateEntry() async { - if (entries.isNotEmpty && _currentHorizontalPage >= entries.length) { + if (entries.isNotEmpty && _currentEntryIndex >= entries.length) { // as of Flutter v1.22.2, `PageView` does not call `onPageChanged` when the last page is deleted // so we manually track the page change, and let the entry update follow _onHorizontalPageChanged(entries.length - 1); return; } - final newEntry = _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null; + final newEntry = _currentEntryIndex < entries.length ? entries[_currentEntryIndex] : null; if (entryNotifier.value == newEntry) return; cleanEntryControllers(entryNotifier.value); entryNotifier.value = newEntry; @@ -606,6 +647,7 @@ class _EntryViewerStackState extends State with EntryViewContr } else { _overlayAnimationController.value = _overlayAnimationController.upperBound; } + viewerController.autopilot = false; } else { final mediaQuery = context.read(); setState(() { diff --git a/lib/widgets/viewer/overlay/bottom.dart b/lib/widgets/viewer/overlay/bottom.dart index 69ff44c15..3adfb4576 100644 --- a/lib/widgets/viewer/overlay/bottom.dart +++ b/lib/widgets/viewer/overlay/bottom.dart @@ -7,8 +7,8 @@ import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/viewer/multipage/controller.dart'; import 'package:aves/widgets/viewer/overlay/multipage.dart'; import 'package:aves/widgets/viewer/overlay/thumbnail_preview.dart'; -import 'package:aves/widgets/viewer/overlay/viewer_button_row.dart'; -import 'package:aves/widgets/viewer/overlay/wallpaper_button_row.dart'; +import 'package:aves/widgets/viewer/overlay/viewer_buttons.dart'; +import 'package:aves/widgets/viewer/overlay/wallpaper_buttons.dart'; import 'package:aves/widgets/viewer/page_entry_builder.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -37,7 +37,7 @@ class ViewerBottomOverlay extends StatefulWidget { State createState() => _ViewerBottomOverlayState(); static double actionSafeHeight(BuildContext context) { - return ViewerButtonRow.preferredHeight(context) + (settings.showOverlayThumbnailPreview ? ViewerThumbnailPreview.preferredHeight : 0); + return ViewerButtons.preferredHeight(context) + (settings.showOverlayThumbnailPreview ? ViewerThumbnailPreview.preferredHeight : 0); } } @@ -156,11 +156,11 @@ class _BottomOverlayContentState extends State<_BottomOverlayContent> { right: viewInsetsPadding.right, ), child: isWallpaperMode - ? WallpaperButton( + ? WallpaperButtons( entry: pageEntry, scale: _buttonScale, ) - : ViewerButtonRow( + : ViewerButtons( mainEntry: mainEntry, pageEntry: pageEntry, scale: _buttonScale, diff --git a/lib/widgets/viewer/overlay/slideshow_buttons.dart b/lib/widgets/viewer/overlay/slideshow_buttons.dart new file mode 100644 index 000000000..091332761 --- /dev/null +++ b/lib/widgets/viewer/overlay/slideshow_buttons.dart @@ -0,0 +1,43 @@ +import 'package:aves/model/actions/slideshow_actions.dart'; +import 'package:aves/widgets/viewer/overlay/common.dart'; +import 'package:aves/widgets/viewer/overlay/viewer_buttons.dart'; +import 'package:aves/widgets/viewer/slideshow_page.dart'; +import 'package:flutter/material.dart'; + +class SlideshowButtons extends StatelessWidget { + final Animation scale; + + const SlideshowButtons({ + super.key, + required this.scale, + }); + + @override + Widget build(BuildContext context) { + const padding = ViewerButtonRowContent.padding; + return SafeArea( + child: Padding( + padding: const EdgeInsets.only(left: padding / 2, right: padding / 2, bottom: padding), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SlideshowAction.resume, + SlideshowAction.showInCollection, + ] + .map((action) => Padding( + padding: const EdgeInsets.symmetric(horizontal: padding / 2), + child: OverlayButton( + scale: scale, + child: IconButton( + icon: action.getIcon(), + onPressed: () => SlideshowActionNotification(action).dispatch(context), + tooltip: action.getText(context), + ), + ), + )) + .toList(), + ), + ), + ); + } +} diff --git a/lib/widgets/viewer/overlay/viewer_button_row.dart b/lib/widgets/viewer/overlay/viewer_buttons.dart similarity index 99% rename from lib/widgets/viewer/overlay/viewer_button_row.dart rename to lib/widgets/viewer/overlay/viewer_buttons.dart index 734948402..5c46953ae 100644 --- a/lib/widgets/viewer/overlay/viewer_button_row.dart +++ b/lib/widgets/viewer/overlay/viewer_buttons.dart @@ -22,7 +22,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; -class ViewerButtonRow extends StatelessWidget { +class ViewerButtons extends StatelessWidget { final AvesEntry mainEntry; final AvesEntry pageEntry; final Animation scale; @@ -35,7 +35,7 @@ class ViewerButtonRow extends StatelessWidget { static double _buttonSize(BuildContext context) => OverlayButton.getSize(context); - const ViewerButtonRow({ + const ViewerButtons({ super.key, required this.mainEntry, required this.pageEntry, diff --git a/lib/widgets/viewer/overlay/wallpaper_button_row.dart b/lib/widgets/viewer/overlay/wallpaper_buttons.dart similarity index 98% rename from lib/widgets/viewer/overlay/wallpaper_button_row.dart rename to lib/widgets/viewer/overlay/wallpaper_buttons.dart index 3165b5e48..8b8506773 100644 --- a/lib/widgets/viewer/overlay/wallpaper_button_row.dart +++ b/lib/widgets/viewer/overlay/wallpaper_buttons.dart @@ -12,7 +12,7 @@ import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; -import 'package:aves/widgets/viewer/overlay/viewer_button_row.dart'; +import 'package:aves/widgets/viewer/overlay/viewer_buttons.dart'; import 'package:aves/widgets/viewer/video/conductor.dart'; import 'package:aves/widgets/viewer/visual/conductor.dart'; import 'package:flutter/material.dart'; @@ -20,11 +20,11 @@ import 'package:flutter/services.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; -class WallpaperButton extends StatelessWidget with FeedbackMixin { +class WallpaperButtons extends StatelessWidget with FeedbackMixin { final AvesEntry entry; final Animation scale; - const WallpaperButton({ + const WallpaperButtons({ super.key, required this.entry, required this.scale, diff --git a/lib/widgets/viewer/slideshow_page.dart b/lib/widgets/viewer/slideshow_page.dart new file mode 100644 index 000000000..c21b238bf --- /dev/null +++ b/lib/widgets/viewer/slideshow_page.dart @@ -0,0 +1,142 @@ +import 'package:aves/app_mode.dart'; +import 'package:aves/model/actions/slideshow_actions.dart'; +import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/mime.dart'; +import 'package:aves/model/settings/enums/enums.dart'; +import 'package:aves/model/settings/enums/slideshow_interval.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/empty.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/viewer/controller.dart'; +import 'package:aves/widgets/viewer/entry_viewer_page.dart'; +import 'package:aves/widgets/viewer/entry_viewer_stack.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class SlideshowPage extends StatefulWidget { + static const routeName = '/collection/slideshow'; + + final CollectionLens collection; + + const SlideshowPage({ + super.key, + required this.collection, + }); + + @override + State createState() => _SlideshowPageState(); +} + +class _SlideshowPageState extends State { + late final CollectionLens _slideshowCollection; + late final ViewerController _viewerController; + + @override + void initState() { + super.initState(); + final originalCollection = widget.collection; + var entries = originalCollection.sortedEntries; + if (settings.slideshowVideoPlayback == SlideshowVideoPlayback.skip) { + entries = entries.where((entry) => !MimeFilter.video.test(entry)).toList(); + } + if (settings.slideshowShuffle) { + entries.shuffle(); + } + _slideshowCollection = CollectionLens( + source: originalCollection.source, + listenToSource: false, + fixedSort: true, + fixedSelection: entries, + ); + _viewerController = ViewerController( + transition: settings.slideshowTransition, + repeat: settings.slideshowRepeat, + autopilot: true, + autopilotInterval: settings.slideshowInterval.getDuration(), + ); + } + + @override + void dispose() { + _viewerController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final entries = _slideshowCollection.sortedEntries; + return ListenableProvider>.value( + value: ValueNotifier(AppMode.slideshow), + child: MediaQueryDataProvider( + child: Scaffold( + body: entries.isEmpty + ? EmptyContent( + icon: AIcons.image, + text: context.l10n.collectionEmptyImages, + alignment: Alignment.center, + ) + : ViewStateConductorProvider( + child: VideoConductorProvider( + child: MultiPageConductorProvider( + child: NotificationListener( + onNotification: (notification) { + _onActionSelected(notification.action); + return true; + }, + child: EntryViewerStack( + collection: _slideshowCollection, + initialEntry: entries.first, + viewerController: _viewerController, + ), + ), + ), + ), + ), + ), + ), + ); + } + + void _onActionSelected(SlideshowAction action) { + switch (action) { + case SlideshowAction.resume: + _viewerController.autopilot = true; + break; + case SlideshowAction.showInCollection: + _showInCollection(); + break; + } + } + + void _showInCollection() { + final entry = _viewerController.entryNotifier.value; + if (entry == null) return; + + final source = _slideshowCollection.source; + final album = entry.directory; + final uri = entry.uri; + + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + settings: const RouteSettings(name: CollectionPage.routeName), + builder: (context) => CollectionPage( + source: source, + filters: album != null ? {AlbumFilter(album, source.getAlbumDisplayName(context, album))} : null, + highlightTest: (entry) => entry.uri == uri, + ), + ), + (route) => false, + ); + } +} + +class SlideshowActionNotification extends Notification { + final SlideshowAction action; + + SlideshowActionNotification(this.action); +} diff --git a/lib/widgets/viewer/visual/controller_mixin.dart b/lib/widgets/viewer/visual/controller_mixin.dart index 062d4b1c2..bf3aff9d7 100644 --- a/lib/widgets/viewer/visual/controller_mixin.dart +++ b/lib/widgets/viewer/visual/controller_mixin.dart @@ -1,4 +1,6 @@ +import 'package:aves/app_mode.dart'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart'; @@ -35,11 +37,27 @@ mixin EntryViewControllerMixin on State { } } + bool _isSlideshow(BuildContext context) => context.read>().value == AppMode.slideshow; + + bool _shouldAutoPlay(BuildContext context) { + if (_isSlideshow(context)) { + switch (settings.slideshowVideoPlayback) { + case SlideshowVideoPlayback.skip: + return false; + case SlideshowVideoPlayback.playMuted: + case SlideshowVideoPlayback.playWithSound: + return true; + } + } + + return settings.enableVideoAutoPlay; + } + Future _initVideoController(AvesEntry entry) async { final controller = context.read().getOrCreateController(entry); setState(() {}); - if (settings.enableVideoAutoPlay) { + if (_shouldAutoPlay(context)) { final resumeTimeMillis = await controller.getResumeTime(context); await _playVideo(controller, () => entry == entryNotifier.value, resumeTimeMillis: resumeTimeMillis); } @@ -66,7 +84,7 @@ mixin EntryViewControllerMixin on State { // auto play/pause when changing page Future _onPageChange() async { await pauseVideoControllers(); - if (settings.enableVideoAutoPlay || (entry.isMotionPhoto && settings.enableMotionPhotoAutoPlay)) { + if (_shouldAutoPlay(context) || (entry.isMotionPhoto && settings.enableMotionPhotoAutoPlay)) { final page = multiPageController.page; final pageInfo = multiPageInfo.getByIndex(page)!; if (pageInfo.isVideo) { @@ -109,6 +127,10 @@ mixin EntryViewControllerMixin on State { // so we play after a delay for increased stability await Future.delayed(const Duration(milliseconds: 300) * timeDilation); + if (_isSlideshow(context) && settings.slideshowVideoPlayback == SlideshowVideoPlayback.playMuted && !videoController.isMuted) { + await videoController.toggleMute(); + } + if (resumeTimeMillis != null) { await videoController.seekTo(resumeTimeMillis); } else { diff --git a/untranslated.json b/untranslated.json index c0fe5aa2f..1ba0a445e 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,99 +1,319 @@ { "de": [ + "slideshowActionResume", + "slideshowActionShowInCollection", + "slideshowVideoPlaybackSkip", + "slideshowVideoPlaybackMuted", + "slideshowVideoPlaybackWithSound", + "viewerTransitionFade", + "viewerTransitionFadeZoomIn", + "viewerTransitionParallax", + "viewerTransitionSlide", "wallpaperTargetHome", "wallpaperTargetLock", "wallpaperTargetHomeLock", + "menuActionSlideshow", "collectionEmptyGrantAccessButtonLabel", + "settingsViewerSlideshowTile", + "settingsViewerSlideshowTitle", + "settingsSlideshowRepeat", + "settingsSlideshowShuffle", + "settingsSlideshowTransitionTile", + "settingsSlideshowTransitionTitle", + "settingsSlideshowIntervalTile", + "settingsSlideshowIntervalTitle", + "settingsSlideshowVideoPlaybackTile", + "settingsSlideshowVideoPlaybackTitle", "settingsThemeEnableDynamicColor", "viewerSetWallpaperButtonLabel" ], "es": [ + "slideshowActionResume", + "slideshowActionShowInCollection", + "slideshowVideoPlaybackSkip", + "slideshowVideoPlaybackMuted", + "slideshowVideoPlaybackWithSound", + "viewerTransitionFade", + "viewerTransitionFadeZoomIn", + "viewerTransitionParallax", + "viewerTransitionSlide", "wallpaperTargetHome", "wallpaperTargetLock", "wallpaperTargetHomeLock", + "menuActionSlideshow", "collectionEmptyGrantAccessButtonLabel", "settingsShowBottomNavigationBar", "settingsThumbnailShowTagIcon", + "settingsViewerSlideshowTile", + "settingsViewerSlideshowTitle", + "settingsSlideshowRepeat", + "settingsSlideshowShuffle", + "settingsSlideshowTransitionTile", + "settingsSlideshowTransitionTitle", + "settingsSlideshowIntervalTile", + "settingsSlideshowIntervalTitle", + "settingsSlideshowVideoPlaybackTile", + "settingsSlideshowVideoPlaybackTitle", "settingsThemeEnableDynamicColor", "viewerSetWallpaperButtonLabel" ], "fr": [ + "slideshowActionResume", + "slideshowActionShowInCollection", + "slideshowVideoPlaybackSkip", + "slideshowVideoPlaybackMuted", + "slideshowVideoPlaybackWithSound", + "viewerTransitionFade", + "viewerTransitionFadeZoomIn", + "viewerTransitionParallax", + "viewerTransitionSlide", "wallpaperTargetHome", "wallpaperTargetLock", "wallpaperTargetHomeLock", + "menuActionSlideshow", "collectionEmptyGrantAccessButtonLabel", + "settingsViewerSlideshowTile", + "settingsViewerSlideshowTitle", + "settingsSlideshowRepeat", + "settingsSlideshowShuffle", + "settingsSlideshowTransitionTile", + "settingsSlideshowTransitionTitle", + "settingsSlideshowIntervalTile", + "settingsSlideshowIntervalTitle", + "settingsSlideshowVideoPlaybackTile", + "settingsSlideshowVideoPlaybackTitle", "settingsThemeEnableDynamicColor", "viewerSetWallpaperButtonLabel" ], "id": [ + "slideshowActionResume", + "slideshowActionShowInCollection", + "slideshowVideoPlaybackSkip", + "slideshowVideoPlaybackMuted", + "slideshowVideoPlaybackWithSound", + "viewerTransitionFade", + "viewerTransitionFadeZoomIn", + "viewerTransitionParallax", + "viewerTransitionSlide", "wallpaperTargetHome", "wallpaperTargetLock", "wallpaperTargetHomeLock", + "menuActionSlideshow", "collectionEmptyGrantAccessButtonLabel", + "settingsViewerSlideshowTile", + "settingsViewerSlideshowTitle", + "settingsSlideshowRepeat", + "settingsSlideshowShuffle", + "settingsSlideshowTransitionTile", + "settingsSlideshowTransitionTitle", + "settingsSlideshowIntervalTile", + "settingsSlideshowIntervalTitle", + "settingsSlideshowVideoPlaybackTile", + "settingsSlideshowVideoPlaybackTitle", "settingsThemeEnableDynamicColor", "viewerSetWallpaperButtonLabel" ], "it": [ + "slideshowActionResume", + "slideshowActionShowInCollection", + "slideshowVideoPlaybackSkip", + "slideshowVideoPlaybackMuted", + "slideshowVideoPlaybackWithSound", + "viewerTransitionFade", + "viewerTransitionFadeZoomIn", + "viewerTransitionParallax", + "viewerTransitionSlide", "wallpaperTargetHome", "wallpaperTargetLock", "wallpaperTargetHomeLock", + "menuActionSlideshow", "collectionEmptyGrantAccessButtonLabel", + "settingsViewerSlideshowTile", + "settingsViewerSlideshowTitle", + "settingsSlideshowRepeat", + "settingsSlideshowShuffle", + "settingsSlideshowTransitionTile", + "settingsSlideshowTransitionTitle", + "settingsSlideshowIntervalTile", + "settingsSlideshowIntervalTitle", + "settingsSlideshowVideoPlaybackTile", + "settingsSlideshowVideoPlaybackTitle", "settingsThemeEnableDynamicColor", "viewerSetWallpaperButtonLabel" ], "ja": [ + "slideshowActionResume", + "slideshowActionShowInCollection", + "slideshowVideoPlaybackSkip", + "slideshowVideoPlaybackMuted", + "slideshowVideoPlaybackWithSound", + "viewerTransitionFade", + "viewerTransitionFadeZoomIn", + "viewerTransitionParallax", + "viewerTransitionSlide", "wallpaperTargetHome", "wallpaperTargetLock", "wallpaperTargetHomeLock", + "menuActionSlideshow", "collectionEmptyGrantAccessButtonLabel", + "settingsViewerSlideshowTile", + "settingsViewerSlideshowTitle", + "settingsSlideshowRepeat", + "settingsSlideshowShuffle", + "settingsSlideshowTransitionTile", + "settingsSlideshowTransitionTitle", + "settingsSlideshowIntervalTile", + "settingsSlideshowIntervalTitle", + "settingsSlideshowVideoPlaybackTile", + "settingsSlideshowVideoPlaybackTitle", "settingsThemeEnableDynamicColor", "viewerSetWallpaperButtonLabel" ], "ko": [ + "slideshowActionResume", + "slideshowActionShowInCollection", + "slideshowVideoPlaybackSkip", + "slideshowVideoPlaybackMuted", + "slideshowVideoPlaybackWithSound", + "viewerTransitionFade", + "viewerTransitionFadeZoomIn", + "viewerTransitionParallax", + "viewerTransitionSlide", "wallpaperTargetHome", "wallpaperTargetLock", "wallpaperTargetHomeLock", + "menuActionSlideshow", "collectionEmptyGrantAccessButtonLabel", + "settingsViewerSlideshowTile", + "settingsViewerSlideshowTitle", + "settingsSlideshowRepeat", + "settingsSlideshowShuffle", + "settingsSlideshowTransitionTile", + "settingsSlideshowTransitionTitle", + "settingsSlideshowIntervalTile", + "settingsSlideshowIntervalTitle", + "settingsSlideshowVideoPlaybackTile", + "settingsSlideshowVideoPlaybackTitle", "settingsThemeEnableDynamicColor", "viewerSetWallpaperButtonLabel" ], "pt": [ + "slideshowActionResume", + "slideshowActionShowInCollection", + "slideshowVideoPlaybackSkip", + "slideshowVideoPlaybackMuted", + "slideshowVideoPlaybackWithSound", + "viewerTransitionFade", + "viewerTransitionFadeZoomIn", + "viewerTransitionParallax", + "viewerTransitionSlide", "wallpaperTargetHome", "wallpaperTargetLock", "wallpaperTargetHomeLock", + "menuActionSlideshow", "collectionEmptyGrantAccessButtonLabel", + "settingsViewerSlideshowTile", + "settingsViewerSlideshowTitle", + "settingsSlideshowRepeat", + "settingsSlideshowShuffle", + "settingsSlideshowTransitionTile", + "settingsSlideshowTransitionTitle", + "settingsSlideshowIntervalTile", + "settingsSlideshowIntervalTitle", + "settingsSlideshowVideoPlaybackTile", + "settingsSlideshowVideoPlaybackTitle", "settingsThemeEnableDynamicColor", "viewerSetWallpaperButtonLabel" ], "ru": [ + "slideshowActionResume", + "slideshowActionShowInCollection", + "slideshowVideoPlaybackSkip", + "slideshowVideoPlaybackMuted", + "slideshowVideoPlaybackWithSound", + "viewerTransitionFade", + "viewerTransitionFadeZoomIn", + "viewerTransitionParallax", + "viewerTransitionSlide", "wallpaperTargetHome", "wallpaperTargetLock", "wallpaperTargetHomeLock", + "menuActionSlideshow", "collectionEmptyGrantAccessButtonLabel", + "settingsViewerSlideshowTile", + "settingsViewerSlideshowTitle", + "settingsSlideshowRepeat", + "settingsSlideshowShuffle", + "settingsSlideshowTransitionTile", + "settingsSlideshowTransitionTitle", + "settingsSlideshowIntervalTile", + "settingsSlideshowIntervalTitle", + "settingsSlideshowVideoPlaybackTile", + "settingsSlideshowVideoPlaybackTitle", "settingsThemeEnableDynamicColor", "viewerSetWallpaperButtonLabel" ], "tr": [ + "slideshowActionResume", + "slideshowActionShowInCollection", + "slideshowVideoPlaybackSkip", + "slideshowVideoPlaybackMuted", + "slideshowVideoPlaybackWithSound", + "viewerTransitionFade", + "viewerTransitionFadeZoomIn", + "viewerTransitionParallax", + "viewerTransitionSlide", "wallpaperTargetHome", "wallpaperTargetLock", "wallpaperTargetHomeLock", + "menuActionSlideshow", + "settingsViewerSlideshowTile", + "settingsViewerSlideshowTitle", + "settingsSlideshowRepeat", + "settingsSlideshowShuffle", + "settingsSlideshowTransitionTile", + "settingsSlideshowTransitionTitle", + "settingsSlideshowIntervalTile", + "settingsSlideshowIntervalTitle", + "settingsSlideshowVideoPlaybackTile", + "settingsSlideshowVideoPlaybackTitle", "viewerSetWallpaperButtonLabel" ], "zh": [ + "slideshowActionResume", + "slideshowActionShowInCollection", + "slideshowVideoPlaybackSkip", + "slideshowVideoPlaybackMuted", + "slideshowVideoPlaybackWithSound", + "viewerTransitionFade", + "viewerTransitionFadeZoomIn", + "viewerTransitionParallax", + "viewerTransitionSlide", "wallpaperTargetHome", "wallpaperTargetLock", "wallpaperTargetHomeLock", + "menuActionSlideshow", "collectionEmptyGrantAccessButtonLabel", + "settingsViewerSlideshowTile", + "settingsViewerSlideshowTitle", + "settingsSlideshowRepeat", + "settingsSlideshowShuffle", + "settingsSlideshowTransitionTile", + "settingsSlideshowTransitionTitle", + "settingsSlideshowIntervalTile", + "settingsSlideshowIntervalTitle", + "settingsSlideshowVideoPlaybackTile", + "settingsSlideshowVideoPlaybackTitle", "settingsThemeEnableDynamicColor", "viewerSetWallpaperButtonLabel" ]