screen saver POC

This commit is contained in:
Thibault Deckers 2022-06-24 16:21:11 +09:00
parent a0eb5caa78
commit 70def37196
27 changed files with 537 additions and 161 deletions

View file

@ -1,2 +1,2 @@
<dream xmlns:android="http://schemas.android.com/apk/res/android"
android:settingsActivity="com.example.app/.ScreenSaverSettingsActivity" />
android:settingsActivity="deckers.thibault.aves.debug/deckers.thibault.aves.ScreenSaverSettingsActivity" />

View file

@ -141,6 +141,11 @@ This change eventually prevents building the app with Flutter v3.0.2.
android:resource="@xml/searchable" />
</activity>
<activity
android:name=".ScreenSaverSettingsActivity"
android:exported="true"
android:theme="@style/NormalTheme" />
<activity
android:name=".WallpaperActivity"
android:exported="true"
@ -165,6 +170,22 @@ This change eventually prevents building the app with Flutter v3.0.2.
android:description="@string/analysis_service_description"
android:exported="false" />
<service
android:name=".ScreenSaverService"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:permission="android.permission.BIND_DREAM_SERVICE">
<intent-filter>
<action android:name="android.service.dreams.DreamService" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<meta-data
android:name="android.service.dream"
android:resource="@xml/screen_saver" />
</service>
<!-- file provider to share files having a file:// URI -->
<provider
android:name="androidx.core.content.FileProvider"

View file

@ -28,7 +28,7 @@ import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
class MainActivity : FlutterActivity() {
open class MainActivity : FlutterActivity() {
private lateinit var mediaStoreChangeStreamHandler: MediaStoreChangeStreamHandler
private lateinit var settingsChangeStreamHandler: SettingsChangeStreamHandler
private lateinit var intentStreamHandler: IntentStreamHandler
@ -203,7 +203,7 @@ class MainActivity : FlutterActivity() {
}
}
private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
open fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
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"

View file

@ -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<ScreenSaverService>()
private val intentDataMap: MutableMap<String, Any?> = 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 = "/"
}
}

View file

@ -1,4 +1,11 @@
package deckers.thibault.aves
class ScreenSaverSettingsActivity {
import android.content.Intent
class ScreenSaverSettingsActivity : MainActivity() {
override fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
return hashMapOf(
INTENT_DATA_KEY_ACTION to INTENT_ACTION_SCREEN_SAVER_SETTINGS,
)
}
}

View file

@ -0,0 +1,2 @@
<dream xmlns:android="http://schemas.android.com/apk/res/android"
android:settingsActivity="deckers.thibault.aves/deckers.thibault.aves.ScreenSaverSettingsActivity" />

View file

@ -1,2 +1,2 @@
<dream xmlns:android="http://schemas.android.com/apk/res/android"
android:settingsActivity="com.example.app/.ScreenSaverSettingsActivity" />
android:settingsActivity="deckers.thibault.aves.profile/deckers.thibault.aves.ScreenSaverSettingsActivity" />

View file

@ -0,0 +1,2 @@
<dream xmlns:android="http://schemas.android.com/apk/res/android"
android:settingsActivity="deckers.thibault.aves.profile/deckers.thibault.aves.ScreenSaverSettingsActivity" />

View file

@ -4,6 +4,7 @@ enum AppMode {
pickMultipleMediaExternal,
pickMediaInternal,
pickFilterInternal,
screenSaver,
setWallpaper,
slideshow,
view,

View file

@ -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": {

View file

@ -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<void> 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;
}
}

View file

@ -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:

View file

@ -370,6 +370,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
AnalysisController? analysisController,
String? directory,
bool loadTopEntriesFirst = false,
bool canAnalyze = true,
});
Future<Set<String>> refreshUris(Set<String> changedUris, {AnalysisController? analysisController});

View file

@ -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();
}
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,

View file

@ -3,6 +3,8 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
abstract class WindowService {
Future<bool> isActivity();
Future<void> keepScreenOn(bool on);
Future<bool> isRotationLocked();
@ -17,6 +19,17 @@ abstract class WindowService {
class PlatformWindowService implements WindowService {
static const platform = MethodChannel('deckers.thibault/aves/window');
@override
Future<bool> 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<void> keepScreenOn(bool on) async {
try {

View file

@ -230,6 +230,7 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
break;
case AppMode.pickMediaInternal:
case AppMode.pickFilterInternal:
case AppMode.screenSaver:
case AppMode.setWallpaper:
case AppMode.slideshow:
case AppMode.view:

View file

@ -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:

View file

@ -63,6 +63,7 @@ class _InteractiveFilterTileState<T extends CollectionFilter> extends State<Inte
Navigator.pop<T>(context, filter);
break;
case AppMode.pickMediaInternal:
case AppMode.screenSaver:
case AppMode.setWallpaper:
case AppMode.slideshow:
case AppMode.view:

View file

@ -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<HomePage> {
Set<String>? _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<HomePage> {
Future<void> _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
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<HomePage> {
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<HomePage> {
loadTopEntriesFirst: settings.homePage == HomePageSetting.collection,
);
break;
case AppMode.screenSaver:
final source = context.read<CollectionSource>();
await source.init(
canAnalyze: false,
);
break;
case AppMode.view:
if (_isViewerSourceable(_viewerEntry)) {
final directory = _viewerEntry?.directory;
@ -271,8 +289,20 @@ class _HomePageState extends State<HomePage> {
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<HomePage> {
case CollectionPage.routeName:
default:
return DirectMaterialPageRoute(
settings: const RouteSettings(name: CollectionPage.routeName),
settings: RouteSettings(name: routeName),
builder: (context) => CollectionPage(
source: source,
filters: filters,

View file

@ -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<ViewerTransition>(
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<SlideshowInterval>(
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<SlideshowVideoPlayback>(
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,
),
],
),
),
);
}
}

View file

@ -116,7 +116,8 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
_buildImagePage(),
];
if (context.read<ValueNotifier<AppMode>>().value != AppMode.slideshow) {
final appMode = context.read<ValueNotifier<AppMode>>().value;
if (!{AppMode.screenSaver, AppMode.slideshow}.contains(appMode)) {
final infoPage = NotificationListener<ShowImageNotification>(
onNotification: (notification) {
widget.onImagePageRequested();

View file

@ -276,15 +276,21 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
}
List<Widget> _buildOverlays() {
if (context.read<ValueNotifier<AppMode>>().value == AppMode.slideshow) {
return [_buildSlideshowBottomOverlay()];
}
final appMode = context.read<ValueNotifier<AppMode>>().value;
switch (appMode) {
case AppMode.screenSaver:
return [];
case AppMode.slideshow:
return [
_buildSlideshowBottomOverlay(),
];
default:
return [
_buildViewerTopOverlay(),
_buildViewerBottomOverlay(),
];
}
}
Widget _buildSlideshowBottomOverlay() {
return Selector<MediaQueryData, Size>(

View file

@ -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<SlideshowPage> createState() => _SlideshowPageState();
State<ScreenSaverPage> createState() => _ScreenSaverPageState();
}
class _SlideshowPageState extends State<SlideshowPage> {
late final CollectionLens _slideshowCollection;
class _ScreenSaverPageState extends State<ScreenSaverPage> {
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<ValueNotifier<AppMode>>.value(
value: ValueNotifier(AppMode.slideshow),
child: MediaQueryDataProvider(
child: Scaffold(
body: entries.isEmpty
? EmptyContent(
Widget child;
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,
)
: ViewStateConductorProvider(
);
} else {
child = ViewStateConductorProvider(
child: VideoConductorProvider(
child: MultiPageConductorProvider(
child: NotificationListener<SlideshowActionNotification>(
onNotification: (notification) {
_onActionSelected(notification.action);
return true;
},
child: EntryViewerStack(
collection: _slideshowCollection,
collection: collection,
initialEntry: entries.first,
viewerController: _viewerController,
),
),
),
),
),
),
);
}
}
return MediaQueryDataProvider(
child: Scaffold(
body: child,
),
);
}
void _onActionSelected(SlideshowAction action) {
switch (action) {
case SlideshowAction.resume:
_viewerController.autopilot = true;
break;
case SlideshowAction.showInCollection:
_showInCollection();
break;
}
}
void _initSlideshowCollection() {
if (source.stateNotifier.value != SourceState.ready || _slideshowCollection != null) return;
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(
final originalCollection = CollectionLens(
source: source,
filters: album != null ? {AlbumFilter(album, source.getAlbumDisplayName(context, album))} : null,
highlightTest: (entry) => entry.uri == uri,
),
),
(route) => false,
// 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);
}

View file

@ -32,32 +32,19 @@ class SlideshowPage extends StatefulWidget {
}
class _SlideshowPageState extends State<SlideshowPage> {
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<SlideshowPage> {
);
}
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:

View file

@ -37,21 +37,29 @@ mixin EntryViewControllerMixin<T extends StatefulWidget> on State<T> {
}
}
bool _isSlideshow(BuildContext context) => context.read<ValueNotifier<AppMode>>().value == AppMode.slideshow;
SlideshowVideoPlayback? get videoPlaybackOverride {
final appMode = context.read<ValueNotifier<AppMode>>().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) {
switch (videoPlaybackOverride) {
case SlideshowVideoPlayback.skip:
return false;
case SlideshowVideoPlayback.playMuted:
case SlideshowVideoPlayback.playWithSound:
return true;
}
}
case null:
return settings.enableVideoAutoPlay;
}
}
Future<void> _initVideoController(AvesEntry entry) async {
final controller = context.read<VideoConductor>().getOrCreateController(entry);
@ -127,7 +135,7 @@ mixin EntryViewControllerMixin<T extends StatefulWidget> on State<T> {
// 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();
}

View file

@ -4,6 +4,9 @@ import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
class FakeWindowService extends Fake implements WindowService {
@override
Future<bool> isActivity() => SynchronousFuture(true);
@override
Future<void> keepScreenOn(bool on) => SynchronousFuture(null);

View file

@ -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"
]
}