feat(wallpapers): add Immich integration with caching and rotation
This patch adds Immich integration for remote images with local caching and pruning. It adds image rotation to the crop flow and stores rotation with the crop state. It adds progress tracking and size checks during image download to avoid oversized assets. - Add Immich caching with size limits and automatic pruning - Extend crop handling to support rotation and persist it - Improve download progress and error messaging
This commit is contained in:
+29
-7
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
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 { 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 { initialLanguage, languageNames, languages, translations, type Language } from "./i18n-local";
|
||||
import { immichTranslations } from "./immich-i18n";
|
||||
@@ -278,8 +278,11 @@ export default function App() {
|
||||
if (!editing || !preview || !image) return null;
|
||||
if (!image.naturalWidth || !image.naturalHeight) return null;
|
||||
const bounds = preview.getBoundingClientRect();
|
||||
const scaleX = bounds.width / image.naturalWidth;
|
||||
const scaleY = bounds.height / image.naturalHeight;
|
||||
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(),
|
||||
@@ -290,11 +293,29 @@ export default function App() {
|
||||
top: bounds.top,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
baseWidth: image.naturalWidth * baseScale,
|
||||
baseHeight: image.naturalHeight * baseScale,
|
||||
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();
|
||||
@@ -407,16 +428,17 @@ export default function App() {
|
||||
{!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}>
|
||||
<img ref={phoneImageRef} src={editing.url} alt={t.cropPreview} draggable={false} style={{ objectFit: editing.cropMode, objectPosition: `${editing.cropPositionX * 100}% ${editing.cropPositionY * 100}%`, transform: `scale(${editing.cropZoom})`, transformOrigin: `${editing.cropPositionX * 100}% ${editing.cropPositionY * 100}%` }} />
|
||||
<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 })}><RotateCcw /> {t.reset}</button>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user