feat(onboarding): guided first-run tour with background FFmpeg provisioning

Phase 5 of the 0.1.0 release prep:
- FFmpeg no longer blocks startup (or crashes the app offline): provisioning
  runs in a background thread emitting throttled ffmpeg-progress events,
  with installed/downloading/failed state queryable via get_ffmpeg_status
  and a retry command
- video jobs are invisible to claiming and tier-priority checks until FFmpeg
  is ready, so they wait as pending without failing — and without starving
  image embeddings/tagging (include_videos gating in db.rs)
- 7-step show-don't-tell onboarding wizard: welcome + live FFmpeg progress,
  real first-folder picker, faked animating pipeline bar, placeholder
  gallery tiles, cycling search-syntax demo, views overview, and an AI
  features step with a real opt-in tagger download
- skippable at every point (Escape included), persisted via
  settings/onboarding_completed.txt, re-runnable from Settings > General,
  which also gains an FFmpeg status/retry row
This commit is contained in:
2026-06-12 23:09:05 +01:00
parent 1640e30330
commit 7403f0cfeb
17 changed files with 1139 additions and 30 deletions
+47
View File
@@ -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<FfmpegStatus, String> {
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<bool, String> {
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())
}
+28 -2
View File
@@ -1132,6 +1132,7 @@ pub fn get_all_folder_job_progress(conn: &Connection) -> Result<Vec<FolderJobPro
fn get_pending_thumbnail_jobs_excluding(
conn: &Connection,
excluded_folder_ids: &std::collections::HashSet<i64>,
include_videos: bool,
limit: usize,
) -> Result<Vec<ThumbnailJob>> {
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::<rusqlite::Result<Vec<_>>>()?)
}
/// 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<i64>,
include_videos: bool,
) -> Result<bool> {
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<i64>,
paused_folder_ids: &std::collections::HashSet<i64>,
include_videos: bool,
fetch_limit: usize,
claim_limit: usize,
) -> Result<Vec<ThumbnailJob>> {
@@ -1219,7 +1240,12 @@ pub fn claim_thumbnail_jobs(
.union(paused_folder_ids)
.copied()
.collect::<std::collections::HashSet<_>>();
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 {
+12 -2
View File
@@ -142,14 +142,18 @@ fn higher_priority_work_pending(pool: &DbPool, own_tier: WorkerTier) -> Result<b
let conn = pool.get()?;
let active_folders = active_indexing_folders();
// Video jobs are unclaimable until FFmpeg is provisioned; they must not
// count as pending higher-priority work or they'd stall lower tiers.
let ffmpeg_ready = crate::media::ffmpeg_ready();
if own_tier > 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<bool> {
// 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);
}
+8 -1
View File
@@ -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,
])
+128 -25
View File
@@ -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<u64>,
pub total_bytes: Option<u64>,
pub error: Option<String>,
}
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")]
+6
View File
@@ -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() {
<Lightbox />
<SettingsModal />
<UpdateToast />
<OnboardingOverlay />
</div>
);
}
+20
View File
@@ -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() {
</SettingsItem>
</SettingsGroup>
<SettingsGroup title="Setup">
<FfmpegStatusRow />
<SettingsItem
label="Welcome tour"
description="Replay the guided first-run tour — library setup, the background pipeline, search modes, and AI features."
>
<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"
onClick={() => {
setSettingsOpen(false);
openOnboarding();
}}
>
Show welcome tour
</button>
</SettingsItem>
</SettingsGroup>
<SettingsGroup title="Storage & notifications">
<SettingsItem
label="App data folder"
@@ -0,0 +1,110 @@
import { useEffect } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useGalleryStore } from "../../store";
import { StepWelcome } from "./StepWelcome";
import { StepAddLibrary } from "./StepAddLibrary";
import { StepPipeline } from "./StepPipeline";
import { StepGalleryPreview } from "./StepGalleryPreview";
import { StepSearchDemo } from "./StepSearchDemo";
import { StepViews } from "./StepViews";
import { StepAiFeatures } from "./StepAiFeatures";
const STEPS: { id: string; title: string; component: () => React.ReactNode }[] = [
{ id: "welcome", title: "Welcome", component: () => <StepWelcome /> },
{ id: "library", title: "Your library", component: () => <StepAddLibrary /> },
{ id: "pipeline", title: "Background work", component: () => <StepPipeline /> },
{ id: "gallery", title: "The gallery", component: () => <StepGalleryPreview /> },
{ id: "search", title: "Search", component: () => <StepSearchDemo /> },
{ id: "views", title: "Views", component: () => <StepViews /> },
{ id: "ai", title: "AI features", component: () => <StepAiFeatures /> },
];
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 (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/65 backdrop-blur-sm">
<div className="flex max-h-[min(85vh,760px)] w-[min(90vw,720px)] flex-col rounded-lg border border-white/10 bg-[#07080f] shadow-2xl shadow-black/60">
{/* Header */}
<div className="flex items-center justify-between border-b border-white/[0.07] px-7 py-4">
<div>
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">
Step {onboardingStep + 1} of {STEPS.length}
</p>
<h2 className="mt-0.5 text-base font-semibold text-white">{step.title}</h2>
</div>
<div className="flex items-center gap-1.5">
{STEPS.map((s, i) => (
<button
key={s.id}
aria-label={s.title}
className={`h-1.5 rounded-full transition-all ${
i === onboardingStep ? "w-5 bg-white/70" : i < onboardingStep ? "w-1.5 bg-white/35" : "w-1.5 bg-white/15"
}`}
onClick={() => setOnboardingStep(i)}
/>
))}
</div>
</div>
{/* Step body */}
<div className="min-h-0 flex-1 overflow-y-auto px-7 py-6">
<AnimatePresence mode="wait">
<motion.div
key={step.id}
initial={{ opacity: 0, x: 14 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -14 }}
transition={{ duration: 0.16 }}
>
{step.component()}
</motion.div>
</AnimatePresence>
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-white/[0.07] px-7 py-4">
<button
className="rounded-md border border-transparent px-3 py-1.5 text-xs text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-gray-200"
onClick={completeOnboarding}
>
Skip tour
</button>
<div className="flex items-center gap-2">
<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"
onClick={() => setOnboardingStep(onboardingStep - 1)}
disabled={isFirst}
>
Back
</button>
<button
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-4 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25"
onClick={() => (isLast ? completeOnboarding() : setOnboardingStep(onboardingStep + 1))}
>
{isLast ? "Finish" : "Next"}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -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<string | null>(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 (
<div>
<p className="text-sm leading-relaxed text-gray-300">
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.
</p>
<div className="mt-5 flex items-center justify-between gap-6 border-y border-white/[0.05] py-4">
<div className="min-w-0">
{folders.length > 0 ? (
<>
<p className="text-sm text-white">
{folders.length === 1 ? `${folders[0].name}” added` : `${folders.length} folders in your library`}
</p>
<p className="mt-1 text-xs text-gray-500">
Indexing runs in the background you can add more folders from the sidebar any time.
</p>
</>
) : (
<>
<p className="text-sm text-white">Add your first folder</p>
<p className="mt-1 text-xs text-gray-500">
Pick somewhere with photos or videos. You can add more later from the sidebar.
</p>
</>
)}
{addError ? <p className="mt-1.5 text-xs text-amber-300/90">{addError}</p> : null}
</div>
<button
className="shrink-0 rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25 disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => void handlePick()}
disabled={adding}
>
{adding ? "Adding..." : folders.length > 0 ? "Add another folder" : "Choose a folder"}
</button>
</div>
<p className="mt-5 text-xs leading-relaxed text-gray-500">
As indexing runs, the gallery fills in roughly like this tiles appear immediately and sharpen
as thumbnails are generated:
</p>
<div className="mt-3 grid grid-cols-6 gap-1.5">
<FakeTile index={0} />
<FakeTile index={1} favorite />
<FakeTile index={2} duration="0:42" />
<FakeTile index={3} rating={4} />
<FakeTile index={4} loaded={false} />
<FakeTile index={5} loaded={false} />
</div>
</div>
);
}
@@ -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 (
<div>
<p className="text-sm leading-relaxed text-gray-300">
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.
</p>
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">AI tagging</h4>
<div className="mt-1 divide-y divide-white/[0.05]">
<div className="py-4">
<div className="flex items-start justify-between gap-6">
<div className="min-w-0">
<p className="text-sm text-white">Automatic tags for every image</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">
The WD tagger model (~1.3 GB download) labels images so you can search with{" "}
<code className="rounded bg-white/[0.07] px-1 py-0.5 text-[11px] text-gray-200">/t</code> tags look like:
</p>
<span className="mt-2 flex flex-wrap gap-1.5">
{FAKE_TAGS.map((tag) => (
<span key={tag} className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-gray-400">
{tag}
</span>
))}
</span>
</div>
<div className="shrink-0">
{taggerReady ? (
<span className="inline-flex rounded-md border border-emerald-400/25 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-300">
Installed
</span>
) : (
<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"
onClick={() => void prepareTaggerModel()}
disabled={taggerModelPreparing}
>
{taggerModelPreparing ? "Downloading..." : "Download now"}
</button>
)}
</div>
</div>
{taggerModelPreparing ? (
<div className="mt-3">
<FakeProgressBar
fraction={
taggerModelProgress && taggerModelProgress.total_files > 0
? taggerModelProgress.completed_files / taggerModelProgress.total_files
: null
}
/>
{taggerModelProgress?.current_file ? (
<p className="mt-1.5 truncate text-[11px] text-gray-600">{taggerModelProgress.current_file}</p>
) : null}
</div>
) : null}
</div>
</div>
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">Semantic search & similarity</h4>
<div className="mt-1 divide-y divide-white/[0.05]">
<div className="py-4">
<p className="text-sm text-white">Search by meaning, find look-alikes</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">
Powers <code className="rounded bg-white/[0.07] px-1 py-0.5 text-[11px] text-gray-200">/s</code> 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.
</p>
</div>
</div>
<p className="mt-5 text-xs leading-relaxed text-gray-500">
That's the tour. Add folders from the sidebar, and revisit any of this from Settings including
re-running this tour.
</p>
</div>
);
}
@@ -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 (
<div>
<p className="text-sm leading-relaxed text-gray-300">
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.
</p>
<div className="mt-5 grid grid-cols-4 gap-1.5">
{TILE_PROPS.map((props, i) => (
<FakeTile key={i} index={i} loaded={i < revealed} {...props} />
))}
</div>
<div className="mt-6 divide-y divide-white/[0.05] text-xs leading-relaxed text-gray-500">
<p className="py-2.5">
<span className="text-gray-300">Click any tile</span> to open the lightbox keyboard navigation,
zoom, inline tag editing, ratings, and a full video player.
</p>
<p className="py-2.5">
<span className="text-gray-300">The toolbar</span> filters by type, favorites, or rating, sorts by
date/name/size, and switches grid density.
</p>
</div>
</div>
);
}
@@ -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 (
<div>
<p className="text-sm leading-relaxed text-gray-300">
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.
</p>
<p className="mt-5 text-xs text-gray-500">
While it's working you'll see this bar above the gallery here's a preview:
</p>
{/* Fake BackgroundTasks slim bar */}
<div className="mt-3 rounded-lg border border-white/[0.07] bg-white/[0.02] px-4 py-3">
<div className="flex items-center gap-3">
<span className="relative flex h-1.5 w-1.5 shrink-0">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-60" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-blue-400" />
</span>
<span className="text-[13px] font-medium text-white/60">Holiday Photos</span>
<div className="flex items-center gap-1.5">
{STAGES.map((stage, i) => (
<FakeStageTag
key={stage}
label={i === stageIndex ? `${stage} · ${remaining}` : stage}
state={i < stageIndex ? "done" : i === stageIndex ? "active" : "waiting"}
/>
))}
</div>
<FakeProgressBar fraction={done / STAGE_TOTAL} className="ml-auto w-24" />
</div>
</div>
<div className="mt-6 divide-y divide-white/[0.05] text-xs leading-relaxed text-gray-500">
<p className="py-2.5">
<span className="text-gray-300">It's all interruptible.</span> Close the app whenever you like
the queue picks up where it left off next launch.
</p>
<p className="py-2.5">
<span className="text-gray-300">Per-folder control.</span> Right-click a folder in the sidebar to
pause its background work, and click the bar itself to expand a detailed per-folder view.
</p>
</div>
</div>
);
}
@@ -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 (
<div>
<p className="text-sm leading-relaxed text-gray-300">
One search bar, three modes picked by prefix. No prefix searches filenames, <code className="rounded bg-white/[0.07] px-1 py-0.5 text-[11px] text-gray-200">/s</code> searches
by meaning, <code className="rounded bg-white/[0.07] px-1 py-0.5 text-[11px] text-gray-200">/t</code> searches tags.
</p>
{/* Mock search bar */}
<div className="mt-5 flex items-center gap-2.5 rounded-lg border border-white/10 bg-white/[0.04] px-3.5 py-2.5">
<svg className="h-4 w-4 shrink-0 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<span className="text-sm text-white">
{demo.query.slice(0, typed)}
<span className="animate-pulse text-gray-500">|</span>
</span>
<span className="ml-auto shrink-0 rounded-md border border-sky-400/25 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium text-sky-300">
{demo.mode}
</span>
</div>
{/* Fake results */}
<div className={`mt-3 grid grid-cols-6 gap-1.5 transition-opacity duration-300 ${fullyTyped ? "opacity-100" : "opacity-30"}`}>
{demo.tiles.map((tileIndex, i) => (
<FakeTile key={`${demoIndex}-${i}`} index={tileIndex} />
))}
</div>
<p className="mt-4 min-h-10 text-xs leading-relaxed text-gray-500">{demo.description}</p>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { FakeTile, tileGradient } from "./fakes";
function ViewRow({ title, description, preview }: { title: string; description: string; preview: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-6 py-4">
<div className="min-w-0">
<p className="text-sm text-white">{title}</p>
<p className="mt-1 max-w-md text-xs leading-relaxed text-gray-500">{description}</p>
</div>
<div className="shrink-0">{preview}</div>
</div>
);
}
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 (
<div className="relative h-[72px] w-32 overflow-hidden rounded-lg border border-white/[0.07] bg-white/[0.02]">
{blobs.map((blob, idx) => (
<div
key={idx}
className={`absolute rounded-full bg-gradient-to-br opacity-80 ${tileGradient(blob.i)}`}
style={{ width: blob.size, height: blob.size, left: blob.x, top: blob.y }}
/>
))}
</div>
);
}
function TimelinePreview() {
return (
<div className="flex h-[72px] w-32 flex-col justify-center gap-2 rounded-lg border border-white/[0.07] bg-white/[0.02] px-3">
{[2024, 2023].map((year, row) => (
<div key={year} className="flex items-center gap-1.5">
<span className="w-7 text-[9px] tabular-nums text-gray-600">{year}</span>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className={`h-4 w-4 rounded-sm bg-gradient-to-br ${tileGradient(row * 3 + i)}`} />
))}
</div>
))}
</div>
);
}
function DuplicatesPreview() {
return (
<div className="flex h-[72px] w-32 items-center justify-center gap-1.5 rounded-lg border border-white/[0.07] bg-white/[0.02]">
<div className="w-10">
<FakeTile index={3} className="rounded-md" />
</div>
<div className="relative w-10">
<FakeTile index={3} className="rounded-md" />
<span className="absolute -right-1 -top-1 rounded-full bg-amber-500/90 px-1 text-[8px] font-semibold text-black">2×</span>
</div>
</div>
);
}
export function StepViews() {
return (
<div>
<p className="text-sm leading-relaxed text-gray-300">
Beyond the main grid, the sidebar switches between three more ways to look at your library:
</p>
<div className="mt-3 divide-y divide-white/[0.05]">
<ViewRow
title="Explore"
description="A visual cluster map and tag cloud — browse by theme instead of folder, and jump into any cluster."
preview={<ExplorePreview />}
/>
<ViewRow
title="Timeline"
description="Everything grouped chronologically by capture date (EXIF), from years down to days."
preview={<TimelinePreview />}
/>
<ViewRow
title="Duplicates"
description="A three-phase exact-duplicate scan with bulk delete — reclaim space from copies safely."
preview={<DuplicatesPreview />}
/>
</div>
<p className="mt-4 text-xs leading-relaxed text-gray-500">
Each view can be scoped to a single folder from its header no need to bounce through the sidebar.
</p>
</div>
);
}
+92
View File
@@ -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 (
<div className="flex items-center gap-2.5 py-3">
<svg className="h-4 w-4 shrink-0 text-emerald-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<p className="text-sm text-white">Media engine ready</p>
<p className="text-xs text-gray-500">FFmpeg is installed video thumbnails and metadata are available.</p>
</div>
</div>
);
}
if (ffmpegStatus === "error") {
return (
<div className="py-3">
<div className="flex items-start justify-between gap-6">
<div className="min-w-0">
<p className="text-sm text-white">Media engine download failed</p>
<p className="mt-1 text-xs leading-relaxed text-amber-300/90">{ffmpegError}</p>
<p className="mt-1.5 text-xs leading-relaxed text-gray-500">
You can keep going photos work without it. Video thumbnails and metadata will start
automatically once the download succeeds.
</p>
</div>
<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"
onClick={() => void retryFfmpegDownload()}
>
Retry download
</button>
</div>
</div>
);
}
const fraction =
ffmpegStatus === "downloading" && ffmpegProgress && ffmpegProgress.total_bytes > 0
? ffmpegProgress.downloaded_bytes / ffmpegProgress.total_bytes
: null;
return (
<div className="py-3">
<div className="flex items-baseline justify-between gap-6">
<p className="text-sm text-white">
{ffmpegStatus === "unpacking" ? "Unpacking media engine..." : "Downloading media engine (FFmpeg)..."}
</p>
{ffmpegProgress ? (
<span className="text-[11px] tabular-nums text-gray-500">
{formatBytes(ffmpegProgress.downloaded_bytes)} / {formatBytes(ffmpegProgress.total_bytes)}
</span>
) : null}
</div>
<FakeProgressBar fraction={ffmpegStatus === "unpacking" ? null : fraction} className="mt-2.5" />
<p className="mt-2 text-xs leading-relaxed text-gray-500">
FFmpeg powers video thumbnails and metadata. It downloads once in the background you can keep
using the tour and the app while it finishes.
</p>
</div>
);
}
export function StepWelcome() {
return (
<div>
<p className="text-sm leading-relaxed text-gray-300">
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.
</p>
<p className="mt-2.5 text-sm leading-relaxed text-gray-500">
This short tour shows what to expect. Every step is skippable, and you can re-run it any time
from Settings.
</p>
<h4 className="mt-7 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">First-time setup</h4>
<div className="mt-1 divide-y divide-white/[0.05]">
<FfmpegStatusRow />
</div>
</div>
);
}
+97
View File
@@ -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 (
<div className={`group relative aspect-square overflow-hidden rounded-xl bg-white/[0.04] ${className}`}>
{loaded ? (
<div className={`absolute inset-0 bg-gradient-to-br ${tileGradient(index)} transition-opacity duration-500`} />
) : (
<div className="absolute inset-0 animate-pulse bg-white/[0.04]" />
)}
{loaded && favorite ? (
<div className="absolute right-1.5 top-1.5 rounded-full bg-black/50 p-1 text-rose-400 backdrop-blur-sm">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
</svg>
</div>
) : null}
{loaded && rating > 0 ? (
<div className="absolute bottom-1.5 left-1.5 flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-1 text-amber-300">
{Array.from({ length: rating }).map((_, i) => (
<svg key={i} className="h-2.5 w-2.5" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.563.563 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
</svg>
))}
</div>
) : null}
{loaded && duration ? (
<div className="absolute bottom-1.5 right-1.5 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/80">
{duration}
</div>
) : null}
</div>
);
}
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 <span className={`rounded-md px-2 py-0.5 text-[11px] ${className}`}>{label}</span>;
}
export function FakeProgressBar({ fraction, className = "" }: { fraction: number | null; className?: string }) {
return (
<div className={`h-px overflow-hidden rounded-full bg-white/8 ${className}`}>
{fraction === null ? (
<div className="h-full w-full animate-pulse bg-blue-500/40" />
) : (
<div
className="h-full bg-blue-500 transition-[width] duration-300"
style={{ width: `${Math.round(Math.min(fraction, 1) * 100)}%` }}
/>
)}
</div>
);
}
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`;
}
+111
View File
@@ -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<void>;
installUpdate: () => Promise<void>;
dismissUpdate: () => void;
loadFfmpegStatus: () => Promise<void>;
retryFfmpegDownload: () => Promise<void>;
loadOnboardingCompleted: () => Promise<void>;
completeOnboarding: () => void;
openOnboarding: () => void;
setOnboardingStep: (step: number) => void;
openAppDataFolder: () => Promise<void>;
getDatabaseInfo: () => Promise<DatabaseInfo>;
vacuumDatabase: () => Promise<VacuumResult>;
@@ -692,6 +714,13 @@ export const useGalleryStore = create<GalleryState>((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<GalleryState>((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<boolean>("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<GalleryState>((set, get) => ({
void get().loadFolders();
});
const unlistenFfmpegProgress = await listen<FfmpegProgressEvent>("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<GalleryState>((set, get) => ({
unlistenThumbnails();
unlistenWatcherDeleted();
unlistenFolderCounts();
unlistenFfmpegProgress();
};
},
}));