Improve indexing updates and thumbnail streaming
This commit is contained in:
+1
-1
@@ -15,7 +15,6 @@ dist-ssr
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
@@ -23,3 +22,4 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
*.local.*
|
||||
|
||||
@@ -65,6 +65,13 @@ pub struct EmbeddingJob {
|
||||
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> {
|
||||
vector::register_sqlite_vec();
|
||||
let manager = SqliteConnectionManager::file(db_path);
|
||||
@@ -111,9 +118,19 @@ pub fn migrate(conn: &Connection) -> Result<()> {
|
||||
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_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_thumbnail_jobs_status ON thumbnail_jobs(status);
|
||||
",
|
||||
)?;
|
||||
|
||||
@@ -209,6 +226,19 @@ pub fn enqueue_embedding_job(conn: &Connection, image_id: i64) -> Result<()> {
|
||||
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)]
|
||||
pub fn get_next_embedding_job(conn: &Connection) -> Result<Option<EmbeddingJob>> {
|
||||
let mut stmt = conn.prepare(
|
||||
@@ -282,6 +312,61 @@ pub fn update_folder_count(conn: &Connection, folder_id: i64) -> Result<()> {
|
||||
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(
|
||||
conn: &Connection,
|
||||
image_id: i64,
|
||||
@@ -307,6 +392,18 @@ pub fn update_image_details(
|
||||
.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>> {
|
||||
let mut stmt =
|
||||
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>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct ThumbnailBatch {
|
||||
pub images: Vec<ImageRecord>,
|
||||
}
|
||||
|
||||
const INDEX_BATCH_SIZE: usize = 25;
|
||||
const THUMBNAIL_BATCH_SIZE: usize = 12;
|
||||
|
||||
pub fn index_folder(
|
||||
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 records: Vec<ImageRecord> = image_paths
|
||||
let mut indexed = 0usize;
|
||||
for path_chunk in image_paths.chunks(INDEX_BATCH_SIZE) {
|
||||
let records: Vec<ImageRecord> = path_chunk
|
||||
.par_iter()
|
||||
.filter_map(|path| build_record(path, folder_id))
|
||||
.collect();
|
||||
|
||||
// Sequential: commit in batches and stream results to the frontend
|
||||
let mut indexed = 0usize;
|
||||
for chunk in records.chunks(INDEX_BATCH_SIZE) {
|
||||
let committed = commit_batch(&pool, chunk)?;
|
||||
if records.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let committed = commit_batch(&pool, &records)?;
|
||||
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(
|
||||
&app,
|
||||
&IndexProgress {
|
||||
folder_id,
|
||||
total,
|
||||
indexed,
|
||||
current_file: String::new(),
|
||||
current_file,
|
||||
done: false,
|
||||
},
|
||||
);
|
||||
@@ -191,6 +212,10 @@ fn emit_images(app: &AppHandle, batch: &IndexedImagesBatch) {
|
||||
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>> {
|
||||
let mut conn = pool.get()?;
|
||||
let tx = conn.transaction()?;
|
||||
@@ -200,9 +225,76 @@ fn commit_batch(pool: &DbPool, records: &[ImageRecord]) -> Result<Vec<ImageRecor
|
||||
let mut committed_record = record.clone();
|
||||
committed_record.id = db::upsert_image(&tx, record)?;
|
||||
db::enqueue_embedding_job(&tx, committed_record.id)?;
|
||||
db::enqueue_thumbnail_job(&tx, committed_record.id)?;
|
||||
committed.push(committed_record);
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
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");
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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.
|
||||
pub fn get_dimensions(image_path: &Path) -> Option<(u32, u32)> {
|
||||
|
||||
+27
-12
@@ -79,15 +79,20 @@ function ContextMenu({
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{image.rating > 0 ? (
|
||||
<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 () => {
|
||||
await updateImageDetails(image.id, { rating: 0 });
|
||||
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>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -119,15 +124,7 @@ function ImageTile({
|
||||
onContextMenu={onContextMenu}
|
||||
title={image.filename}
|
||||
>
|
||||
{image.media_kind === "video" ? (
|
||||
<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 ? (
|
||||
{src && !errored ? (
|
||||
<>
|
||||
{!loaded ? <div className="absolute inset-0 animate-pulse bg-white/5" /> : null}
|
||||
<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">
|
||||
<path
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</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 left-3 right-3 top-3 flex items-start justify-between gap-2">
|
||||
|
||||
+51
-1
@@ -49,6 +49,10 @@ export interface IndexedImagesBatch {
|
||||
images: ImageRecord[];
|
||||
}
|
||||
|
||||
export interface ThumbnailBatch {
|
||||
images: ImageRecord[];
|
||||
}
|
||||
|
||||
export type SortOrder =
|
||||
| "date_desc"
|
||||
| "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));
|
||||
}
|
||||
|
||||
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[] {
|
||||
return mergeImages(images, [updatedImage], sort);
|
||||
}
|
||||
@@ -340,11 +358,42 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
return state;
|
||||
}
|
||||
|
||||
const newVisibleCount = countNewImages(state.images, visibleImages);
|
||||
const images = mergeImages(state.images, visibleImages, state.sort);
|
||||
return {
|
||||
images,
|
||||
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 () => {
|
||||
unlistenProgress();
|
||||
unlistenImages();
|
||||
unlistenThumbnails();
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user