feat: implement video_protocol.rs, commands.rs, wire up main.rs, and index.html
- video_protocol.rs: tutdock:// custom protocol with HTTP Range support for video streaming, subtitle/font serving with path traversal protection - commands.rs: all 26 Tauri command handlers as thin wrappers - main.rs: full Tauri bootstrap with state management, window restore, async font caching, and ffmpeg discovery - index.html: complete HTML markup extracted from Python app - lib.rs: updated with all module declarations and AppPaths struct
This commit is contained in:
487
src-tauri/src/commands.rs
Normal file
487
src-tauri/src/commands.rs
Normal file
@@ -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<Library>>,
|
||||||
|
prefs: tauri::State<'_, Mutex<Prefs>>,
|
||||||
|
paths: tauri::State<'_, AppPaths>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
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<Library>>,
|
||||||
|
prefs: tauri::State<'_, Mutex<Prefs>>,
|
||||||
|
paths: tauri::State<'_, AppPaths>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
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<Value, String> {
|
||||||
|
let all = recents::load_recents(&paths.state_dir);
|
||||||
|
|
||||||
|
let items: Vec<Value> = 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<Value, String> {
|
||||||
|
recents::remove_recent(&paths.state_dir, &path);
|
||||||
|
Ok(json!({"ok": true}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 5. get_library
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_library(library: tauri::State<'_, Mutex<Library>>) -> Result<Value, String> {
|
||||||
|
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<Library>>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
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<f64>,
|
||||||
|
playing: bool,
|
||||||
|
library: tauri::State<'_, Mutex<Library>>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
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<Library>>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
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<Library>>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
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<Library>>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
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<String>,
|
||||||
|
library: tauri::State<'_, Mutex<Library>>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
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<Library>>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
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::<Mutex<Library>>();
|
||||||
|
|
||||||
|
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<Prefs>>) -> Result<Value, String> {
|
||||||
|
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<Prefs>>,
|
||||||
|
paths: tauri::State<'_, AppPaths>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
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<Prefs>>,
|
||||||
|
paths: tauri::State<'_, AppPaths>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
{
|
||||||
|
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<Prefs>>,
|
||||||
|
paths: tauri::State<'_, AppPaths>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
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<Library>>) -> Result<Value, String> {
|
||||||
|
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<Library>>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
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<Library>>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
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<Library>>,
|
||||||
|
paths: tauri::State<'_, AppPaths>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
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<Library>>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
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<Library>>,
|
||||||
|
paths: tauri::State<'_, AppPaths>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
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<Library>>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
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<Library>>,
|
||||||
|
paths: tauri::State<'_, AppPaths>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
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<Library>>,
|
||||||
|
paths: tauri::State<'_, AppPaths>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
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<Library>>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?;
|
||||||
|
Ok(lib.reset_watch_progress())
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
pub mod commands;
|
||||||
pub mod ffmpeg;
|
pub mod ffmpeg;
|
||||||
pub mod fonts;
|
pub mod fonts;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
@@ -6,11 +9,13 @@ pub mod recents;
|
|||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod subtitles;
|
pub mod subtitles;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
pub mod video_protocol;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
/// Application directory paths resolved at startup, managed as Tauri state.
|
||||||
pub fn run() {
|
pub struct AppPaths {
|
||||||
tauri::Builder::default()
|
pub exe_dir: PathBuf,
|
||||||
.plugin(tauri_plugin_dialog::init())
|
pub state_dir: PathBuf,
|
||||||
.run(tauri::generate_context!())
|
pub fonts_dir: PathBuf,
|
||||||
.expect("error while running tauri application");
|
pub fa_dir: PathBuf,
|
||||||
|
pub subs_dir: PathBuf,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1438,7 +1438,7 @@ impl Library {
|
|||||||
/// This method signals any previous scan to stop, then prepares the
|
/// This method signals any previous scan to stop, then prepares the
|
||||||
/// pending list. The actual scanning is driven externally via
|
/// pending list. The actual scanning is driven externally via
|
||||||
/// `get_pending_scans()` and `apply_scanned_duration()`.
|
/// `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.
|
// Signal any running scan to stop.
|
||||||
self.scan_stop.store(true, Ordering::SeqCst);
|
self.scan_stop.store(true, Ordering::SeqCst);
|
||||||
// Create a fresh stop flag for any new scan cycle.
|
// Create a fresh stop flag for any new scan cycle.
|
||||||
|
|||||||
@@ -1,5 +1,126 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![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() {
|
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::<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 {
|
||||||
|
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::<AppPaths>();
|
||||||
|
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");
|
||||||
}
|
}
|
||||||
|
|||||||
404
src-tauri/src/video_protocol.rs
Normal file
404
src-tauri/src/video_protocol.rs
Normal file
@@ -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/<path>` (Tauri v2 Windows behaviour).
|
||||||
|
//! On macOS / Linux it would be `tutdock://localhost/<path>`.
|
||||||
|
//!
|
||||||
|
//! 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::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||||
|
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<Vec<u8>>,
|
||||||
|
) -> http::Response<Vec<u8>> {
|
||||||
|
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<Vec<u8>>,
|
||||||
|
index_str: &str,
|
||||||
|
) -> http::Response<Vec<u8>> {
|
||||||
|
let index: usize = match index_str.parse() {
|
||||||
|
Ok(i) => i,
|
||||||
|
Err(_) => return bad_request("invalid index"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let state = app.state::<Mutex<Library>>();
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<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];
|
||||||
|
|
||||||
|
if !fid.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
return not_found();
|
||||||
|
}
|
||||||
|
|
||||||
|
let paths = app.state::<AppPaths>();
|
||||||
|
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<Vec<u8>> {
|
||||||
|
let paths = app.state::<AppPaths>();
|
||||||
|
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<Vec<u8>> {
|
||||||
|
let paths = app.state::<AppPaths>();
|
||||||
|
serve_static_file(&paths.fonts_dir, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_fa_css(app: &tauri::AppHandle) -> http::Response<Vec<u8>> {
|
||||||
|
let paths = app.state::<AppPaths>();
|
||||||
|
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<Vec<u8>> {
|
||||||
|
let paths = app.state::<AppPaths>();
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
http::Response::builder()
|
||||||
|
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||||
|
.header(header::CONTENT_RANGE, format!("bytes */{}", file_size))
|
||||||
|
.body(Vec::new())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
247
src/index.html
247
src/index.html
@@ -1,14 +1,251 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>TutorialDock</title>
|
<title>TutorialDock</title>
|
||||||
|
<link rel="stylesheet" href="tutdock://localhost/fonts.css">
|
||||||
|
<link rel="stylesheet" href="tutdock://localhost/fa.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="zoomRoot">
|
<div id="zoomRoot">
|
||||||
<div class="app"></div>
|
<div class="app">
|
||||||
|
<div class="topbar">
|
||||||
|
<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="tagline">Watch local tutorials, resume instantly, and actually finish them.</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<div class="actionGroup">
|
||||||
|
<label class="switch" data-tooltip="On Top" data-tooltip-desc="Keep this window above others (global)">
|
||||||
|
<input type="checkbox" id="onTopChk">
|
||||||
|
<span class="track"><span class="knob"></span></span>
|
||||||
|
<span>On top</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="switch" data-tooltip="Autoplay" data-tooltip-desc="Autoplay next/prev (saved per folder)">
|
||||||
|
<input type="checkbox" id="autoplayChk">
|
||||||
|
<span class="track"><span class="knob"></span></span>
|
||||||
|
<span>Autoplay</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actionDivider"></div>
|
||||||
|
|
||||||
|
<div class="actionGroup">
|
||||||
|
<div class="zoomControl" data-tooltip="UI Zoom" data-tooltip-desc="Adjust the interface zoom level">
|
||||||
|
<button class="zoomBtn" id="zoomOutBtn"><i class="fa-solid fa-minus"></i></button>
|
||||||
|
<span class="zoomValue" id="zoomResetBtn">100%</span>
|
||||||
|
<button class="zoomBtn" id="zoomInBtn"><i class="fa-solid fa-plus"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actionDivider"></div>
|
||||||
|
|
||||||
|
<div class="actionGroup">
|
||||||
|
<div class="splitBtn primary">
|
||||||
|
<button class="btn primary" id="chooseBtn" data-tooltip="Open Folder" data-tooltip-desc="Browse and select a folder containing videos"><i class="fa-solid fa-folder-open"></i> Open folder</button>
|
||||||
|
<button class="btn drop" id="chooseDropBtn" data-tooltip="Recent Folders" data-tooltip-desc="Open a recently used folder"><i class="fa-solid fa-chevron-down"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actionDivider"></div>
|
||||||
|
|
||||||
|
<div class="actionGroup">
|
||||||
|
<button class="toolbarBtn" id="resetProgBtn" data-tooltip="Reset Progress" data-tooltip-desc="Reset DONE / NOW progress for this folder (keeps notes, volume, etc.)"><i class="fa-solid fa-clock-rotate-left"></i></button>
|
||||||
|
<button class="toolbarBtn" id="refreshBtn" data-tooltip="Reload" data-tooltip-desc="Reload the current folder"><i class="fa-solid fa-arrows-rotate"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dropdownPortal" id="recentMenu"></div>
|
||||||
|
|
||||||
|
<div class="content" id="contentGrid">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panelHeader">
|
||||||
|
<div style="min-width:0;">
|
||||||
|
<div class="nowTitle" id="nowTitle">No video loaded</div>
|
||||||
|
<div class="nowSub" id="nowSub">-</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progressPill" data-tooltip="Overall Progress" data-tooltip-desc="Folder completion (time-based, using cached durations when available)">
|
||||||
|
<div class="progressLabel">Overall</div>
|
||||||
|
<div class="progressBar"><div id="overallBar"></div></div>
|
||||||
|
<div class="progressPct" id="overallPct">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="videoWrap">
|
||||||
|
<video id="player" preload="metadata"></video>
|
||||||
|
<div class="videoOverlay" id="videoOverlay">
|
||||||
|
<div class="overlayIcon" id="overlayIcon">
|
||||||
|
<i class="fa-solid fa-play" id="overlayIconI"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<div class="controlsRow">
|
||||||
|
<div class="group">
|
||||||
|
<button class="iconBtn" id="prevBtn" data-tooltip="Previous" data-tooltip-desc="Go to previous video"><i class="fa-solid fa-backward-step"></i></button>
|
||||||
|
|
||||||
|
<button class="iconBtn primary" id="playPauseBtn" data-tooltip="Play/Pause" data-tooltip-desc="Toggle video playback">
|
||||||
|
<i class="fa-solid fa-play" id="ppIcon"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="iconBtn" id="nextBtn" data-tooltip="Next" data-tooltip-desc="Go to next video"><i class="fa-solid fa-forward-step"></i></button>
|
||||||
|
|
||||||
|
<div class="timeChip" data-tooltip="Time" data-tooltip-desc="Current position / Total duration">
|
||||||
|
<div class="timeDot"></div>
|
||||||
|
<div><span id="timeNow">00:00</span> <span style="color:rgba(165,172,196,.65)">/</span> <span id="timeDur">00:00</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group">
|
||||||
|
<div class="subsBox">
|
||||||
|
<button class="iconBtn" id="subsBtn" data-tooltip="Subtitles" data-tooltip-desc="Load or select subtitles"><i class="fa-regular fa-closed-captioning"></i></button>
|
||||||
|
<div class="subsMenu" id="subsMenu" role="menu"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="miniCtl" data-tooltip="Volume" data-tooltip-desc="Adjust volume (saved per folder)">
|
||||||
|
<i class="fa-solid fa-volume-high"></i>
|
||||||
|
<div class="volWrap">
|
||||||
|
<div class="volTrack">
|
||||||
|
<div class="volFill" id="volFill"></div>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="volSlider" class="vol" min="0" max="1" step="0.01" value="1">
|
||||||
|
</div>
|
||||||
|
<div class="volTooltip" id="volTooltip">100%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="miniCtl" data-tooltip="Speed" data-tooltip-desc="Playback speed (saved per folder)">
|
||||||
|
<svg id="speedIcon" width="14" height="14" viewBox="0 0 24 24" style="overflow:visible; color:var(--iconStrong);">
|
||||||
|
<path d="M12 22C6.5 22 2 17.5 2 12S6.5 2 12 2s10 4.5 10 10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity=".5"/>
|
||||||
|
<path d="M12 22c5.5 0 10-4.5 10-10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity=".3"/>
|
||||||
|
<g transform="rotate(0, 12, 13)">
|
||||||
|
<line x1="12" y1="13" x2="12" y2="5" stroke="rgba(255,255,255,.85)" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
<circle cx="12" cy="13" r="2" fill="currentColor" opacity=".7"/>
|
||||||
|
</svg>
|
||||||
|
<div class="speedBox">
|
||||||
|
<button class="speedBtn" id="speedBtn" aria-label="Playback speed">
|
||||||
|
<span id="speedBtnText">1.0x</span>
|
||||||
|
<span class="speedCaret" aria-hidden="true"><i class="fa-solid fa-chevron-up"></i></span>
|
||||||
|
</button>
|
||||||
|
<div class="speedMenu" id="speedMenu" role="menu" aria-label="Speed menu"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="iconBtn" id="fsBtn" data-tooltip="Fullscreen" data-tooltip-desc="Toggle fullscreen mode"><i class="fa-solid fa-expand"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="seekWrap">
|
||||||
|
<div class="seekTrack">
|
||||||
|
<div class="seekFill" id="seekFill"></div>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="seek" class="seek" min="0" max="1000" step="1" value="0" aria-label="Seek">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dock" id="dockGrid">
|
||||||
|
<div class="dockPane">
|
||||||
|
<div class="dockInner">
|
||||||
|
<div class="dockHeader" id="notesHeader" data-tooltip="Notes" data-tooltip-desc="Your notes are automatically saved for each video file. Write timestamps, TODOs, or reminders.">
|
||||||
|
<div class="dockTitle"><i class="fa-solid fa-note-sticky"></i> Notes</div>
|
||||||
|
</div>
|
||||||
|
<div class="notesArea">
|
||||||
|
<textarea class="notes" id="notesBox" placeholder="Write timestamps, TODOs, reminders…"></textarea>
|
||||||
|
<div class="notesSaved" id="notesSaved"><i class="fa-solid fa-check"></i> Saved</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dockDividerWrap">
|
||||||
|
<div class="dockDivider" id="dockDivider" data-tooltip="Resize" data-tooltip-desc="Drag to resize panels"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dockPane">
|
||||||
|
<div class="dockInner">
|
||||||
|
<div class="dockHeader" id="infoHeader" data-tooltip="Info" data-tooltip-desc="Metadata and progress info for the current folder and video. Updates automatically.">
|
||||||
|
<div class="dockTitle"><i class="fa-solid fa-circle-info"></i> Info</div>
|
||||||
|
</div>
|
||||||
|
<div class="infoGrid" id="infoGrid">
|
||||||
|
<div class="kv">
|
||||||
|
<div class="k">Folder</div><div class="v" id="infoFolder">-</div>
|
||||||
|
<div class="k">Next up</div><div class="v" id="infoNext">-</div>
|
||||||
|
<div class="k">Structure</div><div class="v mono" id="infoStruct">-</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kv">
|
||||||
|
<div class="k">Title</div><div class="v" id="infoTitle">-</div>
|
||||||
|
<div class="k">Relpath</div><div class="v mono" id="infoRel">-</div>
|
||||||
|
<div class="k">Position</div><div class="v mono" id="infoPos">-</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kv">
|
||||||
|
<div class="k">File</div><div class="v mono" id="infoFileBits">-</div>
|
||||||
|
<div class="k">Video</div><div class="v mono" id="infoVidBits">-</div>
|
||||||
|
<div class="k">Audio</div><div class="v mono" id="infoAudBits">-</div>
|
||||||
|
<div class="k">Subtitles</div><div class="v mono" id="infoSubsBits">-</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kv">
|
||||||
|
<div class="k">Finished</div><div class="v mono" id="infoFinished">-</div>
|
||||||
|
<div class="k">Remaining</div><div class="v mono" id="infoRemaining">-</div>
|
||||||
|
<div class="k">ETA</div><div class="v mono" id="infoEta">-</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kv">
|
||||||
|
<div class="k">Volume</div><div class="v mono" id="infoVolume">-</div>
|
||||||
|
<div class="k">Speed</div><div class="v mono" id="infoSpeed">-</div>
|
||||||
|
<div class="k">Durations</div><div class="v mono" id="infoKnown">-</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kv">
|
||||||
|
<div class="k">Top folders</div><div class="v mono" id="infoTop">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dividerWrap">
|
||||||
|
<div class="divider" id="divider" data-tooltip="Resize" data-tooltip-desc="Drag to resize panels"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panelHeader" style="align-items:center;">
|
||||||
|
<div class="playlistHeader" id="plistHeader" data-tooltip="Playlist" data-tooltip-desc="Drag items to reorder. The blue line shows where it will drop."><i class="fa-solid fa-list"></i> Playlist</div>
|
||||||
|
<div style="flex:1 1 auto;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="listWrap">
|
||||||
|
<div class="list" id="list"></div>
|
||||||
|
<div class="listScrollbar" id="listScrollbar"><div class="listScrollbarThumb" id="listScrollbarThumb"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="empty" id="emptyHint" style="display:none;">
|
||||||
|
No videos found (searched recursively).<br/>Native playback is happiest with MP4 (H.264/AAC) or WebM.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast" aria-live="polite">
|
||||||
|
<div class="toastInner">
|
||||||
|
<div class="toastIcon"><i class="fa-solid fa-circle-info"></i></div>
|
||||||
|
<div class="toastMsg" id="toastMsg">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tooltip" id="fancyTooltip"><div class="tooltip-title"></div><div class="tooltip-desc"></div></div>
|
||||||
|
|
||||||
<script type="module" src="/main.ts"></script>
|
<script type="module" src="/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user