58 KiB
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
{
"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
{
"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
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
/// <reference types="vite/client" />
Step 5: Create minimal src/index.html
A bare-bones HTML file that loads main.ts. Just enough to verify the build works.
<!doctype html>
<html><head><meta charset="utf-8"/><title>TutorialDock</title></head>
<body><div id="zoomRoot"><div class="app">Loading...</div></div>
<script type="module" src="/main.ts"></script></body></html>
Step 6: Create minimal src/main.ts
console.log("TutorialDock frontend loaded");
Step 7: Create src-tauri/Cargo.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
fn main() {
tauri_build::build()
}
Step 9: Create src-tauri/tauri.conf.json
Key settings:
identifier:com.tutorialdock.appproductName:TutorialDockapp.windows[0]: title "TutorialDock", width 1320, height 860, decorations trueapp.security.csp: allowtutdock:protocol in media-src, font-src, style-srcbundle.active: false (no installer — portable exe only)- Register
tutdockas allowed custom protocol underapp.security.assetProtocol
{
"$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
#![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(addmod 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) -> f64is_within_root(root: &Path, target: &Path) -> booltruthy(v: &serde_json::Value) -> boolfolder_display_name(path_str: &str) -> Stringdeduplicate_list(items: &[String]) -> Vec<String>natural_key(s: &str) -> Vec<NaturalKeyPart>— enum withNum(u64)andText(String)variants, implementsOrdsmart_title_case(text: &str) -> String— same SMALL_WORDS set as Pythonpretty_title_from_filename(filename: &str) -> String— same regex patterns:LEADING_INDEX_RE, underscore→space, strip leading punctfile_fingerprint(path: &Path) -> String— SHA-256 ofb"VIDFIDv1\0"+ size +b"\0"+ first 256KB + last 256KB, truncated to 20 hex charscompute_library_id(fids: &[String]) -> String— SHA-256 ofb"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
#[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(addmod 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 viastd::fs::rename, write.lastgoodload_json_with_fallbacks(path: &Path, backup_count: usize) -> Option<serde_json::Value>— 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
#[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(addmod prefs;)
Port from: tutorial.py lines 806-858 (Prefs class)
Structs to implement:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WindowState {
pub width: i32,
pub height: i32,
pub x: Option<i32>,
pub y: Option<i32>,
}
#[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<String>,
pub last_library_id: Option<String>,
#[serde(default)]
pub updated_at: u64,
}
Functions:
Prefs::default()— version 19, ui_zoom 1.0, split_ratio 0.62, dock_ratio 0.62, window 1320x860Prefs::load(state_dir: &Path) -> Prefs— load fromprefs.jsonusingstate::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 viastate::atomic_write_jsonPrefs::update(&mut self, patch: serde_json::Value, state_dir: &Path)— apply partial updates, handle window dict merge, setupdated_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(addmod recents;)
Port from: tutorial.py lines 208-235
Functions:
load_recents(state_dir: &Path) -> Vec<String>— load fromrecent_folders.json, validate structure, deduplicate, max 50save_recents(state_dir: &Path, paths: &[String])— write with version 2 + timestamp + itemspush_recent(state_dir: &Path, path_str: &str)— load, remove if exists, insert at front, saveremove_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(addmod 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:
pub struct FfmpegPaths {
pub ffprobe: Option<PathBuf>,
pub ffmpeg: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoMetadata {
pub v_codec: Option<String>,
pub width: Option<u32>,
pub height: Option<u32>,
pub fps: Option<f64>,
pub v_bitrate: Option<u64>,
pub pix_fmt: Option<String>,
pub color_space: Option<String>,
pub a_codec: Option<String>,
pub channels: Option<u32>,
pub sample_rate: Option<String>,
pub a_bitrate: Option<u64>,
pub subtitle_tracks: Vec<SubtitleTrack>,
pub container_bitrate: Option<u64>,
pub duration: Option<f64>,
pub format_name: Option<String>,
pub container_title: Option<String>,
pub encoder: Option<String>,
}
#[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— checkwhich::which("ffprobe")/which::which("ffmpeg"), thenexe_dir/ffprobe.exe, thenstate_dir/ffmpeg/ffprobe.exedownload_ffmpeg(state_dir: &Path, progress_tx: tokio::sync::mpsc::Sender<DownloadProgress>)— async, download fromgithub.com/BtbN/FFmpeg-Builds/releases, extract withzipcrate, place instate_dir/ffmpeg/duration_seconds(path: &Path, paths: &FfmpegPaths) -> Option<f64>— try ffprobe-show_entries format=duration, fallback to ffmpeg stderrDuration:parsingffprobe_video_metadata(path: &Path, ffprobe: &Path) -> Option<VideoMetadata>— 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:
- Create
state_dir/ffmpeg/if it doesn't exist - GET the zip from GitHub releases with
reqweststreaming - Emit progress events as bytes download
- Extract only
ffmpeg.exeandffprobe.exefrom the zip - 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(addmod 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, addWEBVTTheader, convert timestamps (comma→dot), strip cue indicesauto_subtitle_sidecar(video_path: &Path) -> Option<PathBuf>— priority-based matching: exact → normalized → English lang suffix → other lang suffix. Samenormalize()logic (lowercase, replace-_with space, collapse spaces)store_subtitle_for_fid(fid: &str, src_path: &Path, subs_dir: &Path) -> Option<SubtitleStored>— convert SRT→VTT if needed, save as{fid}_{sanitized_name}.vttextract_embedded_subtitle(video_path: &Path, track_index: u32, ffmpeg_path: &Path, subs_dir: &Path, fid: &str) -> Result<SubtitleStored, String>— run ffmpeg-map 0:{track_index} -c:s webvtt
Step 1: Write subtitles.rs
Step 2: Write unit tests for srt_to_vtt
#[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(addmod 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 viafonts_meta.jsonwith 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 viafa_meta.jsonwith 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 <link> 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(addmod library;)
Port from: tutorial.py lines 861-1993 (entire Library class)
This is the largest module. It contains all library management logic.
Structs:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoMeta {
pub pos: f64,
pub watched: f64,
pub duration: Option<f64>,
pub finished: bool,
pub note: String,
pub last_open: u64,
pub subtitle: Option<SubtitleRef>,
}
#[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<String>,
pub current_time: f64,
pub volume: f64,
pub autoplay: bool,
pub playback_rate: f64,
pub order_fids: Vec<String>,
pub videos: HashMap<String, VideoMeta>,
}
pub struct Library {
pub root: Option<PathBuf>,
pub files: Vec<PathBuf>,
pub fids: Vec<String>,
pub relpaths: Vec<String>,
pub rel_to_fid: HashMap<String, String>,
pub fid_to_rel: HashMap<String, String>,
pub state: LibraryState,
pub state_path: Option<PathBuf>,
// ffmpeg paths
pub ffprobe: Option<PathBuf>,
pub ffmpeg: Option<PathBuf>,
// metadata cache
pub meta_cache: HashMap<String, VideoMetadata>,
}
Supported video extensions (same as Python):
const VIDEO_EXTS: &[&str] = &[
".mp4", ".m4v", ".mov", ".webm", ".mkv",
".avi", ".mpg", ".mpeg", ".m2ts", ".mts", ".ogv"
];
Methods to implement (all map 1:1 to Python):
-
set_root(folder: &str, state_dir: &Path) -> Result<LibraryInfo, String>— Port ofLibrary.set_root()(lines 933-1082). Scan folder for video files, compute fingerprints, load/create state, merge, apply saved order, save, start duration scan. -
get_library_info() -> LibraryInfo— Port ofLibrary.get_library_info()(lines 1726-1799). Build full response with items, tree flags, stats. -
update_progress(index, current_time, duration, playing) -> Result<(), String>— Port ofLibrary.update_progress()(lines 1801-1835). Update pos, watched (high-water mark), duration, finished (sticky), last_open. -
set_current(index, timecode)— Port of lines 1837-1845. -
set_folder_volume(volume),set_folder_autoplay(enabled),set_folder_rate(rate)— Port of lines 1847-1870. -
set_order(fids)— Port of lines 1872-1908. Reorder playlist, rebuild file lists. -
get_note(fid),set_note(fid, note)— Port of lines 1920-1941. -
get_current_video_metadata()— Port of lines 1695-1724. Probe + cache. -
get_subtitle_for_current(state_dir)— Port of lines 1319-1400. Check stored → sidecar → embedded. -
set_subtitle_for_current(file_path, state_dir)— Port of lines 1402-1421. -
get_embedded_subtitles()— Port of lines 1423-1449. -
extract_embedded_subtitle(track_index, state_dir)— Port of lines 1451-1520. -
get_available_subtitles()— Port of lines 1522-1650. -
load_sidecar_subtitle(file_path, state_dir)— Port of lines 1652-1677. -
reset_watch_progress()— Port of lines 1679-1693. -
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 tosubtitles::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<Mutex<Library>> pattern, or use channels to send updates back.
Implementation approach: The scan function takes an Arc<Mutex<LibraryState>> 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(addmod 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— registertutdockcustom protocol with Tauri
The protocol handler function receives a tauri::http::Request and returns a tauri::http::Response. It must:
- Parse the URL path from the request
- Route to the appropriate handler:
/video/{index}→ read file from Library, serve with range support/sub/{libid}/{fid}→ read VTT from state/subtitles/, serve astext/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
- For video: parse
Rangeheader, return 206 withContent-Rangeor 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<u8> 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):
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(addmod 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:
#[tauri::command]
async fn select_folder(
library: State<'_, Mutex<Library>>,
paths: State<'_, AppPaths>,
app: AppHandle,
) -> Result<LibraryInfo, String>
#[tauri::command]
fn open_folder_path(folder: String, library: State<Mutex<Library>>, paths: State<AppPaths>) -> Result<LibraryInfo, String>
#[tauri::command]
fn get_recents(paths: State<AppPaths>) -> Result<RecentsResponse, String>
#[tauri::command]
fn remove_recent(path: String, paths: State<AppPaths>) -> Result<OkResponse, String>
#[tauri::command]
fn get_library(library: State<Mutex<Library>>) -> Result<LibraryInfo, String>
#[tauri::command]
fn set_current(index: usize, timecode: f64, library: State<Mutex<Library>>) -> Result<OkResponse, String>
#[tauri::command]
fn tick_progress(index: usize, current_time: f64, duration: Option<f64>, playing: bool, library: State<Mutex<Library>>) -> Result<OkResponse, String>
#[tauri::command]
fn set_folder_volume(volume: f64, library: State<Mutex<Library>>) -> Result<OkResponse, String>
#[tauri::command]
fn set_folder_autoplay(enabled: bool, library: State<Mutex<Library>>) -> Result<OkResponse, String>
#[tauri::command]
fn set_folder_rate(rate: f64, library: State<Mutex<Library>>) -> Result<OkResponse, String>
#[tauri::command]
fn set_order(fids: Vec<String>, library: State<Mutex<Library>>) -> Result<OkResponse, String>
#[tauri::command]
fn start_duration_scan(library: State<Mutex<Library>>) -> Result<OkResponse, String>
#[tauri::command]
fn get_prefs(prefs: State<Mutex<Prefs>>) -> Result<PrefsResponse, String>
#[tauri::command]
fn set_prefs(patch: serde_json::Value, prefs: State<Mutex<Prefs>>, paths: State<AppPaths>) -> Result<OkResponse, String>
#[tauri::command]
fn set_always_on_top(enabled: bool, prefs: State<Mutex<Prefs>>, paths: State<AppPaths>, app: AppHandle) -> Result<OkResponse, String>
#[tauri::command]
fn save_window_state(prefs: State<Mutex<Prefs>>, paths: State<AppPaths>, app: AppHandle) -> Result<OkResponse, String>
#[tauri::command]
fn get_note(fid: String, library: State<Mutex<Library>>) -> Result<NoteResponse, String>
#[tauri::command]
fn set_note(fid: String, note: String, library: State<Mutex<Library>>) -> Result<OkResponse, String>
#[tauri::command]
fn get_current_video_meta(library: State<Mutex<Library>>) -> Result<VideoMetaResponse, String>
#[tauri::command]
fn get_current_subtitle(library: State<Mutex<Library>>, paths: State<AppPaths>) -> Result<SubtitleResponse, String>
#[tauri::command]
fn get_embedded_subtitles(library: State<Mutex<Library>>) -> Result<EmbeddedSubsResponse, String>
#[tauri::command]
fn extract_embedded_subtitle(track_index: u32, library: State<Mutex<Library>>, paths: State<AppPaths>) -> Result<SubtitleResponse, String>
#[tauri::command]
fn get_available_subtitles(library: State<Mutex<Library>>) -> Result<AvailableSubsResponse, String>
#[tauri::command]
fn load_sidecar_subtitle(file_path: String, library: State<Mutex<Library>>, paths: State<AppPaths>) -> Result<SubtitleResponse, String>
#[tauri::command]
async fn choose_subtitle_file(library: State<'_, Mutex<Library>>, paths: State<'_, AppPaths>, app: AppHandle) -> Result<SubtitleResponse, String>
Special commands:
select_folderusestauri_plugin_dialog::FileDialogBuilder::new().pick_folder()for native dialogchoose_subtitle_fileusesFileDialogBuilder::new().add_filter("Subtitles", &["srt", "vtt"]).pick_file()set_always_on_topcallsapp.get_webview_window("main").unwrap().set_always_on_top(enabled)save_window_statereads position/size from the window object viawindow.outer_position()andwindow.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:
#![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::<Mutex<prefs::Prefs>>();
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::<AppPaths>();
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::<Mutex<library::Library>>();
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 <style> block inside the HTML string)
Extract the CSS verbatim from the Python HTML string. Split by logical section:
main.css — Lines 2287-2510:
:rootvariables (--zoom, --bg0, --bg1, --stroke, --text, --accent, etc.)*,html,bodybase styles#zoomRoottransform-based zoom.applayout.topbarwith pseudo-element patterns.brand,.appIcon,.brandText,.appName,.tagline.actions,.actionGroup,.actionDivider
player.css — From the <style> block:
.videoWrap,videoelement.videoOverlay.controlslayout.seekRow,.seekBar(range input).timeChip.volSlider(range input)- Fullscreen styles
playlist.css — From the <style> block:
.listWrap,#list.listItemwith all states (hover, active, current, finished).treeSvgconnector styles.listScrollbar,.scrollThumb#emptyHint
panels.css — From the <style> block:
.panel,.panelHeader#contentGrid(3-column grid).dividerWrap,.dividerLine#dockGrid(sub-grid).notesArea,.notesBox(textarea).infoGrid,.infoRow,.infoKey,.infoVal
components.css — From the <style> block:
.zoomControl,.zoomBtn,.zoomValue.toolbarBtnwith pseudo-elements.splitBtn,.drop(split button).toggleSwitch,.switchTrack,.switchLabel.dropdownPortal,.recentMenu,.recentRow#toast#fancyTooltip.subsMenu,.subsMenuItem.speedMenu,.speedItem
animations.css — From the <style> block:
@keyframes logoSpin@keyframes logoWiggle- Any transition/animation definitions that are standalone
Key change: All CSS stays identical. The only modification is that font references change:
href="/fonts.css"→href="tutdock://localhost/fonts.css"(in index.html)href="/fa.css"→href="tutdock://localhost/fa.css"(in index.html)
The CSS files themselves are unchanged — they reference /fonts/ and /fa/webfonts/ paths, which the custom protocol handles.
Step 1: Extract CSS from tutorial.py lines 2287-3747 into the 6 files
Read the <style> section carefully and split by logical boundary. Keep exact formatting and values.
Step 2: Create src/main.ts to import all CSS files
import './styles/main.css';
import './styles/player.css';
import './styles/playlist.css';
import './styles/panels.css';
import './styles/components.css';
import './styles/animations.css';
Step 3: Verify styles load correctly
Run: npm run tauri dev
Expected: Window opens with correct dark background, CSS variables applied. No content yet, but no CSS errors in console.
Step 4: Commit
feat: extract CSS from Python HTML string into 6 modular files
Phase 4: Frontend — TypeScript Modules
Task 14: Create types.ts
Files:
- Create:
src/types.ts
Derive all interfaces from: The return types of every API command. Reference tutorial.py method return values in the API class (lines 2125-2276) and Library class (lines 1726-1799 for get_library_info).
All interfaces to define:
// Library and video items
export interface LibraryInfo { ... } // 20+ fields from get_library_info()
export interface VideoItem { ... } // 17 fields per video
export interface FolderStats { ... }
// Preferences
export interface WindowState { width: number; height: number; x: number | null; y: number | null; }
export interface Prefs { version: number; ui_zoom: number; split_ratio: number; dock_ratio: number; always_on_top: boolean; window: WindowState; last_folder_path: string | null; last_library_id: string | null; }
// API responses
export interface OkResponse { ok: boolean; error?: string; }
export interface PrefsResponse { ok: boolean; prefs: Prefs; }
export interface RecentsResponse { ok: boolean; items: RecentItem[]; }
export interface RecentItem { name: string; path: string; }
export interface NoteResponse { ok: boolean; note: string; }
export interface VideoMetaResponse { ok: boolean; fid: string; basic: BasicFileMeta; probe: ProbeMeta | null; ffprobe_found: boolean; }
export interface BasicFileMeta { ext: string; size: number; mtime: number; folder: string; }
export interface ProbeMeta { v_codec?: string; width?: number; height?: number; fps?: number; v_bitrate?: number; pix_fmt?: string; color_space?: string; a_codec?: string; channels?: number; sample_rate?: string; a_bitrate?: number; subtitle_tracks?: SubtitleTrack[]; container_bitrate?: number; duration?: number; format_name?: string; container_title?: string; encoder?: string; }
export interface SubtitleTrack { index: number; codec: string; language: string; title: string; }
export interface SubtitleResponse { ok: boolean; has?: boolean; url?: string; label?: string; cancelled?: boolean; error?: string; }
export interface AvailableSubsResponse { ok: boolean; sidecar: SidecarSub[]; embedded: EmbeddedSub[]; }
export interface SidecarSub { path: string; label: string; format: string; }
export interface EmbeddedSub { index: number; label: string; codec: string; language: string; }
// ffmpeg download progress events
export interface FfmpegProgress { percent: number; downloaded_bytes: number; total_bytes: number; }
Step 1: Write types.ts
Step 2: Commit
feat: create types.ts — all TypeScript interfaces for API
Task 15: Create api.ts
Files:
- Create:
src/api.ts
Port from: Every window.pywebview.api.* call in the JavaScript (tutorial.py lines 3748-5221)
import { invoke } from '@tauri-apps/api/core';
import type { LibraryInfo, OkResponse, PrefsResponse, RecentsResponse, NoteResponse, VideoMetaResponse, SubtitleResponse, AvailableSubsResponse } from './types';
export const api = {
selectFolder: () => invoke<LibraryInfo>('select_folder'),
openFolderPath: (folder: string) => invoke<LibraryInfo>('open_folder_path', { folder }),
getRecents: () => invoke<RecentsResponse>('get_recents'),
removeRecent: (path: string) => invoke<OkResponse>('remove_recent', { path }),
getLibrary: () => invoke<LibraryInfo>('get_library'),
setCurrent: (index: number, timecode: number = 0) => invoke<OkResponse>('set_current', { index, timecode }),
tickProgress: (index: number, currentTime: number, duration: number | null, playing: boolean) =>
invoke<OkResponse>('tick_progress', { index, currentTime, duration, playing }),
setFolderVolume: (volume: number) => invoke<OkResponse>('set_folder_volume', { volume }),
setFolderAutoplay: (enabled: boolean) => invoke<OkResponse>('set_folder_autoplay', { enabled }),
setFolderRate: (rate: number) => invoke<OkResponse>('set_folder_rate', { rate }),
setOrder: (fids: string[]) => invoke<OkResponse>('set_order', { fids }),
startDurationScan: () => invoke<OkResponse>('start_duration_scan'),
getPrefs: () => invoke<PrefsResponse>('get_prefs'),
setPrefs: (patch: Record<string, unknown>) => invoke<OkResponse>('set_prefs', { patch }),
setAlwaysOnTop: (enabled: boolean) => invoke<OkResponse>('set_always_on_top', { enabled }),
saveWindowState: () => invoke<OkResponse>('save_window_state'),
getNote: (fid: string) => invoke<NoteResponse>('get_note', { fid }),
setNote: (fid: string, note: string) => invoke<OkResponse>('set_note', { fid, note }),
getCurrentVideoMeta: () => invoke<VideoMetaResponse>('get_current_video_meta'),
getCurrentSubtitle: () => invoke<SubtitleResponse>('get_current_subtitle'),
getEmbeddedSubtitles: () => invoke<{ ok: boolean; tracks: SubtitleTrack[] }>('get_embedded_subtitles'),
extractEmbeddedSubtitle: (trackIndex: number) => invoke<SubtitleResponse>('extract_embedded_subtitle', { trackIndex }),
getAvailableSubtitles: () => invoke<AvailableSubsResponse>('get_available_subtitles'),
loadSidecarSubtitle: (filePath: string) => invoke<SubtitleResponse>('load_sidecar_subtitle', { filePath }),
chooseSubtitleFile: () => invoke<SubtitleResponse>('choose_subtitle_file'),
resetWatchProgress: () => invoke<OkResponse>('reset_watch_progress'),
};
Step 1: Write api.ts
Step 2: Commit
feat: create api.ts — typed Tauri invoke wrappers
Task 16: Create index.html with full markup
Files:
- Modify:
src/index.html
Port from: tutorial.py lines 2278-3747 (the HTML body markup, NOT the <style> or <script> tags)
Extract the complete HTML structure from the Python string. This includes:
#zoomRoot > .appcontainer.topbarwith brand, actions, zoom control, buttons#recentMenudropdown portal#contentGridwith left panel (player), divider, right panel (playlist)- Video element, controls, seek bar, volume
- Dock grid with notes and info panels
#toast,#fancyTooltip
Key changes from original:
- Remove
<style>block (now in CSS files) - Remove
<script>block (now in TS modules) - Add
<link rel="stylesheet" href="tutdock://localhost/fonts.css"> - Add
<link rel="stylesheet" href="tutdock://localhost/fa.css"> - Add
<script type="module" src="/main.ts"></script> - All inline event handlers (
onclick,oninput, etc.) will be removed — they'll be wired up in TypeScript modules instead
Step 1: Write complete index.html
Step 2: Commit
feat: extract HTML markup into index.html
Task 17: Create player.ts
Files:
- Create:
src/player.ts
Port from: tutorial.py JavaScript — all video element interaction code
Exports:
export function initPlayer(): void; // Get DOM refs, wire event listeners
export function loadVideo(index: number, timecode?: number, pauseAfterLoad?: boolean, autoplayOnLoad?: boolean): Promise<void>;
export function togglePlay(): void;
export function nextPrev(delta: number): void;
export function updatePlayPauseIcon(): void;
export function updateTimeReadout(): void;
export function updateSeekFill(): void;
export function updateVolFill(): void;
export function updateVideoOverlay(): void;
export function setVolume(vol: number): void;
export function setPlaybackRate(rate: number): void;
export function getCurrentIndex(): number;
export function isPlaying(): boolean;
export function getVideoElement(): HTMLVideoElement;
// For the tick loop
export function getVideoTime(): number;
export function getVideoDuration(): number | null;
Key change: Video src becomes tutdock://localhost/video/${index} instead of http://127.0.0.1:${port}/video/${index}.
Step 1: Write player.ts
Port all video-related JavaScript functions. Wire up event listeners on the video element, seek bar, volume slider, play/pause button, prev/next buttons, fullscreen button.
Step 2: Commit
feat: create player.ts — video playback controls
Task 18: Create playlist.ts
Files:
- Create:
src/playlist.ts
Port from: tutorial.py JavaScript — renderList(), drag-and-drop, scrollbar, tree SVG
Exports:
export function initPlaylist(): void; // Wire scroll events, scrollbar
export function renderList(): void; // Build/rebuild playlist DOM
export function updateScrollbar(): void; // Update custom scrollbar thumb
export function renderTreeSvg(item: VideoItem): string; // SVG tree connectors
export function highlightCurrent(index: number): void; // Scroll to and highlight
The drag-and-drop reorder logic from the Python JS uses pointer events (pointerdown/pointermove/pointerup) on list items, with a gap-based reorder algorithm. Port this exactly.
Step 1: Write playlist.ts
Step 2: Commit
feat: create playlist.ts — playlist rendering and drag-and-drop
Task 19: Create subtitles.ts
Files:
- Create:
src/subtitles.ts
Port from: tutorial.py JavaScript — subtitle menu, track management
Exports:
export function initSubtitles(): void;
export function refreshSubtitles(): Promise<void>;
export function openSubsMenu(): Promise<void>;
export function closeSubsMenu(): void;
export function applySubtitle(url: string, label: string): void;
export function clearSubtitles(): void;
export function ensureSubtitleTrack(): HTMLTrackElement;
Key change: Subtitle URLs become tutdock://localhost/sub/${libId}/${fid} instead of HTTP.
Step 1: Write subtitles.ts
Step 2: Commit
feat: create subtitles.ts — subtitle menu and track management
Task 20: Create ui.ts
Files:
- Create:
src/ui.ts
Port from: tutorial.py JavaScript — zoom, splits, topbar, info panel, notes, toasts, recent menu, speed menu
Exports:
export function initUI(): void; // Wire all topbar/layout events
export function applyZoom(z: number): void;
export function applySplit(ratio: number): void;
export function applyDockSplit(ratio: number): void;
export function updateInfoPanel(): Promise<void>;
export function updateOverall(): void;
export function updateNowHeader(item: VideoItem | null): void;
export function loadNoteForCurrent(): Promise<void>;
export function notify(msg: string): void; // Toast notification
export function openRecentMenu(): Promise<void>;
export function closeRecentMenu(): void;
export function openSpeedMenu(): void;
export function closeSpeedMenu(): void;
export function updateSpeedIcon(rate: number): void;
This is the second-largest frontend module. It handles:
- Zoom control (+/- buttons, click-to-reset)
- Split ratio dragging (main divider + dock divider) via pointerdown/pointermove/pointerup
- Always-on-top toggle switch
- Autoplay toggle switch
- Open folder button (split button with dropdown for recents)
- Recent folders menu (position, open, close, click, remove)
- Speed control menu
- Info panel (all metadata key-value pairs)
- Notes textarea with debounced auto-save (350ms timeout)
- Toast notifications
- Reset progress button
- Reload button
Step 1: Write ui.ts
Step 2: Commit
feat: create ui.ts — layout, panels, topbar, notes, toasts
Task 21: Create tooltips.ts
Files:
- Create:
src/tooltips.ts
Port from: tutorial.py JavaScript — the initTooltips() IIFE (lines 5124-5218)
Exports:
export function initTooltips(): void;
Implements:
- Event delegation on mouseover/mouseout for
[data-tip]elements - 250ms show delay (instant if tooltip already visible)
- 80ms hide delay
- Zoom-aware positioning (reads CSS
--zoomvariable) - Viewport-clamped positioning (stays within window bounds)
Step 1: Write tooltips.ts — direct port of the IIFE
Step 2: Commit
feat: create tooltips.ts — zoom-aware tooltip system
Task 22: Create main.ts — boot and tick loop
Files:
- Modify:
src/main.ts
Port from: tutorial.py JavaScript — boot(), tick(), pywebviewready event, all initialization
This is the orchestrator module. It:
- Imports all CSS files
- Imports and calls init functions from all modules
- Implements
boot():- Call
api.getPrefs(), apply zoom/split/dock ratios - Call
api.getLibrary()— if ok, callonLibraryLoaded() - Set up Tauri event listeners for ffmpeg download progress
- Call
- Implements
onLibraryLoaded(info):- Set volume, playback rate, autoplay from library state
- Call
renderList() - Call
loadVideo()for current index - Call
updateOverall(),updateInfoPanel()
- Implements
tick()— 250ms interval:- If playing: call
api.tickProgress()every ~1s - Refresh library info every ~3s (to pick up duration scan updates)
- Call
api.saveWindowState()periodically
- If playing: call
- Implements
chooseFolder()— callsapi.selectFolder()thenonLibraryLoaded() - Implements
reloadLibrary()— callsapi.getLibrary()then re-renders - Wires global keyboard shortcuts
No pywebviewready event needed — Tauri's frontend JS loads and runs immediately since the webview is local. Just call boot() on DOMContentLoaded (or at module top level since Vite modules are deferred).
Step 1: Write main.ts with boot, tick, and all initialization
Step 2: Commit
feat: create main.ts — boot sequence, tick loop, global wiring
Phase 5: Integration and Build
Task 23: Integration verification
Step 1: Run full dev build
Run: npm run tauri dev
Step 2: Verify each feature works:
Checklist:
- App window opens with correct size
- Dark theme with correct styling
- Open folder dialog works
- Video plays with seek support
- Playlist renders with tree structure
- Progress tracking saves and restores
- Notes save and load
- Volume/speed/autoplay controls work
- Subtitle loading (sidecar, embedded)
- Zoom control works
- Split ratio dragging works
- Always-on-top toggle works
- Recent folders menu works
- Drag-and-drop reorder works
- Tooltips appear on hover
- Toast notifications show
- Info panel shows metadata
- Window position/size saves on close
- ffmpeg download triggers if not found
Step 3: Fix any integration issues found
Step 4: Commit
fix: integration fixes from end-to-end testing
Task 24: Portable release build
Step 1: Build the release exe
Run: npm run tauri build
This produces src-tauri/target/release/TutorialDock.exe.
Step 2: Test portability
- Copy
TutorialDock.exeto a fresh directory - Run it
- Verify
state/directory is created next to exe - Verify all functionality works
- Close and reopen — verify state persists
- Move the exe + state/ to another location — verify it still works
Step 3: Verify no writes outside exe directory
Check that nothing was written to:
%APPDATA%%LOCALAPPDATA%- Registry
- Any other system location
Step 4: Commit
feat: verified portable release build
Dependency Graph
Task 1 (scaffold)
├── Task 2 (utils.rs)
├── Task 3 (state.rs)
│ ├── Task 4 (prefs.rs)
│ └── Task 5 (recents.rs)
├── Task 6 (ffmpeg.rs) ← depends on utils
├── Task 7 (subtitles.rs) ← depends on utils
├── Task 8 (fonts.rs) ← depends on state
├── Task 9 (library.rs) ← depends on utils, state, ffmpeg, subtitles
├── Task 10 (video_protocol.rs) ← depends on library
├── Task 11 (commands.rs) ← depends on all Rust modules
└── Task 12 (main.rs wiring) ← depends on all Rust modules
├── Task 13 (CSS extraction) ← independent of Rust
├── Task 14 (types.ts)
├── Task 15 (api.ts)
├── Task 16 (index.html)
├── Task 17 (player.ts) ← depends on api, types
├── Task 18 (playlist.ts) ← depends on api, types
├── Task 19 (subtitles.ts) ← depends on api, types
├── Task 20 (ui.ts) ← depends on api, types
├── Task 21 (tooltips.ts) ← independent
└── Task 22 (main.ts) ← depends on all TS modules
├── Task 23 (integration)
└── Task 24 (release build)
Notes for the Implementing Engineer
-
Backward compatibility: The Rust backend MUST produce identical JSON state files and file fingerprints as the Python version. Users should be able to switch from the Python app to the Tauri app and keep their progress. Test fingerprint and library ID generation against the Python output.
-
Portability is non-negotiable: Everything in
state/next to the exe. No%APPDATA%, no registry, no installer. Override any Tauri defaults that write elsewhere. -
The CSS must be pixel-identical: Don't "improve" or reformat the CSS. Extract it verbatim. Any visual difference is a bug.
-
The custom protocol must handle range requests correctly: The HTML5
<video>element relies on this for seeking. Test with large video files (>1GB) to ensure streaming works. -
Thread safety: The Library struct is behind a
Mutex. Keep lock durations short. The background duration scanner needs careful lock management — lock, check if duration needed, unlock, compute duration, lock, update state, unlock. -
The "once finished, always finished" rule: If a video's
finishedflag istrue, it must never be set back tofalse. This is a key UX invariant from the original app.