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.
This commit is contained in:
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,9 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
src-tauri/target/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.claude/
|
||||
ffmpeg.exe
|
||||
ffprobe.exe
|
||||
state/
|
||||
@@ -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<Library>` — video library + per-folder state
|
||||
- `Mutex<Prefs>` — 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
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
1273
package-lock.json
generated
Normal file
1273
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "tutorialdock",
|
||||
"name": "tutorialvault",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
@@ -9,6 +9,10 @@
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/manrope": "^5.2.8",
|
||||
"@fontsource/sora": "^5.2.8",
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
"@tauri-apps/api": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import os
|
||||
|
||||
|
||||
def rename_folder():
|
||||
# Get the current working directory
|
||||
current_dir = os.getcwd()
|
||||
|
||||
print(f"Current directory: {current_dir}")
|
||||
|
||||
# Prompt user for the current folder name and new folder name
|
||||
current_name = input("Enter the current folder name to rename: ")
|
||||
new_name = input("Enter the new folder name: ")
|
||||
|
||||
# Construct full paths
|
||||
current_path = os.path.join(current_dir, current_name)
|
||||
new_path = os.path.join(current_dir, new_name)
|
||||
|
||||
try:
|
||||
# Rename the folder
|
||||
os.rename(current_path, new_path)
|
||||
print(f"Folder '{current_name}' renamed to '{new_name}' successfully.")
|
||||
except FileNotFoundError:
|
||||
print(f"Error: Folder '{current_name}' does not exist in {current_dir}.")
|
||||
except PermissionError:
|
||||
print(
|
||||
"Error: Permission denied. Try running the script with administrator privileges."
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"An unexpected error occurred: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
rename_folder()
|
||||
5622
src-tauri/Cargo.lock
generated
Normal file
5622
src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
||||
[package]
|
||||
name = "tutorialdock"
|
||||
name = "tutorialvault"
|
||||
version = "0.1.0"
|
||||
description = "TutorialDock - Video Tutorial Library Manager"
|
||||
description = "TutorialVault - Video Tutorial Library Manager"
|
||||
authors = []
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "tutorialdock_lib"
|
||||
name = "tutorialvault_lib"
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for TutorialDock",
|
||||
"description": "Default capabilities for TutorialVault",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
|
||||
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
src-tauri/gen/schemas/capabilities.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for TutorialVault","local":true,"windows":["main"],"permissions":["core:default","dialog:default"]}}
|
||||
2310
src-tauri/gen/schemas/desktop-schema.json
Normal file
2310
src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2310
src-tauri/gen/schemas/windows-schema.json
Normal file
2310
src-tauri/gen/schemas/windows-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -519,7 +519,11 @@ impl Library {
|
||||
let folder = self
|
||||
.root
|
||||
.as_ref()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.map(|p| {
|
||||
let s = p.to_string_lossy().to_string();
|
||||
// Strip Windows extended-length path prefix.
|
||||
s.strip_prefix("\\\\?\\").unwrap_or(&s).to_string()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let count = self.fids.len();
|
||||
@@ -623,6 +627,14 @@ impl Library {
|
||||
|
||||
/// Find the next unfinished video index after the current position.
|
||||
fn _compute_next_up(&self, current_index: Option<usize>) -> Value {
|
||||
let make_result = |i: usize| -> Value {
|
||||
let fid = &self.fids[i];
|
||||
let title = self.fid_to_rel.get(fid)
|
||||
.map(|r| pretty_title_from_filename(r))
|
||||
.unwrap_or_default();
|
||||
json!({"index": i, "title": title})
|
||||
};
|
||||
|
||||
let start = current_index.map(|i| i + 1).unwrap_or(0);
|
||||
for i in start..self.fids.len() {
|
||||
let fid = &self.fids[i];
|
||||
@@ -633,7 +645,7 @@ impl Library {
|
||||
.map(|m| m.finished)
|
||||
.unwrap_or(false);
|
||||
if !finished {
|
||||
return json!(i);
|
||||
return make_result(i);
|
||||
}
|
||||
}
|
||||
// Wrap around from beginning.
|
||||
@@ -647,7 +659,7 @@ impl Library {
|
||||
.map(|m| m.finished)
|
||||
.unwrap_or(false);
|
||||
if !finished {
|
||||
return json!(i);
|
||||
return make_result(i);
|
||||
}
|
||||
}
|
||||
Value::Null
|
||||
@@ -914,7 +926,7 @@ impl Library {
|
||||
None => return json!({"ok": false, "error": "current fid not in library"}),
|
||||
};
|
||||
|
||||
let mut result = self._basic_file_meta(&fid);
|
||||
let basic = self._basic_file_meta(&fid);
|
||||
|
||||
// Probe if not cached.
|
||||
if !self.meta_cache.contains_key(&fid) {
|
||||
@@ -929,6 +941,12 @@ impl Library {
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = json!({
|
||||
"ok": true,
|
||||
"basic": basic,
|
||||
"ffprobe_found": self.ffprobe.is_some(),
|
||||
});
|
||||
|
||||
if let Some(cached) = self.meta_cache.get(&fid) {
|
||||
if let Ok(meta_val) = serde_json::to_value(cached) {
|
||||
result
|
||||
@@ -938,10 +956,6 @@ impl Library {
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("ok".to_string(), json!(true));
|
||||
result
|
||||
}
|
||||
|
||||
@@ -949,6 +963,23 @@ impl Library {
|
||||
// Subtitle methods
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Build a protocol URL for a stored subtitle VTT path.
|
||||
/// The `vtt` field is like `"subtitles/{fid}_{name}.vtt"` — extract the filename.
|
||||
fn _sub_url(vtt: &str) -> String {
|
||||
let filename = vtt.rsplit('/').next().unwrap_or(vtt);
|
||||
format!("http://tutdock.localhost/sub/{}", filename)
|
||||
}
|
||||
|
||||
/// Build a successful subtitle JSON response with `has`, `url`, and `label`.
|
||||
fn _sub_response(vtt: &str, label: &str) -> Value {
|
||||
json!({
|
||||
"ok": true,
|
||||
"has": true,
|
||||
"url": Self::_sub_url(vtt),
|
||||
"label": label,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get subtitle for the current video.
|
||||
///
|
||||
/// Priority: stored subtitle -> sidecar -> embedded.
|
||||
@@ -968,12 +999,7 @@ impl Library {
|
||||
if let Some(ref sub_ref) = meta.subtitle {
|
||||
let vtt_path = state_dir.join(&sub_ref.vtt);
|
||||
if vtt_path.exists() {
|
||||
return json!({
|
||||
"ok": true,
|
||||
"source": "stored",
|
||||
"vtt": sub_ref.vtt,
|
||||
"label": sub_ref.label,
|
||||
});
|
||||
return Self::_sub_response(&sub_ref.vtt, &sub_ref.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -993,12 +1019,7 @@ impl Library {
|
||||
});
|
||||
}
|
||||
self.save_state();
|
||||
return json!({
|
||||
"ok": true,
|
||||
"source": "sidecar",
|
||||
"vtt": stored.vtt,
|
||||
"label": stored.label,
|
||||
});
|
||||
return Self::_sub_response(&stored.vtt, &stored.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1026,12 +1047,7 @@ impl Library {
|
||||
});
|
||||
}
|
||||
self.save_state();
|
||||
return json!({
|
||||
"ok": true,
|
||||
"source": "embedded",
|
||||
"vtt": stored.vtt,
|
||||
"label": stored.label,
|
||||
});
|
||||
return Self::_sub_response(&stored.vtt, &stored.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1039,7 +1055,7 @@ impl Library {
|
||||
}
|
||||
}
|
||||
|
||||
json!({"ok": true, "source": "none"})
|
||||
json!({"ok": true, "has": false})
|
||||
}
|
||||
|
||||
/// Store a user-selected subtitle file for the current video.
|
||||
@@ -1064,11 +1080,7 @@ impl Library {
|
||||
});
|
||||
}
|
||||
self.save_state();
|
||||
json!({
|
||||
"ok": true,
|
||||
"vtt": stored.vtt,
|
||||
"label": stored.label,
|
||||
})
|
||||
Self::_sub_response(&stored.vtt, &stored.label)
|
||||
}
|
||||
None => {
|
||||
json!({"ok": false, "error": "unsupported subtitle format"})
|
||||
@@ -1154,11 +1166,7 @@ impl Library {
|
||||
});
|
||||
}
|
||||
self.save_state();
|
||||
json!({
|
||||
"ok": true,
|
||||
"vtt": stored.vtt,
|
||||
"label": stored.label,
|
||||
})
|
||||
Self::_sub_response(&stored.vtt, &stored.label)
|
||||
}
|
||||
Err(e) => {
|
||||
json!({"ok": false, "error": e})
|
||||
@@ -1288,10 +1296,15 @@ impl Library {
|
||||
});
|
||||
|
||||
for (label, path, _) in &found {
|
||||
let ext = std::path::Path::new(path)
|
||||
.extension()
|
||||
.map(|e| e.to_string_lossy().to_uppercase())
|
||||
.unwrap_or_default();
|
||||
sidecar_subs.push(json!({
|
||||
"type": "sidecar",
|
||||
"label": label,
|
||||
"path": path,
|
||||
"format": ext,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1358,11 +1371,7 @@ impl Library {
|
||||
});
|
||||
}
|
||||
self.save_state();
|
||||
json!({
|
||||
"ok": true,
|
||||
"vtt": stored.vtt,
|
||||
"label": stored.label,
|
||||
})
|
||||
Self::_sub_response(&stored.vtt, &stored.label)
|
||||
}
|
||||
None => {
|
||||
json!({"ok": false, "error": "unsupported subtitle format or read error"})
|
||||
@@ -1572,7 +1581,10 @@ impl Library {
|
||||
|
||||
let folder = path
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.map(|p| {
|
||||
let s = p.to_string_lossy().to_string();
|
||||
s.strip_prefix("\\\\?\\").unwrap_or(&s).to_string()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
return json!({
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::sync::Mutex;
|
||||
use tauri::Manager;
|
||||
use tutorialdock_lib::{commands, ffmpeg, fonts, library, prefs, video_protocol, AppPaths};
|
||||
use tutorialvault_lib::{commands, ffmpeg, fonts, library, prefs, video_protocol, AppPaths};
|
||||
|
||||
fn main() {
|
||||
// 1. Resolve exe directory for portability.
|
||||
@@ -41,6 +41,7 @@ fn main() {
|
||||
|
||||
// Discover ffmpeg/ffprobe.
|
||||
let ff_paths = ffmpeg::discover(&paths.exe_dir, &paths.state_dir);
|
||||
let needs_ffmpeg_download = ff_paths.ffprobe.is_none() || ff_paths.ffmpeg.is_none();
|
||||
lib.ffprobe = ff_paths.ffprobe;
|
||||
lib.ffmpeg = ff_paths.ffmpeg;
|
||||
|
||||
@@ -91,22 +92,22 @@ fn main() {
|
||||
|
||||
// Configure window from saved prefs and launch.
|
||||
builder
|
||||
.setup(|app| {
|
||||
.setup(move |app| {
|
||||
let prefs_state = app.state::<Mutex<prefs::Prefs>>();
|
||||
let p = prefs_state.lock().unwrap();
|
||||
let win = app.get_webview_window("main").unwrap();
|
||||
|
||||
if let Some(x) = p.window.x {
|
||||
if let Some(y) = p.window.y {
|
||||
// Only restore position/size if values are sane (not minimized/offscreen).
|
||||
let w = p.window.width.max(640) as u32;
|
||||
let h = p.window.height.max(480) as u32;
|
||||
if let (Some(x), Some(y)) = (p.window.x, p.window.y) {
|
||||
if x > -10000 && y > -10000 && x < 10000 && y < 10000 {
|
||||
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_size(tauri::Size::Physical(tauri::PhysicalSize::new(w, h)));
|
||||
let _ = win.set_always_on_top(p.always_on_top);
|
||||
drop(p);
|
||||
|
||||
@@ -119,6 +120,32 @@ fn main() {
|
||||
let _ = fonts::ensure_fontawesome_local(&fa_dir).await;
|
||||
});
|
||||
|
||||
// Auto-download ffmpeg/ffprobe if not found locally.
|
||||
if needs_ffmpeg_download {
|
||||
let handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let sd = handle.state::<AppPaths>().state_dir.clone();
|
||||
let (tx, _rx) = tokio::sync::mpsc::channel(16);
|
||||
match ffmpeg::download_ffmpeg(&sd, tx).await {
|
||||
Ok(paths) => {
|
||||
let lib_state = handle.state::<Mutex<library::Library>>();
|
||||
if let Ok(mut lib) = lib_state.lock() {
|
||||
if lib.ffprobe.is_none() {
|
||||
lib.ffprobe = paths.ffprobe;
|
||||
}
|
||||
if lib.ffmpeg.is_none() {
|
||||
lib.ffmpeg = paths.ffmpeg;
|
||||
}
|
||||
}
|
||||
eprintln!("[ffmpeg] Auto-download complete");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[ffmpeg] Auto-download failed: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
|
||||
@@ -26,6 +26,9 @@ use crate::AppPaths;
|
||||
/// Maximum chunk size for full-file responses (4 MB).
|
||||
const MAX_CHUNK: u64 = 4 * 1024 * 1024;
|
||||
|
||||
/// CORS header name.
|
||||
const CORS: &str = "Access-Control-Allow-Origin";
|
||||
|
||||
/// Register the `tutdock` custom protocol on the builder.
|
||||
pub fn register_protocol(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||
builder.register_uri_scheme_protocol("tutdock", move |ctx, request| {
|
||||
@@ -140,6 +143,7 @@ fn serve_full(path: &PathBuf, file_size: u64, mime: &str) -> http::Response<Vec<
|
||||
.header(header::CONTENT_LENGTH, file_size.to_string())
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(CORS, "*")
|
||||
.body(buf)
|
||||
.unwrap_or_else(|_| internal_error("response build error"))
|
||||
}
|
||||
@@ -180,6 +184,7 @@ fn serve_range(
|
||||
.header(header::CONTENT_LENGTH, length.to_string())
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(CORS, "*")
|
||||
.body(buf)
|
||||
.unwrap_or_else(|_| internal_error("response build error"))
|
||||
}
|
||||
@@ -215,33 +220,36 @@ fn parse_range(header: &str, file_size: u64) -> Option<(u64, u64)> {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn handle_subtitle(app: &tauri::AppHandle, rest: &str) -> http::Response<Vec<u8>> {
|
||||
// Path: sub/{libid}/{fid}
|
||||
let parts: Vec<&str> = rest.splitn(2, '/').collect();
|
||||
if parts.len() != 2 {
|
||||
return not_found();
|
||||
}
|
||||
let fid = parts[1];
|
||||
// rest is a VTT filename like "{fid}_{name}.vtt"
|
||||
let filename = rest;
|
||||
|
||||
if !fid.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
// Validate: no path traversal
|
||||
if filename.contains("..") || filename.contains('/') || filename.contains('\\') || filename.is_empty() {
|
||||
return not_found();
|
||||
}
|
||||
|
||||
let paths = app.state::<AppPaths>();
|
||||
let vtt_path = paths.subs_dir.join(format!("{}.vtt", fid));
|
||||
let vtt_path = paths.subs_dir.join(filename);
|
||||
|
||||
if !vtt_path.exists() {
|
||||
// Ensure resolved path is still inside subs_dir
|
||||
if let (Ok(base), Ok(resolved)) = (paths.subs_dir.canonicalize(), vtt_path.canonicalize()) {
|
||||
if !resolved.starts_with(&base) {
|
||||
return not_found();
|
||||
}
|
||||
} else {
|
||||
return not_found();
|
||||
}
|
||||
|
||||
let data = match fs::read(&vtt_path) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return internal_error("read error"),
|
||||
Err(_) => return not_found(),
|
||||
};
|
||||
|
||||
http::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "text/vtt; charset=utf-8")
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(CORS, "*")
|
||||
.body(data)
|
||||
.unwrap_or_else(|_| internal_error("response build error"))
|
||||
}
|
||||
@@ -331,6 +339,7 @@ fn serve_static_file(base_dir: &PathBuf, filename: &str) -> http::Response<Vec<u
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, mime)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(CORS, "*")
|
||||
.body(data)
|
||||
.unwrap_or_else(|_| internal_error("response build error"))
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-utils/schema.json",
|
||||
"productName": "TutorialDock",
|
||||
"productName": "TutorialVault",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.tutorialdock.app",
|
||||
"identifier": "com.tutorialvault.app",
|
||||
"build": {
|
||||
"devUrl": "http://localhost:1420",
|
||||
"frontendDist": "../dist",
|
||||
@@ -12,7 +12,7 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "TutorialDock",
|
||||
"title": "TutorialVault",
|
||||
"width": 1320,
|
||||
"height": 860,
|
||||
"minWidth": 640,
|
||||
@@ -20,7 +20,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; media-src 'self' tutdock: asset: https://asset.localhost; font-src 'self' tutdock: asset: https://asset.localhost data:; style-src 'self' tutdock: asset: https://asset.localhost 'unsafe-inline'; img-src 'self' tutdock: asset: https://asset.localhost data:; script-src 'self'"
|
||||
"csp": "default-src 'self'; media-src 'self' http://tutdock.localhost; font-src 'self' data:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; script-src 'self'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
@@ -34,7 +34,5 @@
|
||||
"icons/icon.ico"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"dialog": {}
|
||||
}
|
||||
"plugins": {}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const api = {
|
||||
invoke<OkResponse>('set_current', { index, timecode }),
|
||||
|
||||
tickProgress: (index: number, currentTime: number, duration: number | null, playing: boolean) =>
|
||||
invoke<OkResponse>('tick_progress', { index, current_time: currentTime, duration, playing }),
|
||||
invoke<OkResponse>('tick_progress', { index, currentTime, duration, playing }),
|
||||
|
||||
setFolderVolume: (volume: number) =>
|
||||
invoke<OkResponse>('set_folder_volume', { volume }),
|
||||
@@ -76,13 +76,13 @@ export const api = {
|
||||
invoke<EmbeddedSubsResponse>('get_embedded_subtitles'),
|
||||
|
||||
extractEmbeddedSubtitle: (trackIndex: number) =>
|
||||
invoke<SubtitleResponse>('extract_embedded_subtitle', { track_index: trackIndex }),
|
||||
invoke<SubtitleResponse>('extract_embedded_subtitle', { trackIndex }),
|
||||
|
||||
getAvailableSubtitles: () =>
|
||||
invoke<AvailableSubsResponse>('get_available_subtitles'),
|
||||
|
||||
loadSidecarSubtitle: (filePath: string) =>
|
||||
invoke<SubtitleResponse>('load_sidecar_subtitle', { file_path: filePath }),
|
||||
invoke<SubtitleResponse>('load_sidecar_subtitle', { filePath }),
|
||||
|
||||
chooseSubtitleFile: () =>
|
||||
invoke<SubtitleResponse>('choose_subtitle_file'),
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>TutorialDock</title>
|
||||
<link rel="stylesheet" href="tutdock://localhost/fonts.css">
|
||||
<link rel="stylesheet" href="tutdock://localhost/fa.css">
|
||||
<title>TutorialVault</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="zoomRoot">
|
||||
@@ -14,7 +12,7 @@
|
||||
<div class="brand">
|
||||
<div class="appIcon" aria-hidden="true"><div class="appIconGlow"></div><i class="fa-solid fa-graduation-cap"></i></div>
|
||||
<div class="brandText">
|
||||
<div class="appName">TutorialDock</div>
|
||||
<div class="appName">TutorialVault</div>
|
||||
<div class="tagline">Watch local tutorials, resume instantly, and actually finish them.</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,7 +78,7 @@
|
||||
</div>
|
||||
|
||||
<div class="videoWrap">
|
||||
<video id="player" preload="metadata"></video>
|
||||
<video id="player" preload="metadata" crossorigin="anonymous"></video>
|
||||
<div class="videoOverlay" id="videoOverlay">
|
||||
<div class="overlayIcon" id="overlayIcon">
|
||||
<i class="fa-solid fa-play" id="overlayIconI"></i>
|
||||
|
||||
13
src/main.ts
13
src/main.ts
@@ -2,6 +2,19 @@
|
||||
* TutorialDock frontend — boot sequence, tick loop, global wiring.
|
||||
* Orchestrates all modules and holds cross-module callbacks.
|
||||
*/
|
||||
import '@fortawesome/fontawesome-free/css/all.min.css';
|
||||
import '@fontsource/sora/500.css';
|
||||
import '@fontsource/sora/600.css';
|
||||
import '@fontsource/sora/700.css';
|
||||
import '@fontsource/sora/800.css';
|
||||
import '@fontsource/manrope/400.css';
|
||||
import '@fontsource/manrope/500.css';
|
||||
import '@fontsource/manrope/600.css';
|
||||
import '@fontsource/manrope/700.css';
|
||||
import '@fontsource/manrope/800.css';
|
||||
import '@fontsource/ibm-plex-mono/400.css';
|
||||
import '@fontsource/ibm-plex-mono/500.css';
|
||||
import '@fontsource/ibm-plex-mono/600.css';
|
||||
import './styles/main.css';
|
||||
import './styles/player.css';
|
||||
import './styles/playlist.css';
|
||||
|
||||
@@ -296,7 +296,7 @@ export async function loadVideoSrc(
|
||||
const keepRate = clamp(Number(library.folder_rate ?? player.playbackRate ?? 1.0), 0.25, 3);
|
||||
|
||||
setSuppressTick(true);
|
||||
player.src = `tutdock://localhost/video/${idx}`;
|
||||
player.src = `http://tutdock.localhost/video/${idx}`;
|
||||
player.load();
|
||||
|
||||
player.onloadedmetadata = async () => {
|
||||
|
||||
@@ -46,7 +46,7 @@ export interface LibraryInfo {
|
||||
finished_count?: number;
|
||||
remaining_count?: number;
|
||||
remaining_seconds_known?: number | null;
|
||||
top_folders?: [string, number][];
|
||||
top_folders?: { name: string; total: number; finished: number }[];
|
||||
next_up?: NextUp | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -328,9 +328,9 @@ export function updateOverall(): void {
|
||||
if (library.overall_progress === null || library.overall_progress === undefined) {
|
||||
overallBar.style.width = '0%'; overallPct.textContent = '-'; return;
|
||||
}
|
||||
const p = clamp(library.overall_progress, 0, 1);
|
||||
overallBar.style.width = `${(p * 100).toFixed(1)}%`;
|
||||
overallPct.textContent = `${(p * 100).toFixed(1)}%`;
|
||||
const p = clamp(library.overall_progress, 0, 100);
|
||||
overallBar.style.width = `${p.toFixed(1)}%`;
|
||||
overallPct.textContent = `${p.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function updateInfoPanel(): void {
|
||||
@@ -350,7 +350,7 @@ export function updateInfoPanel(): void {
|
||||
infoRemaining.textContent = `${library.remaining_count ?? 0}`;
|
||||
infoEta.textContent = (library.remaining_seconds_known != null) ? fmtTime(library.remaining_seconds_known) : '-';
|
||||
infoKnown.textContent = `${library.durations_known || 0}/${library.count || 0}`;
|
||||
infoTop.textContent = (library.top_folders || []).map(([n, c]: [string, number]) => `${n}:${c}`).join(' \u2022 ') || '-';
|
||||
infoTop.textContent = (library.top_folders || []).map((f: any) => `${f.name}: ${f.finished}/${f.total}`).join(' \u2022 ') || '-';
|
||||
infoVolume.textContent = `${Math.round(clamp(Number(library.folder_volume ?? 1), 0, 1) * 100)}%`;
|
||||
infoSpeed.textContent = `${Number(library.folder_rate ?? 1).toFixed(2)}x`;
|
||||
} else {
|
||||
|
||||
5282
tutorial.py
5282
tutorial.py
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
||||
@echo off
|
||||
set SCRIPT_DIR=%~dp0
|
||||
pushd "%SCRIPT_DIR%"
|
||||
start "" /B pythonw "tutorial.py"
|
||||
popd
|
||||
exit
|
||||
Reference in New Issue
Block a user