1704 lines
58 KiB
Markdown
1704 lines
58 KiB
Markdown
# TutorialDock Tauri Conversion — Implementation Plan
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||
|
||
**Goal:** Convert TutorialDock from a single-file Python+PyWebView+Flask app into a Tauri v2 desktop app with full Rust backend and Vite+TypeScript frontend, preserving identical functionality and UI.
|
||
|
||
**Architecture:** Tauri v2 with custom `tutdock://` protocol for video/asset serving, 10 Rust backend modules (utils, state, prefs, recents, ffmpeg, subtitles, fonts, library, video_protocol, commands), and 9 TypeScript frontend modules. 100% portable — all state next to the exe.
|
||
|
||
**Tech Stack:** Rust (Tauri v2, serde, sha2, tokio, reqwest), TypeScript (Vite), tauri-plugin-dialog
|
||
|
||
**Source reference:** The original Python app is at `tutorial.py` (5,282 lines). All porting work references specific line ranges in that file.
|
||
|
||
---
|
||
|
||
## Phase 1: Project Scaffolding
|
||
|
||
### Task 1: Initialize Tauri v2 + Vite + TypeScript project
|
||
|
||
**Files:**
|
||
- Create: `src-tauri/Cargo.toml`
|
||
- Create: `src-tauri/tauri.conf.json`
|
||
- Create: `src-tauri/build.rs`
|
||
- Create: `src-tauri/src/main.rs` (minimal stub)
|
||
- Create: `src/index.html` (minimal stub)
|
||
- Create: `src/main.ts` (minimal stub)
|
||
- Create: `src/vite-env.d.ts`
|
||
- Create: `package.json`
|
||
- Create: `tsconfig.json`
|
||
- Create: `vite.config.ts`
|
||
|
||
**Step 1: Create package.json with Tauri + Vite + TypeScript dependencies**
|
||
|
||
```json
|
||
{
|
||
"name": "tutorialdock",
|
||
"private": true,
|
||
"version": "1.0.0",
|
||
"type": "module",
|
||
"scripts": {
|
||
"dev": "vite",
|
||
"build": "tsc && vite build",
|
||
"tauri": "tauri"
|
||
},
|
||
"dependencies": {
|
||
"@tauri-apps/api": "^2"
|
||
},
|
||
"devDependencies": {
|
||
"@tauri-apps/cli": "^2",
|
||
"typescript": "^5.5",
|
||
"vite": "^6"
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Create tsconfig.json**
|
||
|
||
```json
|
||
{
|
||
"compilerOptions": {
|
||
"target": "ES2021",
|
||
"useDefineForClassFields": true,
|
||
"module": "ESNext",
|
||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||
"skipLibCheck": true,
|
||
"moduleResolution": "bundler",
|
||
"allowImportingTsExtensions": true,
|
||
"resolveJsonModule": true,
|
||
"isolatedModules": true,
|
||
"noEmit": true,
|
||
"strict": true,
|
||
"noUnusedLocals": true,
|
||
"noUnusedParameters": true,
|
||
"noFallthroughCasesInSwitch": true,
|
||
"forceConsistentCasingInFileNames": true
|
||
},
|
||
"include": ["src"]
|
||
}
|
||
```
|
||
|
||
**Step 3: Create vite.config.ts**
|
||
|
||
```typescript
|
||
import { defineConfig } from "vite";
|
||
|
||
export default defineConfig({
|
||
clearScreen: false,
|
||
server: {
|
||
port: 1420,
|
||
strictPort: true,
|
||
watch: { ignored: ["**/src-tauri/**"] },
|
||
},
|
||
});
|
||
```
|
||
|
||
**Step 4: Create src/vite-env.d.ts**
|
||
|
||
```typescript
|
||
/// <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.
|
||
|
||
```html
|
||
<!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**
|
||
|
||
```typescript
|
||
console.log("TutorialDock frontend loaded");
|
||
```
|
||
|
||
**Step 7: Create src-tauri/Cargo.toml**
|
||
|
||
```toml
|
||
[package]
|
||
name = "tutorialdock"
|
||
version = "1.0.0"
|
||
edition = "2021"
|
||
|
||
[dependencies]
|
||
tauri = { version = "2", features = ["protocol-asset"] }
|
||
tauri-plugin-dialog = "2"
|
||
serde = { version = "1", features = ["derive"] }
|
||
serde_json = "1"
|
||
sha2 = "0.10"
|
||
tokio = { version = "1", features = ["full"] }
|
||
reqwest = { version = "0.12", features = ["stream"] }
|
||
zip = "2"
|
||
which = "7"
|
||
regex = "1"
|
||
once_cell = "1"
|
||
|
||
[build-dependencies]
|
||
tauri-build = { version = "2", features = [] }
|
||
|
||
[features]
|
||
default = ["custom-protocol"]
|
||
custom-protocol = ["tauri/custom-protocol"]
|
||
```
|
||
|
||
**Step 8: Create src-tauri/build.rs**
|
||
|
||
```rust
|
||
fn main() {
|
||
tauri_build::build()
|
||
}
|
||
```
|
||
|
||
**Step 9: Create src-tauri/tauri.conf.json**
|
||
|
||
Key settings:
|
||
- `identifier`: `com.tutorialdock.app`
|
||
- `productName`: `TutorialDock`
|
||
- `app.windows[0]`: title "TutorialDock", width 1320, height 860, decorations true
|
||
- `app.security.csp`: allow `tutdock:` protocol in media-src, font-src, style-src
|
||
- `bundle.active`: false (no installer — portable exe only)
|
||
- Register `tutdock` as allowed custom protocol under `app.security.assetProtocol`
|
||
|
||
```json
|
||
{
|
||
"$schema": "https://raw.githubusercontent.com/nicedoc/tauri-schema/main/schema.json",
|
||
"productName": "TutorialDock",
|
||
"version": "1.0.0",
|
||
"identifier": "com.tutorialdock.app",
|
||
"build": {
|
||
"frontendDist": "../dist",
|
||
"devUrl": "http://localhost:1420",
|
||
"beforeDevCommand": "npm run dev",
|
||
"beforeBuildCommand": "npm run build"
|
||
},
|
||
"app": {
|
||
"windows": [
|
||
{
|
||
"title": "TutorialDock",
|
||
"width": 1320,
|
||
"height": 860,
|
||
"minWidth": 640,
|
||
"minHeight": 480
|
||
}
|
||
],
|
||
"security": {
|
||
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' tutdock: blob:; font-src 'self' tutdock:; style-src 'self' 'unsafe-inline' tutdock:; img-src 'self' data: tutdock:; connect-src 'self' tutdock: https:"
|
||
}
|
||
},
|
||
"bundle": {
|
||
"active": false
|
||
},
|
||
"plugins": {
|
||
"dialog": {}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 10: Create minimal src-tauri/src/main.rs**
|
||
|
||
```rust
|
||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||
|
||
fn main() {
|
||
tauri::Builder::default()
|
||
.plugin(tauri_plugin_dialog::init())
|
||
.run(tauri::generate_context!())
|
||
.expect("error while running tauri application");
|
||
}
|
||
```
|
||
|
||
**Step 11: Install dependencies and verify build**
|
||
|
||
Run: `npm install`
|
||
Run: `npm run tauri dev`
|
||
Expected: App window opens showing "Loading..." text. Console shows "TutorialDock frontend loaded".
|
||
|
||
**Step 12: Commit**
|
||
|
||
```
|
||
feat: scaffold Tauri v2 + Vite + TypeScript project
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 2: Rust Foundation Modules
|
||
|
||
### Task 2: Implement utils.rs
|
||
|
||
**Files:**
|
||
- Create: `src-tauri/src/utils.rs`
|
||
- Modify: `src-tauri/src/main.rs` (add `mod utils;`)
|
||
|
||
**Port from:** `tutorial.py` lines 90-93 (`clamp`), 159-166 (`is_within_root`), 168-176 (`truthy`), 186-192 (`folder_display_name`), 195-205 (`_deduplicate_list`), 400-464 (`natural_key`, `_smart_title_case`, `pretty_title_from_filename`), 696-731 (`file_fingerprint`), 734-749 (`compute_library_id_from_fids`)
|
||
|
||
**Functions to implement:**
|
||
- `clamp(v: f64, a: f64, b: f64) -> f64`
|
||
- `is_within_root(root: &Path, target: &Path) -> bool`
|
||
- `truthy(v: &serde_json::Value) -> bool`
|
||
- `folder_display_name(path_str: &str) -> String`
|
||
- `deduplicate_list(items: &[String]) -> Vec<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**
|
||
|
||
```rust
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_natural_key_sorting() {
|
||
let mut items = vec!["10.mp4", "2.mp4", "1.mp4", "20.mp4"];
|
||
items.sort_by(|a, b| natural_key(a).cmp(&natural_key(b)));
|
||
assert_eq!(items, vec!["1.mp4", "2.mp4", "10.mp4", "20.mp4"]);
|
||
}
|
||
|
||
#[test]
|
||
fn test_pretty_title() {
|
||
assert_eq!(pretty_title_from_filename("01_introduction_to_python.mp4"), "Introduction to Python");
|
||
assert_eq!(pretty_title_from_filename("02. advanced topics.mp4"), "Advanced Topics");
|
||
assert_eq!(pretty_title_from_filename("(3) the_basics.mkv"), "The Basics");
|
||
}
|
||
|
||
#[test]
|
||
fn test_clamp() {
|
||
assert_eq!(clamp(5.0, 0.0, 10.0), 5.0);
|
||
assert_eq!(clamp(-1.0, 0.0, 10.0), 0.0);
|
||
assert_eq!(clamp(15.0, 0.0, 10.0), 10.0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_deduplicate() {
|
||
let input = vec!["a".into(), "b".into(), "a".into(), "c".into()];
|
||
assert_eq!(deduplicate_list(&input), vec!["a", "b", "c"]);
|
||
}
|
||
|
||
#[test]
|
||
fn test_smart_title_case() {
|
||
assert_eq!(smart_title_case("introduction to the basics"), "Introduction to the Basics");
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 3: Add `mod utils;` to main.rs**
|
||
|
||
**Step 4: Run tests**
|
||
|
||
Run: `cd src-tauri && cargo test utils`
|
||
Expected: All tests pass.
|
||
|
||
**Step 5: Commit**
|
||
|
||
```
|
||
feat: implement utils.rs — natural sort, fingerprinting, title formatting
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Implement state.rs
|
||
|
||
**Files:**
|
||
- Create: `src-tauri/src/state.rs`
|
||
- Modify: `src-tauri/src/main.rs` (add `mod state;`)
|
||
|
||
**Port from:** `tutorial.py` lines 95-156 (`atomic_write_json`, `load_json_with_fallbacks`)
|
||
|
||
**Functions to implement:**
|
||
- `atomic_write_json(path: &Path, data: &serde_json::Value, backup_count: usize)` — write to `.tmp`, rotate `.bak1`–`.bakN`, atomic rename via `std::fs::rename`, write `.lastgood`
|
||
- `load_json_with_fallbacks(path: &Path, backup_count: usize) -> Option<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**
|
||
|
||
```rust
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use tempfile::TempDir;
|
||
|
||
#[test]
|
||
fn test_atomic_write_and_read() {
|
||
let dir = TempDir::new().unwrap();
|
||
let path = dir.path().join("test.json");
|
||
let data = serde_json::json!({"hello": "world"});
|
||
atomic_write_json(&path, &data, 8);
|
||
let loaded = load_json_with_fallbacks(&path, 8).unwrap();
|
||
assert_eq!(loaded, data);
|
||
}
|
||
|
||
#[test]
|
||
fn test_fallback_to_lastgood() {
|
||
let dir = TempDir::new().unwrap();
|
||
let path = dir.path().join("test.json");
|
||
let data = serde_json::json!({"version": 1});
|
||
atomic_write_json(&path, &data, 8);
|
||
// Corrupt primary
|
||
std::fs::write(&path, "not json").unwrap();
|
||
let loaded = load_json_with_fallbacks(&path, 8).unwrap();
|
||
assert_eq!(loaded, data);
|
||
}
|
||
|
||
#[test]
|
||
fn test_backup_rotation() {
|
||
let dir = TempDir::new().unwrap();
|
||
let path = dir.path().join("test.json");
|
||
for i in 0..5 {
|
||
atomic_write_json(&path, &serde_json::json!({"v": i}), 8);
|
||
}
|
||
// .bak1 should be version 3 (second-to-last write)
|
||
let bak1 = dir.path().join("test.json.bak1");
|
||
let bak1_data: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(bak1).unwrap()).unwrap();
|
||
assert_eq!(bak1_data["v"], 3);
|
||
}
|
||
}
|
||
```
|
||
|
||
Add `tempfile = "3"` to `[dev-dependencies]` in Cargo.toml.
|
||
|
||
**Step 3: Run tests**
|
||
|
||
Run: `cd src-tauri && cargo test state`
|
||
Expected: All tests pass.
|
||
|
||
**Step 4: Commit**
|
||
|
||
```
|
||
feat: implement state.rs — atomic JSON persistence with backup rotation
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Implement prefs.rs
|
||
|
||
**Files:**
|
||
- Create: `src-tauri/src/prefs.rs`
|
||
- Modify: `src-tauri/src/main.rs` (add `mod prefs;`)
|
||
|
||
**Port from:** `tutorial.py` lines 806-858 (`Prefs` class)
|
||
|
||
**Structs to implement:**
|
||
|
||
```rust
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct WindowState {
|
||
pub width: i32,
|
||
pub height: i32,
|
||
pub x: Option<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:**
|
||
|
||
```rust
|
||
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**
|
||
|
||
```rust
|
||
#[test]
|
||
fn test_srt_to_vtt_basic() {
|
||
let srt = "1\n00:00:01,000 --> 00:00:04,000\nHello world\n\n2\n00:00:05,000 --> 00:00:08,000\nSecond line\n";
|
||
let vtt = srt_to_vtt(srt);
|
||
assert!(vtt.starts_with("WEBVTT"));
|
||
assert!(vtt.contains("00:00:01.000 --> 00:00:04.000"));
|
||
assert!(!vtt.contains(","));
|
||
}
|
||
|
||
#[test]
|
||
fn test_srt_to_vtt_bom() {
|
||
let srt = "\u{FEFF}1\n00:00:01,500 --> 00:00:03,500\nWith BOM\n";
|
||
let vtt = srt_to_vtt(srt);
|
||
assert!(vtt.starts_with("WEBVTT"));
|
||
assert!(!vtt.contains("\u{FEFF}"));
|
||
}
|
||
```
|
||
|
||
**Step 3: Run tests, commit**
|
||
|
||
```
|
||
feat: implement subtitles.rs — SRT/VTT conversion, sidecar discovery
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Implement fonts.rs
|
||
|
||
**Files:**
|
||
- Create: `src-tauri/src/fonts.rs`
|
||
- Modify: `src-tauri/src/main.rs` (add `mod fonts;`)
|
||
|
||
**Port from:** `tutorial.py` lines 241-393 (`ensure_google_fonts_local`, `ensure_fontawesome_local`, helpers)
|
||
|
||
**Functions:**
|
||
- `ensure_google_fonts_local(fonts_dir: &Path)` — async, download CSS from Google Fonts API for Sora/Manrope/IBM Plex Mono, regex-extract woff2 URLs, download font files, rewrite CSS to use local `/fonts/{file}` paths. Track via `fonts_meta.json` with version 7.
|
||
- `ensure_fontawesome_local(fa_dir: &Path)` — async, download Font Awesome 6.5.2 all.min.css from cdnjs, download all webfont files, rewrite URLs to `/fa/webfonts/{file}`. Track via `fa_meta.json` with version 3.
|
||
- `safe_filename_from_url(url: &str) -> String` — SHA-256 hash of URL for unique local filename
|
||
|
||
**Key detail:** The CSS URL rewriting must produce paths that the `tutdock://` custom protocol can serve. Current Python rewrites to `/fonts/{file}` — we rewrite to `tutdock://localhost/fonts/{file}`.
|
||
|
||
Actually, looking at this more carefully: the CSS files reference fonts via relative URLs. Since the CSS is served via `tutdock://localhost/fonts.css`, relative URLs like `url(/fonts/xyz.woff2)` won't resolve correctly. We need to rewrite to absolute `tutdock://localhost/fonts/xyz.woff2` URLs in the CSS, OR serve the CSS inline, OR use the same `/fonts/` path since Tauri's custom protocol can handle it. The simplest approach: keep the URLs as `/fonts/{file}` in the CSS and have the frontend load fonts.css via a `<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:**
|
||
|
||
```rust
|
||
#[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):**
|
||
```rust
|
||
const VIDEO_EXTS: &[&str] = &[
|
||
".mp4", ".m4v", ".mov", ".webm", ".mkv",
|
||
".avi", ".mpg", ".mpeg", ".m2ts", ".mts", ".ogv"
|
||
];
|
||
```
|
||
|
||
**Methods to implement (all map 1:1 to Python):**
|
||
|
||
1. `set_root(folder: &str, state_dir: &Path) -> Result<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):
|
||
```rust
|
||
fn mime_for_video(ext: &str) -> &str {
|
||
match ext {
|
||
".mp4" | ".m4v" => "video/mp4",
|
||
".webm" => "video/webm",
|
||
".ogv" => "video/ogg",
|
||
".mov" => "video/quicktime",
|
||
".mkv" => "video/x-matroska",
|
||
".avi" => "video/x-msvideo",
|
||
".mpeg" | ".mpg" => "video/mpeg",
|
||
".m2ts" | ".mts" => "video/mp2t",
|
||
_ => "application/octet-stream",
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 1: Write video_protocol.rs — protocol registration + URL routing**
|
||
|
||
**Step 2: Write video_protocol.rs — range request handling for video**
|
||
|
||
Port the range parsing logic from `_send_file_range`. Read only the requested byte range from disk.
|
||
|
||
**Step 3: Write video_protocol.rs — subtitle, fonts, FA handlers**
|
||
|
||
Simple file-serving handlers with appropriate MIME types and path traversal prevention.
|
||
|
||
**Step 4: Commit**
|
||
|
||
```
|
||
feat: implement video_protocol.rs — tutdock:// custom protocol with range requests
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Implement commands.rs
|
||
|
||
**Files:**
|
||
- Create: `src-tauri/src/commands.rs`
|
||
- Modify: `src-tauri/src/main.rs` (add `mod commands;`)
|
||
|
||
**Port from:** `tutorial.py` lines 2125-2276 (API class)
|
||
|
||
All 25 commands as thin `#[tauri::command]` wrappers. Each acquires state locks and delegates to the appropriate module.
|
||
|
||
**Commands list with signatures:**
|
||
|
||
```rust
|
||
#[tauri::command]
|
||
async fn select_folder(
|
||
library: State<'_, Mutex<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:**
|
||
|
||
```rust
|
||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||
|
||
mod commands;
|
||
mod ffmpeg;
|
||
mod fonts;
|
||
mod library;
|
||
mod prefs;
|
||
mod recents;
|
||
mod state;
|
||
mod subtitles;
|
||
mod utils;
|
||
mod video_protocol;
|
||
|
||
use std::path::PathBuf;
|
||
use std::sync::Mutex;
|
||
use tauri::Manager;
|
||
|
||
pub struct AppPaths {
|
||
pub exe_dir: PathBuf,
|
||
pub state_dir: PathBuf,
|
||
pub fonts_dir: PathBuf,
|
||
pub fa_dir: PathBuf,
|
||
pub subs_dir: PathBuf,
|
||
pub webview_profile_dir: PathBuf,
|
||
}
|
||
|
||
fn main() {
|
||
// 1. Resolve exe directory for portability
|
||
let exe_dir = std::env::current_exe()
|
||
.expect("cannot resolve exe path")
|
||
.parent()
|
||
.expect("exe has no parent")
|
||
.to_path_buf();
|
||
|
||
let state_dir = exe_dir.join("state");
|
||
|
||
// Create all subdirectories
|
||
let paths = AppPaths {
|
||
exe_dir: exe_dir.clone(),
|
||
state_dir: state_dir.clone(),
|
||
fonts_dir: state_dir.join("fonts"),
|
||
fa_dir: state_dir.join("fontawesome"),
|
||
subs_dir: state_dir.join("subtitles"),
|
||
webview_profile_dir: state_dir.join("webview_profile"),
|
||
};
|
||
std::fs::create_dir_all(&paths.fonts_dir).ok();
|
||
std::fs::create_dir_all(&paths.fa_dir.join("webfonts")).ok();
|
||
std::fs::create_dir_all(&paths.subs_dir).ok();
|
||
std::fs::create_dir_all(&paths.webview_profile_dir).ok();
|
||
|
||
// 2. Set WebView2 user data folder for portability
|
||
std::env::set_var("WEBVIEW2_USER_DATA_FOLDER", &paths.webview_profile_dir);
|
||
|
||
// 3. Load preferences
|
||
let prefs = prefs::Prefs::load(&state_dir);
|
||
|
||
// 4. Initialize library (empty — will be loaded from frontend or from last_folder_path)
|
||
let mut lib = library::Library::new();
|
||
|
||
// Discover ffmpeg
|
||
let ff_paths = ffmpeg::discover(&paths.exe_dir, &paths.state_dir);
|
||
lib.ffprobe = ff_paths.ffprobe;
|
||
lib.ffmpeg = ff_paths.ffmpeg;
|
||
|
||
// Restore last folder if exists
|
||
if let Some(ref last_path) = prefs.last_folder_path {
|
||
if std::path::Path::new(last_path).is_dir() {
|
||
let _ = lib.set_root(last_path, &state_dir);
|
||
}
|
||
}
|
||
|
||
// 5. Build Tauri app
|
||
let builder = tauri::Builder::default()
|
||
.plugin(tauri_plugin_dialog::init())
|
||
.manage(Mutex::new(lib))
|
||
.manage(Mutex::new(prefs))
|
||
.manage(paths)
|
||
.invoke_handler(tauri::generate_handler![
|
||
commands::select_folder,
|
||
commands::open_folder_path,
|
||
commands::get_recents,
|
||
commands::remove_recent,
|
||
commands::get_library,
|
||
commands::set_current,
|
||
commands::tick_progress,
|
||
commands::set_folder_volume,
|
||
commands::set_folder_autoplay,
|
||
commands::set_folder_rate,
|
||
commands::set_order,
|
||
commands::start_duration_scan,
|
||
commands::get_prefs,
|
||
commands::set_prefs,
|
||
commands::set_always_on_top,
|
||
commands::save_window_state,
|
||
commands::get_note,
|
||
commands::set_note,
|
||
commands::get_current_video_meta,
|
||
commands::get_current_subtitle,
|
||
commands::get_embedded_subtitles,
|
||
commands::extract_embedded_subtitle,
|
||
commands::get_available_subtitles,
|
||
commands::load_sidecar_subtitle,
|
||
commands::choose_subtitle_file,
|
||
]);
|
||
|
||
// Register custom protocol
|
||
let builder = video_protocol::register_protocol(builder);
|
||
|
||
// Configure window from saved prefs
|
||
builder
|
||
.setup(|app| {
|
||
let prefs = app.state::<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**
|
||
|
||
```typescript
|
||
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:**
|
||
|
||
```typescript
|
||
// 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)
|
||
|
||
```typescript
|
||
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:**
|
||
|
||
```typescript
|
||
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:**
|
||
|
||
```typescript
|
||
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:**
|
||
|
||
```typescript
|
||
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:**
|
||
|
||
```typescript
|
||
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:**
|
||
|
||
```typescript
|
||
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.
|