diff --git a/android/app/src/debug/res/xml/screen_saver.xml b/android/app/src/debug/res/xml/screen_saver.xml
index 91be344f3..7b51a7bea 100644
--- a/android/app/src/debug/res/xml/screen_saver.xml
+++ b/android/app/src/debug/res/xml/screen_saver.xml
@@ -1,2 +1,2 @@
\ No newline at end of file
+ android:settingsActivity="deckers.thibault.aves.debug/deckers.thibault.aves.ScreenSaverSettingsActivity" />
\ No newline at end of file
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index e566bc012..642303be5 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -141,6 +141,11 @@ This change eventually prevents building the app with Flutter v3.0.2.
android:resource="@xml/searchable" />
+
+
+
+
+
+
+
+
+
+
+
{
+ open fun extractIntentData(intent: Intent?): MutableMap {
when (intent?.action) {
Intent.ACTION_MAIN -> {
intent.getStringExtra(SHORTCUT_KEY_PAGE)?.let { page ->
@@ -338,6 +338,8 @@ class MainActivity : FlutterActivity() {
const val INTENT_DATA_KEY_QUERY = "query"
const val INTENT_ACTION_PICK = "pick"
+ const val INTENT_ACTION_SCREEN_SAVER = "screen_saver"
+ const val INTENT_ACTION_SCREEN_SAVER_SETTINGS = "screen_saver_settings"
const val INTENT_ACTION_SEARCH = "search"
const val INTENT_ACTION_SET_WALLPAPER = "set_wallpaper"
const val INTENT_ACTION_VIEW = "view"
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/ScreenSaverService.kt b/android/app/src/main/kotlin/deckers/thibault/aves/ScreenSaverService.kt
index ad949c2de..b5333bb8d 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/ScreenSaverService.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/ScreenSaverService.kt
@@ -1,4 +1,198 @@
package deckers.thibault.aves
-class ScreenSaverService {
+import android.service.dreams.DreamService
+import android.view.View
+import android.view.ViewTreeObserver
+import app.loup.streams_channel.StreamsChannel
+import deckers.thibault.aves.channel.calls.AccessibilityHandler
+import deckers.thibault.aves.channel.calls.DeviceHandler
+import deckers.thibault.aves.channel.calls.MediaFileHandler
+import deckers.thibault.aves.channel.calls.MediaStoreHandler
+import deckers.thibault.aves.channel.calls.window.ServiceWindowHandler
+import deckers.thibault.aves.channel.calls.window.WindowHandler
+import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
+import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler
+import deckers.thibault.aves.utils.LogUtils
+import io.flutter.FlutterInjector
+import io.flutter.Log
+import io.flutter.embedding.android.FlutterActivity
+import io.flutter.embedding.android.FlutterSurfaceView
+import io.flutter.embedding.android.FlutterView
+import io.flutter.embedding.engine.FlutterEngine
+import io.flutter.embedding.engine.dart.DartExecutor.DartEntrypoint
+import io.flutter.embedding.engine.plugins.util.GeneratedPluginRegister
+import io.flutter.embedding.engine.renderer.FlutterUiDisplayListener
+import io.flutter.plugin.common.MethodChannel
+
+// for FlutterView-level integration, cf https://docs.flutter.dev/development/add-to-app/android/add-flutter-view
+class ScreenSaverService : DreamService() {
+ private var flutterEngine: FlutterEngine? = null
+ private var flutterView: FlutterView? = null
+ private var activePreDrawListener: ViewTreeObserver.OnPreDrawListener? = null
+ private var isFlutterUiDisplayed = false
+ private var isFirstFrameRendered = false
+ private var isAttached = false
+
+ private val flutterUiDisplayListener: FlutterUiDisplayListener = object : FlutterUiDisplayListener {
+ override fun onFlutterUiDisplayed() {
+ isFlutterUiDisplayed = true
+ isFirstFrameRendered = true
+ }
+
+ override fun onFlutterUiNoLongerDisplayed() {
+ isFlutterUiDisplayed = false
+ }
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ onAttach()
+
+ val messenger = flutterEngine!!.dartExecutor.binaryMessenger
+
+ // dart -> platform -> dart
+ // - need Context
+ MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this))
+ MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
+ // - need ContextWrapper
+ MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
+ MethodChannel(messenger, MediaFileHandler.CHANNEL).setMethodCallHandler(MediaFileHandler(this))
+ // - need Service
+ MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(ServiceWindowHandler(this))
+
+ // result streaming: dart -> platform ->->-> dart
+ // - need Context
+ StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) }
+ StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) }
+
+ // intent handling
+ // detail fetch: dart -> platform
+ MethodChannel(messenger, WallpaperActivity.VIEWER_CHANNEL).setMethodCallHandler { call, result ->
+ when (call.method) {
+ "getIntentData" -> {
+ result.success(intentDataMap)
+ intentDataMap.clear()
+ }
+ }
+ }
+
+ // dream setup
+ isInteractive = false
+ isFullscreen = true
+ setContentView(createFlutterView())
+ }
+
+ override fun onDreamingStarted() {
+ super.onDreamingStarted()
+ onStart()
+ }
+
+ override fun onDreamingStopped() {
+ onDestroyView()
+ super.onDreamingStopped()
+ }
+
+ override fun onDetachedFromWindow() {
+ release()
+ super.onDetachedFromWindow()
+ }
+
+ // from `FlutterActivityAndFragmentDelegate`
+
+ private fun createFlutterView(): View {
+ Log.d(LOG_TAG, "Creating FlutterView.")
+ val flutterSurfaceView = FlutterSurfaceView(this)
+ val pFlutterView = FlutterView(this, flutterSurfaceView)
+ flutterView = pFlutterView
+
+ // Add listener to be notified when Flutter renders its first frame.
+ pFlutterView.addOnFirstFrameRenderedListener(flutterUiDisplayListener)
+ Log.d(LOG_TAG, "Attaching FlutterEngine to FlutterView.")
+ pFlutterView.attachToFlutterEngine(flutterEngine!!)
+ pFlutterView.id = FlutterActivity.FLUTTER_VIEW_ID
+ delayFirstAndroidViewDraw(pFlutterView)
+ return pFlutterView
+ }
+
+ private fun release() {
+ flutterEngine = null
+ flutterView = null
+ }
+
+ private fun onAttach() {
+ if (flutterEngine == null) {
+ Log.d(LOG_TAG, "Setting up FlutterEngine.")
+ flutterEngine = FlutterEngine(
+ this,
+ null,
+ false,
+ )
+ }
+ GeneratedPluginRegister.registerGeneratedPlugins(flutterEngine!!)
+ isAttached = true
+ }
+
+ private fun onStart() {
+ Log.d(LOG_TAG, "onStart()")
+ doInitialFlutterViewRun()
+ flutterView!!.visibility = View.VISIBLE
+ flutterEngine!!.lifecycleChannel.appIsResumed()
+ }
+
+ private fun onDestroyView() {
+ Log.v(LOG_TAG, "onDestroyView()")
+ flutterView ?: return
+
+ val pFlutterView = flutterView!!
+ if (activePreDrawListener != null) {
+ pFlutterView.viewTreeObserver.removeOnPreDrawListener(activePreDrawListener)
+ activePreDrawListener = null
+ }
+ pFlutterView.detachFromFlutterEngine()
+ pFlutterView.removeOnFirstFrameRenderedListener(flutterUiDisplayListener)
+
+ flutterEngine!!.lifecycleChannel.appIsInactive()
+ }
+
+ private fun delayFirstAndroidViewDraw(flutterView: FlutterView) {
+ if (activePreDrawListener != null) {
+ flutterView.viewTreeObserver.removeOnPreDrawListener(activePreDrawListener)
+ }
+ activePreDrawListener = object : ViewTreeObserver.OnPreDrawListener {
+ override fun onPreDraw(): Boolean {
+ if (isFlutterUiDisplayed && activePreDrawListener != null) {
+ flutterView.viewTreeObserver.removeOnPreDrawListener(this)
+ activePreDrawListener = null
+ }
+ return isFlutterUiDisplayed
+ }
+ }
+ flutterView.viewTreeObserver.addOnPreDrawListener(activePreDrawListener)
+ }
+
+ private fun doInitialFlutterViewRun() {
+ val pFlutterEngine = flutterEngine!!
+ if (pFlutterEngine.dartExecutor.isExecutingDart) {
+ // No warning is logged because this situation will happen on every config
+ // change if the developer does not choose to retain the Fragment instance.
+ // So this is expected behavior in many cases.
+ return
+ }
+
+ pFlutterEngine.navigationChannel.setInitialRoute(DEFAULT_INITIAL_ROUTE)
+ val appBundlePathOverride = FlutterInjector.instance().flutterLoader().findAppBundlePath()
+ val entrypoint = DartEntrypoint(appBundlePathOverride, DEFAULT_DART_ENTRYPOINT)
+ pFlutterEngine.dartExecutor.executeDartEntrypoint(entrypoint)
+ }
+
+ companion object {
+ private val LOG_TAG = LogUtils.createTag()
+ private val intentDataMap: MutableMap = hashMapOf(
+ MainActivity.INTENT_DATA_KEY_ACTION to MainActivity.INTENT_ACTION_SCREEN_SAVER,
+ )
+
+ // from `FlutterActivityLaunchConfigs`
+ const val DEFAULT_DART_ENTRYPOINT = "main"
+ const val DEFAULT_INITIAL_ROUTE = "/"
+ }
}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/ScreenSaverSettingsActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/ScreenSaverSettingsActivity.kt
index 5f6549f7c..dbc2e74f3 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/ScreenSaverSettingsActivity.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/ScreenSaverSettingsActivity.kt
@@ -1,4 +1,11 @@
package deckers.thibault.aves
-class ScreenSaverSettingsActivity {
+import android.content.Intent
+
+class ScreenSaverSettingsActivity : MainActivity() {
+ override fun extractIntentData(intent: Intent?): MutableMap {
+ return hashMapOf(
+ INTENT_DATA_KEY_ACTION to INTENT_ACTION_SCREEN_SAVER_SETTINGS,
+ )
+ }
}
\ No newline at end of file
diff --git a/android/app/src/main/res/xml/screen_saver.xml b/android/app/src/main/res/xml/screen_saver.xml
index e69de29bb..78dbc6777 100644
--- a/android/app/src/main/res/xml/screen_saver.xml
+++ b/android/app/src/main/res/xml/screen_saver.xml
@@ -0,0 +1,2 @@
+
\ No newline at end of file
diff --git a/android/app/src/profile/res/xml/screen_saver.xml b/android/app/src/profile/res/xml/screen_saver.xml
index 91be344f3..012d5b878 100644
--- a/android/app/src/profile/res/xml/screen_saver.xml
+++ b/android/app/src/profile/res/xml/screen_saver.xml
@@ -1,2 +1,2 @@
\ No newline at end of file
+ android:settingsActivity="deckers.thibault.aves.profile/deckers.thibault.aves.ScreenSaverSettingsActivity" />
\ No newline at end of file
diff --git a/android/app/src/profile/res/xml/screen_saverasd.xml b/android/app/src/profile/res/xml/screen_saverasd.xml
new file mode 100644
index 000000000..012d5b878
--- /dev/null
+++ b/android/app/src/profile/res/xml/screen_saverasd.xml
@@ -0,0 +1,2 @@
+
\ No newline at end of file
diff --git a/lib/app_mode.dart b/lib/app_mode.dart
index 0c4840ba3..2f823dbdd 100644
--- a/lib/app_mode.dart
+++ b/lib/app_mode.dart
@@ -4,6 +4,7 @@ enum AppMode {
pickMultipleMediaExternal,
pickMediaInternal,
pickFilterInternal,
+ screenSaver,
setWallpaper,
slideshow,
view,
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index cb9cc6b69..1a37ff721 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -765,6 +765,8 @@
"settingsUnitSystemTile": "Units",
"settingsUnitSystemTitle": "Units",
+ "settingsScreenSaverTitle": "Screen Saver",
+
"statsPageTitle": "Stats",
"statsWithGps": "{count, plural, =1{1 item with location} other{{count} items with location}}",
"@statsWithGps": {
diff --git a/lib/model/settings/enums/display_refresh_rate_mode.dart b/lib/model/settings/enums/display_refresh_rate_mode.dart
index d650d5337..76df4ea4d 100644
--- a/lib/model/settings/enums/display_refresh_rate_mode.dart
+++ b/lib/model/settings/enums/display_refresh_rate_mode.dart
@@ -1,3 +1,4 @@
+import 'package:aves/services/common/services.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/material.dart';
import 'package:flutter_displaymode/flutter_displaymode.dart';
@@ -16,17 +17,19 @@ extension ExtraDisplayRefreshRateMode on DisplayRefreshRateMode {
}
}
- void apply() {
+ Future apply() async {
+ if (!await windowService.isActivity()) return;
+
debugPrint('Apply display refresh rate: $name');
switch (this) {
case DisplayRefreshRateMode.auto:
- FlutterDisplayMode.setPreferredMode(DisplayMode.auto);
+ await FlutterDisplayMode.setPreferredMode(DisplayMode.auto);
break;
case DisplayRefreshRateMode.highest:
- FlutterDisplayMode.setHighRefreshRate();
+ await FlutterDisplayMode.setHighRefreshRate();
break;
case DisplayRefreshRateMode.lowest:
- FlutterDisplayMode.setLowRefreshRate();
+ await FlutterDisplayMode.setLowRefreshRate();
break;
}
}
diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart
index 624c359b4..96c63b72e 100644
--- a/lib/model/settings/settings.dart
+++ b/lib/model/settings/settings.dart
@@ -138,6 +138,11 @@ class Settings extends ChangeNotifier {
// file picker
static const filePickerShowHiddenFilesKey = 'file_picker_show_hidden_files';
+ // screen saver
+ static const screenSaverTransitionKey = 'screen_saver_transition';
+ static const screenSaverVideoPlaybackKey = 'screen_saver_video_playback';
+ static const screenSaverIntervalKey = 'screen_saver_interval';
+
// slideshow
static const slideshowRepeatKey = 'slideshow_loop';
static const slideshowShuffleKey = 'slideshow_shuffle';
@@ -583,6 +588,20 @@ class Settings extends ChangeNotifier {
set filePickerShowHiddenFiles(bool newValue) => setAndNotify(filePickerShowHiddenFilesKey, newValue);
+ // screen saver
+
+ ViewerTransition get screenSaverTransition => getEnumOrDefault(screenSaverTransitionKey, SettingsDefaults.slideshowTransition, ViewerTransition.values);
+
+ set screenSaverTransition(ViewerTransition newValue) => setAndNotify(screenSaverTransitionKey, newValue.toString());
+
+ SlideshowVideoPlayback get screenSaverVideoPlayback => getEnumOrDefault(screenSaverVideoPlaybackKey, SettingsDefaults.slideshowVideoPlayback, SlideshowVideoPlayback.values);
+
+ set screenSaverVideoPlayback(SlideshowVideoPlayback newValue) => setAndNotify(screenSaverVideoPlaybackKey, newValue.toString());
+
+ SlideshowInterval get screenSaverInterval => getEnumOrDefault(screenSaverIntervalKey, SettingsDefaults.slideshowInterval, SlideshowInterval.values);
+
+ set screenSaverInterval(SlideshowInterval newValue) => setAndNotify(screenSaverIntervalKey, newValue.toString());
+
// slideshow
bool get slideshowRepeat => getBoolOrDefault(slideshowRepeatKey, SettingsDefaults.slideshowRepeat);
@@ -792,6 +811,9 @@ class Settings extends ChangeNotifier {
case unitSystemKey:
case accessibilityAnimationsKey:
case timeToTakeActionKey:
+ case screenSaverTransitionKey:
+ case screenSaverVideoPlaybackKey:
+ case screenSaverIntervalKey:
case slideshowTransitionKey:
case slideshowVideoPlaybackKey:
case slideshowIntervalKey:
diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart
index 02b6c0b9f..3ba0bca9e 100644
--- a/lib/model/source/collection_source.dart
+++ b/lib/model/source/collection_source.dart
@@ -370,6 +370,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
AnalysisController? analysisController,
String? directory,
bool loadTopEntriesFirst = false,
+ bool canAnalyze = true,
});
Future> refreshUris(Set changedUris, {AnalysisController? analysisController});
diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart
index 28fbc4b76..72b977c6b 100644
--- a/lib/model/source/media_store_source.dart
+++ b/lib/model/source/media_store_source.dart
@@ -24,6 +24,7 @@ class MediaStoreSource extends CollectionSource {
AnalysisController? analysisController,
String? directory,
bool loadTopEntriesFirst = false,
+ bool canAnalyze = true,
}) async {
if (_initState == SourceInitializationState.none) {
await _loadEssentials();
@@ -35,6 +36,7 @@ class MediaStoreSource extends CollectionSource {
analysisController: analysisController,
directory: directory,
loadTopEntriesFirst: loadTopEntriesFirst,
+ canAnalyze: canAnalyze,
));
}
@@ -63,6 +65,7 @@ class MediaStoreSource extends CollectionSource {
AnalysisController? analysisController,
String? directory,
required bool loadTopEntriesFirst,
+ required bool canAnalyze,
}) async {
debugPrint('$runtimeType refresh start');
final stopwatch = Stopwatch()..start();
@@ -182,7 +185,11 @@ class MediaStoreSource extends CollectionSource {
if (analysisIds != null) {
analysisEntries = visibleEntries.where((entry) => analysisIds.contains(entry.id)).toSet();
}
- await analyze(analysisController, entries: analysisEntries);
+ if (canAnalyze) {
+ await analyze(analysisController, entries: analysisEntries);
+ } else {
+ stateNotifier.value = SourceState.ready;
+ }
// the home page may not reflect the current derived filters
// as the initial addition of entries is silent,
diff --git a/lib/services/window_service.dart b/lib/services/window_service.dart
index 2a0b53b1b..28cc45d6d 100644
--- a/lib/services/window_service.dart
+++ b/lib/services/window_service.dart
@@ -3,6 +3,8 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
abstract class WindowService {
+ Future isActivity();
+
Future keepScreenOn(bool on);
Future isRotationLocked();
@@ -17,6 +19,17 @@ abstract class WindowService {
class PlatformWindowService implements WindowService {
static const platform = MethodChannel('deckers.thibault/aves/window');
+ @override
+ Future isActivity() async {
+ try {
+ final result = await platform.invokeMethod('isActivity');
+ if (result != null) return result as bool;
+ } on PlatformException catch (e, stack) {
+ await reportService.recordError(e, stack);
+ }
+ return false;
+ }
+
@override
Future keepScreenOn(bool on) async {
try {
diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart
index 5b7c64661..8894d25fb 100644
--- a/lib/widgets/aves_app.dart
+++ b/lib/widgets/aves_app.dart
@@ -230,6 +230,7 @@ class _AvesAppState extends State with WidgetsBindingObserver {
break;
case AppMode.pickMediaInternal:
case AppMode.pickFilterInternal:
+ case AppMode.screenSaver:
case AppMode.setWallpaper:
case AppMode.slideshow:
case AppMode.view:
diff --git a/lib/widgets/collection/grid/tile.dart b/lib/widgets/collection/grid/tile.dart
index 3eef2ef9c..54b86e54f 100644
--- a/lib/widgets/collection/grid/tile.dart
+++ b/lib/widgets/collection/grid/tile.dart
@@ -54,6 +54,7 @@ class InteractiveTile extends StatelessWidget {
Navigator.pop(context, entry);
break;
case AppMode.pickFilterInternal:
+ case AppMode.screenSaver:
case AppMode.setWallpaper:
case AppMode.slideshow:
case AppMode.view:
diff --git a/lib/widgets/filter_grids/common/filter_tile.dart b/lib/widgets/filter_grids/common/filter_tile.dart
index 60a165d51..226389f8c 100644
--- a/lib/widgets/filter_grids/common/filter_tile.dart
+++ b/lib/widgets/filter_grids/common/filter_tile.dart
@@ -63,6 +63,7 @@ class _InteractiveFilterTileState extends State(context, filter);
break;
case AppMode.pickMediaInternal:
+ case AppMode.screenSaver:
case AppMode.setWallpaper:
case AppMode.slideshow:
case AppMode.view:
diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart
index 039cea723..770f20281 100644
--- a/lib/widgets/home_page.dart
+++ b/lib/widgets/home_page.dart
@@ -21,7 +21,9 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/search/route.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/search/search_delegate.dart';
+import 'package:aves/widgets/settings/screen_saver_settings_page.dart';
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
+import 'package:aves/widgets/viewer/screen_saver_page.dart';
import 'package:aves/widgets/wallpaper_page.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
@@ -49,6 +51,8 @@ class _HomePageState extends State {
Set? _shortcutFilters;
static const actionPick = 'pick';
+ static const actionScreenSaver = 'screen_saver';
+ static const actionScreenSaverSettings = 'screen_saver_settings';
static const actionSearch = 'search';
static const actionSetWallpaper = 'set_wallpaper';
static const actionView = 'view';
@@ -71,20 +75,21 @@ class _HomePageState extends State {
Future _setup() async {
final stopwatch = Stopwatch()..start();
- // do not check whether permission was granted,
- // as some app stores hide in some countries
- // apps that force quit on permission denial
- await [
- Permission.storage,
- // to access media with unredacted metadata with scoped storage (Android 10+)
- Permission.accessMediaLocation,
- ].request();
+ if (await windowService.isActivity()) {
+ // do not check whether permission was granted, because some app stores
+ // hide in some countries apps that force quit on permission denial
+ await [
+ Permission.storage,
+ // to access media with unredacted metadata with scoped storage (Android 10+)
+ Permission.accessMediaLocation,
+ ].request();
+ }
var appMode = AppMode.main;
final intentData = widget.intentData ?? await ViewerService.getIntentData();
final intentAction = intentData['action'];
- if (intentAction != actionSetWallpaper) {
+ if (!{actionScreenSaver, actionSetWallpaper}.contains(intentAction)) {
await androidFileUtils.init();
if (settings.isInstalledAppAccessAllowed) {
unawaited(androidFileUtils.initAppNames());
@@ -111,6 +116,13 @@ class _HomePageState extends State {
debugPrint('pick mimeType=$pickMimeTypes multiple=$multiple');
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
break;
+ case actionScreenSaver:
+ appMode = AppMode.screenSaver;
+ _shortcutRouteName = ScreenSaverPage.routeName;
+ break;
+ case actionScreenSaverSettings:
+ _shortcutRouteName = ScreenSaverSettingsPage.routeName;
+ break;
case actionSearch:
_shortcutRouteName = CollectionSearchDelegate.pageRouteName;
_shortcutSearchQuery = intentData['query'];
@@ -147,6 +159,12 @@ class _HomePageState extends State {
loadTopEntriesFirst: settings.homePage == HomePageSetting.collection,
);
break;
+ case AppMode.screenSaver:
+ final source = context.read();
+ await source.init(
+ canAnalyze: false,
+ );
+ break;
case AppMode.view:
if (_isViewerSourceable(_viewerEntry)) {
final directory = _viewerEntry?.directory;
@@ -271,8 +289,20 @@ class _HomePageState extends State {
switch (routeName) {
case AlbumListPage.routeName:
return DirectMaterialPageRoute(
- settings: const RouteSettings(name: AlbumListPage.routeName),
- builder: (_) => const AlbumListPage(),
+ settings: RouteSettings(name: routeName),
+ builder: (context) => const AlbumListPage(),
+ );
+ case ScreenSaverPage.routeName:
+ return DirectMaterialPageRoute(
+ settings: RouteSettings(name: routeName),
+ builder: (context) => ScreenSaverPage(
+ source: source,
+ ),
+ );
+ case ScreenSaverSettingsPage.routeName:
+ return DirectMaterialPageRoute(
+ settings: RouteSettings(name: routeName),
+ builder: (context) => const ScreenSaverSettingsPage(),
);
case CollectionSearchDelegate.pageRouteName:
return SearchPageRoute(
@@ -286,7 +316,7 @@ class _HomePageState extends State {
case CollectionPage.routeName:
default:
return DirectMaterialPageRoute(
- settings: const RouteSettings(name: CollectionPage.routeName),
+ settings: RouteSettings(name: routeName),
builder: (context) => CollectionPage(
source: source,
filters: filters,
diff --git a/lib/widgets/settings/screen_saver_settings_page.dart b/lib/widgets/settings/screen_saver_settings_page.dart
index e69de29bb..a8979d122 100644
--- a/lib/widgets/settings/screen_saver_settings_page.dart
+++ b/lib/widgets/settings/screen_saver_settings_page.dart
@@ -0,0 +1,53 @@
+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 ScreenSaverSettingsPage extends StatelessWidget {
+ static const routeName = '/settings/screen_saver';
+
+ const ScreenSaverSettingsPage({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: Text(context.l10n.settingsScreenSaverTitle),
+ ),
+ body: SafeArea(
+ child: ListView(
+ children: [
+ SettingsSelectionListTile(
+ values: ViewerTransition.values,
+ getName: (context, v) => v.getName(context),
+ selector: (context, s) => s.screenSaverTransition,
+ onSelection: (v) => settings.screenSaverTransition = v,
+ tileTitle: context.l10n.settingsSlideshowTransitionTile,
+ dialogTitle: context.l10n.settingsSlideshowTransitionTitle,
+ ),
+ SettingsSelectionListTile(
+ values: SlideshowInterval.values,
+ getName: (context, v) => v.getName(context),
+ selector: (context, s) => s.screenSaverInterval,
+ onSelection: (v) => settings.screenSaverInterval = v,
+ tileTitle: context.l10n.settingsSlideshowIntervalTile,
+ dialogTitle: context.l10n.settingsSlideshowIntervalTitle,
+ ),
+ SettingsSelectionListTile(
+ values: SlideshowVideoPlayback.values,
+ getName: (context, v) => v.getName(context),
+ selector: (context, s) => s.screenSaverVideoPlayback,
+ onSelection: (v) => settings.screenSaverVideoPlayback = v,
+ tileTitle: context.l10n.settingsSlideshowVideoPlaybackTile,
+ dialogTitle: context.l10n.settingsSlideshowVideoPlaybackTitle,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/widgets/viewer/entry_vertical_pager.dart b/lib/widgets/viewer/entry_vertical_pager.dart
index b7e310155..56b503263 100644
--- a/lib/widgets/viewer/entry_vertical_pager.dart
+++ b/lib/widgets/viewer/entry_vertical_pager.dart
@@ -116,7 +116,8 @@ class _ViewerVerticalPageViewState extends State {
_buildImagePage(),
];
- if (context.read>().value != AppMode.slideshow) {
+ final appMode = context.read>().value;
+ if (!{AppMode.screenSaver, AppMode.slideshow}.contains(appMode)) {
final infoPage = NotificationListener(
onNotification: (notification) {
widget.onImagePageRequested();
diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart
index 12b9eee24..f181b1f9e 100644
--- a/lib/widgets/viewer/entry_viewer_stack.dart
+++ b/lib/widgets/viewer/entry_viewer_stack.dart
@@ -276,14 +276,20 @@ class _EntryViewerStackState extends State with EntryViewContr
}
List _buildOverlays() {
- if (context.read>().value == AppMode.slideshow) {
- return [_buildSlideshowBottomOverlay()];
+ final appMode = context.read>().value;
+ switch (appMode) {
+ case AppMode.screenSaver:
+ return [];
+ case AppMode.slideshow:
+ return [
+ _buildSlideshowBottomOverlay(),
+ ];
+ default:
+ return [
+ _buildViewerTopOverlay(),
+ _buildViewerBottomOverlay(),
+ ];
}
-
- return [
- _buildViewerTopOverlay(),
- _buildViewerBottomOverlay(),
- ];
}
Widget _buildSlideshowBottomOverlay() {
diff --git a/lib/widgets/viewer/screen_saver_page.dart b/lib/widgets/viewer/screen_saver_page.dart
index c21b238bf..cdae83431 100644
--- a/lib/widgets/viewer/screen_saver_page.dart
+++ b/lib/widgets/viewer/screen_saver_page.dart
@@ -1,13 +1,11 @@
-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/model/source/collection_source.dart';
+import 'package:aves/model/source/enums.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';
@@ -15,128 +13,110 @@ 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';
+class ScreenSaverPage extends StatefulWidget {
+ static const routeName = '/screen_saver';
- final CollectionLens collection;
+ final CollectionSource source;
- const SlideshowPage({
+ const ScreenSaverPage({
super.key,
- required this.collection,
+ required this.source,
});
@override
- State createState() => _SlideshowPageState();
+ State createState() => _ScreenSaverPageState();
}
-class _SlideshowPageState extends State {
- late final CollectionLens _slideshowCollection;
+class _ScreenSaverPageState extends State {
late final ViewerController _viewerController;
+ CollectionLens? _slideshowCollection;
+
+ CollectionSource get source => widget.source;
@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,
+ transition: settings.screenSaverTransition,
+ repeat: true,
autopilot: true,
- autopilotInterval: settings.slideshowInterval.getDuration(),
+ autopilotInterval: settings.screenSaverInterval.getDuration(),
);
+ source.stateNotifier.addListener(_onSourceStateChanged);
+ _initSlideshowCollection();
+ }
+
+ void _onSourceStateChanged() {
+ if (_slideshowCollection == null) {
+ _initSlideshowCollection();
+ if (_slideshowCollection != null) {
+ setState(() {});
+ }
+ }
}
@override
void dispose() {
+ source.stateNotifier.removeListener(_onSourceStateChanged);
_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,
- ),
- ),
- ),
- ),
- ),
- ),
- ),
- );
- }
+ Widget child;
- void _onActionSelected(SlideshowAction action) {
- switch (action) {
- case SlideshowAction.resume:
- _viewerController.autopilot = true;
- break;
- case SlideshowAction.showInCollection:
- _showInCollection();
- break;
+ final collection = _slideshowCollection;
+ if (collection == null) {
+ child = const SizedBox();
+ } else {
+ final entries = collection.sortedEntries;
+ if (entries.isEmpty) {
+ child = EmptyContent(
+ icon: AIcons.image,
+ text: context.l10n.collectionEmptyImages,
+ alignment: Alignment.center,
+ );
+ } else {
+ child = ViewStateConductorProvider(
+ child: VideoConductorProvider(
+ child: MultiPageConductorProvider(
+ child: EntryViewerStack(
+ collection: collection,
+ initialEntry: entries.first,
+ viewerController: _viewerController,
+ ),
+ ),
+ ),
+ );
+ }
}
+
+ return MediaQueryDataProvider(
+ child: Scaffold(
+ body: child,
+ ),
+ );
}
- void _showInCollection() {
- final entry = _viewerController.entryNotifier.value;
- if (entry == null) return;
+ void _initSlideshowCollection() {
+ if (source.stateNotifier.value != SourceState.ready || _slideshowCollection != 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,
+ final originalCollection = CollectionLens(
+ source: source,
+ // TODO TLAD [screensaver] custom filters
+ );
+ var entries = originalCollection.sortedEntries;
+ if (settings.screenSaverVideoPlayback == SlideshowVideoPlayback.skip) {
+ entries = entries.where((entry) => !MimeFilter.video.test(entry)).toList();
+ }
+ entries.shuffle();
+ _slideshowCollection = CollectionLens(
+ source: originalCollection.source,
+ listenToSource: false,
+ fixedSort: true,
+ fixedSelection: entries,
);
}
}
-
-class SlideshowActionNotification extends Notification {
- final SlideshowAction action;
-
- SlideshowActionNotification(this.action);
-}
diff --git a/lib/widgets/viewer/slideshow_page.dart b/lib/widgets/viewer/slideshow_page.dart
index c21b238bf..cc2e9ab74 100644
--- a/lib/widgets/viewer/slideshow_page.dart
+++ b/lib/widgets/viewer/slideshow_page.dart
@@ -32,32 +32,19 @@ class SlideshowPage extends StatefulWidget {
}
class _SlideshowPageState extends State {
- late final CollectionLens _slideshowCollection;
late final ViewerController _viewerController;
+ late final CollectionLens _slideshowCollection;
@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(),
);
+ _initSlideshowCollection();
}
@override
@@ -101,6 +88,23 @@ class _SlideshowPageState extends State {
);
}
+ void _initSlideshowCollection() {
+ 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,
+ );
+ }
+
void _onActionSelected(SlideshowAction action) {
switch (action) {
case SlideshowAction.resume:
diff --git a/lib/widgets/viewer/visual/controller_mixin.dart b/lib/widgets/viewer/visual/controller_mixin.dart
index bf3aff9d7..ff5eead4c 100644
--- a/lib/widgets/viewer/visual/controller_mixin.dart
+++ b/lib/widgets/viewer/visual/controller_mixin.dart
@@ -37,20 +37,28 @@ mixin EntryViewControllerMixin on State {
}
}
- bool _isSlideshow(BuildContext context) => context.read>().value == AppMode.slideshow;
+ SlideshowVideoPlayback? get videoPlaybackOverride {
+ final appMode = context.read>().value;
+ switch (appMode) {
+ case AppMode.screenSaver:
+ return settings.screenSaverVideoPlayback;
+ case AppMode.slideshow:
+ return settings.slideshowVideoPlayback;
+ default:
+ return null;
+ }
+ }
bool _shouldAutoPlay(BuildContext context) {
- if (_isSlideshow(context)) {
- switch (settings.slideshowVideoPlayback) {
- case SlideshowVideoPlayback.skip:
- return false;
- case SlideshowVideoPlayback.playMuted:
- case SlideshowVideoPlayback.playWithSound:
- return true;
- }
+ switch (videoPlaybackOverride) {
+ case SlideshowVideoPlayback.skip:
+ return false;
+ case SlideshowVideoPlayback.playMuted:
+ case SlideshowVideoPlayback.playWithSound:
+ return true;
+ case null:
+ return settings.enableVideoAutoPlay;
}
-
- return settings.enableVideoAutoPlay;
}
Future _initVideoController(AvesEntry entry) async {
@@ -127,7 +135,7 @@ 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) {
+ if (videoPlaybackOverride == SlideshowVideoPlayback.playMuted && !videoController.isMuted) {
await videoController.toggleMute();
}
diff --git a/test/fake/window_service.dart b/test/fake/window_service.dart
index aff923d3e..a87e5c2dc 100644
--- a/test/fake/window_service.dart
+++ b/test/fake/window_service.dart
@@ -4,6 +4,9 @@ import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
class FakeWindowService extends Fake implements WindowService {
+ @override
+ Future isActivity() => SynchronousFuture(true);
+
@override
Future keepScreenOn(bool on) => SynchronousFuture(null);
diff --git a/untranslated.json b/untranslated.json
index 5f9c32b15..b64b4fbfc 100644
--- a/untranslated.json
+++ b/untranslated.json
@@ -1,34 +1,42 @@
{
"de": [
- "filterOnThisDayLabel"
+ "filterOnThisDayLabel",
+ "settingsScreenSaverTitle"
],
"es": [
- "filterOnThisDayLabel"
+ "filterOnThisDayLabel",
+ "settingsScreenSaverTitle"
],
"fr": [
- "filterOnThisDayLabel"
+ "filterOnThisDayLabel",
+ "settingsScreenSaverTitle"
],
"id": [
- "filterOnThisDayLabel"
+ "filterOnThisDayLabel",
+ "settingsScreenSaverTitle"
],
"it": [
- "filterOnThisDayLabel"
+ "filterOnThisDayLabel",
+ "settingsScreenSaverTitle"
],
"ja": [
- "filterOnThisDayLabel"
+ "filterOnThisDayLabel",
+ "settingsScreenSaverTitle"
],
"ko": [
- "filterOnThisDayLabel"
+ "filterOnThisDayLabel",
+ "settingsScreenSaverTitle"
],
"pt": [
- "filterOnThisDayLabel"
+ "filterOnThisDayLabel",
+ "settingsScreenSaverTitle"
],
"ru": [
@@ -58,6 +66,7 @@
"settingsSlideshowVideoPlaybackTile",
"settingsSlideshowVideoPlaybackTitle",
"settingsThemeEnableDynamicColor",
+ "settingsScreenSaverTitle",
"viewerSetWallpaperButtonLabel"
],
@@ -86,10 +95,12 @@
"settingsSlideshowIntervalTitle",
"settingsSlideshowVideoPlaybackTile",
"settingsSlideshowVideoPlaybackTitle",
+ "settingsScreenSaverTitle",
"viewerSetWallpaperButtonLabel"
],
"zh": [
- "filterOnThisDayLabel"
+ "filterOnThisDayLabel",
+ "settingsScreenSaverTitle"
]
}