# TutorialDock Tauri Conversion — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Convert TutorialDock from a single-file Python+PyWebView+Flask app into a Tauri v2 desktop app with full Rust backend and Vite+TypeScript frontend, preserving identical functionality and UI. **Architecture:** Tauri v2 with custom `tutdock://` protocol for video/asset serving, 10 Rust backend modules (utils, state, prefs, recents, ffmpeg, subtitles, fonts, library, video_protocol, commands), and 9 TypeScript frontend modules. 100% portable — all state next to the exe. **Tech Stack:** Rust (Tauri v2, serde, sha2, tokio, reqwest), TypeScript (Vite), tauri-plugin-dialog **Source reference:** The original Python app is at `tutorial.py` (5,282 lines). All porting work references specific line ranges in that file. --- ## Phase 1: Project Scaffolding ### Task 1: Initialize Tauri v2 + Vite + TypeScript project **Files:** - Create: `src-tauri/Cargo.toml` - Create: `src-tauri/tauri.conf.json` - Create: `src-tauri/build.rs` - Create: `src-tauri/src/main.rs` (minimal stub) - Create: `src/index.html` (minimal stub) - Create: `src/main.ts` (minimal stub) - Create: `src/vite-env.d.ts` - Create: `package.json` - Create: `tsconfig.json` - Create: `vite.config.ts` **Step 1: Create package.json with Tauri + Vite + TypeScript dependencies** ```json { "name": "tutorialdock", "private": true, "version": "1.0.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", "tauri": "tauri" }, "dependencies": { "@tauri-apps/api": "^2" }, "devDependencies": { "@tauri-apps/cli": "^2", "typescript": "^5.5", "vite": "^6" } } ``` **Step 2: Create tsconfig.json** ```json { "compilerOptions": { "target": "ES2021", "useDefineForClassFields": true, "module": "ESNext", "lib": ["ES2021", "DOM", "DOM.Iterable"], "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "forceConsistentCasingInFileNames": true }, "include": ["src"] } ``` **Step 3: Create vite.config.ts** ```typescript import { defineConfig } from "vite"; export default defineConfig({ clearScreen: false, server: { port: 1420, strictPort: true, watch: { ignored: ["**/src-tauri/**"] }, }, }); ``` **Step 4: Create src/vite-env.d.ts** ```typescript /// ``` **Step 5: Create minimal src/index.html** A bare-bones HTML file that loads main.ts. Just enough to verify the build works. ```html TutorialDock
Loading...
``` **Step 6: Create minimal src/main.ts** ```typescript console.log("TutorialDock frontend loaded"); ``` **Step 7: Create src-tauri/Cargo.toml** ```toml [package] name = "tutorialdock" version = "1.0.0" edition = "2021" [dependencies] tauri = { version = "2", features = ["protocol-asset"] } tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" tokio = { version = "1", features = ["full"] } reqwest = { version = "0.12", features = ["stream"] } zip = "2" which = "7" regex = "1" once_cell = "1" [build-dependencies] tauri-build = { version = "2", features = [] } [features] default = ["custom-protocol"] custom-protocol = ["tauri/custom-protocol"] ``` **Step 8: Create src-tauri/build.rs** ```rust fn main() { tauri_build::build() } ``` **Step 9: Create src-tauri/tauri.conf.json** Key settings: - `identifier`: `com.tutorialdock.app` - `productName`: `TutorialDock` - `app.windows[0]`: title "TutorialDock", width 1320, height 860, decorations true - `app.security.csp`: allow `tutdock:` protocol in media-src, font-src, style-src - `bundle.active`: false (no installer — portable exe only) - Register `tutdock` as allowed custom protocol under `app.security.assetProtocol` ```json { "$schema": "https://raw.githubusercontent.com/nicedoc/tauri-schema/main/schema.json", "productName": "TutorialDock", "version": "1.0.0", "identifier": "com.tutorialdock.app", "build": { "frontendDist": "../dist", "devUrl": "http://localhost:1420", "beforeDevCommand": "npm run dev", "beforeBuildCommand": "npm run build" }, "app": { "windows": [ { "title": "TutorialDock", "width": 1320, "height": 860, "minWidth": 640, "minHeight": 480 } ], "security": { "csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' tutdock: blob:; font-src 'self' tutdock:; style-src 'self' 'unsafe-inline' tutdock:; img-src 'self' data: tutdock:; connect-src 'self' tutdock: https:" } }, "bundle": { "active": false }, "plugins": { "dialog": {} } } ``` **Step 10: Create minimal src-tauri/src/main.rs** ```rust #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .run(tauri::generate_context!()) .expect("error while running tauri application"); } ``` **Step 11: Install dependencies and verify build** Run: `npm install` Run: `npm run tauri dev` Expected: App window opens showing "Loading..." text. Console shows "TutorialDock frontend loaded". **Step 12: Commit** ``` feat: scaffold Tauri v2 + Vite + TypeScript project ``` --- ## Phase 2: Rust Foundation Modules ### Task 2: Implement utils.rs **Files:** - Create: `src-tauri/src/utils.rs` - Modify: `src-tauri/src/main.rs` (add `mod utils;`) **Port from:** `tutorial.py` lines 90-93 (`clamp`), 159-166 (`is_within_root`), 168-176 (`truthy`), 186-192 (`folder_display_name`), 195-205 (`_deduplicate_list`), 400-464 (`natural_key`, `_smart_title_case`, `pretty_title_from_filename`), 696-731 (`file_fingerprint`), 734-749 (`compute_library_id_from_fids`) **Functions to implement:** - `clamp(v: f64, a: f64, b: f64) -> f64` - `is_within_root(root: &Path, target: &Path) -> bool` - `truthy(v: &serde_json::Value) -> bool` - `folder_display_name(path_str: &str) -> String` - `deduplicate_list(items: &[String]) -> Vec` - `natural_key(s: &str) -> Vec` — enum with `Num(u64)` and `Text(String)` variants, implements `Ord` - `smart_title_case(text: &str) -> String` — same SMALL_WORDS set as Python - `pretty_title_from_filename(filename: &str) -> String` — same regex patterns: `LEADING_INDEX_RE`, underscore→space, strip leading punct - `file_fingerprint(path: &Path) -> String` — SHA-256 of `b"VIDFIDv1\0"` + size + `b"\0"` + first 256KB + last 256KB, truncated to 20 hex chars - `compute_library_id(fids: &[String]) -> String` — SHA-256 of `b"LIBFIDv2\0"` + sorted fids joined by `\n`, truncated to 16 hex chars **Key detail for `file_fingerprint`:** Must produce identical output to the Python version for backward compatibility with existing library state files. The Python code uses `hashlib.sha256` with the exact byte sequence `b"VIDFIDv1\0"` + ascii size string + `b"\0"` + head bytes + tail bytes. The Rust version must match byte-for-byte. **Step 1: Write utils.rs with all functions** Implement all functions listed above. Use `sha2::Sha256` for hashing, `regex::Regex` with `once_cell::sync::Lazy` for compiled patterns. **Step 2: Write unit tests at bottom of utils.rs** ```rust #[cfg(test)] mod tests { use super::*; #[test] fn test_natural_key_sorting() { let mut items = vec!["10.mp4", "2.mp4", "1.mp4", "20.mp4"]; items.sort_by(|a, b| natural_key(a).cmp(&natural_key(b))); assert_eq!(items, vec!["1.mp4", "2.mp4", "10.mp4", "20.mp4"]); } #[test] fn test_pretty_title() { assert_eq!(pretty_title_from_filename("01_introduction_to_python.mp4"), "Introduction to Python"); assert_eq!(pretty_title_from_filename("02. advanced topics.mp4"), "Advanced Topics"); assert_eq!(pretty_title_from_filename("(3) the_basics.mkv"), "The Basics"); } #[test] fn test_clamp() { assert_eq!(clamp(5.0, 0.0, 10.0), 5.0); assert_eq!(clamp(-1.0, 0.0, 10.0), 0.0); assert_eq!(clamp(15.0, 0.0, 10.0), 10.0); } #[test] fn test_deduplicate() { let input = vec!["a".into(), "b".into(), "a".into(), "c".into()]; assert_eq!(deduplicate_list(&input), vec!["a", "b", "c"]); } #[test] fn test_smart_title_case() { assert_eq!(smart_title_case("introduction to the basics"), "Introduction to the Basics"); } } ``` **Step 3: Add `mod utils;` to main.rs** **Step 4: Run tests** Run: `cd src-tauri && cargo test utils` Expected: All tests pass. **Step 5: Commit** ``` feat: implement utils.rs — natural sort, fingerprinting, title formatting ``` --- ### Task 3: Implement state.rs **Files:** - Create: `src-tauri/src/state.rs` - Modify: `src-tauri/src/main.rs` (add `mod state;`) **Port from:** `tutorial.py` lines 95-156 (`atomic_write_json`, `load_json_with_fallbacks`) **Functions to implement:** - `atomic_write_json(path: &Path, data: &serde_json::Value, backup_count: usize)` — write to `.tmp`, rotate `.bak1`–`.bakN`, atomic rename via `std::fs::rename`, write `.lastgood` - `load_json_with_fallbacks(path: &Path, backup_count: usize) -> Option` — try path → `.lastgood` → `.bak1`–`.bak{N+2}` **Key detail:** The backup rotation logic must match Python exactly: iterate `backup_count` down to 1, rename `.bakI` to `.bak{I+1}`, then rename current file to `.bak1`. Default `backup_count` = 8. **Step 1: Write state.rs** **Step 2: Write unit tests using temp directories** ```rust #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[test] fn test_atomic_write_and_read() { let dir = TempDir::new().unwrap(); let path = dir.path().join("test.json"); let data = serde_json::json!({"hello": "world"}); atomic_write_json(&path, &data, 8); let loaded = load_json_with_fallbacks(&path, 8).unwrap(); assert_eq!(loaded, data); } #[test] fn test_fallback_to_lastgood() { let dir = TempDir::new().unwrap(); let path = dir.path().join("test.json"); let data = serde_json::json!({"version": 1}); atomic_write_json(&path, &data, 8); // Corrupt primary std::fs::write(&path, "not json").unwrap(); let loaded = load_json_with_fallbacks(&path, 8).unwrap(); assert_eq!(loaded, data); } #[test] fn test_backup_rotation() { let dir = TempDir::new().unwrap(); let path = dir.path().join("test.json"); for i in 0..5 { atomic_write_json(&path, &serde_json::json!({"v": i}), 8); } // .bak1 should be version 3 (second-to-last write) let bak1 = dir.path().join("test.json.bak1"); let bak1_data: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(bak1).unwrap()).unwrap(); assert_eq!(bak1_data["v"], 3); } } ``` Add `tempfile = "3"` to `[dev-dependencies]` in Cargo.toml. **Step 3: Run tests** Run: `cd src-tauri && cargo test state` Expected: All tests pass. **Step 4: Commit** ``` feat: implement state.rs — atomic JSON persistence with backup rotation ``` --- ### Task 4: Implement prefs.rs **Files:** - Create: `src-tauri/src/prefs.rs` - Modify: `src-tauri/src/main.rs` (add `mod prefs;`) **Port from:** `tutorial.py` lines 806-858 (`Prefs` class) **Structs to implement:** ```rust #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WindowState { pub width: i32, pub height: i32, pub x: Option, pub y: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Prefs { pub version: u32, pub ui_zoom: f64, pub split_ratio: f64, pub dock_ratio: f64, pub always_on_top: bool, pub window: WindowState, pub last_folder_path: Option, pub last_library_id: Option, #[serde(default)] pub updated_at: u64, } ``` **Functions:** - `Prefs::default()` — version 19, ui_zoom 1.0, split_ratio 0.62, dock_ratio 0.62, window 1320x860 - `Prefs::load(state_dir: &Path) -> Prefs` — load from `prefs.json` using `state::load_json_with_fallbacks`, merge with defaults (the Python way: defaults first, then overlay loaded values; handle window dict merge specially) - `Prefs::save(&self, state_dir: &Path)` — save via `state::atomic_write_json` - `Prefs::update(&mut self, patch: serde_json::Value, state_dir: &Path)` — apply partial updates, handle window dict merge, set `updated_at`, save **Step 1: Write prefs.rs** **Step 2: Write tests** Test default creation, save/load round-trip, partial update with window merge. **Step 3: Run tests** Run: `cd src-tauri && cargo test prefs` **Step 4: Commit** ``` feat: implement prefs.rs — preferences with save/load/update ``` --- ### Task 5: Implement recents.rs **Files:** - Create: `src-tauri/src/recents.rs` - Modify: `src-tauri/src/main.rs` (add `mod recents;`) **Port from:** `tutorial.py` lines 208-235 **Functions:** - `load_recents(state_dir: &Path) -> Vec` — load from `recent_folders.json`, validate structure, deduplicate, max 50 - `save_recents(state_dir: &Path, paths: &[String])` — write with version 2 + timestamp + items - `push_recent(state_dir: &Path, path_str: &str)` — load, remove if exists, insert at front, save - `remove_recent(state_dir: &Path, path_str: &str)` — load, remove, save **Step 1: Write recents.rs** **Step 2: Write tests** Test push (adds to front, deduplicates), remove, max 50 limit. **Step 3: Run tests, commit** ``` feat: implement recents.rs — recent folders management ``` --- ### Task 6: Implement ffmpeg.rs **Files:** - Create: `src-tauri/src/ffmpeg.rs` - Modify: `src-tauri/src/main.rs` (add `mod ffmpeg;`) **Port from:** `tutorial.py` lines 82-88 (`_subprocess_no_window_kwargs`), 467-513 (`find_ffprobe_ffmpeg`), 516-569 (`duration_seconds`), 572-694 (`ffprobe_video_metadata`) **Structs:** ```rust pub struct FfmpegPaths { pub ffprobe: Option, pub ffmpeg: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VideoMetadata { pub v_codec: Option, pub width: Option, pub height: Option, pub fps: Option, pub v_bitrate: Option, pub pix_fmt: Option, pub color_space: Option, pub a_codec: Option, pub channels: Option, pub sample_rate: Option, pub a_bitrate: Option, pub subtitle_tracks: Vec, pub container_bitrate: Option, pub duration: Option, pub format_name: Option, pub container_title: Option, pub encoder: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SubtitleTrack { pub index: u32, pub codec: String, pub language: String, pub title: String, } ``` **Functions:** - `discover(exe_dir: &Path, state_dir: &Path) -> FfmpegPaths` — check `which::which("ffprobe")` / `which::which("ffmpeg")`, then `exe_dir/ffprobe.exe`, then `state_dir/ffmpeg/ffprobe.exe` - `download_ffmpeg(state_dir: &Path, progress_tx: tokio::sync::mpsc::Sender)` — async, download from `github.com/BtbN/FFmpeg-Builds/releases`, extract with `zip` crate, place in `state_dir/ffmpeg/` - `duration_seconds(path: &Path, paths: &FfmpegPaths) -> Option` — try ffprobe `-show_entries format=duration`, fallback to ffmpeg stderr `Duration:` parsing - `ffprobe_video_metadata(path: &Path, ffprobe: &Path) -> Option` — run ffprobe with `-print_format json -show_streams -show_format`, parse all stream types **Key Windows detail:** All `Command::new()` calls must use `.creation_flags(0x08000000)` (CREATE_NO_WINDOW) to prevent console flashes. This replaces the Python `_subprocess_no_window_kwargs`. **Step 1: Write ffmpeg.rs — discovery + duration_seconds + metadata** **Step 2: Write ffmpeg.rs — download function (async with progress)** The download function should: 1. Create `state_dir/ffmpeg/` if it doesn't exist 2. GET the zip from GitHub releases with `reqwest` streaming 3. Emit progress events as bytes download 4. Extract only `ffmpeg.exe` and `ffprobe.exe` from the zip 5. Place them in `state_dir/ffmpeg/` **Step 3: Write unit tests for duration parsing and metadata extraction** Tests for `duration_seconds` and `ffprobe_video_metadata` need actual ffprobe/ffmpeg installed, so mark those as `#[ignore]` for CI. Test the string parsing logic (ffmpeg stderr Duration: extraction) with mock data. **Step 4: Run tests, commit** ``` feat: implement ffmpeg.rs — discovery, download, metadata extraction ``` --- ### Task 7: Implement subtitles.rs **Files:** - Create: `src-tauri/src/subtitles.rs` - Modify: `src-tauri/src/main.rs` (add `mod subtitles;`) **Port from:** `tutorial.py` lines 751-798 (`srt_to_vtt_bytes`), 1219-1295 (`_auto_subtitle_sidecar`), 1297-1317 (`_store_subtitle_for_fid`), 1451-1520 (`extract_embedded_subtitle`) **Functions:** - `srt_to_vtt(srt_text: &str) -> String` — remove BOM, add `WEBVTT` header, convert timestamps (comma→dot), strip cue indices - `auto_subtitle_sidecar(video_path: &Path) -> Option` — priority-based matching: exact → normalized → English lang suffix → other lang suffix. Same `normalize()` logic (lowercase, replace `-_` with space, collapse spaces) - `store_subtitle_for_fid(fid: &str, src_path: &Path, subs_dir: &Path) -> Option` — convert SRT→VTT if needed, save as `{fid}_{sanitized_name}.vtt` - `extract_embedded_subtitle(video_path: &Path, track_index: u32, ffmpeg_path: &Path, subs_dir: &Path, fid: &str) -> Result` — run ffmpeg `-map 0:{track_index} -c:s webvtt` **Step 1: Write subtitles.rs** **Step 2: Write unit tests for srt_to_vtt** ```rust #[test] fn test_srt_to_vtt_basic() { let srt = "1\n00:00:01,000 --> 00:00:04,000\nHello world\n\n2\n00:00:05,000 --> 00:00:08,000\nSecond line\n"; let vtt = srt_to_vtt(srt); assert!(vtt.starts_with("WEBVTT")); assert!(vtt.contains("00:00:01.000 --> 00:00:04.000")); assert!(!vtt.contains(",")); } #[test] fn test_srt_to_vtt_bom() { let srt = "\u{FEFF}1\n00:00:01,500 --> 00:00:03,500\nWith BOM\n"; let vtt = srt_to_vtt(srt); assert!(vtt.starts_with("WEBVTT")); assert!(!vtt.contains("\u{FEFF}")); } ``` **Step 3: Run tests, commit** ``` feat: implement subtitles.rs — SRT/VTT conversion, sidecar discovery ``` --- ### Task 8: Implement fonts.rs **Files:** - Create: `src-tauri/src/fonts.rs` - Modify: `src-tauri/src/main.rs` (add `mod fonts;`) **Port from:** `tutorial.py` lines 241-393 (`ensure_google_fonts_local`, `ensure_fontawesome_local`, helpers) **Functions:** - `ensure_google_fonts_local(fonts_dir: &Path)` — async, download CSS from Google Fonts API for Sora/Manrope/IBM Plex Mono, regex-extract woff2 URLs, download font files, rewrite CSS to use local `/fonts/{file}` paths. Track via `fonts_meta.json` with version 7. - `ensure_fontawesome_local(fa_dir: &Path)` — async, download Font Awesome 6.5.2 all.min.css from cdnjs, download all webfont files, rewrite URLs to `/fa/webfonts/{file}`. Track via `fa_meta.json` with version 3. - `safe_filename_from_url(url: &str) -> String` — SHA-256 hash of URL for unique local filename **Key detail:** The CSS URL rewriting must produce paths that the `tutdock://` custom protocol can serve. Current Python rewrites to `/fonts/{file}` — we rewrite to `tutdock://localhost/fonts/{file}`. Actually, looking at this more carefully: the CSS files reference fonts via relative URLs. Since the CSS is served via `tutdock://localhost/fonts.css`, relative URLs like `url(/fonts/xyz.woff2)` won't resolve correctly. We need to rewrite to absolute `tutdock://localhost/fonts/xyz.woff2` URLs in the CSS, OR serve the CSS inline, OR use the same `/fonts/` path since Tauri's custom protocol can handle it. The simplest approach: keep the URLs as `/fonts/{file}` in the CSS and have the frontend load fonts.css via a `` tag with `href="tutdock://localhost/fonts.css"` — the browser should resolve relative URLs against the protocol origin. **Step 1: Write fonts.rs** **Step 2: Test (manual — requires network)** This module is hard to unit-test without network. Add an `#[ignore]` integration test that downloads to a temp dir. **Step 3: Commit** ``` feat: implement fonts.rs — Google Fonts and Font Awesome caching ``` --- ### Task 9: Implement library.rs **Files:** - Create: `src-tauri/src/library.rs` - Modify: `src-tauri/src/main.rs` (add `mod library;`) **Port from:** `tutorial.py` lines 861-1993 (entire `Library` class) This is the largest module. It contains all library management logic. **Structs:** ```rust #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VideoMeta { pub pos: f64, pub watched: f64, pub duration: Option, pub finished: bool, pub note: String, pub last_open: u64, pub subtitle: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SubtitleRef { pub vtt: String, pub label: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LibraryState { pub version: u32, pub library_id: String, pub last_path: String, pub updated_at: u64, pub current_fid: Option, pub current_time: f64, pub volume: f64, pub autoplay: bool, pub playback_rate: f64, pub order_fids: Vec, pub videos: HashMap, } pub struct Library { pub root: Option, pub files: Vec, pub fids: Vec, pub relpaths: Vec, pub rel_to_fid: HashMap, pub fid_to_rel: HashMap, pub state: LibraryState, pub state_path: Option, // ffmpeg paths pub ffprobe: Option, pub ffmpeg: Option, // metadata cache pub meta_cache: HashMap, } ``` **Supported video extensions (same as Python):** ```rust const VIDEO_EXTS: &[&str] = &[ ".mp4", ".m4v", ".mov", ".webm", ".mkv", ".avi", ".mpg", ".mpeg", ".m2ts", ".mts", ".ogv" ]; ``` **Methods to implement (all map 1:1 to Python):** 1. `set_root(folder: &str, state_dir: &Path) -> Result` — Port of `Library.set_root()` (lines 933-1082). Scan folder for video files, compute fingerprints, load/create state, merge, apply saved order, save, start duration scan. 2. `get_library_info() -> LibraryInfo` — Port of `Library.get_library_info()` (lines 1726-1799). Build full response with items, tree flags, stats. 3. `update_progress(index, current_time, duration, playing) -> Result<(), String>` — Port of `Library.update_progress()` (lines 1801-1835). Update pos, watched (high-water mark), duration, finished (sticky), last_open. 4. `set_current(index, timecode)` — Port of lines 1837-1845. 5. `set_folder_volume(volume)`, `set_folder_autoplay(enabled)`, `set_folder_rate(rate)` — Port of lines 1847-1870. 6. `set_order(fids)` — Port of lines 1872-1908. Reorder playlist, rebuild file lists. 7. `get_note(fid)`, `set_note(fid, note)` — Port of lines 1920-1941. 8. `get_current_video_metadata()` — Port of lines 1695-1724. Probe + cache. 9. `get_subtitle_for_current(state_dir)` — Port of lines 1319-1400. Check stored → sidecar → embedded. 10. `set_subtitle_for_current(file_path, state_dir)` — Port of lines 1402-1421. 11. `get_embedded_subtitles()` — Port of lines 1423-1449. 12. `extract_embedded_subtitle(track_index, state_dir)` — Port of lines 1451-1520. 13. `get_available_subtitles()` — Port of lines 1522-1650. 14. `load_sidecar_subtitle(file_path, state_dir)` — Port of lines 1652-1677. 15. `reset_watch_progress()` — Port of lines 1679-1693. 16. `save_state()` — Port of lines 1084-1088. **Helper methods:** - `_sorted_default(relpaths)` — natural sort - `_apply_saved_order(all_fids, fid_to_rel, order_fids)` — port of lines 902-931 - `_folder_stats(fids, state)` — port of lines 1090-1138 - `_tree_flags(rels)` — port of lines 1140-1191 - `_basic_file_meta(fid)` — port of lines 1193-1209 - `_auto_subtitle_sidecar(video_path)` — delegates to `subtitles::auto_subtitle_sidecar` **Background duration scanner:** Port of `_duration_scan_worker` (lines 1952-1991). In Rust, this runs as a `tokio::spawn` task. It needs a way to communicate scanned durations back to the Library state. Use an `Arc>` pattern, or use channels to send updates back. Implementation approach: The scan function takes an `Arc>` and the file list, iterates through files, calls `ffmpeg::duration_seconds` for each, and updates the state directly under the lock. Same as the Python threading pattern. **Step 1: Write library.rs — structs + set_root + save_state** Start with the core: `Library::new()`, `set_root()`, `save_state()`, and the helper functions it depends on (`_sorted_default`, `_apply_saved_order`). **Step 2: Write library.rs — get_library_info + helpers** Add `get_library_info()`, `_folder_stats()`, `_tree_flags()`, `_basic_file_meta()`. **Step 3: Write library.rs — progress tracking methods** Add `update_progress()`, `set_current()`, `set_folder_volume()`, `set_folder_autoplay()`, `set_folder_rate()`, `set_order()`. **Step 4: Write library.rs — notes + metadata + subtitles** Add `get_note()`, `set_note()`, `get_current_video_metadata()`, all subtitle methods, `reset_watch_progress()`. **Step 5: Write library.rs — background duration scanner** Implement as an async function that can be spawned with `tokio::spawn`. **Step 6: Write unit tests** Test `_tree_flags`, `_apply_saved_order`, `_folder_stats` with mock data. Test `set_root` with a temp directory containing dummy files (can't test video-specific features without real videos). **Step 7: Run tests, commit** ``` feat: implement library.rs — full library management with progress tracking ``` --- ### Task 10: Implement video_protocol.rs **Files:** - Create: `src-tauri/src/video_protocol.rs` - Modify: `src-tauri/src/main.rs` (add `mod video_protocol;`) **Port from:** `tutorial.py` lines 1995-2118 (Flask routes: `_send_file_range`, video, subtitle, fonts routes) **Function:** - `register_protocol(builder: tauri::Builder) -> tauri::Builder` — register `tutdock` custom protocol with Tauri The protocol handler function receives a `tauri::http::Request` and returns a `tauri::http::Response`. It must: 1. Parse the URL path from the request 2. Route to the appropriate handler: - `/video/{index}` → read file from Library, serve with range support - `/sub/{libid}/{fid}` → read VTT from state/subtitles/, serve as `text/vtt` - `/fonts.css` → serve fonts CSS file - `/fonts/{filename}` → serve font file - `/fa.css` → serve Font Awesome CSS - `/fa/webfonts/{filename}` → serve FA webfont file 3. For video: parse `Range` header, return 206 with `Content-Range` or 200 with full body **Key detail about Tauri custom protocols:** In Tauri v2, custom protocols are registered via `tauri::Builder::register_asynchronous_uri_scheme_protocol()`. The handler receives the full request and must return a response. Unlike Flask streaming, we read file chunks into a `Vec` body (Tauri protocol responses don't support streaming generators). For large video files, we serve the requested range only (typically a few MB at a time due to browser range requests), so memory usage stays bounded. **MIME type mapping** (same as Python lines 2081-2093): ```rust fn mime_for_video(ext: &str) -> &str { match ext { ".mp4" | ".m4v" => "video/mp4", ".webm" => "video/webm", ".ogv" => "video/ogg", ".mov" => "video/quicktime", ".mkv" => "video/x-matroska", ".avi" => "video/x-msvideo", ".mpeg" | ".mpg" => "video/mpeg", ".m2ts" | ".mts" => "video/mp2t", _ => "application/octet-stream", } } ``` **Step 1: Write video_protocol.rs — protocol registration + URL routing** **Step 2: Write video_protocol.rs — range request handling for video** Port the range parsing logic from `_send_file_range`. Read only the requested byte range from disk. **Step 3: Write video_protocol.rs — subtitle, fonts, FA handlers** Simple file-serving handlers with appropriate MIME types and path traversal prevention. **Step 4: Commit** ``` feat: implement video_protocol.rs — tutdock:// custom protocol with range requests ``` --- ### Task 11: Implement commands.rs **Files:** - Create: `src-tauri/src/commands.rs` - Modify: `src-tauri/src/main.rs` (add `mod commands;`) **Port from:** `tutorial.py` lines 2125-2276 (API class) All 25 commands as thin `#[tauri::command]` wrappers. Each acquires state locks and delegates to the appropriate module. **Commands list with signatures:** ```rust #[tauri::command] async fn select_folder( library: State<'_, Mutex>, paths: State<'_, AppPaths>, app: AppHandle, ) -> Result #[tauri::command] fn open_folder_path(folder: String, library: State>, paths: State) -> Result #[tauri::command] fn get_recents(paths: State) -> Result #[tauri::command] fn remove_recent(path: String, paths: State) -> Result #[tauri::command] fn get_library(library: State>) -> Result #[tauri::command] fn set_current(index: usize, timecode: f64, library: State>) -> Result #[tauri::command] fn tick_progress(index: usize, current_time: f64, duration: Option, playing: bool, library: State>) -> Result #[tauri::command] fn set_folder_volume(volume: f64, library: State>) -> Result #[tauri::command] fn set_folder_autoplay(enabled: bool, library: State>) -> Result #[tauri::command] fn set_folder_rate(rate: f64, library: State>) -> Result #[tauri::command] fn set_order(fids: Vec, library: State>) -> Result #[tauri::command] fn start_duration_scan(library: State>) -> Result #[tauri::command] fn get_prefs(prefs: State>) -> Result #[tauri::command] fn set_prefs(patch: serde_json::Value, prefs: State>, paths: State) -> Result #[tauri::command] fn set_always_on_top(enabled: bool, prefs: State>, paths: State, app: AppHandle) -> Result #[tauri::command] fn save_window_state(prefs: State>, paths: State, app: AppHandle) -> Result #[tauri::command] fn get_note(fid: String, library: State>) -> Result #[tauri::command] fn set_note(fid: String, note: String, library: State>) -> Result #[tauri::command] fn get_current_video_meta(library: State>) -> Result #[tauri::command] fn get_current_subtitle(library: State>, paths: State) -> Result #[tauri::command] fn get_embedded_subtitles(library: State>) -> Result #[tauri::command] fn extract_embedded_subtitle(track_index: u32, library: State>, paths: State) -> Result #[tauri::command] fn get_available_subtitles(library: State>) -> Result #[tauri::command] fn load_sidecar_subtitle(file_path: String, library: State>, paths: State) -> Result #[tauri::command] async fn choose_subtitle_file(library: State<'_, Mutex>, paths: State<'_, AppPaths>, app: AppHandle) -> Result ``` **Special commands:** - `select_folder` uses `tauri_plugin_dialog::FileDialogBuilder::new().pick_folder()` for native dialog - `choose_subtitle_file` uses `FileDialogBuilder::new().add_filter("Subtitles", &["srt", "vtt"]).pick_file()` - `set_always_on_top` calls `app.get_webview_window("main").unwrap().set_always_on_top(enabled)` - `save_window_state` reads position/size from the window object via `window.outer_position()` and `window.outer_size()` **Step 1: Write commands.rs — all 25 commands** **Step 2: Commit** ``` feat: implement commands.rs — all 25 Tauri commands ``` --- ### Task 12: Wire up main.rs **Files:** - Modify: `src-tauri/src/main.rs` **Port from:** `tutorial.py` lines 5231-5282 (`main` function) **Final main.rs structure:** ```rust #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod commands; mod ffmpeg; mod fonts; mod library; mod prefs; mod recents; mod state; mod subtitles; mod utils; mod video_protocol; use std::path::PathBuf; use std::sync::Mutex; use tauri::Manager; pub struct AppPaths { pub exe_dir: PathBuf, pub state_dir: PathBuf, pub fonts_dir: PathBuf, pub fa_dir: PathBuf, pub subs_dir: PathBuf, pub webview_profile_dir: PathBuf, } fn main() { // 1. Resolve exe directory for portability let exe_dir = std::env::current_exe() .expect("cannot resolve exe path") .parent() .expect("exe has no parent") .to_path_buf(); let state_dir = exe_dir.join("state"); // Create all subdirectories let paths = AppPaths { exe_dir: exe_dir.clone(), state_dir: state_dir.clone(), fonts_dir: state_dir.join("fonts"), fa_dir: state_dir.join("fontawesome"), subs_dir: state_dir.join("subtitles"), webview_profile_dir: state_dir.join("webview_profile"), }; std::fs::create_dir_all(&paths.fonts_dir).ok(); std::fs::create_dir_all(&paths.fa_dir.join("webfonts")).ok(); std::fs::create_dir_all(&paths.subs_dir).ok(); std::fs::create_dir_all(&paths.webview_profile_dir).ok(); // 2. Set WebView2 user data folder for portability std::env::set_var("WEBVIEW2_USER_DATA_FOLDER", &paths.webview_profile_dir); // 3. Load preferences let prefs = prefs::Prefs::load(&state_dir); // 4. Initialize library (empty — will be loaded from frontend or from last_folder_path) let mut lib = library::Library::new(); // Discover ffmpeg let ff_paths = ffmpeg::discover(&paths.exe_dir, &paths.state_dir); lib.ffprobe = ff_paths.ffprobe; lib.ffmpeg = ff_paths.ffmpeg; // Restore last folder if exists if let Some(ref last_path) = prefs.last_folder_path { if std::path::Path::new(last_path).is_dir() { let _ = lib.set_root(last_path, &state_dir); } } // 5. Build Tauri app let builder = tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .manage(Mutex::new(lib)) .manage(Mutex::new(prefs)) .manage(paths) .invoke_handler(tauri::generate_handler![ commands::select_folder, commands::open_folder_path, commands::get_recents, commands::remove_recent, commands::get_library, commands::set_current, commands::tick_progress, commands::set_folder_volume, commands::set_folder_autoplay, commands::set_folder_rate, commands::set_order, commands::start_duration_scan, commands::get_prefs, commands::set_prefs, commands::set_always_on_top, commands::save_window_state, commands::get_note, commands::set_note, commands::get_current_video_meta, commands::get_current_subtitle, commands::get_embedded_subtitles, commands::extract_embedded_subtitle, commands::get_available_subtitles, commands::load_sidecar_subtitle, commands::choose_subtitle_file, ]); // Register custom protocol let builder = video_protocol::register_protocol(builder); // Configure window from saved prefs builder .setup(|app| { let prefs = app.state::>(); let p = prefs.lock().unwrap(); let win = app.get_webview_window("main").unwrap(); if let Some(x) = p.window.x { if let Some(y) = p.window.y { let _ = win.set_position(tauri::Position::Physical( tauri::PhysicalPosition::new(x, y) )); } } let _ = win.set_size(tauri::Size::Physical( tauri::PhysicalSize::new(p.window.width as u32, p.window.height as u32) )); let _ = win.set_always_on_top(p.always_on_top); // Spawn async font caching (silent, non-blocking) let paths = app.state::(); let fonts_dir = paths.fonts_dir.clone(); let fa_dir = paths.fa_dir.clone(); tokio::spawn(async move { let _ = fonts::ensure_google_fonts_local(&fonts_dir).await; let _ = fonts::ensure_fontawesome_local(&fa_dir).await; }); // If ffmpeg not found, emit event to frontend for download UI let ff_check = { let lib = app.state::>(); let l = lib.lock().unwrap(); l.ffprobe.is_none() && l.ffmpeg.is_none() }; if ff_check { let handle = app.handle().clone(); let sd = paths.state_dir.clone(); tokio::spawn(async move { let _ = handle.emit("ffmpeg-download-needed", ()); // Download and emit progress // After download, emit ffmpeg-download-complete }); } Ok(()) }) .run(tauri::generate_context!()) .expect("error while running tauri application"); } ``` **Step 1: Write final main.rs** **Step 2: Verify Rust compiles** Run: `cd src-tauri && cargo check` Expected: No errors. **Step 3: Commit** ``` feat: wire up main.rs — Tauri bootstrap with all modules ``` --- ## Phase 3: Frontend — CSS Extraction ### Task 13: Extract and split CSS **Files:** - Create: `src/styles/main.css` - Create: `src/styles/player.css` - Create: `src/styles/playlist.css` - Create: `src/styles/panels.css` - Create: `src/styles/components.css` - Create: `src/styles/animations.css` **Port from:** `tutorial.py` lines 2287-3747 (the `