# 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
```
**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 `