6 Commits
Author SHA1 Message Date
Christoph affa330bf9 ci(workflows): upgrade actions/setup-java to v5
Update CI workflow to use actions/setup-java@v5.
Bring in upstream fixes and features for Java setup.
Improve compatibility with Java 21 and future updates.

- Use actions/setup-java@v5 for Java setup
2026-08-26 17:33:46 +02:00
Christoph 5ffa921967 feat(wallpaper-store): enhance previews, EXIF handling, and shuffle
Android Release / Build signed release APK (push) Successful in 34m17s
These changes add image previews and robust image handling across platforms.
Android, desktop, and TS UI now share imagePreviews data and a
persistent shuffle queue, improving ordering and previews.
EXIF orientation is applied when decoding images and rendering crops.
Manifest and rendering paths were adjusted for portrait mode.

- Introduce imagePreviews in models and UI to show per-image crop data.
- Implement EXIF orientation handling and enhanced cropping.
- Improve shuffle with persistent queue and seen-tracking.
2026-08-26 17:12:18 +02:00
Christoph 455b68e562 feat(android-release): derive Android version code from release tag
The release workflow now derives versionName and versionCode from the tag
and validates the tag format. It updates multiple project files in the
runner workspace without committing changes back to the repository.
A verification step ensures the generated Android properties are correct.

- Tag parsing validates vMAJOR.MINOR.PATCH and enforces minor/patch <= 999
- VersionCode computed as major*1e6 + minor*1e3 + patch and applied
- Added post-build verification of Android versionName and versionCode
2026-08-26 00:54:20 +02:00
Christoph aca5562f81 feat(gitea): attach build artifacts to Gitea releases
Android Release / Build signed release APK (push) Successful in 37m54s
The Android release workflow now creates or updates a Gitea
release and attaches the APK and its SHA256 to it. A
GITEA_TOKEN with releases: write is required for uploads.
The README now documents the Gitea release flow and the
required permissions.

- Enforces that releases run only on a tag and matches the app version.
- Replaces or updates release assets to match tag names.
- Requires GITEA_TOKEN with releases: write for uploads.
2026-08-26 00:14:39 +02:00
Christoph 7c15f51355 feat(android): add Android release workflow and shuffle UI improvements
Android Release / Build signed release APK (push) Canceled after 40m19s
Adds a new Android release workflow to sign and publish APKs when tags are pushed. Also enhances wallpaper shuffle by tracking seen IDs to reduce repeats. UI changes in App.tsx introduce lazy loading for the gallery and improved navigation/history handling.

- Add Android signing workflow with keystore secrets
- Track shuffle seen IDs to avoid image repeats
- Enable gallery lazy loading via sentinel and observer
2026-08-25 23:22:36 +02:00
Christoph a5f71b576b feat(immich): add prefetch and mobile data controls
Adds Immich image prefetch and a mobile data option to the UI.
Android now ships ImmichPrefetchWorker to fetch originals in the background.
Frontend and desktop code are updated to expose and persist new settings.
Translations cover the new labels and hints in multiple languages.

- Introduce ImmichPrefetchWorker for background prefetch
- Add allowMobileData and prefetchImmich UI controls
- Wire changes to desktop state and translations
2026-08-22 23:18:08 +02:00
14 changed files with 754 additions and 88 deletions
+266
View File
@@ -0,0 +1,266 @@
name: Android Release
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
build-release-apk:
name: Build signed release APK
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
code: read
releases: write
env:
ANDROID_COMPILE_SDK: "36"
ANDROID_BUILD_TOOLS: "36.0.0"
ANDROID_NDK_VERSION: "30.0.15729638"
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
GITEA_RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Set up Java
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "21"
cache: gradle
- name: Set up Rust
uses: dtolnay/rust-toolchain@1.95.0
with:
targets: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android
- name: Cache Rust build
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri -> target
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Install Android SDK packages
run: |
yes | sdkmanager --licenses >/dev/null || true
sdkmanager \
"platforms;android-${ANDROID_COMPILE_SDK}" \
"build-tools;${ANDROID_BUILD_TOOLS}" \
"ndk;${ANDROID_NDK_VERSION}"
echo "NDK_HOME=${ANDROID_HOME}/ndk/${ANDROID_NDK_VERSION}" >> "${GITEA_ENV}"
echo "${ANDROID_HOME}/build-tools/${ANDROID_BUILD_TOOLS}" >> "${GITEA_PATH}"
- name: Install JavaScript dependencies
run: npm ci
- name: Apply version from release tag
run: |
if [ "${GITHUB_REF_TYPE}" != "tag" ]; then
echo "Release builds must run on a tag such as v0.1.0." >&2
exit 1
fi
RELEASE_VERSION="${GITHUB_REF_NAME#v}"
if [[ ! "${RELEASE_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Tag ${GITHUB_REF_NAME} must use the format vMAJOR.MINOR.PATCH." >&2
exit 1
fi
IFS=. read -r VERSION_MAJOR VERSION_MINOR VERSION_PATCH <<< "${RELEASE_VERSION}"
if (( 10#${VERSION_MINOR} > 999 || 10#${VERSION_PATCH} > 999 )); then
echo "Minor and patch versions must not exceed 999." >&2
exit 1
fi
ANDROID_VERSION_CODE=$((10#${VERSION_MAJOR} * 1000000 + 10#${VERSION_MINOR} * 1000 + 10#${VERSION_PATCH}))
if (( ANDROID_VERSION_CODE < 1 || ANDROID_VERSION_CODE > 2100000000 )); then
echo "Derived Android versionCode ${ANDROID_VERSION_CODE} is outside the allowed range." >&2
exit 1
fi
RELEASE_VERSION="${RELEASE_VERSION}" \
ANDROID_VERSION_CODE="${ANDROID_VERSION_CODE}" \
GITHUB_REF_NAME="${GITHUB_REF_NAME}" \
node -e '
const fs = require("fs");
const version = process.env.RELEASE_VERSION;
const versionCode = process.env.ANDROID_VERSION_CODE;
function updateJson(path, update) {
const value = JSON.parse(fs.readFileSync(path, "utf8"));
update(value);
fs.writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
}
updateJson("package.json", value => { value.version = version; });
updateJson("package-lock.json", value => {
value.version = version;
value.packages[""].version = version;
});
updateJson("src-tauri/tauri.conf.json", value => { value.version = version; });
const cargoTomlPath = "src-tauri/Cargo.toml";
const cargoToml = fs.readFileSync(cargoTomlPath, "utf8").replace(
/(\[package\][\s\S]*?\nversion = ")[^"]+("\n)/,
`$1${version}$2`
);
fs.writeFileSync(cargoTomlPath, cargoToml);
const cargoLockPath = "src-tauri/Cargo.lock";
const cargoLock = fs.readFileSync(cargoLockPath, "utf8").replace(
/(\[\[package\]\]\nname = "lockscreenwallpaper"\nversion = ")[^"]+("\n)/,
`$1${version}$2`
);
fs.writeFileSync(cargoLockPath, cargoLock);
const fdroidPath = ".fdroid.yml";
let fdroid = fs.readFileSync(fdroidPath, "utf8");
fdroid = fdroid
.replace(/^ - versionName: .*$/m, ` - versionName: ${version}`)
.replace(/^ versionCode: .*$/m, ` versionCode: ${versionCode}`)
.replace(/^ commit: .*$/m, ` commit: ${process.env.GITHUB_REF_NAME}`)
.replace(/^CurrentVersion: .*$/m, `CurrentVersion: ${version}`)
.replace(/^CurrentVersionCode: .*$/m, `CurrentVersionCode: ${versionCode}`);
fs.writeFileSync(fdroidPath, fdroid);
'
echo "RELEASE_VERSION=${RELEASE_VERSION}" >> "${GITEA_ENV}"
echo "ANDROID_VERSION_CODE=${ANDROID_VERSION_CODE}" >> "${GITEA_ENV}"
echo "Building ${GITHUB_REF_NAME} with Android versionCode ${ANDROID_VERSION_CODE}."
- name: Validate signing secrets
run: |
for SECRET_NAME in \
ANDROID_KEYSTORE_BASE64 \
ANDROID_KEYSTORE_PASSWORD \
ANDROID_KEY_ALIAS \
ANDROID_KEY_PASSWORD \
GITEA_RELEASE_TOKEN
do
if [ -z "${!SECRET_NAME:-}" ]; then
echo "Missing Gitea Actions secret: ${SECRET_NAME}" >&2
exit 1
fi
done
- name: Restore release keystore
run: |
SIGNING_DIRECTORY="${RUNNER_TEMP}/wallpaperflow-signing"
mkdir -p "${SIGNING_DIRECTORY}"
printf '%s' "${ANDROID_KEYSTORE_BASE64}" | base64 --decode > "${SIGNING_DIRECTORY}/release.jks"
chmod 600 "${SIGNING_DIRECTORY}/release.jks"
echo "ANDROID_SIGNING_KEYSTORE=${SIGNING_DIRECTORY}/release.jks" >> "${GITEA_ENV}"
- name: Build unsigned release APK
run: npm run tauri -- android build --apk --ci
- name: Verify generated Android version
run: |
TAURI_PROPERTIES="src-tauri/gen/android/app/tauri.properties"
grep -Fx "tauri.android.versionName=${RELEASE_VERSION}" "${TAURI_PROPERTIES}"
grep -Fx "tauri.android.versionCode=${ANDROID_VERSION_CODE}" "${TAURI_PROPERTIES}"
- name: Align and sign APK
run: |
UNSIGNED_APK="src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release-unsigned.apk"
RELEASE_DIRECTORY="release"
ALIGNED_APK="${RELEASE_DIRECTORY}/WallpaperFlow-release-aligned.apk"
SIGNED_APK="${RELEASE_DIRECTORY}/WallpaperFlow-release.apk"
test -f "${UNSIGNED_APK}"
mkdir -p "${RELEASE_DIRECTORY}"
zipalign -p -f 4 "${UNSIGNED_APK}" "${ALIGNED_APK}"
apksigner sign \
--ks "${ANDROID_SIGNING_KEYSTORE}" \
--ks-key-alias "${ANDROID_KEY_ALIAS}" \
--ks-pass env:ANDROID_KEYSTORE_PASSWORD \
--key-pass env:ANDROID_KEY_PASSWORD \
--out "${SIGNED_APK}" \
"${ALIGNED_APK}"
apksigner verify --verbose --print-certs "${SIGNED_APK}"
rm "${ALIGNED_APK}"
(cd "${RELEASE_DIRECTORY}" && sha256sum WallpaperFlow-release.apk > WallpaperFlow-release.apk.sha256)
- name: Attach APK to Gitea release
run: |
API_ROOT="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
RELEASE_JSON="${RUNNER_TEMP}/wallpaperflow-release.json"
CREATE_JSON="${RUNNER_TEMP}/wallpaperflow-create-release.json"
ASSETS_JSON="${RUNNER_TEMP}/wallpaperflow-release-assets.json"
STATUS="$(curl --silent --show-error \
--output "${RELEASE_JSON}" \
--write-out '%{http_code}' \
--header "Authorization: token ${GITEA_RELEASE_TOKEN}" \
"${API_ROOT}/releases/tags/${GITHUB_REF_NAME}")"
if [ "${STATUS}" = "404" ]; then
node -e '
process.stdout.write(JSON.stringify({
tag_name: process.env.GITHUB_REF_NAME,
name: `WallpaperFlow ${process.env.GITHUB_REF_NAME}`,
body: "Automatisch erstelltes Android-Release.",
draft: false,
prerelease: false
}))
' > "${CREATE_JSON}"
curl --fail --silent --show-error \
--request POST \
--header "Authorization: token ${GITEA_RELEASE_TOKEN}" \
--header "Content-Type: application/json" \
--data-binary "@${CREATE_JSON}" \
--output "${RELEASE_JSON}" \
"${API_ROOT}/releases"
elif [ "${STATUS}" != "200" ]; then
echo "Could not read Gitea release for ${GITHUB_REF_NAME} (HTTP ${STATUS})." >&2
exit 1
fi
RELEASE_ID="$(node -e '
const release = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
if (!release.id) process.exit(1);
process.stdout.write(String(release.id));
' "${RELEASE_JSON}")"
curl --fail --silent --show-error \
--header "Authorization: token ${GITEA_RELEASE_TOKEN}" \
--output "${ASSETS_JSON}" \
"${API_ROOT}/releases/${RELEASE_ID}/assets"
for FILE in \
release/WallpaperFlow-release.apk \
release/WallpaperFlow-release.apk.sha256
do
ASSET_NAME="$(basename "${FILE}")"
ASSET_ID="$(ASSET_NAME="${ASSET_NAME}" node -e '
const assets = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
const asset = assets.find(item => item.name === process.env.ASSET_NAME);
if (asset) process.stdout.write(String(asset.id));
' "${ASSETS_JSON}")"
if [ -n "${ASSET_ID}" ]; then
curl --fail --silent --show-error \
--request DELETE \
--header "Authorization: token ${GITEA_RELEASE_TOKEN}" \
"${API_ROOT}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
fi
curl --fail --silent --show-error \
--request POST \
--header "Authorization: token ${GITEA_RELEASE_TOKEN}" \
--form "attachment=@${FILE}" \
"${API_ROOT}/releases/${RELEASE_ID}/assets" >/dev/null
done
+50 -1
View File
@@ -47,6 +47,51 @@ npm run tauri android dev
Der native Android-Code liegt als lokales Tauri-Plugin in `plugins/android`. Er wird beim Android-Build automatisch eingebunden.
## Signierte Android-Releases mit Gitea Actions
Der Workflow `.gitea/workflows/android-release.yml` baut bei Tags wie `v1.2.3`
eine signierte Universal-APK. Der Tag ist dabei die Quelle für alle
Versionsnummern im Build. Die Pipeline setzt vorübergehend die Versionen in
`package.json`, `package-lock.json`, `src-tauri/tauri.conf.json`,
`src-tauri/Cargo.toml`, `src-tauri/Cargo.lock` und `.fdroid.yml`. Diese Änderungen
gelten nur im Arbeitsverzeichnis des Runners und werden nicht zurück ins
Repository geschrieben.
Aus `v1.2.3` erzeugt Tauri den Android-`versionCode` `1002003` nach dem Schema
`major * 1000000 + minor * 1000 + patch`. Der Workflow kann außerdem manuell
über die Actions-Oberfläche gestartet werden, wenn dabei ein Versions-Tag als
Ref ausgewählt wird.
In den Repository-Einstellungen unter **Settings → Actions → Secrets** müssen
folgende Secrets angelegt werden:
- `ANDROID_KEYSTORE_BASE64`: der Base64-kodierte Inhalt des Release-Keystores
- `ANDROID_KEYSTORE_PASSWORD`: Passwort des Keystores
- `ANDROID_KEY_ALIAS`: Alias des Signaturschlüssels
- `ANDROID_KEY_PASSWORD`: Passwort des Signaturschlüssels
Den Keystore-Inhalt für das Secret erzeugt man unter Linux mit:
```bash
base64 -w 0 /sicherer/pfad/wallpaperflow-release.jks
```
Nach erfolgreichem Lauf erstellt die Pipeline ein Gitea-Release für den Tag
beziehungsweise verwendet ein bereits vorhandenes Release. Die Dateien
`WallpaperFlow-release.apk` und `WallpaperFlow-release.apk.sha256` werden direkt
an dieses Release angehängt. Bei einem erneuten Lauf für denselben Tag werden
vorhandene Anhänge mit diesen Namen ersetzt. Dafür muss der integrierte
`GITEA_TOKEN` Schreibzugriff auf Releases besitzen; die Pipeline fordert die
Berechtigung `releases: write` selbst an.
Der Keystore darf nicht in das Repository eingecheckt werden und muss dauerhaft
gesichert bleiben, da spätere Updates mit demselben Schlüssel signiert werden
müssen.
Voraussetzung ist ein aktiver Gitea-Actions-Runner mit dem Label
`ubuntu-latest`, Netzwerkzugriff auf npm, Rust und die Android-SDK-Server sowie
ausreichend Speicher für Android SDK, NDK und Rust-Buildartefakte.
## Wichtige Android-Hinweise
- Android darf ungenaue Alarme im Ruhemodus verzögern. Der nächste Wechsel wird ausgeführt, sobald das Gerät wieder aktiv ist.
@@ -62,7 +107,11 @@ Weitere Einzelheiten stehen in der [Datenschutzerklärung](PRIVACY.md).
## Veröffentlichungen
Für eine neue Veröffentlichung müssen die Version in `package.json`, `src-tauri/tauri.conf.json` und `src-tauri/Cargo.toml` gemeinsam erhöht und ein passender Git-Tag angelegt werden, beispielsweise `v0.1.0`. Der Android-`versionCode` muss bei jeder Veröffentlichung steigen.
Für ein Gitea-Release genügt ein neuer, höherer Versions-Tag wie `v0.1.1`; die
Pipeline übernimmt daraus alle Versionen für den Build. Für dauerhaft im
Repository gepflegte Versionsstände und F-Droid-Releases sollten die Versionen
in `package.json`, `src-tauri/tauri.conf.json`, `src-tauri/Cargo.toml` und
`.fdroid.yml` zusätzlich im Quellcode aktualisiert werden.
Die Metadaten für F-Droid liegen unter `fastlane/metadata/android`. Das Buildrezept für F-Droid befindet sich in `.fdroid.yml`.
@@ -11,5 +11,6 @@ class BootReceiver : BroadcastReceiver() {
WallpaperStore.screenOnEnabled(context) -> runCatching { ScreenOnRotationService.start(context) }
WallpaperStore.timedEnabled(context) -> WallpaperScheduler.scheduleNext(context)
}
if (ImmichClient.configured(context) && WallpaperStore.prefetchImmich(context)) ImmichPrefetchWorker.enqueue(context)
}
}
@@ -119,6 +119,11 @@ object ImmichClient {
fun connection(context: Context) = connectionObject(context)
fun configured(context: Context): Boolean {
val preferences = prefs(context)
return preferences.getString(KEY_SERVER_URL, "").orEmpty().isNotBlank() && !decryptApiKey(context).isNullOrBlank()
}
fun connect(context: Context, serverUrl: String, apiKey: String): JSObject {
val normalized = normalizeServerUrl(serverUrl)
require(apiKey.isNotBlank()) { "Bitte gib deinen Immich API-Key ein." }
@@ -314,10 +319,10 @@ object ImmichClient {
return bounds.outWidth > 0 && bounds.outHeight > 0
}
fun automaticDownloadsAllowed(context: Context): Boolean {
fun automaticDownloadsAllowed(context: Context, allowMetered: Boolean): Boolean {
val connectivity = context.getSystemService(ConnectivityManager::class.java)
@Suppress("DEPRECATION")
return connectivity.activeNetworkInfo?.isConnected == true && !connectivity.isActiveNetworkMetered
return connectivity.activeNetworkInfo?.isConnected == true && (allowMetered || !connectivity.isActiveNetworkMetered)
}
@Synchronized
@@ -0,0 +1,37 @@
package de.wechselbild.wallpaper
import android.content.Context
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import java.util.concurrent.TimeUnit
class ImmichPrefetchWorker(context: Context, parameters: WorkerParameters) : Worker(context, parameters) {
override fun doWork(): Result {
WallpaperStore.prefetchImmichOriginals(applicationContext)
return Result.success()
}
companion object {
private const val WORK_NAME = "immich-wifi-prefetch"
fun enqueue(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.setRequiresBatteryNotLow(true)
.build()
val request = PeriodicWorkRequestBuilder<ImmichPrefetchWorker>(6, TimeUnit.HOURS)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
}
fun cancel(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
}
}
}
@@ -170,11 +170,14 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
@Command fun connectImmich(invoke: Invoke) = io.execute {
try {
val args = invoke.parseArgs(ImmichConnectArgs::class.java)
invoke.resolve(ImmichClient.connect(activity, args.serverUrl, args.apiKey))
val result = ImmichClient.connect(activity, args.serverUrl, args.apiKey)
if (WallpaperStore.prefetchImmich(activity)) ImmichPrefetchWorker.enqueue(activity)
invoke.resolve(result)
} catch (error: Exception) { invoke.reject(error.message ?: "Immich-Verbindung fehlgeschlagen") }
}
@Command fun disconnectImmich(invoke: Invoke) = io.execute {
ImmichPrefetchWorker.cancel(activity)
invoke.resolve(ImmichClient.disconnect(activity))
}
+180 -37
View File
@@ -8,6 +8,9 @@ import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.RectF
import android.media.ExifInterface
import android.os.Build
import android.util.Base64
import android.util.LruCache
import app.tauri.plugin.JSArray
@@ -15,6 +18,7 @@ import app.tauri.plugin.JSObject
import java.io.ByteArrayOutputStream
import java.io.File
import java.security.MessageDigest
import org.json.JSONArray
import org.json.JSONObject
object WallpaperStore {
@@ -22,11 +26,15 @@ object WallpaperStore {
private const val KEY_INDEX = "current_index"
private const val KEY_CURRENT_ID = "current_entry_id"
private const val KEY_INTERVAL = "interval_minutes"
private const val KEY_SHUFFLE_SEEN_IDS = "shuffle_seen_ids"
private const val KEY_SHUFFLE_QUEUE_IDS = "shuffle_queue_ids"
private const val CROP_PREFIX = "crop_"
private const val INVALID_PREFIX = "invalid_"
private const val RENDER_VERSION = 7
private const val THUMBNAIL_VERSION = 2
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 const val HOME_PREVIEW_LIMIT = 6
private val thumbnailCache = LruCache<String, String>(48)
private data class CropSettings(val mode: String = "cover", val zoom: Double = 1.0, val x: Double = 0.5, val y: Double = 0.5, val rotation: Int = 0)
@@ -92,6 +100,38 @@ object WallpaperStore {
return legacy
}
private fun addedAt(entry: Entry): Long = when (entry) {
is Entry.Local -> entry.file.name.substringBefore('-').toLongOrNull() ?: entry.file.lastModified()
is Entry.Immich -> entry.addedAt
}
private fun writeShuffleQueue(context: Context, ids: List<String>) {
prefs(context).edit().putString(KEY_SHUFFLE_QUEUE_IDS, JSONArray(ids).toString()).apply()
}
private fun shuffleOrder(context: Context, items: List<Entry>, currentId: String): Pair<MutableSet<String>, MutableList<String>> {
val availableIds = items.mapTo(linkedSetOf()) { it.id }
val preferences = prefs(context)
val seenIds = preferences.getStringSet(KEY_SHUFFLE_SEEN_IDS, emptySet())
.orEmpty().filterTo(linkedSetOf()) { it in availableIds }
seenIds.add(currentId)
if (seenIds.size >= availableIds.size && availableIds.size > 1) {
seenIds.clear()
seenIds.add(currentId)
}
val storedQueue = runCatching {
val json = JSONArray(preferences.getString(KEY_SHUFFLE_QUEUE_IDS, "[]"))
(0 until json.length()).map { json.getString(it) }
}.getOrDefault(emptyList())
val queue = storedQueue.filterTo(mutableListOf()) { it in availableIds && it !in seenIds }
val missing = availableIds.filter { it !in seenIds && it !in queue }.shuffled()
queue.addAll(missing)
preferences.edit().putStringSet(KEY_SHUFFLE_SEEN_IDS, seenIds).apply()
writeShuffleQueue(context, queue)
return seenIds to queue
}
fun intervalMinutes(context: Context): Int {
val preferences = prefs(context)
return if (preferences.contains(KEY_INTERVAL)) preferences.getInt(KEY_INTERVAL, 0)
@@ -102,10 +142,18 @@ object WallpaperStore {
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)
fun allowMobileData(context: Context) = prefs(context).getBoolean("allowMobileData", false)
fun prefetchImmich(context: Context) = prefs(context).getBoolean("prefetchImmich", true)
fun set(context: Context, name: String, value: Boolean) {
require(name in setOf("shuffle", "lockScreenOnly")) { "Unbekannte Einstellung" }
prefs(context).edit().putBoolean(name, value).apply()
require(name in setOf("shuffle", "lockScreenOnly", "allowMobileData", "prefetchImmich")) { "Unbekannte Einstellung" }
val editor = prefs(context).edit().putBoolean(name, value)
if (name == "shuffle") editor.remove(KEY_SHUFFLE_SEEN_IDS).remove(KEY_SHUFFLE_QUEUE_IDS)
editor.apply()
if (name == "prefetchImmich") {
if (value && ImmichClient.configured(context)) ImmichPrefetchWorker.enqueue(context)
else if (!value) ImmichPrefetchWorker.cancel(context)
}
}
fun setInterval(context: Context, minutes: Int) {
@@ -117,15 +165,24 @@ object WallpaperStore {
fun state(context: Context, includePreviews: Boolean = true): JSObject {
val items = entries(context)
val index = currentIndex(context, items)
val previewItems = if (index < HOME_PREVIEW_LIMIT) items.take(HOME_PREVIEW_LIMIT) else listOf(items[index]) + items.take(HOME_PREVIEW_LIMIT - 1)
val previewIndex = if (index < HOME_PREVIEW_LIMIT) index else 0
val previewItems = if (items.isEmpty()) emptyList() else {
val current = items[index]
if (shuffle(context) && items.size > 1) {
val queue = shuffleOrder(context, items, current.id).second
listOf(current) + queue.take(HOME_PREVIEW_LIMIT - 1).mapNotNull { id -> items.firstOrNull { it.id == id } }
} else {
(0 until minOf(HOME_PREVIEW_LIMIT, items.size)).map { items[(index + it).mod(items.size)] }
}
}
return JSObject().apply {
put("imageCount", items.size)
put("enabled", enabled(context))
put("intervalMinutes", intervalMinutes(context))
put("shuffle", shuffle(context))
put("lockScreenOnly", lockOnly(context))
put("currentIndex", previewIndex)
put("allowMobileData", allowMobileData(context))
put("prefetchImmich", prefetchImmich(context))
put("currentIndex", 0)
put("currentId", items.getOrNull(index)?.id ?: JSONObject.NULL)
val ids = JSArray()
previewItems.forEach { ids.put(it.id) }
@@ -133,25 +190,39 @@ object WallpaperStore {
val previews = JSArray()
if (includePreviews) previewItems.forEach { previews.put(thumbnailDataUrl(context, it.preview)) }
put("imageUrls", previews)
val imagePreviews = JSArray()
if (includePreviews) previewItems.forEach { imagePreviews.put(galleryImage(context, it, it.id == items.getOrNull(index)?.id)) }
put("imagePreviews", imagePreviews)
}
}
@Synchronized
fun gallery(context: Context, offset: Int, limit: Int): JSObject {
val items = entries(context)
val selectedIndex = currentIndex(context, items)
val safeOffset = offset.coerceAtLeast(0).coerceAtMost(items.size)
val selectedId = items.getOrNull(currentIndex(context, items))?.id
val galleryItems = items.sortedWith(compareByDescending<Entry> { addedAt(it) }.thenByDescending { it.id })
val safeOffset = offset.coerceAtLeast(0).coerceAtMost(galleryItems.size)
val safeLimit = limit.coerceIn(1, 100)
val page = JSArray()
items.drop(safeOffset).take(safeLimit).forEachIndexed { pageIndex, entry ->
page.put(galleryImage(context, entry, safeOffset + pageIndex == selectedIndex))
galleryItems.drop(safeOffset).take(safeLimit).forEach { entry ->
page.put(galleryImage(context, entry, entry.id == selectedId))
}
return JSObject().apply { put("total", items.size); put("items", page) }
return JSObject().apply { put("total", galleryItems.size); put("items", page) }
}
@Synchronized
fun imageIds(context: Context) = entries(context).map { it.id }
fun prefetchImmichOriginals(context: Context) {
val unavailableServers = mutableSetOf<String>()
entries(context).filterIsInstance<Entry.Immich>().forEach { entry ->
if (entry.serverUrl in unavailableServers) return@forEach
if (ImmichClient.cachedOriginal(context, entry.serverUrl, entry.assetId, allowNetwork = true) == null) {
unavailableServers.add(entry.serverUrl)
}
}
}
private fun cropKey(id: String) = CROP_PREFIX + id
private fun crop(context: Context, entry: Entry): CropSettings {
val raw = prefs(context).getString(cropKey(entry.id), null) ?: return CropSettings()
@@ -194,8 +265,16 @@ object WallpaperStore {
val json = JSONObject().apply {
put("mode", normalized.mode); put("zoom", normalized.zoom); put("x", normalized.x); put("y", normalized.y); put("rotation", normalized.rotation)
}
prefs(context).edit().putString(cropKey(id), json.toString()).apply()
return galleryImage(context, entry, items.indexOf(entry) == currentIndex(context, items))
val selected = items.indexOf(entry) == currentIndex(context, items)
check(prefs(context).edit().putString(cropKey(id), json.toString()).commit()) {
"Bildausschnitt konnte nicht gespeichert werden"
}
if (selected) {
check(apply(context, id)) {
"Bildausschnitt wurde gespeichert, konnte aber nicht angewendet werden"
}
}
return galleryImage(context, entry, selected)
}
@Synchronized
@@ -267,27 +346,42 @@ object WallpaperStore {
val metrics = context.resources.displayMetrics
val targetWidth = metrics.widthPixels.coerceAtLeast(1)
val targetHeight = metrics.heightPixels.coerceAtLeast(1)
val quarterTurn = crop.rotation == 90 || crop.rotation == 270
val rotatedWidth = if (quarterTurn) source.height else source.width
val rotatedHeight = if (quarterTurn) source.width else source.height
val rotated = if (crop.rotation == 0) source else Bitmap.createBitmap(
source,
0,
0,
source.width,
source.height,
Matrix().apply { setRotate(crop.rotation.toFloat()) },
true,
)
val rotatedWidth = rotated.width
val rotatedHeight = rotated.height
val widthScale = targetWidth.toDouble() / rotatedWidth
val heightScale = targetHeight.toDouble() / rotatedHeight
val baseScale = if (crop.mode == "contain") minOf(widthScale, heightScale) else maxOf(widthScale, heightScale)
val scale = (baseScale * crop.zoom).toFloat()
val baseWidth = rotatedWidth * baseScale
val baseHeight = rotatedHeight * baseScale
val scaledWidth = rotatedWidth * scale
val scaledHeight = rotatedHeight * scale
val left = ((targetWidth - scaledWidth) * crop.x).toFloat()
val top = ((targetHeight - scaledHeight) * crop.y).toFloat()
return Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888).also { output ->
val baseLeft = (targetWidth - baseWidth) * crop.x
val baseTop = (targetHeight - baseHeight) * crop.y
val left = (targetWidth / 2.0 + (baseLeft - targetWidth / 2.0) * crop.zoom).toFloat()
val top = (targetHeight / 2.0 + (baseTop - targetHeight / 2.0) * crop.zoom).toFloat()
return try {
Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888).also { output ->
val canvas = Canvas(output)
canvas.drawColor(Color.BLACK)
val matrix = Matrix().apply {
postTranslate(-source.width / 2f, -source.height / 2f)
postRotate(crop.rotation.toFloat())
postScale(scale, scale)
postTranslate(left + scaledWidth / 2f, top + scaledHeight / 2f)
canvas.drawBitmap(
rotated,
null,
RectF(left, top, left + scaledWidth, top + scaledHeight),
Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG),
)
}
canvas.drawBitmap(source, matrix, Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG))
} finally {
if (rotated !== source) rotated.recycle()
}
}
@@ -298,11 +392,34 @@ object WallpaperStore {
val targetLongSide = maxOf(context.resources.displayMetrics.widthPixels, context.resources.displayMetrics.heightPixels).coerceAtLeast(1)
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= targetLongSide) sample *= 2
return BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample; inPreferredConfig = Bitmap.Config.ARGB_8888 })
val decoded = BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample; inPreferredConfig = Bitmap.Config.ARGB_8888 }) ?: return null
return applyExifOrientation(file, decoded)
}
private fun applyExifOrientation(file: File, bitmap: Bitmap): Bitmap {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return bitmap
val orientation = runCatching {
ExifInterface(file.absolutePath).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
val matrix = Matrix().apply {
when (orientation) {
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> setScale(-1f, 1f)
ExifInterface.ORIENTATION_ROTATE_180 -> setRotate(180f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> setScale(1f, -1f)
ExifInterface.ORIENTATION_TRANSPOSE -> { setRotate(90f); postScale(-1f, 1f) }
ExifInterface.ORIENTATION_ROTATE_90 -> setRotate(90f)
ExifInterface.ORIENTATION_TRANSVERSE -> { setRotate(-90f); postScale(-1f, 1f) }
ExifInterface.ORIENTATION_ROTATE_270 -> setRotate(-90f)
}
}
if (matrix.isIdentity) return bitmap
val oriented = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
if (oriented !== bitmap) bitmap.recycle()
return oriented
}
private fun thumbnailDataUrl(context: Context, file: File): String {
val cacheKey = "${file.absolutePath}:${file.lastModified()}:${file.length()}"
val cacheKey = "$THUMBNAIL_VERSION:${file.absolutePath}:${file.lastModified()}:${file.length()}"
thumbnailCache.get(cacheKey)?.let { return it }
val cached = File(thumbnailDirectory(context), "${digest(cacheKey)}.jpg")
if (cached.isFile) {
@@ -315,7 +432,8 @@ object WallpaperStore {
BitmapFactory.decodeFile(file.absolutePath, bounds)
var sample = 1
while (bounds.outWidth / sample > 360 || bounds.outHeight / sample > 480) sample *= 2
val bitmap = BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return ""
val decoded = BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return ""
val bitmap = applyExifOrientation(file, decoded)
val result = ByteArrayOutputStream().use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 76, out); bitmap.recycle()
val bytes = out.toByteArray()
@@ -327,7 +445,7 @@ object WallpaperStore {
}
private fun removeThumbnail(file: File) {
thumbnailCache.remove("${file.absolutePath}:${file.lastModified()}:${file.length()}")
thumbnailCache.remove("$THUMBNAIL_VERSION:${file.absolutePath}:${file.lastModified()}:${file.length()}")
}
private fun digest(value: String): String = MessageDigest.getInstance("SHA-256")
@@ -339,7 +457,7 @@ object WallpaperStore {
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), "")}"
val key = "$RENDER_VERSION:${entry.id}:$sourceVersion:${metrics.widthPixels}x${metrics.heightPixels}:${prefs(context).getString(cropKey(entry.id), "")}"
return File(renderedDirectory(context), "${digest(key)}.jpg")
}
@@ -372,9 +490,18 @@ object WallpaperStore {
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) }
if (shuffle(context) && items.size > 1) {
val (seenIds, queue) = shuffleOrder(context, items, items[previous].id)
val candidates = queue.mapNotNull { id -> items.indexOfFirst { it.id == id }.takeIf { it >= 0 } }
val applied = applyCandidates(context, items, candidates, automatic, seenIds)
if (applied) {
val selectedId = prefs(context).getString(KEY_CURRENT_ID, null)
val selectedPosition = queue.indexOf(selectedId)
writeShuffleQueue(context, if (selectedPosition >= 0) queue.drop(selectedPosition + 1) else queue.filter { it != selectedId })
}
return applied
}
val candidates = (1..items.size).map { (previous + it).mod(items.size) }
return applyCandidates(context, items, candidates, automatic)
}
@@ -383,10 +510,21 @@ object WallpaperStore {
val items = entries(context)
val index = items.indexOfFirst { it.id == id }
if (index < 0) return false
return applyCandidates(context, items, listOf(index), automatic = false)
val seenIds = if (shuffle(context)) {
val availableIds = items.mapTo(mutableSetOf()) { it.id }
prefs(context).getStringSet(KEY_SHUFFLE_SEEN_IDS, emptySet())
.orEmpty().filterTo(mutableSetOf()) { it in availableIds }
} else null
return applyCandidates(context, items, listOf(index), automatic = false, seenIds)
}
private fun applyCandidates(context: Context, items: List<Entry>, candidates: List<Int>, automatic: Boolean): Boolean {
private fun applyCandidates(
context: Context,
items: List<Entry>,
candidates: List<Int>,
automatic: Boolean,
shuffleSeenIds: Set<String>? = null,
): Boolean {
val unavailableServers = mutableSetOf<String>()
for (index in candidates) {
val entry = items[index]
@@ -401,7 +539,7 @@ object WallpaperStore {
val sourceFile = when (entry) {
is Entry.Local -> entry.file
is Entry.Immich -> {
val allowNetwork = entry.serverUrl !in unavailableServers && (!automatic || ImmichClient.automaticDownloadsAllowed(context))
val allowNetwork = entry.serverUrl !in unavailableServers && (!automatic || ImmichClient.automaticDownloadsAllowed(context, allowMobileData(context)))
val source = ImmichClient.cachedOriginal(context, entry.serverUrl, entry.assetId, allowNetwork)
if (source == null) {
if (allowNetwork) unavailableServers.add(entry.serverUrl)
@@ -429,7 +567,12 @@ object WallpaperStore {
val manager = WallpaperManager.getInstance(context)
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()
val editor = prefs(context).edit()
.remove(INVALID_PREFIX + entry.id)
.putString(KEY_CURRENT_ID, entry.id)
.putInt(KEY_INDEX, index)
if (shuffleSeenIds != null) editor.putStringSet(KEY_SHUFFLE_SEEN_IDS, shuffleSeenIds + entry.id)
editor.apply()
return true
} catch (_: Exception) {
// Try the next usable entry without changing the current selection.
+19
View File
@@ -15,12 +15,28 @@ pub struct Wallpaper<R: Runtime>(AppHandle<R>);
impl<R: Runtime> Wallpaper<R> {
fn demo() -> WallpaperState {
let image_previews = ["alpine", "waterfall", "coast"]
.into_iter()
.enumerate()
.map(|(index, name)| GalleryImage {
id: format!("demo-{index}"),
url: format!("/wallpapers/{name}.png"),
selected: index == 0,
crop_mode: "cover".into(),
crop_zoom: 1.0,
crop_position_x: 0.5,
crop_position_y: 0.5,
crop_rotation: 0,
})
.collect();
WallpaperState {
image_count: 3,
enabled: true,
interval_minutes: 30,
shuffle: true,
lock_screen_only: true,
allow_mobile_data: false,
prefetch_immich: true,
current_index: 0,
current_id: Some("demo-0".into()),
image_ids: vec!["demo-0".into(), "demo-1".into(), "demo-2".into()],
@@ -29,6 +45,7 @@ impl<R: Runtime> Wallpaper<R> {
"/wallpapers/waterfall.png".into(),
"/wallpapers/coast.png".into(),
],
image_previews,
}
}
pub fn get_state(&self) -> crate::Result<WallpaperState> {
@@ -89,6 +106,8 @@ impl<R: Runtime> Wallpaper<R> {
"enabled" => state.enabled = payload.value,
"shuffle" => state.shuffle = payload.value,
"lockScreenOnly" => state.lock_screen_only = payload.value,
"allowMobileData" => state.allow_mobile_data = payload.value,
"prefetchImmich" => state.prefetch_immich = payload.value,
_ => {}
};
Ok(state)
+4
View File
@@ -8,10 +8,14 @@ pub struct WallpaperState {
pub interval_minutes: i32,
pub shuffle: bool,
pub lock_screen_only: bool,
pub allow_mobile_data: bool,
pub prefetch_immich: bool,
pub current_index: usize,
pub current_id: Option<String>,
pub image_ids: Vec<String>,
pub image_urls: Vec<String>,
#[serde(default)]
pub image_previews: Vec<GalleryImage>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -15,6 +15,7 @@
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:screenOrientation="portrait"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
+84 -28
View File
@@ -1,10 +1,11 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { ArrowLeft, Check, ChevronRight, Cloud, CloudDownload, Crop, Home, Images, Languages, Link2Off, LockKeyhole, Plus, RotateCcw, RotateCw, Save, Server, Settings, Shuffle, Smartphone, Sparkles, Trash2 } from "lucide-react";
import { applyWallpaper, connectImmich, deleteImages, disconnectImmich, getGallery, getImageIds, getImmichAlbums, getImmichAssets, getImmichConnection, getImmichImportProgress, getState, importImmichAssets, nextWallpaper, selectImages, setImageCrop, setIntervalMinutes, setSetting, type GalleryImage, type ImmichAlbum, type ImmichAsset, type ImmichConnection, type ImmichImportProgress, type WallpaperState } from "./native";
import { ArrowLeft, Check, ChevronRight, Cloud, CloudDownload, Crop, Download, Home, Images, Languages, Link2Off, LockKeyhole, Plus, RotateCcw, RotateCw, Save, Server, Settings, Shuffle, Smartphone, Sparkles, Trash2, Wifi } from "lucide-react";
import { applyWallpaper, connectImmich, deleteImages, disconnectImmich, getGallery, getImageIds, getImmichAlbums, getImmichAssets, getImmichConnection, getImmichImportProgress, getState, importImmichAssets, nextWallpaper, selectImages, setImageCrop, setIntervalMinutes, setSetting, type GalleryImage, type ImagePreview, type ImmichAlbum, type ImmichAsset, type ImmichConnection, type ImmichImportProgress, type WallpaperState } from "./native";
import { initialLanguage, languageNames, languages, translations, type Language } from "./i18n-local";
import { immichTranslations } from "./immich-i18n";
const initial: WallpaperState = { imageCount: 0, enabled: false, intervalMinutes: 0, shuffle: true, lockScreenOnly: true, currentIndex: 0, currentId: null, imageIds: [], imageUrls: [] };
const initial: WallpaperState = { imageCount: 0, enabled: false, intervalMinutes: 0, shuffle: true, lockScreenOnly: true, allowMobileData: false, prefetchImmich: true, currentIndex: 0, currentId: null, imageIds: [], imageUrls: [], imagePreviews: [] };
type Tab = "home" | "settings" | "gallery" | "editor" | "immich";
function Switch({ checked, onChange, label }: { checked: boolean; onChange: (value: boolean) => void; label: string }) {
return <button className={`switch ${checked ? "on" : ""}`} role="switch" aria-checked={checked} aria-label={label} onClick={() => onChange(!checked)}><span /></button>;
@@ -24,7 +25,7 @@ function IntervalRow({ value, onChange, label, paused, screenOn, everyMinutes }:
export default function App() {
const [state, setState] = useState(initial);
const [tab, setTab] = useState<"home" | "settings" | "gallery" | "editor" | "immich">("home");
const [tab, setTab] = useState<Tab>("home");
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState("");
const [language, setLanguage] = useState<Language>(initialLanguage);
@@ -46,6 +47,8 @@ export default function App() {
const [immichImportProgress, setImmichImportProgress] = useState<ImmichImportProgress | null>(null);
const phonePreviewRef = useRef<HTMLDivElement>(null);
const phoneImageRef = useRef<HTMLImageElement>(null);
const galleryLoadMoreRef = useRef<HTMLDivElement>(null);
const galleryLoadingRef = useRef(false);
const previewPointers = useRef(new Map<number, { x: number; y: number }>());
const previewGesture = useRef<null | { centerX: number; centerY: number; distance: number; x: number; y: number; zoom: number; left: number; top: number; width: number; height: number; baseWidth: number; baseHeight: number }>(null);
const galleryScrollPosition = useRef(0);
@@ -62,6 +65,12 @@ export default function App() {
window.removeEventListener("focus", refresh);
};
}, []);
useEffect(() => {
window.history.replaceState({ ...window.history.state, wallpaperFlowTab: "home" }, "");
const onPopState = (event: PopStateEvent) => setTab((event.state?.wallpaperFlowTab as Tab | undefined) ?? "home");
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
useEffect(() => {
getImmichConnection().then(connection => {
setImmichConnection(connection);
@@ -90,30 +99,56 @@ export default function App() {
const settled = window.setTimeout(() => window.scrollTo(0, top), 120);
return () => { window.cancelAnimationFrame(frame); window.clearTimeout(settled); };
}, [tab]);
useEffect(() => {
const target = galleryLoadMoreRef.current;
if (tab !== "gallery" || !target || galleryLoading || gallery.length >= galleryTotal) return;
const observer = new IntersectionObserver(entries => {
if (entries[0]?.isIntersecting) void loadGallery(gallery.length, true);
}, { rootMargin: "300px" });
observer.observe(target);
return () => observer.disconnect();
}, [tab, gallery.length, galleryTotal, galleryLoading]);
const t = translations[language];
const it = immichTranslations[language];
const previewDate = useMemo(() => new Intl.DateTimeFormat(language, { weekday: "long", day: "numeric", month: "long" }).format(new Date()), [language]);
const currentPreviewIndex = state.imageIds.indexOf(state.currentId ?? "");
const current = state.imageUrls[currentPreviewIndex] ?? "/wallpapers/alpine.png";
const photos = useMemo(() => state.imageUrls, [state.imageUrls]);
const photos = useMemo<ImagePreview[]>(() => state.imagePreviews?.length ? state.imagePreviews : state.imageUrls.map((url, index) => ({
id: state.imageIds[index] ?? `preview-${index}`,
url,
cropMode: "cover",
cropZoom: 1,
cropPositionX: 0.5,
cropPositionY: 0.5,
cropRotation: 0,
})), [state.imageIds, state.imagePreviews, state.imageUrls]);
async function loadGallery(offset = 0, append = false) {
if (galleryLoadingRef.current) return;
galleryLoadingRef.current = true;
setGalleryLoading(true);
try {
const page = await getGallery(offset, 48);
setGallery(previous => append ? [...previous, ...page.items] : page.items);
setGalleryTotal(page.total);
} catch { setNotice(t.galleryLoadFailed); }
finally { setGalleryLoading(false); }
finally {
galleryLoadingRef.current = false;
setGalleryLoading(false);
}
}
async function update(name: "shuffle" | "lockScreenOnly", value: boolean) {
async function update(name: "shuffle" | "lockScreenOnly" | "allowMobileData" | "prefetchImmich", value: boolean) {
const previous = state[name];
setState(prev => ({ ...prev, [name]: value }));
try {
const saved = await setSetting(name, value);
setState(saved);
setNotice(t.settingSaved);
} catch { setNotice(t.androidOnly); }
} catch (error) {
setState(prev => ({ ...prev, [name]: previous }));
setNotice(String(error).replace(/^Error:\s*/, "") || t.androidOnly);
}
}
async function updateInterval(minutes: number) {
@@ -136,6 +171,17 @@ export default function App() {
finally { setBusy(false); }
}
function navigate(nextTab: Tab) {
if (tab === nextTab) return;
window.history.pushState({ ...window.history.state, wallpaperFlowTab: nextTab }, "");
setTab(nextTab);
}
function goBack(fallback: Tab) {
if (window.history.state?.wallpaperFlowTab === tab) window.history.back();
else setTab(fallback);
}
function toggleSelected(id: string) {
setSelectedIds(previous => {
const next = new Set(previous);
@@ -167,18 +213,18 @@ export default function App() {
function openEditor(image: GalleryImage) {
galleryScrollPosition.current = window.scrollY;
setEditing({ ...image, cropZoom: Math.max(1, image.cropZoom) });
setTab("editor");
navigate("editor");
}
function returnToGallery() {
restoreGalleryScroll.current = true;
setTab("gallery");
goBack("gallery");
}
function openGallery() {
galleryScrollPosition.current = 0;
restoreGalleryScroll.current = false;
setTab("gallery");
navigate("gallery");
void loadGallery();
}
@@ -220,7 +266,7 @@ export default function App() {
}
async function openImmich() {
setTab("immich");
navigate("immich");
setImmichSelected(new Set());
setImmichLoading(true);
try {
@@ -253,7 +299,7 @@ export default function App() {
setState(await importImmichAssets([...immichSelected]));
setImmichSelected(new Set());
setNotice(it.importSuccess);
setTab("home");
navigate("home");
} catch (error) { setNotice(String(error).replace(/^Error:\s*/, "") || it.immichLoadFailed); }
finally {
window.clearInterval(poll);
@@ -268,6 +314,7 @@ export default function App() {
try {
const saved = await setImageCrop(editing);
setGallery(previous => previous.map(image => image.id === saved.id ? saved : image));
setState(previous => ({ ...previous, imagePreviews: (previous.imagePreviews ?? []).map(image => image.id === saved.id ? saved : image) }));
setEditing(saved);
setNotice(t.cropSaved);
returnToGallery();
@@ -323,7 +370,7 @@ export default function App() {
} : previous);
}
function previewPosition(image: GalleryImage) {
function previewPosition(image: ImagePreview) {
const { cropPositionX: x, cropPositionY: y } = image;
if (image.cropRotation === 90) return `${y * 100}% ${(1 - x) * 100}%`;
if (image.cropRotation === 180) return `${(1 - x) * 100}% ${(1 - y) * 100}%`;
@@ -349,16 +396,20 @@ export default function App() {
const zoom = geometry.distance && gesture.distance
? Math.min(3, Math.max(1, gesture.zoom * geometry.distance / gesture.distance))
: gesture.zoom;
const previousLeft = (gesture.width - gesture.baseWidth * gesture.zoom) * gesture.x;
const previousTop = (gesture.height - gesture.baseHeight * gesture.zoom) * gesture.y;
const previousBaseLeft = (gesture.width - gesture.baseWidth) * gesture.x;
const previousBaseTop = (gesture.height - gesture.baseHeight) * gesture.y;
const previousLeft = gesture.width / 2 + (previousBaseLeft - gesture.width / 2) * gesture.zoom;
const previousTop = gesture.height / 2 + (previousBaseTop - gesture.height / 2) * gesture.zoom;
const imageX = (gesture.centerX - gesture.left - previousLeft) / gesture.zoom;
const imageY = (gesture.centerY - gesture.top - previousTop) / gesture.zoom;
const nextLeft = geometry.centerX - gesture.left - imageX * zoom;
const nextTop = geometry.centerY - gesture.top - imageY * zoom;
const horizontalTravel = gesture.width - gesture.baseWidth * zoom;
const verticalTravel = gesture.height - gesture.baseHeight * zoom;
const x = Math.abs(horizontalTravel) < 0.5 ? 0.5 : nextLeft / horizontalTravel;
const y = Math.abs(verticalTravel) < 0.5 ? 0.5 : nextTop / verticalTravel;
const horizontalTravel = gesture.width - gesture.baseWidth;
const verticalTravel = gesture.height - gesture.baseHeight;
const nextBaseLeft = gesture.width / 2 + (nextLeft - gesture.width / 2) / zoom;
const nextBaseTop = gesture.height / 2 + (nextTop - gesture.height / 2) / zoom;
const x = Math.abs(horizontalTravel) < 0.5 ? 0.5 : nextBaseLeft / horizontalTravel;
const y = Math.abs(verticalTravel) < 0.5 ? 0.5 : nextBaseTop / verticalTravel;
setEditing(previous => previous ? {
...previous,
cropZoom: zoom,
@@ -393,10 +444,10 @@ export default function App() {
}
return <main className="app-shell">
{tab === "gallery" ? <header className="gallery-header"><button className="icon-button" aria-label={t.back} onClick={() => { setSelectedIds(new Set()); setTab("home"); }}><ArrowLeft /></button><div><h1>{selectedIds.size ? `${selectedIds.size} ${t.selected}` : t.collection}</h1><p>{galleryTotal} {galleryTotal === 1 ? t.image : t.images}</p></div>{selectedIds.size ? <button className="icon-button delete-selection" aria-label={t.deleteSelected} onClick={removeSelected} disabled={busy}><Trash2 /></button> : <button className="icon-button" aria-label={t.addImages} onClick={choose} disabled={busy}><Plus /></button>}</header> : tab === "editor" ?
{tab === "gallery" ? <header className="gallery-header"><button className="icon-button" aria-label={t.back} onClick={() => { setSelectedIds(new Set()); goBack("home"); }}><ArrowLeft /></button><div><h1>{selectedIds.size ? `${selectedIds.size} ${t.selected}` : t.collection}</h1><p>{galleryTotal} {galleryTotal === 1 ? t.image : t.images}</p></div>{selectedIds.size ? <button className="icon-button delete-selection" aria-label={t.deleteSelected} onClick={removeSelected} disabled={busy}><Trash2 /></button> : <button className="icon-button" aria-label={t.addImages} onClick={choose} disabled={busy}><Plus /></button>}</header> : tab === "editor" ?
<header className="gallery-header"><button className="icon-button" aria-label={t.backToGallery} onClick={returnToGallery}><ArrowLeft /></button><div><h1>{t.editImage}</h1><p>{t.savedForImage}</p></div><button className="icon-button save-crop" aria-label={t.saveCrop} onClick={saveCrop} disabled={busy}><Save /></button></header> :
tab === "immich" ? <header className="gallery-header immich-header"><button className="icon-button" aria-label={t.back} onClick={() => { setImmichSelected(new Set()); setTab("settings"); }}><ArrowLeft /></button><div><h1>{it.immich}</h1><p>{immichSelected.size ? `${immichSelected.size} ${t.selected}` : it.selectImmichPhotos}</p></div>{immichSelected.size > 0 && <button className="icon-button import-selection" aria-label={it.importSelected} onClick={importFromImmich} disabled={busy}><CloudDownload /></button>}</header> :
<header className="app-header"><div className="brand"><img className="brand-logo" src="/app-icon.svg" alt="" /><div className="brand-copy"><h1>WallpaperFlow</h1><p>{state.enabled ? t.automaticActive : t.automaticPaused}</p></div></div><button className={`icon-button settings-shortcut ${tab === "settings" ? "selected" : ""}`} aria-label={t.settings} aria-current={tab === "settings" ? "page" : undefined} onClick={() => setTab("settings")}><Settings /></button></header>}
tab === "immich" ? <header className="gallery-header immich-header"><button className="icon-button" aria-label={t.back} onClick={() => { setImmichSelected(new Set()); goBack("settings"); }}><ArrowLeft /></button><div><h1>{it.immich}</h1><p>{immichSelected.size ? `${immichSelected.size} ${t.selected}` : it.selectImmichPhotos}</p></div>{immichSelected.size > 0 && <button className="icon-button import-selection" aria-label={it.importSelected} onClick={importFromImmich} disabled={busy}><CloudDownload /></button>}</header> :
<header className="app-header"><div className="brand"><img className="brand-logo" src="/app-icon.svg" alt="" /><div className="brand-copy"><h1>WallpaperFlow</h1><p>{state.enabled ? t.automaticActive : t.automaticPaused}</p></div></div><button className={`icon-button settings-shortcut ${tab === "settings" ? "selected" : ""}`} aria-label={t.settings} aria-current={tab === "settings" ? "page" : undefined} onClick={() => navigate("settings")}><Settings /></button></header>}
<div className="content">
{tab === "home" ? <>
@@ -412,7 +463,7 @@ export default function App() {
<button className="primary" onClick={choose} disabled={busy}><Images />{busy ? t.pleaseWait : t.selectImages}</button>
<section className="collection"><div className="section-heading"><h2>{t.collection}</h2><button onClick={openGallery}>{state.imageCount} {state.imageCount === 1 ? t.image : t.images} <ChevronRight /></button></div>
{photos.length ? <div className="photo-rail">{photos.map((photo, index) => <button key={state.imageIds[index] ?? `${photo}-${index}`} className={state.imageIds[index] === state.currentId ? "selected" : ""} disabled={busy} onClick={() => void selectWallpaper(state.imageIds[index])}><img src={photo} alt={`${t.motif} ${index + 1}`} />{state.imageIds[index] === state.currentId && <span><Check /></span>}</button>)}</div> : <button className="empty-collection" onClick={choose}><Images /><span>{t.noImages}</span></button>}
{photos.length ? <div className="photo-rail">{photos.map((photo, index) => <button key={photo.id} className={photo.id === state.currentId ? "selected" : ""} disabled={busy} onClick={() => void selectWallpaper(photo.id)}><span className={`gallery-preview-frame ${photo.cropRotation === 90 || photo.cropRotation === 270 ? "quarter-turn" : ""}`} style={{ transform: `rotate(${photo.cropRotation}deg) scale(${photo.cropZoom})` }}><img src={photo.url} alt={`${t.motif} ${index + 1}`} style={{ objectFit: photo.cropMode, objectPosition: previewPosition(photo) }} /></span>{photo.id === state.currentId && <span className="photo-current"><Check /></span>}</button>)}</div> : <button className="empty-collection" onClick={choose}><Images /><span>{t.noImages}</span></button>}
<p className="hint">{t.collectionHint}</p>
</section>
@@ -425,6 +476,10 @@ export default function App() {
<div className="immich-title"><span><Cloud /></span><div><h3>{it.immich}</h3><p>{it.immichIntro}</p></div></div>
{immichConnection.configured ? <>
<div className="immich-connected"><strong>{it.connectedAs} {immichConnection.userName}</strong><span>{immichConnection.serverUrl}</span></div>
<SettingRow icon={<Download />} label={it.prefetchImmich} value={state.prefetchImmich} onChange={v => update("prefetchImmich", v)} />
<p className="immich-help">{it.prefetchImmichHint}</p>
<SettingRow icon={<Wifi />} label={it.allowMobileData} value={state.allowMobileData} onChange={v => update("allowMobileData", v)} />
<p className="immich-help">{it.allowMobileDataHint}</p>
<button className="immich-open" onClick={openImmich} disabled={busy}><Images /> {it.openImmich} <ChevronRight /></button>
<button className="immich-disconnect" onClick={removeImmichConnection} disabled={busy}><Link2Off /> {it.disconnect}</button>
</> : <>
@@ -445,11 +500,12 @@ export default function App() {
{!immichLoading && immichHasMore && <button className="load-more" onClick={() => loadImmichPhotos(immichAlbumId, immichPage + 1, true)}>{t.loadMore}</button>}
{!!immichSelected.size && (busy && immichImportProgress ? <div className="immich-import-progress" role="status" aria-live="polite"><div><span>{it.importing}</span><strong>{immichImportProgress.completed} / {immichImportProgress.total}</strong></div><progress max={immichImportProgress.bytesTotal || immichImportProgress.total || 1} value={immichImportProgress.bytesTotal ? immichImportProgress.bytesDownloaded : immichImportProgress.completed} />{immichImportProgress.bytesTotal > 0 && <div><span>{Math.round(immichImportProgress.bytesDownloaded / immichImportProgress.bytesTotal * 100)}%</span><span>{(immichImportProgress.bytesDownloaded / 1024 / 1024).toFixed(1)} / {(immichImportProgress.bytesTotal / 1024 / 1024).toFixed(1)} MB</span></div>}</div> : <button className="primary immich-import" onClick={importFromImmich} disabled={busy}><CloudDownload /> {`${it.importSelected} (${immichSelected.size})`}</button>)}
</section> : tab === "gallery" ?
<section className="gallery-page" aria-label={t.myImages}>
<section className={`gallery-page ${selectedIds.size ? "has-floating-action" : ""}`} aria-label={t.myImages}>
{!!galleryTotal && <div className="selection-toolbar"><span>{selectedIds.size ? `${selectedIds.size} ${t.selected}` : t.selectImagesToDelete}</span><button onClick={selectAll} disabled={busy}>{selectedIds.size === galleryTotal ? t.clearSelection : t.selectAll}</button></div>}
{gallery.length ? <div className="gallery-grid">{gallery.map((image, index) => <article className={`${image.selected ? "current " : ""}${selectedIds.has(image.id) ? "chosen" : ""}`} key={image.id}><button className="gallery-image-button" aria-label={`${selectedIds.has(image.id) ? t.deselectImage : t.selectImage} ${index + 1}`} aria-pressed={selectedIds.has(image.id)} onClick={() => toggleSelected(image.id)}><img src={image.url} alt={`${t.image} ${index + 1}`} /></button>{image.selected && <span className="current-badge"><Check /> {t.current}</span>}<span className="selection-check"><Check /></span><button className="edit-button" aria-label={`${t.adjustImage} ${index + 1}`} onClick={() => openEditor(image)}><Crop /></button></article>)}</div> : !galleryLoading && <div className="gallery-empty"><Images /><h2>{t.emptyCollection}</h2><p>{t.emptyCollectionText}</p><button className="primary" onClick={choose}><Plus /> {t.addImages}</button></div>}
{gallery.length ? <div className="gallery-grid wallpaper-gallery">{gallery.map((image, index) => <article className={`${image.selected ? "current " : ""}${selectedIds.has(image.id) ? "chosen" : ""}`} key={image.id}><button className="gallery-image-button" aria-label={`${selectedIds.has(image.id) ? t.deselectImage : t.selectImage} ${index + 1}`} aria-pressed={selectedIds.has(image.id)} onClick={() => toggleSelected(image.id)}><span className={`gallery-preview-frame ${image.cropRotation === 90 || image.cropRotation === 270 ? "quarter-turn" : ""}`} style={{ transform: `rotate(${image.cropRotation}deg) scale(${image.cropZoom})` }}><img src={image.url} alt={`${t.image} ${index + 1}`} loading="lazy" decoding="async" style={{ objectFit: image.cropMode, objectPosition: previewPosition(image) }} /></span></button>{image.selected && <span className="current-badge"><Check /> {t.current}</span>}<span className="selection-check"><Check /></span><button className="edit-button" aria-label={`${t.adjustImage} ${index + 1}`} onClick={() => openEditor(image)}><Crop /></button></article>)}</div> : !galleryLoading && <div className="gallery-empty"><Images /><h2>{t.emptyCollection}</h2><p>{t.emptyCollectionText}</p><button className="primary" onClick={choose}><Plus /> {t.addImages}</button></div>}
{galleryLoading && <p className="gallery-status">{t.galleryLoading}</p>}
{!galleryLoading && gallery.length < galleryTotal && <button className="load-more" onClick={() => loadGallery(gallery.length, true)}>{t.loadMore}</button>}
{gallery.length < galleryTotal && <div ref={galleryLoadMoreRef} className="gallery-load-sentinel" aria-hidden="true" />}
{!!selectedIds.size && <button className="floating-delete-selection" aria-label={`${t.deleteSelected} (${selectedIds.size})`} onClick={removeSelected} disabled={busy}><Trash2 /><span>{t.deleteSelected}</span><strong>{selectedIds.size}</strong></button>}
</section> : editing && <section className="crop-editor" aria-label={t.editCrop}>
<div ref={phonePreviewRef} className="phone-preview interactive" aria-label={t.gestureLabel} onPointerDown={beginPreviewGesture} onPointerMove={movePreviewGesture} onPointerUp={endPreviewGesture} onPointerCancel={endPreviewGesture}>
<div className={`preview-image-frame ${editing.cropRotation === 90 || editing.cropRotation === 270 ? "quarter-turn" : ""}`} style={{ transform: `rotate(${editing.cropRotation}deg) scale(${editing.cropZoom})` }}><img ref={phoneImageRef} src={editing.url} alt={t.cropPreview} draggable={false} style={{ objectFit: editing.cropMode, objectPosition: previewPosition(editing) }} /></div>
@@ -468,6 +524,6 @@ export default function App() {
</div>
{notice && <button className="snackbar" onClick={() => setNotice("")}>{notice}</button>}
{tab !== "gallery" && tab !== "editor" && tab !== "immich" && <nav><button className={tab === "home" ? "active" : ""} onClick={() => setTab("home")}><Home /><span>{t.home}</span></button><button className={tab === "settings" ? "active" : ""} onClick={() => setTab("settings")}><Settings /><span>{t.settings}</span></button></nav>}
{tab !== "gallery" && tab !== "editor" && tab !== "immich" && <nav><button className={tab === "home" ? "active" : ""} onClick={() => navigate("home")}><Home /><span>{t.home}</span></button><button className={tab === "settings" ? "active" : ""} onClick={() => navigate("settings")}><Settings /><span>{t.settings}</span></button></nav>}
</main>;
}
+48
View File
@@ -22,6 +22,10 @@ export type ImmichText = {
importSuccess: string;
immichLoadFailed: string;
noImmichPhotos: string;
allowMobileData: string;
allowMobileDataHint: string;
prefetchImmich: string;
prefetchImmichHint: string;
};
export const immichTranslations: Record<Language, ImmichText> = {
@@ -47,6 +51,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
importSuccess: "Immich-Bilder wurden zur Sammlung hinzugefügt",
immichLoadFailed: "Immich-Bilder konnten nicht geladen werden",
noImmichPhotos: "Keine Bilder gefunden",
allowMobileData: "Auch über mobile Daten laden",
allowMobileDataHint: "Ist das ausgeschaltet, lädt der automatische Wechsel Immich-Bilder nur über WLAN und überspringt sie sonst. Manuelles Wechseln lädt immer.",
prefetchImmich: "Immich-Bilder immer vorladen",
prefetchImmichHint: "Lädt neue Immich-Bilder automatisch im Hintergrund herunter, sobald WLAN verfügbar ist nie über mobile Daten. Ausgeschaltet werden Bilder nur bei Bedarf geladen.",
},
en: {
immich: "Immich", immichIntro: "Import images from your own Immich server.", serverUrl: "Server URL", apiKey: "API key",
@@ -56,6 +64,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
httpWarning: "HTTP is unencrypted. Use HTTPS outside your home network.", allPhotos: "All photos",
selectImmichPhotos: "Select images to import", importSelected: "Import selection", importing: "Importing images…",
importSuccess: "Immich images imported", immichLoadFailed: "Could not load Immich images", noImmichPhotos: "No images found",
allowMobileData: "Allow downloads over mobile data",
allowMobileDataHint: "When off, automatic rotation only downloads Immich images over Wi-Fi and skips them otherwise. Manually changing the wallpaper always downloads.",
prefetchImmich: "Always prefetch Immich images",
prefetchImmichHint: "Downloads new Immich images in the background as soon as Wi-Fi is available never over mobile data. When off, images are only downloaded when needed.",
},
fr: {
immich: "Immich", immichIntro: "Importez des images depuis votre propre serveur Immich.", serverUrl: "URL du serveur", apiKey: "Clé API",
@@ -65,6 +77,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
httpWarning: "HTTP nest pas chiffré. Utilisez HTTPS hors de votre réseau domestique.", allPhotos: "Toutes les photos",
selectImmichPhotos: "Sélectionnez les images à importer", importSelected: "Importer la sélection", importing: "Importation des images…",
importSuccess: "Images Immich importées", immichLoadFailed: "Impossible de charger les images Immich", noImmichPhotos: "Aucune image trouvée",
allowMobileData: "Autoriser les données mobiles",
allowMobileDataHint: "Désactivé, la rotation automatique ne télécharge les images Immich que via le Wi-Fi et les ignore sinon. Un changement manuel télécharge toujours.",
prefetchImmich: "Toujours précharger les images Immich",
prefetchImmichHint: "Télécharge les nouvelles images Immich en arrière-plan dès que le Wi-Fi est disponible jamais via les données mobiles. Désactivé, les images ne sont téléchargées quen cas de besoin.",
},
es: {
immich: "Immich", immichIntro: "Importa imágenes desde tu propio servidor Immich.", serverUrl: "URL del servidor", apiKey: "Clave API",
@@ -74,6 +90,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
httpWarning: "HTTP no está cifrado. Usa HTTPS fuera de tu red doméstica.", allPhotos: "Todas las fotos",
selectImmichPhotos: "Selecciona imágenes para importar", importSelected: "Importar selección", importing: "Importando imágenes…",
importSuccess: "Imágenes de Immich importadas", immichLoadFailed: "No se pudieron cargar las imágenes de Immich", noImmichPhotos: "No se encontraron imágenes",
allowMobileData: "Permitir datos móviles",
allowMobileDataHint: "Si está desactivado, la rotación automática solo descarga imágenes de Immich por Wi-Fi y las omite si no hay Wi-Fi. Cambiar manualmente siempre descarga.",
prefetchImmich: "Precargar siempre las imágenes de Immich",
prefetchImmichHint: "Descarga las nuevas imágenes de Immich en segundo plano en cuanto haya Wi-Fi disponible, nunca con datos móviles. Si está desactivado, las imágenes solo se descargan cuando se necesitan.",
},
it: {
immich: "Immich", immichIntro: "Importa immagini dal tuo server Immich.", serverUrl: "URL del server", apiKey: "Chiave API",
@@ -83,6 +103,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
httpWarning: "HTTP non è crittografato. Usa HTTPS fuori dalla rete domestica.", allPhotos: "Tutte le foto",
selectImmichPhotos: "Seleziona le immagini da importare", importSelected: "Importa selezione", importing: "Importazione immagini…",
importSuccess: "Immagini Immich importate", immichLoadFailed: "Impossibile caricare le immagini Immich", noImmichPhotos: "Nessuna immagine trovata",
allowMobileData: "Consenti dati mobili",
allowMobileDataHint: "Se disattivato, la rotazione automatica scarica le immagini Immich solo via Wi-Fi e le salta altrimenti. Il cambio manuale scarica sempre.",
prefetchImmich: "Precarica sempre le immagini Immich",
prefetchImmichHint: "Scarica le nuove immagini Immich in background non appena il Wi-Fi è disponibile, mai con i dati mobili. Se disattivato, le immagini vengono scaricate solo quando servono.",
},
nl: {
immich: "Immich", immichIntro: "Importeer afbeeldingen van je eigen Immich-server.", serverUrl: "Server-URL", apiKey: "API-sleutel",
@@ -92,6 +116,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
httpWarning: "HTTP is niet versleuteld. Gebruik HTTPS buiten je thuisnetwerk.", allPhotos: "Alle fotos",
selectImmichPhotos: "Selecteer afbeeldingen om te importeren", importSelected: "Selectie importeren", importing: "Afbeeldingen importeren…",
importSuccess: "Immich-afbeeldingen geïmporteerd", immichLoadFailed: "Immich-afbeeldingen konden niet worden geladen", noImmichPhotos: "Geen afbeeldingen gevonden",
allowMobileData: "Mobiele data toestaan",
allowMobileDataHint: "Indien uit, worden Immich-afbeeldingen bij automatisch wisselen alleen via wifi gedownload en anders overgeslagen. Handmatig wisselen downloadt altijd.",
prefetchImmich: "Immich-afbeeldingen altijd vooraf laden",
prefetchImmichHint: "Downloadt nieuwe Immich-afbeeldingen op de achtergrond zodra wifi beschikbaar is nooit via mobiele data. Indien uit, worden afbeeldingen alleen bij gebruik gedownload.",
},
pl: {
immich: "Immich", immichIntro: "Importuj obrazy z własnego serwera Immich.", serverUrl: "Adres serwera", apiKey: "Klucz API",
@@ -101,6 +129,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
httpWarning: "HTTP nie jest szyfrowany. Poza siecią domową używaj HTTPS.", allPhotos: "Wszystkie zdjęcia",
selectImmichPhotos: "Wybierz obrazy do importu", importSelected: "Importuj wybrane", importing: "Importowanie obrazów…",
importSuccess: "Zaimportowano obrazy Immich", immichLoadFailed: "Nie udało się wczytać obrazów Immich", noImmichPhotos: "Nie znaleziono obrazów",
allowMobileData: "Zezwól na dane mobilne",
allowMobileDataHint: "Gdy wyłączone, automatyczna zmiana pobiera obrazy Immich tylko przez Wi-Fi, w przeciwnym razie je pomija. Ręczna zmiana zawsze pobiera.",
prefetchImmich: "Zawsze pobieraj obrazy Immich z wyprzedzeniem",
prefetchImmichHint: "Pobiera nowe obrazy Immich w tle, gdy tylko dostępne jest Wi-Fi nigdy przez dane mobilne. Gdy wyłączone, obrazy są pobierane tylko w razie potrzeby.",
},
pt: {
immich: "Immich", immichIntro: "Importe imagens do seu próprio servidor Immich.", serverUrl: "URL do servidor", apiKey: "Chave de API",
@@ -110,6 +142,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
httpWarning: "HTTP não é encriptado. Use HTTPS fora da sua rede doméstica.", allPhotos: "Todas as fotos",
selectImmichPhotos: "Selecione imagens para importar", importSelected: "Importar seleção", importing: "A importar imagens…",
importSuccess: "Imagens do Immich importadas", immichLoadFailed: "Não foi possível carregar imagens do Immich", noImmichPhotos: "Nenhuma imagem encontrada",
allowMobileData: "Permitir dados móveis",
allowMobileDataHint: "Quando desativado, a rotação automática só transfere imagens do Immich por Wi-Fi e ignora-as caso contrário. A troca manual transfere sempre.",
prefetchImmich: "Pré-carregar sempre imagens do Immich",
prefetchImmichHint: "Transfere novas imagens do Immich em segundo plano assim que o Wi-Fi estiver disponível nunca com dados móveis. Quando desativado, as imagens só são transferidas quando necessário.",
},
ja: {
immich: "Immich", immichIntro: "自分の Immich サーバーから画像を取り込みます。", serverUrl: "サーバー URL", apiKey: "API キー",
@@ -119,6 +155,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
httpWarning: "HTTP は暗号化されません。自宅ネットワーク外では HTTPS を使用してください。", allPhotos: "すべての写真",
selectImmichPhotos: "取り込む画像を選択", importSelected: "選択項目を取り込む", importing: "画像を取り込み中…",
importSuccess: "Immich の画像を取り込みました", immichLoadFailed: "Immich の画像を読み込めません", noImmichPhotos: "画像が見つかりません",
allowMobileData: "モバイルデータでのダウンロードを許可",
allowMobileDataHint: "オフの場合、自動切り替えは Wi-Fi 接続時のみ Immich の画像をダウンロードし、それ以外はスキップします。手動での切り替えは常にダウンロードします。",
prefetchImmich: "Immich画像を常に先読みする",
prefetchImmichHint: "Wi-Fi が利用可能になり次第、新しい Immich 画像をバックグラウンドでダウンロードします(モバイルデータは使用しません)。オフの場合、画像は必要なときのみダウンロードされます。",
},
ko: {
immich: "Immich", immichIntro: "내 Immich 서버에서 이미지를 가져옵니다.", serverUrl: "서버 URL", apiKey: "API 키",
@@ -128,6 +168,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
httpWarning: "HTTP는 암호화되지 않습니다. 홈 네트워크 밖에서는 HTTPS를 사용하세요.", allPhotos: "모든 사진",
selectImmichPhotos: "가져올 이미지를 선택하세요", importSelected: "선택 항목 가져오기", importing: "이미지 가져오는 중…",
importSuccess: "Immich 이미지를 가져왔습니다", immichLoadFailed: "Immich 이미지를 불러올 수 없습니다", noImmichPhotos: "이미지를 찾을 수 없습니다",
allowMobileData: "모바일 데이터 다운로드 허용",
allowMobileDataHint: "꺼져 있으면 자동 변경 시 Wi-Fi에서만 Immich 이미지를 다운로드하고 그렇지 않으면 건너뜁니다. 수동 변경은 항상 다운로드합니다.",
prefetchImmich: "Immich 이미지 항상 미리 다운로드",
prefetchImmichHint: "Wi-Fi를 사용할 수 있게 되면 새 Immich 이미지를 백그라운드에서 다운로드합니다(모바일 데이터는 사용하지 않음). 꺼져 있으면 필요할 때만 이미지를 다운로드합니다.",
},
"zh-CN": {
immich: "Immich", immichIntro: "从你自己的 Immich 服务器导入图片。", serverUrl: "服务器 URL", apiKey: "API 密钥",
@@ -137,5 +181,9 @@ export const immichTranslations: Record<Language, ImmichText> = {
httpWarning: "HTTP 未加密。在家庭网络之外请使用 HTTPS。", allPhotos: "所有照片",
selectImmichPhotos: "选择要导入的图片", importSelected: "导入所选图片", importing: "正在导入图片…",
importSuccess: "已导入 Immich 图片", immichLoadFailed: "无法加载 Immich 图片", noImmichPhotos: "未找到图片",
allowMobileData: "允许使用移动数据下载",
allowMobileDataHint: "关闭时,自动更换仅在 Wi-Fi 下下载 Immich 图片,否则将跳过;手动更换始终会下载。",
prefetchImmich: "始终预下载 Immich 图片",
prefetchImmichHint: "只要有 Wi-Fi 可用,就会在后台下载新的 Immich 图片——绝不使用移动数据。关闭时,图片仅在需要时才会下载。",
},
};
+36 -12
View File
@@ -1,26 +1,32 @@
import { invoke } from "@tauri-apps/api/core";
export type ImagePreview = {
id: string;
url: string;
cropMode: "cover" | "contain";
cropZoom: number;
cropPositionX: number;
cropPositionY: number;
cropRotation: number;
};
export type WallpaperState = {
imageCount: number;
enabled: boolean;
intervalMinutes: number;
shuffle: boolean;
lockScreenOnly: boolean;
allowMobileData: boolean;
prefetchImmich: boolean;
currentIndex: number;
currentId: string | null;
imageIds: string[];
imageUrls: string[];
imagePreviews: ImagePreview[];
};
export type GalleryImage = {
id: string;
url: string;
export type GalleryImage = ImagePreview & {
selected: boolean;
cropMode: "cover" | "contain";
cropZoom: number;
cropPositionX: number;
cropPositionY: number;
cropRotation: number;
};
export type GalleryPage = {
@@ -63,15 +69,25 @@ export type ImmichImportProgress = {
};
const demoState: WallpaperState = {
imageCount: 3,
imageCount: 6,
enabled: true,
intervalMinutes: 30,
shuffle: true,
lockScreenOnly: true,
allowMobileData: false,
prefetchImmich: true,
currentIndex: 0,
currentId: "demo-0",
imageIds: ["demo-0", "demo-1", "demo-2"],
imageUrls: ["/wallpapers/alpine.png", "/wallpapers/waterfall.png", "/wallpapers/coast.png"],
imageIds: ["demo-0", "demo-1", "demo-2", "demo-3", "demo-4", "demo-5"],
imageUrls: ["/wallpapers/alpine.png", "/wallpapers/waterfall.png", "/wallpapers/coast.png", "/wallpapers/alpine.png", "/wallpapers/waterfall.png", "/wallpapers/coast.png"],
imagePreviews: [
{ id: "demo-0", url: "/wallpapers/alpine.png", cropMode: "cover", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5, cropRotation: 0 },
{ id: "demo-1", url: "/wallpapers/waterfall.png", cropMode: "cover", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5, cropRotation: 0 },
{ id: "demo-2", url: "/wallpapers/coast.png", cropMode: "cover", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5, cropRotation: 0 },
{ id: "demo-3", url: "/wallpapers/alpine.png", cropMode: "cover", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5, cropRotation: 0 },
{ id: "demo-4", url: "/wallpapers/waterfall.png", cropMode: "cover", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5, cropRotation: 0 },
{ id: "demo-5", url: "/wallpapers/coast.png", cropMode: "cover", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5, cropRotation: 0 },
],
};
const inTauri = () => "__TAURI_INTERNALS__" in window;
@@ -79,6 +95,11 @@ const demoCrops = new Map<string, Pick<GalleryImage, "cropMode" | "cropZoom" | "
const defaultCrop = { cropMode: "cover" as const, cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5, cropRotation: 0 };
let demoImmichConnection: ImmichConnection = { configured: false, serverUrl: "", userName: "" };
function orderDemoPreviewsFromCurrent() {
const index = demoState.imagePreviews.findIndex(image => image.id === demoState.currentId);
if (index > 0) demoState.imagePreviews = [...demoState.imagePreviews.slice(index), ...demoState.imagePreviews.slice(0, index)];
}
export async function getState(): Promise<WallpaperState> {
return inTauri() ? invoke<WallpaperState>("plugin:wallpaper|get_state") : demoState;
}
@@ -145,10 +166,11 @@ export async function setImageCrop(image: GalleryImage): Promise<GalleryImage> {
cropPositionY: image.cropPositionY,
cropRotation: image.cropRotation,
});
demoState.imagePreviews = demoState.imagePreviews.map(preview => preview.id === image.id ? { ...preview, ...image } : preview);
return { ...image };
}
export async function setSetting(name: "shuffle" | "lockScreenOnly", value: boolean) {
export async function setSetting(name: "shuffle" | "lockScreenOnly" | "allowMobileData" | "prefetchImmich", value: boolean) {
if (!inTauri()) return { ...demoState, [name]: value };
return invoke<WallpaperState>("plugin:wallpaper|set_setting", { name, value });
}
@@ -166,6 +188,7 @@ export async function nextWallpaper(): Promise<WallpaperState> {
if (!inTauri()) {
if (demoState.imageUrls.length) demoState.currentIndex = (demoState.currentIndex + 1) % demoState.imageUrls.length;
demoState.currentId = demoState.imageIds[demoState.currentIndex] ?? null;
orderDemoPreviewsFromCurrent();
return { ...demoState };
}
return invoke<WallpaperState>("plugin:wallpaper|next_wallpaper");
@@ -177,6 +200,7 @@ export async function applyWallpaper(id: string): Promise<WallpaperState> {
if (index < 0) throw new Error("Image not found");
demoState.currentIndex = index;
demoState.currentId = id;
orderDemoPreviewsFromCurrent();
return { ...demoState };
}
return invoke<WallpaperState>("plugin:wallpaper|apply_wallpaper", { id });
+15 -5
View File
@@ -53,13 +53,13 @@ header p { margin: 0; font-size: 13px; color: #657068; font-weight: 500; }
h2 { margin: 0; font-size: 21px; letter-spacing: -.6px; }
.section-heading button { border: 0; padding: 4px 0; color: var(--green); background: transparent; font-size: 13px; font-weight: 750; display: flex; align-items: center; }
.section-heading svg { width: 17px; }
.photo-rail { display: flex; gap: 10px; overflow-x: auto; padding: 2px 2px 4px; scrollbar-width: none; }
.photo-rail { display: flex; gap: 8px; overflow-x: auto; padding: 2px 2px 4px; scrollbar-width: none; }
.photo-rail::-webkit-scrollbar { display: none; }
.photo-rail button { position: relative; width: 80px; height: 102px; padding: 0; border-radius: 13px; border: 2px solid transparent; background: #ddd; flex: 0 0 auto; overflow: hidden; }
.photo-rail button { position: relative; width: 64px; aspect-ratio: 9 / 19.5; padding: 0; border-radius: 13px; border: 2px solid transparent; background: #090b09; flex: 0 0 auto; overflow: hidden; }
.photo-rail button.selected { border-color: #73a17c; box-shadow: 0 0 0 2px #f8faf7 inset; }
.photo-rail img { width: 100%; height: 100%; object-fit: cover; display: block; }
.photo-rail span { position: absolute; left: 7px; top: 7px; width: 23px; height: 23px; background: var(--green); color: white; border-radius: 50%; display: grid; place-items: center; }
.photo-rail span svg { width: 14px; }
.photo-rail .photo-current { position: absolute; z-index: 2; left: 6px; top: 6px; width: 22px; height: 22px; background: var(--green); color: white; border-radius: 50%; display: grid; place-items: center; }
.photo-rail .photo-current svg { width: 13px; }
.empty-collection { width: 100%; min-height: 84px; border: 1px dashed #b8c6ba; border-radius: 15px; background: #f1f5ef; color: #667069; display: flex; align-items: center; justify-content: center; gap: 9px; font-size: 13px; font-weight: 650; }
.empty-collection svg { width: 20px; color: var(--green); }
.hint { margin: 8px 0 12px; text-align: center; font-size: 11px; color: #7a837d; }
@@ -122,16 +122,25 @@ nav button.active { color: var(--green); background: var(--sage); }
nav svg { width: 21px; }
.snackbar { position: fixed; z-index: 20; left: 50%; transform: translateX(-50%); bottom: calc(92px + env(safe-area-inset-bottom)); max-width: calc(100% - 40px); background: #26312a; color: white; border: 0; border-radius: 12px; padding: 13px 18px; font-size: 12px; box-shadow: 0 8px 26px rgba(0,0,0,.22); }
.gallery-page { padding-bottom: max(24px, env(safe-area-inset-bottom)); }
.gallery-page.has-floating-action { padding-bottom: calc(94px + env(safe-area-inset-bottom)); }
.selection-toolbar { min-height: 44px; margin: 0 0 11px; padding: 0 4px; display: flex; align-items: center; justify-content: space-between; gap: 12px; color: #6c766e; font-size: 12px; }
.selection-toolbar button { min-height: 36px; padding: 0 12px; border: 0; border-radius: 11px; color: var(--green); background: var(--sage); font-size: 12px; font-weight: 800; }
.selection-toolbar button:disabled { opacity: .55; }
.delete-selection { color: #fff; background: #8e2929; }
.floating-delete-selection { position: fixed; z-index: 12; left: 50%; bottom: max(18px, env(safe-area-inset-bottom)); width: min(calc(100% - 40px), 440px); height: 56px; padding: 0 18px; border: 0; border-radius: 17px; transform: translateX(-50%); display: flex; align-items: center; justify-content: center; gap: 10px; color: white; background: #8e2929; box-shadow: 0 12px 30px rgba(91, 21, 21, .34); font-size: 15px; font-weight: 800; }
.floating-delete-selection svg { width: 20px; }
.floating-delete-selection strong { min-width: 24px; height: 24px; padding: 0 7px; border-radius: 99px; display: grid; place-items: center; color: #8e2929; background: white; font-size: 11px; }
.floating-delete-selection:disabled { opacity: .6; }
.gallery-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 9px; }
.gallery-grid article { position: relative; aspect-ratio: 3 / 4; overflow: hidden; border-radius: 14px; background: #e3e9e2; border: 2px solid transparent; }
.wallpaper-gallery article { aspect-ratio: 9 / 19.5; background: #090b09; }
.gallery-grid article.current { border-color: #73a17c; }
.gallery-grid article.chosen { border-color: var(--green); box-shadow: 0 0 0 2px rgba(20,93,50,.14); }
.gallery-image-button { width: 100%; height: 100%; padding: 0; border: 0; background: transparent; display: block; }
.gallery-image-button { position: relative; width: 100%; height: 100%; padding: 0; overflow: hidden; border: 0; background: transparent; display: block; }
.gallery-image-button img { width: 100%; height: 100%; object-fit: cover; display: block; }
.gallery-preview-frame { position: absolute; inset: 0; transform-origin: center; }
.gallery-preview-frame.quarter-turn { inset: 26.923% -58.333%; }
.gallery-preview-frame img { width: 100%; height: 100%; display: block; }
.gallery-grid article.chosen .gallery-image-button img { filter: brightness(.78); }
.selection-check { position: absolute; z-index: 2; top: 7px; right: 7px; width: 27px; height: 27px; display: grid; place-items: center; border: 2px solid rgba(255,255,255,.94); border-radius: 50%; color: transparent; background: rgba(28,38,31,.28); pointer-events: none; }
.selection-check svg { width: 15px; }
@@ -149,6 +158,7 @@ nav svg { width: 21px; }
.gallery-empty p { max-width: 290px; margin: 8px 0 2px; font-size: 13px; line-height: 1.5; }
.gallery-empty .primary { max-width: 260px; }
.gallery-status { padding: 28px 0; text-align: center; color: #6d776f; font-size: 13px; }
.gallery-load-sentinel { height: 1px; }
.load-more { width: 100%; height: 48px; margin-top: 18px; border: 1px solid #b9c8bb; border-radius: 15px; color: var(--green); background: white; font-weight: 750; }
.save-crop { background: var(--green); color: white; }
.crop-editor { padding-bottom: max(28px, env(safe-area-inset-bottom)); }