commit 47d4409bcc0d36a0f8f2d41c635d3181fa18d0fc Author: Your Name Date: Thu Feb 19 01:16:17 2026 +0200 initial: existing TutorialDock Python app + conversion design and plan diff --git a/docs/plans/2026-02-19-tauri-conversion-design.md b/docs/plans/2026-02-19-tauri-conversion-design.md new file mode 100644 index 0000000..719ee2e --- /dev/null +++ b/docs/plans/2026-02-19-tauri-conversion-design.md @@ -0,0 +1,244 @@ +# TutorialDock: PyWebView to Tauri Conversion Design + +**Date:** 2026-02-19 +**Status:** Approved + +## Overview + +Convert TutorialDock from a single-file Python app (PyWebView + Flask) to a Tauri v2 desktop app with a full Rust backend and Vite + TypeScript frontend. The app must remain 100% portable (everything next to the exe, no AppData, no registry, no installer). + +## Decisions + +| Decision | Choice | +|----------|--------| +| Backend | Full Rust rewrite (no Python dependency) | +| Video serving | Tauri custom protocol (`tutdock://`) with range requests | +| Frontend tooling | Vite + TypeScript | +| ffmpeg strategy | Discovery (PATH -> next to exe -> state/ffmpeg/) with auto-download + progress UI | +| Platform | Windows only, portable exe only | +| API surface | Exact 1:1 mapping of all 24 PyWebView API methods as Tauri commands | +| Window state | Manual save/restore via prefs.json (no tauri-plugin-window-state) | +| Dialogs | tauri-plugin-dialog for folder picker and subtitle file picker | + +## Project Structure + +``` +tutorial-app/ +├── src-tauri/ +│ ├── src/ +│ │ ├── main.rs # Entry point, Tauri builder, plugin registration +│ │ ├── commands.rs # All 24 #[tauri::command] functions +│ │ ├── library.rs # Library struct + all library operations +│ │ ├── prefs.rs # Prefs struct, defaults, load/save +│ │ ├── state.rs # atomic_write_json, load_json_with_fallbacks, backup rotation +│ │ ├── ffmpeg.rs # Discovery, download, duration_seconds, ffprobe_video_metadata +│ │ ├── video_protocol.rs # Custom tutdock:// protocol with range request handling +│ │ ├── subtitles.rs # SRT->VTT, sidecar discovery, embedded extraction +│ │ ├── fonts.rs # Google Fonts + Font Awesome download & caching +│ │ ├── recents.rs # Recent folders list management +│ │ └── utils.rs # natural_key, file_fingerprint, pretty_title, path helpers +│ ├── Cargo.toml +│ ├── tauri.conf.json +│ └── build.rs +├── src/ +│ ├── index.html +│ ├── main.ts # Boot sequence, tick loop, global event wiring +│ ├── api.ts # Typed invoke() wrappers for all 24 commands +│ ├── types.ts # All TypeScript interfaces +│ ├── player.ts # Video element control, seek, volume, playback rate +│ ├── playlist.ts # Playlist rendering, drag-and-drop reorder, tree SVG +│ ├── subtitles.ts # Subtitle menu, track management +│ ├── ui.ts # Zoom, splits, topbar, info panel, notes, toasts +│ ├── tooltips.ts # Tooltip system +│ ├── styles/ +│ │ ├── main.css # Variables, body, app layout, topbar +│ │ ├── player.css # Video wrap, controls, seek bar, volume +│ │ ├── playlist.css # List items, tree view, scrollbar +│ │ ├── panels.css # Panel headers, dock grid, notes, info +│ │ ├── components.css # Buttons, switches, dropdowns, tooltips, toasts +│ │ └── animations.css # Keyframes +│ └── vite-env.d.ts +├── package.json +├── tsconfig.json +└── vite.config.ts +``` + +## Rust Backend + +### State Management + +Three pieces of managed Tauri state: +- `Mutex` — video library + per-folder state +- `Mutex` — global preferences +- `AppPaths` — resolved portable paths (immutable after startup) + +### Module Details + +**state.rs** — Atomic JSON persistence +- `atomic_write_json(path, data, backup_count=8)`: tmp-file write, rotate .bak1-.bak8, .lastgood copy +- `load_json_with_fallbacks(path)`: try primary -> .lastgood -> .bak1-.bak10 + +**utils.rs** — Pure utility functions +- `natural_key()`: natural sort (split on digits, mixed comparison) +- `file_fingerprint()`: SHA-256 of size + first 256KB + last 256KB, 20-char hex +- `compute_library_id()`: SHA-256 of sorted fingerprints, 16-char hex +- `pretty_title_from_filename()`: strip index prefix, underscores to spaces, smart title case +- `is_within_root()`, `clamp()`, `truthy()`, `folder_display_name()` + +**prefs.rs** — Global preferences +- Struct with serde Serialize/Deserialize +- Fields: ui_zoom, split_ratio, dock_ratio, always_on_top, window (x/y/width/height), last_folder_path, last_library_id +- Versioned (version: 19), merged on load + +**recents.rs** — Recent folders (max 50, deduplicated, most-recent-first) + +**ffmpeg.rs** — ffmpeg/ffprobe management +- `discover()`: PATH -> next to exe -> state/ffmpeg/ +- `download_ffmpeg(progress_callback)`: download from github.com/BtbN/FFmpeg-Builds, extract +- `duration_seconds()`: ffprobe first, ffmpeg stderr fallback +- `ffprobe_video_metadata()`: full JSON probe, video/audio/subtitle stream extraction +- Windows CREATE_NO_WINDOW for all subprocess calls + +**subtitles.rs** +- `srt_to_vtt()`: BOM removal, comma->dot timestamps +- `auto_subtitle_sidecar()`: case-insensitive matching, language code awareness, priority candidates +- `store_subtitle_for_fid()`: convert and save to state/subtitles/ +- `extract_embedded_subtitle()`: ffmpeg extraction to VTT + +**library.rs** — Core library management +- Scan folder for video files (VIDEO_EXTS set) +- Build content-based fingerprints (survives renames/moves) +- Load/create/merge per-library state +- Playlist ordering with saved order + natural sort for new files +- Tree display flags (depth-1 only) +- Folder stats (finished/remaining counts, overall progress, remaining time) +- Background duration scanner via tokio::spawn +- "Once finished, always finished" sticky flag + +**video_protocol.rs** — Custom protocol handler +- Routes: /video/{index}, /sub/{libid}/{fid}, /fonts.css, /fonts/{file}, /fa.css, /fa/webfonts/{file} +- Video: Range header parsing, 206 Partial Content, 512KB chunk streaming +- MIME type mapping from file extension +- Path traversal prevention + +**commands.rs** — 24 Tauri commands (1:1 with Python API class) +1. select_folder() — native folder dialog + set_root +2. open_folder_path(folder) +3. get_recents() +4. remove_recent(path) +5. get_library() +6. set_current(index, timecode) +7. tick_progress(index, current_time, duration, playing) +8. set_folder_volume(volume) +9. set_folder_autoplay(enabled) +10. set_folder_rate(rate) +11. set_order(fids) +12. start_duration_scan() +13. get_prefs() +14. set_prefs(patch) +15. set_always_on_top(enabled) +16. save_window_state() +17. get_note(fid) +18. set_note(fid, note) +19. get_current_video_meta() +20. get_current_subtitle() +21. get_embedded_subtitles() +22. extract_embedded_subtitle(track_index) +23. get_available_subtitles() +24. load_sidecar_subtitle(file_path) +25. choose_subtitle_file() — native file dialog + set subtitle + +### Rust Crates + +| Crate | Purpose | +|-------|---------| +| tauri | App framework, windows, commands, custom protocols | +| serde + serde_json | JSON serialization | +| sha2 | SHA-256 fingerprinting | +| tokio | Async runtime (duration scan, downloads) | +| reqwest | HTTP client (ffmpeg + font downloads) | +| zip | Extracting ffmpeg archive | +| which | Finding executables on PATH | +| tauri-plugin-dialog | Native file/folder dialogs | + +## Frontend + +### TypeScript Modules + +**types.ts** — Interfaces for all API responses (LibraryInfo, VideoItem, Prefs, VideoMeta, SubtitleInfo, etc.) + +**api.ts** — Typed invoke() wrappers for all 24 commands. Every `window.pywebview.api.foo()` becomes `api.foo()`. + +**main.ts** — Boot sequence, tick loop (250ms interval, progress save ~1s, library refresh ~3s, window state save), global keyboard shortcuts, Tauri event listeners (ffmpeg download progress). + +**player.ts** — Video element with `tutdock://localhost/video/{index}` src, play/pause/seek/volume/speed/fullscreen, overlay, autoplay-next on ended. + +**playlist.ts** — Render list items with progress bars and tree connectors, drag-and-drop reorder via pointer events, custom scrollbar. + +**subtitles.ts** — Subtitle menu (sidecar + embedded + file picker + disable), track management via `tutdock://localhost/sub/...`. + +**ui.ts** — Zoom control, split ratio dragging, topbar actions (always-on-top, autoplay, open folder, recent dropdown, reset progress, reload), info panel, notes textarea with debounced auto-save, toast notifications. + +**tooltips.ts** — Event delegation on [data-tip] elements, zoom-aware positioning. + +### CSS Split + +| File | Content | +|------|---------| +| main.css | :root variables, body, #zoomRoot, .app, .topbar, .brand | +| player.css | .videoWrap, video, .controls, seek/volume bars, time display | +| playlist.css | #list, .listItem, tree SVG, .listScrollbar, #emptyHint | +| panels.css | .panel, .panelHeader, #dockGrid, .notesArea, .infoGrid | +| components.css | .toolbarBtn, .splitBtn, .toggleSwitch, .dropdownPortal, #toast, #fancyTooltip | +| animations.css | @keyframes logoSpin, logoWiggle, transitions | + +### Video Source URLs + +- Current: `http://127.0.0.1:{port}/video/{index}` +- New: `tutdock://localhost/video/{index}` + +Same change for subtitles, fonts, and font awesome assets. + +## Custom Protocol Routing + +| URL Pattern | Response | +|-------------|----------| +| tutdock://localhost/video/{index} | Video file (200/206 with range support) | +| tutdock://localhost/sub/{libid}/{fid} | VTT subtitle file | +| tutdock://localhost/fonts.css | Combined Google Fonts CSS | +| tutdock://localhost/fonts/{filename} | Font woff2 files | +| tutdock://localhost/fa.css | Font Awesome CSS | +| tutdock://localhost/fa/webfonts/{filename} | Font Awesome webfont files | + +## Startup Flow + +1. Resolve exe directory, create state/ subdirectories, set WEBVIEW2_USER_DATA_FOLDER +2. Load prefs.json, restore window size/position +3. Create Tauri window, register tutdock:// protocol, register 24 commands +4. Frontend boot(): apply zoom/splits, load last library if exists +5. ffmpeg discovery (async): PATH -> exe dir -> state/ffmpeg/ -> auto-download with progress UI +6. Font caching (async, silent): Google Fonts + Font Awesome + +## Portable State Layout + +``` +TutorialDock.exe +state/ +├── prefs.json (+ .lastgood, .bak1-.bak8) +├── recent_folders.json (+ backups) +├── library_{id}.json (per unique library, + backups) +├── webview_profile/ (WebView2 data) +├── fonts/ (cached Google Fonts) +│ ├── fonts.css +│ ├── fonts_meta.json +│ └── *.woff2 +├── fontawesome/ (cached Font Awesome) +│ ├── fa.css +│ ├── fa_meta.json +│ └── webfonts/*.woff2 +├── subtitles/ (converted subtitle files) +│ └── {fid}_*.vtt +└── ffmpeg/ (auto-downloaded) + ├── ffmpeg.exe + └── ffprobe.exe +``` diff --git a/docs/plans/2026-02-19-tauri-conversion-plan.md b/docs/plans/2026-02-19-tauri-conversion-plan.md new file mode 100644 index 0000000..f0882ca --- /dev/null +++ b/docs/plans/2026-02-19-tauri-conversion-plan.md @@ -0,0 +1,1703 @@ +# TutorialDock Tauri Conversion — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Convert TutorialDock from a single-file Python+PyWebView+Flask app into a Tauri v2 desktop app with full Rust backend and Vite+TypeScript frontend, preserving identical functionality and UI. + +**Architecture:** Tauri v2 with custom `tutdock://` protocol for video/asset serving, 10 Rust backend modules (utils, state, prefs, recents, ffmpeg, subtitles, fonts, library, video_protocol, commands), and 9 TypeScript frontend modules. 100% portable — all state next to the exe. + +**Tech Stack:** Rust (Tauri v2, serde, sha2, tokio, reqwest), TypeScript (Vite), tauri-plugin-dialog + +**Source reference:** The original Python app is at `tutorial.py` (5,282 lines). All porting work references specific line ranges in that file. + +--- + +## Phase 1: Project Scaffolding + +### Task 1: Initialize Tauri v2 + Vite + TypeScript project + +**Files:** +- Create: `src-tauri/Cargo.toml` +- Create: `src-tauri/tauri.conf.json` +- Create: `src-tauri/build.rs` +- Create: `src-tauri/src/main.rs` (minimal stub) +- Create: `src/index.html` (minimal stub) +- Create: `src/main.ts` (minimal stub) +- Create: `src/vite-env.d.ts` +- Create: `package.json` +- Create: `tsconfig.json` +- Create: `vite.config.ts` + +**Step 1: Create package.json with Tauri + Vite + TypeScript dependencies** + +```json +{ + "name": "tutorialdock", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "tauri": "tauri" + }, + "dependencies": { + "@tauri-apps/api": "^2" + }, + "devDependencies": { + "@tauri-apps/cli": "^2", + "typescript": "^5.5", + "vite": "^6" + } +} +``` + +**Step 2: Create tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ES2021", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2021", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} +``` + +**Step 3: Create vite.config.ts** + +```typescript +import { defineConfig } from "vite"; + +export default defineConfig({ + clearScreen: false, + server: { + port: 1420, + strictPort: true, + watch: { ignored: ["**/src-tauri/**"] }, + }, +}); +``` + +**Step 4: Create src/vite-env.d.ts** + +```typescript +/// +``` + +**Step 5: Create minimal src/index.html** + +A bare-bones HTML file that loads main.ts. Just enough to verify the build works. + +```html + +TutorialDock +
Loading...
+ +``` + +**Step 6: Create minimal src/main.ts** + +```typescript +console.log("TutorialDock frontend loaded"); +``` + +**Step 7: Create src-tauri/Cargo.toml** + +```toml +[package] +name = "tutorialdock" +version = "1.0.0" +edition = "2021" + +[dependencies] +tauri = { version = "2", features = ["protocol-asset"] } +tauri-plugin-dialog = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +tokio = { version = "1", features = ["full"] } +reqwest = { version = "0.12", features = ["stream"] } +zip = "2" +which = "7" +regex = "1" +once_cell = "1" + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[features] +default = ["custom-protocol"] +custom-protocol = ["tauri/custom-protocol"] +``` + +**Step 8: Create src-tauri/build.rs** + +```rust +fn main() { + tauri_build::build() +} +``` + +**Step 9: Create src-tauri/tauri.conf.json** + +Key settings: +- `identifier`: `com.tutorialdock.app` +- `productName`: `TutorialDock` +- `app.windows[0]`: title "TutorialDock", width 1320, height 860, decorations true +- `app.security.csp`: allow `tutdock:` protocol in media-src, font-src, style-src +- `bundle.active`: false (no installer — portable exe only) +- Register `tutdock` as allowed custom protocol under `app.security.assetProtocol` + +```json +{ + "$schema": "https://raw.githubusercontent.com/nicedoc/tauri-schema/main/schema.json", + "productName": "TutorialDock", + "version": "1.0.0", + "identifier": "com.tutorialdock.app", + "build": { + "frontendDist": "../dist", + "devUrl": "http://localhost:1420", + "beforeDevCommand": "npm run dev", + "beforeBuildCommand": "npm run build" + }, + "app": { + "windows": [ + { + "title": "TutorialDock", + "width": 1320, + "height": 860, + "minWidth": 640, + "minHeight": 480 + } + ], + "security": { + "csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' tutdock: blob:; font-src 'self' tutdock:; style-src 'self' 'unsafe-inline' tutdock:; img-src 'self' data: tutdock:; connect-src 'self' tutdock: https:" + } + }, + "bundle": { + "active": false + }, + "plugins": { + "dialog": {} + } +} +``` + +**Step 10: Create minimal src-tauri/src/main.rs** + +```rust +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +**Step 11: Install dependencies and verify build** + +Run: `npm install` +Run: `npm run tauri dev` +Expected: App window opens showing "Loading..." text. Console shows "TutorialDock frontend loaded". + +**Step 12: Commit** + +``` +feat: scaffold Tauri v2 + Vite + TypeScript project +``` + +--- + +## Phase 2: Rust Foundation Modules + +### Task 2: Implement utils.rs + +**Files:** +- Create: `src-tauri/src/utils.rs` +- Modify: `src-tauri/src/main.rs` (add `mod utils;`) + +**Port from:** `tutorial.py` lines 90-93 (`clamp`), 159-166 (`is_within_root`), 168-176 (`truthy`), 186-192 (`folder_display_name`), 195-205 (`_deduplicate_list`), 400-464 (`natural_key`, `_smart_title_case`, `pretty_title_from_filename`), 696-731 (`file_fingerprint`), 734-749 (`compute_library_id_from_fids`) + +**Functions to implement:** +- `clamp(v: f64, a: f64, b: f64) -> f64` +- `is_within_root(root: &Path, target: &Path) -> bool` +- `truthy(v: &serde_json::Value) -> bool` +- `folder_display_name(path_str: &str) -> String` +- `deduplicate_list(items: &[String]) -> Vec` +- `natural_key(s: &str) -> Vec` — enum with `Num(u64)` and `Text(String)` variants, implements `Ord` +- `smart_title_case(text: &str) -> String` — same SMALL_WORDS set as Python +- `pretty_title_from_filename(filename: &str) -> String` — same regex patterns: `LEADING_INDEX_RE`, underscore→space, strip leading punct +- `file_fingerprint(path: &Path) -> String` — SHA-256 of `b"VIDFIDv1\0"` + size + `b"\0"` + first 256KB + last 256KB, truncated to 20 hex chars +- `compute_library_id(fids: &[String]) -> String` — SHA-256 of `b"LIBFIDv2\0"` + sorted fids joined by `\n`, truncated to 16 hex chars + +**Key detail for `file_fingerprint`:** Must produce identical output to the Python version for backward compatibility with existing library state files. The Python code uses `hashlib.sha256` with the exact byte sequence `b"VIDFIDv1\0"` + ascii size string + `b"\0"` + head bytes + tail bytes. The Rust version must match byte-for-byte. + +**Step 1: Write utils.rs with all functions** + +Implement all functions listed above. Use `sha2::Sha256` for hashing, `regex::Regex` with `once_cell::sync::Lazy` for compiled patterns. + +**Step 2: Write unit tests at bottom of utils.rs** + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_natural_key_sorting() { + let mut items = vec!["10.mp4", "2.mp4", "1.mp4", "20.mp4"]; + items.sort_by(|a, b| natural_key(a).cmp(&natural_key(b))); + assert_eq!(items, vec!["1.mp4", "2.mp4", "10.mp4", "20.mp4"]); + } + + #[test] + fn test_pretty_title() { + assert_eq!(pretty_title_from_filename("01_introduction_to_python.mp4"), "Introduction to Python"); + assert_eq!(pretty_title_from_filename("02. advanced topics.mp4"), "Advanced Topics"); + assert_eq!(pretty_title_from_filename("(3) the_basics.mkv"), "The Basics"); + } + + #[test] + fn test_clamp() { + assert_eq!(clamp(5.0, 0.0, 10.0), 5.0); + assert_eq!(clamp(-1.0, 0.0, 10.0), 0.0); + assert_eq!(clamp(15.0, 0.0, 10.0), 10.0); + } + + #[test] + fn test_deduplicate() { + let input = vec!["a".into(), "b".into(), "a".into(), "c".into()]; + assert_eq!(deduplicate_list(&input), vec!["a", "b", "c"]); + } + + #[test] + fn test_smart_title_case() { + assert_eq!(smart_title_case("introduction to the basics"), "Introduction to the Basics"); + } +} +``` + +**Step 3: Add `mod utils;` to main.rs** + +**Step 4: Run tests** + +Run: `cd src-tauri && cargo test utils` +Expected: All tests pass. + +**Step 5: Commit** + +``` +feat: implement utils.rs — natural sort, fingerprinting, title formatting +``` + +--- + +### Task 3: Implement state.rs + +**Files:** +- Create: `src-tauri/src/state.rs` +- Modify: `src-tauri/src/main.rs` (add `mod state;`) + +**Port from:** `tutorial.py` lines 95-156 (`atomic_write_json`, `load_json_with_fallbacks`) + +**Functions to implement:** +- `atomic_write_json(path: &Path, data: &serde_json::Value, backup_count: usize)` — write to `.tmp`, rotate `.bak1`–`.bakN`, atomic rename via `std::fs::rename`, write `.lastgood` +- `load_json_with_fallbacks(path: &Path, backup_count: usize) -> Option` — try path → `.lastgood` → `.bak1`–`.bak{N+2}` + +**Key detail:** The backup rotation logic must match Python exactly: iterate `backup_count` down to 1, rename `.bakI` to `.bak{I+1}`, then rename current file to `.bak1`. Default `backup_count` = 8. + +**Step 1: Write state.rs** + +**Step 2: Write unit tests using temp directories** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_atomic_write_and_read() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("test.json"); + let data = serde_json::json!({"hello": "world"}); + atomic_write_json(&path, &data, 8); + let loaded = load_json_with_fallbacks(&path, 8).unwrap(); + assert_eq!(loaded, data); + } + + #[test] + fn test_fallback_to_lastgood() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("test.json"); + let data = serde_json::json!({"version": 1}); + atomic_write_json(&path, &data, 8); + // Corrupt primary + std::fs::write(&path, "not json").unwrap(); + let loaded = load_json_with_fallbacks(&path, 8).unwrap(); + assert_eq!(loaded, data); + } + + #[test] + fn test_backup_rotation() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("test.json"); + for i in 0..5 { + atomic_write_json(&path, &serde_json::json!({"v": i}), 8); + } + // .bak1 should be version 3 (second-to-last write) + let bak1 = dir.path().join("test.json.bak1"); + let bak1_data: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(bak1).unwrap()).unwrap(); + assert_eq!(bak1_data["v"], 3); + } +} +``` + +Add `tempfile = "3"` to `[dev-dependencies]` in Cargo.toml. + +**Step 3: Run tests** + +Run: `cd src-tauri && cargo test state` +Expected: All tests pass. + +**Step 4: Commit** + +``` +feat: implement state.rs — atomic JSON persistence with backup rotation +``` + +--- + +### Task 4: Implement prefs.rs + +**Files:** +- Create: `src-tauri/src/prefs.rs` +- Modify: `src-tauri/src/main.rs` (add `mod prefs;`) + +**Port from:** `tutorial.py` lines 806-858 (`Prefs` class) + +**Structs to implement:** + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WindowState { + pub width: i32, + pub height: i32, + pub x: Option, + pub y: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Prefs { + pub version: u32, + pub ui_zoom: f64, + pub split_ratio: f64, + pub dock_ratio: f64, + pub always_on_top: bool, + pub window: WindowState, + pub last_folder_path: Option, + pub last_library_id: Option, + #[serde(default)] + pub updated_at: u64, +} +``` + +**Functions:** +- `Prefs::default()` — version 19, ui_zoom 1.0, split_ratio 0.62, dock_ratio 0.62, window 1320x860 +- `Prefs::load(state_dir: &Path) -> Prefs` — load from `prefs.json` using `state::load_json_with_fallbacks`, merge with defaults (the Python way: defaults first, then overlay loaded values; handle window dict merge specially) +- `Prefs::save(&self, state_dir: &Path)` — save via `state::atomic_write_json` +- `Prefs::update(&mut self, patch: serde_json::Value, state_dir: &Path)` — apply partial updates, handle window dict merge, set `updated_at`, save + +**Step 1: Write prefs.rs** + +**Step 2: Write tests** + +Test default creation, save/load round-trip, partial update with window merge. + +**Step 3: Run tests** + +Run: `cd src-tauri && cargo test prefs` + +**Step 4: Commit** + +``` +feat: implement prefs.rs — preferences with save/load/update +``` + +--- + +### Task 5: Implement recents.rs + +**Files:** +- Create: `src-tauri/src/recents.rs` +- Modify: `src-tauri/src/main.rs` (add `mod recents;`) + +**Port from:** `tutorial.py` lines 208-235 + +**Functions:** +- `load_recents(state_dir: &Path) -> Vec` — load from `recent_folders.json`, validate structure, deduplicate, max 50 +- `save_recents(state_dir: &Path, paths: &[String])` — write with version 2 + timestamp + items +- `push_recent(state_dir: &Path, path_str: &str)` — load, remove if exists, insert at front, save +- `remove_recent(state_dir: &Path, path_str: &str)` — load, remove, save + +**Step 1: Write recents.rs** + +**Step 2: Write tests** + +Test push (adds to front, deduplicates), remove, max 50 limit. + +**Step 3: Run tests, commit** + +``` +feat: implement recents.rs — recent folders management +``` + +--- + +### Task 6: Implement ffmpeg.rs + +**Files:** +- Create: `src-tauri/src/ffmpeg.rs` +- Modify: `src-tauri/src/main.rs` (add `mod ffmpeg;`) + +**Port from:** `tutorial.py` lines 82-88 (`_subprocess_no_window_kwargs`), 467-513 (`find_ffprobe_ffmpeg`), 516-569 (`duration_seconds`), 572-694 (`ffprobe_video_metadata`) + +**Structs:** + +```rust +pub struct FfmpegPaths { + pub ffprobe: Option, + pub ffmpeg: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VideoMetadata { + pub v_codec: Option, + pub width: Option, + pub height: Option, + pub fps: Option, + pub v_bitrate: Option, + pub pix_fmt: Option, + pub color_space: Option, + pub a_codec: Option, + pub channels: Option, + pub sample_rate: Option, + pub a_bitrate: Option, + pub subtitle_tracks: Vec, + pub container_bitrate: Option, + pub duration: Option, + pub format_name: Option, + pub container_title: Option, + pub encoder: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubtitleTrack { + pub index: u32, + pub codec: String, + pub language: String, + pub title: String, +} +``` + +**Functions:** +- `discover(exe_dir: &Path, state_dir: &Path) -> FfmpegPaths` — check `which::which("ffprobe")` / `which::which("ffmpeg")`, then `exe_dir/ffprobe.exe`, then `state_dir/ffmpeg/ffprobe.exe` +- `download_ffmpeg(state_dir: &Path, progress_tx: tokio::sync::mpsc::Sender)` — async, download from `github.com/BtbN/FFmpeg-Builds/releases`, extract with `zip` crate, place in `state_dir/ffmpeg/` +- `duration_seconds(path: &Path, paths: &FfmpegPaths) -> Option` — try ffprobe `-show_entries format=duration`, fallback to ffmpeg stderr `Duration:` parsing +- `ffprobe_video_metadata(path: &Path, ffprobe: &Path) -> Option` — run ffprobe with `-print_format json -show_streams -show_format`, parse all stream types + +**Key Windows detail:** All `Command::new()` calls must use `.creation_flags(0x08000000)` (CREATE_NO_WINDOW) to prevent console flashes. This replaces the Python `_subprocess_no_window_kwargs`. + +**Step 1: Write ffmpeg.rs — discovery + duration_seconds + metadata** + +**Step 2: Write ffmpeg.rs — download function (async with progress)** + +The download function should: +1. Create `state_dir/ffmpeg/` if it doesn't exist +2. GET the zip from GitHub releases with `reqwest` streaming +3. Emit progress events as bytes download +4. Extract only `ffmpeg.exe` and `ffprobe.exe` from the zip +5. Place them in `state_dir/ffmpeg/` + +**Step 3: Write unit tests for duration parsing and metadata extraction** + +Tests for `duration_seconds` and `ffprobe_video_metadata` need actual ffprobe/ffmpeg installed, so mark those as `#[ignore]` for CI. Test the string parsing logic (ffmpeg stderr Duration: extraction) with mock data. + +**Step 4: Run tests, commit** + +``` +feat: implement ffmpeg.rs — discovery, download, metadata extraction +``` + +--- + +### Task 7: Implement subtitles.rs + +**Files:** +- Create: `src-tauri/src/subtitles.rs` +- Modify: `src-tauri/src/main.rs` (add `mod subtitles;`) + +**Port from:** `tutorial.py` lines 751-798 (`srt_to_vtt_bytes`), 1219-1295 (`_auto_subtitle_sidecar`), 1297-1317 (`_store_subtitle_for_fid`), 1451-1520 (`extract_embedded_subtitle`) + +**Functions:** +- `srt_to_vtt(srt_text: &str) -> String` — remove BOM, add `WEBVTT` header, convert timestamps (comma→dot), strip cue indices +- `auto_subtitle_sidecar(video_path: &Path) -> Option` — priority-based matching: exact → normalized → English lang suffix → other lang suffix. Same `normalize()` logic (lowercase, replace `-_` with space, collapse spaces) +- `store_subtitle_for_fid(fid: &str, src_path: &Path, subs_dir: &Path) -> Option` — convert SRT→VTT if needed, save as `{fid}_{sanitized_name}.vtt` +- `extract_embedded_subtitle(video_path: &Path, track_index: u32, ffmpeg_path: &Path, subs_dir: &Path, fid: &str) -> Result` — run ffmpeg `-map 0:{track_index} -c:s webvtt` + +**Step 1: Write subtitles.rs** + +**Step 2: Write unit tests for srt_to_vtt** + +```rust +#[test] +fn test_srt_to_vtt_basic() { + let srt = "1\n00:00:01,000 --> 00:00:04,000\nHello world\n\n2\n00:00:05,000 --> 00:00:08,000\nSecond line\n"; + let vtt = srt_to_vtt(srt); + assert!(vtt.starts_with("WEBVTT")); + assert!(vtt.contains("00:00:01.000 --> 00:00:04.000")); + assert!(!vtt.contains(",")); +} + +#[test] +fn test_srt_to_vtt_bom() { + let srt = "\u{FEFF}1\n00:00:01,500 --> 00:00:03,500\nWith BOM\n"; + let vtt = srt_to_vtt(srt); + assert!(vtt.starts_with("WEBVTT")); + assert!(!vtt.contains("\u{FEFF}")); +} +``` + +**Step 3: Run tests, commit** + +``` +feat: implement subtitles.rs — SRT/VTT conversion, sidecar discovery +``` + +--- + +### Task 8: Implement fonts.rs + +**Files:** +- Create: `src-tauri/src/fonts.rs` +- Modify: `src-tauri/src/main.rs` (add `mod fonts;`) + +**Port from:** `tutorial.py` lines 241-393 (`ensure_google_fonts_local`, `ensure_fontawesome_local`, helpers) + +**Functions:** +- `ensure_google_fonts_local(fonts_dir: &Path)` — async, download CSS from Google Fonts API for Sora/Manrope/IBM Plex Mono, regex-extract woff2 URLs, download font files, rewrite CSS to use local `/fonts/{file}` paths. Track via `fonts_meta.json` with version 7. +- `ensure_fontawesome_local(fa_dir: &Path)` — async, download Font Awesome 6.5.2 all.min.css from cdnjs, download all webfont files, rewrite URLs to `/fa/webfonts/{file}`. Track via `fa_meta.json` with version 3. +- `safe_filename_from_url(url: &str) -> String` — SHA-256 hash of URL for unique local filename + +**Key detail:** The CSS URL rewriting must produce paths that the `tutdock://` custom protocol can serve. Current Python rewrites to `/fonts/{file}` — we rewrite to `tutdock://localhost/fonts/{file}`. + +Actually, looking at this more carefully: the CSS files reference fonts via relative URLs. Since the CSS is served via `tutdock://localhost/fonts.css`, relative URLs like `url(/fonts/xyz.woff2)` won't resolve correctly. We need to rewrite to absolute `tutdock://localhost/fonts/xyz.woff2` URLs in the CSS, OR serve the CSS inline, OR use the same `/fonts/` path since Tauri's custom protocol can handle it. The simplest approach: keep the URLs as `/fonts/{file}` in the CSS and have the frontend load fonts.css via a `` tag with `href="tutdock://localhost/fonts.css"` — the browser should resolve relative URLs against the protocol origin. + +**Step 1: Write fonts.rs** + +**Step 2: Test (manual — requires network)** + +This module is hard to unit-test without network. Add an `#[ignore]` integration test that downloads to a temp dir. + +**Step 3: Commit** + +``` +feat: implement fonts.rs — Google Fonts and Font Awesome caching +``` + +--- + +### Task 9: Implement library.rs + +**Files:** +- Create: `src-tauri/src/library.rs` +- Modify: `src-tauri/src/main.rs` (add `mod library;`) + +**Port from:** `tutorial.py` lines 861-1993 (entire `Library` class) + +This is the largest module. It contains all library management logic. + +**Structs:** + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VideoMeta { + pub pos: f64, + pub watched: f64, + pub duration: Option, + pub finished: bool, + pub note: String, + pub last_open: u64, + pub subtitle: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubtitleRef { + pub vtt: String, + pub label: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LibraryState { + pub version: u32, + pub library_id: String, + pub last_path: String, + pub updated_at: u64, + pub current_fid: Option, + pub current_time: f64, + pub volume: f64, + pub autoplay: bool, + pub playback_rate: f64, + pub order_fids: Vec, + pub videos: HashMap, +} + +pub struct Library { + pub root: Option, + pub files: Vec, + pub fids: Vec, + pub relpaths: Vec, + pub rel_to_fid: HashMap, + pub fid_to_rel: HashMap, + pub state: LibraryState, + pub state_path: Option, + // ffmpeg paths + pub ffprobe: Option, + pub ffmpeg: Option, + // metadata cache + pub meta_cache: HashMap, +} +``` + +**Supported video extensions (same as Python):** +```rust +const VIDEO_EXTS: &[&str] = &[ + ".mp4", ".m4v", ".mov", ".webm", ".mkv", + ".avi", ".mpg", ".mpeg", ".m2ts", ".mts", ".ogv" +]; +``` + +**Methods to implement (all map 1:1 to Python):** + +1. `set_root(folder: &str, state_dir: &Path) -> Result` — Port of `Library.set_root()` (lines 933-1082). Scan folder for video files, compute fingerprints, load/create state, merge, apply saved order, save, start duration scan. + +2. `get_library_info() -> LibraryInfo` — Port of `Library.get_library_info()` (lines 1726-1799). Build full response with items, tree flags, stats. + +3. `update_progress(index, current_time, duration, playing) -> Result<(), String>` — Port of `Library.update_progress()` (lines 1801-1835). Update pos, watched (high-water mark), duration, finished (sticky), last_open. + +4. `set_current(index, timecode)` — Port of lines 1837-1845. + +5. `set_folder_volume(volume)`, `set_folder_autoplay(enabled)`, `set_folder_rate(rate)` — Port of lines 1847-1870. + +6. `set_order(fids)` — Port of lines 1872-1908. Reorder playlist, rebuild file lists. + +7. `get_note(fid)`, `set_note(fid, note)` — Port of lines 1920-1941. + +8. `get_current_video_metadata()` — Port of lines 1695-1724. Probe + cache. + +9. `get_subtitle_for_current(state_dir)` — Port of lines 1319-1400. Check stored → sidecar → embedded. + +10. `set_subtitle_for_current(file_path, state_dir)` — Port of lines 1402-1421. + +11. `get_embedded_subtitles()` — Port of lines 1423-1449. + +12. `extract_embedded_subtitle(track_index, state_dir)` — Port of lines 1451-1520. + +13. `get_available_subtitles()` — Port of lines 1522-1650. + +14. `load_sidecar_subtitle(file_path, state_dir)` — Port of lines 1652-1677. + +15. `reset_watch_progress()` — Port of lines 1679-1693. + +16. `save_state()` — Port of lines 1084-1088. + +**Helper methods:** +- `_sorted_default(relpaths)` — natural sort +- `_apply_saved_order(all_fids, fid_to_rel, order_fids)` — port of lines 902-931 +- `_folder_stats(fids, state)` — port of lines 1090-1138 +- `_tree_flags(rels)` — port of lines 1140-1191 +- `_basic_file_meta(fid)` — port of lines 1193-1209 +- `_auto_subtitle_sidecar(video_path)` — delegates to `subtitles::auto_subtitle_sidecar` + +**Background duration scanner:** + +Port of `_duration_scan_worker` (lines 1952-1991). In Rust, this runs as a `tokio::spawn` task. It needs a way to communicate scanned durations back to the Library state. Use an `Arc>` pattern, or use channels to send updates back. + +Implementation approach: The scan function takes an `Arc>` and the file list, iterates through files, calls `ffmpeg::duration_seconds` for each, and updates the state directly under the lock. Same as the Python threading pattern. + +**Step 1: Write library.rs — structs + set_root + save_state** + +Start with the core: `Library::new()`, `set_root()`, `save_state()`, and the helper functions it depends on (`_sorted_default`, `_apply_saved_order`). + +**Step 2: Write library.rs — get_library_info + helpers** + +Add `get_library_info()`, `_folder_stats()`, `_tree_flags()`, `_basic_file_meta()`. + +**Step 3: Write library.rs — progress tracking methods** + +Add `update_progress()`, `set_current()`, `set_folder_volume()`, `set_folder_autoplay()`, `set_folder_rate()`, `set_order()`. + +**Step 4: Write library.rs — notes + metadata + subtitles** + +Add `get_note()`, `set_note()`, `get_current_video_metadata()`, all subtitle methods, `reset_watch_progress()`. + +**Step 5: Write library.rs — background duration scanner** + +Implement as an async function that can be spawned with `tokio::spawn`. + +**Step 6: Write unit tests** + +Test `_tree_flags`, `_apply_saved_order`, `_folder_stats` with mock data. Test `set_root` with a temp directory containing dummy files (can't test video-specific features without real videos). + +**Step 7: Run tests, commit** + +``` +feat: implement library.rs — full library management with progress tracking +``` + +--- + +### Task 10: Implement video_protocol.rs + +**Files:** +- Create: `src-tauri/src/video_protocol.rs` +- Modify: `src-tauri/src/main.rs` (add `mod video_protocol;`) + +**Port from:** `tutorial.py` lines 1995-2118 (Flask routes: `_send_file_range`, video, subtitle, fonts routes) + +**Function:** +- `register_protocol(builder: tauri::Builder) -> tauri::Builder` — register `tutdock` custom protocol with Tauri + +The protocol handler function receives a `tauri::http::Request` and returns a `tauri::http::Response`. It must: + +1. Parse the URL path from the request +2. Route to the appropriate handler: + - `/video/{index}` → read file from Library, serve with range support + - `/sub/{libid}/{fid}` → read VTT from state/subtitles/, serve as `text/vtt` + - `/fonts.css` → serve fonts CSS file + - `/fonts/{filename}` → serve font file + - `/fa.css` → serve Font Awesome CSS + - `/fa/webfonts/{filename}` → serve FA webfont file +3. For video: parse `Range` header, return 206 with `Content-Range` or 200 with full body + +**Key detail about Tauri custom protocols:** In Tauri v2, custom protocols are registered via `tauri::Builder::register_asynchronous_uri_scheme_protocol()`. The handler receives the full request and must return a response. Unlike Flask streaming, we read file chunks into a `Vec` body (Tauri protocol responses don't support streaming generators). For large video files, we serve the requested range only (typically a few MB at a time due to browser range requests), so memory usage stays bounded. + +**MIME type mapping** (same as Python lines 2081-2093): +```rust +fn mime_for_video(ext: &str) -> &str { + match ext { + ".mp4" | ".m4v" => "video/mp4", + ".webm" => "video/webm", + ".ogv" => "video/ogg", + ".mov" => "video/quicktime", + ".mkv" => "video/x-matroska", + ".avi" => "video/x-msvideo", + ".mpeg" | ".mpg" => "video/mpeg", + ".m2ts" | ".mts" => "video/mp2t", + _ => "application/octet-stream", + } +} +``` + +**Step 1: Write video_protocol.rs — protocol registration + URL routing** + +**Step 2: Write video_protocol.rs — range request handling for video** + +Port the range parsing logic from `_send_file_range`. Read only the requested byte range from disk. + +**Step 3: Write video_protocol.rs — subtitle, fonts, FA handlers** + +Simple file-serving handlers with appropriate MIME types and path traversal prevention. + +**Step 4: Commit** + +``` +feat: implement video_protocol.rs — tutdock:// custom protocol with range requests +``` + +--- + +### Task 11: Implement commands.rs + +**Files:** +- Create: `src-tauri/src/commands.rs` +- Modify: `src-tauri/src/main.rs` (add `mod commands;`) + +**Port from:** `tutorial.py` lines 2125-2276 (API class) + +All 25 commands as thin `#[tauri::command]` wrappers. Each acquires state locks and delegates to the appropriate module. + +**Commands list with signatures:** + +```rust +#[tauri::command] +async fn select_folder( + library: State<'_, Mutex>, + paths: State<'_, AppPaths>, + app: AppHandle, +) -> Result + +#[tauri::command] +fn open_folder_path(folder: String, library: State>, paths: State) -> Result + +#[tauri::command] +fn get_recents(paths: State) -> Result + +#[tauri::command] +fn remove_recent(path: String, paths: State) -> Result + +#[tauri::command] +fn get_library(library: State>) -> Result + +#[tauri::command] +fn set_current(index: usize, timecode: f64, library: State>) -> Result + +#[tauri::command] +fn tick_progress(index: usize, current_time: f64, duration: Option, playing: bool, library: State>) -> Result + +#[tauri::command] +fn set_folder_volume(volume: f64, library: State>) -> Result + +#[tauri::command] +fn set_folder_autoplay(enabled: bool, library: State>) -> Result + +#[tauri::command] +fn set_folder_rate(rate: f64, library: State>) -> Result + +#[tauri::command] +fn set_order(fids: Vec, library: State>) -> Result + +#[tauri::command] +fn start_duration_scan(library: State>) -> Result + +#[tauri::command] +fn get_prefs(prefs: State>) -> Result + +#[tauri::command] +fn set_prefs(patch: serde_json::Value, prefs: State>, paths: State) -> Result + +#[tauri::command] +fn set_always_on_top(enabled: bool, prefs: State>, paths: State, app: AppHandle) -> Result + +#[tauri::command] +fn save_window_state(prefs: State>, paths: State, app: AppHandle) -> Result + +#[tauri::command] +fn get_note(fid: String, library: State>) -> Result + +#[tauri::command] +fn set_note(fid: String, note: String, library: State>) -> Result + +#[tauri::command] +fn get_current_video_meta(library: State>) -> Result + +#[tauri::command] +fn get_current_subtitle(library: State>, paths: State) -> Result + +#[tauri::command] +fn get_embedded_subtitles(library: State>) -> Result + +#[tauri::command] +fn extract_embedded_subtitle(track_index: u32, library: State>, paths: State) -> Result + +#[tauri::command] +fn get_available_subtitles(library: State>) -> Result + +#[tauri::command] +fn load_sidecar_subtitle(file_path: String, library: State>, paths: State) -> Result + +#[tauri::command] +async fn choose_subtitle_file(library: State<'_, Mutex>, paths: State<'_, AppPaths>, app: AppHandle) -> Result +``` + +**Special commands:** +- `select_folder` uses `tauri_plugin_dialog::FileDialogBuilder::new().pick_folder()` for native dialog +- `choose_subtitle_file` uses `FileDialogBuilder::new().add_filter("Subtitles", &["srt", "vtt"]).pick_file()` +- `set_always_on_top` calls `app.get_webview_window("main").unwrap().set_always_on_top(enabled)` +- `save_window_state` reads position/size from the window object via `window.outer_position()` and `window.outer_size()` + +**Step 1: Write commands.rs — all 25 commands** + +**Step 2: Commit** + +``` +feat: implement commands.rs — all 25 Tauri commands +``` + +--- + +### Task 12: Wire up main.rs + +**Files:** +- Modify: `src-tauri/src/main.rs` + +**Port from:** `tutorial.py` lines 5231-5282 (`main` function) + +**Final main.rs structure:** + +```rust +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +mod commands; +mod ffmpeg; +mod fonts; +mod library; +mod prefs; +mod recents; +mod state; +mod subtitles; +mod utils; +mod video_protocol; + +use std::path::PathBuf; +use std::sync::Mutex; +use tauri::Manager; + +pub struct AppPaths { + pub exe_dir: PathBuf, + pub state_dir: PathBuf, + pub fonts_dir: PathBuf, + pub fa_dir: PathBuf, + pub subs_dir: PathBuf, + pub webview_profile_dir: PathBuf, +} + +fn main() { + // 1. Resolve exe directory for portability + let exe_dir = std::env::current_exe() + .expect("cannot resolve exe path") + .parent() + .expect("exe has no parent") + .to_path_buf(); + + let state_dir = exe_dir.join("state"); + + // Create all subdirectories + let paths = AppPaths { + exe_dir: exe_dir.clone(), + state_dir: state_dir.clone(), + fonts_dir: state_dir.join("fonts"), + fa_dir: state_dir.join("fontawesome"), + subs_dir: state_dir.join("subtitles"), + webview_profile_dir: state_dir.join("webview_profile"), + }; + std::fs::create_dir_all(&paths.fonts_dir).ok(); + std::fs::create_dir_all(&paths.fa_dir.join("webfonts")).ok(); + std::fs::create_dir_all(&paths.subs_dir).ok(); + std::fs::create_dir_all(&paths.webview_profile_dir).ok(); + + // 2. Set WebView2 user data folder for portability + std::env::set_var("WEBVIEW2_USER_DATA_FOLDER", &paths.webview_profile_dir); + + // 3. Load preferences + let prefs = prefs::Prefs::load(&state_dir); + + // 4. Initialize library (empty — will be loaded from frontend or from last_folder_path) + let mut lib = library::Library::new(); + + // Discover ffmpeg + let ff_paths = ffmpeg::discover(&paths.exe_dir, &paths.state_dir); + lib.ffprobe = ff_paths.ffprobe; + lib.ffmpeg = ff_paths.ffmpeg; + + // Restore last folder if exists + if let Some(ref last_path) = prefs.last_folder_path { + if std::path::Path::new(last_path).is_dir() { + let _ = lib.set_root(last_path, &state_dir); + } + } + + // 5. Build Tauri app + let builder = tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .manage(Mutex::new(lib)) + .manage(Mutex::new(prefs)) + .manage(paths) + .invoke_handler(tauri::generate_handler![ + commands::select_folder, + commands::open_folder_path, + commands::get_recents, + commands::remove_recent, + commands::get_library, + commands::set_current, + commands::tick_progress, + commands::set_folder_volume, + commands::set_folder_autoplay, + commands::set_folder_rate, + commands::set_order, + commands::start_duration_scan, + commands::get_prefs, + commands::set_prefs, + commands::set_always_on_top, + commands::save_window_state, + commands::get_note, + commands::set_note, + commands::get_current_video_meta, + commands::get_current_subtitle, + commands::get_embedded_subtitles, + commands::extract_embedded_subtitle, + commands::get_available_subtitles, + commands::load_sidecar_subtitle, + commands::choose_subtitle_file, + ]); + + // Register custom protocol + let builder = video_protocol::register_protocol(builder); + + // Configure window from saved prefs + builder + .setup(|app| { + let prefs = app.state::>(); + let p = prefs.lock().unwrap(); + let win = app.get_webview_window("main").unwrap(); + + if let Some(x) = p.window.x { + if let Some(y) = p.window.y { + let _ = win.set_position(tauri::Position::Physical( + tauri::PhysicalPosition::new(x, y) + )); + } + } + let _ = win.set_size(tauri::Size::Physical( + tauri::PhysicalSize::new(p.window.width as u32, p.window.height as u32) + )); + let _ = win.set_always_on_top(p.always_on_top); + + // Spawn async font caching (silent, non-blocking) + let paths = app.state::(); + let fonts_dir = paths.fonts_dir.clone(); + let fa_dir = paths.fa_dir.clone(); + tokio::spawn(async move { + let _ = fonts::ensure_google_fonts_local(&fonts_dir).await; + let _ = fonts::ensure_fontawesome_local(&fa_dir).await; + }); + + // If ffmpeg not found, emit event to frontend for download UI + let ff_check = { + let lib = app.state::>(); + let l = lib.lock().unwrap(); + l.ffprobe.is_none() && l.ffmpeg.is_none() + }; + if ff_check { + let handle = app.handle().clone(); + let sd = paths.state_dir.clone(); + tokio::spawn(async move { + let _ = handle.emit("ffmpeg-download-needed", ()); + // Download and emit progress + // After download, emit ffmpeg-download-complete + }); + } + + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +**Step 1: Write final main.rs** + +**Step 2: Verify Rust compiles** + +Run: `cd src-tauri && cargo check` +Expected: No errors. + +**Step 3: Commit** + +``` +feat: wire up main.rs — Tauri bootstrap with all modules +``` + +--- + +## Phase 3: Frontend — CSS Extraction + +### Task 13: Extract and split CSS + +**Files:** +- Create: `src/styles/main.css` +- Create: `src/styles/player.css` +- Create: `src/styles/playlist.css` +- Create: `src/styles/panels.css` +- Create: `src/styles/components.css` +- Create: `src/styles/animations.css` + +**Port from:** `tutorial.py` lines 2287-3747 (the ` + + +
+
+
+
+ +
+
TutorialDock
+
Watch local tutorials, resume instantly, and actually finish them.
+
+
+ +
+
+ + + +
+ +
+ +
+
+ + 100% + +
+
+ +
+ +
+
+ + +
+
+ +
+ +
+ + +
+
+
+ + + +
+
+
+
+
No video loaded
+
-
+
+ +
+
Overall
+
+
-
+
+
+ +
+ +
+
+ +
+
+
+ +
+
+
+ + + + + + +
+
+
00:00 / 00:00
+
+
+ +
+
+ + +
+ +
+ +
+
+
+
+ +
+
100%
+
+ +
+ + + + + + + + +
+ + +
+
+ + +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+
Notes
+
+
+ +
Saved
+
+
+
+ +
+
+
+ +
+
+
+
Info
+
+
+
+
Folder
-
+
Next up
-
+
Structure
-
+
+ +
+
Title
-
+
Relpath
-
+
Position
-
+
+ +
+
File
-
+
Video
-
+
Audio
-
+
Subtitles
-
+
+ +
+
Finished
-
+
Remaining
-
+
ETA
-
+
+ +
+
Volume
-
+
Speed
-
+
Durations
-
+
+ +
+
Top folders
-
+
+
+
+
+
+ +
+ +
+
+
+ +
+
+
Playlist
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
-
+
+
+ +
+ + + + +""" + +# ============================================================================= +# Application Entry Point +# ============================================================================= + + +def main() -> None: + """Initialize and run the TutorialDock application.""" + # Download and cache fonts for offline use (fail silently) + try: + ensure_google_fonts_local() + except Exception: + pass + try: + ensure_fontawesome_local() + except Exception: + pass + + # Restore last opened folder if it exists + prefs = PREFS.get() + last_path = prefs.get("last_folder_path") + if isinstance(last_path, str) and last_path.strip(): + try: + if Path(last_path).expanduser().exists(): + LIB.set_root(last_path) + except Exception: + pass + + # Start Flask server on a free port + port = pick_free_port(HOST) + flask_thread = threading.Thread(target=run_flask, args=(port,), daemon=True) + flask_thread.start() + + # Get window dimensions from saved preferences + window_prefs = prefs.get("window", {}) if isinstance(prefs.get("window"), dict) else {} + width = int(window_prefs.get("width") or 1320) + height = int(window_prefs.get("height") or 860) + x = window_prefs.get("x") + y = window_prefs.get("y") + on_top = bool(prefs.get("always_on_top", False)) + + # Create and start the webview window + api = API() + webview.create_window( + "TutorialDock", + url=f"http://{HOST}:{port}/", + width=width, + height=height, + x=x if isinstance(x, int) else None, + y=y if isinstance(y, int) else None, + on_top=on_top, + js_api=api, + ) + webview.start(debug=False) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tutorials.bat b/tutorials.bat new file mode 100644 index 0000000..e4b096c --- /dev/null +++ b/tutorials.bat @@ -0,0 +1,6 @@ +@echo off +set SCRIPT_DIR=%~dp0 +pushd "%SCRIPT_DIR%" +start "" /B pythonw "tutorial.py" +popd +exit \ No newline at end of file