diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b114a10..77cd5ab 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2101,3 +2101,50 @@ pub async fn set_notifications_paused(app: AppHandle, paused: bool) -> Result<() ) .map_err(|e| e.to_string()) } + +#[derive(Serialize)] +pub struct FfmpegStatus { + pub installed: bool, + pub downloading: bool, + pub failed: bool, +} + +#[tauri::command] +pub async fn get_ffmpeg_status() -> Result { + Ok(FfmpegStatus { + installed: crate::media::ffmpeg_ready(), + downloading: crate::media::ffmpeg_downloading(), + failed: crate::media::ffmpeg_failed(), + }) +} + +#[tauri::command] +pub async fn retry_ffmpeg_download(app: AppHandle) -> Result<(), String> { + crate::media::spawn_ffmpeg_provision(app); + Ok(()) +} + +const ONBOARDING_COMPLETED_FILE: &str = "settings/onboarding_completed.txt"; + +#[tauri::command] +pub async fn get_onboarding_completed(app: AppHandle) -> Result { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let path = app_dir.join(ONBOARDING_COMPLETED_FILE); + if !path.exists() { + return Ok(false); + } + let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; + Ok(content.trim() == "true") +} + +#[tauri::command] +pub async fn set_onboarding_completed(app: AppHandle, completed: bool) -> Result<(), String> { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let settings_dir = app_dir.join("settings"); + std::fs::create_dir_all(&settings_dir).map_err(|e| e.to_string())?; + std::fs::write( + settings_dir.join("onboarding_completed.txt"), + if completed { "true" } else { "false" }, + ) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 7596055..f62d823 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -1132,6 +1132,7 @@ pub fn get_all_folder_job_progress(conn: &Connection) -> Result, + include_videos: bool, limit: usize, ) -> Result> { let sql = format!( @@ -1140,8 +1141,10 @@ fn get_pending_thumbnail_jobs_excluding( JOIN images i ON i.id = j.image_id WHERE j.status = 'pending' {} + {} ORDER BY j.updated_at, j.image_id LIMIT ?1", + media_kind_clause(include_videos), folder_exclusion_clause("i", excluded_folder_ids) ); let mut stmt = conn.prepare(&sql)?; @@ -1156,6 +1159,17 @@ fn get_pending_thumbnail_jobs_excluding( Ok(rows.collect::>>()?) } +/// Video jobs need FFmpeg; while it isn't provisioned they must be invisible +/// to both claiming and the tier-priority checks, or pending video jobs would +/// stall every lower tier indefinitely. +fn media_kind_clause(include_videos: bool) -> &'static str { + if include_videos { + "" + } else { + "AND i.media_kind = 'image'" + } +} + /// True if any claimable (pending, non-excluded) jobs exist in `job_table`. /// Used by lower-priority workers to defer to higher-priority queues. /// `extra_predicate` narrows the image join (e.g. videos only for metadata). @@ -1184,8 +1198,14 @@ fn has_claimable_jobs( pub fn has_claimable_thumbnail_jobs( conn: &Connection, excluded_folder_ids: &std::collections::HashSet, + include_videos: bool, ) -> Result { - has_claimable_jobs(conn, "thumbnail_jobs", "", excluded_folder_ids) + has_claimable_jobs( + conn, + "thumbnail_jobs", + media_kind_clause(include_videos), + excluded_folder_ids, + ) } pub fn has_claimable_metadata_jobs( @@ -1211,6 +1231,7 @@ pub fn claim_thumbnail_jobs( conn: &mut Connection, active_folder_ids: &std::collections::HashSet, paused_folder_ids: &std::collections::HashSet, + include_videos: bool, fetch_limit: usize, claim_limit: usize, ) -> Result> { @@ -1219,7 +1240,12 @@ pub fn claim_thumbnail_jobs( .union(paused_folder_ids) .copied() .collect::>(); - let candidates = get_pending_thumbnail_jobs_excluding(&tx, &excluded_folder_ids, fetch_limit)?; + let candidates = get_pending_thumbnail_jobs_excluding( + &tx, + &excluded_folder_ids, + include_videos, + fetch_limit, + )?; let mut claimed = Vec::with_capacity(claim_limit); for job in candidates { diff --git a/src-tauri/src/indexer.rs b/src-tauri/src/indexer.rs index c4e922f..8233348 100644 --- a/src-tauri/src/indexer.rs +++ b/src-tauri/src/indexer.rs @@ -142,14 +142,18 @@ fn higher_priority_work_pending(pool: &DbPool, own_tier: WorkerTier) -> Result WorkerTier::Thumbnail { let mut excluded = paused_folder_ids("thumbnail"); excluded.extend(active_folders.iter().copied()); - if db::has_claimable_thumbnail_jobs(&conn, &excluded)? { + if db::has_claimable_thumbnail_jobs(&conn, &excluded, ffmpeg_ready)? { return Ok(true); } } - if own_tier > WorkerTier::Metadata { + if own_tier > WorkerTier::Metadata && ffmpeg_ready { let mut excluded = paused_folder_ids("metadata"); excluded.extend(active_folders.iter().copied()); if db::has_claimable_metadata_jobs(&conn, &excluded)? { @@ -641,6 +645,7 @@ fn process_thumbnail_batch( &mut conn, &active_folders, &paused_folders, + crate::media::ffmpeg_ready(), worker_fetch_size, worker_batch_size, ) @@ -748,6 +753,11 @@ fn process_metadata_batch( pool: &DbPool, media_tools: &MediaTools, ) -> Result { + // Metadata jobs are video-only and need ffprobe; leave them pending until + // FFmpeg is provisioned — the worker poll loop drains them once ready. + if !crate::media::ffmpeg_ready() { + return Ok(false); + } if higher_priority_work_pending(pool, WorkerTier::Metadata)? { return Ok(false); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 882c9b7..da34945 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -52,7 +52,10 @@ pub fn run() { std::fs::create_dir_all(&app_dir).expect("Failed to create app data dir"); - media::MediaTools::ensure_installed().expect("Failed to provision FFmpeg sidecar"); + // FFmpeg provisioning happens in the background so the window + // appears immediately; workers gate video jobs on readiness and + // the onboarding/Settings UI shows progress and retry. + media::spawn_ffmpeg_provision(app.handle().clone()); let db_path = app_dir.join("gallery.db"); let pool = db::create_pool(&db_path).expect("Failed to create database pool"); @@ -186,6 +189,10 @@ pub fn run() { commands::cleanup_orphaned_thumbnails, commands::get_muted_folder_ids, commands::set_muted_folder_ids, + commands::get_ffmpeg_status, + commands::retry_ffmpeg_download, + commands::get_onboarding_completed, + commands::set_onboarding_completed, commands::get_notifications_paused, commands::set_notifications_paused, ]) diff --git a/src-tauri/src/media.rs b/src-tauri/src/media.rs index be64af5..1c615f1 100644 --- a/src-tauri/src/media.rs +++ b/src-tauri/src/media.rs @@ -2,9 +2,136 @@ use anyhow::{anyhow, Result}; use ffmpeg_sidecar::download::{auto_download_with_progress, FfmpegDownloadProgressEvent}; use ffmpeg_sidecar::ffprobe::ffprobe_path; use ffmpeg_sidecar::paths::ffmpeg_path; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::process::Command; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Emitter}; + +static FFMPEG_READY: AtomicBool = AtomicBool::new(false); +static FFMPEG_DOWNLOADING: AtomicBool = AtomicBool::new(false); +// Set when a provision attempt fails, so the frontend can distinguish +// "failed before the event listener attached" from "about to start". +static FFMPEG_FAILED: AtomicBool = AtomicBool::new(false); + +/// True once both ffmpeg and ffprobe binaries are present on disk. Workers +/// gate video jobs on this so a missing/in-flight download never fails jobs. +pub fn ffmpeg_ready() -> bool { + FFMPEG_READY.load(Ordering::Relaxed) +} + +pub fn ffmpeg_downloading() -> bool { + FFMPEG_DOWNLOADING.load(Ordering::Relaxed) +} + +pub fn ffmpeg_failed() -> bool { + FFMPEG_FAILED.load(Ordering::Relaxed) +} + +#[derive(Debug, Clone, Serialize)] +pub struct FfmpegProgressPayload { + pub phase: String, + pub downloaded_bytes: Option, + pub total_bytes: Option, + pub error: Option, +} + +impl FfmpegProgressPayload { + fn phase(phase: &str) -> Self { + Self { + phase: phase.to_string(), + downloaded_bytes: None, + total_bytes: None, + error: None, + } + } +} + +const FFMPEG_PROGRESS_EVENT: &str = "ffmpeg-progress"; + +/// Provision FFmpeg in the background, streaming progress to the frontend as +/// `ffmpeg-progress` events. Idempotent: re-invoking while a download is in +/// flight is a no-op, and re-invoking after success only re-emits `done`. +pub fn spawn_ffmpeg_provision(app: AppHandle) { + // Fast path: binaries already on disk (previous run, or a manual install). + if ffmpeg_path().exists() && ffprobe_path().exists() { + FFMPEG_READY.store(true, Ordering::Relaxed); + let _ = app.emit(FFMPEG_PROGRESS_EVENT, FfmpegProgressPayload::phase("done")); + return; + } + + if FFMPEG_DOWNLOADING + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return; // a download is already running + } + FFMPEG_FAILED.store(false, Ordering::SeqCst); + + std::thread::spawn(move || { + // The Downloading callback fires very frequently; throttle emissions. + // Cell because the download callback is Fn, not FnMut. + let last_emit = std::cell::Cell::new(Instant::now() - Duration::from_secs(1)); + let result = auto_download_with_progress(|event| match event { + FfmpegDownloadProgressEvent::Starting => { + log::info!("Downloading bundled FFmpeg..."); + let _ = app.emit( + FFMPEG_PROGRESS_EVENT, + FfmpegProgressPayload::phase("starting"), + ); + } + FfmpegDownloadProgressEvent::Downloading { + total_bytes, + downloaded_bytes, + } => { + if last_emit.get().elapsed() >= Duration::from_millis(250) { + last_emit.set(Instant::now()); + let _ = app.emit( + FFMPEG_PROGRESS_EVENT, + FfmpegProgressPayload { + phase: "downloading".to_string(), + downloaded_bytes: Some(downloaded_bytes), + total_bytes: Some(total_bytes), + error: None, + }, + ); + } + } + FfmpegDownloadProgressEvent::UnpackingArchive => { + log::info!("Unpacking bundled FFmpeg..."); + let _ = app.emit( + FFMPEG_PROGRESS_EVENT, + FfmpegProgressPayload::phase("unpacking"), + ); + } + FfmpegDownloadProgressEvent::Done => { + log::info!("Bundled FFmpeg ready."); + } + }); + + FFMPEG_DOWNLOADING.store(false, Ordering::SeqCst); + match result { + Ok(()) => { + FFMPEG_READY.store(true, Ordering::Relaxed); + let _ = app.emit(FFMPEG_PROGRESS_EVENT, FfmpegProgressPayload::phase("done")); + } + Err(error) => { + FFMPEG_FAILED.store(true, Ordering::SeqCst); + log::error!("FFmpeg provisioning failed: {error}"); + let _ = app.emit( + FFMPEG_PROGRESS_EVENT, + FfmpegProgressPayload { + phase: "error".to_string(), + downloaded_bytes: None, + total_bytes: None, + error: Some(error.to_string()), + }, + ); + } + } + }); +} // On Windows, GUI apps spawn subprocesses with a visible console window by default. // CREATE_NO_WINDOW suppresses that for every ffmpeg/ffprobe invocation. @@ -36,30 +163,6 @@ impl MediaTools { } } - pub fn ensure_installed() -> Result<()> { - // Skip download entirely if both binaries are already present. - if ffmpeg_path().exists() && ffprobe_path().exists() { - return Ok(()); - } - auto_download_with_progress(|event| match event { - FfmpegDownloadProgressEvent::Starting => { - log::info!("Downloading bundled FFmpeg..."); - } - FfmpegDownloadProgressEvent::Downloading { - total_bytes, - downloaded_bytes, - } => { - log::info!("Downloading bundled FFmpeg: {downloaded_bytes}/{total_bytes} bytes"); - } - FfmpegDownloadProgressEvent::UnpackingArchive => { - log::info!("Unpacking bundled FFmpeg..."); - } - FfmpegDownloadProgressEvent::Done => { - log::info!("Bundled FFmpeg ready."); - } - }) - } - pub fn ffmpeg_command(&self) -> Command { let mut cmd = Command::new(&self.ffmpeg_path); #[cfg(target_os = "windows")] diff --git a/src/App.tsx b/src/App.tsx index d1ad17e..8abf98f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { Timeline } from "./components/Timeline"; import { TitleBar } from "./components/TitleBar"; import { SettingsModal } from "./components/SettingsModal"; import { UpdateToast } from "./components/UpdateToast"; +import { OnboardingOverlay } from "./components/onboarding/OnboardingOverlay"; import { initializeNotifications } from "./notifications"; export default function App() { @@ -24,6 +25,8 @@ export default function App() { const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress); const loadAppVersion = useGalleryStore((state) => state.loadAppVersion); const checkForUpdates = useGalleryStore((state) => state.checkForUpdates); + const loadFfmpegStatus = useGalleryStore((state) => state.loadFfmpegStatus); + const loadOnboardingCompleted = useGalleryStore((state) => state.loadOnboardingCompleted); const activeView = useGalleryStore((state) => state.activeView); useEffect(() => { @@ -31,6 +34,8 @@ export default function App() { void loadMutedFolderIds(); void loadNotificationsPaused(); void loadAppVersion(); + void loadFfmpegStatus(); + void loadOnboardingCompleted(); // Quiet launch check — dev builds have no signed artifacts to update to. if (import.meta.env.PROD) { void checkForUpdates({ quiet: true }); @@ -88,6 +93,7 @@ export default function App() { + ); } diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index f0c54f9..c11f0b6 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store"; +import { FfmpegStatusRow } from "./onboarding/StepWelcome"; type SettingsSection = "workspace" | "general"; @@ -184,6 +185,7 @@ export function SettingsModal() { const updateError = useGalleryStore((state) => state.updateError); const checkForUpdates = useGalleryStore((state) => state.checkForUpdates); const installUpdate = useGalleryStore((state) => state.installUpdate); + const openOnboarding = useGalleryStore((state) => state.openOnboarding); useEffect(() => { if (!settingsOpen) return; @@ -625,6 +627,24 @@ export function SettingsModal() { + + + + + + + React.ReactNode }[] = [ + { id: "welcome", title: "Welcome", component: () => }, + { id: "library", title: "Your library", component: () => }, + { id: "pipeline", title: "Background work", component: () => }, + { id: "gallery", title: "The gallery", component: () => }, + { id: "search", title: "Search", component: () => }, + { id: "views", title: "Views", component: () => }, + { id: "ai", title: "AI features", component: () => }, +]; + +export function OnboardingOverlay() { + const onboardingOpen = useGalleryStore((s) => s.onboardingOpen); + const onboardingStep = useGalleryStore((s) => s.onboardingStep); + const setOnboardingStep = useGalleryStore((s) => s.setOnboardingStep); + const completeOnboarding = useGalleryStore((s) => s.completeOnboarding); + + useEffect(() => { + if (!onboardingOpen) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") completeOnboarding(); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [onboardingOpen, completeOnboarding]); + + if (!onboardingOpen) return null; + + const step = STEPS[Math.min(onboardingStep, STEPS.length - 1)]; + const isFirst = onboardingStep === 0; + const isLast = onboardingStep >= STEPS.length - 1; + + return ( +
+
+ {/* Header */} +
+
+

+ Step {onboardingStep + 1} of {STEPS.length} +

+

{step.title}

+
+
+ {STEPS.map((s, i) => ( +
+
+ + {/* Step body */} +
+ + + {step.component()} + + +
+ + {/* Footer */} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/components/onboarding/StepAddLibrary.tsx b/src/components/onboarding/StepAddLibrary.tsx new file mode 100644 index 0000000..ea1fdc7 --- /dev/null +++ b/src/components/onboarding/StepAddLibrary.tsx @@ -0,0 +1,77 @@ +import { useState } from "react"; +import { open } from "@tauri-apps/plugin-dialog"; +import { useGalleryStore } from "../../store"; +import { FakeTile } from "./fakes"; + +export function StepAddLibrary() { + const folders = useGalleryStore((s) => s.folders); + const addFolder = useGalleryStore((s) => s.addFolder); + const [adding, setAdding] = useState(false); + const [addError, setAddError] = useState(null); + + const handlePick = async () => { + setAddError(null); + const selected = await open({ directory: true, multiple: false, title: "Select Media Folder" }); + if (typeof selected !== "string") return; + setAdding(true); + try { + await addFolder(selected); + } catch (error) { + setAddError(error instanceof Error ? error.message : String(error)); + } finally { + setAdding(false); + } + }; + + return ( +
+

+ A library is just a folder on disk — Phokus watches it and keeps itself in sync as files are + added, renamed, or removed. Nothing is moved or copied. +

+ +
+
+ {folders.length > 0 ? ( + <> +

+ {folders.length === 1 ? `“${folders[0].name}” added` : `${folders.length} folders in your library`} +

+

+ Indexing runs in the background — you can add more folders from the sidebar any time. +

+ + ) : ( + <> +

Add your first folder

+

+ Pick somewhere with photos or videos. You can add more later from the sidebar. +

+ + )} + {addError ?

{addError}

: null} +
+ +
+ +

+ As indexing runs, the gallery fills in roughly like this — tiles appear immediately and sharpen + as thumbnails are generated: +

+
+ + + + + + +
+
+ ); +} diff --git a/src/components/onboarding/StepAiFeatures.tsx b/src/components/onboarding/StepAiFeatures.tsx new file mode 100644 index 0000000..77334f1 --- /dev/null +++ b/src/components/onboarding/StepAiFeatures.tsx @@ -0,0 +1,96 @@ +import { useEffect } from "react"; +import { useGalleryStore } from "../../store"; +import { FakeProgressBar } from "./fakes"; + +const FAKE_TAGS = ["landscape", "sunset", "outdoors", "no_humans", "ocean", "cloudy_sky"]; + +export function StepAiFeatures() { + const taggerModelStatus = useGalleryStore((s) => s.taggerModelStatus); + const taggerModelPreparing = useGalleryStore((s) => s.taggerModelPreparing); + const taggerModelProgress = useGalleryStore((s) => s.taggerModelProgress); + const prepareTaggerModel = useGalleryStore((s) => s.prepareTaggerModel); + const loadTaggerModelStatus = useGalleryStore((s) => s.loadTaggerModelStatus); + + useEffect(() => { + void loadTaggerModelStatus(); + }, [loadTaggerModelStatus]); + + const taggerReady = taggerModelStatus?.ready ?? false; + + return ( +
+

+ Two optional AI features run entirely on this machine — nothing is sent anywhere. Both are + one-time downloads you can also start later from Settings. +

+ +

AI tagging

+
+
+
+
+

Automatic tags for every image

+

+ The WD tagger model (~1.3 GB download) labels images so you can search with{" "} + /t — tags look like: +

+ + {FAKE_TAGS.map((tag) => ( + + {tag} + + ))} + +
+
+ {taggerReady ? ( + + Installed + + ) : ( + + )} +
+
+ {taggerModelPreparing ? ( +
+ 0 + ? taggerModelProgress.completed_files / taggerModelProgress.total_files + : null + } + /> + {taggerModelProgress?.current_file ? ( +

{taggerModelProgress.current_file}

+ ) : null} +
+ ) : null} +
+
+ +

Semantic search & similarity

+
+
+

Search by meaning, find look-alikes

+

+ Powers /s search and + "find similar". The CLIP model (~330 MB) downloads automatically the first time embeddings are + generated — no action needed; you'll see it in the background-tasks bar. +

+
+
+ +

+ That's the tour. Add folders from the sidebar, and revisit any of this from Settings — including + re-running this tour. +

+
+ ); +} diff --git a/src/components/onboarding/StepGalleryPreview.tsx b/src/components/onboarding/StepGalleryPreview.tsx new file mode 100644 index 0000000..611cb61 --- /dev/null +++ b/src/components/onboarding/StepGalleryPreview.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from "react"; +import { FakeTile } from "./fakes"; + +const TILE_COUNT = 12; +const REVEAL_MS = 350; + +const TILE_PROPS: { favorite?: boolean; rating?: number; duration?: string }[] = [ + {}, + { favorite: true }, + {}, + { duration: "1:24" }, + { rating: 5 }, + {}, + { favorite: true, rating: 3 }, + {}, + { duration: "0:09" }, + {}, + { rating: 4 }, + {}, +]; + +/// Placeholder tiles "loading in" one by one, looping, to show how the grid +/// fills while thumbnails are generated. +export function StepGalleryPreview() { + const [revealed, setRevealed] = useState(0); + + useEffect(() => { + const timer = setInterval(() => { + setRevealed((current) => (current >= TILE_COUNT + 4 ? 0 : current + 1)); + }, REVEAL_MS); + return () => clearInterval(timer); + }, []); + + return ( +
+

+ The gallery is a virtualized grid — it stays fast with hundreds of thousands of items. Tiles + carry your favorites, star ratings, and video durations; hover for filename and quick actions. +

+ +
+ {TILE_PROPS.map((props, i) => ( + + ))} +
+ +
+

+ Click any tile to open the lightbox — keyboard navigation, + zoom, inline tag editing, ratings, and a full video player. +

+

+ The toolbar filters by type, favorites, or rating, sorts by + date/name/size, and switches grid density. +

+
+
+ ); +} diff --git a/src/components/onboarding/StepPipeline.tsx b/src/components/onboarding/StepPipeline.tsx new file mode 100644 index 0000000..5cee730 --- /dev/null +++ b/src/components/onboarding/StepPipeline.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from "react"; +import { FakeProgressBar, FakeStageTag } from "./fakes"; + +const STAGES = ["Thumbnails", "Metadata", "Embeddings", "Tags"] as const; +const STAGE_TOTAL = 128; +const TICK_MS = 90; +const STEP_PER_TICK = 6; + +/// A looping, entirely fake render of the background-tasks bar: each stage +/// drains in order, then the cycle restarts. +export function StepPipeline() { + const [stageIndex, setStageIndex] = useState(0); + const [done, setDone] = useState(0); + + useEffect(() => { + const timer = setInterval(() => { + setDone((current) => { + if (current + STEP_PER_TICK < STAGE_TOTAL) return current + STEP_PER_TICK; + setStageIndex((stage) => (stage + 1) % STAGES.length); + return 0; + }); + }, TICK_MS); + return () => clearInterval(timer); + }, []); + + const remaining = STAGE_TOTAL - done; + + return ( +
+

+ After indexing, Phokus works through a strict pipeline: thumbnails first, then video metadata, + then visual embeddings (for similarity and semantic search), then AI tags. One stage at a time, + so each runs at full speed. +

+ +

+ While it's working you'll see this bar above the gallery — here's a preview: +

+ + {/* Fake BackgroundTasks slim bar */} +
+
+ + + + + Holiday Photos +
+ {STAGES.map((stage, i) => ( + + ))} +
+ +
+
+ +
+

+ It's all interruptible. Close the app whenever you like — + the queue picks up where it left off next launch. +

+

+ Per-folder control. Right-click a folder in the sidebar to + pause its background work, and click the bar itself to expand a detailed per-folder view. +

+
+
+ ); +} diff --git a/src/components/onboarding/StepSearchDemo.tsx b/src/components/onboarding/StepSearchDemo.tsx new file mode 100644 index 0000000..57af0c8 --- /dev/null +++ b/src/components/onboarding/StepSearchDemo.tsx @@ -0,0 +1,81 @@ +import { useEffect, useState } from "react"; +import { FakeTile } from "./fakes"; + +const DEMOS = [ + { + query: "beach-day_042.jpg", + mode: "Filename", + description: "Plain text matches file names — the default, instant search.", + tiles: [2, 5, 0], + }, + { + query: "/s golden sunset over water", + mode: "Semantic", + description: "Describe what's in the picture. Visual embeddings find matches even when filenames say nothing.", + tiles: [1, 7, 5], + }, + { + query: "/t landscape", + mode: "Tags", + description: "Search by AI or manual tags. Autocomplete suggests tags as you type.", + tiles: [4, 2, 6], + }, +] as const; + +const TYPE_MS = 55; +const HOLD_MS = 2600; + +/// A mock search bar that "types" each demo query, shows fake results, and +/// cycles to the next mode. +export function StepSearchDemo() { + const [demoIndex, setDemoIndex] = useState(0); + const [typed, setTyped] = useState(0); + + const demo = DEMOS[demoIndex]; + + useEffect(() => { + if (typed < demo.query.length) { + const timer = setTimeout(() => setTyped((n) => n + 1), TYPE_MS); + return () => clearTimeout(timer); + } + const timer = setTimeout(() => { + setDemoIndex((i) => (i + 1) % DEMOS.length); + setTyped(0); + }, HOLD_MS); + return () => clearTimeout(timer); + }, [typed, demo.query.length]); + + const fullyTyped = typed >= demo.query.length; + + return ( +
+

+ One search bar, three modes — picked by prefix. No prefix searches filenames, /s searches + by meaning, /t searches tags. +

+ + {/* Mock search bar */} +
+ + + + + {demo.query.slice(0, typed)} + | + + + {demo.mode} + +
+ + {/* Fake results */} +
+ {demo.tiles.map((tileIndex, i) => ( + + ))} +
+ +

{demo.description}

+
+ ); +} diff --git a/src/components/onboarding/StepViews.tsx b/src/components/onboarding/StepViews.tsx new file mode 100644 index 0000000..32f633d --- /dev/null +++ b/src/components/onboarding/StepViews.tsx @@ -0,0 +1,94 @@ +import { FakeTile, tileGradient } from "./fakes"; + +function ViewRow({ title, description, preview }: { title: string; description: string; preview: React.ReactNode }) { + return ( +
+
+

{title}

+

{description}

+
+
{preview}
+
+ ); +} + +function ExplorePreview() { + // Cluster blobs of varying size, like the tag cloud / cluster map. + const blobs = [ + { size: 34, x: 4, y: 10, i: 0 }, + { size: 24, x: 46, y: 0, i: 2 }, + { size: 18, x: 86, y: 22, i: 4 }, + { size: 26, x: 30, y: 36, i: 1 }, + { size: 16, x: 70, y: 44, i: 6 }, + ]; + return ( +
+ {blobs.map((blob, idx) => ( +
+ ))} +
+ ); +} + +function TimelinePreview() { + return ( +
+ {[2024, 2023].map((year, row) => ( +
+ {year} + {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ ))} +
+ ); +} + +function DuplicatesPreview() { + return ( +
+
+ +
+
+ + +
+
+ ); +} + +export function StepViews() { + return ( +
+

+ Beyond the main grid, the sidebar switches between three more ways to look at your library: +

+
+ } + /> + } + /> + } + /> +
+

+ Each view can be scoped to a single folder from its header — no need to bounce through the sidebar. +

+
+ ); +} diff --git a/src/components/onboarding/StepWelcome.tsx b/src/components/onboarding/StepWelcome.tsx new file mode 100644 index 0000000..3673e64 --- /dev/null +++ b/src/components/onboarding/StepWelcome.tsx @@ -0,0 +1,92 @@ +import { useGalleryStore } from "../../store"; +import { FakeProgressBar, formatBytes } from "./fakes"; + +export function FfmpegStatusRow() { + const ffmpegStatus = useGalleryStore((s) => s.ffmpegStatus); + const ffmpegProgress = useGalleryStore((s) => s.ffmpegProgress); + const ffmpegError = useGalleryStore((s) => s.ffmpegError); + const retryFfmpegDownload = useGalleryStore((s) => s.retryFfmpegDownload); + + if (ffmpegStatus === "installed") { + return ( +
+ + + +
+

Media engine ready

+

FFmpeg is installed — video thumbnails and metadata are available.

+
+
+ ); + } + + if (ffmpegStatus === "error") { + return ( +
+
+
+

Media engine download failed

+

{ffmpegError}

+

+ You can keep going — photos work without it. Video thumbnails and metadata will start + automatically once the download succeeds. +

+
+ +
+
+ ); + } + + const fraction = + ffmpegStatus === "downloading" && ffmpegProgress && ffmpegProgress.total_bytes > 0 + ? ffmpegProgress.downloaded_bytes / ffmpegProgress.total_bytes + : null; + + return ( +
+
+

+ {ffmpegStatus === "unpacking" ? "Unpacking media engine..." : "Downloading media engine (FFmpeg)..."} +

+ {ffmpegProgress ? ( + + {formatBytes(ffmpegProgress.downloaded_bytes)} / {formatBytes(ffmpegProgress.total_bytes)} + + ) : null} +
+ +

+ FFmpeg powers video thumbnails and metadata. It downloads once in the background — you can keep + using the tour and the app while it finishes. +

+
+ ); +} + +export function StepWelcome() { + return ( +
+

+ Phokus is a local-first media library: point it at your folders and it builds a fast, searchable + gallery with thumbnails, AI tags, and visual search. Everything is processed on this machine — + your photos and videos never leave it. +

+

+ This short tour shows what to expect. Every step is skippable, and you can re-run it any time + from Settings. +

+ +

First-time setup

+
+ +
+
+ ); +} diff --git a/src/components/onboarding/fakes.tsx b/src/components/onboarding/fakes.tsx new file mode 100644 index 0000000..e0d147f --- /dev/null +++ b/src/components/onboarding/fakes.tsx @@ -0,0 +1,97 @@ +// Shared placeholder primitives for the onboarding tour. Everything here is +// deliberately fake — gradient stand-ins for media, looping demo progress — +// so new users see the real UI's shapes before their own library exists. + +const TILE_GRADIENTS = [ + "from-sky-900/70 via-slate-800 to-slate-900", + "from-amber-900/60 via-stone-800 to-stone-900", + "from-emerald-900/60 via-slate-800 to-gray-900", + "from-rose-900/50 via-slate-800 to-slate-900", + "from-indigo-900/60 via-slate-800 to-gray-900", + "from-cyan-900/60 via-slate-800 to-slate-900", + "from-fuchsia-900/40 via-slate-800 to-gray-900", + "from-orange-900/50 via-stone-800 to-stone-900", +]; + +export function tileGradient(index: number): string { + return TILE_GRADIENTS[index % TILE_GRADIENTS.length]; +} + +export function FakeTile({ + index, + loaded = true, + favorite = false, + rating = 0, + duration, + className = "", +}: { + index: number; + loaded?: boolean; + favorite?: boolean; + rating?: number; + duration?: string; + className?: string; +}) { + return ( +
+ {loaded ? ( +
+ ) : ( +
+ )} + {loaded && favorite ? ( +
+ + + +
+ ) : null} + {loaded && rating > 0 ? ( +
+ {Array.from({ length: rating }).map((_, i) => ( + + + + ))} +
+ ) : null} + {loaded && duration ? ( +
+ {duration} +
+ ) : null} +
+ ); +} + +export function FakeStageTag({ label, state }: { label: string; state: "active" | "done" | "waiting" }) { + const className = + state === "active" + ? "bg-white/5 text-gray-300" + : state === "done" + ? "bg-emerald-500/10 text-emerald-400" + : "bg-white/4 text-gray-600"; + return {label}; +} + +export function FakeProgressBar({ fraction, className = "" }: { fraction: number | null; className?: string }) { + return ( +
+ {fraction === null ? ( +
+ ) : ( +
+ )} +
+ ); +} + +export function formatBytes(bytes: number): string { + if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; + if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${bytes} B`; +} diff --git a/src/store.ts b/src/store.ts index 6fcd25d..a150bb5 100644 --- a/src/store.ts +++ b/src/store.ts @@ -264,6 +264,15 @@ export type SortOrder = export type UpdateStatus = "idle" | "checking" | "upToDate" | "available" | "downloading" | "installing" | "error"; +export type FfmpegStatus = "unknown" | "starting" | "downloading" | "unpacking" | "installed" | "error"; + +interface FfmpegProgressEvent { + phase: string; + downloaded_bytes: number | null; + total_bytes: number | null; + error: string | null; +} + // The Update handle from the plugin carries the download method; it's not // serializable state, so it lives outside the store. let pendingUpdate: Update | null = null; @@ -326,6 +335,13 @@ interface GalleryState { updateError: string | null; updateDismissed: boolean; + ffmpegStatus: FfmpegStatus; + ffmpegProgress: { downloaded_bytes: number; total_bytes: number } | null; + ffmpegError: string | null; + onboardingCompleted: boolean | null; // null = not loaded yet + onboardingOpen: boolean; + onboardingStep: number; + taggerModelStatus: TaggerModelStatus | null; taggerModelPreparing: boolean; taggerModelError: string | null; @@ -407,6 +423,12 @@ interface GalleryState { checkForUpdates: (options?: { quiet?: boolean }) => Promise; installUpdate: () => Promise; dismissUpdate: () => void; + loadFfmpegStatus: () => Promise; + retryFfmpegDownload: () => Promise; + loadOnboardingCompleted: () => Promise; + completeOnboarding: () => void; + openOnboarding: () => void; + setOnboardingStep: (step: number) => void; openAppDataFolder: () => Promise; getDatabaseInfo: () => Promise; vacuumDatabase: () => Promise; @@ -692,6 +714,13 @@ export const useGalleryStore = create((set, get) => ({ updateError: null, updateDismissed: false, + ffmpegStatus: "unknown", + ffmpegProgress: null, + ffmpegError: null, + onboardingCompleted: null, + onboardingOpen: false, + onboardingStep: 0, + taggerModelStatus: null, taggerModelPreparing: false, taggerModelError: null, @@ -1562,6 +1591,60 @@ export const useGalleryStore = create((set, get) => ({ dismissUpdate: () => set({ updateDismissed: true }), + loadFfmpegStatus: async () => { + try { + const status = await invoke<{ installed: boolean; downloading: boolean; failed: boolean }>( + "get_ffmpeg_status", + ); + if (status.installed) { + set({ ffmpegStatus: "installed" }); + } else if (status.failed) { + // The download failed before our event listener attached — surface + // the error state so the retry button is reachable. + set({ ffmpegStatus: "error", ffmpegError: "The download could not be completed. Check your connection and retry." }); + } else { + // Not installed and possibly not downloading yet — the provision + // thread starts with the app, so treat the gap as "starting" and let + // the first ffmpeg-progress event settle the real state. + set({ ffmpegStatus: "starting" }); + } + } catch { + // leave "unknown"; events will correct it + } + }, + + retryFfmpegDownload: async () => { + set({ ffmpegStatus: "starting", ffmpegError: null, ffmpegProgress: null }); + try { + await invoke("retry_ffmpeg_download"); + } catch (error) { + set({ ffmpegStatus: "error", ffmpegError: error instanceof Error ? error.message : String(error) }); + } + }, + + loadOnboardingCompleted: async () => { + try { + const completed = await invoke("get_onboarding_completed"); + set( + completed + ? { onboardingCompleted: true } + : { onboardingCompleted: false, onboardingOpen: true, onboardingStep: 0 }, + ); + } catch { + // If the flag can't be read, don't trap the user in onboarding. + set({ onboardingCompleted: true }); + } + }, + + completeOnboarding: () => { + set({ onboardingOpen: false, onboardingCompleted: true }); + void invoke("set_onboarding_completed", { completed: true }).catch(() => {}); + }, + + openOnboarding: () => set({ onboardingOpen: true, onboardingStep: 0 }), + + setOnboardingStep: (step) => set({ onboardingStep: step }), + openAppDataFolder: async () => { await invoke("open_app_data_folder"); }, @@ -2070,6 +2153,33 @@ export const useGalleryStore = create((set, get) => ({ void get().loadFolders(); }); + const unlistenFfmpegProgress = await listen("ffmpeg-progress", (event) => { + const payload = event.payload; + switch (payload.phase) { + case "starting": + set({ ffmpegStatus: "starting", ffmpegError: null }); + break; + case "downloading": + set({ + ffmpegStatus: "downloading", + ffmpegProgress: + payload.downloaded_bytes !== null && payload.total_bytes !== null + ? { downloaded_bytes: payload.downloaded_bytes, total_bytes: payload.total_bytes } + : null, + }); + break; + case "unpacking": + set({ ffmpegStatus: "unpacking" }); + break; + case "done": + set({ ffmpegStatus: "installed", ffmpegProgress: null, ffmpegError: null }); + break; + case "error": + set({ ffmpegStatus: "error", ffmpegError: payload.error ?? "Download failed" }); + break; + } + }); + return () => { unlistenProgress(); unlistenMediaJobs(); @@ -2079,6 +2189,7 @@ export const useGalleryStore = create((set, get) => ({ unlistenThumbnails(); unlistenWatcherDeleted(); unlistenFolderCounts(); + unlistenFfmpegProgress(); }; }, }));