From 70def371965f26731415c89cf79e6b45eb3cd933 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Fri, 24 Jun 2022 16:21:11 +0900 Subject: [PATCH] screen saver POC --- .../app/src/debug/res/xml/screen_saver.xml | 2 +- android/app/src/main/AndroidManifest.xml | 21 ++ .../deckers/thibault/aves/MainActivity.kt | 6 +- .../thibault/aves/ScreenSaverService.kt | 196 +++++++++++++++++- .../aves/ScreenSaverSettingsActivity.kt | 9 +- android/app/src/main/res/xml/screen_saver.xml | 2 + .../app/src/profile/res/xml/screen_saver.xml | 2 +- .../src/profile/res/xml/screen_saverasd.xml | 2 + lib/app_mode.dart | 1 + lib/l10n/app_en.arb | 2 + .../enums/display_refresh_rate_mode.dart | 11 +- lib/model/settings/settings.dart | 22 ++ lib/model/source/collection_source.dart | 1 + lib/model/source/media_store_source.dart | 9 +- lib/services/window_service.dart | 13 ++ lib/widgets/aves_app.dart | 1 + lib/widgets/collection/grid/tile.dart | 1 + .../filter_grids/common/filter_tile.dart | 1 + lib/widgets/home_page.dart | 54 +++-- .../settings/screen_saver_settings_page.dart | 53 +++++ lib/widgets/viewer/entry_vertical_pager.dart | 3 +- lib/widgets/viewer/entry_viewer_stack.dart | 20 +- lib/widgets/viewer/screen_saver_page.dart | 168 +++++++-------- lib/widgets/viewer/slideshow_page.dart | 34 +-- .../viewer/visual/controller_mixin.dart | 32 +-- test/fake/window_service.dart | 3 + untranslated.json | 29 ++- 27 files changed, 537 insertions(+), 161 deletions(-) create mode 100644 android/app/src/profile/res/xml/screen_saverasd.xml 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" ] }