Improve media indexing and processing flow
This commit is contained in:
+5
-1
@@ -1,13 +1,16 @@
|
||||
import { useEffect } from "react";
|
||||
import { useGalleryStore } from "./store";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BackgroundTasks } from "./components/BackgroundTasks";
|
||||
import { MenuBar } from "./components/MenuBar";
|
||||
import { Toolbar } from "./components/Toolbar";
|
||||
import { Gallery } from "./components/Gallery";
|
||||
import { Lightbox } from "./components/Lightbox";
|
||||
|
||||
export default function App() {
|
||||
const { loadFolders, loadImages, subscribeToProgress } = useGalleryStore();
|
||||
const loadFolders = useGalleryStore((state) => state.loadFolders);
|
||||
const loadImages = useGalleryStore((state) => state.loadImages);
|
||||
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress);
|
||||
|
||||
useEffect(() => {
|
||||
loadFolders().then(() => loadImages(true));
|
||||
@@ -26,6 +29,7 @@ export default function App() {
|
||||
<main className="flex-1 flex flex-col min-w-0">
|
||||
<MenuBar />
|
||||
<Toolbar />
|
||||
<BackgroundTasks />
|
||||
<Gallery />
|
||||
</main>
|
||||
<Lightbox />
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useMemo } from "react";
|
||||
import { useGalleryStore } from "../store";
|
||||
|
||||
function ProgressBar({ value }: { value: number }) {
|
||||
return (
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-white/8">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-400 transition-all duration-300"
|
||||
style={{ width: `${Math.max(0, Math.min(100, value))}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackgroundTasks() {
|
||||
const folders = useGalleryStore((state) => state.folders);
|
||||
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
|
||||
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
|
||||
|
||||
const tasks = useMemo(() => {
|
||||
return folders
|
||||
.map((folder) => {
|
||||
const index = indexingProgress[folder.id];
|
||||
const jobs = mediaJobProgress[folder.id];
|
||||
const pendingMediaWork = (jobs?.thumbnail_pending ?? 0) + (jobs?.metadata_pending ?? 0);
|
||||
|
||||
if (!index && pendingMediaWork === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const indexPercent = index && index.total > 0 ? (index.indexed / index.total) * 100 : 0;
|
||||
return {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
index,
|
||||
jobs,
|
||||
pendingMediaWork,
|
||||
indexPercent,
|
||||
};
|
||||
})
|
||||
.filter((task) => task !== null);
|
||||
}, [folders, indexingProgress, mediaJobProgress]);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-white/5 bg-gray-950/40 px-5 py-2 backdrop-blur-xl">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">Background Tasks</h3>
|
||||
<span className="text-xs text-gray-500">{tasks.length} active</span>
|
||||
</div>
|
||||
<div className="grid gap-2 xl:grid-cols-2">
|
||||
{tasks.map((task) => (
|
||||
<div key={task.id} className="rounded-xl border border-white/8 bg-white/[0.03] px-3 py-2.5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-white">{task.name}</p>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
{task.index && !task.index.done
|
||||
? `${task.index.indexed.toLocaleString()} of ${task.index.total.toLocaleString()} scanned`
|
||||
: `${task.pendingMediaWork.toLocaleString()} media jobs remaining`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-[11px] text-gray-400">
|
||||
{task.jobs?.thumbnail_pending ? <div>{task.jobs.thumbnail_pending.toLocaleString()} thumbnails</div> : null}
|
||||
{task.jobs?.metadata_pending ? <div>{task.jobs.metadata_pending.toLocaleString()} metadata</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{task.index && !task.index.done ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
<ProgressBar value={task.indexPercent} />
|
||||
<p className="truncate text-[11px] text-gray-500">{task.index.current_file || "Scanning..."}</p>
|
||||
</div>
|
||||
) : task.pendingMediaWork > 0 ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
<ProgressBar value={0} />
|
||||
<p className="text-[11px] text-gray-500">Processing thumbnails and metadata</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,20 @@ function RatingStars({ rating }: { rating: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(durationMs: number | null): string | null {
|
||||
if (!durationMs || durationMs <= 0) return null;
|
||||
const totalSeconds = Math.floor(durationMs / 1000);
|
||||
const seconds = totalSeconds % 60;
|
||||
const minutes = Math.floor(totalSeconds / 60) % 60;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function ContextMenu({
|
||||
x,
|
||||
y,
|
||||
@@ -174,6 +188,11 @@ function ImageTile({
|
||||
{image.media_kind}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{image.media_kind === "video" && image.duration_ms ? (
|
||||
<div className="rounded-full border border-white/10 bg-black/35 px-2 py-1 text-[10px] font-medium text-white/80">
|
||||
{formatDuration(image.duration_ms)}
|
||||
</div>
|
||||
) : null}
|
||||
{image.favorite ? (
|
||||
<div className="rounded-full border border-white/10 bg-black/35 p-1 text-rose-300">
|
||||
<svg className="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
@@ -196,7 +215,12 @@ function ImageTile({
|
||||
}
|
||||
|
||||
export function Gallery() {
|
||||
const { images, loadMoreImages, openImage, totalImages, loadingImages, zoomPreset } = useGalleryStore();
|
||||
const images = useGalleryStore((state) => state.images);
|
||||
const loadMoreImages = useGalleryStore((state) => state.loadMoreImages);
|
||||
const openImage = useGalleryStore((state) => state.openImage);
|
||||
const totalImages = useGalleryStore((state) => state.totalImages);
|
||||
const loadingImages = useGalleryStore((state) => state.loadingImages);
|
||||
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null);
|
||||
|
||||
+96
-49
@@ -18,8 +18,26 @@ function formatDate(iso: string | null): string {
|
||||
});
|
||||
}
|
||||
|
||||
function formatDuration(durationMs: number | null): string {
|
||||
if (!durationMs || durationMs <= 0) return "Pending / unavailable";
|
||||
const totalSeconds = Math.floor(durationMs / 1000);
|
||||
const seconds = totalSeconds % 60;
|
||||
const minutes = Math.floor(totalSeconds / 60) % 60;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function Lightbox() {
|
||||
const { selectedImage, closeImage, images, openImage, updateImageDetails } = useGalleryStore();
|
||||
const selectedImage = useGalleryStore((state) => state.selectedImage);
|
||||
const closeImage = useGalleryStore((state) => state.closeImage);
|
||||
const images = useGalleryStore((state) => state.images);
|
||||
const openImage = useGalleryStore((state) => state.openImage);
|
||||
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const imageViewportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -93,58 +111,61 @@ export function Lightbox() {
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<motion.div
|
||||
key={selectedImage.id}
|
||||
className="flex flex-1 flex-col"
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-1 flex-col" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div
|
||||
ref={imageViewportRef}
|
||||
className="group relative flex flex-1 items-center justify-center overflow-auto p-10"
|
||||
>
|
||||
{selectedImage.media_kind === "video" ? (
|
||||
<video
|
||||
src={convertFileSrc(selectedImage.path)}
|
||||
controls
|
||||
className="max-h-full max-w-full rounded-2xl shadow-2xl"
|
||||
style={{ maxHeight: "calc(100vh - 10rem)" }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<img
|
||||
src={convertFileSrc(selectedImage.path)}
|
||||
alt={selectedImage.filename}
|
||||
className="max-w-full rounded-2xl shadow-2xl"
|
||||
style={{
|
||||
maxHeight: "calc(100vh - 10rem)",
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute right-6 top-6 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/55 px-2 py-1 backdrop-blur">
|
||||
<button
|
||||
className="px-2 text-sm text-gray-300 hover:text-white"
|
||||
onClick={() => setZoom((value) => Math.max(0.5, value - 0.25))}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="min-w-14 text-center text-xs text-gray-300">{Math.round(zoom * 100)}%</span>
|
||||
<button
|
||||
className="px-2 text-sm text-gray-300 hover:text-white"
|
||||
onClick={() => setZoom((value) => Math.min(4, value + 0.25))}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={selectedImage.id}
|
||||
className="flex items-center justify-center"
|
||||
initial={{ opacity: 0.3, scale: 0.985 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0.3, scale: 0.985 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
>
|
||||
{selectedImage.media_kind === "video" ? (
|
||||
<video
|
||||
src={convertFileSrc(selectedImage.path)}
|
||||
controls
|
||||
className="max-h-full max-w-full rounded-2xl shadow-2xl"
|
||||
style={{ maxHeight: "calc(100vh - 10rem)" }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<img
|
||||
src={convertFileSrc(selectedImage.path)}
|
||||
alt={selectedImage.filename}
|
||||
className="max-w-full rounded-2xl shadow-2xl"
|
||||
style={{
|
||||
maxHeight: "calc(100vh - 10rem)",
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute right-6 top-6 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/55 px-2 py-1 backdrop-blur">
|
||||
<button
|
||||
className="px-2 text-sm text-gray-300 hover:text-white"
|
||||
onClick={() => setZoom((value) => Math.max(0.5, value - 0.25))}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="min-w-14 text-center text-xs text-gray-300">{Math.round(zoom * 100)}%</span>
|
||||
<button
|
||||
className="px-2 text-sm text-gray-300 hover:text-white"
|
||||
onClick={() => setZoom((value) => Math.min(4, value + 0.25))}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="flex w-72 shrink-0 flex-col border-l border-white/5 bg-gray-900/95 p-5">
|
||||
@@ -217,6 +238,32 @@ export function Lightbox() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedImage.media_kind === "video" ? (
|
||||
<>
|
||||
<div>
|
||||
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Duration</p>
|
||||
<p className="text-white">{formatDuration(selectedImage.duration_ms)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Video codec</p>
|
||||
<p className="text-white">{selectedImage.video_codec ?? "Pending / unavailable"}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Audio codec</p>
|
||||
<p className="text-white">{selectedImage.audio_codec ?? "None / unavailable"}</p>
|
||||
</div>
|
||||
|
||||
{selectedImage.metadata_error ? (
|
||||
<div>
|
||||
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Metadata</p>
|
||||
<p className="text-amber-300">{selectedImage.metadata_error}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Type</p>
|
||||
<p className="text-white">{selectedImage.mime_type}</p>
|
||||
@@ -248,7 +295,7 @@ export function Lightbox() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="absolute right-80 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20 disabled:opacity-20"
|
||||
|
||||
@@ -72,17 +72,15 @@ const FILTER_OPTIONS: { value: MediaFilter; label: string }[] = [
|
||||
export function MenuBar() {
|
||||
const [openMenu, setOpenMenu] = useState<MenuKey | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
addFolder,
|
||||
reindexFolder,
|
||||
selectedFolderId,
|
||||
zoomPreset,
|
||||
setZoomPreset,
|
||||
mediaFilter,
|
||||
setMediaFilter,
|
||||
favoritesOnly,
|
||||
setFavoritesOnly,
|
||||
} = useGalleryStore();
|
||||
const addFolder = useGalleryStore((state) => state.addFolder);
|
||||
const reindexFolder = useGalleryStore((state) => state.reindexFolder);
|
||||
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
||||
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
|
||||
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
|
||||
const mediaFilter = useGalleryStore((state) => state.mediaFilter);
|
||||
const setMediaFilter = useGalleryStore((state) => state.setMediaFilter);
|
||||
const favoritesOnly = useGalleryStore((state) => state.favoritesOnly);
|
||||
const setFavoritesOnly = useGalleryStore((state) => state.setFavoritesOnly);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
|
||||
@@ -95,8 +95,12 @@ function FolderItem({
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const { folders, selectedFolderId, addFolder, indexingProgress, totalImages, selectFolder } =
|
||||
useGalleryStore();
|
||||
const folders = useGalleryStore((state) => state.folders);
|
||||
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
||||
const addFolder = useGalleryStore((state) => state.addFolder);
|
||||
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
|
||||
const totalImages = useGalleryStore((state) => state.totalImages);
|
||||
const selectFolder = useGalleryStore((state) => state.selectFolder);
|
||||
|
||||
const handleAddFolder = async () => {
|
||||
const selected = await open({
|
||||
|
||||
+14
-16
@@ -34,22 +34,20 @@ function FilterChip({
|
||||
}
|
||||
|
||||
export function Toolbar() {
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
sort,
|
||||
setSort,
|
||||
totalImages,
|
||||
loadedCount,
|
||||
selectedFolderId,
|
||||
folders,
|
||||
mediaFilter,
|
||||
setMediaFilter,
|
||||
favoritesOnly,
|
||||
setFavoritesOnly,
|
||||
zoomPreset,
|
||||
setZoomPreset,
|
||||
} = useGalleryStore();
|
||||
const search = useGalleryStore((state) => state.search);
|
||||
const setSearch = useGalleryStore((state) => state.setSearch);
|
||||
const sort = useGalleryStore((state) => state.sort);
|
||||
const setSort = useGalleryStore((state) => state.setSort);
|
||||
const totalImages = useGalleryStore((state) => state.totalImages);
|
||||
const loadedCount = useGalleryStore((state) => state.loadedCount);
|
||||
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
||||
const folders = useGalleryStore((state) => state.folders);
|
||||
const mediaFilter = useGalleryStore((state) => state.mediaFilter);
|
||||
const setMediaFilter = useGalleryStore((state) => state.setMediaFilter);
|
||||
const favoritesOnly = useGalleryStore((state) => state.favoritesOnly);
|
||||
const setFavoritesOnly = useGalleryStore((state) => state.setFavoritesOnly);
|
||||
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
|
||||
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
|
||||
|
||||
const [searchValue, setSearchValue] = useState(search);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
+58
-8
@@ -28,6 +28,11 @@ export interface ImageRecord {
|
||||
modified_at: string | null;
|
||||
mime_type: string;
|
||||
media_kind: MediaKind;
|
||||
duration_ms: number | null;
|
||||
video_codec: string | null;
|
||||
audio_codec: string | null;
|
||||
metadata_updated_at: string | null;
|
||||
metadata_error: string | null;
|
||||
favorite: boolean;
|
||||
rating: number;
|
||||
embedding_status: string;
|
||||
@@ -44,6 +49,16 @@ export interface IndexProgress {
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface FolderJobProgress {
|
||||
folder_id: number;
|
||||
thumbnail_pending: number;
|
||||
metadata_pending: number;
|
||||
}
|
||||
|
||||
export interface MediaJobProgressEvent {
|
||||
progress: FolderJobProgress[];
|
||||
}
|
||||
|
||||
export interface IndexedImagesBatch {
|
||||
folder_id: number;
|
||||
images: ImageRecord[];
|
||||
@@ -75,6 +90,7 @@ interface GalleryState {
|
||||
zoomPreset: ZoomPreset;
|
||||
selectedImage: ImageRecord | null;
|
||||
indexingProgress: Record<number, IndexProgress>;
|
||||
mediaJobProgress: Record<number, FolderJobProgress>;
|
||||
cacheDir: string;
|
||||
|
||||
loadFolders: () => Promise<void>;
|
||||
@@ -98,6 +114,16 @@ interface GalleryState {
|
||||
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
function mergeIntoVisibleWindow(
|
||||
currentImages: ImageRecord[],
|
||||
newImages: ImageRecord[],
|
||||
sort: SortOrder,
|
||||
windowSize: number,
|
||||
): ImageRecord[] {
|
||||
const merged = mergeImages(currentImages, newImages, sort);
|
||||
return merged.slice(0, Math.max(windowSize, 0));
|
||||
}
|
||||
|
||||
function matchesSearch(image: ImageRecord, search: string): boolean {
|
||||
if (!search) return true;
|
||||
return image.filename.toLowerCase().includes(search.toLowerCase());
|
||||
@@ -175,6 +201,16 @@ function replaceImage(images: ImageRecord[], updatedImage: ImageRecord, sort: So
|
||||
return mergeImages(images, [updatedImage], sort);
|
||||
}
|
||||
|
||||
function replaceExistingImages(
|
||||
currentImages: ImageRecord[],
|
||||
updatedImages: ImageRecord[],
|
||||
sort: SortOrder,
|
||||
): ImageRecord[] {
|
||||
const updatesByPath = new Map(updatedImages.map((image) => [image.path, image]));
|
||||
const nextImages = currentImages.map((image) => updatesByPath.get(image.path) ?? image);
|
||||
return nextImages.sort((a, b) => compareImages(a, b, sort));
|
||||
}
|
||||
|
||||
export function tileSizeForZoom(zoomPreset: ZoomPreset): number {
|
||||
switch (zoomPreset) {
|
||||
case "compact":
|
||||
@@ -200,6 +236,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
zoomPreset: "comfortable",
|
||||
selectedImage: null,
|
||||
indexingProgress: {},
|
||||
mediaJobProgress: {},
|
||||
cacheDir: "",
|
||||
|
||||
setCacheDir: (cacheDir) => set({ cacheDir }),
|
||||
@@ -210,8 +247,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
},
|
||||
|
||||
addFolder: async (path) => {
|
||||
const { cacheDir, loadFolders } = get();
|
||||
await invoke("add_folder", { path, cacheDir });
|
||||
const { loadFolders } = get();
|
||||
await invoke("add_folder", { path });
|
||||
await loadFolders();
|
||||
},
|
||||
|
||||
@@ -226,8 +263,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
},
|
||||
|
||||
reindexFolder: async (folderId) => {
|
||||
const { cacheDir, loadFolders } = get();
|
||||
await invoke("reindex_folder", { folderId, cacheDir });
|
||||
const { loadFolders } = get();
|
||||
await invoke("reindex_folder", { folderId });
|
||||
await loadFolders();
|
||||
},
|
||||
|
||||
@@ -329,6 +366,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
|
||||
if (progress.done) {
|
||||
void get().loadFolders();
|
||||
void get().loadImages(true);
|
||||
|
||||
setTimeout(() => {
|
||||
set((state) => {
|
||||
@@ -340,6 +378,16 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
}
|
||||
});
|
||||
|
||||
const unlistenMediaJobs = await listen<MediaJobProgressEvent>("media-job-progress", (event) => {
|
||||
set((state) => {
|
||||
const next = { ...state.mediaJobProgress };
|
||||
for (const progress of event.payload.progress) {
|
||||
next[progress.folder_id] = progress;
|
||||
}
|
||||
return { mediaJobProgress: next };
|
||||
});
|
||||
});
|
||||
|
||||
const unlistenImages = await listen<IndexedImagesBatch>("indexed-images", (event) => {
|
||||
const batch = event.payload;
|
||||
|
||||
@@ -359,16 +407,17 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
}
|
||||
|
||||
const newVisibleCount = countNewImages(state.images, visibleImages);
|
||||
const images = mergeImages(state.images, visibleImages, state.sort);
|
||||
const visibleWindow = Math.max(state.loadedCount, PAGE_SIZE);
|
||||
const images = mergeIntoVisibleWindow(state.images, visibleImages, state.sort, visibleWindow);
|
||||
return {
|
||||
images,
|
||||
loadedCount: images.length,
|
||||
loadedCount: Math.max(state.loadedCount, Math.min(images.length, visibleWindow)),
|
||||
totalImages: Math.max(state.totalImages + newVisibleCount, images.length),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const unlistenThumbnails = await listen<ThumbnailBatch>("thumbnail-updated", (event) => {
|
||||
const unlistenThumbnails = await listen<ThumbnailBatch>("media-updated", (event) => {
|
||||
const batch = event.payload;
|
||||
|
||||
set((state) => {
|
||||
@@ -392,7 +441,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
}
|
||||
|
||||
return {
|
||||
images: mergeImages(state.images, visibleImages, state.sort),
|
||||
images: replaceExistingImages(state.images, visibleImages, state.sort),
|
||||
selectedImage,
|
||||
};
|
||||
});
|
||||
@@ -400,6 +449,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
|
||||
return () => {
|
||||
unlistenProgress();
|
||||
unlistenMediaJobs();
|
||||
unlistenImages();
|
||||
unlistenThumbnails();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user