Compare commits

...

4 Commits

Author SHA1 Message Date
LyAhn 4cd3bbd4fd docs(readme): bring feature list and architecture up to date
Adds everything shipped since the last README pass: live file tracking
with rename-aware watching, Timeline view, batched notifications with
mute/pause, the custom lightbox video player, in-view folder scoping
for Explore/Timeline/Duplicates, three-phase duplicate scanning,
maintenance tools (database compaction, thumbnail cleanup), per-folder
worker pausing, and the strict-priority background worker pipeline.
Features are now grouped by area, and the stack/how-it-works sections
mention mozjpeg scaled decoding, EXIF capture dates, and the watcher.
2026-06-12 12:49:30 +01:00
LyAhn 86ce7bc8e2 feat(lightbox): custom immersive video player
Replace the native <video controls> element with a custom player.
Videos now fill the lightbox media area edge-to-edge on black instead
of sitting in a padded, rounded box, with an auto-hiding control bar
over a bottom gradient (2.5s idle, always visible while paused):
play/pause, drag scrubber with buffered ranges, time readout, volume
slider with session-persisted volume/mute, playback speed (0.25-2x),
loop, and fullscreen.

Click toggles playback, double-click toggles fullscreen. Keyboard:
Space play/pause, M mute, F fullscreen, L loop, Shift+Left/Right seek
5s, Up/Down volume (unmutes) — plain arrows still navigate between
media, and all player keys are ignored while typing in the info-panel
inputs. Videos autoplay on open, falling back to paused-with-controls
if the webview blocks it.
2026-06-12 12:39:04 +01:00
LyAhn b02bf1da2b feat(views): in-view folder scope switching
Timeline, Explore, and Duplicates get a folder-scope dropdown in their
headers (Timeline via the shared Toolbar), so changing scope no longer
means leaving the feature and bouncing through the sidebar. The new
setViewFolderScope store action updates selectedFolderId while
preserving activeView: Timeline/Gallery reload images, Explore reloads
via its existing selectedFolderId effect, and Duplicates loads the
cached results for the new scope (fresh scans remain an explicit
Rescan). Sidebar behavior is unchanged — clicking a library still
opens its gallery.
2026-06-12 12:39:04 +01:00
LyAhn cd7dd89f00 feat(settings): flat desktop redesign with proportional sizing
Complete presentation rework of the Settings modal away from the
card-based tablet look:

- Dialog now sizes proportionally (85vw x 85vh, capped at 1400x900)
  and resizes with the app window instead of a fixed centred square.
- Content rebuilt as flat form rows in the VS Code preferences style:
  uppercase group headings with hairline-divided label/control rows —
  no nested cards, panels, or boxed strips. Sidebar retained.
- Duplicated content header removed; close button floats top-right.
- Folder selection restyled from bordered chips to flat divider rows;
  model path and runtime check render as plain monospace text.

Also fixes two maintenance-section bugs: thumbnail-cache stats no
longer blank to em-dashes while a cleanup is running, and the Compact
now button disables immediately after compaction instead of allowing
pointless re-runs until the section remounted.
2026-06-12 12:07:52 +01:00
9 changed files with 1056 additions and 466 deletions
+31 -12
View File
@@ -4,16 +4,30 @@ A local-first desktop media library for browsing, filtering, and curating image
## Features ## Features
### Library
- Add and remove media folders; background indexing with live progress - 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 - 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`) - Sort by date added, date taken (EXIF), name, size, rating, or duration
- **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
- Grid density controls (compact / comfortable / detail) - 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 ## Supported formats
@@ -26,9 +40,10 @@ A local-first desktop media library for browsing, filtering, and curating image
- Tauri 2 + Rust backend - Tauri 2 + Rust backend
- React 19 + TypeScript + Zustand - React 19 + TypeScript + Zustand
- SQLite + `sqlite-vec` (vector search) - SQLite + `sqlite-vec` (vector search) + HNSW index
- ONNX Runtime (`ort`) for AI tagging - 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 - FFmpeg sidecar for video thumbnails and metadata
- Vite + Tailwind CSS v4 - Vite + Tailwind CSS v4
@@ -47,12 +62,16 @@ pnpm dev:vite
# Production build # Production build
pnpm build:app pnpm build:app
# Type-check the frontend
pnpm build:vite
``` ```
## How it works ## How it works
1. Add a folder from the sidebar — the Rust indexer walks it recursively. 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.). 2. Supported files are written to SQLite with metadata (path, dimensions, media type, EXIF capture date, etc.).
3. Background workers generate thumbnails, compute visual embeddings, and run AI tagging. 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. 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.
+2
View File
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc } from "@tauri-apps/api/core";
import { DuplicateGroup, useGalleryStore } from "../store"; import { DuplicateGroup, useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`; if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
@@ -188,6 +189,7 @@ export function DuplicateFinder() {
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FolderScopeDropdown />
{/* Batch select — only shown when there are groups and nothing is selected yet */} {/* Batch select — only shown when there are groups and nothing is selected yet */}
{hasResults && selectedCount === 0 && !deleting && ( {hasResults && selectedCount === 0 && !deleting && (
<button <button
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useRef, useState } from "react";
import { useGalleryStore } from "../store";
/**
* In-view folder scope picker for feature views (Timeline / Explore /
* Duplicates). Changes the scope via setViewFolderScope, which keeps the
* current view active — unlike sidebar folder clicks, which jump to Gallery.
*/
export function FolderScopeDropdown() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(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 (
<div ref={ref} className="relative">
<button
onClick={() => 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"
>
<svg className="h-3.5 w-3.5 shrink-0 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span className="truncate">{currentLabel}</span>
<svg
className={`h-3 w-3 shrink-0 text-gray-500 transition-transform duration-150 ${open ? "rotate-180" : ""}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open ? (
<div className="absolute right-0 top-full z-30 mt-1.5 max-h-80 min-w-52 overflow-y-auto rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur">
<button
className={`flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
selectedFolderId === null ? "bg-white/6 text-white" : "text-gray-400 hover:bg-white/5 hover:text-white"
}`}
onClick={() => select(null)}
>
All Media
{selectedFolderId === null ? (
<svg className="h-3.5 w-3.5 shrink-0 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : null}
</button>
{folders.map((folder) => {
const active = selectedFolderId === folder.id;
return (
<button
key={folder.id}
className={`flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
active ? "bg-white/6 text-white" : "text-gray-400 hover:bg-white/5 hover:text-white"
}`}
onClick={() => select(folder.id)}
>
<span className="min-w-0 truncate">{folder.name}</span>
<span className="flex shrink-0 items-center gap-2">
<span className="text-[11px] tabular-nums text-gray-600">{folder.image_count.toLocaleString()}</span>
{active ? (
<svg className="h-3.5 w-3.5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : null}
</span>
</button>
);
})}
</div>
) : null}
</div>
);
}
+10 -9
View File
@@ -2,6 +2,7 @@ import { useEffect, useCallback, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc } from "@tauri-apps/api/core";
import { useGalleryStore, ImageTag, AiRating } from "../store"; import { useGalleryStore, ImageTag, AiRating } from "../store";
import { VideoPlayer } from "./VideoPlayer";
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
@@ -223,8 +224,9 @@ export function Lightbox() {
} }
} }
if (regionSelectMode) return; // block nav keys during selection if (regionSelectMode) return; // block nav keys during selection
if (event.key === "ArrowLeft") goPrev(); // Shift+arrows are reserved for video seeking (handled by VideoPlayer)
if (event.key === "ArrowRight") goNext(); 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 === "+" || event.key === "=") setZoom((value) => Math.min(3, value + 0.25));
if (event.key === "-") setZoom((value) => Math.max(0.75, value - 0.25)); if (event.key === "-") setZoom((value) => Math.max(0.75, value - 0.25));
}; };
@@ -365,19 +367,18 @@ export function Lightbox() {
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<motion.div <motion.div
key={selectedImage.id} key={selectedImage.id}
className="flex items-center justify-center" className={
selectedImage.media_kind === "video"
? "absolute inset-0"
: "flex items-center justify-center"
}
initial={{ opacity: 0.3, scale: 0.985 }} initial={{ opacity: 0.3, scale: 0.985 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0.3, scale: 0.985 }} exit={{ opacity: 0.3, scale: 0.985 }}
transition={{ duration: 0.12 }} transition={{ duration: 0.12 }}
> >
{selectedImage.media_kind === "video" ? ( {selectedImage.media_kind === "video" ? (
<video <VideoPlayer src={convertFileSrc(selectedImage.path)} />
src={convertFileSrc(selectedImage.path)}
controls
className="max-h-full max-w-full rounded-2xl shadow-2xl"
style={{ maxHeight: "calc(100vh - 10rem)" }}
/>
) : ( ) : (
<> <>
<img <img
+178 -193
View File
@@ -16,46 +16,56 @@ function StatusPill({ children, tone }: { children: React.ReactNode; tone: "read
? "border-sky-400/25 bg-sky-500/10 text-sky-300" ? "border-sky-400/25 bg-sky-500/10 text-sky-300"
: "border-white/10 bg-white/[0.04] text-gray-500"; : "border-white/10 bg-white/[0.04] text-gray-500";
return <span className={`inline-flex rounded-md border px-2 py-1 text-[11px] font-medium ${className}`}>{children}</span>; return <span className={`inline-flex rounded-md border px-2 py-0.5 text-[11px] font-medium ${className}`}>{children}</span>;
} }
function SectionShell({ eyebrow, title, description, children }: { function SettingsGroup({ title, description, children }: {
eyebrow: string;
title: string; title: string;
description?: string; description?: string;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section> <section>
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-gray-600">{eyebrow}</p> <h4 className="text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">{title}</h4>
<h3 className="mt-1 text-lg font-semibold text-white">{title}</h3> {description ? <p className="mt-1 text-xs leading-relaxed text-gray-600">{description}</p> : null}
{description ? <p className="mt-2 max-w-2xl text-sm leading-relaxed text-gray-500">{description}</p> : null} <div className="mt-1 divide-y divide-white/[0.05]">{children}</div>
<div className="mt-5 space-y-4">{children}</div>
</section> </section>
); );
} }
function SettingsCard({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) { function SettingsItem({ label, description, children, vertical = false }: {
label: React.ReactNode;
description?: React.ReactNode;
children?: React.ReactNode;
vertical?: boolean;
}) {
if (vertical) {
return ( return (
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] p-5"> <div className="py-4">
<div className="flex flex-col gap-1 border-b border-white/[0.07] pb-4"> <p className="text-sm text-white">{label}</p>
<p className="text-sm font-medium text-white">{title}</p> {description ? <div className="mt-1 text-xs leading-relaxed text-gray-500">{description}</div> : null}
{description ? <p className="text-xs leading-relaxed text-gray-500">{description}</p> : null} {children ? <div className="mt-3">{children}</div> : null}
</div> </div>
<div className="pt-4">{children}</div> );
}
return (
<div className="flex items-start justify-between gap-6 py-4">
<div className="min-w-0">
<p className="text-sm text-white">{label}</p>
{description ? <div className="mt-1 max-w-xl text-xs leading-relaxed text-gray-500">{description}</div> : null}
</div>
<div className="shrink-0">{children}</div>
</div> </div>
); );
} }
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 ( return (
<div className="flex items-start justify-between gap-5 border-b border-white/[0.07] py-4 last:border-b-0 first:pt-0 last:pb-0"> <span className="inline-flex items-baseline gap-2">
<div className="min-w-0"> <span className="text-[10px] uppercase tracking-[0.14em] text-gray-600">{label}</span>
<p className="text-sm font-medium text-white">{title}</p> <span className={`text-sm font-semibold tabular-nums ${accent ? "text-emerald-300" : "text-white"}`}>{value}</span>
<p className="mt-1 max-w-md text-xs leading-relaxed text-gray-500">{description}</p> </span>
</div>
<div className="shrink-0">{children}</div>
</div>
); );
} }
@@ -72,7 +82,7 @@ function ScopeButton({ scope, current, onSelect, children }: {
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${ className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
active active
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200" ? "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)} 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 ${ className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
active active
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200" ? "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)} onClick={() => onSelect(acceleration)}
> >
@@ -277,7 +287,7 @@ export function SettingsModal() {
return ( return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm" onClick={() => setSettingsOpen(false)}> <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm" onClick={() => setSettingsOpen(false)}>
<div <div
className="flex h-[min(760px,calc(100vh-56px))] w-full max-w-5xl overflow-hidden rounded-lg border border-white/10 bg-[#07080f] shadow-2xl shadow-black/60" className="relative flex h-[min(85vh,900px)] w-[min(85vw,1400px)] overflow-hidden rounded-lg border border-white/10 bg-[#07080f] shadow-2xl shadow-black/60"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<aside className="flex w-64 shrink-0 flex-col border-r border-white/[0.07] bg-white/[0.025]"> <aside className="flex w-64 shrink-0 flex-col border-r border-white/[0.07] bg-white/[0.025]">
@@ -301,14 +311,8 @@ export function SettingsModal() {
</div> </div>
</aside> </aside>
<main className="flex min-w-0 flex-1 flex-col">
<div className="flex h-14 shrink-0 items-center justify-between border-b border-white/[0.07] px-6">
<div>
<p className="text-sm font-medium text-white">{activeSection === "workspace" ? "AI Workspace" : "General"}</p>
<p className="text-xs text-gray-600">{activeSection === "workspace" ? "Model setup and queue targets" : "App data and diagnostics"}</p>
</div>
<button <button
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white" className="absolute right-4 top-4 z-10 rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={() => setSettingsOpen(false)} onClick={() => setSettingsOpen(false)}
title="Close settings" title="Close settings"
> >
@@ -316,38 +320,29 @@ export function SettingsModal() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</div>
<div className="flex-1 overflow-y-auto px-7 py-6"> <main className="min-w-0 flex-1 overflow-y-auto">
<div className="px-10 py-8">
<h3 className="text-lg font-semibold text-white">{activeSection === "workspace" ? "AI Workspace" : "General"}</h3>
<p className="mt-1 text-xs text-gray-600">{activeSection === "workspace" ? "Model setup and queue targets" : "App data and diagnostics"}</p>
{activeSection === "workspace" ? ( {activeSection === "workspace" ? (
<div className="space-y-8"> <div className="mt-8 space-y-9">
<SectionShell <SettingsGroup title="Model">
eyebrow="AI Workspace" <SettingsItem
title="Tagging" label={
> <>
<SettingsCard title="Tagging Models"> WD SwinV2 Tagger v3{" "}
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4"> <span className="ml-1.5 align-middle">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-white">WD SwinV2 Tagger v3</p>
<StatusPill tone={taggerReady ? "ready" : taggerModelPreparing ? "busy" : "muted"}> <StatusPill tone={taggerReady ? "ready" : taggerModelPreparing ? "busy" : "muted"}>
{taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"} {taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"}
</StatusPill> </StatusPill>
</div> </span>
<p className="mt-1 max-w-xl text-xs leading-relaxed text-gray-500"> </>
Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds. }
</p> description="Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds."
</div>
<div className="flex flex-col items-end gap-2">
<button
className="relative overflow-hidden 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 prepareTaggerModel()}
disabled={taggerModelPreparing || taggerReady}
> >
{taggerModelProgress ? <span className="absolute inset-y-0 left-0 bg-emerald-400/15 transition-[width] duration-200" style={{ width: `${taggerDownloadPercent}%` }} /> : null} <div className="flex items-center gap-2">
<span className="relative">{taggerDownloadLabel}</span>
</button>
{taggerReady ? ( {taggerReady ? (
<> <>
<button <button
@@ -365,14 +360,22 @@ export function SettingsModal() {
Delete model files Delete model files
</button> </button>
</> </>
) : null} ) : (
</div> <button
className="relative overflow-hidden 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 prepareTaggerModel()}
disabled={taggerModelPreparing}
>
{taggerModelProgress ? <span className="absolute inset-y-0 left-0 bg-emerald-400/15 transition-[width] duration-200" style={{ width: `${taggerDownloadPercent}%` }} /> : null}
<span className="relative">{taggerDownloadLabel}</span>
</button>
)}
</div> </div>
</SettingsItem>
<div className="mt-4 space-y-4 border-t border-white/[0.07] pt-4"> <SettingsItem label="Tagger acceleration" description="Use DirectML when available, or fall back to CPU for reliability.">
<SettingsRow title="Tagger acceleration" description="Use DirectML when available, or fall back to CPU for reliability."> <div className="flex flex-col items-end gap-1.5">
<div className="flex flex-col items-end gap-2"> <div className="flex rounded-lg border border-white/[0.07] p-0.5">
<div className="flex rounded-lg border border-white/[0.07] bg-black/20 p-1">
{(["auto", "directml", "cpu"] as const).map((acceleration) => ( {(["auto", "directml", "cpu"] as const).map((acceleration) => (
<TaggerAccelerationButton <TaggerAccelerationButton
key={acceleration} key={acceleration}
@@ -396,10 +399,10 @@ export function SettingsModal() {
<p className="text-[11px] text-gray-600">{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}</p> <p className="text-[11px] text-gray-600">{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}</p>
)} )}
</div> </div>
</SettingsRow> </SettingsItem>
<SettingsRow title="Confidence threshold" description="Lower values keep more tags. Higher values are stricter and usually cleaner."> <SettingsItem label="Confidence threshold" description="Lower values keep more tags. Higher values are stricter and usually cleaner.">
<div className="flex flex-col items-end gap-2"> <div className="flex flex-col items-end gap-1.5">
<input <input
type="number" type="number"
min="0.05" min="0.05"
@@ -433,10 +436,10 @@ export function SettingsModal() {
<p className="text-[11px] text-gray-600">{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}</p> <p className="text-[11px] text-gray-600">{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}</p>
)} )}
</div> </div>
</SettingsRow> </SettingsItem>
<SettingsRow title="Tagging batch size" description="Number of images processed concurrently during tag generation."> <SettingsItem label="Tagging batch size" description="Number of images processed concurrently during tag generation.">
<div className="flex flex-col items-end gap-2"> <div className="flex flex-col items-end gap-1.5">
<input <input
type="number" type="number"
min="1" min="1"
@@ -470,42 +473,40 @@ export function SettingsModal() {
<p className="text-[11px] text-gray-600">{taggerBatchSizeSaving ? "Saving..." : "Default: 8"}</p> <p className="text-[11px] text-gray-600">{taggerBatchSizeSaving ? "Saving..." : "Default: 8"}</p>
)} )}
</div> </div>
</SettingsRow> </SettingsItem>
<SettingsItem label="Model location" vertical>
<div> <div>
<p className="text-xs font-medium text-gray-400">Model location</p> <p className="break-all font-mono text-xs text-gray-600">
<p className="mt-2 break-all rounded-md border border-white/[0.07] bg-black/20 px-3 py-2 text-xs text-gray-600">
{taggerReady ? taggerModelStatus?.local_dir : "Not downloaded"} {taggerReady ? taggerModelStatus?.local_dir : "Not downloaded"}
</p> </p>
{taggerModelProgress?.current_file ? <p className="mt-3 break-all text-xs text-gray-500">{taggerModelProgress.current_file}</p> : null} {taggerModelProgress?.current_file ? <p className="mt-2 break-all text-xs text-gray-500">{taggerModelProgress.current_file}</p> : null}
{taggerModelError ? <p className="mt-3 text-xs text-amber-300">{taggerModelError}</p> : null} {taggerModelError ? <p className="mt-2 text-xs text-amber-300">{taggerModelError}</p> : null}
{taggerRuntimeProbe ? ( {taggerRuntimeProbe ? (
<div className="mt-4 rounded-lg border border-white/[0.07] bg-black/20 px-3 py-3"> <div className="mt-3">
<div className="flex items-center justify-between gap-3"> <p className="text-xs text-gray-400">
<p className="text-xs font-medium text-gray-400">Runtime check</p> Runtime check <span className="ml-1.5 align-middle"><StatusPill tone="ready">Ready</StatusPill></span>
<StatusPill tone="ready">Ready</StatusPill> <span className="ml-2 text-gray-600">acceleration: {taggerRuntimeProbe.acceleration}</span>
</div> </p>
<p className="mt-2 text-xs text-gray-600">Tagger acceleration: {taggerRuntimeProbe.acceleration}</p> <p className="mt-1.5 break-all font-mono text-xs text-gray-600">{taggerRuntimeProbe.session.file}</p>
<p className="mt-2 break-all text-xs text-gray-500">{taggerRuntimeProbe.session.file}</p>
</div> </div>
) : null} ) : null}
</div> </div>
</div> </SettingsItem>
</div> </SettingsGroup>
</SettingsCard>
<SettingsCard title="Queue Targets" description="Choose which folders to include when queuing tagging jobs."> <SettingsGroup title="Queue targets" description="Choose which folders to include when queuing tagging jobs.">
<SettingsRow title="Target scope" description="Queue across the full library, or choose a specific folder set and keep the modal open while you work through it."> <SettingsItem label="Target scope" description="Queue across the full library, or choose a specific folder set and keep the modal open while you work through it.">
<div className="flex rounded-lg border border-white/[0.07] bg-black/20 p-1"> <div className="flex rounded-lg border border-white/[0.07] p-0.5">
<ScopeButton scope="all" current={taggingQueueScope} onSelect={setTaggingQueueScope}>All media</ScopeButton> <ScopeButton scope="all" current={taggingQueueScope} onSelect={setTaggingQueueScope}>All media</ScopeButton>
<ScopeButton scope="selected" current={taggingQueueScope} onSelect={setTaggingQueueScope}>Selected folders</ScopeButton> <ScopeButton scope="selected" current={taggingQueueScope} onSelect={setTaggingQueueScope}>Selected folders</ScopeButton>
</div> </div>
</SettingsRow> </SettingsItem>
<div className="border-b border-white/[0.07] py-4"> <div className="py-4">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div> <div>
<p className="text-sm font-medium text-white">Folder selection</p> <p className="text-sm text-white">Folder selection</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">Current target: {queueScopeLabel}.</p> <p className="mt-1 text-xs leading-relaxed text-gray-500">Current target: {queueScopeLabel}.</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -526,7 +527,7 @@ export function SettingsModal() {
</div> </div>
</div> </div>
<div className={`mt-4 grid max-h-64 gap-2 overflow-y-auto pr-1 ${taggingQueueScope === "selected" ? "opacity-100" : "opacity-60"}`}> <div className={`mt-2 max-h-64 divide-y divide-white/[0.04] overflow-y-auto pr-1 ${taggingQueueScope === "selected" ? "opacity-100" : "opacity-60"}`}>
{folders.map((folder) => { {folders.map((folder) => {
const active = taggingQueueFolderIds.includes(folder.id); const active = taggingQueueFolderIds.includes(folder.id);
const progress = mediaJobProgress[folder.id]; const progress = mediaJobProgress[folder.id];
@@ -534,31 +535,27 @@ export function SettingsModal() {
<button <button
key={folder.id} key={folder.id}
type="button" type="button"
className={`flex items-center justify-between rounded-xl border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed ${ className={`flex w-full items-center justify-between gap-3 px-1 py-2 text-left transition-colors disabled:cursor-not-allowed ${
active active ? "text-white" : "text-gray-400 hover:text-gray-200"
? "border-emerald-400/30 bg-emerald-500/10 text-white"
: "border-white/[0.07] bg-black/20 text-gray-300 hover:border-white/15 hover:bg-white/[0.04]"
}`} }`}
onClick={() => toggleTaggingQueueFolder(folder.id)} onClick={() => toggleTaggingQueueFolder(folder.id)}
disabled={taggingQueueScope === "all"} disabled={taggingQueueScope === "all"}
> >
<div> <p className="min-w-0 truncate text-sm">{folder.name}</p>
<p className="text-sm font-medium">{folder.name}</p> <div className="flex shrink-0 items-center gap-3">
<p className="mt-1 text-[11px] text-gray-500">{folder.image_count.toLocaleString()} items</p>
</div>
<div className="flex items-center gap-2">
{(progress?.tagging_pending ?? 0) > 0 ? <StatusPill tone="busy">{progress?.tagging_pending} queued</StatusPill> : null} {(progress?.tagging_pending ?? 0) > 0 ? <StatusPill tone="busy">{progress?.tagging_pending} queued</StatusPill> : null}
<span className="text-[11px] tabular-nums text-gray-600">{folder.image_count.toLocaleString()} items</span>
<span className={`h-4 w-4 rounded-full border ${active ? "border-emerald-300 bg-emerald-300" : "border-white/15 bg-transparent"}`} /> <span className={`h-4 w-4 rounded-full border ${active ? "border-emerald-300 bg-emerald-300" : "border-white/15 bg-transparent"}`} />
</div> </div>
</button> </button>
); );
})} })}
{folders.length === 0 ? <p className="text-sm text-gray-500">Add a folder first to enable targeted tagging queues.</p> : null} {folders.length === 0 ? <p className="py-2 text-sm text-gray-500">Add a folder first to enable targeted tagging queues.</p> : null}
</div> </div>
</div> </div>
<SettingsRow title="Queue tagging jobs" description="Generate missing AI tags for the current target. Results flow back into the library as the background worker finishes."> <SettingsItem label="Queue tagging jobs" description="Generate missing AI tags for the current target. Results flow back into the library as the background worker finishes.">
<div className="flex flex-col items-end gap-2"> <div className="flex items-center gap-2">
<button <button
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" 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={() => runQueueAction("queue")} onClick={() => runQueueAction("queue")}
@@ -574,24 +571,20 @@ export function SettingsModal() {
{taggerClearing ? "Clearing..." : "Clear queued jobs"} {taggerClearing ? "Clearing..." : "Clear queued jobs"}
</button> </button>
</div> </div>
</SettingsRow> </SettingsItem>
{taggerQueueStatus ? <p className="pt-4 text-xs text-gray-500">{taggerQueueStatus}</p> : null} {taggerQueueStatus ? <p className="pt-3 text-xs text-gray-500">{taggerQueueStatus}</p> : null}
</SettingsCard> </SettingsGroup>
</SectionShell>
</div> </div>
) : ( ) : (
<div className="space-y-8"> <div className="mt-8 space-y-9">
<SectionShell <SettingsGroup title="Storage & notifications">
eyebrow="General" <SettingsItem
title="App data" label="App data folder"
description="Access the folder where Phokus stores its database, thumbnails, AI models, and settings." description="Open the folder in Explorer to inspect or back up the database, thumbnails, and models."
> >
<SettingsCard title="Storage location">
<div className="flex items-center justify-between gap-4 rounded-xl border border-white/[0.07] bg-black/20 p-4">
<p className="text-sm text-gray-400">Open the app data folder in Explorer to inspect or back up files.</p>
<button <button
className="shrink-0 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" 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={() => { onClick={() => {
setOpeningDataFolder(true); setOpeningDataFolder(true);
void openAppDataFolder().finally(() => setOpeningDataFolder(false)); void openAppDataFolder().finally(() => setOpeningDataFolder(false));
@@ -600,18 +593,11 @@ export function SettingsModal() {
> >
{openingDataFolder ? "Opening..." : "Open data folder"} {openingDataFolder ? "Opening..." : "Open data folder"}
</button> </button>
</div> </SettingsItem>
</SettingsCard> <SettingsItem
label="Pause all notifications"
<SettingsCard
title="Notifications"
description="Notifications are batched per folder — a single alert fires once activity settles. Mute individual folders from their right-click menu." description="Notifications are batched per folder — a single alert fires once activity settles. Mute individual folders from their right-click menu."
> >
<div className="flex items-center justify-between gap-4 rounded-xl border border-white/[0.07] bg-black/20 p-4">
<div>
<p className="text-sm font-medium text-white">Pause all notifications</p>
<p className="mt-0.5 text-xs text-gray-500">Suppress all indexing notifications until re-enabled.</p>
</div>
<button <button
role="switch" role="switch"
aria-checked={notificationsPaused} aria-checked={notificationsPaused}
@@ -620,46 +606,50 @@ export function SettingsModal() {
> >
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${notificationsPaused ? "translate-x-4" : "translate-x-0"}`} /> <span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${notificationsPaused ? "translate-x-4" : "translate-x-0"}`} />
</button> </button>
</div> </SettingsItem>
</SettingsCard> </SettingsGroup>
<SettingsCard <SettingsGroup title="Maintenance">
title="Compact database" <SettingsItem
description="Reclaims wasted space left behind when images or tags are deleted. Safe to run at any time." label="Compact database"
> description={
<div className="space-y-3"> <>
<div className="grid grid-cols-2 gap-3"> <span>Reclaims wasted space left behind when images or tags are deleted. Safe to run at any time.</span>
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4"> <span className="mt-2.5 flex flex-wrap items-center gap-x-5 gap-y-1">
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Database size</p> <StatPair
<p className="mt-2 text-2xl font-semibold text-white"> label="Size"
{vacuumResult value={
vacuumResult
? `${vacuumResult.after_mb.toFixed(1)} MB` ? `${vacuumResult.after_mb.toFixed(1)} MB`
: dbInfo : dbInfo
? `${dbInfo.size_mb.toFixed(1)} MB` ? `${dbInfo.size_mb.toFixed(1)} MB`
: "—"} : "—"
</p> }
</div> />
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4"> <StatPair
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Reclaimable</p> label="Reclaimable"
<p className={`mt-2 text-2xl font-semibold ${vacuumResult ? "text-emerald-300" : "text-white"}`}> accent={vacuumResult !== null}
{vacuumResult value={
? `${vacuumResult.freed_mb.toFixed(1)} MB freed` vacuumResult
? `${vacuumResult.freed_mb.toFixed(1)} MB freed`
: dbInfo : dbInfo
? `${dbInfo.reclaimable_mb.toFixed(1)} MB` ? `${dbInfo.reclaimable_mb.toFixed(1)} MB`
: "—"} : "—"
</p> }
</div> />
</div> </span>
<div className="flex items-center justify-between gap-4 rounded-xl border border-white/[0.07] bg-black/20 p-4"> <span className="mt-2 block text-gray-600">
<p className="text-sm text-gray-400">
{vacuumResult {vacuumResult
? `Compacted from ${vacuumResult.before_mb.toFixed(1)} MB to ${vacuumResult.after_mb.toFixed(1)} MB.` ? `Compacted from ${vacuumResult.before_mb.toFixed(1)} MB to ${vacuumResult.after_mb.toFixed(1)} MB.`
: dbInfo && dbInfo.reclaimable_mb < 0.5 : dbInfo && dbInfo.reclaimable_mb < 0.5
? "Database is already compact." ? "Database is already compact."
: "Run this after removing folders or bulk-deleting images."} : "Run this after removing folders or bulk-deleting images."}
</p> </span>
</>
}
>
<button <button
className="shrink-0 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" 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={() => { onClick={() => {
setVacuuming(true); setVacuuming(true);
setVacuumResult(null); setVacuumResult(null);
@@ -671,47 +661,41 @@ export function SettingsModal() {
.catch(() => {}) .catch(() => {})
.finally(() => setVacuuming(false)); .finally(() => setVacuuming(false));
}} }}
disabled={vacuuming || (dbInfo !== null && dbInfo.reclaimable_mb < 0.5 && vacuumResult === null)} disabled={vacuuming || (dbInfo !== null && dbInfo.reclaimable_mb < 0.5)}
> >
{vacuuming ? "Compacting..." : "Compact now"} {vacuuming ? "Compacting..." : "Compact now"}
</button> </button>
</div> </SettingsItem>
</div>
</SettingsCard>
<SettingsCard <SettingsItem
title="Thumbnail cache" label="Thumbnail cache"
description="Thumbnails left behind when folders or images are removed. Safe to delete — they will be regenerated if the original files are re-indexed." description={
> <>
<div className="space-y-3"> <span>Thumbnails left behind when folders or images are removed. Safe to delete they are regenerated if the originals are re-indexed.</span>
<div className="grid grid-cols-2 gap-3"> <span className="mt-2.5 flex flex-wrap items-center gap-x-5 gap-y-1">
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4"> <StatPair
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Orphaned files</p> label="Orphaned files"
<p className="mt-2 text-2xl font-semibold text-white"> value={
{cleaningThumbnails thumbnailCleanupResult
? "—"
: thumbnailCleanupResult
? "0" ? "0"
: thumbnailInfo : thumbnailInfo
? thumbnailInfo.count.toLocaleString() ? thumbnailInfo.count.toLocaleString()
: "—"} : "—"
</p> }
</div> />
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4"> <StatPair
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Reclaimable</p> label="Reclaimable"
<p className={`mt-2 text-2xl font-semibold ${thumbnailCleanupResult ? "text-emerald-300" : "text-white"}`}> accent={thumbnailCleanupResult !== null}
{cleaningThumbnails value={
? "—" thumbnailCleanupResult
: thumbnailCleanupResult ? `${thumbnailCleanupResult.freed_mb.toFixed(1)} MB freed`
? "0 MB"
: thumbnailInfo : thumbnailInfo
? `${thumbnailInfo.size_mb.toFixed(1)} MB` ? `${thumbnailInfo.size_mb.toFixed(1)} MB`
: "—"} : "—"
</p> }
</div> />
</div> </span>
<div className="flex items-center justify-between gap-4 rounded-xl border border-white/[0.07] bg-black/20 p-4"> <span className="mt-2 block text-gray-600">
<p className="text-sm text-gray-400">
{cleaningThumbnails {cleaningThumbnails
? "Scanning and removing orphaned thumbnails…" ? "Scanning and removing orphaned thumbnails…"
: thumbnailCleanupResult : thumbnailCleanupResult
@@ -719,11 +703,14 @@ export function SettingsModal() {
: thumbnailInfo && thumbnailInfo.count === 0 : thumbnailInfo && thumbnailInfo.count === 0
? "No orphaned thumbnails found." ? "No orphaned thumbnails found."
: thumbnailInfo && thumbnailInfo.count > 1000 : 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."} : "Remove thumbnails no longer associated with any indexed image."}
</p> </span>
</>
}
>
<button <button
className="shrink-0 rounded-lg bg-white/[0.07] px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-white/[0.11] disabled:cursor-not-allowed disabled:opacity-40" 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={() => { onClick={() => {
setCleaningThumbnails(true); setCleaningThumbnails(true);
cleanupOrphanedThumbnails() cleanupOrphanedThumbnails()
@@ -738,10 +725,8 @@ export function SettingsModal() {
> >
{cleaningThumbnails ? "Cleaning…" : "Clean up"} {cleaningThumbnails ? "Cleaning…" : "Clean up"}
</button> </button>
</div> </SettingsItem>
</div> </SettingsGroup>
</SettingsCard>
</SectionShell>
</div> </div>
)} )}
</div> </div>
+5 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc } from "@tauri-apps/api/core";
import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store"; import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
const ACCENTS = [ const ACCENTS = [
"#60a5fa", "#60a5fa",
@@ -317,7 +318,9 @@ export function TagCloud() {
: "No tags — run the AI tagger or add tags manually"} : "No tags — run the AI tagger or add tags manually"}
</p> </p>
</div> </div>
<div className="flex shrink-0 rounded-lg border border-white/8 bg-white/[0.03] p-0.5"> <div className="flex shrink-0 items-center gap-2">
<FolderScopeDropdown />
<div className="flex rounded-lg border border-white/8 bg-white/[0.03] p-0.5">
<button <button
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${ className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
exploreMode === "visual" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300" exploreMode === "visual" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
@@ -337,6 +340,7 @@ export function TagCloud() {
</div> </div>
</div> </div>
</div> </div>
</div>
{loading ? ( {loading ? (
<div className="flex flex-1 items-center justify-center gap-3 text-white/25"> <div className="flex flex-1 items-center justify-center gap-3 text-white/25">
+4
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store"; import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [ const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
{ value: "date_desc", label: "Newest first" }, { value: "date_desc", label: "Newest first" },
@@ -162,6 +163,7 @@ export function Toolbar() {
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const zoomPreset = useGalleryStore((state) => state.zoomPreset); const zoomPreset = useGalleryStore((state) => state.zoomPreset);
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset); const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
const activeView = useGalleryStore((state) => state.activeView);
const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0); const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0);
@@ -269,6 +271,8 @@ export function Toolbar() {
)} )}
</div> </div>
{activeView === "timeline" ? <FolderScopeDropdown /> : null}
<div className="flex-1" /> <div className="flex-1" />
{/* Search */} {/* Search */}
+448
View File
@@ -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 (
<button
className={`rounded-md p-1.5 transition-colors ${active ? "text-white" : "text-gray-300 hover:text-white"} hover:bg-white/10`}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
title={title}
>
{children}
</button>
);
}
export function VideoPlayer({ src }: { src: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | 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<BufferedRange[]>([]);
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<HTMLDivElement>) => {
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
scrubbingRef.current = true;
seekToPointer(event.clientX);
},
[seekToPointer],
);
const handleTrackPointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!scrubbingRef.current) return;
seekToPointer(event.clientX);
},
[seekToPointer],
);
const handleTrackPointerUp = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
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 (
<div
ref={containerRef}
className={`relative flex h-full w-full items-center justify-center bg-black ${controlsVisible ? "" : "cursor-none"}`}
onPointerMove={showControls}
onClick={(event) => event.stopPropagation()}
>
<video
ref={videoRef}
src={src}
className="h-full w-full object-contain"
onClick={togglePlay}
onDoubleClick={toggleFullscreen}
onPlay={() => {
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}
/>
<AnimatePresence>
{controlsVisible ? (
<motion.div
className="absolute inset-x-0 bottom-0 z-10 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-4 pb-2.5 pt-12"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{/* Scrubber */}
<div
ref={trackRef}
className="group/track relative flex h-4 cursor-pointer items-center"
onPointerDown={handleTrackPointerDown}
onPointerMove={handleTrackPointerMove}
onPointerUp={handleTrackPointerUp}
>
<div className="relative h-1 w-full overflow-hidden rounded-full bg-white/15 transition-[height] group-hover/track:h-1.5">
{buffered.map((range, index) => (
<div
key={index}
className="absolute inset-y-0 bg-white/20"
style={{ left: `${range.start * 100}%`, width: `${(range.end - range.start) * 100}%` }}
/>
))}
<div className="absolute inset-y-0 left-0 bg-white/90" style={{ width: `${playedFraction * 100}%` }} />
</div>
<div
className="pointer-events-none absolute h-3 w-3 -translate-x-1/2 rounded-full bg-white opacity-0 shadow transition-opacity group-hover/track:opacity-100"
style={{ left: `${playedFraction * 100}%` }}
/>
</div>
{/* Control row */}
<div className="mt-1 flex items-center gap-1.5">
<ControlButton onClick={togglePlay} title={playing ? "Pause (Space)" : "Play (Space)"}>
{playing ? (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 5h4v14H7zM13 5h4v14h-4z" />
</svg>
) : (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5.14v13.72L19 12 8 5.14z" />
</svg>
)}
</ControlButton>
<span className="ml-1 text-xs tabular-nums text-gray-300">
{formatTime(currentTime)} <span className="text-gray-500">/ {formatTime(duration)}</span>
</span>
<div className="flex-1" />
{/* Volume */}
<ControlButton onClick={toggleMute} title={muted ? "Unmute (M)" : "Mute (M)"}>
{effectiveVolume === 0 ? (
<svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M17 9l4 6m0-6l-4 6" />
</svg>
) : (
<svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15.536 8.464a5 5 0 010 7.072M18.364 5.636a9 9 0 010 12.728" />
</svg>
)}
</ControlButton>
<input
type="range"
min={0}
max={1}
step={0.02}
value={effectiveVolume}
className="h-1 w-20 cursor-pointer accent-white"
onChange={(event) => applyVolume(parseFloat(event.target.value), false)}
onClick={(event) => event.stopPropagation()}
title="Volume (↑/↓)"
/>
{/* Playback speed */}
<div className="relative">
<button
className={`min-w-12 rounded-md px-2 py-1.5 text-xs tabular-nums transition-colors hover:bg-white/10 ${playbackRate !== 1 ? "text-white" : "text-gray-300 hover:text-white"}`}
onClick={(event) => {
event.stopPropagation();
setSpeedMenuOpen((value) => !value);
}}
title="Playback speed"
>
{playbackRate}×
</button>
{speedMenuOpen ? (
<div className="absolute bottom-full right-0 z-20 mb-2 min-w-20 rounded-lg border border-white/10 bg-gray-950/95 p-1 shadow-2xl backdrop-blur">
{SPEED_OPTIONS.map((rate) => (
<button
key={rate}
className={`flex w-full items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-left text-xs tabular-nums transition-colors ${
rate === playbackRate ? "bg-white/10 text-white" : "text-gray-400 hover:bg-white/5 hover:text-white"
}`}
onClick={(event) => {
event.stopPropagation();
setSpeed(rate);
}}
>
{rate}×
</button>
))}
</div>
) : null}
</div>
{/* Loop */}
<ControlButton onClick={toggleLoop} title={loop ? "Loop on (L)" : "Loop off (L)"} active={loop}>
<svg className={`h-4.5 w-4.5 ${loop ? "text-emerald-300" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h5M20 20v-5h-5M4 9a8 8 0 0114-3M20 15a8 8 0 01-14 3" />
</svg>
</ControlButton>
{/* Fullscreen */}
<ControlButton onClick={toggleFullscreen} title={fullscreen ? "Exit fullscreen (F)" : "Fullscreen (F)"}>
{fullscreen ? (
<svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9H4m5 0V4m6 5h5m-5 0V4M9 15H4m5 0v5m6-5h5m-5 0v5" />
</svg>
) : (
<svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 8V4h4M20 8V4h-4M4 16v4h4M20 16v4h-4" />
</svg>
)}
</ControlButton>
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
+29
View File
@@ -337,6 +337,7 @@ interface GalleryState {
renameFolder: (folderId: number, newName: string) => Promise<void>; renameFolder: (folderId: number, newName: string) => Promise<void>;
updateFolderPath: (folderId: number, newPath: string) => Promise<void>; updateFolderPath: (folderId: number, newPath: string) => Promise<void>;
selectFolder: (folderId: number | null) => void; selectFolder: (folderId: number | null) => void;
setViewFolderScope: (folderId: number | null) => void;
loadImages: (reset?: boolean) => Promise<void>; loadImages: (reset?: boolean) => Promise<void>;
loadMoreImages: () => Promise<void>; loadMoreImages: () => Promise<void>;
setSearch: (search: string) => void; setSearch: (search: string) => void;
@@ -755,6 +756,34 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
void get().loadImages(true); 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) => { loadImages: async (reset = false) => {
const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly } = get(); const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly } = get();
const parsedSearch = parseSearchValue(search); const parsedSearch = parseSearchValue(search);