feat(wallpaper): add apply_wallpaper workflow and rotation worker

Adds a new apply_wallpaper command and its plumbing across
desktop, mobile, and Android. A WorkManager-based rotation
worker handles applying wallpapers in the background. Wallpaper
state now tracks currentId and imageIds for precise control.

- Introduced apply_wallpaper command across desktop, mobile and Android
- Added WorkManager-based rotation worker to apply wallpapers in the
  background
- Extended wallpaper state with currentId and imageIds and updated schemas/permissions
This commit is contained in:
2026-08-21 23:15:00 +02:00
parent e494b17117
commit 73f8599c7f
25 changed files with 266 additions and 49 deletions
+33 -8
View File
@@ -1,10 +1,10 @@
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 { 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 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, imageUrls: [] };
const initial: WallpaperState = { imageCount: 0, enabled: false, intervalMinutes: 0, shuffle: true, lockScreenOnly: true, currentIndex: 0, currentId: null, imageIds: [], imageUrls: [] };
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>;
@@ -50,7 +50,19 @@ export default function App() {
const galleryScrollPosition = useRef(0);
const restoreGalleryScroll = useRef(false);
useEffect(() => { getState().then(setState).catch(() => setState(initial)); }, []);
useEffect(() => {
const refresh = () => { void getState().then(setState).catch(() => undefined); };
refresh();
const onVisibilityChange = () => { if (!document.hidden) refresh(); };
const refreshInterval = window.setInterval(() => { if (!document.hidden) refresh(); }, 15000);
document.addEventListener("visibilitychange", onVisibilityChange);
window.addEventListener("focus", refresh);
return () => {
window.clearInterval(refreshInterval);
document.removeEventListener("visibilitychange", onVisibilityChange);
window.removeEventListener("focus", refresh);
};
}, []);
useEffect(() => {
getImmichConnection().then(connection => {
setImmichConnection(connection);
@@ -62,6 +74,9 @@ export default function App() {
const timeout = window.setTimeout(() => setNotice(""), 3000);
return () => window.clearTimeout(timeout);
}, [notice]);
useEffect(() => {
setGallery(previous => previous.map(image => ({ ...image, selected: image.id === state.currentId })));
}, [state.currentId]);
useEffect(() => {
window.localStorage.setItem("wallpaperflow-language", language);
document.documentElement.lang = language;
@@ -79,7 +94,8 @@ export default function App() {
const t = translations[language];
const it = immichTranslations[language];
const previewDate = useMemo(() => new Intl.DateTimeFormat(language, { weekday: "long", day: "numeric", month: "long" }).format(new Date()), [language]);
const current = state.imageUrls[state.currentIndex] ?? "/wallpapers/alpine.png";
const currentPreviewIndex = state.imageIds.indexOf(state.currentId ?? "");
const current = state.imageUrls[currentPreviewIndex] ?? "/wallpapers/alpine.png";
const photos = useMemo(() => state.imageUrls, [state.imageUrls]);
async function loadGallery(offset = 0, append = false) {
@@ -96,7 +112,7 @@ export default function App() {
setState(prev => ({ ...prev, [name]: value }));
try {
const saved = await setSetting(name, value);
setState(prev => ({ ...saved, imageUrls: saved.imageUrls.length ? saved.imageUrls : prev.imageUrls }));
setState(saved);
setNotice(t.settingSaved);
} catch { setNotice(t.androidOnly); }
}
@@ -105,7 +121,7 @@ export default function App() {
setState(prev => ({ ...prev, intervalMinutes: minutes, enabled: minutes > 0 }));
try {
const saved = await setIntervalMinutes(minutes);
setState(prev => ({ ...saved, imageUrls: saved.imageUrls.length ? saved.imageUrls : prev.imageUrls }));
setState(saved);
setNotice(minutes > 0 ? t.intervalSaved : t.automaticDisabled);
}
catch { setNotice(t.androidOnly); }
@@ -364,7 +380,16 @@ export default function App() {
async function next() {
setBusy(true);
try { setState(await nextWallpaper()); setNotice(t.wallpaperUpdated); } catch { setState(prev => ({ ...prev, currentIndex: (prev.currentIndex + 1) % Math.max(1, photos.length) })); }
try { setState(await nextWallpaper()); setNotice(t.wallpaperUpdated); }
catch (error) { setNotice(String(error).replace(/^Error:\s*/, "")); }
finally { setBusy(false); }
}
async function selectWallpaper(id: string) {
if (busy || id === state.currentId) return;
setBusy(true);
try { setState(await applyWallpaper(id)); setNotice(t.wallpaperUpdated); }
catch (error) { setNotice(String(error).replace(/^Error:\s*/, "")); }
finally { setBusy(false); }
}
@@ -388,7 +413,7 @@ export default function App() {
<button className="primary" onClick={choose} disabled={busy}><Images />{busy ? t.pleaseWait : t.selectImages}</button>
<section className="collection"><div className="section-heading"><h2>{t.collection}</h2><button onClick={openGallery}>{state.imageCount} {state.imageCount === 1 ? t.image : t.images} <ChevronRight /></button></div>
{photos.length ? <div className="photo-rail">{photos.map((photo, index) => <button key={`${photo}-${index}`} className={index === state.currentIndex ? "selected" : ""} onClick={() => setState(prev => ({ ...prev, currentIndex: index }))}><img src={photo} alt={`${t.motif} ${index + 1}`} />{index === state.currentIndex && <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={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>}
<p className="hint">{t.collectionHint}</p>
</section>