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
482 lines
31 KiB
TypeScript
482 lines
31 KiB
TypeScript
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 <button className={`switch ${checked ? "on" : ""}`} role="switch" aria-checked={checked} aria-label={label} onClick={() => onChange(!checked)}><span /></button>;
|
||
}
|
||
|
||
function SettingRow({ icon, label, value, onChange }: { icon: React.ReactNode; label: string; value: boolean; onChange: (value: boolean) => void }) {
|
||
return <div className="setting-row"><div className="setting-icon">{icon}</div><span>{label}</span><Switch checked={value} onChange={onChange} label={label} /></div>;
|
||
}
|
||
|
||
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 <label className="setting-row interval-row"><div className="setting-icon"><Smartphone /></div><span>{label}</span><select value={value} onChange={event => onChange(Number(event.target.value))} aria-label={label}>
|
||
<option value={0}>{paused}</option>
|
||
<option value={-1}>{screenOn}</option>
|
||
{[5, 15, 30, 60, 180, 360, 720].map(minutes => <option value={minutes} key={minutes}>{everyMinutes(minutes)}</option>)}
|
||
</select></label>;
|
||
}
|
||
|
||
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<Language>(initialLanguage);
|
||
const [gallery, setGallery] = useState<GalleryImage[]>([]);
|
||
const [galleryTotal, setGalleryTotal] = useState(0);
|
||
const [galleryLoading, setGalleryLoading] = useState(false);
|
||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||
const [editing, setEditing] = useState<GalleryImage | null>(null);
|
||
const [immichConnection, setImmichConnection] = useState<ImmichConnection>({ configured: false, serverUrl: "", userName: "" });
|
||
const [immichUrl, setImmichUrl] = useState("");
|
||
const [immichApiKey, setImmichApiKey] = useState("");
|
||
const [immichAlbums, setImmichAlbums] = useState<ImmichAlbum[]>([]);
|
||
const [immichAlbumId, setImmichAlbumId] = useState("");
|
||
const [immichAssets, setImmichAssets] = useState<ImmichAsset[]>([]);
|
||
const [immichSelected, setImmichSelected] = useState<Set<string>>(() => new Set());
|
||
const [immichPage, setImmichPage] = useState(1);
|
||
const [immichHasMore, setImmichHasMore] = useState(false);
|
||
const [immichLoading, setImmichLoading] = useState(false);
|
||
const [immichImportProgress, setImmichImportProgress] = useState<ImmichImportProgress | null>(null);
|
||
const phonePreviewRef = useRef<HTMLDivElement>(null);
|
||
const phoneImageRef = useRef<HTMLImageElement>(null);
|
||
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);
|
||
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 <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" ?
|
||
<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>}
|
||
|
||
<div className="content">
|
||
{tab === "home" ? <>
|
||
<section className="hero" aria-label={t.currentWallpaper}>
|
||
<div className="photo-stack" />
|
||
<img src={current} alt={t.currentImage} />
|
||
<div className="hero-shade" />
|
||
<div className="hero-meta"><span><Sparkles size={16} /> {t.currentImage}</span><button onClick={next} disabled={busy}>{t.nextImage} <ChevronRight size={18} /></button></div>
|
||
</section>
|
||
|
||
<IntervalRow value={state.intervalMinutes} onChange={updateInterval} label={t.changeInterval} paused={t.paused} screenOn={t.screenOn} everyMinutes={t.everyMinutes} />
|
||
|
||
<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={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>
|
||
|
||
<section className="settings-list"><SettingRow icon={<Shuffle />} label={t.shuffle} value={state.shuffle} onChange={v => update("shuffle", v)} /><SettingRow icon={<LockKeyhole />} label={t.lockScreenOnly} value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} /></section>
|
||
</> : tab === "settings" ? <section className="settings-page">
|
||
<h2>{t.settings}</h2><p>{t.settingsIntro}</p>
|
||
<label className="language-setting"><div className="setting-icon"><Languages /></div><div><strong>{t.language}</strong><span>{languageNames[language]}</span></div><select className="locale-select" value={language} onChange={event => setLanguage(event.target.value as Language)} aria-label={t.language}>{languages.map(locale => <option value={locale} key={locale}>{languageNames[locale]}</option>)}</select></label>
|
||
<div className="settings-list"><IntervalRow value={state.intervalMinutes} onChange={updateInterval} label={t.changeInterval} paused={t.paused} screenOn={t.screenOn} everyMinutes={t.everyMinutes} /><SettingRow icon={<Shuffle />} label={t.shuffle} value={state.shuffle} onChange={v => update("shuffle", v)} /><SettingRow icon={<LockKeyhole />} label={t.lockScreenOnly} value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} /></div>
|
||
<section className="immich-settings">
|
||
<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>
|
||
</> : <>
|
||
<label className="immich-field"><span>{it.serverUrl}</span><div><Server /><input type="url" inputMode="url" autoCapitalize="none" autoCorrect="off" value={immichUrl} onChange={event => setImmichUrl(event.target.value)} placeholder="https://photos.example.com" /></div></label>
|
||
{immichUrl.trim().toLowerCase().startsWith("http://") && <p className="immich-warning">{it.httpWarning}</p>}
|
||
<label className="immich-field"><span>{it.apiKey}</span><input type="password" autoCapitalize="none" autoCorrect="off" value={immichApiKey} onChange={event => setImmichApiKey(event.target.value)} placeholder={it.apiKeyPlaceholder} /></label>
|
||
<p className="immich-help">{it.apiKeyHelp}</p>
|
||
<button className="immich-connect" onClick={saveImmichConnection} disabled={busy || !immichUrl.trim() || !immichApiKey.trim()}><Cloud /> {busy ? t.pleaseWait : it.connect}</button>
|
||
</>}
|
||
</section>
|
||
<div className="info"><h3>{t.noImageLimit}</h3><p>{t.privacyInfo}</p></div>
|
||
</section> : tab === "immich" ?
|
||
<section className="immich-browser">
|
||
<label className="immich-album-filter"><span>{it.immich}</span><select value={immichAlbumId} onChange={event => { const id = event.target.value; setImmichAlbumId(id); setImmichSelected(new Set()); void loadImmichPhotos(id); }}><option value="">{it.allPhotos}</option>{immichAlbums.map(album => <option value={album.id} key={album.id}>{album.name} ({album.assetCount})</option>)}</select></label>
|
||
{!!immichAssets.length && <div className="selection-toolbar"><span>{immichSelected.size ? `${immichSelected.size} ${t.selected}` : it.selectImmichPhotos}</span><button onClick={() => setImmichSelected(immichSelected.size === immichAssets.length ? new Set() : new Set(immichAssets.map(asset => asset.id)))}>{immichSelected.size === immichAssets.length ? t.clearSelection : t.selectAll}</button></div>}
|
||
{immichAssets.length ? <div className="gallery-grid immich-grid">{immichAssets.map((asset, index) => <article className={immichSelected.has(asset.id) ? "chosen" : ""} key={asset.id}><button className="gallery-image-button" aria-label={`${immichSelected.has(asset.id) ? t.deselectImage : t.selectImage} ${index + 1}`} aria-pressed={immichSelected.has(asset.id)} onClick={() => toggleImmichAsset(asset.id)}>{asset.thumbnailUrl ? <img src={asset.thumbnailUrl} alt={asset.fileName} /> : <span className="immich-placeholder"><Images /></span>}</button><span className="selection-check"><Check /></span></article>)}</div> : !immichLoading && <div className="gallery-empty"><Cloud /><h2>{it.noImmichPhotos}</h2></div>}
|
||
{immichLoading && <p className="gallery-status">{t.galleryLoading}</p>}
|
||
{!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>)}
|
||
</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>}
|
||
{galleryLoading && <p className="gallery-status">{t.galleryLoading}</p>}
|
||
{!galleryLoading && gallery.length < galleryTotal && <button className="load-more" onClick={() => loadGallery(gallery.length, true)}>{t.loadMore}</button>}
|
||
</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>
|
||
<div className="crop-grid" aria-hidden="true"><i /><i /><i /><i /></div>
|
||
<div className="preview-clock">12:34<span>{previewDate}</span></div>
|
||
</div>
|
||
<div className="crop-controls">
|
||
<div className="fit-toggle" aria-label={t.imageFit}><button className={editing.cropMode === "cover" ? "active" : ""} onClick={() => setEditing({ ...editing, cropMode: "cover", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5 })}>{t.fill}</button><button className={editing.cropMode === "contain" ? "active" : ""} onClick={() => setEditing({ ...editing, cropMode: "contain", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5 })}>{t.fit}</button></div>
|
||
<button className="rotate-image" onClick={rotateEditing}><RotateCw /> {t.rotate} <strong>{editing.cropRotation}°</strong></button>
|
||
<div className="direct-crop-heading"><span><Crop /> {t.adjustOnScreen}</span><strong>{editing.cropZoom.toFixed(2)}×</strong></div>
|
||
<p className="direct-crop-help">{t.gestureHelp}</p>
|
||
<label className="zoom-control"><span>{t.zoom}</span><input type="range" min="1" max="3" step="0.01" value={editing.cropZoom} onChange={event => setEditing({ ...editing, cropZoom: Number(event.target.value) })} /></label>
|
||
<button className="reset-crop" onClick={() => setEditing({ ...editing, cropMode: "cover", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5, cropRotation: 0 })}><RotateCcw /> {t.reset}</button>
|
||
</div>
|
||
</section>}
|
||
</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>}
|
||
</main>;
|
||
}
|