feat(immich): add Immich self-hosted import support
Adds an ImmichClient for Android to connect to a self-hosted Immich server, fetch albums and assets, and import selected images locally. API keys are encrypted with Android Keystore and stored securely. The plugin now exposes connect, disconnect and import actions and is documented for Immich import usage. - Encrypted Immich API keys with Android Keystore - Exposed connect, disconnect and import actions in the plugin - Updated docs and metadata to reflect Immich import flow
This commit is contained in:
+123
-5
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { ArrowLeft, Check, ChevronRight, Crop, Home, Images, Languages, LockKeyhole, Plus, RotateCcw, Save, Settings, Shuffle, Smartphone, Sparkles, Trash2 } from "lucide-react";
|
||||
import { deleteImages, getGallery, getImageIds, getState, nextWallpaper, selectImages, setImageCrop, setIntervalMinutes, setSetting, type GalleryImage, type WallpaperState } from "./native";
|
||||
import { ArrowLeft, Check, ChevronRight, Cloud, CloudDownload, Crop, Home, Images, Languages, Link2Off, LockKeyhole, Plus, RotateCcw, Save, Server, Settings, Shuffle, Smartphone, Sparkles, Trash2 } from "lucide-react";
|
||||
import { connectImmich, deleteImages, disconnectImmich, getGallery, getImageIds, getImmichAlbums, getImmichAssets, getImmichConnection, getState, importImmichAssets, nextWallpaper, selectImages, setImageCrop, setIntervalMinutes, setSetting, type GalleryImage, type ImmichAlbum, type ImmichAsset, type ImmichConnection, 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: [] };
|
||||
|
||||
@@ -22,7 +23,7 @@ function IntervalRow({ value, onChange, label, paused, everyMinutes }: { value:
|
||||
|
||||
export default function App() {
|
||||
const [state, setState] = useState(initial);
|
||||
const [tab, setTab] = useState<"home" | "settings" | "gallery" | "editor">("home");
|
||||
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);
|
||||
@@ -31,6 +32,16 @@ export default function App() {
|
||||
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 phonePreviewRef = useRef<HTMLDivElement>(null);
|
||||
const phoneImageRef = useRef<HTMLImageElement>(null);
|
||||
const previewPointers = useRef(new Map<number, { x: number; y: number }>());
|
||||
@@ -39,6 +50,12 @@ export default function App() {
|
||||
const restoreGalleryScroll = useRef(false);
|
||||
|
||||
useEffect(() => { getState().then(setState).catch(() => setState(initial)); }, []);
|
||||
useEffect(() => {
|
||||
getImmichConnection().then(connection => {
|
||||
setImmichConnection(connection);
|
||||
setImmichUrl(connection.serverUrl);
|
||||
}).catch(() => undefined);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!notice) return;
|
||||
const timeout = window.setTimeout(() => setNotice(""), 3000);
|
||||
@@ -59,6 +76,7 @@ export default function App() {
|
||||
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 current = state.imageUrls[state.currentIndex] ?? "/wallpapers/alpine.png";
|
||||
const photos = useMemo(() => state.imageUrls, [state.imageUrls]);
|
||||
@@ -148,6 +166,78 @@ export default function App() {
|
||||
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);
|
||||
setNotice(it.importing);
|
||||
try {
|
||||
setState(await importImmichAssets([...immichSelected]));
|
||||
setImmichSelected(new Set());
|
||||
setNotice(it.importSuccess);
|
||||
setTab("home");
|
||||
} catch (error) { setNotice(String(error).replace(/^Error:\s*/, "") || it.immichLoadFailed); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function saveCrop() {
|
||||
if (!editing) return;
|
||||
setBusy(true);
|
||||
@@ -251,6 +341,7 @@ 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" ?
|
||||
<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">
|
||||
@@ -272,7 +363,34 @@ export default function App() {
|
||||
</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} 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><div className="info"><h3>{t.noImageLimit}</h3><p>{t.privacyInfo}</p></div></section> : tab === "gallery" ?
|
||||
</> : 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} 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>
|
||||
<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 && <button className="primary immich-import" onClick={importFromImmich} disabled={busy}><CloudDownload /> {busy ? it.importing : `${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>}
|
||||
@@ -295,6 +413,6 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
{notice && <button className="snackbar" onClick={() => setNotice("")}>{notice}</button>}
|
||||
{tab !== "gallery" && tab !== "editor" && <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={() => setTab("home")}><Home /><span>{t.home}</span></button><button className={tab === "settings" ? "active" : ""} onClick={() => setTab("settings")}><Settings /><span>{t.settings}</span></button></nav>}
|
||||
</main>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user