Compare commits

2 Commits
Author SHA1 Message Date
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
13 changed files with 384 additions and 30 deletions
+128
View File
@@ -0,0 +1,128 @@
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
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 }}
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@v4
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: Check release tag and app version
if: gitea.ref_type == 'tag'
run: |
APP_VERSION="$(node -p "require('./src-tauri/tauri.conf.json').version")"
if [ "${GITHUB_REF_NAME}" != "v${APP_VERSION}" ]; then
echo "Tag ${GITHUB_REF_NAME} does not match app version v${APP_VERSION}." >&2
exit 1
fi
- name: Validate signing secrets
run: |
for SECRET_NAME in \
ANDROID_KEYSTORE_BASE64 \
ANDROID_KEYSTORE_PASSWORD \
ANDROID_KEY_ALIAS \
ANDROID_KEY_PASSWORD
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: 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: Upload release APK
uses: actions/upload-artifact@v3
with:
name: WallpaperFlow-${{ gitea.ref_name }}-android
path: |
release/WallpaperFlow-release.apk
release/WallpaperFlow-release.apk.sha256
if-no-files-found: error
+30
View File
@@ -47,6 +47,36 @@ npm run tauri android dev
Der native Android-Code liegt als lokales Tauri-Plugin in `plugins/android`. Er wird beim Android-Build automatisch eingebunden.
## Signierte Android-Releases mit Gitea Actions
Der Workflow `.gitea/workflows/android-release.yml` baut bei Tags wie `v0.1.0`
eine signierte Universal-APK. Der Tag muss der Version in
`src-tauri/tauri.conf.json` mit vorangestelltem `v` entsprechen. Der Workflow
kann außerdem manuell über die Actions-Oberfläche gestartet werden.
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 stehen `WallpaperFlow-release.apk` und die zugehörige
SHA-256-Prüfsumme als Artefakt des Workflows bereit. Der Keystore darf nicht in
das Repository eingecheckt werden und muss dauerhaft gesichert bleiben, da
spätere Updates mit demselben Schlüssel signiert werden müssen.
Voraussetzung ist ein aktiver Gitea-Actions-Runner mit dem Label
`ubuntu-latest`, Netzwerkzugriff auf npm, Rust und die Android-SDK-Server sowie
ausreichend Speicher für Android SDK, NDK und Rust-Buildartefakte.
## Wichtige Android-Hinweise
- Android darf ungenaue Alarme im Ruhemodus verzögern. Der nächste Wechsel wird ausgeführt, sobald das Gerät wieder aktiv ist.
@@ -11,5 +11,6 @@ class BootReceiver : BroadcastReceiver() {
WallpaperStore.screenOnEnabled(context) -> runCatching { ScreenOnRotationService.start(context) }
WallpaperStore.timedEnabled(context) -> WallpaperScheduler.scheduleNext(context)
}
if (ImmichClient.configured(context) && WallpaperStore.prefetchImmich(context)) ImmichPrefetchWorker.enqueue(context)
}
}
@@ -119,6 +119,11 @@ object ImmichClient {
fun connection(context: Context) = connectionObject(context)
fun configured(context: Context): Boolean {
val preferences = prefs(context)
return preferences.getString(KEY_SERVER_URL, "").orEmpty().isNotBlank() && !decryptApiKey(context).isNullOrBlank()
}
fun connect(context: Context, serverUrl: String, apiKey: String): JSObject {
val normalized = normalizeServerUrl(serverUrl)
require(apiKey.isNotBlank()) { "Bitte gib deinen Immich API-Key ein." }
@@ -314,10 +319,10 @@ object ImmichClient {
return bounds.outWidth > 0 && bounds.outHeight > 0
}
fun automaticDownloadsAllowed(context: Context): Boolean {
fun automaticDownloadsAllowed(context: Context, allowMetered: Boolean): Boolean {
val connectivity = context.getSystemService(ConnectivityManager::class.java)
@Suppress("DEPRECATION")
return connectivity.activeNetworkInfo?.isConnected == true && !connectivity.isActiveNetworkMetered
return connectivity.activeNetworkInfo?.isConnected == true && (allowMetered || !connectivity.isActiveNetworkMetered)
}
@Synchronized
@@ -0,0 +1,37 @@
package de.wechselbild.wallpaper
import android.content.Context
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import java.util.concurrent.TimeUnit
class ImmichPrefetchWorker(context: Context, parameters: WorkerParameters) : Worker(context, parameters) {
override fun doWork(): Result {
WallpaperStore.prefetchImmichOriginals(applicationContext)
return Result.success()
}
companion object {
private const val WORK_NAME = "immich-wifi-prefetch"
fun enqueue(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.setRequiresBatteryNotLow(true)
.build()
val request = PeriodicWorkRequestBuilder<ImmichPrefetchWorker>(6, TimeUnit.HOURS)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
}
fun cancel(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
}
}
}
@@ -170,11 +170,14 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
@Command fun connectImmich(invoke: Invoke) = io.execute {
try {
val args = invoke.parseArgs(ImmichConnectArgs::class.java)
invoke.resolve(ImmichClient.connect(activity, args.serverUrl, args.apiKey))
val result = ImmichClient.connect(activity, args.serverUrl, args.apiKey)
if (WallpaperStore.prefetchImmich(activity)) ImmichPrefetchWorker.enqueue(activity)
invoke.resolve(result)
} catch (error: Exception) { invoke.reject(error.message ?: "Immich-Verbindung fehlgeschlagen") }
}
@Command fun disconnectImmich(invoke: Invoke) = io.execute {
ImmichPrefetchWorker.cancel(activity)
invoke.resolve(ImmichClient.disconnect(activity))
}
@@ -22,6 +22,7 @@ object WallpaperStore {
private const val KEY_INDEX = "current_index"
private const val KEY_CURRENT_ID = "current_entry_id"
private const val KEY_INTERVAL = "interval_minutes"
private const val KEY_SHUFFLE_SEEN_IDS = "shuffle_seen_ids"
private const val CROP_PREFIX = "crop_"
private const val INVALID_PREFIX = "invalid_"
private const val RENDER_CACHE_MAX_BYTES = 128L * 1024 * 1024
@@ -102,10 +103,18 @@ object WallpaperStore {
fun screenOnEnabled(context: Context) = intervalMinutes(context) == -1
fun shuffle(context: Context) = prefs(context).getBoolean("shuffle", true)
fun lockOnly(context: Context) = prefs(context).getBoolean("lockScreenOnly", true)
fun allowMobileData(context: Context) = prefs(context).getBoolean("allowMobileData", false)
fun prefetchImmich(context: Context) = prefs(context).getBoolean("prefetchImmich", true)
fun set(context: Context, name: String, value: Boolean) {
require(name in setOf("shuffle", "lockScreenOnly")) { "Unbekannte Einstellung" }
prefs(context).edit().putBoolean(name, value).apply()
require(name in setOf("shuffle", "lockScreenOnly", "allowMobileData", "prefetchImmich")) { "Unbekannte Einstellung" }
val editor = prefs(context).edit().putBoolean(name, value)
if (name == "shuffle") editor.remove(KEY_SHUFFLE_SEEN_IDS)
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) {
@@ -125,6 +134,8 @@ object WallpaperStore {
put("intervalMinutes", intervalMinutes(context))
put("shuffle", shuffle(context))
put("lockScreenOnly", lockOnly(context))
put("allowMobileData", allowMobileData(context))
put("prefetchImmich", prefetchImmich(context))
put("currentIndex", previewIndex)
put("currentId", items.getOrNull(index)?.id ?: JSONObject.NULL)
val ids = JSArray()
@@ -152,6 +163,16 @@ object WallpaperStore {
@Synchronized
fun imageIds(context: Context) = entries(context).map { it.id }
fun prefetchImmichOriginals(context: Context) {
val unavailableServers = mutableSetOf<String>()
entries(context).filterIsInstance<Entry.Immich>().forEach { entry ->
if (entry.serverUrl in unavailableServers) return@forEach
if (ImmichClient.cachedOriginal(context, entry.serverUrl, entry.assetId, allowNetwork = true) == null) {
unavailableServers.add(entry.serverUrl)
}
}
}
private fun cropKey(id: String) = CROP_PREFIX + id
private fun crop(context: Context, entry: Entry): CropSettings {
val raw = prefs(context).getString(cropKey(entry.id), null) ?: return CropSettings()
@@ -372,9 +393,21 @@ object WallpaperStore {
val items = entries(context)
if (items.isEmpty()) return false
val previous = currentIndex(context, items)
val candidates = if (shuffle(context) && items.size > 1) {
items.indices.filter { it != previous }.shuffled() + previous
} else (1..items.size).map { (previous + it).mod(items.size) }
if (shuffle(context) && items.size > 1) {
val availableIds = items.mapTo(mutableSetOf()) { it.id }
val seenIds = prefs(context).getStringSet(KEY_SHUFFLE_SEEN_IDS, emptySet())
.orEmpty().filterTo(mutableSetOf()) { it in availableIds }
seenIds.add(items[previous].id)
var candidates = items.indices.filter { items[it].id !in seenIds }.shuffled()
if (candidates.isEmpty()) {
seenIds.clear()
seenIds.add(items[previous].id)
candidates = items.indices.filter { it != previous }.shuffled()
}
return applyCandidates(context, items, candidates, automatic, seenIds)
}
val candidates = (1..items.size).map { (previous + it).mod(items.size) }
return applyCandidates(context, items, candidates, automatic)
}
@@ -383,10 +416,21 @@ object WallpaperStore {
val items = entries(context)
val index = items.indexOfFirst { it.id == id }
if (index < 0) return false
return applyCandidates(context, items, listOf(index), automatic = false)
val seenIds = if (shuffle(context)) {
val availableIds = items.mapTo(mutableSetOf()) { it.id }
prefs(context).getStringSet(KEY_SHUFFLE_SEEN_IDS, emptySet())
.orEmpty().filterTo(mutableSetOf()) { it in availableIds }
} else null
return applyCandidates(context, items, listOf(index), automatic = false, seenIds)
}
private fun applyCandidates(context: Context, items: List<Entry>, candidates: List<Int>, automatic: Boolean): Boolean {
private fun applyCandidates(
context: Context,
items: List<Entry>,
candidates: List<Int>,
automatic: Boolean,
shuffleSeenIds: Set<String>? = null,
): Boolean {
val unavailableServers = mutableSetOf<String>()
for (index in candidates) {
val entry = items[index]
@@ -401,7 +445,7 @@ object WallpaperStore {
val sourceFile = when (entry) {
is Entry.Local -> entry.file
is Entry.Immich -> {
val allowNetwork = entry.serverUrl !in unavailableServers && (!automatic || ImmichClient.automaticDownloadsAllowed(context))
val allowNetwork = entry.serverUrl !in unavailableServers && (!automatic || ImmichClient.automaticDownloadsAllowed(context, allowMobileData(context)))
val source = ImmichClient.cachedOriginal(context, entry.serverUrl, entry.assetId, allowNetwork)
if (source == null) {
if (allowNetwork) unavailableServers.add(entry.serverUrl)
@@ -429,7 +473,12 @@ object WallpaperStore {
val manager = WallpaperManager.getInstance(context)
if (android.os.Build.VERSION.SDK_INT >= 24 && lockOnly(context)) manager.setBitmap(wallpaper, null, true, WallpaperManager.FLAG_LOCK)
else manager.setBitmap(wallpaper)
prefs(context).edit().remove(INVALID_PREFIX + entry.id).putString(KEY_CURRENT_ID, entry.id).putInt(KEY_INDEX, index).apply()
val editor = prefs(context).edit()
.remove(INVALID_PREFIX + entry.id)
.putString(KEY_CURRENT_ID, entry.id)
.putInt(KEY_INDEX, index)
if (shuffleSeenIds != null) editor.putStringSet(KEY_SHUFFLE_SEEN_IDS, shuffleSeenIds + entry.id)
editor.apply()
return true
} catch (_: Exception) {
// Try the next usable entry without changing the current selection.
+4
View File
@@ -21,6 +21,8 @@ impl<R: Runtime> Wallpaper<R> {
interval_minutes: 30,
shuffle: true,
lock_screen_only: true,
allow_mobile_data: false,
prefetch_immich: true,
current_index: 0,
current_id: Some("demo-0".into()),
image_ids: vec!["demo-0".into(), "demo-1".into(), "demo-2".into()],
@@ -89,6 +91,8 @@ impl<R: Runtime> Wallpaper<R> {
"enabled" => state.enabled = payload.value,
"shuffle" => state.shuffle = payload.value,
"lockScreenOnly" => state.lock_screen_only = payload.value,
"allowMobileData" => state.allow_mobile_data = payload.value,
"prefetchImmich" => state.prefetch_immich = payload.value,
_ => {}
};
Ok(state)
+2
View File
@@ -8,6 +8,8 @@ pub struct WallpaperState {
pub interval_minutes: i32,
pub shuffle: bool,
pub lock_screen_only: bool,
pub allow_mobile_data: bool,
pub prefetch_immich: bool,
pub current_index: usize,
pub current_id: Option<String>,
pub image_ids: Vec<String>,
+59 -17
View File
@@ -1,10 +1,11 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { ArrowLeft, Check, ChevronRight, Cloud, CloudDownload, Crop, Home, Images, Languages, Link2Off, LockKeyhole, Plus, RotateCcw, RotateCw, Save, Server, Settings, Shuffle, Smartphone, Sparkles, Trash2 } from "lucide-react";
import { 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 { initialLanguage, languageNames, languages, translations, type Language } from "./i18n-local";
import { immichTranslations } from "./immich-i18n";
const initial: WallpaperState = { imageCount: 0, enabled: false, intervalMinutes: 0, shuffle: true, lockScreenOnly: true, currentIndex: 0, currentId: null, imageIds: [], imageUrls: [] };
const initial: WallpaperState = { imageCount: 0, enabled: false, intervalMinutes: 0, shuffle: true, lockScreenOnly: true, allowMobileData: false, prefetchImmich: true, currentIndex: 0, currentId: null, imageIds: [], imageUrls: [] };
type Tab = "home" | "settings" | "gallery" | "editor" | "immich";
function Switch({ checked, onChange, label }: { checked: boolean; onChange: (value: boolean) => void; label: string }) {
return <button className={`switch ${checked ? "on" : ""}`} role="switch" aria-checked={checked} aria-label={label} onClick={() => onChange(!checked)}><span /></button>;
@@ -24,7 +25,7 @@ function IntervalRow({ value, onChange, label, paused, screenOn, everyMinutes }:
export default function App() {
const [state, setState] = useState(initial);
const [tab, setTab] = useState<"home" | "settings" | "gallery" | "editor" | "immich">("home");
const [tab, setTab] = useState<Tab>("home");
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState("");
const [language, setLanguage] = useState<Language>(initialLanguage);
@@ -46,6 +47,8 @@ export default function App() {
const [immichImportProgress, setImmichImportProgress] = useState<ImmichImportProgress | null>(null);
const phonePreviewRef = useRef<HTMLDivElement>(null);
const phoneImageRef = useRef<HTMLImageElement>(null);
const galleryLoadMoreRef = useRef<HTMLDivElement>(null);
const galleryLoadingRef = useRef(false);
const previewPointers = useRef(new Map<number, { x: number; y: number }>());
const previewGesture = useRef<null | { centerX: number; centerY: number; distance: number; x: number; y: number; zoom: number; left: number; top: number; width: number; height: number; baseWidth: number; baseHeight: number }>(null);
const galleryScrollPosition = useRef(0);
@@ -62,6 +65,12 @@ export default function App() {
window.removeEventListener("focus", refresh);
};
}, []);
useEffect(() => {
window.history.replaceState({ ...window.history.state, wallpaperFlowTab: "home" }, "");
const onPopState = (event: PopStateEvent) => setTab((event.state?.wallpaperFlowTab as Tab | undefined) ?? "home");
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
useEffect(() => {
getImmichConnection().then(connection => {
setImmichConnection(connection);
@@ -90,6 +99,15 @@ export default function App() {
const settled = window.setTimeout(() => window.scrollTo(0, top), 120);
return () => { window.cancelAnimationFrame(frame); window.clearTimeout(settled); };
}, [tab]);
useEffect(() => {
const target = galleryLoadMoreRef.current;
if (tab !== "gallery" || !target || galleryLoading || gallery.length >= galleryTotal) return;
const observer = new IntersectionObserver(entries => {
if (entries[0]?.isIntersecting) void loadGallery(gallery.length, true);
}, { rootMargin: "300px" });
observer.observe(target);
return () => observer.disconnect();
}, [tab, gallery.length, galleryTotal, galleryLoading]);
const t = translations[language];
const it = immichTranslations[language];
const previewDate = useMemo(() => new Intl.DateTimeFormat(language, { weekday: "long", day: "numeric", month: "long" }).format(new Date()), [language]);
@@ -98,22 +116,31 @@ export default function App() {
const photos = useMemo(() => state.imageUrls, [state.imageUrls]);
async function loadGallery(offset = 0, append = false) {
if (galleryLoadingRef.current) return;
galleryLoadingRef.current = true;
setGalleryLoading(true);
try {
const page = await getGallery(offset, 48);
setGallery(previous => append ? [...previous, ...page.items] : page.items);
setGalleryTotal(page.total);
} catch { setNotice(t.galleryLoadFailed); }
finally { setGalleryLoading(false); }
finally {
galleryLoadingRef.current = false;
setGalleryLoading(false);
}
}
async function update(name: "shuffle" | "lockScreenOnly", value: boolean) {
async function update(name: "shuffle" | "lockScreenOnly" | "allowMobileData" | "prefetchImmich", value: boolean) {
const previous = state[name];
setState(prev => ({ ...prev, [name]: value }));
try {
const saved = await setSetting(name, value);
setState(saved);
setNotice(t.settingSaved);
} catch { setNotice(t.androidOnly); }
} catch (error) {
setState(prev => ({ ...prev, [name]: previous }));
setNotice(String(error).replace(/^Error:\s*/, "") || t.androidOnly);
}
}
async function updateInterval(minutes: number) {
@@ -136,6 +163,17 @@ export default function App() {
finally { setBusy(false); }
}
function navigate(nextTab: Tab) {
if (tab === nextTab) return;
window.history.pushState({ ...window.history.state, wallpaperFlowTab: nextTab }, "");
setTab(nextTab);
}
function goBack(fallback: Tab) {
if (window.history.state?.wallpaperFlowTab === tab) window.history.back();
else setTab(fallback);
}
function toggleSelected(id: string) {
setSelectedIds(previous => {
const next = new Set(previous);
@@ -167,18 +205,18 @@ export default function App() {
function openEditor(image: GalleryImage) {
galleryScrollPosition.current = window.scrollY;
setEditing({ ...image, cropZoom: Math.max(1, image.cropZoom) });
setTab("editor");
navigate("editor");
}
function returnToGallery() {
restoreGalleryScroll.current = true;
setTab("gallery");
goBack("gallery");
}
function openGallery() {
galleryScrollPosition.current = 0;
restoreGalleryScroll.current = false;
setTab("gallery");
navigate("gallery");
void loadGallery();
}
@@ -220,7 +258,7 @@ export default function App() {
}
async function openImmich() {
setTab("immich");
navigate("immich");
setImmichSelected(new Set());
setImmichLoading(true);
try {
@@ -253,7 +291,7 @@ export default function App() {
setState(await importImmichAssets([...immichSelected]));
setImmichSelected(new Set());
setNotice(it.importSuccess);
setTab("home");
navigate("home");
} catch (error) { setNotice(String(error).replace(/^Error:\s*/, "") || it.immichLoadFailed); }
finally {
window.clearInterval(poll);
@@ -393,10 +431,10 @@ export default function App() {
}
return <main className="app-shell">
{tab === "gallery" ? <header className="gallery-header"><button className="icon-button" aria-label={t.back} onClick={() => { setSelectedIds(new Set()); setTab("home"); }}><ArrowLeft /></button><div><h1>{selectedIds.size ? `${selectedIds.size} ${t.selected}` : t.collection}</h1><p>{galleryTotal} {galleryTotal === 1 ? t.image : t.images}</p></div>{selectedIds.size ? <button className="icon-button delete-selection" aria-label={t.deleteSelected} onClick={removeSelected} disabled={busy}><Trash2 /></button> : <button className="icon-button" aria-label={t.addImages} onClick={choose} disabled={busy}><Plus /></button>}</header> : tab === "editor" ?
{tab === "gallery" ? <header className="gallery-header"><button className="icon-button" aria-label={t.back} onClick={() => { setSelectedIds(new Set()); goBack("home"); }}><ArrowLeft /></button><div><h1>{selectedIds.size ? `${selectedIds.size} ${t.selected}` : t.collection}</h1><p>{galleryTotal} {galleryTotal === 1 ? t.image : t.images}</p></div>{selectedIds.size ? <button className="icon-button delete-selection" aria-label={t.deleteSelected} onClick={removeSelected} disabled={busy}><Trash2 /></button> : <button className="icon-button" aria-label={t.addImages} onClick={choose} disabled={busy}><Plus /></button>}</header> : tab === "editor" ?
<header className="gallery-header"><button className="icon-button" aria-label={t.backToGallery} onClick={returnToGallery}><ArrowLeft /></button><div><h1>{t.editImage}</h1><p>{t.savedForImage}</p></div><button className="icon-button save-crop" aria-label={t.saveCrop} onClick={saveCrop} disabled={busy}><Save /></button></header> :
tab === "immich" ? <header className="gallery-header immich-header"><button className="icon-button" aria-label={t.back} onClick={() => { setImmichSelected(new Set()); setTab("settings"); }}><ArrowLeft /></button><div><h1>{it.immich}</h1><p>{immichSelected.size ? `${immichSelected.size} ${t.selected}` : it.selectImmichPhotos}</p></div>{immichSelected.size > 0 && <button className="icon-button import-selection" aria-label={it.importSelected} onClick={importFromImmich} disabled={busy}><CloudDownload /></button>}</header> :
<header className="app-header"><div className="brand"><img className="brand-logo" src="/app-icon.svg" alt="" /><div className="brand-copy"><h1>WallpaperFlow</h1><p>{state.enabled ? t.automaticActive : t.automaticPaused}</p></div></div><button className={`icon-button settings-shortcut ${tab === "settings" ? "selected" : ""}`} aria-label={t.settings} aria-current={tab === "settings" ? "page" : undefined} onClick={() => setTab("settings")}><Settings /></button></header>}
tab === "immich" ? <header className="gallery-header immich-header"><button className="icon-button" aria-label={t.back} onClick={() => { setImmichSelected(new Set()); goBack("settings"); }}><ArrowLeft /></button><div><h1>{it.immich}</h1><p>{immichSelected.size ? `${immichSelected.size} ${t.selected}` : it.selectImmichPhotos}</p></div>{immichSelected.size > 0 && <button className="icon-button import-selection" aria-label={it.importSelected} onClick={importFromImmich} disabled={busy}><CloudDownload /></button>}</header> :
<header className="app-header"><div className="brand"><img className="brand-logo" src="/app-icon.svg" alt="" /><div className="brand-copy"><h1>WallpaperFlow</h1><p>{state.enabled ? t.automaticActive : t.automaticPaused}</p></div></div><button className={`icon-button settings-shortcut ${tab === "settings" ? "selected" : ""}`} aria-label={t.settings} aria-current={tab === "settings" ? "page" : undefined} onClick={() => navigate("settings")}><Settings /></button></header>}
<div className="content">
{tab === "home" ? <>
@@ -425,6 +463,10 @@ export default function App() {
<div className="immich-title"><span><Cloud /></span><div><h3>{it.immich}</h3><p>{it.immichIntro}</p></div></div>
{immichConnection.configured ? <>
<div className="immich-connected"><strong>{it.connectedAs} {immichConnection.userName}</strong><span>{immichConnection.serverUrl}</span></div>
<SettingRow icon={<Download />} label={it.prefetchImmich} value={state.prefetchImmich} onChange={v => update("prefetchImmich", v)} />
<p className="immich-help">{it.prefetchImmichHint}</p>
<SettingRow icon={<Wifi />} label={it.allowMobileData} value={state.allowMobileData} onChange={v => update("allowMobileData", v)} />
<p className="immich-help">{it.allowMobileDataHint}</p>
<button className="immich-open" onClick={openImmich} disabled={busy}><Images /> {it.openImmich} <ChevronRight /></button>
<button className="immich-disconnect" onClick={removeImmichConnection} disabled={busy}><Link2Off /> {it.disconnect}</button>
</> : <>
@@ -447,9 +489,9 @@ export default function App() {
</section> : tab === "gallery" ?
<section className="gallery-page" aria-label={t.myImages}>
{!!galleryTotal && <div className="selection-toolbar"><span>{selectedIds.size ? `${selectedIds.size} ${t.selected}` : t.selectImagesToDelete}</span><button onClick={selectAll} disabled={busy}>{selectedIds.size === galleryTotal ? t.clearSelection : t.selectAll}</button></div>}
{gallery.length ? <div className="gallery-grid">{gallery.map((image, index) => <article className={`${image.selected ? "current " : ""}${selectedIds.has(image.id) ? "chosen" : ""}`} key={image.id}><button className="gallery-image-button" aria-label={`${selectedIds.has(image.id) ? t.deselectImage : t.selectImage} ${index + 1}`} aria-pressed={selectedIds.has(image.id)} onClick={() => toggleSelected(image.id)}><img src={image.url} alt={`${t.image} ${index + 1}`} /></button>{image.selected && <span className="current-badge"><Check /> {t.current}</span>}<span className="selection-check"><Check /></span><button className="edit-button" aria-label={`${t.adjustImage} ${index + 1}`} onClick={() => openEditor(image)}><Crop /></button></article>)}</div> : !galleryLoading && <div className="gallery-empty"><Images /><h2>{t.emptyCollection}</h2><p>{t.emptyCollectionText}</p><button className="primary" onClick={choose}><Plus /> {t.addImages}</button></div>}
{gallery.length ? <div className="gallery-grid">{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}`} loading="lazy" decoding="async" /></button>{image.selected && <span className="current-badge"><Check /> {t.current}</span>}<span className="selection-check"><Check /></span><button className="edit-button" aria-label={`${t.adjustImage} ${index + 1}`} onClick={() => openEditor(image)}><Crop /></button></article>)}</div> : !galleryLoading && <div className="gallery-empty"><Images /><h2>{t.emptyCollection}</h2><p>{t.emptyCollectionText}</p><button className="primary" onClick={choose}><Plus /> {t.addImages}</button></div>}
{galleryLoading && <p className="gallery-status">{t.galleryLoading}</p>}
{!galleryLoading && gallery.length < galleryTotal && <button className="load-more" onClick={() => loadGallery(gallery.length, true)}>{t.loadMore}</button>}
{gallery.length < galleryTotal && <div ref={galleryLoadMoreRef} className="gallery-load-sentinel" aria-hidden="true" />}
</section> : editing && <section className="crop-editor" aria-label={t.editCrop}>
<div ref={phonePreviewRef} className="phone-preview interactive" aria-label={t.gestureLabel} onPointerDown={beginPreviewGesture} onPointerMove={movePreviewGesture} onPointerUp={endPreviewGesture} onPointerCancel={endPreviewGesture}>
<div className={`preview-image-frame ${editing.cropRotation === 90 || editing.cropRotation === 270 ? "quarter-turn" : ""}`} style={{ transform: `rotate(${editing.cropRotation}deg) scale(${editing.cropZoom})` }}><img ref={phoneImageRef} src={editing.url} alt={t.cropPreview} draggable={false} style={{ objectFit: editing.cropMode, objectPosition: previewPosition(editing) }} /></div>
@@ -468,6 +510,6 @@ export default function App() {
</div>
{notice && <button className="snackbar" onClick={() => setNotice("")}>{notice}</button>}
{tab !== "gallery" && tab !== "editor" && tab !== "immich" && <nav><button className={tab === "home" ? "active" : ""} onClick={() => setTab("home")}><Home /><span>{t.home}</span></button><button className={tab === "settings" ? "active" : ""} onClick={() => setTab("settings")}><Settings /><span>{t.settings}</span></button></nav>}
{tab !== "gallery" && tab !== "editor" && tab !== "immich" && <nav><button className={tab === "home" ? "active" : ""} onClick={() => navigate("home")}><Home /><span>{t.home}</span></button><button className={tab === "settings" ? "active" : ""} onClick={() => navigate("settings")}><Settings /><span>{t.settings}</span></button></nav>}
</main>;
}
+48
View File
@@ -22,6 +22,10 @@ export type ImmichText = {
importSuccess: string;
immichLoadFailed: string;
noImmichPhotos: string;
allowMobileData: string;
allowMobileDataHint: string;
prefetchImmich: string;
prefetchImmichHint: string;
};
export const immichTranslations: Record<Language, ImmichText> = {
@@ -47,6 +51,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
importSuccess: "Immich-Bilder wurden zur Sammlung hinzugefügt",
immichLoadFailed: "Immich-Bilder konnten nicht geladen werden",
noImmichPhotos: "Keine Bilder gefunden",
allowMobileData: "Auch über mobile Daten laden",
allowMobileDataHint: "Ist das ausgeschaltet, lädt der automatische Wechsel Immich-Bilder nur über WLAN und überspringt sie sonst. Manuelles Wechseln lädt immer.",
prefetchImmich: "Immich-Bilder immer vorladen",
prefetchImmichHint: "Lädt neue Immich-Bilder automatisch im Hintergrund herunter, sobald WLAN verfügbar ist nie über mobile Daten. Ausgeschaltet werden Bilder nur bei Bedarf geladen.",
},
en: {
immich: "Immich", immichIntro: "Import images from your own Immich server.", serverUrl: "Server URL", apiKey: "API key",
@@ -56,6 +64,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
httpWarning: "HTTP is unencrypted. Use HTTPS outside your home network.", allPhotos: "All photos",
selectImmichPhotos: "Select images to import", importSelected: "Import selection", importing: "Importing images…",
importSuccess: "Immich images imported", immichLoadFailed: "Could not load Immich images", noImmichPhotos: "No images found",
allowMobileData: "Allow downloads over mobile data",
allowMobileDataHint: "When off, automatic rotation only downloads Immich images over Wi-Fi and skips them otherwise. Manually changing the wallpaper always downloads.",
prefetchImmich: "Always prefetch Immich images",
prefetchImmichHint: "Downloads new Immich images in the background as soon as Wi-Fi is available never over mobile data. When off, images are only downloaded when needed.",
},
fr: {
immich: "Immich", immichIntro: "Importez des images depuis votre propre serveur Immich.", serverUrl: "URL du serveur", apiKey: "Clé API",
@@ -65,6 +77,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
httpWarning: "HTTP nest pas chiffré. Utilisez HTTPS hors de votre réseau domestique.", allPhotos: "Toutes les photos",
selectImmichPhotos: "Sélectionnez les images à importer", importSelected: "Importer la sélection", importing: "Importation des images…",
importSuccess: "Images Immich importées", immichLoadFailed: "Impossible de charger les images Immich", noImmichPhotos: "Aucune image trouvée",
allowMobileData: "Autoriser les données mobiles",
allowMobileDataHint: "Désactivé, la rotation automatique ne télécharge les images Immich que via le Wi-Fi et les ignore sinon. Un changement manuel télécharge toujours.",
prefetchImmich: "Toujours précharger les images Immich",
prefetchImmichHint: "Télécharge les nouvelles images Immich en arrière-plan dès que le Wi-Fi est disponible jamais via les données mobiles. Désactivé, les images ne sont téléchargées quen cas de besoin.",
},
es: {
immich: "Immich", immichIntro: "Importa imágenes desde tu propio servidor Immich.", serverUrl: "URL del servidor", apiKey: "Clave API",
@@ -74,6 +90,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
httpWarning: "HTTP no está cifrado. Usa HTTPS fuera de tu red doméstica.", allPhotos: "Todas las fotos",
selectImmichPhotos: "Selecciona imágenes para importar", importSelected: "Importar selección", importing: "Importando imágenes…",
importSuccess: "Imágenes de Immich importadas", immichLoadFailed: "No se pudieron cargar las imágenes de Immich", noImmichPhotos: "No se encontraron imágenes",
allowMobileData: "Permitir datos móviles",
allowMobileDataHint: "Si está desactivado, la rotación automática solo descarga imágenes de Immich por Wi-Fi y las omite si no hay Wi-Fi. Cambiar manualmente siempre descarga.",
prefetchImmich: "Precargar siempre las imágenes de Immich",
prefetchImmichHint: "Descarga las nuevas imágenes de Immich en segundo plano en cuanto haya Wi-Fi disponible, nunca con datos móviles. Si está desactivado, las imágenes solo se descargan cuando se necesitan.",
},
it: {
immich: "Immich", immichIntro: "Importa immagini dal tuo server Immich.", serverUrl: "URL del server", apiKey: "Chiave API",
@@ -83,6 +103,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
httpWarning: "HTTP non è crittografato. Usa HTTPS fuori dalla rete domestica.", allPhotos: "Tutte le foto",
selectImmichPhotos: "Seleziona le immagini da importare", importSelected: "Importa selezione", importing: "Importazione immagini…",
importSuccess: "Immagini Immich importate", immichLoadFailed: "Impossibile caricare le immagini Immich", noImmichPhotos: "Nessuna immagine trovata",
allowMobileData: "Consenti dati mobili",
allowMobileDataHint: "Se disattivato, la rotazione automatica scarica le immagini Immich solo via Wi-Fi e le salta altrimenti. Il cambio manuale scarica sempre.",
prefetchImmich: "Precarica sempre le immagini Immich",
prefetchImmichHint: "Scarica le nuove immagini Immich in background non appena il Wi-Fi è disponibile, mai con i dati mobili. Se disattivato, le immagini vengono scaricate solo quando servono.",
},
nl: {
immich: "Immich", immichIntro: "Importeer afbeeldingen van je eigen Immich-server.", serverUrl: "Server-URL", apiKey: "API-sleutel",
@@ -92,6 +116,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
httpWarning: "HTTP is niet versleuteld. Gebruik HTTPS buiten je thuisnetwerk.", allPhotos: "Alle fotos",
selectImmichPhotos: "Selecteer afbeeldingen om te importeren", importSelected: "Selectie importeren", importing: "Afbeeldingen importeren…",
importSuccess: "Immich-afbeeldingen geïmporteerd", immichLoadFailed: "Immich-afbeeldingen konden niet worden geladen", noImmichPhotos: "Geen afbeeldingen gevonden",
allowMobileData: "Mobiele data toestaan",
allowMobileDataHint: "Indien uit, worden Immich-afbeeldingen bij automatisch wisselen alleen via wifi gedownload en anders overgeslagen. Handmatig wisselen downloadt altijd.",
prefetchImmich: "Immich-afbeeldingen altijd vooraf laden",
prefetchImmichHint: "Downloadt nieuwe Immich-afbeeldingen op de achtergrond zodra wifi beschikbaar is nooit via mobiele data. Indien uit, worden afbeeldingen alleen bij gebruik gedownload.",
},
pl: {
immich: "Immich", immichIntro: "Importuj obrazy z własnego serwera Immich.", serverUrl: "Adres serwera", apiKey: "Klucz API",
@@ -101,6 +129,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
httpWarning: "HTTP nie jest szyfrowany. Poza siecią domową używaj HTTPS.", allPhotos: "Wszystkie zdjęcia",
selectImmichPhotos: "Wybierz obrazy do importu", importSelected: "Importuj wybrane", importing: "Importowanie obrazów…",
importSuccess: "Zaimportowano obrazy Immich", immichLoadFailed: "Nie udało się wczytać obrazów Immich", noImmichPhotos: "Nie znaleziono obrazów",
allowMobileData: "Zezwól na dane mobilne",
allowMobileDataHint: "Gdy wyłączone, automatyczna zmiana pobiera obrazy Immich tylko przez Wi-Fi, w przeciwnym razie je pomija. Ręczna zmiana zawsze pobiera.",
prefetchImmich: "Zawsze pobieraj obrazy Immich z wyprzedzeniem",
prefetchImmichHint: "Pobiera nowe obrazy Immich w tle, gdy tylko dostępne jest Wi-Fi nigdy przez dane mobilne. Gdy wyłączone, obrazy są pobierane tylko w razie potrzeby.",
},
pt: {
immich: "Immich", immichIntro: "Importe imagens do seu próprio servidor Immich.", serverUrl: "URL do servidor", apiKey: "Chave de API",
@@ -110,6 +142,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
httpWarning: "HTTP não é encriptado. Use HTTPS fora da sua rede doméstica.", allPhotos: "Todas as fotos",
selectImmichPhotos: "Selecione imagens para importar", importSelected: "Importar seleção", importing: "A importar imagens…",
importSuccess: "Imagens do Immich importadas", immichLoadFailed: "Não foi possível carregar imagens do Immich", noImmichPhotos: "Nenhuma imagem encontrada",
allowMobileData: "Permitir dados móveis",
allowMobileDataHint: "Quando desativado, a rotação automática só transfere imagens do Immich por Wi-Fi e ignora-as caso contrário. A troca manual transfere sempre.",
prefetchImmich: "Pré-carregar sempre imagens do Immich",
prefetchImmichHint: "Transfere novas imagens do Immich em segundo plano assim que o Wi-Fi estiver disponível nunca com dados móveis. Quando desativado, as imagens só são transferidas quando necessário.",
},
ja: {
immich: "Immich", immichIntro: "自分の Immich サーバーから画像を取り込みます。", serverUrl: "サーバー URL", apiKey: "API キー",
@@ -119,6 +155,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
httpWarning: "HTTP は暗号化されません。自宅ネットワーク外では HTTPS を使用してください。", allPhotos: "すべての写真",
selectImmichPhotos: "取り込む画像を選択", importSelected: "選択項目を取り込む", importing: "画像を取り込み中…",
importSuccess: "Immich の画像を取り込みました", immichLoadFailed: "Immich の画像を読み込めません", noImmichPhotos: "画像が見つかりません",
allowMobileData: "モバイルデータでのダウンロードを許可",
allowMobileDataHint: "オフの場合、自動切り替えは Wi-Fi 接続時のみ Immich の画像をダウンロードし、それ以外はスキップします。手動での切り替えは常にダウンロードします。",
prefetchImmich: "Immich画像を常に先読みする",
prefetchImmichHint: "Wi-Fi が利用可能になり次第、新しい Immich 画像をバックグラウンドでダウンロードします(モバイルデータは使用しません)。オフの場合、画像は必要なときのみダウンロードされます。",
},
ko: {
immich: "Immich", immichIntro: "내 Immich 서버에서 이미지를 가져옵니다.", serverUrl: "서버 URL", apiKey: "API 키",
@@ -128,6 +168,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
httpWarning: "HTTP는 암호화되지 않습니다. 홈 네트워크 밖에서는 HTTPS를 사용하세요.", allPhotos: "모든 사진",
selectImmichPhotos: "가져올 이미지를 선택하세요", importSelected: "선택 항목 가져오기", importing: "이미지 가져오는 중…",
importSuccess: "Immich 이미지를 가져왔습니다", immichLoadFailed: "Immich 이미지를 불러올 수 없습니다", noImmichPhotos: "이미지를 찾을 수 없습니다",
allowMobileData: "모바일 데이터 다운로드 허용",
allowMobileDataHint: "꺼져 있으면 자동 변경 시 Wi-Fi에서만 Immich 이미지를 다운로드하고 그렇지 않으면 건너뜁니다. 수동 변경은 항상 다운로드합니다.",
prefetchImmich: "Immich 이미지 항상 미리 다운로드",
prefetchImmichHint: "Wi-Fi를 사용할 수 있게 되면 새 Immich 이미지를 백그라운드에서 다운로드합니다(모바일 데이터는 사용하지 않음). 꺼져 있으면 필요할 때만 이미지를 다운로드합니다.",
},
"zh-CN": {
immich: "Immich", immichIntro: "从你自己的 Immich 服务器导入图片。", serverUrl: "服务器 URL", apiKey: "API 密钥",
@@ -137,5 +181,9 @@ export const immichTranslations: Record<Language, ImmichText> = {
httpWarning: "HTTP 未加密。在家庭网络之外请使用 HTTPS。", allPhotos: "所有照片",
selectImmichPhotos: "选择要导入的图片", importSelected: "导入所选图片", importing: "正在导入图片…",
importSuccess: "已导入 Immich 图片", immichLoadFailed: "无法加载 Immich 图片", noImmichPhotos: "未找到图片",
allowMobileData: "允许使用移动数据下载",
allowMobileDataHint: "关闭时,自动更换仅在 Wi-Fi 下下载 Immich 图片,否则将跳过;手动更换始终会下载。",
prefetchImmich: "始终预下载 Immich 图片",
prefetchImmichHint: "只要有 Wi-Fi 可用,就会在后台下载新的 Immich 图片——绝不使用移动数据。关闭时,图片仅在需要时才会下载。",
},
};
+5 -1
View File
@@ -6,6 +6,8 @@ export type WallpaperState = {
intervalMinutes: number;
shuffle: boolean;
lockScreenOnly: boolean;
allowMobileData: boolean;
prefetchImmich: boolean;
currentIndex: number;
currentId: string | null;
imageIds: string[];
@@ -68,6 +70,8 @@ const demoState: WallpaperState = {
intervalMinutes: 30,
shuffle: true,
lockScreenOnly: true,
allowMobileData: false,
prefetchImmich: true,
currentIndex: 0,
currentId: "demo-0",
imageIds: ["demo-0", "demo-1", "demo-2"],
@@ -148,7 +152,7 @@ export async function setImageCrop(image: GalleryImage): Promise<GalleryImage> {
return { ...image };
}
export async function setSetting(name: "shuffle" | "lockScreenOnly", value: boolean) {
export async function setSetting(name: "shuffle" | "lockScreenOnly" | "allowMobileData" | "prefetchImmich", value: boolean) {
if (!inTauri()) return { ...demoState, [name]: value };
return invoke<WallpaperState>("plugin:wallpaper|set_setting", { name, value });
}
+1
View File
@@ -149,6 +149,7 @@ nav svg { width: 21px; }
.gallery-empty p { max-width: 290px; margin: 8px 0 2px; font-size: 13px; line-height: 1.5; }
.gallery-empty .primary { max-width: 260px; }
.gallery-status { padding: 28px 0; text-align: center; color: #6d776f; font-size: 13px; }
.gallery-load-sentinel { height: 1px; }
.load-more { width: 100%; height: 48px; margin-top: 18px; border: 1px solid #b9c8bb; border-radius: 15px; color: var(--green); background: white; font-weight: 750; }
.save-crop { background: var(--green); color: white; }
.crop-editor { padding-bottom: max(28px, env(safe-area-inset-bottom)); }