From 9c8d7d94cddb9e5290f493f8ea3d1dd4506ab824 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 19 Feb 2026 12:44:57 +0200 Subject: [PATCH] TutorialVault: complete Tauri v2 port with runtime fixes Rename from TutorialDock to TutorialVault. Remove legacy Python app and scripts. Fix video playback, subtitles, metadata display, window state persistence, and auto-download of ffmpeg/ffprobe on first run. Bundle fonts via npm instead of runtime download. --- .gitignore | 9 - .../2026-02-19-tauri-conversion-design.md | 244 - .../plans/2026-02-19-tauri-conversion-plan.md | 1703 ----- package-lock.json | 1273 ++++ package.json | 6 +- scripts/rename_folder.py | 33 - src-tauri/Cargo.lock | 5622 +++++++++++++++++ src-tauri/Cargo.toml | 6 +- src-tauri/capabilities/default.json | 2 +- src-tauri/gen/schemas/acl-manifests.json | 1 + src-tauri/gen/schemas/capabilities.json | 1 + src-tauri/gen/schemas/desktop-schema.json | 2310 +++++++ src-tauri/gen/schemas/windows-schema.json | 2310 +++++++ src-tauri/src/library.rs | 98 +- src-tauri/src/main.rs | 43 +- src-tauri/src/video_protocol.rs | 29 +- src-tauri/tauri.conf.json | 12 +- src/api.ts | 6 +- src/index.html | 8 +- src/main.ts | 13 + src/player.ts | 2 +- src/types.ts | 2 +- src/ui.ts | 8 +- tutorial.py | 5282 ---------------- tutorials.bat | 6 - 25 files changed, 11665 insertions(+), 7364 deletions(-) delete mode 100644 .gitignore delete mode 100644 docs/plans/2026-02-19-tauri-conversion-design.md delete mode 100644 docs/plans/2026-02-19-tauri-conversion-plan.md create mode 100644 package-lock.json delete mode 100644 scripts/rename_folder.py create mode 100644 src-tauri/Cargo.lock create mode 100644 src-tauri/gen/schemas/acl-manifests.json create mode 100644 src-tauri/gen/schemas/capabilities.json create mode 100644 src-tauri/gen/schemas/desktop-schema.json create mode 100644 src-tauri/gen/schemas/windows-schema.json delete mode 100644 tutorial.py delete mode 100644 tutorials.bat diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 0f1ecab..0000000 --- a/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -node_modules/ -dist/ -src-tauri/target/ -__pycache__/ -*.pyc -.claude/ -ffmpeg.exe -ffprobe.exe -state/ diff --git a/docs/plans/2026-02-19-tauri-conversion-design.md b/docs/plans/2026-02-19-tauri-conversion-design.md deleted file mode 100644 index 719ee2e..0000000 --- a/docs/plans/2026-02-19-tauri-conversion-design.md +++ /dev/null @@ -1,244 +0,0 @@ -# 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 deleted file mode 100644 index f0882ca..0000000 --- a/docs/plans/2026-02-19-tauri-conversion-plan.md +++ /dev/null @@ -1,1703 +0,0 @@ -# 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 deleted file mode 100644 index e4b096c..0000000 --- a/tutorials.bat +++ /dev/null @@ -1,6 +0,0 @@ -@echo off -set SCRIPT_DIR=%~dp0 -pushd "%SCRIPT_DIR%" -start "" /B pythonw "tutorial.py" -popd -exit \ No newline at end of file