diff --git a/README.md b/README.md
index 784cc6b..6ee6ac6 100644
--- a/README.md
+++ b/README.md
@@ -4,16 +4,30 @@ A local-first desktop media library for browsing, filtering, and curating image
## Features
+### Library
- Add and remove media folders; background indexing with live progress
+- **Live file tracking** — a filesystem watcher keeps the library in sync as files are added, changed, or removed; renames and moves are handled in place, preserving thumbnails and embeddings
- Browse all media or filter by folder, type (image/video), favorites, or star rating
-- **Filename search**, **semantic search** (`/s query`), and **tag search** (`/t tag`)
-- **Similar image search** — find visually similar media by image or selected region
-- **AI tagging** via WD tagger (ONNX, CPU/DirectML) with confidence threshold control
-- **Explore view** — visual cluster map and tag cloud for browsing by theme
-- **Duplicate finder** — scan for exact duplicates by file hash with bulk delete
-- Lightbox preview with keyboard navigation, inline tag editing, and rating controls
-- Sort by date, name, size, rating, or duration
+- Sort by date added, date taken (EXIF), name, size, rating, or duration
- Grid density controls (compact / comfortable / detail)
+- Desktop notifications, batched per folder, with per-folder mute and a global pause
+
+### Search & discovery
+- **Filename search**, **semantic search** (`/s query`), and **tag search** (`/t tag`)
+- **Similar image search** — find visually similar media by image or a selected region
+- **Explore view** — visual cluster map and tag cloud for browsing by theme
+- **Timeline view** — media grouped chronologically by capture date (EXIF)
+- **Duplicate finder** — three-phase exact-duplicate scan (size → sample hash → full hash) with live progress and bulk delete
+- Explore, Timeline, and Duplicates are folder-scopable directly from their headers — no need to bounce through the sidebar
+
+### Viewing & curation
+- Lightbox with keyboard navigation, zoom, inline tag editing, and rating controls
+- **Custom video player** — immersive edge-to-edge playback with scrubbing, volume, playback speed, loop, and fullscreen; auto-hiding controls and full keyboard support
+- **AI tagging** via WD tagger (ONNX, CPU/DirectML) with confidence threshold, batch size, and per-folder queue targeting
+
+### Maintenance
+- Database compaction and orphaned-thumbnail cleanup from Settings, with live size/reclaimable stats
+- Per-folder pausing of background work (thumbnails, metadata, embeddings, tagging)
## Supported formats
@@ -26,9 +40,10 @@ A local-first desktop media library for browsing, filtering, and curating image
- Tauri 2 + Rust backend
- React 19 + TypeScript + Zustand
-- SQLite + `sqlite-vec` (vector search)
+- SQLite + `sqlite-vec` (vector search) + HNSW index
- ONNX Runtime (`ort`) for AI tagging
-- Candle (Rust ML) for visual embeddings
+- Candle (Rust ML) for CLIP visual embeddings
+- mozjpeg for fast scaled JPEG decoding
- FFmpeg sidecar for video thumbnails and metadata
- Vite + Tailwind CSS v4
@@ -47,12 +62,16 @@ pnpm dev:vite
# Production build
pnpm build:app
+
+# Type-check the frontend
+pnpm build:vite
```
## How it works
1. Add a folder from the sidebar — the Rust indexer walks it recursively.
-2. Supported files are written to SQLite with metadata (path, dimensions, media type, etc.).
-3. Background workers generate thumbnails, compute visual embeddings, and run AI tagging.
+2. Supported files are written to SQLite with metadata (path, dimensions, media type, EXIF capture date, etc.).
+3. Background workers process the queue as a strict priority pipeline — thumbnails first, then video metadata, then visual embeddings, then AI tags — so each stage runs at full speed instead of competing for CPU, disk, and the database.
4. Progress events stream back to the UI while the gallery updates incrementally.
-5. Embeddings power semantic search and the similar images feature via an HNSW index.
+5. A filesystem watcher picks up later changes (new files, edits, deletions, renames) and keeps the index current without rescans.
+6. Embeddings power semantic search and the similar-images feature via an HNSW index.
diff --git a/src/components/DuplicateFinder.tsx b/src/components/DuplicateFinder.tsx
index 67b361b..ce9c344 100644
--- a/src/components/DuplicateFinder.tsx
+++ b/src/components/DuplicateFinder.tsx
@@ -1,6 +1,7 @@
import { useState } from "react";
import { convertFileSrc } from "@tauri-apps/api/core";
import { DuplicateGroup, useGalleryStore } from "../store";
+import { FolderScopeDropdown } from "./FolderScopeDropdown";
function formatBytes(bytes: number): string {
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
@@ -188,6 +189,7 @@ export function DuplicateFinder() {
)}
+
{/* Batch select — only shown when there are groups and nothing is selected yet */}
{hasResults && selectedCount === 0 && !deleting && (
(null);
+
+ const folders = useGalleryStore((state) => state.folders);
+ const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
+ const setViewFolderScope = useGalleryStore((state) => state.setViewFolderScope);
+
+ useEffect(() => {
+ const close = (e: MouseEvent) => {
+ if (!ref.current?.contains(e.target as Node)) setOpen(false);
+ };
+ window.addEventListener("pointerdown", close);
+ return () => window.removeEventListener("pointerdown", close);
+ }, []);
+
+ const currentLabel =
+ selectedFolderId === null
+ ? "All Media"
+ : folders.find((folder) => folder.id === selectedFolderId)?.name ?? "All Media";
+
+ const select = (folderId: number | null) => {
+ setViewFolderScope(folderId);
+ setOpen(false);
+ };
+
+ return (
+
+
setOpen((v) => !v)}
+ className={`flex max-w-56 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${
+ open
+ ? "border-white/15 bg-white/8 text-white"
+ : "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200"
+ }`}
+ title="Change folder scope"
+ >
+
+
+
+ {currentLabel}
+
+
+
+
+ {open ? (
+
+
select(null)}
+ >
+ All Media
+ {selectedFolderId === null ? (
+
+
+
+ ) : null}
+
+ {folders.map((folder) => {
+ const active = selectedFolderId === folder.id;
+ return (
+
select(folder.id)}
+ >
+ {folder.name}
+
+ {folder.image_count.toLocaleString()}
+ {active ? (
+
+
+
+ ) : null}
+
+
+ );
+ })}
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/Lightbox.tsx b/src/components/Lightbox.tsx
index dadec21..4921002 100644
--- a/src/components/Lightbox.tsx
+++ b/src/components/Lightbox.tsx
@@ -2,6 +2,7 @@ import { useEffect, useCallback, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { convertFileSrc } from "@tauri-apps/api/core";
import { useGalleryStore, ImageTag, AiRating } from "../store";
+import { VideoPlayer } from "./VideoPlayer";
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
@@ -223,8 +224,9 @@ export function Lightbox() {
}
}
if (regionSelectMode) return; // block nav keys during selection
- if (event.key === "ArrowLeft") goPrev();
- if (event.key === "ArrowRight") goNext();
+ // Shift+arrows are reserved for video seeking (handled by VideoPlayer)
+ if (event.key === "ArrowLeft" && !event.shiftKey) goPrev();
+ if (event.key === "ArrowRight" && !event.shiftKey) goNext();
if (event.key === "+" || event.key === "=") setZoom((value) => Math.min(3, value + 0.25));
if (event.key === "-") setZoom((value) => Math.max(0.75, value - 0.25));
};
@@ -365,19 +367,18 @@ export function Lightbox() {
{selectedImage.media_kind === "video" ? (
-
+
) : (
<>
{children};
+ return {children} ;
}
-function SectionShell({ eyebrow, title, description, children }: {
- eyebrow: string;
+function SettingsGroup({ title, description, children }: {
title: string;
description?: string;
children: React.ReactNode;
}) {
return (
- {eyebrow}
- {title}
- {description ? {description}
: null}
- {children}
+ {title}
+ {description ? {description}
: null}
+ {children}
);
}
-function SettingsCard({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
- return (
-
-
-
{title}
- {description ?
{description}
: null}
+function SettingsItem({ label, description, children, vertical = false }: {
+ label: React.ReactNode;
+ description?: React.ReactNode;
+ children?: React.ReactNode;
+ vertical?: boolean;
+}) {
+ if (vertical) {
+ return (
+
+
{label}
+ {description ?
{description}
: null}
+ {children ?
{children}
: null}
-
{children}
+ );
+ }
+
+ return (
+
+
+
{label}
+ {description ?
{description}
: null}
+
+
{children}
);
}
-function SettingsRow({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
+function StatPair({ label, value, accent = false }: { label: string; value: string; accent?: boolean }) {
return (
-
-
-
{title}
-
{description}
-
-
{children}
-
+
+ {label}
+ {value}
+
);
}
@@ -72,7 +82,7 @@ function ScopeButton({ scope, current, onSelect, children }: {
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
active
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200"
- : "border-white/10 bg-white/[0.045] text-gray-500 hover:bg-white/[0.075] hover:text-gray-200"
+ : "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200"
}`}
onClick={() => onSelect(scope)}
>
@@ -94,7 +104,7 @@ function TaggerAccelerationButton({ acceleration, current, onSelect, children }:
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
active
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200"
- : "border-white/10 bg-white/[0.045] text-gray-500 hover:bg-white/[0.075] hover:text-gray-200"
+ : "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200"
}`}
onClick={() => onSelect(acceleration)}
>
@@ -277,7 +287,7 @@ export function SettingsModal() {
return (
setSettingsOpen(false)}>
event.stopPropagation()}
>
@@ -301,417 +311,391 @@ export function SettingsModal() {
-
-
-
-
{activeSection === "workspace" ? "AI Workspace" : "General"}
-
{activeSection === "workspace" ? "Model setup and queue targets" : "App data and diagnostics"}
-
-
setSettingsOpen(false)}
- title="Close settings"
- >
-
-
-
-
-
+ setSettingsOpen(false)}
+ title="Close settings"
+ >
+
+
+
+
+
+
+
+
{activeSection === "workspace" ? "AI Workspace" : "General"}
+
{activeSection === "workspace" ? "Model setup and queue targets" : "App data and diagnostics"}
-
{activeSection === "workspace" ? (
-
-
-
-
-
-
-
-
WD SwinV2 Tagger v3
-
- {taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"}
-
-
-
- Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds.
-
-
-
+
+
+
+ WD SwinV2 Tagger v3{" "}
+
+
+ {taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"}
+
+
+ >
+ }
+ description="Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds."
+ >
+
+ {taggerReady ? (
+ <>
void prepareTaggerModel()}
- disabled={taggerModelPreparing || taggerReady}
+ className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
+ onClick={() => void probeTaggerRuntime()}
+ disabled={taggerRuntimeChecking}
>
- {taggerModelProgress ? : null}
- {taggerDownloadLabel}
+ {taggerRuntimeChecking ? "Checking runtime..." : "Check runtime"}
- {taggerReady ? (
- <>
- void probeTaggerRuntime()}
- disabled={taggerRuntimeChecking}
- >
- {taggerRuntimeChecking ? "Checking runtime..." : "Check runtime"}
-
- void deleteTaggerModel()}
- disabled={taggerModelPreparing}
- >
- Delete model files
-
- >
- ) : null}
-
-
-
-
-
-
-
- {(["auto", "directml", "cpu"] as const).map((acceleration) => (
- {
- setTaggerAccelerationSaving(true);
- setTaggerAccelerationError(null);
- void setTaggerAcceleration(nextAcceleration)
- .catch((error: unknown) => setTaggerAccelerationError(String(error)))
- .finally(() => setTaggerAccelerationSaving(false));
- }}
- >
- {acceleration === "directml" ? "DirectML" : acceleration === "cpu" ? "CPU" : "Auto"}
-
- ))}
-
- {taggerAccelerationError ? (
-
{taggerAccelerationError}
- ) : (
-
{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}
- )}
-
-
-
-
-
-
setTaggerThresholdDraft(event.target.value)}
- onBlur={() => {
- const value = parseFloat(thresholdDisplay);
- if (!isNaN(value) && value >= 0.05 && value <= 0.99) {
- setTaggerThresholdError(null);
- setTaggerThresholdSaving(true);
- void setTaggerThreshold(value)
- .catch((error: unknown) => setTaggerThresholdError(String(error)))
- .finally(() => {
- setTaggerThresholdDraft(null);
- setTaggerThresholdSaving(false);
- });
- } else {
- setTaggerThresholdDraft(null);
- setTaggerThresholdError("Must be 0.05 – 0.99");
- if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current);
- thresholdErrorTimerRef.current = setTimeout(() => setTaggerThresholdError(null), 2000);
- }
- }}
- />
- {taggerThresholdError ? (
-
{taggerThresholdError}
- ) : (
-
{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}
- )}
-
-
-
-
-
-
setTaggerBatchSizeDraft(event.target.value)}
- onBlur={() => {
- const value = parseInt(batchSizeDisplay, 10);
- if (!isNaN(value) && value >= 1 && value <= 100) {
- setTaggerBatchSizeError(null);
- setTaggerBatchSizeSaving(true);
- void setTaggerBatchSize(value)
- .catch((error: unknown) => setTaggerQueueStatus(String(error)))
- .finally(() => {
- setTaggerBatchSizeDraft(null);
- setTaggerBatchSizeSaving(false);
- });
- } else {
- setTaggerBatchSizeDraft(null);
- setTaggerBatchSizeError("Must be 1 – 100");
- if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current);
- batchSizeErrorTimerRef.current = setTimeout(() => setTaggerBatchSizeError(null), 2000);
- }
- }}
- />
- {taggerBatchSizeError ? (
-
{taggerBatchSizeError}
- ) : (
-
{taggerBatchSizeSaving ? "Saving..." : "Default: 8"}
- )}
-
-
-
-
-
Model location
-
- {taggerReady ? taggerModelStatus?.local_dir : "Not downloaded"}
-
- {taggerModelProgress?.current_file ?
{taggerModelProgress.current_file}
: null}
- {taggerModelError ?
{taggerModelError}
: null}
- {taggerRuntimeProbe ? (
-
-
-
Runtime check
-
Ready
-
-
Tagger acceleration: {taggerRuntimeProbe.acceleration}
-
{taggerRuntimeProbe.session.file}
-
- ) : null}
-
-
+
void deleteTaggerModel()}
+ disabled={taggerModelPreparing}
+ >
+ Delete model files
+
+ >
+ ) : (
+
void prepareTaggerModel()}
+ disabled={taggerModelPreparing}
+ >
+ {taggerModelProgress ? : null}
+ {taggerDownloadLabel}
+
+ )}
-
+
-
-
-
- All media
- Selected folders
-
-
-
-
-
-
-
Folder selection
-
Current target: {queueScopeLabel}.
-
-
-
setTaggingQueueFolderIds(folders.map((folder) => folder.id))}
- disabled={taggingQueueScope === "all" || folders.length === 0}
+
+
+
+ {(["auto", "directml", "cpu"] as const).map((acceleration) => (
+ {
+ setTaggerAccelerationSaving(true);
+ setTaggerAccelerationError(null);
+ void setTaggerAcceleration(nextAcceleration)
+ .catch((error: unknown) => setTaggerAccelerationError(String(error)))
+ .finally(() => setTaggerAccelerationSaving(false));
+ }}
>
- Select all
-
- setTaggingQueueFolderIds([])}
- disabled={taggingQueueScope === "all" || taggingQueueFolderIds.length === 0}
- >
- Clear
-
-
+ {acceleration === "directml" ? "DirectML" : acceleration === "cpu" ? "CPU" : "Auto"}
+
+ ))}
+ {taggerAccelerationError ? (
+ {taggerAccelerationError}
+ ) : (
+ {taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}
+ )}
+
+
-
- {folders.map((folder) => {
- const active = taggingQueueFolderIds.includes(folder.id);
- const progress = mediaJobProgress[folder.id];
- return (
-
toggleTaggingQueueFolder(folder.id)}
- disabled={taggingQueueScope === "all"}
- >
-
-
{folder.name}
-
{folder.image_count.toLocaleString()} items
-
-
- {(progress?.tagging_pending ?? 0) > 0 ? {progress?.tagging_pending} queued : null}
-
-
-
- );
- })}
- {folders.length === 0 ?
Add a folder first to enable targeted tagging queues.
: null}
+
+
+
setTaggerThresholdDraft(event.target.value)}
+ onBlur={() => {
+ const value = parseFloat(thresholdDisplay);
+ if (!isNaN(value) && value >= 0.05 && value <= 0.99) {
+ setTaggerThresholdError(null);
+ setTaggerThresholdSaving(true);
+ void setTaggerThreshold(value)
+ .catch((error: unknown) => setTaggerThresholdError(String(error)))
+ .finally(() => {
+ setTaggerThresholdDraft(null);
+ setTaggerThresholdSaving(false);
+ });
+ } else {
+ setTaggerThresholdDraft(null);
+ setTaggerThresholdError("Must be 0.05 – 0.99");
+ if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current);
+ thresholdErrorTimerRef.current = setTimeout(() => setTaggerThresholdError(null), 2000);
+ }
+ }}
+ />
+ {taggerThresholdError ? (
+
{taggerThresholdError}
+ ) : (
+
{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}
+ )}
+
+
+
+
+
+
setTaggerBatchSizeDraft(event.target.value)}
+ onBlur={() => {
+ const value = parseInt(batchSizeDisplay, 10);
+ if (!isNaN(value) && value >= 1 && value <= 100) {
+ setTaggerBatchSizeError(null);
+ setTaggerBatchSizeSaving(true);
+ void setTaggerBatchSize(value)
+ .catch((error: unknown) => setTaggerQueueStatus(String(error)))
+ .finally(() => {
+ setTaggerBatchSizeDraft(null);
+ setTaggerBatchSizeSaving(false);
+ });
+ } else {
+ setTaggerBatchSizeDraft(null);
+ setTaggerBatchSizeError("Must be 1 – 100");
+ if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current);
+ batchSizeErrorTimerRef.current = setTimeout(() => setTaggerBatchSizeError(null), 2000);
+ }
+ }}
+ />
+ {taggerBatchSizeError ? (
+
{taggerBatchSizeError}
+ ) : (
+
{taggerBatchSizeSaving ? "Saving..." : "Default: 8"}
+ )}
+
+
+
+
+
+
+ {taggerReady ? taggerModelStatus?.local_dir : "Not downloaded"}
+
+ {taggerModelProgress?.current_file ?
{taggerModelProgress.current_file}
: null}
+ {taggerModelError ?
{taggerModelError}
: null}
+ {taggerRuntimeProbe ? (
+
+
+ Runtime check Ready
+ acceleration: {taggerRuntimeProbe.acceleration}
+
+
{taggerRuntimeProbe.session.file}
+
+ ) : null}
+
+
+
+
+
+
+
+ All media
+ Selected folders
+
+
+
+
+
+
+
Folder selection
+
Current target: {queueScopeLabel}.
+
+
+ setTaggingQueueFolderIds(folders.map((folder) => folder.id))}
+ disabled={taggingQueueScope === "all" || folders.length === 0}
+ >
+ Select all
+
+ setTaggingQueueFolderIds([])}
+ disabled={taggingQueueScope === "all" || taggingQueueFolderIds.length === 0}
+ >
+ Clear
+
-
-
- runQueueAction("queue")}
- disabled={!taggerReady || taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
- >
- {taggerQueueing ? "Queueing..." : "Queue tagging"}
-
- runQueueAction("clear")}
- disabled={taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
- >
- {taggerClearing ? "Clearing..." : "Clear queued jobs"}
-
-
-
+
+ {folders.map((folder) => {
+ const active = taggingQueueFolderIds.includes(folder.id);
+ const progress = mediaJobProgress[folder.id];
+ return (
+
toggleTaggingQueueFolder(folder.id)}
+ disabled={taggingQueueScope === "all"}
+ >
+ {folder.name}
+
+ {(progress?.tagging_pending ?? 0) > 0 ? {progress?.tagging_pending} queued : null}
+ {folder.image_count.toLocaleString()} items
+
+
+
+ );
+ })}
+ {folders.length === 0 ?
Add a folder first to enable targeted tagging queues.
: null}
+
+
- {taggerQueueStatus ? {taggerQueueStatus}
: null}
-
-
+
+
+ runQueueAction("queue")}
+ disabled={!taggerReady || taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
+ >
+ {taggerQueueing ? "Queueing..." : "Queue tagging"}
+
+ runQueueAction("clear")}
+ disabled={taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
+ >
+ {taggerClearing ? "Clearing..." : "Clear queued jobs"}
+
+
+
+
+ {taggerQueueStatus ? {taggerQueueStatus}
: null}
+
) : (
-
-
-
-
-
Open the app data folder in Explorer to inspect or back up files.
-
{
- setOpeningDataFolder(true);
- void openAppDataFolder().finally(() => setOpeningDataFolder(false));
- }}
- disabled={openingDataFolder}
- >
- {openingDataFolder ? "Opening..." : "Open data folder"}
-
-
-
-
-
+
+
+ {
+ setOpeningDataFolder(true);
+ void openAppDataFolder().finally(() => setOpeningDataFolder(false));
+ }}
+ disabled={openingDataFolder}
+ >
+ {openingDataFolder ? "Opening..." : "Open data folder"}
+
+
+
-
-
-
Pause all notifications
-
Suppress all indexing notifications until re-enabled.
-
-
setNotificationsPaused(!notificationsPaused)}
- >
-
-
-
-
+ setNotificationsPaused(!notificationsPaused)}
+ >
+
+
+
+
-
-
-
-
-
Database size
-
- {vacuumResult
- ? `${vacuumResult.after_mb.toFixed(1)} MB`
- : dbInfo
- ? `${dbInfo.size_mb.toFixed(1)} MB`
- : "—"}
-
-
-
-
Reclaimable
-
- {vacuumResult
- ? `−${vacuumResult.freed_mb.toFixed(1)} MB freed`
- : dbInfo
- ? `${dbInfo.reclaimable_mb.toFixed(1)} MB`
- : "—"}
-
-
-
-
-
+
+
+ Reclaims wasted space left behind when images or tags are deleted. Safe to run at any time.
+
+
+
+
+
{vacuumResult
? `Compacted from ${vacuumResult.before_mb.toFixed(1)} MB to ${vacuumResult.after_mb.toFixed(1)} MB.`
: dbInfo && dbInfo.reclaimable_mb < 0.5
? "Database is already compact."
: "Run this after removing folders or bulk-deleting images."}
-
-
{
- setVacuuming(true);
- setVacuumResult(null);
- void vacuumDatabase()
- .then((result) => {
- setVacuumResult(result);
- setDbInfo({ size_mb: result.after_mb, reclaimable_mb: 0 });
- })
- .catch(() => {})
- .finally(() => setVacuuming(false));
- }}
- disabled={vacuuming || (dbInfo !== null && dbInfo.reclaimable_mb < 0.5 && vacuumResult === null)}
- >
- {vacuuming ? "Compacting..." : "Compact now"}
-
-
-
-
-
-
+ >
+ }
>
-
-
-
-
Orphaned files
-
- {cleaningThumbnails
- ? "—"
- : thumbnailCleanupResult
+ {
+ setVacuuming(true);
+ setVacuumResult(null);
+ void vacuumDatabase()
+ .then((result) => {
+ setVacuumResult(result);
+ setDbInfo({ size_mb: result.after_mb, reclaimable_mb: 0 });
+ })
+ .catch(() => {})
+ .finally(() => setVacuuming(false));
+ }}
+ disabled={vacuuming || (dbInfo !== null && dbInfo.reclaimable_mb < 0.5)}
+ >
+ {vacuuming ? "Compacting..." : "Compact now"}
+
+
+
+
+ Thumbnails left behind when folders or images are removed. Safe to delete — they are regenerated if the originals are re-indexed.
+
+
-
-
-
Reclaimable
-
- {cleaningThumbnails
- ? "—"
- : thumbnailCleanupResult
- ? "0 MB"
+ : "—"
+ }
+ />
+
-
-
-
-
+ : "—"
+ }
+ />
+
+
{cleaningThumbnails
? "Scanning and removing orphaned thumbnails…"
: thumbnailCleanupResult
@@ -719,29 +703,30 @@ export function SettingsModal() {
: thumbnailInfo && thumbnailInfo.count === 0
? "No orphaned thumbnails found."
: thumbnailInfo && thumbnailInfo.count > 1000
- ? "Remove thumbnails no longer associated with any indexed image. This may take a few minutes for large collections."
+ ? "May take a few minutes for large collections."
: "Remove thumbnails no longer associated with any indexed image."}
-
-
{
- setCleaningThumbnails(true);
- cleanupOrphanedThumbnails()
- .then((result) => {
- setThumbnailCleanupResult(result);
- setThumbnailInfo(null);
- })
- .catch(() => {})
- .finally(() => setCleaningThumbnails(false));
- }}
- disabled={cleaningThumbnails || thumbnailCleanupResult !== null || (thumbnailInfo !== null && thumbnailInfo.count === 0)}
- >
- {cleaningThumbnails ? "Cleaning…" : "Clean up"}
-
-
-
-
-
+
+ >
+ }
+ >
+
{
+ setCleaningThumbnails(true);
+ cleanupOrphanedThumbnails()
+ .then((result) => {
+ setThumbnailCleanupResult(result);
+ setThumbnailInfo(null);
+ })
+ .catch(() => {})
+ .finally(() => setCleaningThumbnails(false));
+ }}
+ disabled={cleaningThumbnails || thumbnailCleanupResult !== null || (thumbnailInfo !== null && thumbnailInfo.count === 0)}
+ >
+ {cleaningThumbnails ? "Cleaning…" : "Clean up"}
+
+
+
)}
diff --git a/src/components/TagCloud.tsx b/src/components/TagCloud.tsx
index 43d2fcc..fffae2f 100644
--- a/src/components/TagCloud.tsx
+++ b/src/components/TagCloud.tsx
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { motion } from "framer-motion";
import { convertFileSrc } from "@tauri-apps/api/core";
import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store";
+import { FolderScopeDropdown } from "./FolderScopeDropdown";
const ACCENTS = [
"#60a5fa",
@@ -317,23 +318,26 @@ export function TagCloud() {
: "No tags — run the AI tagger or add tags manually"}
-
-
setExploreMode("visual")}
- >
- Clusters
-
-
setExploreMode("tags")}
- >
- Tag Cloud
-
+
+
+
+ setExploreMode("visual")}
+ >
+ Clusters
+
+ setExploreMode("tags")}
+ >
+ Tag Cloud
+
+
diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx
index 02f4041..c276410 100644
--- a/src/components/Toolbar.tsx
+++ b/src/components/Toolbar.tsx
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
+import { FolderScopeDropdown } from "./FolderScopeDropdown";
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
{ value: "date_desc", label: "Newest first" },
@@ -162,6 +163,7 @@ export function Toolbar() {
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
+ const activeView = useGalleryStore((state) => state.activeView);
const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0);
@@ -269,6 +271,8 @@ export function Toolbar() {
)}
+ {activeView === "timeline" ? : null}
+
{/* Search */}
diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx
new file mode 100644
index 0000000..90a6012
--- /dev/null
+++ b/src/components/VideoPlayer.tsx
@@ -0,0 +1,448 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { AnimatePresence, motion } from "framer-motion";
+
+const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
+const CONTROLS_HIDE_DELAY_MS = 2500;
+const SEEK_STEP_SECONDS = 5;
+const VOLUME_STEP = 0.1;
+
+// Session-wide playback preferences shared across player instances.
+let persistedVolume = 1;
+let persistedMuted = false;
+
+function formatTime(seconds: number): string {
+ if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
+ const total = Math.floor(seconds);
+ const s = total % 60;
+ const m = Math.floor(total / 60) % 60;
+ const h = Math.floor(total / 3600);
+ if (h > 0) return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
+ return `${m}:${s.toString().padStart(2, "0")}`;
+}
+
+interface BufferedRange {
+ start: number;
+ end: number;
+}
+
+function ControlButton({ onClick, title, active = false, children }: {
+ onClick: () => void;
+ title: string;
+ active?: boolean;
+ children: React.ReactNode;
+}) {
+ return (
+ {
+ event.stopPropagation();
+ onClick();
+ }}
+ title={title}
+ >
+ {children}
+
+ );
+}
+
+export function VideoPlayer({ src }: { src: string }) {
+ const containerRef = useRef(null);
+ const videoRef = useRef(null);
+ const trackRef = useRef(null);
+ const hideTimerRef = useRef | null>(null);
+ const scrubbingRef = useRef(false);
+
+ const [playing, setPlaying] = useState(false);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [duration, setDuration] = useState(0);
+ const [buffered, setBuffered] = useState([]);
+ const [volume, setVolume] = useState(persistedVolume);
+ const [muted, setMuted] = useState(persistedMuted);
+ const [playbackRate, setPlaybackRate] = useState(1);
+ const [loop, setLoop] = useState(false);
+ const [fullscreen, setFullscreen] = useState(false);
+ const [controlsVisible, setControlsVisible] = useState(true);
+ const [speedMenuOpen, setSpeedMenuOpen] = useState(false);
+
+ const speedMenuOpenRef = useRef(speedMenuOpen);
+ speedMenuOpenRef.current = speedMenuOpen;
+
+ // ── Controls visibility ────────────────────────────────────────────────────
+ const showControls = useCallback(() => {
+ setControlsVisible(true);
+ if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
+ hideTimerRef.current = setTimeout(() => {
+ const video = videoRef.current;
+ if (video && !video.paused && !scrubbingRef.current && !speedMenuOpenRef.current) {
+ setControlsVisible(false);
+ }
+ }, CONTROLS_HIDE_DELAY_MS);
+ }, []);
+
+ useEffect(() => {
+ return () => {
+ if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
+ };
+ }, []);
+
+ // ── Video element wiring ───────────────────────────────────────────────────
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video) return;
+ video.volume = persistedVolume;
+ video.muted = persistedMuted;
+ // Autoplay; if the webview blocks it the catch leaves us paused with
+ // controls visible, which is a fine fallback.
+ video.play().catch(() => {});
+ }, [src]);
+
+ const readBuffered = useCallback(() => {
+ const video = videoRef.current;
+ if (!video || !Number.isFinite(video.duration) || video.duration <= 0) return;
+ const ranges: BufferedRange[] = [];
+ for (let i = 0; i < video.buffered.length; i++) {
+ ranges.push({
+ start: video.buffered.start(i) / video.duration,
+ end: video.buffered.end(i) / video.duration,
+ });
+ }
+ setBuffered(ranges);
+ }, []);
+
+ const togglePlay = useCallback(() => {
+ const video = videoRef.current;
+ if (!video) return;
+ if (video.paused) {
+ void video.play().catch(() => {});
+ } else {
+ video.pause();
+ }
+ showControls();
+ }, [showControls]);
+
+ const seekBy = useCallback(
+ (deltaSeconds: number) => {
+ const video = videoRef.current;
+ if (!video || !Number.isFinite(video.duration)) return;
+ video.currentTime = Math.min(video.duration, Math.max(0, video.currentTime + deltaSeconds));
+ showControls();
+ },
+ [showControls],
+ );
+
+ const applyVolume = useCallback(
+ (nextVolume: number, nextMuted?: boolean) => {
+ const video = videoRef.current;
+ const clamped = Math.min(1, Math.max(0, nextVolume));
+ setVolume(clamped);
+ persistedVolume = clamped;
+ if (nextMuted !== undefined) {
+ setMuted(nextMuted);
+ persistedMuted = nextMuted;
+ if (video) video.muted = nextMuted;
+ }
+ if (video) video.volume = clamped;
+ showControls();
+ },
+ [showControls],
+ );
+
+ const toggleMute = useCallback(() => {
+ const next = !muted;
+ setMuted(next);
+ persistedMuted = next;
+ const video = videoRef.current;
+ if (video) video.muted = next;
+ showControls();
+ }, [muted, showControls]);
+
+ const toggleLoop = useCallback(() => {
+ setLoop((value) => {
+ const video = videoRef.current;
+ if (video) video.loop = !value;
+ return !value;
+ });
+ showControls();
+ }, [showControls]);
+
+ const toggleFullscreen = useCallback(() => {
+ if (document.fullscreenElement) {
+ void document.exitFullscreen().catch(() => {});
+ } else {
+ void containerRef.current?.requestFullscreen().catch(() => {});
+ }
+ showControls();
+ }, [showControls]);
+
+ useEffect(() => {
+ const onChange = () => setFullscreen(document.fullscreenElement !== null);
+ document.addEventListener("fullscreenchange", onChange);
+ return () => document.removeEventListener("fullscreenchange", onChange);
+ }, []);
+
+ const setSpeed = useCallback(
+ (rate: number) => {
+ setPlaybackRate(rate);
+ const video = videoRef.current;
+ if (video) video.playbackRate = rate;
+ setSpeedMenuOpen(false);
+ showControls();
+ },
+ [showControls],
+ );
+
+ // ── Scrubber ───────────────────────────────────────────────────────────────
+ const seekToPointer = useCallback((clientX: number) => {
+ const video = videoRef.current;
+ const track = trackRef.current;
+ if (!video || !track || !Number.isFinite(video.duration) || video.duration <= 0) return;
+ const bounds = track.getBoundingClientRect();
+ const fraction = Math.min(1, Math.max(0, (clientX - bounds.left) / bounds.width));
+ video.currentTime = fraction * video.duration;
+ setCurrentTime(video.currentTime);
+ }, []);
+
+ const handleTrackPointerDown = useCallback(
+ (event: React.PointerEvent) => {
+ event.stopPropagation();
+ event.currentTarget.setPointerCapture(event.pointerId);
+ scrubbingRef.current = true;
+ seekToPointer(event.clientX);
+ },
+ [seekToPointer],
+ );
+
+ const handleTrackPointerMove = useCallback(
+ (event: React.PointerEvent) => {
+ if (!scrubbingRef.current) return;
+ seekToPointer(event.clientX);
+ },
+ [seekToPointer],
+ );
+
+ const handleTrackPointerUp = useCallback((event: React.PointerEvent) => {
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ scrubbingRef.current = false;
+ }, []);
+
+ // ── Keyboard ───────────────────────────────────────────────────────────────
+ useEffect(() => {
+ const handler = (event: KeyboardEvent) => {
+ const target = event.target as HTMLElement | null;
+ if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) {
+ return;
+ }
+ switch (event.key) {
+ case " ":
+ event.preventDefault();
+ togglePlay();
+ break;
+ case "m":
+ case "M":
+ toggleMute();
+ break;
+ case "f":
+ case "F":
+ toggleFullscreen();
+ break;
+ case "l":
+ case "L":
+ toggleLoop();
+ break;
+ case "ArrowLeft":
+ if (event.shiftKey) {
+ event.preventDefault();
+ seekBy(-SEEK_STEP_SECONDS);
+ }
+ break;
+ case "ArrowRight":
+ if (event.shiftKey) {
+ event.preventDefault();
+ seekBy(SEEK_STEP_SECONDS);
+ }
+ break;
+ case "ArrowUp":
+ event.preventDefault();
+ // Adjusting volume always unmutes, matching the slider behavior.
+ applyVolume(persistedVolume + VOLUME_STEP, false);
+ break;
+ case "ArrowDown":
+ event.preventDefault();
+ applyVolume(persistedVolume - VOLUME_STEP, false);
+ break;
+ }
+ };
+ window.addEventListener("keydown", handler);
+ return () => window.removeEventListener("keydown", handler);
+ }, [togglePlay, toggleMute, toggleFullscreen, toggleLoop, seekBy, applyVolume]);
+
+ const playedFraction = duration > 0 ? currentTime / duration : 0;
+ const effectiveVolume = muted ? 0 : volume;
+
+ return (
+ event.stopPropagation()}
+ >
+
{
+ setPlaying(true);
+ showControls();
+ }}
+ onPause={() => {
+ setPlaying(false);
+ setControlsVisible(true);
+ }}
+ onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
+ onLoadedMetadata={(event) => {
+ setDuration(event.currentTarget.duration);
+ readBuffered();
+ }}
+ onDurationChange={(event) => setDuration(event.currentTarget.duration)}
+ onProgress={readBuffered}
+ />
+
+
+ {controlsVisible ? (
+
+ {/* Scrubber */}
+
+
+ {buffered.map((range, index) => (
+
+ ))}
+
+
+
+
+
+ {/* Control row */}
+
+
+ {playing ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {formatTime(currentTime)} / {formatTime(duration)}
+
+
+
+
+ {/* Volume */}
+
+ {effectiveVolume === 0 ? (
+
+
+
+
+ ) : (
+
+
+
+
+ )}
+
+
applyVolume(parseFloat(event.target.value), false)}
+ onClick={(event) => event.stopPropagation()}
+ title="Volume (↑/↓)"
+ />
+
+ {/* Playback speed */}
+
+
{
+ event.stopPropagation();
+ setSpeedMenuOpen((value) => !value);
+ }}
+ title="Playback speed"
+ >
+ {playbackRate}×
+
+ {speedMenuOpen ? (
+
+ {SPEED_OPTIONS.map((rate) => (
+ {
+ event.stopPropagation();
+ setSpeed(rate);
+ }}
+ >
+ {rate}×
+
+ ))}
+
+ ) : null}
+
+
+ {/* Loop */}
+
+
+
+
+
+
+ {/* Fullscreen */}
+
+ {fullscreen ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/store.ts b/src/store.ts
index 1fd5eb6..f5364b9 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -337,6 +337,7 @@ interface GalleryState {
renameFolder: (folderId: number, newName: string) => Promise;
updateFolderPath: (folderId: number, newPath: string) => Promise;
selectFolder: (folderId: number | null) => void;
+ setViewFolderScope: (folderId: number | null) => void;
loadImages: (reset?: boolean) => Promise;
loadMoreImages: () => Promise;
setSearch: (search: string) => void;
@@ -755,6 +756,34 @@ export const useGalleryStore = create((set, get) => ({
void get().loadImages(true);
},
+ // Change folder scope from inside a feature view (Timeline/Explore/Duplicates)
+ // without leaving it — unlike selectFolder, activeView is preserved.
+ setViewFolderScope: (folderId) => {
+ const { activeView, selectedFolderId } = get();
+ if (folderId === selectedFolderId) return;
+
+ set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
+
+ if (activeView === "duplicates") {
+ const { duplicateScanFolderId } = get();
+ if (duplicateScanFolderId !== folderId) {
+ set({
+ duplicateGroups: [],
+ duplicateLastScanned: null,
+ duplicateScanFolderId: undefined,
+ duplicateScanWarning: null,
+ });
+ void get().loadDuplicateScanCache(folderId);
+ }
+ return;
+ }
+
+ // Explore reloads itself via TagCloud's useEffect on selectedFolderId.
+ if (activeView === "explore") return;
+
+ void get().loadImages(true);
+ },
+
loadImages: async (reset = false) => {
const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly } = get();
const parsedSearch = parseSearchValue(search);