feat(android): switch to screen-on rotation service and updated scheduler
The rotation service now responds to screen on/off events via a dedicated ScreenOnRotationService, reducing background work and avoiding perpetual wakeful states. Manifest and receiver logic were adjusted to align with the new behavior and energy constraints. Scheduling and enablement now consistently use timed-based rules and screen-on preferences, with the boot and alarm paths updated to honor these settings. This avoids unnecessary foreground services and keeps the app compliant with Doze and battery optimizations. Immich image handling improvements include throttled progress updates, larger cache allowances, and a connectivity-aware automatic download check to avoid unwanted network usage. - Use ScreenOnRotationService for screen-on based rotation scheduling - Centralize enablement via timed-enabled paths and AlarmManager windowing - Improve Immich cache and progress reporting with connectivity checks
This commit is contained in:
@@ -10,12 +10,15 @@ WallpaperFlow ist eine freie Tauri-2-App für Android, die aus einer frei wählb
|
||||
- zufällige oder fortlaufende Reihenfolge
|
||||
- manueller Wechsel über „Nächstes Motiv“
|
||||
- automatischer Wechsel alle 5, 15 oder 30 Minuten beziehungsweise alle 1, 3, 6 oder 12 Stunden
|
||||
- Neustart des Dienstes nach einem Geräte-Neustart, soweit die Android-Version Hintergrundstarts zulässt
|
||||
- optionaler Wechsel bei jedem Einschalten des Displays
|
||||
- erneute Planung des automatischen Wechsels nach einem Geräte-Neustart
|
||||
- lokale Vorschaubilder; die Originalbilder verlassen das Gerät nicht
|
||||
- optionaler Import aus einem selbst gehosteten Immich-Server über einen eingeschränkten API-Key
|
||||
- vollständig lokale Oberfläche in Deutsch, Englisch, Französisch, Spanisch, Italienisch, Niederländisch, Polnisch, Portugiesisch, Japanisch, Koreanisch und vereinfachtem Chinesisch
|
||||
|
||||
Android hält den Zeitgeber über einen Foreground Service aktiv. Solange ein Wechselintervall ausgewählt ist, zeigt das System deshalb eine dauerhafte, stille Benachrichtigung. Auf Geräten mit aggressivem Energiesparen muss LockScreenWallpaper gegebenenfalls von der Akku-Optimierung ausgenommen werden.
|
||||
Der automatische Wechsel verwendet einen ungenauen Android-Alarm und respektiert Doze sowie den Energiesparmodus. Dadurch gibt es keinen dauerhaft laufenden Dienst und keine permanente Benachrichtigung; Wechsel können im Ruhezustand etwas später erfolgen. Automatische Immich-Downloads erfolgen nur über eine nicht getaktete Verbindung und bei ausreichendem Akkustand.
|
||||
|
||||
Der optionale Modus „Beim Einschalten des Displays“ benötigt dagegen einen kleinen Foreground Service mit permanenter Benachrichtigung, damit Android die Display-Ereignisse zuverlässig zustellt. Das nächste Bild wird bereits beim Ausschalten gesetzt und ist dadurch beim folgenden Einschalten ohne sichtbare Verzögerung vorhanden. Der Dienst hält keinen CPU-Wake-Lock.
|
||||
|
||||
## Entwicklung
|
||||
|
||||
@@ -46,9 +49,9 @@ Der native Android-Code liegt als lokales Tauri-Plugin in `plugins/android`. Er
|
||||
|
||||
## Wichtige Android-Hinweise
|
||||
|
||||
- Android erlaubt keinen komplett unsichtbaren, dauerhaft laufenden Zeitgeber. Der automatische Wechsel verwendet deshalb einen Foreground Service.
|
||||
- Android darf ungenaue Alarme im Ruhemodus verzögern. Der nächste Wechsel wird ausgeführt, sobald das Gerät wieder aktiv ist.
|
||||
- `WallpaperManager.FLAG_LOCK` ist ab Android 7 verfügbar. Auf älteren Geräten setzt die App das allgemeine Hintergrundbild.
|
||||
- Hersteller können Hintergrunddienste zusätzlich einschränken. Besonders bei Samsung/Xiaomi kann eine Ausnahme von der Akku-Optimierung nötig sein.
|
||||
- Hersteller können Hintergrundarbeit zusätzlich verzögern. Eine Ausnahme von der Akku-Optimierung ist für den normalen Betrieb nicht vorgesehen.
|
||||
- „Ohne Limit“ bedeutet: kein App-Zähler wie Samsungs 15-Bilder-Grenze. Praktisch begrenzen freier Gerätespeicher und Dateisystem die Sammlung.
|
||||
|
||||
## Datenschutz
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.SET_WALLPAPER" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<application>
|
||||
<service android:name="de.wechselbild.wallpaper.WallpaperRotationService" android:exported="false" android:foregroundServiceType="specialUse">
|
||||
<property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" android:value="Changes the user-selected lock-screen wallpaper at the configured interval" />
|
||||
<service android:name="de.wechselbild.wallpaper.ScreenOnRotationService" android:exported="false" android:foregroundServiceType="specialUse">
|
||||
<property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" android:value="Changes the user-selected lock-screen wallpaper when the screen turns on" />
|
||||
</service>
|
||||
<receiver android:name="de.wechselbild.wallpaper.BootReceiver" android:enabled="true" android:exported="true">
|
||||
<intent-filter>
|
||||
|
||||
@@ -6,9 +6,10 @@ import android.content.Intent
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action in setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED) && WallpaperStore.enabled(context)) {
|
||||
WallpaperScheduler.scheduleNext(context)
|
||||
runCatching { WallpaperRotationService.start(context) }
|
||||
if (intent.action !in setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED)) return
|
||||
when {
|
||||
WallpaperStore.screenOnEnabled(context) -> runCatching { ScreenOnRotationService.start(context) }
|
||||
WallpaperStore.timedEnabled(context) -> WallpaperScheduler.scheduleNext(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.wechselbild.wallpaper
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.graphics.BitmapFactory
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
@@ -33,9 +34,9 @@ object ImmichClient {
|
||||
private const val KEY_API_KEY_DATA = "immich_api_key_data"
|
||||
private const val KEY_API_KEY_IV = "immich_api_key_iv"
|
||||
private const val KEY_ALIAS = "wallpaperflow_immich_api_key"
|
||||
private const val CACHE_MAX_FILES = 3
|
||||
private const val CACHE_MAX_BYTES = 256L * 1024 * 1024
|
||||
private const val CACHE_MAX_BYTES = 512L * 1024 * 1024
|
||||
private const val DOWNLOAD_MAX_BYTES = 128L * 1024 * 1024
|
||||
private const val PROGRESS_STEP_BYTES = 256L * 1024
|
||||
private val assetIdPattern = Regex("^[0-9a-fA-F-]{36}$")
|
||||
private val thumbnailPool = Executors.newFixedThreadPool(4)
|
||||
private val importProgress = AtomicReference(ImportProgress())
|
||||
@@ -204,6 +205,7 @@ object ImmichClient {
|
||||
val total = connection.contentLengthLong.coerceAtLeast(0)
|
||||
require(total == 0L || total <= DOWNLOAD_MAX_BYTES) { "Das Immich-Bild ist zu groß." }
|
||||
var downloaded = 0L
|
||||
var lastProgressUpdate = 0L
|
||||
if (trackProgress) importProgress.updateAndGet { it.copy(bytesDownloaded = 0, bytesTotal = total) }
|
||||
connection.inputStream.use { input ->
|
||||
target.outputStream().use { output ->
|
||||
@@ -214,7 +216,10 @@ object ImmichClient {
|
||||
output.write(buffer, 0, count)
|
||||
downloaded += count
|
||||
require(downloaded <= DOWNLOAD_MAX_BYTES) { "Das Immich-Bild ist zu groß." }
|
||||
if (trackProgress) importProgress.updateAndGet { it.copy(bytesDownloaded = downloaded, bytesTotal = total) }
|
||||
if (trackProgress && (downloaded - lastProgressUpdate >= PROGRESS_STEP_BYTES || downloaded == total)) {
|
||||
lastProgressUpdate = downloaded
|
||||
importProgress.updateAndGet { it.copy(bytesDownloaded = downloaded, bytesTotal = total) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -309,6 +314,12 @@ object ImmichClient {
|
||||
return bounds.outWidth > 0 && bounds.outHeight > 0
|
||||
}
|
||||
|
||||
fun automaticDownloadsAllowed(context: Context): Boolean {
|
||||
val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||
@Suppress("DEPRECATION")
|
||||
return connectivity.activeNetworkInfo?.isConnected == true && !connectivity.isActiveNetworkMetered
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun cachedOriginal(context: Context, serverUrl: String, assetId: String, allowNetwork: Boolean = true): File? {
|
||||
val target = File(cacheDirectory(context), "${cacheKey(serverUrl, assetId)}.image")
|
||||
@@ -347,13 +358,13 @@ object ImmichClient {
|
||||
}
|
||||
|
||||
private fun trimCache(context: Context, protected: File) {
|
||||
val files = cacheDirectory(context).listFiles()?.filter { it.isFile && !it.name.startsWith(".") }?.sortedByDescending { it.lastModified() } ?: return
|
||||
var bytes = 0L
|
||||
files.forEachIndexed { index, file ->
|
||||
bytes += file.length()
|
||||
if (file != protected && (index >= CACHE_MAX_FILES || bytes > CACHE_MAX_BYTES)) {
|
||||
bytes -= file.length()
|
||||
file.delete()
|
||||
val files = cacheDirectory(context).listFiles()?.filter { it.isFile && !it.name.startsWith(".") }?.sortedBy { it.lastModified() } ?: return
|
||||
var bytes = files.sumOf { it.length() }
|
||||
for (file in files) {
|
||||
if (bytes <= CACHE_MAX_BYTES) break
|
||||
if (file != protected) {
|
||||
val length = file.length()
|
||||
if (file.delete()) bytes -= length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+51
-50
@@ -4,37 +4,46 @@ import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class WallpaperRotationService : Service() {
|
||||
class ScreenOnRotationService : Service() {
|
||||
private val worker = Executors.newSingleThreadExecutor()
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val rotate = object : Runnable {
|
||||
override fun run() {
|
||||
if (!WallpaperStore.enabled(this@WallpaperRotationService)) {
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
worker.execute {
|
||||
if (WallpaperScheduler.claimIfDue(this@WallpaperRotationService)) {
|
||||
WallpaperRotationWorker.enqueue(this@WallpaperRotationService)
|
||||
private val rotating = AtomicBoolean(false)
|
||||
private val changedWhileScreenOff = AtomicBoolean(false)
|
||||
private val screenReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (!WallpaperStore.screenOnEnabled(context)) return
|
||||
when (intent.action) {
|
||||
Intent.ACTION_SCREEN_OFF -> rotate(context, preparedForScreenOn = true)
|
||||
Intent.ACTION_SCREEN_ON -> {
|
||||
if (!changedWhileScreenOff.getAndSet(false)) rotate(context, preparedForScreenOn = false)
|
||||
}
|
||||
handler.post { scheduleLocalTimer() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rotate(context: Context, preparedForScreenOn: Boolean) {
|
||||
if (!rotating.compareAndSet(false, true)) return
|
||||
worker.execute {
|
||||
try {
|
||||
if (WallpaperStore.applyNext(context, automatic = true) && preparedForScreenOn) {
|
||||
changedWhileScreenOff.set(true)
|
||||
}
|
||||
} finally { rotating.set(false) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
runningService = this
|
||||
createChannel()
|
||||
val launch = packageManager.getLaunchIntentForPackage(packageName)
|
||||
val pending = PendingIntent.getActivity(this, 0, launch, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
@@ -42,34 +51,49 @@ class WallpaperRotationService : Service() {
|
||||
.setSmallIcon(android.R.drawable.ic_menu_gallery)
|
||||
.setContentTitle(getString(R.string.wallpaper_service_title))
|
||||
.setContentText(getString(R.string.wallpaper_service_text))
|
||||
.setOngoing(true).setSilent(true).setContentIntent(pending).build()
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.setContentIntent(pending)
|
||||
.build()
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
WallpaperScheduler.ensureScheduled(this)
|
||||
scheduleLocalTimer()
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
registerReceiver(screenReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
registerReceiver(screenReceiver, filter)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (!WallpaperStore.enabled(this)) {
|
||||
if (!WallpaperStore.screenOnEnabled(this)) {
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
WallpaperScheduler.ensureScheduled(this)
|
||||
scheduleLocalTimer()
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onDestroy() {
|
||||
handler.removeCallbacks(rotate)
|
||||
runCatching { unregisterReceiver(screenReceiver) }
|
||||
worker.shutdown()
|
||||
if (runningService === this) runningService = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun scheduleLocalTimer() {
|
||||
handler.removeCallbacks(rotate)
|
||||
if (WallpaperStore.enabled(this)) {
|
||||
handler.postDelayed(rotate, WallpaperScheduler.remainingDelay(this))
|
||||
companion object {
|
||||
private const val CHANNEL = "wechselbild_screen_on"
|
||||
private const val NOTIFICATION_ID = 4217
|
||||
|
||||
fun start(context: Context) {
|
||||
ContextCompat.startForegroundService(context, Intent(context, ScreenOnRotationService::class.java))
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, ScreenOnRotationService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,27 +103,4 @@ class WallpaperRotationService : Service() {
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL = "wechselbild_rotation"
|
||||
private const val NOTIFICATION_ID = 4217
|
||||
@Volatile private var runningService: WallpaperRotationService? = null
|
||||
|
||||
fun rescheduleRunningService() {
|
||||
val service = runningService ?: return
|
||||
service.handler.post { service.scheduleLocalTimer() }
|
||||
}
|
||||
fun start(context: Context) {
|
||||
WallpaperScheduler.ensureScheduled(context)
|
||||
ContextCompat.startForegroundService(context, Intent(context, WallpaperRotationService::class.java))
|
||||
}
|
||||
fun restart(context: Context) {
|
||||
WallpaperScheduler.scheduleNext(context)
|
||||
start(context)
|
||||
}
|
||||
fun stop(context: Context) {
|
||||
WallpaperScheduler.cancel(context)
|
||||
context.stopService(Intent(context, WallpaperRotationService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import android.content.Intent
|
||||
|
||||
class WallpaperAlarmReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (!WallpaperStore.enabled(context)) {
|
||||
if (!WallpaperStore.timedEnabled(context)) {
|
||||
WallpaperScheduler.cancel(context)
|
||||
return
|
||||
}
|
||||
@@ -14,6 +14,5 @@ class WallpaperAlarmReceiver : BroadcastReceiver() {
|
||||
WallpaperRotationWorker.enqueue(context)
|
||||
}
|
||||
WallpaperScheduler.ensureScheduled(context)
|
||||
WallpaperRotationService.rescheduleRunningService()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,6 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
|
||||
private val io = Executors.newSingleThreadExecutor()
|
||||
|
||||
@Command fun getState(invoke: Invoke) {
|
||||
if (WallpaperStore.enabled(activity)) runCatching { WallpaperRotationService.start(activity) }
|
||||
io.execute { invoke.resolve(WallpaperStore.state(activity)) }
|
||||
}
|
||||
|
||||
@@ -141,7 +140,10 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
|
||||
try {
|
||||
val args = invoke.parseArgs(IntervalArgs::class.java)
|
||||
WallpaperStore.setInterval(activity, args.minutes)
|
||||
if (args.minutes > 0) WallpaperRotationService.restart(activity) else WallpaperRotationService.stop(activity)
|
||||
WallpaperScheduler.cancel(activity)
|
||||
ScreenOnRotationService.stop(activity)
|
||||
if (args.minutes == -1) ScreenOnRotationService.start(activity)
|
||||
else if (args.minutes > 0) WallpaperScheduler.scheduleNext(activity)
|
||||
invoke.resolve(WallpaperStore.state(activity))
|
||||
} catch (error: Exception) { invoke.reject(error.message ?: "Wechselintervall konnte nicht gespeichert werden") }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.wechselbild.wallpaper
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.Worker
|
||||
@@ -9,9 +10,9 @@ import androidx.work.WorkerParameters
|
||||
|
||||
class WallpaperRotationWorker(context: Context, parameters: WorkerParameters) : Worker(context, parameters) {
|
||||
override fun doWork(): Result {
|
||||
if (!WallpaperStore.enabled(applicationContext)) return Result.success()
|
||||
if (WallpaperStore.applyNext(applicationContext)) return Result.success()
|
||||
return if (runAttemptCount < 2) Result.retry() else Result.failure()
|
||||
if (!WallpaperStore.timedEnabled(applicationContext)) return Result.success()
|
||||
WallpaperStore.applyNext(applicationContext, automatic = true)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -21,7 +22,9 @@ class WallpaperRotationWorker(context: Context, parameters: WorkerParameters) :
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
WORK_NAME,
|
||||
ExistingWorkPolicy.KEEP,
|
||||
OneTimeWorkRequestBuilder<WallpaperRotationWorker>().build(),
|
||||
OneTimeWorkRequestBuilder<WallpaperRotationWorker>()
|
||||
.setConstraints(Constraints.Builder().setRequiresBatteryNotLow(true).build())
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
|
||||
object WallpaperScheduler {
|
||||
@@ -13,7 +12,7 @@ object WallpaperScheduler {
|
||||
private const val REQUEST_CODE = 4218
|
||||
|
||||
fun ensureScheduled(context: Context) {
|
||||
if (!WallpaperStore.enabled(context)) {
|
||||
if (!WallpaperStore.timedEnabled(context)) {
|
||||
cancel(context)
|
||||
return
|
||||
}
|
||||
@@ -23,11 +22,6 @@ object WallpaperScheduler {
|
||||
if (storedTrigger <= now || storedTrigger > now + interval) scheduleNext(context)
|
||||
}
|
||||
|
||||
fun remainingDelay(context: Context): Long {
|
||||
val trigger = preferences(context).getLong(KEY_NEXT_TRIGGER, 0L)
|
||||
return (trigger - SystemClock.elapsedRealtime()).coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun claimIfDue(context: Context): Boolean {
|
||||
val preferences = preferences(context)
|
||||
@@ -47,11 +41,8 @@ object WallpaperScheduler {
|
||||
val trigger = SystemClock.elapsedRealtime() + minutes * 60_000L
|
||||
val alarmManager = context.getSystemService(AlarmManager::class.java)
|
||||
val operation = operation(context)
|
||||
if (Build.VERSION.SDK_INT >= 23) {
|
||||
alarmManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, trigger, operation)
|
||||
} else {
|
||||
alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, trigger, operation)
|
||||
}
|
||||
val window = (minutes * 60_000L / 10).coerceIn(60_000L, 15 * 60_000L)
|
||||
alarmManager.setWindow(AlarmManager.ELAPSED_REALTIME, trigger, window, operation)
|
||||
preferences(context).edit().putLong(KEY_NEXT_TRIGGER, trigger).commit()
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ object WallpaperStore {
|
||||
private const val KEY_CURRENT_ID = "current_entry_id"
|
||||
private const val KEY_INTERVAL = "interval_minutes"
|
||||
private const val CROP_PREFIX = "crop_"
|
||||
private const val INVALID_PREFIX = "invalid_"
|
||||
private const val RENDER_CACHE_MAX_BYTES = 128L * 1024 * 1024
|
||||
private const val THUMBNAIL_CACHE_MAX_BYTES = 64L * 1024 * 1024
|
||||
private const val HOME_PREVIEW_LIMIT = 12
|
||||
private val thumbnailCache = LruCache<String, String>(48)
|
||||
|
||||
@@ -47,6 +50,8 @@ object WallpaperStore {
|
||||
private fun immichEntries(context: Context) = File(immichRoot(context), "entries").apply { mkdirs() }
|
||||
private fun immichPreviews(context: Context) = File(immichRoot(context), "previews").apply { mkdirs() }
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
private fun renderedDirectory(context: Context) = File(context.cacheDir, "rendered-wallpapers").apply { mkdirs() }
|
||||
private fun thumbnailDirectory(context: Context) = File(context.cacheDir, "wallpaper-thumbnails").apply { mkdirs() }
|
||||
|
||||
private fun serverKey(serverUrl: String): String = MessageDigest.getInstance("SHA-256")
|
||||
.digest(serverUrl.toByteArray(Charsets.UTF_8)).take(6).joinToString("") { "%02x".format(it) }
|
||||
@@ -92,7 +97,9 @@ object WallpaperStore {
|
||||
return if (preferences.contains(KEY_INTERVAL)) preferences.getInt(KEY_INTERVAL, 0)
|
||||
else if (preferences.getBoolean("enabled", false)) 30 else 0
|
||||
}
|
||||
fun enabled(context: Context) = intervalMinutes(context) > 0
|
||||
fun enabled(context: Context) = intervalMinutes(context) != 0
|
||||
fun timedEnabled(context: Context) = intervalMinutes(context) > 0
|
||||
fun screenOnEnabled(context: Context) = intervalMinutes(context) == -1
|
||||
fun shuffle(context: Context) = prefs(context).getBoolean("shuffle", true)
|
||||
fun lockOnly(context: Context) = prefs(context).getBoolean("lockScreenOnly", true)
|
||||
|
||||
@@ -102,8 +109,8 @@ object WallpaperStore {
|
||||
}
|
||||
|
||||
fun setInterval(context: Context, minutes: Int) {
|
||||
require(minutes == 0 || minutes in 5..720) { "Ungültiges Wechselintervall" }
|
||||
prefs(context).edit().putInt(KEY_INTERVAL, minutes).putBoolean("enabled", minutes > 0).apply()
|
||||
require(minutes == -1 || minutes == 0 || minutes in 5..720) { "Ungültiges Wechselintervall" }
|
||||
prefs(context).edit().putInt(KEY_INTERVAL, minutes).putBoolean("enabled", minutes != 0).commit()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@@ -124,7 +131,7 @@ object WallpaperStore {
|
||||
previewItems.forEach { ids.put(it.id) }
|
||||
put("imageIds", ids)
|
||||
val previews = JSArray()
|
||||
if (includePreviews) previewItems.forEach { previews.put(thumbnailDataUrl(it.preview)) }
|
||||
if (includePreviews) previewItems.forEach { previews.put(thumbnailDataUrl(context, it.preview)) }
|
||||
put("imageUrls", previews)
|
||||
}
|
||||
}
|
||||
@@ -164,7 +171,7 @@ object WallpaperStore {
|
||||
val crop = crop(context, entry)
|
||||
return JSObject().apply {
|
||||
put("id", entry.id)
|
||||
put("url", thumbnailDataUrl(entry.preview))
|
||||
put("url", thumbnailDataUrl(context, entry.preview))
|
||||
put("selected", selected)
|
||||
put("cropMode", crop.mode)
|
||||
put("cropZoom", crop.zoom)
|
||||
@@ -294,9 +301,16 @@ object WallpaperStore {
|
||||
return BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample; inPreferredConfig = Bitmap.Config.ARGB_8888 })
|
||||
}
|
||||
|
||||
private fun thumbnailDataUrl(file: File): String {
|
||||
private fun thumbnailDataUrl(context: Context, file: File): String {
|
||||
val cacheKey = "${file.absolutePath}:${file.lastModified()}:${file.length()}"
|
||||
thumbnailCache.get(cacheKey)?.let { return it }
|
||||
val cached = File(thumbnailDirectory(context), "${digest(cacheKey)}.jpg")
|
||||
if (cached.isFile) {
|
||||
cached.setLastModified(System.currentTimeMillis())
|
||||
val result = "data:image/jpeg;base64," + Base64.encodeToString(cached.readBytes(), Base64.NO_WRAP)
|
||||
thumbnailCache.put(cacheKey, result)
|
||||
return result
|
||||
}
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(file.absolutePath, bounds)
|
||||
var sample = 1
|
||||
@@ -304,7 +318,9 @@ object WallpaperStore {
|
||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return ""
|
||||
val result = ByteArrayOutputStream().use { out ->
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 76, out); bitmap.recycle()
|
||||
"data:image/jpeg;base64," + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||
val bytes = out.toByteArray()
|
||||
runCatching { cached.writeBytes(bytes); trimDirectory(thumbnailDirectory(context), THUMBNAIL_CACHE_MAX_BYTES, cached) }
|
||||
"data:image/jpeg;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
}
|
||||
thumbnailCache.put(cacheKey, result)
|
||||
return result
|
||||
@@ -314,15 +330,52 @@ object WallpaperStore {
|
||||
thumbnailCache.remove("${file.absolutePath}:${file.lastModified()}:${file.length()}")
|
||||
}
|
||||
|
||||
private fun digest(value: String): String = MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun renderedFile(context: Context, entry: Entry): File {
|
||||
val metrics = context.resources.displayMetrics
|
||||
val sourceVersion = when (entry) {
|
||||
is Entry.Local -> "${entry.file.lastModified()}:${entry.file.length()}"
|
||||
is Entry.Immich -> entry.assetId
|
||||
}
|
||||
val key = "${entry.id}:$sourceVersion:${metrics.widthPixels}x${metrics.heightPixels}:${prefs(context).getString(cropKey(entry.id), "")}"
|
||||
return File(renderedDirectory(context), "${digest(key)}.jpg")
|
||||
}
|
||||
|
||||
private fun sourceVersion(entry: Entry): String = when (entry) {
|
||||
is Entry.Local -> "${entry.file.lastModified()}:${entry.file.length()}"
|
||||
is Entry.Immich -> entry.assetId
|
||||
}
|
||||
|
||||
private fun validImage(file: File): Boolean {
|
||||
if (!file.isFile || file.length() <= 0) return false
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(file.absolutePath, bounds)
|
||||
return bounds.outWidth > 0 && bounds.outHeight > 0
|
||||
}
|
||||
|
||||
private fun trimDirectory(directory: File, maxBytes: Long, protected: File) {
|
||||
val files = directory.listFiles()?.filter { it.isFile }?.sortedBy { it.lastModified() } ?: return
|
||||
var bytes = files.sumOf { it.length() }
|
||||
for (file in files) {
|
||||
if (bytes <= maxBytes) break
|
||||
if (file != protected) {
|
||||
val length = file.length()
|
||||
if (file.delete()) bytes -= length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun applyNext(context: Context): Boolean {
|
||||
fun applyNext(context: Context, automatic: Boolean = false): Boolean {
|
||||
val items = entries(context)
|
||||
if (items.isEmpty()) return false
|
||||
val previous = currentIndex(context, items)
|
||||
val candidates = if (shuffle(context) && items.size > 1) {
|
||||
items.indices.filter { it != previous }.shuffled() + previous
|
||||
} else (1..items.size).map { (previous + it).mod(items.size) }
|
||||
return applyCandidates(context, items, candidates)
|
||||
return applyCandidates(context, items, candidates, automatic)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@@ -330,37 +383,58 @@ object WallpaperStore {
|
||||
val items = entries(context)
|
||||
val index = items.indexOfFirst { it.id == id }
|
||||
if (index < 0) return false
|
||||
return applyCandidates(context, items, listOf(index))
|
||||
return applyCandidates(context, items, listOf(index), automatic = false)
|
||||
}
|
||||
|
||||
private fun applyCandidates(context: Context, items: List<Entry>, candidates: List<Int>): Boolean {
|
||||
private fun applyCandidates(context: Context, items: List<Entry>, candidates: List<Int>, automatic: Boolean): Boolean {
|
||||
val unavailableServers = mutableSetOf<String>()
|
||||
for (index in candidates) {
|
||||
val entry = items[index]
|
||||
val sourceFile = when (entry) {
|
||||
is Entry.Local -> entry.file
|
||||
is Entry.Immich -> {
|
||||
val source = ImmichClient.cachedOriginal(context, entry.serverUrl, entry.assetId, entry.serverUrl !in unavailableServers)
|
||||
if (source == null) {
|
||||
unavailableServers.add(entry.serverUrl)
|
||||
continue
|
||||
val renderedFile = renderedFile(context, entry)
|
||||
var rendered = if (validImage(renderedFile)) {
|
||||
renderedFile.setLastModified(System.currentTimeMillis())
|
||||
BitmapFactory.decodeFile(renderedFile.absolutePath)
|
||||
} else null
|
||||
if (rendered == null) {
|
||||
renderedFile.delete()
|
||||
if (prefs(context).getString(INVALID_PREFIX + entry.id, null) == sourceVersion(entry)) continue
|
||||
val sourceFile = when (entry) {
|
||||
is Entry.Local -> entry.file
|
||||
is Entry.Immich -> {
|
||||
val allowNetwork = entry.serverUrl !in unavailableServers && (!automatic || ImmichClient.automaticDownloadsAllowed(context))
|
||||
val source = ImmichClient.cachedOriginal(context, entry.serverUrl, entry.assetId, allowNetwork)
|
||||
if (source == null) {
|
||||
if (allowNetwork) unavailableServers.add(entry.serverUrl)
|
||||
continue
|
||||
}
|
||||
source
|
||||
}
|
||||
source
|
||||
}
|
||||
val bitmap = decodeForScreen(context, sourceFile)
|
||||
if (bitmap == null) {
|
||||
prefs(context).edit().putString(INVALID_PREFIX + entry.id, sourceVersion(entry)).apply()
|
||||
continue
|
||||
}
|
||||
val generated = runCatching { renderForScreen(context, bitmap, crop(context, entry)) }.getOrNull()
|
||||
bitmap.recycle()
|
||||
if (generated == null) continue
|
||||
runCatching {
|
||||
renderedFile.outputStream().use { generated.compress(Bitmap.CompressFormat.JPEG, 92, it) }
|
||||
trimDirectory(renderedDirectory(context), RENDER_CACHE_MAX_BYTES, renderedFile)
|
||||
}
|
||||
rendered = generated
|
||||
}
|
||||
val bitmap = decodeForScreen(context, sourceFile) ?: continue
|
||||
val rendered = runCatching { renderForScreen(context, bitmap, crop(context, entry)) }.getOrNull()
|
||||
if (rendered == null) { bitmap.recycle(); continue }
|
||||
val wallpaper = rendered ?: continue
|
||||
try {
|
||||
val manager = WallpaperManager.getInstance(context)
|
||||
if (android.os.Build.VERSION.SDK_INT >= 24 && lockOnly(context)) manager.setBitmap(rendered, null, true, WallpaperManager.FLAG_LOCK)
|
||||
else manager.setBitmap(rendered)
|
||||
prefs(context).edit().putString(KEY_CURRENT_ID, entry.id).putInt(KEY_INDEX, index).apply()
|
||||
if (android.os.Build.VERSION.SDK_INT >= 24 && lockOnly(context)) manager.setBitmap(wallpaper, null, true, WallpaperManager.FLAG_LOCK)
|
||||
else manager.setBitmap(wallpaper)
|
||||
prefs(context).edit().remove(INVALID_PREFIX + entry.id).putString(KEY_CURRENT_ID, entry.id).putInt(KEY_INDEX, index).apply()
|
||||
return true
|
||||
} catch (_: Exception) {
|
||||
// Try the next usable entry without changing the current selection.
|
||||
} finally {
|
||||
rendered.recycle(); bitmap.recycle()
|
||||
wallpaper.recycle()
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<resources>
|
||||
<string name="wallpaper_service_title">WallpaperFlow ist aktiv</string>
|
||||
<string name="wallpaper_service_text">Das Motiv wechselt nach dem eingestellten Intervall.</string>
|
||||
<string name="wallpaper_channel_name">Automatischer Bildwechsel</string>
|
||||
<string name="wallpaper_service_text">Das nächste Motiv wird beim Ausschalten des Displays vorbereitet.</string>
|
||||
<string name="wallpaper_channel_name">Bildwechsel bei Display-Aktivierung</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<resources>
|
||||
<string name="wallpaper_service_title">WallpaperFlow está activo</string>
|
||||
<string name="wallpaper_service_text">El fondo cambia según el intervalo configurado.</string>
|
||||
<string name="wallpaper_channel_name">Rotación automática de fondos</string>
|
||||
<string name="wallpaper_service_text">El fondo cambia cuando se enciende la pantalla.</string>
|
||||
<string name="wallpaper_channel_name">Rotación al encender la pantalla</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<resources>
|
||||
<string name="wallpaper_service_title">WallpaperFlow est actif</string>
|
||||
<string name="wallpaper_service_text">Le fond d’écran change selon l’intervalle défini.</string>
|
||||
<string name="wallpaper_channel_name">Rotation automatique des fonds d’écran</string>
|
||||
<string name="wallpaper_service_text">Le fond d’écran change lorsque l’écran s’allume.</string>
|
||||
<string name="wallpaper_channel_name">Rotation à l’allumage de l’écran</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<resources>
|
||||
<string name="wallpaper_service_title">WallpaperFlow è attivo</string>
|
||||
<string name="wallpaper_service_text">Lo sfondo cambia all’intervallo configurato.</string>
|
||||
<string name="wallpaper_channel_name">Rotazione automatica degli sfondi</string>
|
||||
<string name="wallpaper_service_text">Lo sfondo cambia quando si accende lo schermo.</string>
|
||||
<string name="wallpaper_channel_name">Rotazione all’accensione dello schermo</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<resources>
|
||||
<string name="wallpaper_service_title">WallpaperFlow は実行中です</string>
|
||||
<string name="wallpaper_service_text">設定した間隔で壁紙が切り替わります。</string>
|
||||
<string name="wallpaper_channel_name">壁紙の自動切り替え</string>
|
||||
<string name="wallpaper_service_text">画面が点灯すると壁紙が切り替わります。</string>
|
||||
<string name="wallpaper_channel_name">画面点灯時の壁紙切り替え</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<resources>
|
||||
<string name="wallpaper_service_title">WallpaperFlow 실행 중</string>
|
||||
<string name="wallpaper_service_text">설정한 간격에 따라 배경화면이 변경됩니다.</string>
|
||||
<string name="wallpaper_channel_name">자동 배경화면 변경</string>
|
||||
<string name="wallpaper_service_text">화면이 켜질 때 배경화면이 변경됩니다.</string>
|
||||
<string name="wallpaper_channel_name">화면 켜짐 시 배경화면 변경</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<resources>
|
||||
<string name="wallpaper_service_title">WallpaperFlow is actief</string>
|
||||
<string name="wallpaper_service_text">De achtergrond wisselt volgens het ingestelde interval.</string>
|
||||
<string name="wallpaper_channel_name">Automatisch achtergronden wisselen</string>
|
||||
<string name="wallpaper_service_text">De achtergrond wisselt wanneer het scherm aangaat.</string>
|
||||
<string name="wallpaper_channel_name">Wisselen bij scherminschakeling</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<resources>
|
||||
<string name="wallpaper_service_title">WallpaperFlow jest aktywny</string>
|
||||
<string name="wallpaper_service_text">Tapeta zmienia się zgodnie z ustawionym interwałem.</string>
|
||||
<string name="wallpaper_channel_name">Automatyczna zmiana tapety</string>
|
||||
<string name="wallpaper_service_text">Tapeta zmienia się po włączeniu ekranu.</string>
|
||||
<string name="wallpaper_channel_name">Zmiana po włączeniu ekranu</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<resources>
|
||||
<string name="wallpaper_service_title">WallpaperFlow está ativo</string>
|
||||
<string name="wallpaper_service_text">O fundo muda de acordo com o intervalo definido.</string>
|
||||
<string name="wallpaper_channel_name">Rotação automática de fundos</string>
|
||||
<string name="wallpaper_service_text">O fundo muda quando o ecrã é ligado.</string>
|
||||
<string name="wallpaper_channel_name">Rotação ao ligar o ecrã</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<resources>
|
||||
<string name="wallpaper_service_title">WallpaperFlow 正在运行</string>
|
||||
<string name="wallpaper_service_text">壁纸会按照设定的间隔更换。</string>
|
||||
<string name="wallpaper_channel_name">自动更换壁纸</string>
|
||||
<string name="wallpaper_service_text">屏幕亮起时会更换壁纸。</string>
|
||||
<string name="wallpaper_channel_name">亮屏时更换壁纸</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<resources>
|
||||
<string name="wallpaper_service_title">WallpaperFlow is active</string>
|
||||
<string name="wallpaper_service_text">The wallpaper changes at the configured interval.</string>
|
||||
<string name="wallpaper_channel_name">Automatic wallpaper rotation</string>
|
||||
<string name="wallpaper_service_text">The next wallpaper is prepared when the screen turns off.</string>
|
||||
<string name="wallpaper_channel_name">Screen-on wallpaper rotation</string>
|
||||
</resources>
|
||||
|
||||
@@ -64,7 +64,7 @@ pub(crate) async fn set_setting<R: Runtime>(
|
||||
app.wallpaper().set_setting(SettingRequest { name, value })
|
||||
}
|
||||
#[command]
|
||||
pub(crate) async fn set_interval<R: Runtime>(app: AppHandle<R>, minutes: usize) -> Result<WallpaperState> {
|
||||
pub(crate) async fn set_interval<R: Runtime>(app: AppHandle<R>, minutes: i32) -> Result<WallpaperState> {
|
||||
app.wallpaper().set_interval(IntervalRequest { minutes })
|
||||
}
|
||||
#[command]
|
||||
|
||||
@@ -96,7 +96,7 @@ impl<R: Runtime> Wallpaper<R> {
|
||||
pub fn set_interval(&self, payload: IntervalRequest) -> crate::Result<WallpaperState> {
|
||||
let mut state = Self::demo();
|
||||
state.interval_minutes = payload.minutes;
|
||||
state.enabled = payload.minutes > 0;
|
||||
state.enabled = payload.minutes != 0;
|
||||
Ok(state)
|
||||
}
|
||||
pub fn next_wallpaper(&self) -> crate::Result<WallpaperState> {
|
||||
|
||||
@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
|
||||
pub struct WallpaperState {
|
||||
pub image_count: usize,
|
||||
pub enabled: bool,
|
||||
pub interval_minutes: usize,
|
||||
pub interval_minutes: i32,
|
||||
pub shuffle: bool,
|
||||
pub lock_screen_only: bool,
|
||||
pub current_index: usize,
|
||||
@@ -24,7 +24,7 @@ pub struct SettingRequest {
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IntervalRequest {
|
||||
pub minutes: usize,
|
||||
pub minutes: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
|
||||
+7
-8
@@ -14,9 +14,10 @@ function SettingRow({ icon, label, value, onChange }: { icon: React.ReactNode; l
|
||||
return <div className="setting-row"><div className="setting-icon">{icon}</div><span>{label}</span><Switch checked={value} onChange={onChange} label={label} /></div>;
|
||||
}
|
||||
|
||||
function IntervalRow({ value, onChange, label, paused, everyMinutes }: { value: number; onChange: (value: number) => void; label: string; paused: string; everyMinutes: (minutes: number) => string }) {
|
||||
function IntervalRow({ value, onChange, label, paused, screenOn, everyMinutes }: { value: number; onChange: (value: number) => void; label: string; paused: string; screenOn: string; everyMinutes: (minutes: number) => string }) {
|
||||
return <label className="setting-row interval-row"><div className="setting-icon"><Smartphone /></div><span>{label}</span><select value={value} onChange={event => onChange(Number(event.target.value))} aria-label={label}>
|
||||
<option value={0}>{paused}</option>
|
||||
<option value={-1}>{screenOn}</option>
|
||||
{[5, 15, 30, 60, 180, 360, 720].map(minutes => <option value={minutes} key={minutes}>{everyMinutes(minutes)}</option>)}
|
||||
</select></label>;
|
||||
}
|
||||
@@ -54,11 +55,9 @@ export default function App() {
|
||||
const refresh = () => { void getState().then(setState).catch(() => undefined); };
|
||||
refresh();
|
||||
const onVisibilityChange = () => { if (!document.hidden) refresh(); };
|
||||
const refreshInterval = window.setInterval(() => { if (!document.hidden) refresh(); }, 15000);
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
window.addEventListener("focus", refresh);
|
||||
return () => {
|
||||
window.clearInterval(refreshInterval);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
window.removeEventListener("focus", refresh);
|
||||
};
|
||||
@@ -118,11 +117,11 @@ export default function App() {
|
||||
}
|
||||
|
||||
async function updateInterval(minutes: number) {
|
||||
setState(prev => ({ ...prev, intervalMinutes: minutes, enabled: minutes > 0 }));
|
||||
setState(prev => ({ ...prev, intervalMinutes: minutes, enabled: minutes !== 0 }));
|
||||
try {
|
||||
const saved = await setIntervalMinutes(minutes);
|
||||
setState(saved);
|
||||
setNotice(minutes > 0 ? t.intervalSaved : t.automaticDisabled);
|
||||
setNotice(minutes !== 0 ? t.intervalSaved : t.automaticDisabled);
|
||||
}
|
||||
catch { setNotice(t.androidOnly); }
|
||||
}
|
||||
@@ -249,7 +248,7 @@ export default function App() {
|
||||
setNotice(it.importing);
|
||||
const poll = window.setInterval(() => {
|
||||
void getImmichImportProgress().then(setImmichImportProgress).catch(() => undefined);
|
||||
}, 250);
|
||||
}, 750);
|
||||
try {
|
||||
setState(await importImmichAssets([...immichSelected]));
|
||||
setImmichSelected(new Set());
|
||||
@@ -408,7 +407,7 @@ export default function App() {
|
||||
<div className="hero-meta"><span><Sparkles size={16} /> {t.currentImage}</span><button onClick={next} disabled={busy}>{t.nextImage} <ChevronRight size={18} /></button></div>
|
||||
</section>
|
||||
|
||||
<IntervalRow value={state.intervalMinutes} onChange={updateInterval} label={t.changeInterval} paused={t.paused} everyMinutes={t.everyMinutes} />
|
||||
<IntervalRow value={state.intervalMinutes} onChange={updateInterval} label={t.changeInterval} paused={t.paused} screenOn={t.screenOn} everyMinutes={t.everyMinutes} />
|
||||
|
||||
<button className="primary" onClick={choose} disabled={busy}><Images />{busy ? t.pleaseWait : t.selectImages}</button>
|
||||
|
||||
@@ -421,7 +420,7 @@ export default function App() {
|
||||
</> : tab === "settings" ? <section className="settings-page">
|
||||
<h2>{t.settings}</h2><p>{t.settingsIntro}</p>
|
||||
<label className="language-setting"><div className="setting-icon"><Languages /></div><div><strong>{t.language}</strong><span>{languageNames[language]}</span></div><select className="locale-select" value={language} onChange={event => setLanguage(event.target.value as Language)} aria-label={t.language}>{languages.map(locale => <option value={locale} key={locale}>{languageNames[locale]}</option>)}</select></label>
|
||||
<div className="settings-list"><IntervalRow value={state.intervalMinutes} onChange={updateInterval} label={t.changeInterval} paused={t.paused} everyMinutes={t.everyMinutes} /><SettingRow icon={<Shuffle />} label={t.shuffle} value={state.shuffle} onChange={v => update("shuffle", v)} /><SettingRow icon={<LockKeyhole />} label={t.lockScreenOnly} value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} /></div>
|
||||
<div className="settings-list"><IntervalRow value={state.intervalMinutes} onChange={updateInterval} label={t.changeInterval} paused={t.paused} screenOn={t.screenOn} everyMinutes={t.everyMinutes} /><SettingRow icon={<Shuffle />} label={t.shuffle} value={state.shuffle} onChange={v => update("shuffle", v)} /><SettingRow icon={<LockKeyhole />} label={t.lockScreenOnly} value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} /></div>
|
||||
<section className="immich-settings">
|
||||
<div className="immich-title"><span><Cloud /></span><div><h3>{it.immich}</h3><p>{it.immichIntro}</p></div></div>
|
||||
{immichConnection.configured ? <>
|
||||
|
||||
+11
-11
@@ -9,7 +9,7 @@ export const languageNames: Record<Language, string> = {
|
||||
const en = {
|
||||
automaticActive: "Your wallpapers change automatically", automaticPaused: "Automatic rotation is paused",
|
||||
settings: "Settings", currentWallpaper: "Current wallpaper", currentImage: "Current image", nextImage: "Next image",
|
||||
changeInterval: "Change image", paused: "Paused", selectImages: "Select images", pleaseWait: "Please wait …",
|
||||
changeInterval: "Change image", paused: "Paused", screenOn: "When screen turns on", selectImages: "Select images", pleaseWait: "Please wait …",
|
||||
collection: "My collection", image: "image", images: "images", motif: "Image", noImages: "No images selected yet",
|
||||
collectionHint: "Tap the image count to open your gallery.", shuffle: "Shuffle order", lockScreenOnly: "Lock screen only",
|
||||
settingsIntro: "Choose how WallpaperFlow works in the background.", language: "Language", noImageLimit: "No image limit",
|
||||
@@ -35,7 +35,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
|
||||
de: {
|
||||
automaticActive: "Deine Motive wechseln automatisch", automaticPaused: "Automatischer Wechsel ist pausiert",
|
||||
settings: "Einstellungen", currentWallpaper: "Aktuelles Hintergrundbild", currentImage: "Aktuelles Motiv", nextImage: "Nächstes Motiv",
|
||||
changeInterval: "Bild wechseln", paused: "Pausiert", selectImages: "Bilder auswählen", pleaseWait: "Bitte warten …",
|
||||
changeInterval: "Bild wechseln", paused: "Pausiert", screenOn: "Beim Einschalten des Displays", selectImages: "Bilder auswählen", pleaseWait: "Bitte warten …",
|
||||
collection: "Meine Sammlung", image: "Bild", images: "Bilder", motif: "Motiv", noImages: "Noch keine Bilder ausgewählt",
|
||||
collectionHint: "Tippe auf die Bildanzahl, um deine Galerie zu öffnen.", shuffle: "Zufällige Reihenfolge", lockScreenOnly: "Nur Sperrbildschirm",
|
||||
settingsIntro: "Lege fest, wie WallpaperFlow im Hintergrund arbeitet.", language: "Sprache", noImageLimit: "Ohne Bilderlimit",
|
||||
@@ -56,7 +56,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
|
||||
fr: {
|
||||
automaticActive: "Vos fonds d’écran changent automatiquement", automaticPaused: "La rotation automatique est en pause",
|
||||
settings: "Paramètres", currentWallpaper: "Fond d’écran actuel", currentImage: "Image actuelle", nextImage: "Image suivante",
|
||||
changeInterval: "Changer l’image", paused: "En pause", selectImages: "Sélectionner des images", pleaseWait: "Veuillez patienter…",
|
||||
changeInterval: "Changer l’image", paused: "En pause", screenOn: "À l’allumage de l’écran", selectImages: "Sélectionner des images", pleaseWait: "Veuillez patienter…",
|
||||
collection: "Ma collection", image: "image", images: "images", motif: "Image", noImages: "Aucune image sélectionnée",
|
||||
collectionHint: "Touchez le nombre d’images pour ouvrir votre galerie.", shuffle: "Ordre aléatoire", lockScreenOnly: "Écran de verrouillage uniquement",
|
||||
settingsIntro: "Choisissez comment WallpaperFlow fonctionne en arrière-plan.", language: "Langue", noImageLimit: "Aucune limite d’images",
|
||||
@@ -77,7 +77,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
|
||||
es: {
|
||||
automaticActive: "Tus fondos cambian automáticamente", automaticPaused: "La rotación automática está en pausa",
|
||||
settings: "Ajustes", currentWallpaper: "Fondo actual", currentImage: "Imagen actual", nextImage: "Imagen siguiente",
|
||||
changeInterval: "Cambiar imagen", paused: "En pausa", selectImages: "Seleccionar imágenes", pleaseWait: "Espera…",
|
||||
changeInterval: "Cambiar imagen", paused: "En pausa", screenOn: "Al encender la pantalla", selectImages: "Seleccionar imágenes", pleaseWait: "Espera…",
|
||||
collection: "Mi colección", image: "imagen", images: "imágenes", motif: "Imagen", noImages: "Aún no hay imágenes seleccionadas",
|
||||
collectionHint: "Toca el número de imágenes para abrir la galería.", shuffle: "Orden aleatorio", lockScreenOnly: "Solo pantalla de bloqueo",
|
||||
settingsIntro: "Elige cómo funciona WallpaperFlow en segundo plano.", language: "Idioma", noImageLimit: "Sin límite de imágenes",
|
||||
@@ -98,7 +98,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
|
||||
it: {
|
||||
automaticActive: "Gli sfondi cambiano automaticamente", automaticPaused: "La rotazione automatica è in pausa",
|
||||
settings: "Impostazioni", currentWallpaper: "Sfondo attuale", currentImage: "Immagine attuale", nextImage: "Immagine successiva",
|
||||
changeInterval: "Cambia immagine", paused: "In pausa", selectImages: "Seleziona immagini", pleaseWait: "Attendi…",
|
||||
changeInterval: "Cambia immagine", paused: "In pausa", screenOn: "All’accensione dello schermo", selectImages: "Seleziona immagini", pleaseWait: "Attendi…",
|
||||
collection: "La mia raccolta", image: "immagine", images: "immagini", motif: "Immagine", noImages: "Nessuna immagine selezionata",
|
||||
collectionHint: "Tocca il numero di immagini per aprire la galleria.", shuffle: "Ordine casuale", lockScreenOnly: "Solo schermata di blocco",
|
||||
settingsIntro: "Scegli come funziona WallpaperFlow in background.", language: "Lingua", noImageLimit: "Nessun limite di immagini",
|
||||
@@ -119,7 +119,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
|
||||
nl: {
|
||||
automaticActive: "Je achtergronden wisselen automatisch", automaticPaused: "Automatisch wisselen is gepauzeerd",
|
||||
settings: "Instellingen", currentWallpaper: "Huidige achtergrond", currentImage: "Huidige afbeelding", nextImage: "Volgende afbeelding",
|
||||
changeInterval: "Afbeelding wisselen", paused: "Gepauzeerd", selectImages: "Afbeeldingen kiezen", pleaseWait: "Even geduld…",
|
||||
changeInterval: "Afbeelding wisselen", paused: "Gepauzeerd", screenOn: "Wanneer het scherm aangaat", selectImages: "Afbeeldingen kiezen", pleaseWait: "Even geduld…",
|
||||
collection: "Mijn collectie", image: "afbeelding", images: "afbeeldingen", motif: "Afbeelding", noImages: "Nog geen afbeeldingen gekozen",
|
||||
collectionHint: "Tik op het aantal afbeeldingen om je galerij te openen.", shuffle: "Willekeurige volgorde", lockScreenOnly: "Alleen vergrendelscherm",
|
||||
settingsIntro: "Kies hoe WallpaperFlow op de achtergrond werkt.", language: "Taal", noImageLimit: "Geen afbeeldingslimiet",
|
||||
@@ -140,7 +140,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
|
||||
pl: {
|
||||
automaticActive: "Tapety zmieniają się automatycznie", automaticPaused: "Automatyczna zmiana jest wstrzymana",
|
||||
settings: "Ustawienia", currentWallpaper: "Bieżąca tapeta", currentImage: "Bieżący obraz", nextImage: "Następny obraz",
|
||||
changeInterval: "Zmieniaj obraz", paused: "Wstrzymano", selectImages: "Wybierz obrazy", pleaseWait: "Proszę czekać…",
|
||||
changeInterval: "Zmieniaj obraz", paused: "Wstrzymano", screenOn: "Po włączeniu ekranu", selectImages: "Wybierz obrazy", pleaseWait: "Proszę czekać…",
|
||||
collection: "Moja kolekcja", image: "obraz", images: "obrazy", motif: "Obraz", noImages: "Nie wybrano jeszcze obrazów",
|
||||
collectionHint: "Dotknij liczby obrazów, aby otworzyć galerię.", shuffle: "Losowa kolejność", lockScreenOnly: "Tylko ekran blokady",
|
||||
settingsIntro: "Wybierz sposób działania WallpaperFlow w tle.", language: "Język", noImageLimit: "Bez limitu obrazów",
|
||||
@@ -161,7 +161,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
|
||||
pt: {
|
||||
automaticActive: "Os seus fundos mudam automaticamente", automaticPaused: "A rotação automática está em pausa",
|
||||
settings: "Definições", currentWallpaper: "Fundo atual", currentImage: "Imagem atual", nextImage: "Imagem seguinte",
|
||||
changeInterval: "Mudar imagem", paused: "Em pausa", selectImages: "Selecionar imagens", pleaseWait: "Aguarde…",
|
||||
changeInterval: "Mudar imagem", paused: "Em pausa", screenOn: "Ao ligar o ecrã", selectImages: "Selecionar imagens", pleaseWait: "Aguarde…",
|
||||
collection: "A minha coleção", image: "imagem", images: "imagens", motif: "Imagem", noImages: "Ainda não há imagens selecionadas",
|
||||
collectionHint: "Toque no número de imagens para abrir a galeria.", shuffle: "Ordem aleatória", lockScreenOnly: "Apenas ecrã de bloqueio",
|
||||
settingsIntro: "Escolha como o WallpaperFlow funciona em segundo plano.", language: "Idioma", noImageLimit: "Sem limite de imagens",
|
||||
@@ -182,7 +182,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
|
||||
ja: {
|
||||
automaticActive: "壁紙は自動的に切り替わります", automaticPaused: "自動切り替えは一時停止中です",
|
||||
settings: "設定", currentWallpaper: "現在の壁紙", currentImage: "現在の画像", nextImage: "次の画像",
|
||||
changeInterval: "画像を切り替え", paused: "一時停止", selectImages: "画像を選択", pleaseWait: "お待ちください…",
|
||||
changeInterval: "画像を切り替え", paused: "一時停止", screenOn: "画面点灯時", selectImages: "画像を選択", pleaseWait: "お待ちください…",
|
||||
collection: "マイコレクション", image: "枚", images: "枚", motif: "画像", noImages: "画像がまだ選択されていません",
|
||||
collectionHint: "画像数をタップしてギャラリーを開きます。", shuffle: "ランダムな順序", lockScreenOnly: "ロック画面のみ",
|
||||
settingsIntro: "WallpaperFlow のバックグラウンド動作を設定します。", language: "言語", noImageLimit: "画像数の制限なし",
|
||||
@@ -203,7 +203,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
|
||||
ko: {
|
||||
automaticActive: "배경화면이 자동으로 변경됩니다", automaticPaused: "자동 변경이 일시 중지되었습니다",
|
||||
settings: "설정", currentWallpaper: "현재 배경화면", currentImage: "현재 이미지", nextImage: "다음 이미지",
|
||||
changeInterval: "이미지 변경", paused: "일시 중지", selectImages: "이미지 선택", pleaseWait: "잠시 기다려 주세요…",
|
||||
changeInterval: "이미지 변경", paused: "일시 중지", screenOn: "화면이 켜질 때", selectImages: "이미지 선택", pleaseWait: "잠시 기다려 주세요…",
|
||||
collection: "내 컬렉션", image: "이미지", images: "이미지", motif: "이미지", noImages: "선택한 이미지가 없습니다",
|
||||
collectionHint: "이미지 수를 탭하여 갤러리를 여세요.", shuffle: "무작위 순서", lockScreenOnly: "잠금 화면만",
|
||||
settingsIntro: "WallpaperFlow의 백그라운드 작동 방식을 선택하세요.", language: "언어", noImageLimit: "이미지 수 제한 없음",
|
||||
@@ -224,7 +224,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
|
||||
"zh-CN": {
|
||||
automaticActive: "壁纸会自动更换", automaticPaused: "自动更换已暂停",
|
||||
settings: "设置", currentWallpaper: "当前壁纸", currentImage: "当前图片", nextImage: "下一张图片",
|
||||
changeInterval: "更换图片", paused: "已暂停", selectImages: "选择图片", pleaseWait: "请稍候…",
|
||||
changeInterval: "更换图片", paused: "已暂停", screenOn: "屏幕亮起时", selectImages: "选择图片", pleaseWait: "请稍候…",
|
||||
collection: "我的收藏", image: "张图片", images: "张图片", motif: "图片", noImages: "尚未选择图片",
|
||||
collectionHint: "点击图片数量打开图库。", shuffle: "随机顺序", lockScreenOnly: "仅锁屏",
|
||||
settingsIntro: "选择 WallpaperFlow 在后台的工作方式。", language: "语言", noImageLimit: "不限图片数量",
|
||||
|
||||
Reference in New Issue
Block a user