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
{icon}
{label}
; } 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" ?

{selectedIds.size ? `${selectedIds.size} ${t.selected}` : t.collection}

{galleryTotal} {galleryTotal === 1 ? t.image : t.images}

{selectedIds.size ? : }
: tab === "editor" ?

{t.editImage}

{t.savedForImage}

: tab === "immich" ?

{it.immich}

{immichSelected.size ? `${immichSelected.size} ${t.selected}` : it.selectImmichPhotos}

{immichSelected.size > 0 && }
:

WallpaperFlow

{state.enabled ? t.automaticActive : t.automaticPaused}

}
{tab === "home" ? <>
{t.currentImage}
{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.cropPreview}
12:34{previewDate}
{t.adjustOnScreen}{editing.cropZoom.toFixed(2)}×

{t.gestureHelp}

}
{notice && } {tab !== "gallery" && tab !== "editor" && tab !== "immich" && }
; }