diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs new file mode 100644 index 0000000..fb5df80 --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,487 @@ +//! Tauri v2 command handlers for TutorialDock. +//! +//! Each function is a thin `#[tauri::command]` wrapper that acquires the +//! relevant state lock(s) and delegates to the appropriate module. + +use serde_json::{json, Value}; +use std::path::Path; +use std::sync::atomic::Ordering; +use std::sync::Mutex; + +use tauri::Manager; +use tauri_plugin_dialog::DialogExt; + +use crate::ffmpeg; +use crate::library::Library; +use crate::prefs::Prefs; +use crate::recents; +use crate::AppPaths; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Extract a short display name from a folder path (last component). +fn display_name_for_path(p: &str) -> String { + Path::new(p) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| p.to_string()) +} + +// =========================================================================== +// 1. select_folder +// =========================================================================== + +#[tauri::command] +pub async fn select_folder( + app: tauri::AppHandle, + library: tauri::State<'_, Mutex>, + prefs: tauri::State<'_, Mutex>, + paths: tauri::State<'_, AppPaths>, +) -> Result { + let folder = app.dialog().file().blocking_pick_folder(); + + let folder_path = match folder { + Some(fp) => fp.as_path().unwrap().to_path_buf(), + None => return Ok(json!({"ok": false, "cancelled": true})), + }; + + let folder_str = folder_path.to_string_lossy().to_string(); + + let result = { + let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?; + lib.set_root(&folder_str, &paths.state_dir)? + }; + + recents::push_recent(&paths.state_dir, &folder_str); + + { + let mut p = prefs.lock().map_err(|e| format!("lock error: {}", e))?; + p.last_folder_path = Some(folder_str); + p.save(&paths.state_dir); + } + + Ok(result) +} + +// =========================================================================== +// 2. open_folder_path +// =========================================================================== + +#[tauri::command] +pub fn open_folder_path( + folder: String, + library: tauri::State<'_, Mutex>, + prefs: tauri::State<'_, Mutex>, + paths: tauri::State<'_, AppPaths>, +) -> Result { + let result = { + let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?; + lib.set_root(&folder, &paths.state_dir)? + }; + + recents::push_recent(&paths.state_dir, &folder); + + { + let mut p = prefs.lock().map_err(|e| format!("lock error: {}", e))?; + p.last_folder_path = Some(folder); + p.save(&paths.state_dir); + } + + Ok(result) +} + +// =========================================================================== +// 3. get_recents +// =========================================================================== + +#[tauri::command] +pub fn get_recents(paths: tauri::State<'_, AppPaths>) -> Result { + let all = recents::load_recents(&paths.state_dir); + + let items: Vec = all + .into_iter() + .filter(|p| Path::new(p).is_dir()) + .map(|p| { + let name = display_name_for_path(&p); + json!({"path": p, "name": name}) + }) + .collect(); + + Ok(json!({"ok": true, "items": items})) +} + +// =========================================================================== +// 4. remove_recent +// =========================================================================== + +#[tauri::command] +pub fn remove_recent(path: String, paths: tauri::State<'_, AppPaths>) -> Result { + recents::remove_recent(&paths.state_dir, &path); + Ok(json!({"ok": true})) +} + +// =========================================================================== +// 5. get_library +// =========================================================================== + +#[tauri::command] +pub fn get_library(library: tauri::State<'_, Mutex>) -> Result { + let lib = library.lock().map_err(|e| format!("lock error: {}", e))?; + Ok(lib.get_library_info()) +} + +// =========================================================================== +// 6. set_current +// =========================================================================== + +#[tauri::command] +pub fn set_current( + index: usize, + timecode: f64, + library: tauri::State<'_, Mutex>, +) -> Result { + let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?; + Ok(lib.set_current(index, timecode)) +} + +// =========================================================================== +// 7. tick_progress +// =========================================================================== + +#[tauri::command] +pub fn tick_progress( + index: usize, + current_time: f64, + duration: Option, + playing: bool, + library: tauri::State<'_, Mutex>, +) -> Result { + let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?; + Ok(lib.update_progress(index, current_time, duration, playing)) +} + +// =========================================================================== +// 8. set_folder_volume +// =========================================================================== + +#[tauri::command] +pub fn set_folder_volume( + volume: f64, + library: tauri::State<'_, Mutex>, +) -> Result { + let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?; + Ok(lib.set_folder_volume(volume)) +} + +// =========================================================================== +// 9. set_folder_autoplay +// =========================================================================== + +#[tauri::command] +pub fn set_folder_autoplay( + enabled: bool, + library: tauri::State<'_, Mutex>, +) -> Result { + let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?; + Ok(lib.set_folder_autoplay(enabled)) +} + +// =========================================================================== +// 10. set_folder_rate +// =========================================================================== + +#[tauri::command] +pub fn set_folder_rate( + rate: f64, + library: tauri::State<'_, Mutex>, +) -> Result { + let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?; + Ok(lib.set_folder_rate(rate)) +} + +// =========================================================================== +// 11. set_order +// =========================================================================== + +#[tauri::command] +pub fn set_order( + fids: Vec, + library: tauri::State<'_, Mutex>, +) -> Result { + let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?; + Ok(lib.set_order(fids)) +} + +// =========================================================================== +// 12. start_duration_scan +// =========================================================================== + +#[tauri::command] +pub fn start_duration_scan( + app: tauri::AppHandle, + library: tauri::State<'_, Mutex>, +) -> Result { + let (pending, stop_flag, ffprobe_path, ffmpeg_path) = { + let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?; + lib.start_duration_scan(); + let pending = lib.get_pending_scans(); + let stop_flag = lib.scan_stop_flag(); + let ffprobe_path = lib.ffprobe.clone(); + let ffmpeg_path = lib.ffmpeg.clone(); + (pending, stop_flag, ffprobe_path, ffmpeg_path) + }; + + let count = pending.len(); + if count == 0 { + return Ok(json!({"ok": true, "pending": 0})); + } + + let handle = app.clone(); + + std::thread::spawn(move || { + let ff_paths = ffmpeg::FfmpegPaths { + ffprobe: ffprobe_path, + ffmpeg: ffmpeg_path, + }; + + let library_state = handle.state::>(); + + for (fid, file_path) in &pending { + if stop_flag.load(Ordering::SeqCst) { + break; + } + if let Some(dur) = ffmpeg::duration_seconds(file_path, &ff_paths) { + if let Ok(mut lib) = library_state.lock() { + lib.apply_scanned_duration(fid, dur); + } + } + } + }); + + Ok(json!({"ok": true, "pending": count})) +} + +// =========================================================================== +// 13. get_prefs +// =========================================================================== + +#[tauri::command] +pub fn get_prefs(prefs: tauri::State<'_, Mutex>) -> Result { + let p = prefs.lock().map_err(|e| format!("lock error: {}", e))?; + Ok(json!({"ok": true, "prefs": serde_json::to_value(&*p).unwrap_or(json!({}))})) +} + +// =========================================================================== +// 14. set_prefs +// =========================================================================== + +#[tauri::command] +pub fn set_prefs( + patch: Value, + prefs: tauri::State<'_, Mutex>, + paths: tauri::State<'_, AppPaths>, +) -> Result { + let mut p = prefs.lock().map_err(|e| format!("lock error: {}", e))?; + p.update(&patch, &paths.state_dir); + Ok(json!({"ok": true})) +} + +// =========================================================================== +// 15. set_always_on_top +// =========================================================================== + +#[tauri::command] +pub fn set_always_on_top( + enabled: bool, + app: tauri::AppHandle, + prefs: tauri::State<'_, Mutex>, + paths: tauri::State<'_, AppPaths>, +) -> Result { + { + let mut p = prefs.lock().map_err(|e| format!("lock error: {}", e))?; + p.always_on_top = enabled; + p.save(&paths.state_dir); + } + + if let Some(window) = app.get_webview_window("main") { + window + .set_always_on_top(enabled) + .map_err(|e| format!("set_always_on_top failed: {}", e))?; + } + + Ok(json!({"ok": true})) +} + +// =========================================================================== +// 16. save_window_state +// =========================================================================== + +#[tauri::command] +pub fn save_window_state( + app: tauri::AppHandle, + prefs: tauri::State<'_, Mutex>, + paths: tauri::State<'_, AppPaths>, +) -> Result { + let window = app + .get_webview_window("main") + .ok_or_else(|| "main window not found".to_string())?; + + let pos = window + .outer_position() + .map_err(|e| format!("outer_position failed: {}", e))?; + let size = window + .outer_size() + .map_err(|e| format!("outer_size failed: {}", e))?; + + let mut p = prefs.lock().map_err(|e| format!("lock error: {}", e))?; + p.window.x = Some(pos.x); + p.window.y = Some(pos.y); + p.window.width = size.width as i32; + p.window.height = size.height as i32; + p.save(&paths.state_dir); + + Ok(json!({"ok": true})) +} + +// =========================================================================== +// 17. get_note +// =========================================================================== + +#[tauri::command] +pub fn get_note(fid: String, library: tauri::State<'_, Mutex>) -> Result { + let lib = library.lock().map_err(|e| format!("lock error: {}", e))?; + let note = lib.get_note(&fid); + Ok(json!({"ok": true, "note": note})) +} + +// =========================================================================== +// 18. set_note +// =========================================================================== + +#[tauri::command] +pub fn set_note( + fid: String, + note: String, + library: tauri::State<'_, Mutex>, +) -> Result { + let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?; + Ok(lib.set_note(&fid, ¬e)) +} + +// =========================================================================== +// 19. get_current_video_meta +// =========================================================================== + +#[tauri::command] +pub fn get_current_video_meta( + library: tauri::State<'_, Mutex>, +) -> Result { + let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?; + Ok(lib.get_current_video_metadata()) +} + +// =========================================================================== +// 20. get_current_subtitle +// =========================================================================== + +#[tauri::command] +pub fn get_current_subtitle( + library: tauri::State<'_, Mutex>, + paths: tauri::State<'_, AppPaths>, +) -> Result { + let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?; + Ok(lib.get_subtitle_for_current(&paths.state_dir)) +} + +// =========================================================================== +// 21. get_embedded_subtitles +// =========================================================================== + +#[tauri::command] +pub fn get_embedded_subtitles( + library: tauri::State<'_, Mutex>, +) -> Result { + let lib = library.lock().map_err(|e| format!("lock error: {}", e))?; + Ok(lib.get_embedded_subtitles()) +} + +// =========================================================================== +// 22. extract_embedded_subtitle +// =========================================================================== + +#[tauri::command] +pub fn extract_embedded_subtitle( + track_index: u32, + library: tauri::State<'_, Mutex>, + paths: tauri::State<'_, AppPaths>, +) -> Result { + let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?; + Ok(lib.extract_embedded_subtitle(track_index, &paths.state_dir)) +} + +// =========================================================================== +// 23. get_available_subtitles +// =========================================================================== + +#[tauri::command] +pub fn get_available_subtitles( + library: tauri::State<'_, Mutex>, +) -> Result { + let lib = library.lock().map_err(|e| format!("lock error: {}", e))?; + Ok(lib.get_available_subtitles()) +} + +// =========================================================================== +// 24. load_sidecar_subtitle +// =========================================================================== + +#[tauri::command] +pub fn load_sidecar_subtitle( + file_path: String, + library: tauri::State<'_, Mutex>, + paths: tauri::State<'_, AppPaths>, +) -> Result { + let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?; + Ok(lib.load_sidecar_subtitle(&file_path, &paths.state_dir)) +} + +// =========================================================================== +// 25. choose_subtitle_file +// =========================================================================== + +#[tauri::command] +pub async fn choose_subtitle_file( + app: tauri::AppHandle, + library: tauri::State<'_, Mutex>, + paths: tauri::State<'_, AppPaths>, +) -> Result { + let file = app + .dialog() + .file() + .add_filter("Subtitles", &["srt", "vtt"]) + .blocking_pick_file(); + + let file_path = match file { + Some(fp) => fp.as_path().unwrap().to_path_buf(), + None => return Ok(json!({"ok": false, "cancelled": true})), + }; + + let file_str = file_path.to_string_lossy().to_string(); + + let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?; + Ok(lib.set_subtitle_for_current(&file_str, &paths.state_dir)) +} + +// =========================================================================== +// 26. reset_watch_progress +// =========================================================================== + +#[tauri::command] +pub fn reset_watch_progress( + library: tauri::State<'_, Mutex>, +) -> Result { + let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?; + Ok(lib.reset_watch_progress()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e1acab1..ca2831e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,6 @@ +use std::path::PathBuf; + +pub mod commands; pub mod ffmpeg; pub mod fonts; pub mod library; @@ -6,11 +9,13 @@ pub mod recents; pub mod state; pub mod subtitles; pub mod utils; +pub mod video_protocol; -#[cfg_attr(mobile, tauri::mobile_entry_point)] -pub fn run() { - tauri::Builder::default() - .plugin(tauri_plugin_dialog::init()) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); +/// Application directory paths resolved at startup, managed as Tauri state. +pub struct AppPaths { + pub exe_dir: PathBuf, + pub state_dir: PathBuf, + pub fonts_dir: PathBuf, + pub fa_dir: PathBuf, + pub subs_dir: PathBuf, } diff --git a/src-tauri/src/library.rs b/src-tauri/src/library.rs index 95e895d..096570f 100644 --- a/src-tauri/src/library.rs +++ b/src-tauri/src/library.rs @@ -1438,7 +1438,7 @@ impl Library { /// This method signals any previous scan to stop, then prepares the /// pending list. The actual scanning is driven externally via /// `get_pending_scans()` and `apply_scanned_duration()`. - pub fn start_duration_scan(&mut self, _state_dir: PathBuf) { + pub fn start_duration_scan(&mut self) { // Signal any running scan to stop. self.scan_stop.store(true, Ordering::SeqCst); // Create a fresh stop flag for any new scan cycle. diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 9b48f6b..8f4796c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,5 +1,126 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use std::sync::Mutex; +use tauri::Manager; +use tutorialdock_lib::{commands, ffmpeg, fonts, library, prefs, video_protocol, AppPaths}; + fn main() { - tutorialdock_lib::run(); + // 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"); + + // 2. 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"), + }; + 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(); + + // 3. Set WebView2 user data folder for portability (no AppData usage). + let webview_profile = state_dir.join("webview_profile"); + std::fs::create_dir_all(&webview_profile).ok(); + unsafe { + std::env::set_var("WEBVIEW2_USER_DATA_FOLDER", &webview_profile); + } + + // 4. Load preferences. + let prefs_data = prefs::Prefs::load(&state_dir); + + // 5. Initialize library (empty — loaded from last folder or frontend action). + let mut lib = library::Library::new(); + + // Discover ffmpeg/ffprobe. + 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 it exists. + if let Some(ref last_path) = prefs_data.last_folder_path { + if std::path::Path::new(last_path).is_dir() { + let _ = lib.set_root(last_path, &state_dir); + } + } + + // 6. Build Tauri app. + let builder = tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .manage(Mutex::new(lib)) + .manage(Mutex::new(prefs_data)) + .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, + commands::reset_watch_progress, + ]); + + // Register custom protocol for video/subtitle/font serving. + let builder = video_protocol::register_protocol(builder); + + // Configure window from saved prefs and launch. + builder + .setup(|app| { + let prefs_state = app.state::>(); + 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 { + 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); + drop(p); + + // Spawn async font caching (silent, non-blocking). + let app_paths = app.state::(); + let fonts_dir = app_paths.fonts_dir.clone(); + let fa_dir = app_paths.fa_dir.clone(); + tauri::async_runtime::spawn(async move { + let _ = fonts::ensure_google_fonts_local(&fonts_dir).await; + let _ = fonts::ensure_fontawesome_local(&fa_dir).await; + }); + + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); } diff --git a/src-tauri/src/video_protocol.rs b/src-tauri/src/video_protocol.rs new file mode 100644 index 0000000..2252058 --- /dev/null +++ b/src-tauri/src/video_protocol.rs @@ -0,0 +1,404 @@ +//! Custom `tutdock` URI scheme protocol for serving video, subtitles, and fonts. +//! +//! On Windows the actual URL seen by the handler is: +//! `http://tutdock.localhost/` (Tauri v2 Windows behaviour). +//! On macOS / Linux it would be `tutdock://localhost/`. +//! +//! Routes: +//! /video/{index} — video file with Range support +//! /sub/{libid}/{fid} — stored VTT subtitle +//! /fonts.css — cached Google Fonts CSS +//! /fonts/{filename} — cached font file +//! /fa.css — cached Font Awesome CSS +//! /fa/webfonts/{filename} — cached FA webfont + +use std::fs::{self, File}; +use std::io::{Read, Seek, SeekFrom}; +use std::path::PathBuf; +use std::sync::Mutex; + +use tauri::http::{self, header, StatusCode}; +use tauri::Manager; + +use crate::library::Library; +use crate::AppPaths; + +/// Maximum chunk size for full-file responses (4 MB). +const MAX_CHUNK: u64 = 4 * 1024 * 1024; + +/// Register the `tutdock` custom protocol on the builder. +pub fn register_protocol(builder: tauri::Builder) -> tauri::Builder { + builder.register_uri_scheme_protocol("tutdock", move |ctx, request| { + let app = ctx.app_handle(); + handle_request(app, &request) + }) +} + +// --------------------------------------------------------------------------- +// Router +// --------------------------------------------------------------------------- + +fn handle_request( + app: &tauri::AppHandle, + request: &http::Request>, +) -> http::Response> { + let uri = request.uri(); + let path = uri.path(); + + // Strip leading slash. + let path = path.strip_prefix('/').unwrap_or(path); + + // Route. + if let Some(rest) = path.strip_prefix("video/") { + return handle_video(app, request, rest); + } + if let Some(rest) = path.strip_prefix("sub/") { + return handle_subtitle(app, rest); + } + if path == "fonts.css" { + return handle_fonts_css(app); + } + if let Some(rest) = path.strip_prefix("fonts/") { + return handle_font_file(app, rest); + } + if path == "fa.css" { + return handle_fa_css(app); + } + if let Some(rest) = path.strip_prefix("fa/webfonts/") { + return handle_fa_webfont(app, rest); + } + + not_found() +} + +// --------------------------------------------------------------------------- +// Video — with Range request support +// --------------------------------------------------------------------------- + +fn handle_video( + app: &tauri::AppHandle, + request: &http::Request>, + index_str: &str, +) -> http::Response> { + let index: usize = match index_str.parse() { + Ok(i) => i, + Err(_) => return bad_request("invalid index"), + }; + + let state = app.state::>(); + let file_path = { + let lib = match state.lock() { + Ok(l) => l, + Err(_) => return internal_error("lock error"), + }; + match lib.get_video_path(index) { + Ok(p) => p, + Err(_) => return not_found(), + } + }; + + let metadata = match fs::metadata(&file_path) { + Ok(m) => m, + Err(_) => return not_found(), + }; + let file_size = metadata.len(); + + let ext = file_path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + let mime = mime_for_video(ext); + + // Check for Range header. + let range_header = request + .headers() + .get(header::RANGE) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + match range_header { + Some(range_str) => serve_range(&file_path, file_size, &range_str, mime), + None => serve_full(&file_path, file_size, mime), + } +} + +/// Serve the full file (or first MAX_CHUNK bytes). +fn serve_full(path: &PathBuf, file_size: u64, mime: &str) -> http::Response> { + let read_len = file_size.min(MAX_CHUNK) as usize; + let mut buf = vec![0u8; read_len]; + let mut file = match File::open(path) { + Ok(f) => f, + Err(_) => return internal_error("cannot open file"), + }; + if file.read_exact(&mut buf).is_err() { + return internal_error("read error"); + } + + http::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, mime) + .header(header::CONTENT_LENGTH, file_size.to_string()) + .header(header::ACCEPT_RANGES, "bytes") + .header(header::CACHE_CONTROL, "no-store") + .body(buf) + .unwrap_or_else(|_| internal_error("response build error")) +} + +/// Serve a byte range (206 Partial Content). +fn serve_range( + path: &PathBuf, + file_size: u64, + range_str: &str, + mime: &str, +) -> http::Response> { + let (start, end) = match parse_range(range_str, file_size) { + Some(r) => r, + None => return range_not_satisfiable(file_size), + }; + + let length = end - start + 1; + let mut buf = vec![0u8; length as usize]; + + let mut file = match File::open(path) { + Ok(f) => f, + Err(_) => return internal_error("cannot open file"), + }; + if file.seek(SeekFrom::Start(start)).is_err() { + return internal_error("seek error"); + } + if file.read_exact(&mut buf).is_err() { + return internal_error("read error"); + } + + http::Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header(header::CONTENT_TYPE, mime) + .header( + header::CONTENT_RANGE, + format!("bytes {}-{}/{}", start, end, file_size), + ) + .header(header::CONTENT_LENGTH, length.to_string()) + .header(header::ACCEPT_RANGES, "bytes") + .header(header::CACHE_CONTROL, "no-store") + .body(buf) + .unwrap_or_else(|_| internal_error("response build error")) +} + +/// Parse `Range: bytes=START-END` header. +fn parse_range(header: &str, file_size: u64) -> Option<(u64, u64)> { + let s = header.strip_prefix("bytes=")?; + let (start_s, end_s) = s.split_once('-')?; + + let start = if start_s.is_empty() { + let suffix: u64 = end_s.parse().ok()?; + file_size.checked_sub(suffix)? + } else { + start_s.parse().ok()? + }; + + let end = if end_s.is_empty() { + file_size - 1 + } else { + let e: u64 = end_s.parse().ok()?; + e.min(file_size - 1) + }; + + if start > end || start >= file_size { + return None; + } + + Some((start, end)) +} + +// --------------------------------------------------------------------------- +// Subtitles +// --------------------------------------------------------------------------- + +fn handle_subtitle(app: &tauri::AppHandle, rest: &str) -> http::Response> { + // Path: sub/{libid}/{fid} + let parts: Vec<&str> = rest.splitn(2, '/').collect(); + if parts.len() != 2 { + return not_found(); + } + let fid = parts[1]; + + if !fid.chars().all(|c| c.is_ascii_hexdigit()) { + return not_found(); + } + + let paths = app.state::(); + let vtt_path = paths.subs_dir.join(format!("{}.vtt", fid)); + + if !vtt_path.exists() { + return not_found(); + } + + let data = match fs::read(&vtt_path) { + Ok(d) => d, + Err(_) => return internal_error("read error"), + }; + + http::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/vtt; charset=utf-8") + .header(header::CACHE_CONTROL, "no-store") + .body(data) + .unwrap_or_else(|_| internal_error("response build error")) +} + +// --------------------------------------------------------------------------- +// Fonts +// --------------------------------------------------------------------------- + +fn handle_fonts_css(app: &tauri::AppHandle) -> http::Response> { + let paths = app.state::(); + let css_path = paths.fonts_dir.join("fonts.css"); + + let data = if css_path.exists() { + fs::read(&css_path).unwrap_or_default() + } else { + Vec::new() + }; + + http::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/css; charset=utf-8") + .header(header::CACHE_CONTROL, "no-store") + .body(data) + .unwrap_or_else(|_| internal_error("response build error")) +} + +fn handle_font_file(app: &tauri::AppHandle, filename: &str) -> http::Response> { + let paths = app.state::(); + serve_static_file(&paths.fonts_dir, filename) +} + +fn handle_fa_css(app: &tauri::AppHandle) -> http::Response> { + let paths = app.state::(); + let css_path = paths.fa_dir.join("fa.css"); + + let data = if css_path.exists() { + fs::read(&css_path).unwrap_or_default() + } else { + Vec::new() + }; + + http::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/css; charset=utf-8") + .header(header::CACHE_CONTROL, "no-store") + .body(data) + .unwrap_or_else(|_| internal_error("response build error")) +} + +fn handle_fa_webfont(app: &tauri::AppHandle, filename: &str) -> http::Response> { + let paths = app.state::(); + let webfonts_dir = paths.fa_dir.join("webfonts"); + serve_static_file(&webfonts_dir, filename) +} + +// --------------------------------------------------------------------------- +// Static file helper +// --------------------------------------------------------------------------- + +fn serve_static_file(base_dir: &PathBuf, filename: &str) -> http::Response> { + if filename.contains("..") || filename.contains('\\') || filename.contains('/') { + return not_found(); + } + + let file_path = base_dir.join(filename); + + let canonical_base = match base_dir.canonicalize() { + Ok(p) => p, + Err(_) => return not_found(), + }; + let canonical_file = match file_path.canonicalize() { + Ok(p) => p, + Err(_) => return not_found(), + }; + if !canonical_file.starts_with(&canonical_base) { + return not_found(); + } + + let data = match fs::read(&file_path) { + Ok(d) => d, + Err(_) => return not_found(), + }; + + let mime = guess_mime(filename); + + http::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, mime) + .header(header::CACHE_CONTROL, "no-store") + .body(data) + .unwrap_or_else(|_| internal_error("response build error")) +} + +// --------------------------------------------------------------------------- +// MIME helpers +// --------------------------------------------------------------------------- + +fn mime_for_video(ext: &str) -> &'static str { + match ext.to_ascii_lowercase().as_str() { + "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", + } +} + +fn guess_mime(filename: &str) -> &'static str { + let ext = filename.rsplit('.').next().unwrap_or("").to_ascii_lowercase(); + match ext.as_str() { + "css" => "text/css; charset=utf-8", + "woff" => "font/woff", + "woff2" => "font/woff2", + "ttf" => "font/ttf", + "otf" => "font/otf", + "eot" => "application/vnd.ms-fontobject", + "svg" => "image/svg+xml", + _ => "application/octet-stream", + } +} + +// --------------------------------------------------------------------------- +// Error responses +// --------------------------------------------------------------------------- + +fn not_found() -> http::Response> { + http::Response::builder() + .status(StatusCode::NOT_FOUND) + .header(header::CONTENT_TYPE, "text/plain") + .body(b"Not Found".to_vec()) + .unwrap() +} + +fn bad_request(msg: &str) -> http::Response> { + http::Response::builder() + .status(StatusCode::BAD_REQUEST) + .header(header::CONTENT_TYPE, "text/plain") + .body(msg.as_bytes().to_vec()) + .unwrap() +} + +fn internal_error(msg: &str) -> http::Response> { + http::Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .header(header::CONTENT_TYPE, "text/plain") + .body(msg.as_bytes().to_vec()) + .unwrap() +} + +fn range_not_satisfiable(file_size: u64) -> http::Response> { + http::Response::builder() + .status(StatusCode::RANGE_NOT_SATISFIABLE) + .header(header::CONTENT_RANGE, format!("bytes */{}", file_size)) + .body(Vec::new()) + .unwrap() +} diff --git a/src/index.html b/src/index.html index 7784b83..376205f 100644 --- a/src/index.html +++ b/src/index.html @@ -1,14 +1,251 @@ - - - - - - TutorialDock - - -
-
+ + + + + +TutorialDock + + + + +
+
+
+
+ +
+
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
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
-
+
+
+ +
+ + +