diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d2a402d..4772e19 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -617,105 +617,114 @@ export default function App() { <>
` tag | Keep, but add a "Retry" button |
+| Silence detection | Errors use `alert()` | Show error inline in the panel |
+
+### Loading states
+
+| Component | Current | Fix |
+|-----------|---------|-----|
+| WaveformTimeline | Blank canvas while audio loads | Add a centered "Loading waveform…" spinner |
+| Export | Percentage text only | Add a determinate progress bar |
+| Transcription | Spinning waveform bars + text | Add a determinate progress bar for model download phase |
+| AI features | Spinner + "Processing…" | Add descriptive step text ("Analyzing transcript…") |
+
+---
+
+## 5. Consistency fixes
+
+### 5.1 Fix mute zone color in ZoneEditor
+`ZoneEditor.tsx` uses `border-orange-500/40` for mute zones — should be `border-blue-500/40` to match the waveform timeline's blue mute color.
+
+### 5.2 Unify disabled opacity
+- All disabled buttons: `opacity-40` (currently some use 50%)
+
+### 5.3 Unify border radius
+- All toolbar buttons: `rounded-md` (keep)
+- All sidebar panel inputs: `rounded-lg` (keep)
+- All zone/detection list items: `rounded-lg` (currently `rounded`)
+
+### 5.4 Remove orphaned VolumePanel
+`VolumePanel.tsx` is not imported anywhere. Either wire it into the sidebar or remove it.
+
+---
+
+## 6. Quick wins (implement first)
+
+- [ ] Add `title` tooltips to ALL toolbar buttons with shortcut hints
+- [ ] Add `title` tooltips to ALL ExportDialog controls
+- [ ] Fix mute zone color in ZoneEditor (orange → blue)
+- [ ] Add empty state to MarkersPanel
+- [ ] Add error display to AIPanel
+- [ ] Add close button to keyboard cheatsheet
+- [ ] Unify disabled opacity to 40% everywhere
+- [ ] Remove orphaned VolumePanel.tsx
+- [ ] Add loading spinner to WaveformTimeline
+
+## 7. Help system (implement second)
+
+- [ ] Create `HelpContent.tsx` with all feature documentation content
+- [ ] Add Help button to toolbar (`?` icon, opens sidebar)
+- [ ] Wire Help as a sidebar panel (like AI, Export, Settings)
+- [ ] Build first-run welcome overlay component
+- [ ] Add "Show help on startup" checkbox to Settings
+- [ ] Render keyboard cheatsheet as React portal with close button
+
+## 8. Polish (implement third)
+
+- [ ] Progress bar for export (determinate bar, not just text)
+- [ ] Progress bar for model downloads
+- [ ] Retry button on waveform load error
+- [ ] Confirmation dialog for zone/marker deletion
+- [ ] Keyboard-accessible split pane resizing
+- [ ] Larger hit targets for canvas zone handles (r=4 → r=6)
+- [ ] Search bar match indicator contrast improvement
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 0fd225c..3586492 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -11,6 +11,7 @@ mod ai_provider;
mod caption_generator;
mod background_removal;
mod licensing;
+mod models;
#[tauri::command]
fn get_projects_directory() -> Result {
@@ -207,6 +208,22 @@ async fn save_captions(content: String, output_path: String) -> Result Result, String> {
+ let data_dir = app_handle
+ .path()
+ .app_data_dir()
+ .map_err(|e| format!("No app data directory: {e}"))?;
+ Ok(models::list_models(&data_dir))
+}
+
+/// Delete a downloaded model by path.
+#[tauri::command]
+fn delete_model(path: String) -> Result<(), String> {
+ models::delete_model(&path)
+}
+
/// Get the combined app status: licensed, trial, or expired.
#[tauri::command]
fn get_app_status(app_handle: tauri::AppHandle) -> Result {
@@ -358,6 +375,8 @@ pub fn run() {
deactivate_license,
start_trial,
has_license_feature,
+ list_models,
+ delete_model,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs
new file mode 100644
index 0000000..6135b84
--- /dev/null
+++ b/src-tauri/src/models.rs
@@ -0,0 +1,141 @@
+use serde::{Deserialize, Serialize};
+use std::path::PathBuf;
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct ModelInfo {
+ pub name: String,
+ pub path: String,
+ pub size_bytes: u64,
+ pub kind: String,
+}
+
+fn huggingface_cache_dir() -> PathBuf {
+ // Follows huggingface_hub default cache location
+ if let Ok(custom) = std::env::var("HF_HOME") {
+ return PathBuf::from(custom).join("hub");
+ }
+ if let Ok(custom) = std::env::var("XDG_CACHE_HOME") {
+ return PathBuf::from(custom).join("huggingface").join("hub");
+ }
+ dirs::home_dir()
+ .unwrap_or_default()
+ .join(".cache")
+ .join("huggingface")
+ .join("hub")
+}
+
+fn scan_whisper_models() -> Vec {
+ let cache_dir = huggingface_cache_dir();
+ if !cache_dir.exists() {
+ return vec![];
+ }
+
+ let mut models = vec![];
+ let pattern = "models--Systran--faster-whisper-";
+ let Ok(entries) = std::fs::read_dir(&cache_dir) else {
+ return vec![];
+ };
+
+ for entry in entries.flatten() {
+ let name = entry.file_name();
+ let name_str = name.to_string_lossy();
+ if !name_str.starts_with(pattern) {
+ continue;
+ }
+ let model_name = name_str
+ .strip_prefix(pattern)
+ .unwrap_or(&name_str)
+ .to_string();
+
+ // The actual model files are in snapshots/ subdirectory
+ let snapshots_dir = entry.path().join("snapshots");
+ let mut total_size = 0u64;
+ if let Ok(snap_entries) = std::fs::read_dir(&snapshots_dir) {
+ for snap in snap_entries.flatten() {
+ total_size += dir_size(&snap.path());
+ }
+ }
+
+ // If no snapshots dir, try blobs/
+ if total_size == 0 {
+ let blobs_dir = entry.path().join("blobs");
+ if blobs_dir.exists() {
+ total_size = dir_size(&blobs_dir);
+ }
+ }
+
+ models.push(ModelInfo {
+ name: model_name,
+ path: entry.path().to_string_lossy().to_string(),
+ size_bytes: total_size,
+ kind: "whisper".to_string(),
+ });
+ }
+
+ models
+}
+
+fn scan_llm_models(app_data_dir: &PathBuf) -> Vec {
+ let models_dir = app_data_dir.join("models");
+ if !models_dir.exists() {
+ return vec![];
+ }
+
+ let mut models = vec![];
+ let Ok(entries) = std::fs::read_dir(&models_dir) else {
+ return vec![];
+ };
+
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.extension().map(|e| e == "gguf").unwrap_or(false) {
+ let meta = std::fs::metadata(&path).ok();
+ models.push(ModelInfo {
+ name: entry.file_name().to_string_lossy().to_string(),
+ path: path.to_string_lossy().to_string(),
+ size_bytes: meta.map(|m| m.len()).unwrap_or(0),
+ kind: "llm".to_string(),
+ });
+ }
+ }
+
+ models
+}
+
+fn dir_size(path: &std::path::Path) -> u64 {
+ let mut total = 0u64;
+ if let Ok(entries) = std::fs::read_dir(path) {
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ total += dir_size(&path);
+ } else if let Ok(meta) = std::fs::metadata(&path) {
+ total += meta.len();
+ }
+ }
+ }
+ total
+}
+
+pub fn list_models(app_data_dir: &PathBuf) -> Vec {
+ let mut models = scan_whisper_models();
+ models.extend(scan_llm_models(app_data_dir));
+ models
+}
+
+pub fn delete_model(path: &str) -> Result<(), String> {
+ let path = std::path::Path::new(path);
+ if !path.exists() {
+ return Err("Model path not found".to_string());
+ }
+
+ if path.is_dir() {
+ std::fs::remove_dir_all(path)
+ .map_err(|e| format!("Failed to delete model: {e}"))?;
+ } else {
+ std::fs::remove_file(path)
+ .map_err(|e| format!("Failed to delete model: {e}"))?;
+ }
+
+ Ok(())
+}