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. 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 ## 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. - 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 ## 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`. 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.screenOnEnabled(context) -> runCatching { ScreenOnRotationService.start(context) }
WallpaperStore.timedEnabled(context) -> WallpaperScheduler.scheduleNext(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 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 { fun connect(context: Context, serverUrl: String, apiKey: String): JSObject {
val normalized = normalizeServerUrl(serverUrl) val normalized = normalizeServerUrl(serverUrl)
require(apiKey.isNotBlank()) { "Bitte gib deinen Immich API-Key ein." } require(apiKey.isNotBlank()) { "Bitte gib deinen Immich API-Key ein." }
@@ -314,10 +319,10 @@ object ImmichClient {
return bounds.outWidth > 0 && bounds.outHeight > 0 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) val connectivity = context.getSystemService(ConnectivityManager::class.java)
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
return connectivity.activeNetworkInfo?.isConnected == true && !connectivity.isActiveNetworkMetered return connectivity.activeNetworkInfo?.isConnected == true && (allowMetered || !connectivity.isActiveNetworkMetered)
} }
@Synchronized @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 { @Command fun connectImmich(invoke: Invoke) = io.execute {
try { try {
val args = invoke.parseArgs(ImmichConnectArgs::class.java) 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") } } catch (error: Exception) { invoke.reject(error.message ?: "Immich-Verbindung fehlgeschlagen") }
} }
@Command fun disconnectImmich(invoke: Invoke) = io.execute { @Command fun disconnectImmich(invoke: Invoke) = io.execute {
ImmichPrefetchWorker.cancel(activity)
invoke.resolve(ImmichClient.disconnect(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.Color
import android.graphics.Matrix import android.graphics.Matrix
import android.graphics.Paint import android.graphics.Paint
import android.graphics.RectF
import android.media.ExifInterface
import android.os.Build
import android.util.Base64 import android.util.Base64
import android.util.LruCache import android.util.LruCache
import app.tauri.plugin.JSArray import app.tauri.plugin.JSArray
@@ -15,6 +18,7 @@ import app.tauri.plugin.JSObject
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
object WallpaperStore { object WallpaperStore {
@@ -22,11 +26,15 @@ object WallpaperStore {
private const val KEY_INDEX = "current_index" private const val KEY_INDEX = "current_index"
private const val KEY_CURRENT_ID = "current_entry_id" private const val KEY_CURRENT_ID = "current_entry_id"
private const val KEY_INTERVAL = "interval_minutes" 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 CROP_PREFIX = "crop_"
private const val INVALID_PREFIX = "invalid_" 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 RENDER_CACHE_MAX_BYTES = 128L * 1024 * 1024
private const val THUMBNAIL_CACHE_MAX_BYTES = 64L * 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 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) 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 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 { fun intervalMinutes(context: Context): Int {
val preferences = prefs(context) val preferences = prefs(context)
return if (preferences.contains(KEY_INTERVAL)) preferences.getInt(KEY_INTERVAL, 0) 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 screenOnEnabled(context: Context) = intervalMinutes(context) == -1
fun shuffle(context: Context) = prefs(context).getBoolean("shuffle", true) fun shuffle(context: Context) = prefs(context).getBoolean("shuffle", true)
fun lockOnly(context: Context) = prefs(context).getBoolean("lockScreenOnly", 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) { fun set(context: Context, name: String, value: Boolean) {
require(name in setOf("shuffle", "lockScreenOnly")) { "Unbekannte Einstellung" } require(name in setOf("shuffle", "lockScreenOnly", "allowMobileData", "prefetchImmich")) { "Unbekannte Einstellung" }
prefs(context).edit().putBoolean(name, value).apply() 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) { fun setInterval(context: Context, minutes: Int) {
@@ -117,15 +165,24 @@ object WallpaperStore {
fun state(context: Context, includePreviews: Boolean = true): JSObject { fun state(context: Context, includePreviews: Boolean = true): JSObject {
val items = entries(context) val items = entries(context)
val index = currentIndex(context, items) 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 previewItems = if (items.isEmpty()) emptyList() else {
val previewIndex = if (index < HOME_PREVIEW_LIMIT) index else 0 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 { return JSObject().apply {
put("imageCount", items.size) put("imageCount", items.size)
put("enabled", enabled(context)) put("enabled", enabled(context))
put("intervalMinutes", intervalMinutes(context)) put("intervalMinutes", intervalMinutes(context))
put("shuffle", shuffle(context)) put("shuffle", shuffle(context))
put("lockScreenOnly", lockOnly(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) put("currentId", items.getOrNull(index)?.id ?: JSONObject.NULL)
val ids = JSArray() val ids = JSArray()
previewItems.forEach { ids.put(it.id) } previewItems.forEach { ids.put(it.id) }
@@ -133,25 +190,39 @@ object WallpaperStore {
val previews = JSArray() val previews = JSArray()
if (includePreviews) previewItems.forEach { previews.put(thumbnailDataUrl(context, it.preview)) } if (includePreviews) previewItems.forEach { previews.put(thumbnailDataUrl(context, it.preview)) }
put("imageUrls", previews) 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 @Synchronized
fun gallery(context: Context, offset: Int, limit: Int): JSObject { fun gallery(context: Context, offset: Int, limit: Int): JSObject {
val items = entries(context) val items = entries(context)
val selectedIndex = currentIndex(context, items) val selectedId = items.getOrNull(currentIndex(context, items))?.id
val safeOffset = offset.coerceAtLeast(0).coerceAtMost(items.size) 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 safeLimit = limit.coerceIn(1, 100)
val page = JSArray() val page = JSArray()
items.drop(safeOffset).take(safeLimit).forEachIndexed { pageIndex, entry -> galleryItems.drop(safeOffset).take(safeLimit).forEach { entry ->
page.put(galleryImage(context, entry, safeOffset + pageIndex == selectedIndex)) 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 @Synchronized
fun imageIds(context: Context) = entries(context).map { it.id } 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 cropKey(id: String) = CROP_PREFIX + id
private fun crop(context: Context, entry: Entry): CropSettings { private fun crop(context: Context, entry: Entry): CropSettings {
val raw = prefs(context).getString(cropKey(entry.id), null) ?: return CropSettings() val raw = prefs(context).getString(cropKey(entry.id), null) ?: return CropSettings()
@@ -194,8 +265,16 @@ object WallpaperStore {
val json = JSONObject().apply { val json = JSONObject().apply {
put("mode", normalized.mode); put("zoom", normalized.zoom); put("x", normalized.x); put("y", normalized.y); put("rotation", normalized.rotation) 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() val selected = items.indexOf(entry) == currentIndex(context, items)
return galleryImage(context, entry, 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 @Synchronized
@@ -267,27 +346,42 @@ object WallpaperStore {
val metrics = context.resources.displayMetrics val metrics = context.resources.displayMetrics
val targetWidth = metrics.widthPixels.coerceAtLeast(1) val targetWidth = metrics.widthPixels.coerceAtLeast(1)
val targetHeight = metrics.heightPixels.coerceAtLeast(1) val targetHeight = metrics.heightPixels.coerceAtLeast(1)
val quarterTurn = crop.rotation == 90 || crop.rotation == 270 val rotated = if (crop.rotation == 0) source else Bitmap.createBitmap(
val rotatedWidth = if (quarterTurn) source.height else source.width source,
val rotatedHeight = if (quarterTurn) source.width else source.height 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 widthScale = targetWidth.toDouble() / rotatedWidth
val heightScale = targetHeight.toDouble() / rotatedHeight val heightScale = targetHeight.toDouble() / rotatedHeight
val baseScale = if (crop.mode == "contain") minOf(widthScale, heightScale) else maxOf(widthScale, heightScale) val baseScale = if (crop.mode == "contain") minOf(widthScale, heightScale) else maxOf(widthScale, heightScale)
val scale = (baseScale * crop.zoom).toFloat() val scale = (baseScale * crop.zoom).toFloat()
val baseWidth = rotatedWidth * baseScale
val baseHeight = rotatedHeight * baseScale
val scaledWidth = rotatedWidth * scale val scaledWidth = rotatedWidth * scale
val scaledHeight = rotatedHeight * scale val scaledHeight = rotatedHeight * scale
val left = ((targetWidth - scaledWidth) * crop.x).toFloat() val baseLeft = (targetWidth - baseWidth) * crop.x
val top = ((targetHeight - scaledHeight) * crop.y).toFloat() val baseTop = (targetHeight - baseHeight) * crop.y
return Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888).also { output -> 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) val canvas = Canvas(output)
canvas.drawColor(Color.BLACK) canvas.drawColor(Color.BLACK)
val matrix = Matrix().apply { canvas.drawBitmap(
postTranslate(-source.width / 2f, -source.height / 2f) rotated,
postRotate(crop.rotation.toFloat()) null,
postScale(scale, scale) RectF(left, top, left + scaledWidth, top + scaledHeight),
postTranslate(left + scaledWidth / 2f, top + scaledHeight / 2f) 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) val targetLongSide = maxOf(context.resources.displayMetrics.widthPixels, context.resources.displayMetrics.heightPixels).coerceAtLeast(1)
var sample = 1 var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= targetLongSide) sample *= 2 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 { 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 } thumbnailCache.get(cacheKey)?.let { return it }
val cached = File(thumbnailDirectory(context), "${digest(cacheKey)}.jpg") val cached = File(thumbnailDirectory(context), "${digest(cacheKey)}.jpg")
if (cached.isFile) { if (cached.isFile) {
@@ -315,7 +432,8 @@ object WallpaperStore {
BitmapFactory.decodeFile(file.absolutePath, bounds) BitmapFactory.decodeFile(file.absolutePath, bounds)
var sample = 1 var sample = 1
while (bounds.outWidth / sample > 360 || bounds.outHeight / sample > 480) sample *= 2 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 -> val result = ByteArrayOutputStream().use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 76, out); bitmap.recycle() bitmap.compress(Bitmap.CompressFormat.JPEG, 76, out); bitmap.recycle()
val bytes = out.toByteArray() val bytes = out.toByteArray()
@@ -327,7 +445,7 @@ object WallpaperStore {
} }
private fun removeThumbnail(file: File) { 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") 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.Local -> "${entry.file.lastModified()}:${entry.file.length()}"
is Entry.Immich -> entry.assetId 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") return File(renderedDirectory(context), "${digest(key)}.jpg")
} }
@@ -372,9 +490,18 @@ object WallpaperStore {
val items = entries(context) val items = entries(context)
if (items.isEmpty()) return false if (items.isEmpty()) return false
val previous = currentIndex(context, items) val previous = currentIndex(context, items)
val candidates = if (shuffle(context) && items.size > 1) { if (shuffle(context) && items.size > 1) {
items.indices.filter { it != previous }.shuffled() + previous val (seenIds, queue) = shuffleOrder(context, items, items[previous].id)
} else (1..items.size).map { (previous + it).mod(items.size) } 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) return applyCandidates(context, items, candidates, automatic)
} }
@@ -383,10 +510,21 @@ object WallpaperStore {
val items = entries(context) val items = entries(context)
val index = items.indexOfFirst { it.id == id } val index = items.indexOfFirst { it.id == id }
if (index < 0) return false 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>() val unavailableServers = mutableSetOf<String>()
for (index in candidates) { for (index in candidates) {
val entry = items[index] val entry = items[index]
@@ -401,7 +539,7 @@ object WallpaperStore {
val sourceFile = when (entry) { val sourceFile = when (entry) {
is Entry.Local -> entry.file is Entry.Local -> entry.file
is Entry.Immich -> { 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) val source = ImmichClient.cachedOriginal(context, entry.serverUrl, entry.assetId, allowNetwork)
if (source == null) { if (source == null) {
if (allowNetwork) unavailableServers.add(entry.serverUrl) if (allowNetwork) unavailableServers.add(entry.serverUrl)
@@ -429,7 +567,12 @@ object WallpaperStore {
val manager = WallpaperManager.getInstance(context) val manager = WallpaperManager.getInstance(context)
if (android.os.Build.VERSION.SDK_INT >= 24 && lockOnly(context)) manager.setBitmap(wallpaper, null, true, WallpaperManager.FLAG_LOCK) if (android.os.Build.VERSION.SDK_INT >= 24 && lockOnly(context)) manager.setBitmap(wallpaper, null, true, WallpaperManager.FLAG_LOCK)
else manager.setBitmap(wallpaper) 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 return true
} catch (_: Exception) { } catch (_: Exception) {
// Try the next usable entry without changing the current selection. // 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> { impl<R: Runtime> Wallpaper<R> {
fn demo() -> WallpaperState { 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 { WallpaperState {
image_count: 3, image_count: 3,
enabled: true, enabled: true,
interval_minutes: 30, interval_minutes: 30,
shuffle: true, shuffle: true,
lock_screen_only: true, lock_screen_only: true,
allow_mobile_data: false,
prefetch_immich: true,
current_index: 0, current_index: 0,
current_id: Some("demo-0".into()), current_id: Some("demo-0".into()),
image_ids: vec!["demo-0".into(), "demo-1".into(), "demo-2".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/waterfall.png".into(),
"/wallpapers/coast.png".into(), "/wallpapers/coast.png".into(),
], ],
image_previews,
} }
} }
pub fn get_state(&self) -> crate::Result<WallpaperState> { pub fn get_state(&self) -> crate::Result<WallpaperState> {
@@ -89,6 +106,8 @@ impl<R: Runtime> Wallpaper<R> {
"enabled" => state.enabled = payload.value, "enabled" => state.enabled = payload.value,
"shuffle" => state.shuffle = payload.value, "shuffle" => state.shuffle = payload.value,
"lockScreenOnly" => state.lock_screen_only = payload.value, "lockScreenOnly" => state.lock_screen_only = payload.value,
"allowMobileData" => state.allow_mobile_data = payload.value,
"prefetchImmich" => state.prefetch_immich = payload.value,
_ => {} _ => {}
}; };
Ok(state) Ok(state)
+4
View File
@@ -8,10 +8,14 @@ pub struct WallpaperState {
pub interval_minutes: i32, pub interval_minutes: i32,
pub shuffle: bool, pub shuffle: bool,
pub lock_screen_only: bool, pub lock_screen_only: bool,
pub allow_mobile_data: bool,
pub prefetch_immich: bool,
pub current_index: usize, pub current_index: usize,
pub current_id: Option<String>, pub current_id: Option<String>,
pub image_ids: Vec<String>, pub image_ids: Vec<String>,
pub image_urls: Vec<String>, pub image_urls: Vec<String>,
#[serde(default)]
pub image_previews: Vec<GalleryImage>,
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
@@ -15,6 +15,7 @@
android:launchMode="singleTask" android:launchMode="singleTask"
android:label="@string/main_activity_title" android:label="@string/main_activity_title"
android:name=".MainActivity" android:name=".MainActivity"
android:screenOrientation="portrait"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
+84 -28
View File
@@ -1,10 +1,11 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; 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 { 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 ImmichAlbum, type ImmichAsset, type ImmichConnection, type ImmichImportProgress, type WallpaperState } from "./native"; 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 { initialLanguage, languageNames, languages, translations, type Language } from "./i18n-local";
import { immichTranslations } from "./immich-i18n"; 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 }) { 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>; 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() { export default function App() {
const [state, setState] = useState(initial); 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 [busy, setBusy] = useState(false);
const [notice, setNotice] = useState(""); const [notice, setNotice] = useState("");
const [language, setLanguage] = useState<Language>(initialLanguage); const [language, setLanguage] = useState<Language>(initialLanguage);
@@ -46,6 +47,8 @@ export default function App() {
const [immichImportProgress, setImmichImportProgress] = useState<ImmichImportProgress | null>(null); const [immichImportProgress, setImmichImportProgress] = useState<ImmichImportProgress | null>(null);
const phonePreviewRef = useRef<HTMLDivElement>(null); const phonePreviewRef = useRef<HTMLDivElement>(null);
const phoneImageRef = useRef<HTMLImageElement>(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 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 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); const galleryScrollPosition = useRef(0);
@@ -62,6 +65,12 @@ export default function App() {
window.removeEventListener("focus", refresh); 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(() => { useEffect(() => {
getImmichConnection().then(connection => { getImmichConnection().then(connection => {
setImmichConnection(connection); setImmichConnection(connection);
@@ -90,30 +99,56 @@ export default function App() {
const settled = window.setTimeout(() => window.scrollTo(0, top), 120); const settled = window.setTimeout(() => window.scrollTo(0, top), 120);
return () => { window.cancelAnimationFrame(frame); window.clearTimeout(settled); }; return () => { window.cancelAnimationFrame(frame); window.clearTimeout(settled); };
}, [tab]); }, [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 t = translations[language];
const it = immichTranslations[language]; const it = immichTranslations[language];
const previewDate = useMemo(() => new Intl.DateTimeFormat(language, { weekday: "long", day: "numeric", month: "long" }).format(new Date()), [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 currentPreviewIndex = state.imageIds.indexOf(state.currentId ?? "");
const current = state.imageUrls[currentPreviewIndex] ?? "/wallpapers/alpine.png"; 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) { async function loadGallery(offset = 0, append = false) {
if (galleryLoadingRef.current) return;
galleryLoadingRef.current = true;
setGalleryLoading(true); setGalleryLoading(true);
try { try {
const page = await getGallery(offset, 48); const page = await getGallery(offset, 48);
setGallery(previous => append ? [...previous, ...page.items] : page.items); setGallery(previous => append ? [...previous, ...page.items] : page.items);
setGalleryTotal(page.total); setGalleryTotal(page.total);
} catch { setNotice(t.galleryLoadFailed); } } 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 })); setState(prev => ({ ...prev, [name]: value }));
try { try {
const saved = await setSetting(name, value); const saved = await setSetting(name, value);
setState(saved); setState(saved);
setNotice(t.settingSaved); 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) { async function updateInterval(minutes: number) {
@@ -136,6 +171,17 @@ export default function App() {
finally { setBusy(false); } 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) { function toggleSelected(id: string) {
setSelectedIds(previous => { setSelectedIds(previous => {
const next = new Set(previous); const next = new Set(previous);
@@ -167,18 +213,18 @@ export default function App() {
function openEditor(image: GalleryImage) { function openEditor(image: GalleryImage) {
galleryScrollPosition.current = window.scrollY; galleryScrollPosition.current = window.scrollY;
setEditing({ ...image, cropZoom: Math.max(1, image.cropZoom) }); setEditing({ ...image, cropZoom: Math.max(1, image.cropZoom) });
setTab("editor"); navigate("editor");
} }
function returnToGallery() { function returnToGallery() {
restoreGalleryScroll.current = true; restoreGalleryScroll.current = true;
setTab("gallery"); goBack("gallery");
} }
function openGallery() { function openGallery() {
galleryScrollPosition.current = 0; galleryScrollPosition.current = 0;
restoreGalleryScroll.current = false; restoreGalleryScroll.current = false;
setTab("gallery"); navigate("gallery");
void loadGallery(); void loadGallery();
} }
@@ -220,7 +266,7 @@ export default function App() {
} }
async function openImmich() { async function openImmich() {
setTab("immich"); navigate("immich");
setImmichSelected(new Set()); setImmichSelected(new Set());
setImmichLoading(true); setImmichLoading(true);
try { try {
@@ -253,7 +299,7 @@ export default function App() {
setState(await importImmichAssets([...immichSelected])); setState(await importImmichAssets([...immichSelected]));
setImmichSelected(new Set()); setImmichSelected(new Set());
setNotice(it.importSuccess); setNotice(it.importSuccess);
setTab("home"); navigate("home");
} catch (error) { setNotice(String(error).replace(/^Error:\s*/, "") || it.immichLoadFailed); } } catch (error) { setNotice(String(error).replace(/^Error:\s*/, "") || it.immichLoadFailed); }
finally { finally {
window.clearInterval(poll); window.clearInterval(poll);
@@ -268,6 +314,7 @@ export default function App() {
try { try {
const saved = await setImageCrop(editing); const saved = await setImageCrop(editing);
setGallery(previous => previous.map(image => image.id === saved.id ? saved : image)); 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); setEditing(saved);
setNotice(t.cropSaved); setNotice(t.cropSaved);
returnToGallery(); returnToGallery();
@@ -323,7 +370,7 @@ export default function App() {
} : previous); } : previous);
} }
function previewPosition(image: GalleryImage) { function previewPosition(image: ImagePreview) {
const { cropPositionX: x, cropPositionY: y } = image; const { cropPositionX: x, cropPositionY: y } = image;
if (image.cropRotation === 90) return `${y * 100}% ${(1 - x) * 100}%`; if (image.cropRotation === 90) return `${y * 100}% ${(1 - x) * 100}%`;
if (image.cropRotation === 180) return `${(1 - x) * 100}% ${(1 - y) * 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 const zoom = geometry.distance && gesture.distance
? Math.min(3, Math.max(1, gesture.zoom * geometry.distance / gesture.distance)) ? Math.min(3, Math.max(1, gesture.zoom * geometry.distance / gesture.distance))
: gesture.zoom; : gesture.zoom;
const previousLeft = (gesture.width - gesture.baseWidth * gesture.zoom) * gesture.x; const previousBaseLeft = (gesture.width - gesture.baseWidth) * gesture.x;
const previousTop = (gesture.height - gesture.baseHeight * gesture.zoom) * gesture.y; 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 imageX = (gesture.centerX - gesture.left - previousLeft) / gesture.zoom;
const imageY = (gesture.centerY - gesture.top - previousTop) / gesture.zoom; const imageY = (gesture.centerY - gesture.top - previousTop) / gesture.zoom;
const nextLeft = geometry.centerX - gesture.left - imageX * zoom; const nextLeft = geometry.centerX - gesture.left - imageX * zoom;
const nextTop = geometry.centerY - gesture.top - imageY * zoom; const nextTop = geometry.centerY - gesture.top - imageY * zoom;
const horizontalTravel = gesture.width - gesture.baseWidth * zoom; const horizontalTravel = gesture.width - gesture.baseWidth;
const verticalTravel = gesture.height - gesture.baseHeight * zoom; const verticalTravel = gesture.height - gesture.baseHeight;
const x = Math.abs(horizontalTravel) < 0.5 ? 0.5 : nextLeft / horizontalTravel; const nextBaseLeft = gesture.width / 2 + (nextLeft - gesture.width / 2) / zoom;
const y = Math.abs(verticalTravel) < 0.5 ? 0.5 : nextTop / verticalTravel; 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 ? { setEditing(previous => previous ? {
...previous, ...previous,
cropZoom: zoom, cropZoom: zoom,
@@ -393,10 +444,10 @@ export default function App() {
} }
return <main className="app-shell"> 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> : <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> : 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={() => setTab("settings")}><Settings /></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"> <div className="content">
{tab === "home" ? <> {tab === "home" ? <>
@@ -412,7 +463,7 @@ export default function App() {
<button className="primary" onClick={choose} disabled={busy}><Images />{busy ? t.pleaseWait : t.selectImages}</button> <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> <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> <p className="hint">{t.collectionHint}</p>
</section> </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> <div className="immich-title"><span><Cloud /></span><div><h3>{it.immich}</h3><p>{it.immichIntro}</p></div></div>
{immichConnection.configured ? <> {immichConnection.configured ? <>
<div className="immich-connected"><strong>{it.connectedAs} {immichConnection.userName}</strong><span>{immichConnection.serverUrl}</span></div> <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-open" onClick={openImmich} disabled={busy}><Images /> {it.openImmich} <ChevronRight /></button>
<button className="immich-disconnect" onClick={removeImmichConnection} disabled={busy}><Link2Off /> {it.disconnect}</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>} {!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>)} {!!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> : 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>} {!!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 && <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}> </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 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> <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> </div>
{notice && <button className="snackbar" onClick={() => setNotice("")}>{notice}</button>} {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>; </main>;
} }
+48
View File
@@ -22,6 +22,10 @@ export type ImmichText = {
importSuccess: string; importSuccess: string;
immichLoadFailed: string; immichLoadFailed: string;
noImmichPhotos: string; noImmichPhotos: string;
allowMobileData: string;
allowMobileDataHint: string;
prefetchImmich: string;
prefetchImmichHint: string;
}; };
export const immichTranslations: Record<Language, ImmichText> = { export const immichTranslations: Record<Language, ImmichText> = {
@@ -47,6 +51,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
importSuccess: "Immich-Bilder wurden zur Sammlung hinzugefügt", importSuccess: "Immich-Bilder wurden zur Sammlung hinzugefügt",
immichLoadFailed: "Immich-Bilder konnten nicht geladen werden", immichLoadFailed: "Immich-Bilder konnten nicht geladen werden",
noImmichPhotos: "Keine Bilder gefunden", 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: { en: {
immich: "Immich", immichIntro: "Import images from your own Immich server.", serverUrl: "Server URL", apiKey: "API key", 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", httpWarning: "HTTP is unencrypted. Use HTTPS outside your home network.", allPhotos: "All photos",
selectImmichPhotos: "Select images to import", importSelected: "Import selection", importing: "Importing images…", 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", 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: { fr: {
immich: "Immich", immichIntro: "Importez des images depuis votre propre serveur Immich.", serverUrl: "URL du serveur", apiKey: "Clé API", 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", 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…", 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", 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: { es: {
immich: "Immich", immichIntro: "Importa imágenes desde tu propio servidor Immich.", serverUrl: "URL del servidor", apiKey: "Clave API", 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", 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…", 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", 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: { it: {
immich: "Immich", immichIntro: "Importa immagini dal tuo server Immich.", serverUrl: "URL del server", apiKey: "Chiave API", 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", 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…", 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", 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: { nl: {
immich: "Immich", immichIntro: "Importeer afbeeldingen van je eigen Immich-server.", serverUrl: "Server-URL", apiKey: "API-sleutel", 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", 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…", 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", 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: { pl: {
immich: "Immich", immichIntro: "Importuj obrazy z własnego serwera Immich.", serverUrl: "Adres serwera", apiKey: "Klucz API", 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", 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…", 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", 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: { pt: {
immich: "Immich", immichIntro: "Importe imagens do seu próprio servidor Immich.", serverUrl: "URL do servidor", apiKey: "Chave de API", 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", 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…", 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", 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: { ja: {
immich: "Immich", immichIntro: "自分の Immich サーバーから画像を取り込みます。", serverUrl: "サーバー URL", apiKey: "API キー", immich: "Immich", immichIntro: "自分の Immich サーバーから画像を取り込みます。", serverUrl: "サーバー URL", apiKey: "API キー",
@@ -119,6 +155,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
httpWarning: "HTTP は暗号化されません。自宅ネットワーク外では HTTPS を使用してください。", allPhotos: "すべての写真", httpWarning: "HTTP は暗号化されません。自宅ネットワーク外では HTTPS を使用してください。", allPhotos: "すべての写真",
selectImmichPhotos: "取り込む画像を選択", importSelected: "選択項目を取り込む", importing: "画像を取り込み中…", selectImmichPhotos: "取り込む画像を選択", importSelected: "選択項目を取り込む", importing: "画像を取り込み中…",
importSuccess: "Immich の画像を取り込みました", immichLoadFailed: "Immich の画像を読み込めません", noImmichPhotos: "画像が見つかりません", importSuccess: "Immich の画像を取り込みました", immichLoadFailed: "Immich の画像を読み込めません", noImmichPhotos: "画像が見つかりません",
allowMobileData: "モバイルデータでのダウンロードを許可",
allowMobileDataHint: "オフの場合、自動切り替えは Wi-Fi 接続時のみ Immich の画像をダウンロードし、それ以外はスキップします。手動での切り替えは常にダウンロードします。",
prefetchImmich: "Immich画像を常に先読みする",
prefetchImmichHint: "Wi-Fi が利用可能になり次第、新しい Immich 画像をバックグラウンドでダウンロードします(モバイルデータは使用しません)。オフの場合、画像は必要なときのみダウンロードされます。",
}, },
ko: { ko: {
immich: "Immich", immichIntro: "내 Immich 서버에서 이미지를 가져옵니다.", serverUrl: "서버 URL", apiKey: "API 키", immich: "Immich", immichIntro: "내 Immich 서버에서 이미지를 가져옵니다.", serverUrl: "서버 URL", apiKey: "API 키",
@@ -128,6 +168,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
httpWarning: "HTTP는 암호화되지 않습니다. 홈 네트워크 밖에서는 HTTPS를 사용하세요.", allPhotos: "모든 사진", httpWarning: "HTTP는 암호화되지 않습니다. 홈 네트워크 밖에서는 HTTPS를 사용하세요.", allPhotos: "모든 사진",
selectImmichPhotos: "가져올 이미지를 선택하세요", importSelected: "선택 항목 가져오기", importing: "이미지 가져오는 중…", selectImmichPhotos: "가져올 이미지를 선택하세요", importSelected: "선택 항목 가져오기", importing: "이미지 가져오는 중…",
importSuccess: "Immich 이미지를 가져왔습니다", immichLoadFailed: "Immich 이미지를 불러올 수 없습니다", noImmichPhotos: "이미지를 찾을 수 없습니다", importSuccess: "Immich 이미지를 가져왔습니다", immichLoadFailed: "Immich 이미지를 불러올 수 없습니다", noImmichPhotos: "이미지를 찾을 수 없습니다",
allowMobileData: "모바일 데이터 다운로드 허용",
allowMobileDataHint: "꺼져 있으면 자동 변경 시 Wi-Fi에서만 Immich 이미지를 다운로드하고 그렇지 않으면 건너뜁니다. 수동 변경은 항상 다운로드합니다.",
prefetchImmich: "Immich 이미지 항상 미리 다운로드",
prefetchImmichHint: "Wi-Fi를 사용할 수 있게 되면 새 Immich 이미지를 백그라운드에서 다운로드합니다(모바일 데이터는 사용하지 않음). 꺼져 있으면 필요할 때만 이미지를 다운로드합니다.",
}, },
"zh-CN": { "zh-CN": {
immich: "Immich", immichIntro: "从你自己的 Immich 服务器导入图片。", serverUrl: "服务器 URL", apiKey: "API 密钥", immich: "Immich", immichIntro: "从你自己的 Immich 服务器导入图片。", serverUrl: "服务器 URL", apiKey: "API 密钥",
@@ -137,5 +181,9 @@ export const immichTranslations: Record<Language, ImmichText> = {
httpWarning: "HTTP 未加密。在家庭网络之外请使用 HTTPS。", allPhotos: "所有照片", httpWarning: "HTTP 未加密。在家庭网络之外请使用 HTTPS。", allPhotos: "所有照片",
selectImmichPhotos: "选择要导入的图片", importSelected: "导入所选图片", importing: "正在导入图片…", selectImmichPhotos: "选择要导入的图片", importSelected: "导入所选图片", importing: "正在导入图片…",
importSuccess: "已导入 Immich 图片", immichLoadFailed: "无法加载 Immich 图片", noImmichPhotos: "未找到图片", 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"; 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 = { export type WallpaperState = {
imageCount: number; imageCount: number;
enabled: boolean; enabled: boolean;
intervalMinutes: number; intervalMinutes: number;
shuffle: boolean; shuffle: boolean;
lockScreenOnly: boolean; lockScreenOnly: boolean;
allowMobileData: boolean;
prefetchImmich: boolean;
currentIndex: number; currentIndex: number;
currentId: string | null; currentId: string | null;
imageIds: string[]; imageIds: string[];
imageUrls: string[]; imageUrls: string[];
imagePreviews: ImagePreview[];
}; };
export type GalleryImage = { export type GalleryImage = ImagePreview & {
id: string;
url: string;
selected: boolean; selected: boolean;
cropMode: "cover" | "contain";
cropZoom: number;
cropPositionX: number;
cropPositionY: number;
cropRotation: number;
}; };
export type GalleryPage = { export type GalleryPage = {
@@ -63,15 +69,25 @@ export type ImmichImportProgress = {
}; };
const demoState: WallpaperState = { const demoState: WallpaperState = {
imageCount: 3, imageCount: 6,
enabled: true, enabled: true,
intervalMinutes: 30, intervalMinutes: 30,
shuffle: true, shuffle: true,
lockScreenOnly: true, lockScreenOnly: true,
allowMobileData: false,
prefetchImmich: true,
currentIndex: 0, currentIndex: 0,
currentId: "demo-0", currentId: "demo-0",
imageIds: ["demo-0", "demo-1", "demo-2"], imageIds: ["demo-0", "demo-1", "demo-2", "demo-3", "demo-4", "demo-5"],
imageUrls: ["/wallpapers/alpine.png", "/wallpapers/waterfall.png", "/wallpapers/coast.png"], 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; 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 }; const defaultCrop = { cropMode: "cover" as const, cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5, cropRotation: 0 };
let demoImmichConnection: ImmichConnection = { configured: false, serverUrl: "", userName: "" }; 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> { export async function getState(): Promise<WallpaperState> {
return inTauri() ? invoke<WallpaperState>("plugin:wallpaper|get_state") : demoState; return inTauri() ? invoke<WallpaperState>("plugin:wallpaper|get_state") : demoState;
} }
@@ -145,10 +166,11 @@ export async function setImageCrop(image: GalleryImage): Promise<GalleryImage> {
cropPositionY: image.cropPositionY, cropPositionY: image.cropPositionY,
cropRotation: image.cropRotation, cropRotation: image.cropRotation,
}); });
demoState.imagePreviews = demoState.imagePreviews.map(preview => preview.id === image.id ? { ...preview, ...image } : preview);
return { ...image }; 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 }; if (!inTauri()) return { ...demoState, [name]: value };
return invoke<WallpaperState>("plugin:wallpaper|set_setting", { name, value }); return invoke<WallpaperState>("plugin:wallpaper|set_setting", { name, value });
} }
@@ -166,6 +188,7 @@ export async function nextWallpaper(): Promise<WallpaperState> {
if (!inTauri()) { if (!inTauri()) {
if (demoState.imageUrls.length) demoState.currentIndex = (demoState.currentIndex + 1) % demoState.imageUrls.length; if (demoState.imageUrls.length) demoState.currentIndex = (demoState.currentIndex + 1) % demoState.imageUrls.length;
demoState.currentId = demoState.imageIds[demoState.currentIndex] ?? null; demoState.currentId = demoState.imageIds[demoState.currentIndex] ?? null;
orderDemoPreviewsFromCurrent();
return { ...demoState }; return { ...demoState };
} }
return invoke<WallpaperState>("plugin:wallpaper|next_wallpaper"); 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"); if (index < 0) throw new Error("Image not found");
demoState.currentIndex = index; demoState.currentIndex = index;
demoState.currentId = id; demoState.currentId = id;
orderDemoPreviewsFromCurrent();
return { ...demoState }; return { ...demoState };
} }
return invoke<WallpaperState>("plugin:wallpaper|apply_wallpaper", { id }); 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; } 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 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; } .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::-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 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 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 .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 span svg { width: 14px; } .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 { 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); } .empty-collection svg { width: 20px; color: var(--green); }
.hint { margin: 8px 0 12px; text-align: center; font-size: 11px; color: #7a837d; } .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; } 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); } .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 { 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 { 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 { 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; } .selection-toolbar button:disabled { opacity: .55; }
.delete-selection { color: #fff; background: #8e2929; } .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 { 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; } .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.current { border-color: #73a17c; }
.gallery-grid article.chosen { border-color: var(--green); box-shadow: 0 0 0 2px rgba(20,93,50,.14); } .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-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); } .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 { 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; } .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 p { max-width: 290px; margin: 8px 0 2px; font-size: 13px; line-height: 1.5; }
.gallery-empty .primary { max-width: 260px; } .gallery-empty .primary { max-width: 260px; }
.gallery-status { padding: 28px 0; text-align: center; color: #6d776f; font-size: 13px; } .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; } .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; } .save-crop { background: var(--green); color: white; }
.crop-editor { padding-bottom: max(28px, env(safe-area-inset-bottom)); } .crop-editor { padding-bottom: max(28px, env(safe-area-inset-bottom)); }