import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "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, allowMobileData: false, prefetchImmich: true, currentIndex: 0, currentId: null, imageIds: [], imageUrls: [] };
function Switch({ checked, onChange, label }: { checked: boolean; onChange: (value: boolean) => void; label: string }) {
return ;
}
function SettingRow({ icon, label, value, onChange }: { icon: React.ReactNode; label: string; value: boolean; onChange: (value: boolean) => void }) {
return
;
}
function IntervalRow({ value, onChange, label, paused, screenOn, everyMinutes }: { value: number; onChange: (value: number) => void; label: string; paused: string; screenOn: string; everyMinutes: (minutes: number) => string }) {
return ;
}
export default function App() {
const [state, setState] = useState(initial);
const [tab, setTab] = useState<"home" | "settings" | "gallery" | "editor" | "immich">("home");
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState("");
const [language, setLanguage] = useState(initialLanguage);
const [gallery, setGallery] = useState([]);
const [galleryTotal, setGalleryTotal] = useState(0);
const [galleryLoading, setGalleryLoading] = useState(false);
const [selectedIds, setSelectedIds] = useState>(() => new Set());
const [editing, setEditing] = useState(null);
const [immichConnection, setImmichConnection] = useState({ configured: false, serverUrl: "", userName: "" });
const [immichUrl, setImmichUrl] = useState("");
const [immichApiKey, setImmichApiKey] = useState("");
const [immichAlbums, setImmichAlbums] = useState([]);
const [immichAlbumId, setImmichAlbumId] = useState("");
const [immichAssets, setImmichAssets] = useState([]);
const [immichSelected, setImmichSelected] = useState>(() => new Set());
const [immichPage, setImmichPage] = useState(1);
const [immichHasMore, setImmichHasMore] = useState(false);
const [immichLoading, setImmichLoading] = useState(false);
const [immichImportProgress, setImmichImportProgress] = useState(null);
const phonePreviewRef = useRef(null);
const phoneImageRef = useRef(null);
const previewPointers = useRef(new Map());
const previewGesture = useRef(null);
const galleryScrollPosition = useRef(0);
const restoreGalleryScroll = useRef(false);
useEffect(() => {
const refresh = () => { void getState().then(setState).catch(() => undefined); };
refresh();
const onVisibilityChange = () => { if (!document.hidden) refresh(); };
document.addEventListener("visibilitychange", onVisibilityChange);
window.addEventListener("focus", refresh);
return () => {
document.removeEventListener("visibilitychange", onVisibilityChange);
window.removeEventListener("focus", refresh);
};
}, []);
useEffect(() => {
getImmichConnection().then(connection => {
setImmichConnection(connection);
setImmichUrl(connection.serverUrl);
}).catch(() => undefined);
}, []);
useEffect(() => {
if (!notice) return;
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;
document.title = "WallpaperFlow";
}, [language]);
useLayoutEffect(() => {
if (tab !== "editor" && tab !== "gallery") return;
const top = tab === "gallery" && restoreGalleryScroll.current ? galleryScrollPosition.current : 0;
restoreGalleryScroll.current = false;
window.scrollTo(0, top);
const frame = window.requestAnimationFrame(() => window.scrollTo(0, top));
const settled = window.setTimeout(() => window.scrollTo(0, top), 120);
return () => { window.cancelAnimationFrame(frame); window.clearTimeout(settled); };
}, [tab]);
const t = translations[language];
const it = immichTranslations[language];
const previewDate = useMemo(() => new Intl.DateTimeFormat(language, { weekday: "long", day: "numeric", month: "long" }).format(new Date()), [language]);
const currentPreviewIndex = state.imageIds.indexOf(state.currentId ?? "");
const current = state.imageUrls[currentPreviewIndex] ?? "/wallpapers/alpine.png";
const photos = useMemo(() => state.imageUrls, [state.imageUrls]);
async function loadGallery(offset = 0, append = false) {
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); }
}
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 (error) {
setState(prev => ({ ...prev, [name]: previous }));
setNotice(String(error).replace(/^Error:\s*/, "") || t.androidOnly);
}
}
async function updateInterval(minutes: number) {
setState(prev => ({ ...prev, intervalMinutes: minutes, enabled: minutes !== 0 }));
try {
const saved = await setIntervalMinutes(minutes);
setState(saved);
setNotice(minutes !== 0 ? t.intervalSaved : t.automaticDisabled);
}
catch { setNotice(t.androidOnly); }
}
async function choose() {
setBusy(true);
try {
setState(await selectImages());
if (tab === "gallery") await loadGallery();
setNotice(t.imagesAdded);
} catch (error) { setNotice(String(error).includes("cancel") ? t.selectionCancelled : t.imageSelectionAndroid); }
finally { setBusy(false); }
}
function toggleSelected(id: string) {
setSelectedIds(previous => {
const next = new Set(previous);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
async function selectAll() {
if (selectedIds.size === galleryTotal) { setSelectedIds(new Set()); return; }
setBusy(true);
try { setSelectedIds(new Set(await getImageIds())); }
catch { setNotice(t.galleryLoadFailed); }
finally { setBusy(false); }
}
async function removeSelected() {
if (!selectedIds.size || !window.confirm(t.deleteSelectedConfirm)) return;
setBusy(true);
try {
setState(await deleteImages([...selectedIds]));
setSelectedIds(new Set());
await loadGallery();
setNotice(t.imagesDeleted);
} catch { setNotice(t.imagesDeleteFailed); }
finally { setBusy(false); }
}
function openEditor(image: GalleryImage) {
galleryScrollPosition.current = window.scrollY;
setEditing({ ...image, cropZoom: Math.max(1, image.cropZoom) });
setTab("editor");
}
function returnToGallery() {
restoreGalleryScroll.current = true;
setTab("gallery");
}
function openGallery() {
galleryScrollPosition.current = 0;
restoreGalleryScroll.current = false;
setTab("gallery");
void loadGallery();
}
async function saveImmichConnection() {
setBusy(true);
try {
const connection = await connectImmich(immichUrl, immichApiKey);
setImmichConnection(connection);
setImmichUrl(connection.serverUrl);
setImmichApiKey("");
setNotice(it.connectionSuccess);
} catch (error) { setNotice(String(error).replace(/^Error:\s*/, "") || it.connectionFailed); }
finally { setBusy(false); }
}
async function removeImmichConnection() {
setBusy(true);
try {
setImmichConnection(await disconnectImmich());
setImmichUrl("");
setImmichApiKey("");
setImmichAssets([]);
setImmichAlbums([]);
setImmichSelected(new Set());
setNotice(it.disconnected);
} catch { setNotice(it.connectionFailed); }
finally { setBusy(false); }
}
async function loadImmichPhotos(albumId: string, page = 1, append = false) {
setImmichLoading(true);
try {
const result = await getImmichAssets(albumId || null, page, 30);
setImmichAssets(previous => append ? [...previous, ...result.items] : result.items);
setImmichPage(result.page);
setImmichHasMore(result.hasMore);
} catch (error) { setNotice(String(error).replace(/^Error:\s*/, "") || it.immichLoadFailed); }
finally { setImmichLoading(false); }
}
async function openImmich() {
setTab("immich");
setImmichSelected(new Set());
setImmichLoading(true);
try {
const albums = await getImmichAlbums();
setImmichAlbums(albums);
await loadImmichPhotos(immichAlbumId);
} catch (error) {
setNotice(String(error).replace(/^Error:\s*/, "") || it.immichLoadFailed);
setImmichLoading(false);
}
}
function toggleImmichAsset(id: string) {
setImmichSelected(previous => {
const next = new Set(previous);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
async function importFromImmich() {
if (!immichSelected.size) return;
setBusy(true);
setImmichImportProgress({ active: true, completed: 0, total: immichSelected.size, bytesDownloaded: 0, bytesTotal: 0 });
setNotice(it.importing);
const poll = window.setInterval(() => {
void getImmichImportProgress().then(setImmichImportProgress).catch(() => undefined);
}, 750);
try {
setState(await importImmichAssets([...immichSelected]));
setImmichSelected(new Set());
setNotice(it.importSuccess);
setTab("home");
} catch (error) { setNotice(String(error).replace(/^Error:\s*/, "") || it.immichLoadFailed); }
finally {
window.clearInterval(poll);
setBusy(false);
setImmichImportProgress(null);
}
}
async function saveCrop() {
if (!editing) return;
setBusy(true);
try {
const saved = await setImageCrop(editing);
setGallery(previous => previous.map(image => image.id === saved.id ? saved : image));
setEditing(saved);
setNotice(t.cropSaved);
returnToGallery();
} catch { setNotice(t.cropSaveFailed); }
finally { setBusy(false); }
}
function gestureGeometry() {
const points = [...previewPointers.current.values()];
if (!points.length) return { centerX: 0, centerY: 0, distance: 0 };
if (points.length === 1) return { centerX: points[0].x, centerY: points[0].y, distance: 0 };
const [first, second] = points;
return {
centerX: (first.x + second.x) / 2,
centerY: (first.y + second.y) / 2,
distance: Math.hypot(second.x - first.x, second.y - first.y),
};
}
function gestureStart() {
const preview = phonePreviewRef.current;
const image = phoneImageRef.current;
if (!editing || !preview || !image) return null;
if (!image.naturalWidth || !image.naturalHeight) return null;
const bounds = preview.getBoundingClientRect();
const quarterTurn = editing.cropRotation === 90 || editing.cropRotation === 270;
const imageWidth = quarterTurn ? image.naturalHeight : image.naturalWidth;
const imageHeight = quarterTurn ? image.naturalWidth : image.naturalHeight;
const scaleX = bounds.width / imageWidth;
const scaleY = bounds.height / imageHeight;
const baseScale = editing.cropMode === "contain" ? Math.min(scaleX, scaleY) : Math.max(scaleX, scaleY);
return {
...gestureGeometry(),
x: editing.cropPositionX,
y: editing.cropPositionY,
zoom: editing.cropZoom,
left: bounds.left,
top: bounds.top,
width: bounds.width,
height: bounds.height,
baseWidth: imageWidth * baseScale,
baseHeight: imageHeight * baseScale,
};
}
function rotateEditing() {
setEditing(previous => previous ? {
...previous,
cropRotation: (previous.cropRotation + 90) % 360,
cropZoom: 1,
cropPositionX: 0.5,
cropPositionY: 0.5,
} : previous);
}
function previewPosition(image: GalleryImage) {
const { cropPositionX: x, cropPositionY: y } = image;
if (image.cropRotation === 90) return `${y * 100}% ${(1 - x) * 100}%`;
if (image.cropRotation === 180) return `${(1 - x) * 100}% ${(1 - y) * 100}%`;
if (image.cropRotation === 270) return `${(1 - y) * 100}% ${x * 100}%`;
return `${x * 100}% ${y * 100}%`;
}
function beginPreviewGesture(event: React.PointerEvent) {
if (!editing) return;
event.preventDefault();
phonePreviewRef.current?.setPointerCapture(event.pointerId);
previewPointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
previewGesture.current = gestureStart();
}
function movePreviewGesture(event: React.PointerEvent) {
if (!previewPointers.current.has(event.pointerId)) return;
event.preventDefault();
previewPointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
const gesture = previewGesture.current;
if (!gesture) return;
const geometry = gestureGeometry();
const zoom = geometry.distance && gesture.distance
? Math.min(3, Math.max(1, gesture.zoom * geometry.distance / gesture.distance))
: gesture.zoom;
const previousLeft = (gesture.width - gesture.baseWidth * gesture.zoom) * gesture.x;
const previousTop = (gesture.height - gesture.baseHeight * gesture.zoom) * gesture.y;
const imageX = (gesture.centerX - gesture.left - previousLeft) / gesture.zoom;
const imageY = (gesture.centerY - gesture.top - previousTop) / gesture.zoom;
const nextLeft = geometry.centerX - gesture.left - imageX * zoom;
const nextTop = geometry.centerY - gesture.top - imageY * zoom;
const horizontalTravel = gesture.width - gesture.baseWidth * zoom;
const verticalTravel = gesture.height - gesture.baseHeight * zoom;
const x = Math.abs(horizontalTravel) < 0.5 ? 0.5 : nextLeft / horizontalTravel;
const y = Math.abs(verticalTravel) < 0.5 ? 0.5 : nextTop / verticalTravel;
setEditing(previous => previous ? {
...previous,
cropZoom: zoom,
cropPositionX: Math.min(1, Math.max(0, x)),
cropPositionY: Math.min(1, Math.max(0, y)),
} : previous);
}
function endPreviewGesture(event: React.PointerEvent) {
previewPointers.current.delete(event.pointerId);
if (phonePreviewRef.current?.hasPointerCapture(event.pointerId)) phonePreviewRef.current.releasePointerCapture(event.pointerId);
if (!editing || !previewPointers.current.size) {
previewGesture.current = null;
return;
}
previewGesture.current = gestureStart();
}
async function next() {
setBusy(true);
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); }
}
return
{tab === "gallery" ? : tab === "editor" ?
{t.editImage}
{t.savedForImage}
:
tab === "immich" ? :

WallpaperFlow
{state.enabled ? t.automaticActive : t.automaticPaused}
}
{tab === "home" ? <>
{t.currentImage}
{t.collection}
{photos.length ? {photos.map((photo, index) =>
)}
: }
{t.collectionHint}
} label={t.shuffle} value={state.shuffle} onChange={v => update("shuffle", v)} />} label={t.lockScreenOnly} value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} />
> : tab === "settings" ?
{t.settings}
{t.settingsIntro}
} label={t.shuffle} value={state.shuffle} onChange={v => update("shuffle", v)} />} label={t.lockScreenOnly} value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} />
{it.immich}
{it.immichIntro}
{immichConnection.configured ? <>
{it.connectedAs} {immichConnection.userName}{immichConnection.serverUrl}
} label={it.prefetchImmich} value={state.prefetchImmich} onChange={v => update("prefetchImmich", v)} />
{it.prefetchImmichHint}
} label={it.allowMobileData} value={state.allowMobileData} onChange={v => update("allowMobileData", v)} />
{it.allowMobileDataHint}
> : <>
{immichUrl.trim().toLowerCase().startsWith("http://") && {it.httpWarning}
}
{it.apiKeyHelp}
>}
{t.noImageLimit}
{t.privacyInfo}
: tab === "immich" ?
{!!immichAssets.length && {immichSelected.size ? `${immichSelected.size} ${t.selected}` : it.selectImmichPhotos}
}
{immichAssets.length ? {immichAssets.map((asset, index) =>
)}
: !immichLoading &&
{it.noImmichPhotos}
}
{immichLoading && {t.galleryLoading}
}
{!immichLoading && immichHasMore && }
{!!immichSelected.size && (busy && immichImportProgress ? {it.importing}{immichImportProgress.completed} / {immichImportProgress.total}
{immichImportProgress.bytesTotal > 0 &&
{Math.round(immichImportProgress.bytesDownloaded / immichImportProgress.bytesTotal * 100)}%{(immichImportProgress.bytesDownloaded / 1024 / 1024).toFixed(1)} / {(immichImportProgress.bytesTotal / 1024 / 1024).toFixed(1)} MB
}
: )}
: tab === "gallery" ?
{!!galleryTotal && {selectedIds.size ? `${selectedIds.size} ${t.selected}` : t.selectImagesToDelete}
}
{gallery.length ? {gallery.map((image, index) =>
{image.selected && {t.current}})}
: !galleryLoading && {t.emptyCollection}
{t.emptyCollectionText}
}
{galleryLoading && {t.galleryLoading}
}
{!galleryLoading && gallery.length < galleryTotal && }
: editing &&
{t.adjustOnScreen}{editing.cropZoom.toFixed(2)}×
{t.gestureHelp}
}
{notice && }
{tab !== "gallery" && tab !== "editor" && tab !== "immich" && }
;
}