Improve indexing updates and thumbnail streaming
This commit is contained in:
+1
-1
@@ -15,7 +15,6 @@ dist-ssr
|
|||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.suo
|
*.suo
|
||||||
@@ -23,3 +22,4 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
*.local.*
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ pub struct EmbeddingJob {
|
|||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ThumbnailJob {
|
||||||
|
pub image_id: i64,
|
||||||
|
pub path: String,
|
||||||
|
pub media_kind: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn create_pool(db_path: &Path) -> Result<DbPool> {
|
pub fn create_pool(db_path: &Path) -> Result<DbPool> {
|
||||||
vector::register_sqlite_vec();
|
vector::register_sqlite_vec();
|
||||||
let manager = SqliteConnectionManager::file(db_path);
|
let manager = SqliteConnectionManager::file(db_path);
|
||||||
@@ -111,9 +118,19 @@ pub fn migrate(conn: &Connection) -> Result<()> {
|
|||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS thumbnail_jobs (
|
||||||
|
image_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_images_folder_id ON images(folder_id);
|
CREATE INDEX IF NOT EXISTS idx_images_folder_id ON images(folder_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_images_modified_at ON images(modified_at);
|
CREATE INDEX IF NOT EXISTS idx_images_modified_at ON images(modified_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_embedding_jobs_status ON embedding_jobs(status);
|
CREATE INDEX IF NOT EXISTS idx_embedding_jobs_status ON embedding_jobs(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_thumbnail_jobs_status ON thumbnail_jobs(status);
|
||||||
",
|
",
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -209,6 +226,19 @@ pub fn enqueue_embedding_job(conn: &Connection, image_id: i64) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn enqueue_thumbnail_job(conn: &Connection, image_id: i64) -> Result<()> {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO thumbnail_jobs (image_id, status, attempts, last_error, created_at, updated_at)
|
||||||
|
VALUES (?1, 'pending', 0, NULL, datetime('now'), datetime('now'))
|
||||||
|
ON CONFLICT(image_id) DO UPDATE SET
|
||||||
|
status = 'pending',
|
||||||
|
last_error = NULL,
|
||||||
|
updated_at = datetime('now')",
|
||||||
|
[image_id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn get_next_embedding_job(conn: &Connection) -> Result<Option<EmbeddingJob>> {
|
pub fn get_next_embedding_job(conn: &Connection) -> Result<Option<EmbeddingJob>> {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
@@ -282,6 +312,61 @@ pub fn update_folder_count(conn: &Connection, folder_id: i64) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_next_thumbnail_job(conn: &Connection) -> Result<Option<ThumbnailJob>> {
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT j.image_id, i.path, i.media_kind
|
||||||
|
FROM thumbnail_jobs j
|
||||||
|
JOIN images i ON i.id = j.image_id
|
||||||
|
WHERE j.status = 'pending'
|
||||||
|
ORDER BY j.updated_at, j.image_id
|
||||||
|
LIMIT 1",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut rows = stmt.query([])?;
|
||||||
|
let Some(row) = rows.next()? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(ThumbnailJob {
|
||||||
|
image_id: row.get(0)?,
|
||||||
|
path: row.get(1)?,
|
||||||
|
media_kind: row.get(2)?,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_thumbnail_job_processing(conn: &Connection, image_id: i64) -> Result<()> {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE thumbnail_jobs
|
||||||
|
SET status = 'processing', attempts = attempts + 1, updated_at = datetime('now')
|
||||||
|
WHERE image_id = ?1",
|
||||||
|
[image_id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_thumbnail_ready(
|
||||||
|
conn: &Connection,
|
||||||
|
image_id: i64,
|
||||||
|
thumbnail_path: Option<&str>,
|
||||||
|
) -> Result<ImageRecord> {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE images SET thumbnail_path = ?2 WHERE id = ?1",
|
||||||
|
params![image_id, thumbnail_path],
|
||||||
|
)?;
|
||||||
|
conn.execute("DELETE FROM thumbnail_jobs WHERE image_id = ?1", [image_id])?;
|
||||||
|
get_image_by_id(conn, image_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_thumbnail_failed(conn: &Connection, image_id: i64, error: &str) -> Result<()> {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE thumbnail_jobs
|
||||||
|
SET status = 'failed', last_error = ?2, updated_at = datetime('now')
|
||||||
|
WHERE image_id = ?1",
|
||||||
|
params![image_id, error],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn update_image_details(
|
pub fn update_image_details(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
image_id: i64,
|
image_id: i64,
|
||||||
@@ -307,6 +392,18 @@ pub fn update_image_details(
|
|||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_image_by_id(conn: &Connection, image_id: i64) -> Result<ImageRecord> {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type,
|
||||||
|
media_kind, favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error
|
||||||
|
FROM images
|
||||||
|
WHERE id = ?1",
|
||||||
|
[image_id],
|
||||||
|
map_image_row,
|
||||||
|
)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_folders(conn: &Connection) -> Result<Vec<Folder>> {
|
pub fn get_folders(conn: &Connection) -> Result<Vec<Folder>> {
|
||||||
let mut stmt =
|
let mut stmt =
|
||||||
conn.prepare("SELECT id, path, name, image_count, indexed_at FROM folders ORDER BY name")?;
|
conn.prepare("SELECT id, path, name, image_count, indexed_at FROM folders ORDER BY name")?;
|
||||||
|
|||||||
+100
-8
@@ -63,7 +63,13 @@ pub struct IndexedImagesBatch {
|
|||||||
pub images: Vec<ImageRecord>,
|
pub images: Vec<ImageRecord>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
pub struct ThumbnailBatch {
|
||||||
|
pub images: Vec<ImageRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
const INDEX_BATCH_SIZE: usize = 25;
|
const INDEX_BATCH_SIZE: usize = 25;
|
||||||
|
const THUMBNAIL_BATCH_SIZE: usize = 12;
|
||||||
|
|
||||||
pub fn index_folder(
|
pub fn index_folder(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
@@ -101,25 +107,40 @@ fn do_index(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf)
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Parallel: read file metadata and dimensions across all CPU cores
|
let mut indexed = 0usize;
|
||||||
let records: Vec<ImageRecord> = image_paths
|
for path_chunk in image_paths.chunks(INDEX_BATCH_SIZE) {
|
||||||
|
let records: Vec<ImageRecord> = path_chunk
|
||||||
.par_iter()
|
.par_iter()
|
||||||
.filter_map(|path| build_record(path, folder_id))
|
.filter_map(|path| build_record(path, folder_id))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Sequential: commit in batches and stream results to the frontend
|
if records.is_empty() {
|
||||||
let mut indexed = 0usize;
|
continue;
|
||||||
for chunk in records.chunks(INDEX_BATCH_SIZE) {
|
}
|
||||||
let committed = commit_batch(&pool, chunk)?;
|
|
||||||
|
let committed = commit_batch(&pool, &records)?;
|
||||||
indexed += committed.len();
|
indexed += committed.len();
|
||||||
emit_images(&app, &IndexedImagesBatch { folder_id, images: committed });
|
emit_images(
|
||||||
|
&app,
|
||||||
|
&IndexedImagesBatch {
|
||||||
|
folder_id,
|
||||||
|
images: committed,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let current_file = path_chunk
|
||||||
|
.last()
|
||||||
|
.and_then(|path| path.file_name())
|
||||||
|
.map(|name| name.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
emit_progress(
|
emit_progress(
|
||||||
&app,
|
&app,
|
||||||
&IndexProgress {
|
&IndexProgress {
|
||||||
folder_id,
|
folder_id,
|
||||||
total,
|
total,
|
||||||
indexed,
|
indexed,
|
||||||
current_file: String::new(),
|
current_file,
|
||||||
done: false,
|
done: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -191,6 +212,10 @@ fn emit_images(app: &AppHandle, batch: &IndexedImagesBatch) {
|
|||||||
let _ = app.emit("indexed-images", batch);
|
let _ = app.emit("indexed-images", batch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn emit_thumbnails(app: &AppHandle, batch: &ThumbnailBatch) {
|
||||||
|
let _ = app.emit("thumbnail-updated", batch);
|
||||||
|
}
|
||||||
|
|
||||||
fn commit_batch(pool: &DbPool, records: &[ImageRecord]) -> Result<Vec<ImageRecord>> {
|
fn commit_batch(pool: &DbPool, records: &[ImageRecord]) -> Result<Vec<ImageRecord>> {
|
||||||
let mut conn = pool.get()?;
|
let mut conn = pool.get()?;
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
@@ -200,9 +225,76 @@ fn commit_batch(pool: &DbPool, records: &[ImageRecord]) -> Result<Vec<ImageRecor
|
|||||||
let mut committed_record = record.clone();
|
let mut committed_record = record.clone();
|
||||||
committed_record.id = db::upsert_image(&tx, record)?;
|
committed_record.id = db::upsert_image(&tx, record)?;
|
||||||
db::enqueue_embedding_job(&tx, committed_record.id)?;
|
db::enqueue_embedding_job(&tx, committed_record.id)?;
|
||||||
|
db::enqueue_thumbnail_job(&tx, committed_record.id)?;
|
||||||
committed.push(committed_record);
|
committed.push(committed_record);
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
Ok(committed)
|
Ok(committed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn start_thumbnail_worker(app: AppHandle, pool: DbPool, cache_dir: PathBuf) {
|
||||||
|
std::thread::spawn(move || loop {
|
||||||
|
if let Err(error) = process_thumbnail_batch(&app, &pool, &cache_dir) {
|
||||||
|
eprintln!("Thumbnail worker error: {}", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_thumbnail_batch(app: &AppHandle, pool: &DbPool, cache_dir: &Path) -> Result<()> {
|
||||||
|
let mut updated_images = Vec::new();
|
||||||
|
|
||||||
|
for _ in 0..THUMBNAIL_BATCH_SIZE {
|
||||||
|
let job = {
|
||||||
|
let conn = pool.get()?;
|
||||||
|
db::get_next_thumbnail_job(&conn)?
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(job) = job else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = pool.get()?;
|
||||||
|
db::mark_thumbnail_job_processing(&conn, job.image_id)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let thumbnail_result = match job.media_kind.as_str() {
|
||||||
|
"image" => thumbnail::generate_thumbnail(Path::new(&job.path), cache_dir),
|
||||||
|
"video" => thumbnail::generate_video_poster(Path::new(&job.path), cache_dir),
|
||||||
|
_ => {
|
||||||
|
let conn = pool.get()?;
|
||||||
|
updated_images.push(db::mark_thumbnail_ready(&conn, job.image_id, None)?);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let thumbnail_path = match thumbnail_result {
|
||||||
|
Ok(path) => Some(path.to_string_lossy().to_string()),
|
||||||
|
Err(error) => {
|
||||||
|
let conn = pool.get()?;
|
||||||
|
db::mark_thumbnail_failed(&conn, job.image_id, &error.to_string())?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let updated_image = {
|
||||||
|
let conn = pool.get()?;
|
||||||
|
db::mark_thumbnail_ready(&conn, job.image_id, thumbnail_path.as_deref())?
|
||||||
|
};
|
||||||
|
updated_images.push(updated_image);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !updated_images.is_empty() {
|
||||||
|
emit_thumbnails(
|
||||||
|
app,
|
||||||
|
&ThumbnailBatch {
|
||||||
|
images: updated_images,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ pub fn run() {
|
|||||||
db::migrate(&conn).expect("Failed to run migrations");
|
db::migrate(&conn).expect("Failed to run migrations");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let thumb_dir = app_dir.join("thumbnails");
|
||||||
|
std::fs::create_dir_all(&thumb_dir).expect("Failed to create thumbnail dir");
|
||||||
|
|
||||||
|
indexer::start_thumbnail_worker(app.handle().clone(), pool.clone(), thumb_dir);
|
||||||
|
|
||||||
app.manage(pool);
|
app.manage(pool);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,4 +1,86 @@
|
|||||||
use std::path::Path;
|
use anyhow::{anyhow, Result};
|
||||||
|
use image::ImageFormat;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
pub const THUMB_SIZE: u32 = 320;
|
||||||
|
|
||||||
|
pub fn thumb_path(cache_dir: &Path, image_path: &str) -> PathBuf {
|
||||||
|
thumb_path_with_ext(cache_dir, image_path, "webp")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn video_poster_path(cache_dir: &Path, video_path: &str) -> PathBuf {
|
||||||
|
thumb_path_with_ext(cache_dir, video_path, "jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn thumb_path_with_ext(cache_dir: &Path, input_path: &str, extension: &str) -> PathBuf {
|
||||||
|
let hash = hash_path(input_path);
|
||||||
|
cache_dir.join(format!("{}.{}", hash, extension))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_path(s: &str) -> String {
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
s.hash(&mut hasher);
|
||||||
|
format!("{:016x}", hasher.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<PathBuf> {
|
||||||
|
let path_str = image_path.to_string_lossy();
|
||||||
|
let out_path = thumb_path(cache_dir, &path_str);
|
||||||
|
|
||||||
|
if out_path.exists() {
|
||||||
|
return Ok(out_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let img = image::open(image_path)?;
|
||||||
|
let thumb = img.thumbnail(THUMB_SIZE, THUMB_SIZE);
|
||||||
|
|
||||||
|
if let Some(parent) = out_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
thumb.save_with_format(&out_path, ImageFormat::WebP)?;
|
||||||
|
Ok(out_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_video_poster(video_path: &Path, cache_dir: &Path) -> Result<PathBuf> {
|
||||||
|
let path_str = video_path.to_string_lossy();
|
||||||
|
let out_path = video_poster_path(cache_dir, &path_str);
|
||||||
|
|
||||||
|
if out_path.exists() {
|
||||||
|
return Ok(out_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(parent) = out_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = Command::new("ffmpeg")
|
||||||
|
.args([
|
||||||
|
"-y",
|
||||||
|
"-ss",
|
||||||
|
"00:00:01.000",
|
||||||
|
"-i",
|
||||||
|
&path_str,
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
"-vf",
|
||||||
|
"scale=320:-1:force_original_aspect_ratio=decrease",
|
||||||
|
"-q:v",
|
||||||
|
"4",
|
||||||
|
out_path.to_string_lossy().as_ref(),
|
||||||
|
])
|
||||||
|
.status()?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
return Err(anyhow!("ffmpeg failed generating poster for {}", path_str));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(out_path)
|
||||||
|
}
|
||||||
|
|
||||||
/// Gets image dimensions without fully decoding.
|
/// Gets image dimensions without fully decoding.
|
||||||
pub fn get_dimensions(image_path: &Path) -> Option<(u32, u32)> {
|
pub fn get_dimensions(image_path: &Path) -> Option<(u32, u32)> {
|
||||||
|
|||||||
+27
-12
@@ -79,15 +79,20 @@ function ContextMenu({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{image.rating > 0 ? (
|
||||||
<button
|
<button
|
||||||
className="ml-auto rounded-lg px-2 py-1 text-sm text-gray-400 hover:bg-white/5 hover:text-white"
|
className="ml-auto rounded-lg p-1.5 text-gray-400 hover:bg-white/5 hover:text-white"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await updateImageDetails(image.id, { rating: 0 });
|
await updateImageDetails(image.id, { rating: 0 });
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
|
title="Remove rating"
|
||||||
>
|
>
|
||||||
Clear
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -119,15 +124,7 @@ function ImageTile({
|
|||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
title={image.filename}
|
title={image.filename}
|
||||||
>
|
>
|
||||||
{image.media_kind === "video" ? (
|
{src && !errored ? (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-fuchsia-500/10 via-white/5 to-cyan-500/10">
|
|
||||||
<div className="rounded-full border border-white/10 bg-black/30 p-4 text-white shadow-lg">
|
|
||||||
<svg className="h-8 w-8" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M8 5v14l11-7z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : src && !errored ? (
|
|
||||||
<>
|
<>
|
||||||
{!loaded ? <div className="absolute inset-0 animate-pulse bg-white/5" /> : null}
|
{!loaded ? <div className="absolute inset-0 animate-pulse bg-white/5" /> : null}
|
||||||
<img
|
<img
|
||||||
@@ -140,7 +137,14 @@ function ImageTile({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-white/5 text-gray-600">
|
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-fuchsia-500/10 via-white/5 to-cyan-500/10 text-gray-300">
|
||||||
|
{image.media_kind === "video" ? (
|
||||||
|
<div className="rounded-full border border-white/10 bg-black/30 p-4 text-white shadow-lg">
|
||||||
|
<svg className="h-8 w-8" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<svg className="h-9 w-9" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="h-9 w-9" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
@@ -149,9 +153,20 @@ function ImageTile({
|
|||||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{image.media_kind === "video" ? (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="rounded-full border border-white/10 bg-black/35 p-3 text-white shadow-lg backdrop-blur-sm">
|
||||||
|
<svg className="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/20 to-transparent opacity-80 transition-opacity group-hover:opacity-100" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/20 to-transparent opacity-80 transition-opacity group-hover:opacity-100" />
|
||||||
|
|
||||||
<div className="absolute left-3 right-3 top-3 flex items-start justify-between gap-2">
|
<div className="absolute left-3 right-3 top-3 flex items-start justify-between gap-2">
|
||||||
|
|||||||
+51
-1
@@ -49,6 +49,10 @@ export interface IndexedImagesBatch {
|
|||||||
images: ImageRecord[];
|
images: ImageRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ThumbnailBatch {
|
||||||
|
images: ImageRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
export type SortOrder =
|
export type SortOrder =
|
||||||
| "date_desc"
|
| "date_desc"
|
||||||
| "date_asc"
|
| "date_asc"
|
||||||
@@ -153,6 +157,20 @@ function mergeImages(currentImages: ImageRecord[], newImages: ImageRecord[], sor
|
|||||||
return Array.from(merged.values()).sort((a, b) => compareImages(a, b, sort));
|
return Array.from(merged.values()).sort((a, b) => compareImages(a, b, sort));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function countNewImages(currentImages: ImageRecord[], newImages: ImageRecord[]): number {
|
||||||
|
const existingPaths = new Set(currentImages.map((image) => image.path));
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const image of newImages) {
|
||||||
|
if (!existingPaths.has(image.path)) {
|
||||||
|
existingPaths.add(image.path);
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
function replaceImage(images: ImageRecord[], updatedImage: ImageRecord, sort: SortOrder): ImageRecord[] {
|
function replaceImage(images: ImageRecord[], updatedImage: ImageRecord, sort: SortOrder): ImageRecord[] {
|
||||||
return mergeImages(images, [updatedImage], sort);
|
return mergeImages(images, [updatedImage], sort);
|
||||||
}
|
}
|
||||||
@@ -340,11 +358,42 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newVisibleCount = countNewImages(state.images, visibleImages);
|
||||||
const images = mergeImages(state.images, visibleImages, state.sort);
|
const images = mergeImages(state.images, visibleImages, state.sort);
|
||||||
return {
|
return {
|
||||||
images,
|
images,
|
||||||
loadedCount: images.length,
|
loadedCount: images.length,
|
||||||
totalImages: Math.max(state.totalImages, images.length),
|
totalImages: Math.max(state.totalImages + newVisibleCount, images.length),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const unlistenThumbnails = await listen<ThumbnailBatch>("thumbnail-updated", (event) => {
|
||||||
|
const batch = event.payload;
|
||||||
|
|
||||||
|
set((state) => {
|
||||||
|
const visibleImages = batch.images.filter((image) =>
|
||||||
|
matchesFilters(
|
||||||
|
image,
|
||||||
|
state.selectedFolderId,
|
||||||
|
state.mediaFilter,
|
||||||
|
state.favoritesOnly,
|
||||||
|
state.search,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedImage =
|
||||||
|
state.selectedImage && batch.images.some((image) => image.id === state.selectedImage?.id)
|
||||||
|
? batch.images.find((image) => image.id === state.selectedImage?.id) ?? state.selectedImage
|
||||||
|
: state.selectedImage;
|
||||||
|
|
||||||
|
if (visibleImages.length === 0) {
|
||||||
|
return { selectedImage };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
images: mergeImages(state.images, visibleImages, state.sort),
|
||||||
|
selectedImage,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -352,6 +401,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
return () => {
|
return () => {
|
||||||
unlistenProgress();
|
unlistenProgress();
|
||||||
unlistenImages();
|
unlistenImages();
|
||||||
|
unlistenThumbnails();
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user