Files
tutorialvault/docs/plans/2026-02-19-tauri-conversion-plan.md

58 KiB
Raw Blame History

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.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
{
  "$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 (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<String>
  • natural_key(s: &str) -> Vec<NaturalKeyPart> — 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

#[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<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 (add mod 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 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<String> — 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:

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 — 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<DownloadProgress>) — 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<f64> — try ffprobe -show_entries format=duration, fallback to ffmpeg stderr Duration: parsing
  • ffprobe_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:

  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<PathBuf> — 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<SubtitleStored> — 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<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 (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 <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 (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:

#[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):

  1. set_root(folder: &str, state_dir: &Path) -> Result<LibraryInfo, String> — 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<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 (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<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 (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:

#[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_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:

#![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:

  • :root variables (--zoom, --bg0, --bg1, --stroke, --text, --accent, etc.)
  • *, html, body base styles
  • #zoomRoot transform-based zoom
  • .app layout
  • .topbar with pseudo-element patterns
  • .brand, .appIcon, .brandText, .appName, .tagline
  • .actions, .actionGroup, .actionDivider

player.css — From the <style> block:

  • .videoWrap, video element
  • .videoOverlay
  • .controls layout
  • .seekRow, .seekBar (range input)
  • .timeChip
  • .volSlider (range input)
  • Fullscreen styles

playlist.css — From the <style> block:

  • .listWrap, #list
  • .listItem with all states (hover, active, current, finished)
  • .treeSvg connector 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
  • .toolbarBtn with 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 > .app container
  • .topbar with brand, actions, zoom control, buttons
  • #recentMenu dropdown portal
  • #contentGrid with 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 --zoom variable)
  • 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:

  1. Imports all CSS files
  2. Imports and calls init functions from all modules
  3. Implements boot():
    • Call api.getPrefs(), apply zoom/split/dock ratios
    • Call api.getLibrary() — if ok, call onLibraryLoaded()
    • Set up Tauri event listeners for ffmpeg download progress
  4. Implements onLibraryLoaded(info):
    • Set volume, playback rate, autoplay from library state
    • Call renderList()
    • Call loadVideo() for current index
    • Call updateOverall(), updateInfoPanel()
  5. 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
  6. Implements chooseFolder() — calls api.selectFolder() then onLibraryLoaded()
  7. Implements reloadLibrary() — calls api.getLibrary() then re-renders
  8. 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

  1. Copy TutorialDock.exe to a fresh directory
  2. Run it
  3. Verify state/ directory is created next to exe
  4. Verify all functionality works
  5. Close and reopen — verify state persists
  6. 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

  1. 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.

  2. Portability is non-negotiable: Everything in state/ next to the exe. No %APPDATA%, no registry, no installer. Override any Tauri defaults that write elsewhere.

  3. The CSS must be pixel-identical: Don't "improve" or reformat the CSS. Extract it verbatim. Any visual difference is a bug.

  4. 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.

  5. 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.

  6. The "once finished, always finished" rule: If a video's finished flag is true, it must never be set back to false. This is a key UX invariant from the original app.