From 9c8474d24f842afcc850b8228506dff543183995 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 19 Feb 2026 02:08:23 +0200 Subject: [PATCH] feat: implement library.rs, types.ts, api.ts, and extract CSS - library.rs: full video library management (1948 lines, 10 tests) folder scanning, progress tracking, playlists, subtitle integration, background duration scanning - types.ts: all TypeScript interfaces for Tauri command responses - api.ts: typed wrappers for all 26 Tauri invoke commands - 6 CSS files extracted from Python HTML into src/styles/ --- src-tauri/src/lib.rs | 1 + src-tauri/src/library.rs | 1954 +++++++++++++++++++++++++++++++++++++ src/api.ts | 92 ++ src/main.ts | 9 +- src/styles/animations.css | 1 + src/styles/components.css | 182 ++++ src/styles/main.css | 514 ++++++++++ src/styles/panels.css | 144 +++ src/styles/player.css | 278 ++++++ src/styles/playlist.css | 100 ++ src/types.ts | 188 ++++ 11 files changed, 3462 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/library.rs create mode 100644 src/api.ts create mode 100644 src/styles/animations.css create mode 100644 src/styles/components.css create mode 100644 src/styles/main.css create mode 100644 src/styles/panels.css create mode 100644 src/styles/player.css create mode 100644 src/styles/playlist.css create mode 100644 src/types.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 23d94d5..e1acab1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ pub mod ffmpeg; pub mod fonts; +pub mod library; pub mod prefs; pub mod recents; pub mod state; diff --git a/src-tauri/src/library.rs b/src-tauri/src/library.rs new file mode 100644 index 0000000..95e895d --- /dev/null +++ b/src-tauri/src/library.rs @@ -0,0 +1,1954 @@ +//! Video library management: folder scanning, progress tracking, playlists, +//! subtitle integration, and background duration scanning. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::SystemTime; + +use crate::ffmpeg::{self, VideoMetadata}; +use crate::state::{atomic_write_json, load_json_with_fallbacks, BACKUP_COUNT}; +use crate::subtitles; +use crate::utils::{ + clamp, compute_library_id, file_fingerprint, is_within_root, natural_key, + pretty_title_from_filename, +}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// Supported video file extensions (lower-case, with leading dot). +pub const VIDEO_EXTS: &[&str] = &[ + ".mp4", ".m4v", ".mov", ".webm", ".mkv", ".avi", ".mpg", ".mpeg", ".m2ts", ".mts", ".ogv", +]; + +/// Current state file format version. +const STATE_VERSION: u32 = 1; + +// --------------------------------------------------------------------------- +// Structs +// --------------------------------------------------------------------------- + +/// Reference to a stored subtitle file for a video. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubtitleRef { + pub vtt: String, + pub label: String, +} + +/// Per-video metadata: position, progress, notes, subtitle reference. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VideoMeta { + pub pos: f64, + pub watched: f64, + pub duration: Option, + pub finished: bool, + pub note: String, + pub last_open: u64, + pub subtitle: Option, +} + +impl Default for VideoMeta { + fn default() -> Self { + Self { + pos: 0.0, + watched: 0.0, + duration: None, + finished: false, + note: String::new(), + last_open: 0, + subtitle: None, + } + } +} + +/// Serializable library state persisted to disk. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LibraryState { + pub version: u32, + pub library_id: String, + pub last_path: String, + pub updated_at: u64, + pub current_fid: Option, + pub current_time: f64, + pub volume: f64, + pub autoplay: bool, + pub playback_rate: f64, + pub order_fids: Vec, + pub videos: HashMap, +} + +impl Default for LibraryState { + fn default() -> Self { + Self { + version: STATE_VERSION, + library_id: String::new(), + last_path: String::new(), + updated_at: 0, + current_fid: None, + current_time: 0.0, + volume: 1.0, + autoplay: false, + playback_rate: 1.0, + order_fids: Vec::new(), + videos: HashMap::new(), + } + } +} + +/// In-memory representation of the video library. +pub struct Library { + pub root: Option, + pub files: Vec, + pub fids: Vec, + pub relpaths: Vec, + pub rel_to_fid: HashMap, + pub fid_to_rel: HashMap, + pub state: LibraryState, + pub state_path: Option, + // ffmpeg paths + pub ffprobe: Option, + pub ffmpeg: Option, + // metadata cache + pub meta_cache: HashMap, + // background scan control + scan_stop: Arc, +} + +// --------------------------------------------------------------------------- +// Helper functions (private) +// --------------------------------------------------------------------------- + +/// Return the current unix timestamp in seconds. +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Natural-sort relative paths and return the corresponding fid list. +fn _sorted_default(relpaths: &[String], rel_to_fid: &HashMap) -> Vec { + let mut sorted_rels: Vec = relpaths.to_vec(); + sorted_rels.sort_by(|a, b| natural_key(a).cmp(&natural_key(b))); + sorted_rels + .iter() + .filter_map(|r| rel_to_fid.get(r).cloned()) + .collect() +} + +/// Apply a saved ordering to the full set of file IDs. +/// +/// Files present in `order_fids` keep their saved order. Files not in the +/// saved order are appended at the end, naturally sorted by their relative +/// path. +fn _apply_saved_order( + all_fids: &[String], + fid_to_rel: &HashMap, + order_fids: &[String], +) -> Vec { + if order_fids.is_empty() { + // Fall back to natural sort. + let mut fids = all_fids.to_vec(); + fids.sort_by(|a, b| { + let ra = fid_to_rel.get(a).cloned().unwrap_or_default(); + let rb = fid_to_rel.get(b).cloned().unwrap_or_default(); + natural_key(&ra).cmp(&natural_key(&rb)) + }); + return fids; + } + + let all_set: std::collections::HashSet<&String> = all_fids.iter().collect(); + + // Saved order, keeping only fids that still exist. + let mut result: Vec = order_fids + .iter() + .filter(|f| all_set.contains(f)) + .cloned() + .collect(); + + let ordered_set: std::collections::HashSet<&String> = result.iter().collect(); + + // New files not in the saved order, naturally sorted. + let mut new_fids: Vec = all_fids + .iter() + .filter(|f| !ordered_set.contains(f)) + .cloned() + .collect(); + new_fids.sort_by(|a, b| { + let ra = fid_to_rel.get(a).cloned().unwrap_or_default(); + let rb = fid_to_rel.get(b).cloned().unwrap_or_default(); + natural_key(&ra).cmp(&natural_key(&rb)) + }); + + result.extend(new_fids); + result +} + +/// Compute per-file tree display flags: depth, pipe characters, is_last, etc. +/// +/// Depth is limited to 1 level (immediate parent folder only). +fn _tree_flags(rels: &[String]) -> HashMap { + let mut result: HashMap = HashMap::new(); + + // Group files by their immediate parent folder. + // For a relpath like "subfolder/file.mp4", parent = "subfolder". + // For "file.mp4", parent = "". + let mut groups: HashMap> = HashMap::new(); + for (i, rel) in rels.iter().enumerate() { + let parent = if let Some(pos) = rel.rfind('/') { + rel[..pos].to_string() + } else { + String::new() + }; + groups.entry(parent).or_default().push(i); + } + + // Track which parents we have seen so far for has_prev_in_parent. + let mut parent_seen_count: HashMap = HashMap::new(); + + for (_i, rel) in rels.iter().enumerate() { + let (parent, depth) = if let Some(pos) = rel.rfind('/') { + (rel[..pos].to_string(), 1) + } else { + (String::new(), 0) + }; + + let group = groups.get(&parent).unwrap(); + let position_in_group = parent_seen_count.entry(parent.clone()).or_insert(0); + let is_last = *position_in_group + 1 == group.len(); + let has_prev_in_parent = *position_in_group > 0; + *parent_seen_count.get_mut(&parent).unwrap() += 1; + + // Build pipe prefix for tree display. + let pipes = if depth == 0 { + String::new() + } else { + if is_last { + "\u{2514}\u{2500} ".to_string() // "-- " + } else { + "\u{251C}\u{2500} ".to_string() // "|-- " + } + }; + + result.insert( + rel.clone(), + json!({ + "depth": depth, + "pipes": pipes, + "is_last": is_last, + "has_prev_in_parent": has_prev_in_parent, + }), + ); + } + + result +} + +/// Recursively scan a directory for video files. +fn scan_video_files(dir: &Path) -> Vec { + let mut results = Vec::new(); + _scan_recursive(dir, &mut results); + results +} + +fn _scan_recursive(dir: &Path, out: &mut Vec) { + let entries = match fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + + let mut dirs: Vec = Vec::new(); + let mut files: Vec = Vec::new(); + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + dirs.push(path); + } else if path.is_file() { + if let Some(ext) = path.extension() { + let ext_lower = format!(".{}", ext.to_string_lossy().to_lowercase()); + if VIDEO_EXTS.contains(&ext_lower.as_str()) { + files.push(path); + } + } + } + } + + // Sort files naturally for deterministic ordering. + files.sort_by(|a, b| { + let na = a.file_name().unwrap_or_default().to_string_lossy(); + let nb = b.file_name().unwrap_or_default().to_string_lossy(); + natural_key(&na).cmp(&natural_key(&nb)) + }); + out.extend(files); + + // Recurse into subdirectories in sorted order. + dirs.sort_by(|a, b| { + let na = a.file_name().unwrap_or_default().to_string_lossy(); + let nb = b.file_name().unwrap_or_default().to_string_lossy(); + natural_key(&na).cmp(&natural_key(&nb)) + }); + for d in &dirs { + _scan_recursive(d, out); + } +} + +// --------------------------------------------------------------------------- +// Library implementation +// --------------------------------------------------------------------------- + +impl Library { + // ----------------------------------------------------------------------- + // new + // ----------------------------------------------------------------------- + + /// Create a new empty library with default state. + pub fn new() -> Library { + Library { + root: None, + files: Vec::new(), + fids: Vec::new(), + relpaths: Vec::new(), + rel_to_fid: HashMap::new(), + fid_to_rel: HashMap::new(), + state: LibraryState::default(), + state_path: None, + ffprobe: None, + ffmpeg: None, + meta_cache: HashMap::new(), + scan_stop: Arc::new(AtomicBool::new(false)), + } + } + + // ----------------------------------------------------------------------- + // set_root + // ----------------------------------------------------------------------- + + /// Open a folder as the video library root. + /// + /// Scans for video files, computes fingerprints, loads or creates state, + /// merges with existing progress, and returns `get_library_info()`. + pub fn set_root(&mut self, folder: &str, state_dir: &Path) -> Result { + // 1. Resolve and verify folder. + let folder_path = PathBuf::from(folder); + let folder_path = folder_path + .canonicalize() + .map_err(|e| format!("Cannot resolve folder '{}': {}", folder, e))?; + + if !folder_path.is_dir() { + return Err(format!("'{}' is not a directory", folder)); + } + + // Signal any running background scan to stop. + self.scan_stop.store(true, Ordering::SeqCst); + + // 2. Recursively scan for video files. + let found_files = scan_video_files(&folder_path); + + if found_files.is_empty() { + return Err(format!("No video files found in '{}'", folder)); + } + + // 3. Compute fingerprints and build mappings. + let mut rel_to_fid: HashMap = HashMap::new(); + let mut fid_to_rel: HashMap = HashMap::new(); + let mut all_fids: Vec = Vec::new(); + let mut all_relpaths: Vec = Vec::new(); + + for file in &found_files { + let relpath = file + .strip_prefix(&folder_path) + .unwrap_or(file.as_path()) + .to_string_lossy() + .replace('\\', "/"); + + let fid = file_fingerprint(file); + + // Skip duplicates (same fingerprint from hardlinks, etc.). + if fid_to_rel.contains_key(&fid) { + continue; + } + + rel_to_fid.insert(relpath.clone(), fid.clone()); + fid_to_rel.insert(fid.clone(), relpath.clone()); + all_fids.push(fid); + all_relpaths.push(relpath); + } + + // 4. Compute library ID. + let library_id = compute_library_id(&all_fids); + + // 5. Load existing state or create baseline. + let state_file = state_dir.join(format!("library_{}.json", library_id)); + let baseline = LibraryState { + version: STATE_VERSION, + library_id: library_id.clone(), + last_path: folder_path.to_string_lossy().to_string(), + updated_at: now_secs(), + current_fid: None, + current_time: 0.0, + volume: 1.0, + autoplay: false, + playback_rate: 1.0, + order_fids: Vec::new(), + videos: HashMap::new(), + }; + + let mut state = if let Some(loaded_val) = load_json_with_fallbacks(&state_file, BACKUP_COUNT) + { + // Try to deserialize the loaded JSON into LibraryState. + if let Ok(loaded_state) = serde_json::from_value::(loaded_val.clone()) { + // 6. Merge: only if same library_id. + if loaded_state.library_id == library_id { + self._merge_state(loaded_state, &baseline) + } else { + baseline + } + } else { + baseline + } + } else { + baseline + }; + + // 7. Update last_path to current folder. + state.last_path = folder_path.to_string_lossy().to_string(); + + // 8. Normalize video metadata for all found fids. + let existing_videos = state.videos.clone(); + let mut normalized: HashMap = HashMap::new(); + for fid in &all_fids { + let meta = existing_videos.get(fid).cloned().unwrap_or_default(); + normalized.insert(fid.clone(), meta); + } + state.videos = normalized; + + // 9. Normalize settings. + state.volume = clamp(state.volume, 0.0, 1.0); + state.playback_rate = clamp(state.playback_rate, 0.25, 3.0); + + // 10. Clean order_fids. + let valid_set: std::collections::HashSet<&String> = all_fids.iter().collect(); + state.order_fids.retain(|f| valid_set.contains(f)); + + // 11. Apply saved ordering. + let ordered_fids = + _apply_saved_order(&all_fids, &fid_to_rel, &state.order_fids); + state.order_fids = ordered_fids.clone(); + + // 12. Build ordered file lists. + let ordered_relpaths: Vec = ordered_fids + .iter() + .filter_map(|fid| fid_to_rel.get(fid).cloned()) + .collect(); + let ordered_files: Vec = ordered_relpaths + .iter() + .map(|rel| folder_path.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR))) + .collect(); + + // 13. Validate current_fid. + if let Some(ref cfid) = state.current_fid { + if !fid_to_rel.contains_key(cfid) { + state.current_fid = ordered_fids.first().cloned(); + state.current_time = 0.0; + } + } + + // Assign everything to self. + self.root = Some(folder_path); + self.files = ordered_files; + self.fids = ordered_fids; + self.relpaths = ordered_relpaths; + self.rel_to_fid = rel_to_fid; + self.fid_to_rel = fid_to_rel; + self.state = state; + self.state_path = Some(state_file); + self.meta_cache.clear(); + self.scan_stop = Arc::new(AtomicBool::new(false)); + + // 14. Save state. + self.save_state(); + + // 15. Return library info. + Ok(self.get_library_info()) + } + + /// Merge a loaded state with a baseline, preferring loaded values. + fn _merge_state(&self, loaded: LibraryState, baseline: &LibraryState) -> LibraryState { + LibraryState { + version: STATE_VERSION, + library_id: loaded.library_id, + last_path: baseline.last_path.clone(), + updated_at: loaded.updated_at, + current_fid: loaded.current_fid, + current_time: loaded.current_time, + volume: loaded.volume, + autoplay: loaded.autoplay, + playback_rate: loaded.playback_rate, + order_fids: loaded.order_fids, + videos: loaded.videos, + } + } + + // ----------------------------------------------------------------------- + // save_state + // ----------------------------------------------------------------------- + + /// Persist the current state to disk with backup rotation. + pub fn save_state(&mut self) { + self.state.updated_at = now_secs(); + if let Some(ref path) = self.state_path { + if let Ok(val) = serde_json::to_value(&self.state) { + atomic_write_json(path, &val, BACKUP_COUNT); + } + } + } + + // ----------------------------------------------------------------------- + // get_library_info + // ----------------------------------------------------------------------- + + /// Build a comprehensive JSON object describing the library state. + pub fn get_library_info(&self) -> Value { + let folder = self + .root + .as_ref() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + + let count = self.fids.len(); + + let current_index = self + .state + .current_fid + .as_ref() + .and_then(|cfid| self.fids.iter().position(|f| f == cfid)); + + // Build tree flags. + let tree_flags = _tree_flags(&self.relpaths); + + // Check if any subdirectories exist. + let has_subdirs = self.relpaths.iter().any(|r| r.contains('/')); + + // Build items array. + let mut items: Vec = Vec::new(); + for (i, fid) in self.fids.iter().enumerate() { + let relpath = self.fid_to_rel.get(fid).cloned().unwrap_or_default(); + let filename = relpath.rsplit('/').next().unwrap_or(&relpath); + let title = pretty_title_from_filename(filename); + + let flags = tree_flags.get(&relpath); + let depth = flags + .and_then(|f| f.get("depth")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let pipes = flags + .and_then(|f| f.get("pipes")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let is_last = flags + .and_then(|f| f.get("is_last")) + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let has_prev_in_parent = flags + .and_then(|f| f.get("has_prev_in_parent")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let meta = self.state.videos.get(fid); + let pos = meta.map(|m| m.pos).unwrap_or(0.0); + let watched = meta.map(|m| m.watched).unwrap_or(0.0); + let duration = meta.and_then(|m| m.duration); + let finished = meta.map(|m| m.finished).unwrap_or(false); + let note_len = meta.map(|m| m.note.len()).unwrap_or(0); + let last_open = meta.map(|m| m.last_open).unwrap_or(0); + let has_sub = meta + .map(|m| m.subtitle.is_some()) + .unwrap_or(false); + + items.push(json!({ + "index": i, + "fid": fid, + "name": filename, + "title": title, + "relpath": relpath, + "depth": depth, + "pipes": pipes, + "is_last": is_last, + "has_prev_in_parent": has_prev_in_parent, + "pos": pos, + "watched": watched, + "duration": duration, + "finished": finished, + "note_len": note_len, + "last_open": last_open, + "has_sub": has_sub, + })); + } + + // Folder stats. + let stats = self._folder_stats(); + + // Next up: first unfinished video after current. + let next_up = self._compute_next_up(current_index); + + json!({ + "ok": true, + "folder": folder, + "library_id": self.state.library_id, + "count": count, + "current_index": current_index, + "current_fid": self.state.current_fid, + "current_time": self.state.current_time, + "folder_volume": self.state.volume, + "folder_autoplay": self.state.autoplay, + "folder_rate": self.state.playback_rate, + "items": items, + "has_subdirs": has_subdirs, + "overall_progress": stats.get("overall_progress").cloned().unwrap_or(json!(0.0)), + "durations_known": stats.get("durations_known").cloned().unwrap_or(json!(false)), + "finished_count": stats.get("finished_count").cloned().unwrap_or(json!(0)), + "remaining_count": stats.get("remaining_count").cloned().unwrap_or(json!(0)), + "remaining_seconds_known": stats.get("remaining_seconds_known").cloned().unwrap_or(json!(0.0)), + "top_folders": stats.get("top_folders").cloned().unwrap_or(json!([])), + "next_up": next_up, + }) + } + + /// Find the next unfinished video index after the current position. + fn _compute_next_up(&self, current_index: Option) -> Value { + let start = current_index.map(|i| i + 1).unwrap_or(0); + for i in start..self.fids.len() { + let fid = &self.fids[i]; + let finished = self + .state + .videos + .get(fid) + .map(|m| m.finished) + .unwrap_or(false); + if !finished { + return json!(i); + } + } + // Wrap around from beginning. + let end = current_index.unwrap_or(self.fids.len()); + for i in 0..end.min(self.fids.len()) { + let fid = &self.fids[i]; + let finished = self + .state + .videos + .get(fid) + .map(|m| m.finished) + .unwrap_or(false); + if !finished { + return json!(i); + } + } + Value::Null + } + + // ----------------------------------------------------------------------- + // update_progress + // ----------------------------------------------------------------------- + + /// Update playback progress for a video at the given index. + /// + /// The `finished` flag is sticky: once set, it remains true even if + /// `current_time` decreases. + pub fn update_progress( + &mut self, + index: usize, + current_time: f64, + duration: Option, + playing: bool, + ) -> Value { + if self.fids.is_empty() { + return json!({"ok": false, "error": "library is empty"}); + } + + let index = index.min(self.fids.len() - 1); + let fid = self.fids[index].clone(); + + let meta = self + .state + .videos + .entry(fid.clone()) + .or_insert_with(VideoMeta::default); + + meta.pos = current_time; + + // High-water mark. + if current_time > meta.watched { + meta.watched = current_time; + } + + // Update duration if provided and positive. + if let Some(d) = duration { + if d > 0.0 { + meta.duration = Some(d); + } + } + + // Finished is sticky. + let effective_duration = meta.duration.unwrap_or(0.0); + let threshold = (effective_duration - 2.0).max(0.0); + if effective_duration > 0.0 && current_time >= threshold { + meta.finished = true; + } + // Note: if already finished, it stays finished. + + // Update last_open timestamp if actively playing. + if playing { + meta.last_open = now_secs(); + } + + // Update current tracking. + self.state.current_fid = Some(fid); + self.state.current_time = current_time; + + // Snapshot values before releasing the borrow on self.state.videos. + let pos = meta.pos; + let watched = meta.watched; + let duration_val = meta.duration; + let finished = meta.finished; + + self.save_state(); + + json!({ + "ok": true, + "index": index, + "pos": pos, + "watched": watched, + "duration": duration_val, + "finished": finished, + }) + } + + // ----------------------------------------------------------------------- + // set_current + // ----------------------------------------------------------------------- + + /// Set the currently active video by index and timecode. + pub fn set_current(&mut self, index: usize, timecode: f64) -> Value { + if self.fids.is_empty() { + return json!({"ok": false, "error": "library is empty"}); + } + + let index = index.min(self.fids.len() - 1); + let fid = self.fids[index].clone(); + + self.state.current_fid = Some(fid.clone()); + self.state.current_time = timecode; + + self.save_state(); + + json!({ + "ok": true, + "index": index, + "fid": fid, + "current_time": timecode, + }) + } + + // ----------------------------------------------------------------------- + // set_folder_volume + // ----------------------------------------------------------------------- + + /// Set the folder-level volume (clamped 0.0 to 1.0). + pub fn set_folder_volume(&mut self, volume: f64) -> Value { + self.state.volume = clamp(volume, 0.0, 1.0); + self.save_state(); + json!({"ok": true, "folder_volume": self.state.volume}) + } + + // ----------------------------------------------------------------------- + // set_folder_autoplay + // ----------------------------------------------------------------------- + + /// Set the folder-level autoplay preference. + pub fn set_folder_autoplay(&mut self, enabled: bool) -> Value { + self.state.autoplay = enabled; + self.save_state(); + json!({"ok": true, "folder_autoplay": self.state.autoplay}) + } + + // ----------------------------------------------------------------------- + // set_folder_rate + // ----------------------------------------------------------------------- + + /// Set the folder-level playback rate (clamped 0.25 to 3.0). + pub fn set_folder_rate(&mut self, rate: f64) -> Value { + self.state.playback_rate = clamp(rate, 0.25, 3.0); + self.save_state(); + json!({"ok": true, "folder_rate": self.state.playback_rate}) + } + + // ----------------------------------------------------------------------- + // set_order + // ----------------------------------------------------------------------- + + /// Reorder the playlist. `fids` contains the desired ordering; any fids + /// not in the list are appended at the end (naturally sorted). + pub fn set_order(&mut self, fids: Vec) -> Value { + let valid_set: std::collections::HashSet<&String> = self.fids.iter().collect(); + + // Keep only fids that actually exist. + let mut ordered: Vec = fids.into_iter().filter(|f| valid_set.contains(f)).collect(); + + // Append remaining fids not in the new order. + let ordered_set: std::collections::HashSet<&String> = ordered.iter().collect(); + let mut remaining: Vec = self + .fids + .iter() + .filter(|f| !ordered_set.contains(f)) + .cloned() + .collect(); + remaining.sort_by(|a, b| { + let ra = self.fid_to_rel.get(a).cloned().unwrap_or_default(); + let rb = self.fid_to_rel.get(b).cloned().unwrap_or_default(); + natural_key(&ra).cmp(&natural_key(&rb)) + }); + ordered.extend(remaining); + + // Rebuild file lists. + self.fids = ordered.clone(); + self.relpaths = ordered + .iter() + .filter_map(|fid| self.fid_to_rel.get(fid).cloned()) + .collect(); + if let Some(ref root) = self.root { + self.files = self + .relpaths + .iter() + .map(|rel| root.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR))) + .collect(); + } + + self.state.order_fids = ordered; + self.save_state(); + + self.get_library_info() + } + + // ----------------------------------------------------------------------- + // get_note / set_note + // ----------------------------------------------------------------------- + + /// Return the note for the given fid, or an empty string if not found. + pub fn get_note(&self, fid: &str) -> String { + self.state + .videos + .get(fid) + .map(|m| m.note.clone()) + .unwrap_or_default() + } + + /// Set a note for the given fid and save state. + pub fn set_note(&mut self, fid: &str, note: &str) -> Value { + if let Some(meta) = self.state.videos.get_mut(fid) { + meta.note = note.to_string(); + } else { + let mut m = VideoMeta::default(); + m.note = note.to_string(); + self.state.videos.insert(fid.to_string(), m); + } + self.save_state(); + json!({"ok": true, "fid": fid, "note_len": note.len()}) + } + + // ----------------------------------------------------------------------- + // get_video_path + // ----------------------------------------------------------------------- + + /// Get the full file path for a video by index. + /// + /// Performs bounds checking and path-traversal protection. + pub fn get_video_path(&self, index: usize) -> Result { + if self.files.is_empty() { + return Err("No files in library".to_string()); + } + if index >= self.files.len() { + return Err(format!( + "Index {} out of range (0..{})", + index, + self.files.len() + )); + } + + let path = &self.files[index]; + + // Path-traversal protection. + if let Some(ref root) = self.root { + if !is_within_root(root, path) { + return Err("Path traversal detected".to_string()); + } + } + + if !path.exists() { + return Err(format!("File not found: {}", path.display())); + } + + Ok(path.clone()) + } + + // ----------------------------------------------------------------------- + // get_current_video_metadata + // ----------------------------------------------------------------------- + + /// Return basic file metadata plus cached ffprobe metadata for the + /// current video. + pub fn get_current_video_metadata(&mut self) -> Value { + let fid = match &self.state.current_fid { + Some(f) => f.clone(), + None => return json!({"ok": false, "error": "no current video"}), + }; + + let index = match self.fids.iter().position(|f| f == &fid) { + Some(i) => i, + None => return json!({"ok": false, "error": "current fid not in library"}), + }; + + let mut result = self._basic_file_meta(&fid); + + // Probe if not cached. + if !self.meta_cache.contains_key(&fid) { + if let Some(ref ffprobe) = self.ffprobe.clone() { + if index < self.files.len() { + if let Some(meta) = + ffmpeg::ffprobe_video_metadata(&self.files[index], ffprobe) + { + self.meta_cache.insert(fid.clone(), meta); + } + } + } + } + + if let Some(cached) = self.meta_cache.get(&fid) { + if let Ok(meta_val) = serde_json::to_value(cached) { + result + .as_object_mut() + .unwrap() + .insert("probe".to_string(), meta_val); + } + } + + result + .as_object_mut() + .unwrap() + .insert("ok".to_string(), json!(true)); + result + } + + // ----------------------------------------------------------------------- + // Subtitle methods + // ----------------------------------------------------------------------- + + /// Get subtitle for the current video. + /// + /// Priority: stored subtitle -> sidecar -> embedded. + pub fn get_subtitle_for_current(&mut self, state_dir: &Path) -> Value { + let fid = match &self.state.current_fid { + Some(f) => f.clone(), + None => return json!({"ok": false, "error": "no current video"}), + }; + + let index = match self.fids.iter().position(|f| f == &fid) { + Some(i) => i, + None => return json!({"ok": false, "error": "current fid not in library"}), + }; + + // 1. Check stored subtitle. + if let Some(meta) = self.state.videos.get(&fid) { + 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, + }); + } + } + } + + // 2. Try sidecar. + if index < self.files.len() { + if let Some(sidecar_path) = subtitles::auto_subtitle_sidecar(&self.files[index]) { + let subs_dir = state_dir.join("subtitles"); + if let Some(stored) = + subtitles::store_subtitle_for_fid(&fid, &sidecar_path, &subs_dir) + { + // Save reference. + if let Some(meta) = self.state.videos.get_mut(&fid) { + meta.subtitle = Some(SubtitleRef { + vtt: stored.vtt.clone(), + label: stored.label.clone(), + }); + } + self.save_state(); + return json!({ + "ok": true, + "source": "sidecar", + "vtt": stored.vtt, + "label": stored.label, + }); + } + } + } + + // 3. Try embedded (first subtitle track). + if let Some(ref ffmpeg_path) = self.ffmpeg.clone() { + if let Some(ref ffprobe_path) = self.ffprobe.clone() { + if index < self.files.len() { + if let Some(meta) = + ffmpeg::ffprobe_video_metadata(&self.files[index], ffprobe_path) + { + if let Some(track) = meta.subtitle_tracks.first() { + let subs_dir = state_dir.join("subtitles"); + if let Ok(stored) = subtitles::extract_embedded_subtitle( + &self.files[index], + track.index, + ffmpeg_path, + &subs_dir, + &fid, + ) { + if let Some(vmeta) = self.state.videos.get_mut(&fid) { + vmeta.subtitle = Some(SubtitleRef { + vtt: stored.vtt.clone(), + label: stored.label.clone(), + }); + } + self.save_state(); + return json!({ + "ok": true, + "source": "embedded", + "vtt": stored.vtt, + "label": stored.label, + }); + } + } + } + } + } + } + + json!({"ok": true, "source": "none"}) + } + + /// Store a user-selected subtitle file for the current video. + pub fn set_subtitle_for_current(&mut self, file_path: &str, state_dir: &Path) -> Value { + let fid = match &self.state.current_fid { + Some(f) => f.clone(), + None => return json!({"ok": false, "error": "no current video"}), + }; + + let src = PathBuf::from(file_path); + if !src.is_file() { + return json!({"ok": false, "error": "subtitle file not found"}); + } + + let subs_dir = state_dir.join("subtitles"); + match subtitles::store_subtitle_for_fid(&fid, &src, &subs_dir) { + Some(stored) => { + if let Some(meta) = self.state.videos.get_mut(&fid) { + meta.subtitle = Some(SubtitleRef { + vtt: stored.vtt.clone(), + label: stored.label.clone(), + }); + } + self.save_state(); + json!({ + "ok": true, + "vtt": stored.vtt, + "label": stored.label, + }) + } + None => { + json!({"ok": false, "error": "unsupported subtitle format"}) + } + } + } + + /// Get list of embedded subtitle tracks using ffprobe. + pub fn get_embedded_subtitles(&self) -> Value { + let fid = match &self.state.current_fid { + Some(f) => f.clone(), + None => return json!({"ok": false, "error": "no current video"}), + }; + + let index = match self.fids.iter().position(|f| f == &fid) { + Some(i) => i, + None => return json!({"ok": false, "error": "current fid not in library"}), + }; + + if let Some(ref ffprobe_path) = self.ffprobe { + if index < self.files.len() { + if let Some(meta) = + ffmpeg::ffprobe_video_metadata(&self.files[index], ffprobe_path) + { + let tracks: Vec = meta + .subtitle_tracks + .iter() + .map(|t| { + json!({ + "index": t.index, + "codec": t.codec, + "language": t.language, + "title": t.title, + }) + }) + .collect(); + return json!({"ok": true, "tracks": tracks}); + } + } + } + + json!({"ok": true, "tracks": []}) + } + + /// Extract an embedded subtitle track by index. + pub fn extract_embedded_subtitle( + &mut self, + track_index: u32, + state_dir: &Path, + ) -> Value { + let fid = match &self.state.current_fid { + Some(f) => f.clone(), + None => return json!({"ok": false, "error": "no current video"}), + }; + + let index = match self.fids.iter().position(|f| f == &fid) { + Some(i) => i, + None => return json!({"ok": false, "error": "current fid not in library"}), + }; + + let ffmpeg_path = match &self.ffmpeg { + Some(p) => p.clone(), + None => return json!({"ok": false, "error": "ffmpeg not available"}), + }; + + if index >= self.files.len() { + return json!({"ok": false, "error": "index out of range"}); + } + + let subs_dir = state_dir.join("subtitles"); + match subtitles::extract_embedded_subtitle( + &self.files[index], + track_index, + &ffmpeg_path, + &subs_dir, + &fid, + ) { + Ok(stored) => { + if let Some(meta) = self.state.videos.get_mut(&fid) { + meta.subtitle = Some(SubtitleRef { + vtt: stored.vtt.clone(), + label: stored.label.clone(), + }); + } + self.save_state(); + json!({ + "ok": true, + "vtt": stored.vtt, + "label": stored.label, + }) + } + Err(e) => { + json!({"ok": false, "error": e}) + } + } + } + + /// Get all available sidecar and embedded subtitles for the current video. + /// + /// Sidecar discovery uses normalized matching: + /// - Check all .srt/.vtt files in video's parent directory + /// - Match by: exact stem, normalized stem, stem with language suffix + /// - Extract language labels from filename suffixes + /// - Sort: English first, then alphabetical + /// - Deduplicate by label + pub fn get_available_subtitles(&self) -> Value { + let fid = match &self.state.current_fid { + Some(f) => f.clone(), + None => return json!({"ok": false, "error": "no current video"}), + }; + + let index = match self.fids.iter().position(|f| f == &fid) { + Some(i) => i, + None => return json!({"ok": false, "error": "current fid not in library"}), + }; + + let mut sidecar_subs: Vec = Vec::new(); + + if index < self.files.len() { + let video_path = &self.files[index]; + if let Some(parent) = video_path.parent() { + let video_stem = video_path + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + let video_stem_lower = video_stem.to_lowercase(); + let video_stem_norm = _normalize_stem(&video_stem); + + if let Ok(entries) = fs::read_dir(parent) { + let mut found: Vec<(String, String, bool)> = Vec::new(); // (label, path, is_english) + let mut seen_labels: std::collections::HashSet = + std::collections::HashSet::new(); + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + + let fname = match path.file_name() { + Some(n) => n.to_string_lossy().to_string(), + None => continue, + }; + let fname_lower = fname.to_lowercase(); + + let is_sub = subtitles::SUB_EXTS + .iter() + .any(|ext| fname_lower.ends_with(ext)); + if !is_sub { + continue; + } + + let sub_stem = match path.file_stem() { + Some(s) => s.to_string_lossy().to_string(), + None => continue, + }; + let sub_stem_lower = sub_stem.to_lowercase(); + let sub_stem_norm = _normalize_stem(&sub_stem); + + // Check if this subtitle matches the video. + let mut matched = false; + let mut lang_label = String::new(); + let mut is_english = false; + + // Exact stem match. + if sub_stem_lower == video_stem_lower { + matched = true; + lang_label = "Default".to_string(); + } + + // Normalized match. + if !matched && sub_stem_norm == video_stem_norm { + matched = true; + lang_label = "Default".to_string(); + } + + // Language suffix match. + if !matched { + if let Some(dot_pos) = sub_stem.rfind('.') { + let base = &sub_stem[..dot_pos]; + let suffix = &sub_stem[dot_pos + 1..]; + let base_lower = base.to_lowercase(); + let base_norm = _normalize_stem(base); + let suffix_lower = suffix.to_lowercase(); + + if base_lower == video_stem_lower + || base_norm == video_stem_norm + { + matched = true; + lang_label = _language_label(&suffix_lower); + is_english = ["en", "eng", "english"] + .contains(&suffix_lower.as_str()); + } + } + } + + if matched { + if seen_labels.insert(lang_label.clone()) { + found.push(( + lang_label, + path.to_string_lossy().to_string(), + is_english, + )); + } + } + } + + // Sort: English first, then alphabetical. + found.sort_by(|a, b| { + if a.2 && !b.2 { + std::cmp::Ordering::Less + } else if !a.2 && b.2 { + std::cmp::Ordering::Greater + } else { + a.0.cmp(&b.0) + } + }); + + for (label, path, _) in &found { + sidecar_subs.push(json!({ + "type": "sidecar", + "label": label, + "path": path, + })); + } + } + } + } + + // Embedded subtitles. + let mut embedded_subs: Vec = Vec::new(); + if let Some(ref ffprobe_path) = self.ffprobe { + if index < self.files.len() { + if let Some(meta) = + ffmpeg::ffprobe_video_metadata(&self.files[index], ffprobe_path) + { + for track in &meta.subtitle_tracks { + let label = if !track.title.is_empty() { + track.title.clone() + } else if !track.language.is_empty() { + _language_label(&track.language) + } else { + format!("Track {}", track.index) + }; + embedded_subs.push(json!({ + "type": "embedded", + "label": label, + "index": track.index, + "codec": track.codec, + "language": track.language, + })); + } + } + } + } + + json!({ + "ok": true, + "sidecar": sidecar_subs, + "embedded": embedded_subs, + }) + } + + /// Load a specific sidecar subtitle file for the current video. + pub fn load_sidecar_subtitle( + &mut self, + file_path: &str, + state_dir: &Path, + ) -> Value { + let fid = match &self.state.current_fid { + Some(f) => f.clone(), + None => return json!({"ok": false, "error": "no current video"}), + }; + + let src = PathBuf::from(file_path); + if !src.is_file() { + return json!({"ok": false, "error": "subtitle file not found"}); + } + + let subs_dir = state_dir.join("subtitles"); + match subtitles::store_subtitle_for_fid(&fid, &src, &subs_dir) { + Some(stored) => { + if let Some(meta) = self.state.videos.get_mut(&fid) { + meta.subtitle = Some(SubtitleRef { + vtt: stored.vtt.clone(), + label: stored.label.clone(), + }); + } + self.save_state(); + json!({ + "ok": true, + "vtt": stored.vtt, + "label": stored.label, + }) + } + None => { + json!({"ok": false, "error": "unsupported subtitle format or read error"}) + } + } + } + + // ----------------------------------------------------------------------- + // reset_watch_progress + // ----------------------------------------------------------------------- + + /// Reset pos, watched, and finished for ALL videos. + /// + /// Preserves notes, durations, and subtitle references. + pub fn reset_watch_progress(&mut self) -> Value { + for meta in self.state.videos.values_mut() { + meta.pos = 0.0; + meta.watched = 0.0; + meta.finished = false; + } + self.state.current_time = 0.0; + self.save_state(); + json!({"ok": true}) + } + + // ----------------------------------------------------------------------- + // Duration scanning + // ----------------------------------------------------------------------- + + /// Return a list of (fid, file_path) pairs where duration is unknown. + pub fn get_pending_scans(&self) -> Vec<(String, PathBuf)> { + let mut pending = Vec::new(); + for (i, fid) in self.fids.iter().enumerate() { + let has_duration = self + .state + .videos + .get(fid) + .and_then(|m| m.duration) + .is_some(); + if !has_duration && i < self.files.len() { + pending.push((fid.clone(), self.files[i].clone())); + } + } + pending + } + + /// Apply a scanned duration to a video's metadata. + /// + /// Also re-checks the finished status: if watched >= duration - 2, + /// mark finished. + pub fn apply_scanned_duration(&mut self, fid: &str, duration: f64) { + if duration <= 0.0 { + return; + } + let meta = self + .state + .videos + .entry(fid.to_string()) + .or_insert_with(VideoMeta::default); + meta.duration = Some(duration); + + // Re-check finished status with the new duration. + let threshold = (duration - 2.0).max(0.0); + if meta.watched >= threshold { + meta.finished = true; + } + + self.save_state(); + } + + /// Start a background duration scan. + /// + /// 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) { + // Signal any running scan to stop. + self.scan_stop.store(true, Ordering::SeqCst); + // Create a fresh stop flag for any new scan cycle. + self.scan_stop = Arc::new(AtomicBool::new(false)); + } + + /// Check whether the background scan has been signalled to stop. + pub fn is_scan_stopped(&self) -> bool { + self.scan_stop.load(Ordering::SeqCst) + } + + /// Get a reference to the scan stop flag for external use. + pub fn scan_stop_flag(&self) -> Arc { + Arc::clone(&self.scan_stop) + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + /// Compute folder statistics: finished/remaining counts, progress, etc. + fn _folder_stats(&self) -> Value { + let total = self.fids.len(); + let mut finished_count: usize = 0; + let mut total_duration: f64 = 0.0; + let mut total_watched: f64 = 0.0; + let mut all_durations_known = true; + let mut remaining_seconds: f64 = 0.0; + + // Track top-level folder progress. + let mut folder_totals: HashMap = HashMap::new(); // (total, finished) + + for fid in &self.fids { + let meta = self.state.videos.get(fid); + let finished = meta.map(|m| m.finished).unwrap_or(false); + let duration = meta.and_then(|m| m.duration); + let watched = meta.map(|m| m.watched).unwrap_or(0.0); + + if finished { + finished_count += 1; + } + + if let Some(d) = duration { + total_duration += d; + total_watched += watched.min(d); + if !finished { + remaining_seconds += (d - watched).max(0.0); + } + } else { + all_durations_known = false; + } + + // Top-level folder grouping. + let relpath = self.fid_to_rel.get(fid).cloned().unwrap_or_default(); + let top_folder = if let Some(pos) = relpath.find('/') { + relpath[..pos].to_string() + } else { + String::new() + }; + let entry = folder_totals.entry(top_folder).or_insert((0, 0)); + entry.0 += 1; + if finished { + entry.1 += 1; + } + } + + let overall_progress = if total > 0 { + if total_duration > 0.0 { + (total_watched / total_duration * 100.0).min(100.0) + } else { + (finished_count as f64 / total as f64) * 100.0 + } + } else { + 0.0 + }; + + let remaining_count = total - finished_count; + + // Build top_folders list (only if there are subdirs). + let mut top_folders: Vec = Vec::new(); + let mut folder_names: Vec = folder_totals.keys().cloned().collect(); + folder_names.sort_by(|a, b| natural_key(a).cmp(&natural_key(b))); + for name in &folder_names { + if name.is_empty() { + continue; // Skip root-level files. + } + let (ft, ff) = folder_totals[name]; + top_folders.push(json!({ + "name": name, + "total": ft, + "finished": ff, + })); + } + + json!({ + "finished_count": finished_count, + "remaining_count": remaining_count, + "remaining_seconds_known": remaining_seconds, + "overall_progress": overall_progress, + "durations_known": all_durations_known, + "top_folders": top_folders, + }) + } + + /// Get basic file metadata (extension, size, mtime, folder). + fn _basic_file_meta(&self, fid: &str) -> Value { + let relpath = self.fid_to_rel.get(fid).cloned().unwrap_or_default(); + let index = self.fids.iter().position(|f| f == fid); + + if let Some(i) = index { + if i < self.files.len() { + let path = &self.files[i]; + let ext = path + .extension() + .map(|e| e.to_string_lossy().to_string()) + .unwrap_or_default(); + + let (size, mtime) = match fs::metadata(path) { + Ok(m) => { + let size = m.len(); + let mtime = m + .modified() + .ok() + .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + (size, mtime) + } + Err(_) => (0, 0), + }; + + let folder = path + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + + return json!({ + "fid": fid, + "relpath": relpath, + "ext": ext, + "size": size, + "mtime": mtime, + "folder": folder, + }); + } + } + + json!({ + "fid": fid, + "relpath": relpath, + "ext": "", + "size": 0, + "mtime": 0, + "folder": "", + }) + } +} + +// --------------------------------------------------------------------------- +// Module-level helpers +// --------------------------------------------------------------------------- + +/// Normalize a stem for fuzzy matching (lowercase, collapse separators). +fn _normalize_stem(s: &str) -> String { + let lower = s.to_lowercase(); + let re = regex::Regex::new(r"[-_\s]+").unwrap(); + let replaced = re.replace_all(&lower, " "); + replaced.trim().to_string() +} + +/// Convert a language suffix to a human-readable label. +fn _language_label(lang: &str) -> String { + match lang.to_lowercase().as_str() { + "en" | "eng" | "english" => "English".to_string(), + "fr" | "fre" | "fra" | "french" => "French".to_string(), + "de" | "deu" | "ger" | "german" => "German".to_string(), + "es" | "spa" | "spanish" => "Spanish".to_string(), + "it" | "ita" | "italian" => "Italian".to_string(), + "pt" | "por" | "portuguese" => "Portuguese".to_string(), + "ru" | "rus" | "russian" => "Russian".to_string(), + "ja" | "jpn" | "japanese" => "Japanese".to_string(), + "ko" | "kor" | "korean" => "Korean".to_string(), + "zh" | "zho" | "chi" | "chinese" => "Chinese".to_string(), + "ar" | "ara" | "arabic" => "Arabic".to_string(), + "hi" | "hin" | "hindi" => "Hindi".to_string(), + "nl" | "dut" | "nld" | "dutch" => "Dutch".to_string(), + "sv" | "swe" | "swedish" => "Swedish".to_string(), + "pl" | "pol" | "polish" => "Polish".to_string(), + other => { + // Capitalize first letter. + let mut c = other.chars(); + match c.next() { + Some(first) => { + let upper: String = first.to_uppercase().collect(); + format!("{}{}", upper, c.as_str()) + } + None => String::new(), + } + } + } +} + +// =========================================================================== +// Tests +// =========================================================================== + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + // ----------------------------------------------------------------------- + // 1. test_new_library_is_empty + // ----------------------------------------------------------------------- + + #[test] + fn test_new_library_is_empty() { + let lib = Library::new(); + assert!(lib.root.is_none()); + assert!(lib.files.is_empty()); + assert!(lib.fids.is_empty()); + assert!(lib.relpaths.is_empty()); + assert!(lib.rel_to_fid.is_empty()); + assert!(lib.fid_to_rel.is_empty()); + assert!(lib.state_path.is_none()); + assert!(lib.state.current_fid.is_none()); + assert_eq!(lib.state.library_id, ""); + assert_eq!(lib.state.volume, 1.0); + assert_eq!(lib.state.playback_rate, 1.0); + assert!(!lib.state.autoplay); + } + + // ----------------------------------------------------------------------- + // 2. test_sorted_default + // ----------------------------------------------------------------------- + + #[test] + fn test_sorted_default() { + let relpaths = vec![ + "video10.mp4".to_string(), + "video2.mp4".to_string(), + "video1.mp4".to_string(), + "video20.mp4".to_string(), + ]; + let mut rel_to_fid = HashMap::new(); + for (i, rel) in relpaths.iter().enumerate() { + rel_to_fid.insert(rel.clone(), format!("fid{}", i)); + } + + let sorted = _sorted_default(&relpaths, &rel_to_fid); + let sorted_rels: Vec = sorted + .iter() + .map(|fid| { + rel_to_fid + .iter() + .find(|(_, v)| *v == fid) + .map(|(k, _)| k.clone()) + .unwrap() + }) + .collect(); + + assert_eq!( + sorted_rels, + vec![ + "video1.mp4".to_string(), + "video2.mp4".to_string(), + "video10.mp4".to_string(), + "video20.mp4".to_string(), + ] + ); + } + + // ----------------------------------------------------------------------- + // 3. test_apply_saved_order_with_valid_order + // ----------------------------------------------------------------------- + + #[test] + fn test_apply_saved_order_with_valid_order() { + let all_fids = vec!["a".to_string(), "b".to_string(), "c".to_string()]; + let mut fid_to_rel = HashMap::new(); + fid_to_rel.insert("a".to_string(), "01.mp4".to_string()); + fid_to_rel.insert("b".to_string(), "02.mp4".to_string()); + fid_to_rel.insert("c".to_string(), "03.mp4".to_string()); + + let order = vec!["c".to_string(), "a".to_string(), "b".to_string()]; + let result = _apply_saved_order(&all_fids, &fid_to_rel, &order); + assert_eq!(result, vec!["c", "a", "b"]); + } + + // ----------------------------------------------------------------------- + // 4. test_apply_saved_order_with_new_files + // ----------------------------------------------------------------------- + + #[test] + fn test_apply_saved_order_with_new_files() { + let all_fids = vec![ + "a".to_string(), + "b".to_string(), + "c".to_string(), + "d".to_string(), + ]; + let mut fid_to_rel = HashMap::new(); + fid_to_rel.insert("a".to_string(), "01.mp4".to_string()); + fid_to_rel.insert("b".to_string(), "02.mp4".to_string()); + fid_to_rel.insert("c".to_string(), "03.mp4".to_string()); + fid_to_rel.insert("d".to_string(), "04.mp4".to_string()); + + // Saved order only has a and c; b and d are "new". + let order = vec!["c".to_string(), "a".to_string()]; + let result = _apply_saved_order(&all_fids, &fid_to_rel, &order); + + // c, a come first (saved order), then b, d (naturally sorted). + assert_eq!(result, vec!["c", "a", "b", "d"]); + } + + // ----------------------------------------------------------------------- + // 5. test_apply_saved_order_empty + // ----------------------------------------------------------------------- + + #[test] + fn test_apply_saved_order_empty() { + let all_fids = vec!["b".to_string(), "a".to_string(), "c".to_string()]; + let mut fid_to_rel = HashMap::new(); + fid_to_rel.insert("a".to_string(), "01_alpha.mp4".to_string()); + fid_to_rel.insert("b".to_string(), "02_beta.mp4".to_string()); + fid_to_rel.insert("c".to_string(), "03_gamma.mp4".to_string()); + + let result = _apply_saved_order(&all_fids, &fid_to_rel, &[]); + + // Falls back to natural sort by relpath. + assert_eq!(result, vec!["a", "b", "c"]); + } + + // ----------------------------------------------------------------------- + // 6. test_tree_flags_flat + // ----------------------------------------------------------------------- + + #[test] + fn test_tree_flags_flat() { + let rels = vec![ + "video1.mp4".to_string(), + "video2.mp4".to_string(), + "video3.mp4".to_string(), + ]; + let flags = _tree_flags(&rels); + + for rel in &rels { + let f = flags.get(rel).unwrap(); + assert_eq!(f["depth"], 0); + assert_eq!(f["pipes"], ""); + } + + // Last file should have is_last = true. + assert_eq!(flags["video3.mp4"]["is_last"], true); + // First file should not have has_prev_in_parent. + assert_eq!(flags["video1.mp4"]["has_prev_in_parent"], false); + // Second file should have has_prev_in_parent. + assert_eq!(flags["video2.mp4"]["has_prev_in_parent"], true); + } + + // ----------------------------------------------------------------------- + // 7. test_tree_flags_nested + // ----------------------------------------------------------------------- + + #[test] + fn test_tree_flags_nested() { + let rels = vec![ + "intro.mp4".to_string(), + "chapter1/lesson1.mp4".to_string(), + "chapter1/lesson2.mp4".to_string(), + "chapter2/lesson1.mp4".to_string(), + ]; + let flags = _tree_flags(&rels); + + // Root-level file. + assert_eq!(flags["intro.mp4"]["depth"], 0); + + // Nested files. + assert_eq!(flags["chapter1/lesson1.mp4"]["depth"], 1); + assert_eq!(flags["chapter1/lesson2.mp4"]["depth"], 1); + assert_eq!(flags["chapter2/lesson1.mp4"]["depth"], 1); + + // chapter1/lesson1 should not have prev in parent. + assert_eq!(flags["chapter1/lesson1.mp4"]["has_prev_in_parent"], false); + // chapter1/lesson2 should have prev in parent. + assert_eq!(flags["chapter1/lesson2.mp4"]["has_prev_in_parent"], true); + // chapter1/lesson2 is the last in chapter1. + assert_eq!(flags["chapter1/lesson2.mp4"]["is_last"], true); + // chapter2/lesson1 is the only (and last) in chapter2. + assert_eq!(flags["chapter2/lesson1.mp4"]["is_last"], true); + } + + // ----------------------------------------------------------------------- + // 8. test_set_root_with_video_files + // ----------------------------------------------------------------------- + + #[test] + fn test_set_root_with_video_files() { + let dir = TempDir::new().unwrap(); + let state_dir = TempDir::new().unwrap(); + + // Create dummy video files. + fs::write(dir.path().join("video_a.mp4"), b"fake video a content").unwrap(); + fs::write(dir.path().join("video_b.mp4"), b"fake video b content").unwrap(); + fs::write(dir.path().join("video_c.mp4"), b"fake video c content").unwrap(); + + // Also create a non-video file (should be ignored). + fs::write(dir.path().join("readme.txt"), b"not a video").unwrap(); + + let mut lib = Library::new(); + let folder_str = dir.path().to_string_lossy().to_string(); + let result = lib.set_root(&folder_str, state_dir.path()); + + assert!(result.is_ok(), "set_root failed: {:?}", result.err()); + + let info = result.unwrap(); + assert_eq!(info["ok"], true); + assert_eq!(info["count"], 3); + assert_eq!(lib.fids.len(), 3); + assert_eq!(lib.relpaths.len(), 3); + assert_eq!(lib.files.len(), 3); + + // All relpaths should be video files. + for rel in &lib.relpaths { + assert!(rel.ends_with(".mp4"), "unexpected relpath: {}", rel); + } + + // State file should exist. + assert!(lib.state_path.is_some()); + assert!(lib.state_path.as_ref().unwrap().exists()); + + // Library ID should be non-empty. + assert!(!lib.state.library_id.is_empty()); + } + + // ----------------------------------------------------------------------- + // 9. test_update_progress_finished_sticky + // ----------------------------------------------------------------------- + + #[test] + fn test_update_progress_finished_sticky() { + let dir = TempDir::new().unwrap(); + let state_dir = TempDir::new().unwrap(); + + fs::write(dir.path().join("video.mp4"), b"content").unwrap(); + + let mut lib = Library::new(); + let folder_str = dir.path().to_string_lossy().to_string(); + lib.set_root(&folder_str, state_dir.path()).unwrap(); + + // Set duration and advance to near the end to trigger finished. + let result = lib.update_progress(0, 98.5, Some(100.0), true); + assert_eq!(result["finished"], true); + + // Now go back to an earlier time -- finished should remain true. + let result = lib.update_progress(0, 10.0, Some(100.0), true); + assert_eq!(result["finished"], true); + + // Verify pos updated but finished stayed sticky. + assert_eq!(result["pos"], 10.0); + + // Watched should still be the high-water mark. + assert_eq!(result["watched"], 98.5); + } + + // ----------------------------------------------------------------------- + // 10. test_reset_watch_progress + // ----------------------------------------------------------------------- + + #[test] + fn test_reset_watch_progress() { + let dir = TempDir::new().unwrap(); + let state_dir = TempDir::new().unwrap(); + + fs::write(dir.path().join("vid1.mp4"), b"video 1").unwrap(); + fs::write(dir.path().join("vid2.mp4"), b"video 2").unwrap(); + + let mut lib = Library::new(); + let folder_str = dir.path().to_string_lossy().to_string(); + lib.set_root(&folder_str, state_dir.path()).unwrap(); + + // Add some progress and notes. + lib.update_progress(0, 50.0, Some(100.0), true); + lib.update_progress(1, 99.0, Some(100.0), true); + lib.set_note(&lib.fids[0].clone(), "Important lecture"); + + // Verify pre-conditions. + let fid0 = lib.fids[0].clone(); + let fid1 = lib.fids[1].clone(); + assert!(lib.state.videos[&fid0].watched > 0.0); + assert!(lib.state.videos[&fid1].finished); + assert!(!lib.state.videos[&fid0].note.is_empty()); + assert!(lib.state.videos[&fid0].duration.is_some()); + + // Reset. + let result = lib.reset_watch_progress(); + assert_eq!(result["ok"], true); + + // Progress should be reset. + assert_eq!(lib.state.videos[&fid0].pos, 0.0); + assert_eq!(lib.state.videos[&fid0].watched, 0.0); + assert!(!lib.state.videos[&fid0].finished); + assert_eq!(lib.state.videos[&fid1].pos, 0.0); + assert_eq!(lib.state.videos[&fid1].watched, 0.0); + assert!(!lib.state.videos[&fid1].finished); + + // Notes and durations should be preserved. + assert_eq!(lib.state.videos[&fid0].note, "Important lecture"); + assert!(lib.state.videos[&fid0].duration.is_some()); + assert!(lib.state.videos[&fid1].duration.is_some()); + } +} diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..987bf37 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,92 @@ +import { invoke } from '@tauri-apps/api/core'; +import type { + LibraryInfo, + OkResponse, + PrefsResponse, + RecentsResponse, + NoteResponse, + VideoMetaResponse, + SubtitleResponse, + AvailableSubsResponse, + EmbeddedSubsResponse, +} from './types'; + +export const api = { + selectFolder: () => + invoke('select_folder'), + + openFolderPath: (folder: string) => + invoke('open_folder_path', { folder }), + + getRecents: () => + invoke('get_recents'), + + removeRecent: (path: string) => + invoke('remove_recent', { path }), + + getLibrary: () => + invoke('get_library'), + + setCurrent: (index: number, timecode: number = 0) => + invoke('set_current', { index, timecode }), + + tickProgress: (index: number, currentTime: number, duration: number | null, playing: boolean) => + invoke('tick_progress', { index, current_time: currentTime, duration, playing }), + + setFolderVolume: (volume: number) => + invoke('set_folder_volume', { volume }), + + setFolderAutoplay: (enabled: boolean) => + invoke('set_folder_autoplay', { enabled }), + + setFolderRate: (rate: number) => + invoke('set_folder_rate', { rate }), + + setOrder: (fids: string[]) => + invoke('set_order', { fids }), + + startDurationScan: () => + invoke('start_duration_scan'), + + getPrefs: () => + invoke('get_prefs'), + + setPrefs: (patch: Record) => + invoke('set_prefs', { patch }), + + setAlwaysOnTop: (enabled: boolean) => + invoke('set_always_on_top', { enabled }), + + saveWindowState: () => + invoke('save_window_state'), + + getNote: (fid: string) => + invoke('get_note', { fid }), + + setNote: (fid: string, note: string) => + invoke('set_note', { fid, note }), + + getCurrentVideoMeta: () => + invoke('get_current_video_meta'), + + getCurrentSubtitle: () => + invoke('get_current_subtitle'), + + getEmbeddedSubtitles: () => + invoke('get_embedded_subtitles'), + + extractEmbeddedSubtitle: (trackIndex: number) => + invoke('extract_embedded_subtitle', { track_index: trackIndex }), + + getAvailableSubtitles: () => + invoke('get_available_subtitles'), + + loadSidecarSubtitle: (filePath: string) => + invoke('load_sidecar_subtitle', { file_path: filePath }), + + chooseSubtitleFile: () => + invoke('choose_subtitle_file'), + + resetWatchProgress: () => + invoke('reset_watch_progress'), +}; diff --git a/src/main.ts b/src/main.ts index 8e94a08..ed86daa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1 +1,8 @@ -console.log("TutorialDock frontend loaded"); +import './styles/main.css'; +import './styles/player.css'; +import './styles/playlist.css'; +import './styles/panels.css'; +import './styles/components.css'; +import './styles/animations.css'; + +console.log('TutorialDock frontend loaded'); diff --git a/src/styles/animations.css b/src/styles/animations.css new file mode 100644 index 0000000..a552292 --- /dev/null +++ b/src/styles/animations.css @@ -0,0 +1 @@ +/* No standalone animations — included in main.css */ diff --git a/src/styles/components.css b/src/styles/components.css new file mode 100644 index 0000000..52bcc3b --- /dev/null +++ b/src/styles/components.css @@ -0,0 +1,182 @@ +/* Notification toast */ +#toast{ + position:fixed; + left:28px; + bottom:28px; + z-index:999999; + transform:translateY(20px) scale(var(--zoom)); + transform-origin:bottom left; + pointer-events:none; + opacity:0; + transition:opacity .25s ease, transform .25s cubic-bezier(.4,0,.2,1); +} +#toast.show{ + opacity:1; + transform:translateY(0) scale(var(--zoom)); +} +.toastInner{ + pointer-events:none; + display:flex; align-items:center; gap:12px; + padding:12px 14px; + border-radius:7px; + border:1px solid rgba(255,255,255,.14); + background:rgba(18,20,26,.92); + box-shadow:0 26px 70px rgba(0,0,0,.70); + backdrop-filter:blur(16px); +} +.toastIcon{width:18px; height:18px; display:flex; align-items:center; justify-content:center;} +.toastIcon .fa{font-size:14px; color:rgba(230,236,252,.82)!important; opacity:.95;} +.toastMsg{ + font-size:12.8px; + font-weight:760; + letter-spacing:.12px; + color:rgba(246,248,255,.92); +} + +/* Toolbar icon buttons */ +.toolbarIcon{ + width:36px; height:36px; + border-radius:var(--r2); + background:linear-gradient(180deg, rgba(255,255,255,.07), rgba(255,255,255,.025)); + border:1px solid rgba(255,255,255,.1); + color:rgba(246,248,255,.88); + font-size:14px; + transition:all .2s cubic-bezier(.4,0,.2,1); + position:relative; + overflow:hidden; + box-shadow: + 0 2px 4px rgba(0,0,0,.12), + 0 4px 12px rgba(0,0,0,.15), + inset 0 1px 0 rgba(255,255,255,.08); +} +.toolbarIcon::before{ + content:""; + position:absolute; + inset:0; + background:linear-gradient(180deg, rgba(255,255,255,.1), transparent 50%); + opacity:0; + transition:opacity .2s ease; +} +.toolbarIcon:hover{ + background:linear-gradient(180deg, rgba(255,255,255,.12), rgba(255,255,255,.05)); + border-color:rgba(255,255,255,.18); + color:rgba(255,255,255,.98); + transform:translateY(-2px); + box-shadow: + 0 4px 8px rgba(0,0,0,.15), + 0 8px 20px rgba(0,0,0,.2), + inset 0 1px 0 rgba(255,255,255,.12); +} +.toolbarIcon:hover::before{opacity:1;} +.toolbarIcon:active{ + transform:translateY(0); + box-shadow:0 2px 6px rgba(0,0,0,.18); +} + +/* Fancy tooltip */ +.tooltip{ + position:fixed; + pointer-events:none; + z-index:99999; + border-radius:var(--r); + padding:16px 20px; + opacity:0; + transform:translateY(8px) scale(.97); + transition:opacity .25s ease, transform .25s cubic-bezier(.4,0,.2,1), left .12s ease, top .12s ease; + max-width:320px; + font-family:var(--sans); + overflow:hidden; + background:rgba(20,24,32,.5); + backdrop-filter:blur(8px) saturate(1.3); + -webkit-backdrop-filter:blur(8px) saturate(1.3); + border:1px solid rgba(255,255,255,.12); + box-shadow: + 0 0 0 1px rgba(0,0,0,.3), + 0 4px 8px rgba(0,0,0,.15), + 0 12px 24px rgba(0,0,0,.25), + 0 24px 48px rgba(0,0,0,.2); +} +.tooltip.visible{ + opacity:1; + transform:translateY(0) scale(1); +} +.tooltip::before{ + content:""; + position:absolute; + top:0; left:0; right:0; + height:1px; + background:linear-gradient(90deg, transparent 5%, rgba(95,175,255,.5) 30%, rgba(75,200,130,.4) 70%, transparent 95%); + border-radius:var(--r) var(--r) 0 0; +} +.tooltip::after{ + content:""; + position:absolute; + top:1px; left:1px; right:1px; + height:40%; + background:linear-gradient(180deg, rgba(255,255,255,.05), transparent); + border-radius:var(--r) var(--r) 0 0; + pointer-events:none; +} +.tooltip-title{ + font-family:var(--brand); + font-weight:800; + font-size:14px; + margin-bottom:8px; + letter-spacing:-.01em; + background:linear-gradient(135deg, #fff 0%, rgba(180,210,255,1) 50%, rgba(150,230,200,1) 100%); + -webkit-background-clip:text; + background-clip:text; + color:transparent; + text-shadow:none; + position:relative; + z-index:1; +} +.tooltip-desc{ + font-family:var(--sans); + font-size:12px; + font-weight:500; + color:rgba(190,200,225,.88); + line-height:1.55; + letter-spacing:.01em; + position:relative; + z-index:1; +} +.tooltip-desc:empty{display:none;} +.tooltip-desc:empty ~ .tooltip-title{margin-bottom:0;} + +.subsBox{position:relative;} +.subsMenu{ + position:absolute; left:50%; bottom:calc(100% + 10px); + transform:translateX(-50%); + min-width:220px; padding:8px; + border-radius:7px; border:1px solid rgba(255,255,255,.12); + background:rgba(18,20,26,.94); + box-shadow:0 26px 70px rgba(0,0,0,.70); + backdrop-filter:blur(16px); + display:none; z-index:30; +} +.subsMenu.show{display:block;} +.subsMenuHeader{padding:6px 12px 4px; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:rgba(150,160,190,.6);} +.subsMenuItem{padding:10px 12px; border-radius:5px; cursor:pointer; user-select:none; font-size:12px; font-weight:600; color:rgba(246,248,255,.92); letter-spacing:.08px; display:flex; align-items:center; gap:10px; transition:background .12s ease;} +.subsMenuItem:hover{background:rgba(255,255,255,.06);} +.subsMenuItem .fa{font-size:13px; color:var(--iconStrong)!important; opacity:.85; width:18px; text-align:center;} +.subsMenuItem.embedded{color:rgba(180,220,255,.95);} +.subsDivider{height:1px; background:rgba(255,255,255,.08); margin:6px 4px;} +.subsEmpty{padding:10px 12px; color:rgba(165,172,196,.7); font-size:11.5px; text-align:center;} + +.speedMenu{ + position:absolute; right:0; bottom:calc(100% + 10px); + min-width:180px; padding:8px; + border-radius:7px; border:1px solid rgba(255,255,255,.12); + background:rgba(18,20,26,.94); + box-shadow:0 26px 70px rgba(0,0,0,.70); + backdrop-filter:blur(16px); + display:none; z-index:30; + transform:translateY(8px) scale(.96); opacity:0; + transition:transform .18s cubic-bezier(.175,.885,.32,1.275), opacity .15s ease; +} +.speedMenu.show{display:block; transform:translateY(0) scale(1); opacity:1;} +.speedItem{padding:10px 10px; border-radius:6px; cursor:pointer; user-select:none; font-family:var(--mono); font-size:14px; color:rgba(246,248,255,.92); letter-spacing:.10px; display:flex; align-items:center; justify-content:space-between; gap:10px; transition:all .12s ease;} +.speedItem:hover{background:rgba(255,255,255,.06); transform:translateX(3px);} +.speedItem .dot{width:8px; height:8px; border-radius:999px; background:rgba(255,255,255,.08); border:1px solid rgba(255,255,255,.12); flex:0 0 auto; transition:all .15s ease;} +.speedItem.active .dot{background:radial-gradient(circle at 30% 30%, rgba(255,255,255,.92), rgba(100,180,255,.55)); box-shadow:0 0 0 3px rgba(100,180,255,.10); border-color:rgba(100,180,255,.24);} diff --git a/src/styles/main.css b/src/styles/main.css new file mode 100644 index 0000000..dc04286 --- /dev/null +++ b/src/styles/main.css @@ -0,0 +1,514 @@ +:root{ + --zoom:1; + /* Base backgrounds */ + --bg0:#060709; --bg1:#0a0c10; + /* Strokes - consistent opacity scale */ + --stroke:rgba(255,255,255,.07); + --strokeLight:rgba(255,255,255,.04); + --strokeMed:rgba(255,255,255,.10); + /* Text - consistent hierarchy */ + --text:rgba(240,244,255,.91); + --textMuted:rgba(155,165,190,.68); + --textDim:rgba(120,132,165,.50); + /* Surfaces */ + --surface:rgba(255,255,255,.025); + --surfaceHover:rgba(255,255,255,.045); + --surfaceActive:rgba(255,255,255,.06); + /* Shadows */ + --shadow:0 16px 48px rgba(0,0,0,.50); + --shadow2:0 8px 24px rgba(0,0,0,.32); + /* Radii */ + --r:6px; --r2:5px; + /* Fonts */ + --mono:"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + --sans:"Manrope", ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; + --brand:"Sora","Manrope", ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; + /* Icons */ + --icon:rgba(175,185,210,.65); + --iconStrong:rgba(220,228,248,.85); + /* Accent - consistent blue */ + --accent:rgb(95,175,255); + --accentGlow:rgba(95,175,255,.12); + --accentBorder:rgba(95,175,255,.22); + --accentBg:rgba(95,175,255,.07); + /* Success - consistent green */ + --success:rgb(75,200,130); + --successBg:rgba(75,200,130,.07); + --successBorder:rgba(75,200,130,.20); + /* Tree */ + --tree:rgba(195,205,230,.10); + --treeNode:rgba(200,212,238,.52); +} +*{box-sizing:border-box;} +html,body{height:100%;} +body{ + margin:0; padding:0; font-family:var(--sans); color:var(--text); overflow:hidden; + width:100vw; height:100vh; + background: + radial-gradient(800px 500px at 10% 5%, rgba(95,175,255,.08), transparent 60%), + radial-gradient(700px 500px at 90% 8%, rgba(75,200,130,.05), transparent 65%), + linear-gradient(180deg, var(--bg1), var(--bg0)); + letter-spacing:.04px; +} +#zoomRoot{ + transform:scale(var(--zoom)); + transform-origin:0 0; + width:calc(100vw / var(--zoom)); + height:calc(100vh / var(--zoom)); + overflow:hidden; + box-sizing:border-box; +} +.app{height:100%; display:flex; flex-direction:column; position:relative; overflow:hidden;} +.fa, i.fa-solid, i.fa-regular, i.fa-light, i.fa-thin{color:var(--icon)!important;} + +.topbar{ + display:flex; align-items:center; gap:14px; + padding:12px 14px; + height:72px; + flex:0 0 72px; + border-bottom:1px solid var(--stroke); + background: + linear-gradient(135deg, transparent 0%, transparent 48%, rgba(255,255,255,.015) 50%, transparent 52%, transparent 100%) 0 0 / 8px 8px, + linear-gradient(180deg, rgba(22,26,38,1), rgba(18,22,32,1)); + min-width:0; z-index:5; position:relative; + box-sizing:border-box; +} +.topbar::before{ + content:""; + position:absolute; + inset:0; + background: + radial-gradient(circle, rgba(180,210,255,.096) 2px, transparent 2.5px) 0 0 / 12px 12px, + radial-gradient(circle, rgba(220,230,255,.08) 2px, transparent 2.5px) 6px 6px / 12px 12px; + pointer-events:none; + z-index:1; + -webkit-mask-image: linear-gradient(90deg, black 0%, rgba(0,0,0,.5) 20%, transparent 40%); + mask-image: linear-gradient(90deg, black 0%, rgba(0,0,0,.5) 20%, transparent 40%); +} +.topbar::after{ + content:""; + position:absolute; + inset:0; + background:linear-gradient(180deg, rgba(255,255,255,.03), transparent); + pointer-events:none; + z-index:0; +} + +.brand{display:flex; align-items:center; gap:12px; min-width:0; flex:1 1 auto; position:relative; z-index:1;} +.appIcon{ + display:flex; align-items:center; justify-content:center; + flex:0 0 auto; + filter: drop-shadow(0 8px 16px rgba(0,0,0,.35)); + overflow:visible; + transition: transform .4s cubic-bezier(.34,1.56,.64,1), filter .3s ease; + cursor:pointer; + position:relative; +} +.appIconGlow{ + position:absolute; + inset:-15px; + border-radius:50%; + pointer-events:none; + opacity:0; + transition:opacity .4s ease; +} +.appIconGlow::before{ + content:""; + position:absolute; + inset:0; + border-radius:50%; + background:radial-gradient(circle, rgba(100,180,255,.25), rgba(130,230,180,.15), transparent 70%); + transform:scale(.5); + transition:transform .4s ease; +} +.appIconGlow::after{ + content:""; + position:absolute; + inset:0; + border-radius:50%; + background:conic-gradient(from 0deg, rgba(100,180,255,.5), rgba(130,230,180,.5), rgba(210,160,255,.5), rgba(100,180,255,.5)); + mask:radial-gradient(circle, transparent 45%, black 47%, black 53%, transparent 55%); + -webkit-mask:radial-gradient(circle, transparent 45%, black 47%, black 53%, transparent 55%); + animation:none; +} +@keyframes logoSpin{ + 0%{transform:rotate(0deg);} + 100%{transform:rotate(360deg);} +} +@keyframes logoWiggle{ + 0%,100%{transform:rotate(0deg) scale(1);} + 10%{transform:rotate(-12deg) scale(1.15);} + 20%{transform:rotate(10deg) scale(1.12);} + 30%{transform:rotate(-8deg) scale(1.18);} + 40%{transform:rotate(6deg) scale(1.14);} + 50%{transform:rotate(-4deg) scale(1.2);} + 60%{transform:rotate(3deg) scale(1.16);} + 70%{transform:rotate(-2deg) scale(1.12);} + 80%{transform:rotate(1deg) scale(1.08);} + 90%{transform:rotate(0deg) scale(1.04);} +} +.appIcon:hover{ + animation:logoWiggle .8s ease-out; + filter: drop-shadow(0 0 20px rgba(100,180,255,.5)) drop-shadow(0 0 40px rgba(130,230,180,.3)) drop-shadow(0 12px 24px rgba(0,0,0,.4)); +} +.appIcon:hover .appIconGlow{ + opacity:1; +} +.appIcon:hover .appIconGlow::before{ + transform:scale(1.2); +} +.appIcon:hover .appIconGlow::after{ + animation:logoSpin 3s linear infinite; +} +.appIcon i{ + font-size:36px; + line-height:1; + background:linear-gradient(135deg, rgba(100,180,255,.98), rgba(130,230,180,.92), rgba(210,160,255,.82)); + -webkit-background-clip:text; + background-clip:text; + color:transparent!important; + -webkit-text-stroke: 0.5px rgba(0,0,0,.16); + opacity:.98; + transition:all .3s ease; + position:relative; + z-index:2; +} +.appIcon:hover i{ + background:linear-gradient(135deg, rgba(130,210,255,1), rgba(160,250,200,1), rgba(230,180,255,1)); + -webkit-background-clip:text; + background-clip:text; +} +.brandText{min-width:0; position:relative; z-index:1;} +.appName{ + font-family:var(--brand); + font-weight:900; + font-size:18px; + line-height:1.02; + letter-spacing:.35px; + margin:0; padding:0; + transition:text-shadow .3s ease; +} +.brand:hover .appName{ + text-shadow:0 0 20px rgba(100,180,255,.4), 0 0 40px rgba(130,230,180,.2); +} +.tagline{ + margin-top:5px; + font-size:11.5px; + line-height:1.2; + color:rgba(180,188,210,.76); + letter-spacing:.18px; + white-space:nowrap; overflow:hidden; text-overflow:ellipsis; + max-width:52vw; + transition:color .3s ease; +} +.brand:hover .tagline{ + color:rgba(200,210,230,.9); +} + +.actions{ + display:flex; align-items:center; gap:8px; + flex:0 0 auto; flex-wrap:nowrap; white-space:nowrap; + position:relative; z-index:7; +} +.actionGroup{ + display:flex; align-items:center; gap:6px; +} +.actionDivider{ + width:1px; height:28px; + background:linear-gradient(180deg, transparent, rgba(255,255,255,.12) 20%, rgba(255,255,255,.12) 80%, transparent); + margin:0 4px; +} + +/* Zoom control */ +.zoomControl{ + display:flex; align-items:center; gap:0; + background:linear-gradient(180deg, rgba(255,255,255,.04), rgba(255,255,255,.015)); + border:1px solid rgba(255,255,255,.08); + border-radius:8px; + padding:2px; + box-shadow:0 2px 8px rgba(0,0,0,.15), inset 0 1px 0 rgba(255,255,255,.05); +} +.zoomBtn{ + width:28px; height:28px; + border:none; background:transparent; + border-radius:6px; + color:var(--text); + cursor:pointer; + display:flex; align-items:center; justify-content:center; + transition:all .15s ease; +} +.zoomBtn:hover{ + background:rgba(255,255,255,.08); +} +.zoomBtn:active{ + background:rgba(255,255,255,.12); + transform:scale(.95); +} +.zoomBtn .fa{font-size:10px; opacity:.8;} +.zoomValue{ + min-width:48px; + text-align:center; + font-family:var(--mono); + font-size:11px; + font-weight:600; + color:var(--text); + opacity:.9; + cursor:pointer; + padding:4px 6px; + border-radius:4px; + transition:background .15s ease; +} +.zoomValue:hover{ + background:rgba(255,255,255,.06); +} + +/* Toolbar buttons */ +.toolbarBtn{ + width:34px; height:34px; + border:1px solid rgba(255,255,255,.08); + border-radius:8px; + background:linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.02)); + color:var(--text); + cursor:pointer; + display:flex; align-items:center; justify-content:center; + transition:all .2s cubic-bezier(.4,0,.2,1); + position:relative; + overflow:hidden; + box-shadow:0 2px 6px rgba(0,0,0,.12), inset 0 1px 0 rgba(255,255,255,.06); +} +.toolbarBtn::before{ + content:""; + position:absolute; + inset:0; + background:radial-gradient(circle at 50% 0%, rgba(255,255,255,.15), transparent 70%); + opacity:0; + transition:opacity .2s ease; +} +.toolbarBtn:hover{ + border-color:rgba(255,255,255,.15); + background:linear-gradient(180deg, rgba(255,255,255,.08), rgba(255,255,255,.03)); + transform:translateY(-1px); + box-shadow:0 4px 12px rgba(0,0,0,.2), inset 0 1px 0 rgba(255,255,255,.1); +} +.toolbarBtn:hover::before{opacity:1;} +.toolbarBtn:active{ + transform:translateY(0); + box-shadow:0 1px 4px rgba(0,0,0,.15); +} +.toolbarBtn .fa{font-size:13px; opacity:.85; transition:transform .2s ease;} +.toolbarBtn:hover .fa{transform:scale(1.1);} + +/* Primary split button styling */ +.splitBtn.primary{ + border-color:rgba(95,175,255,.25); + background:linear-gradient(180deg, rgba(95,175,255,.12), rgba(95,175,255,.04)); + box-shadow:0 2px 8px rgba(0,0,0,.15), 0 4px 20px rgba(95,175,255,.1), inset 0 1px 0 rgba(255,255,255,.1); +} +.splitBtn.primary:hover{ + border-color:rgba(95,175,255,.4); + box-shadow:0 4px 12px rgba(0,0,0,.2), 0 8px 32px rgba(95,175,255,.15), inset 0 1px 0 rgba(255,255,255,.15); +} +.splitBtn.primary .drop{ + border-left-color:rgba(95,175,255,.2); +} +.btn{ + display:inline-flex; align-items:center; justify-content:center; gap:8px; + padding:9px 14px; + border-radius:var(--r2); + border:1px solid rgba(255,255,255,.08); + background:linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02)); + color:var(--text); + cursor:pointer; user-select:none; + box-shadow: + 0 2px 4px rgba(0,0,0,.2), + 0 8px 24px rgba(0,0,0,.25), + inset 0 1px 0 rgba(255,255,255,.08), + inset 0 -1px 0 rgba(0,0,0,.1); + transition:all .2s cubic-bezier(.4,0,.2,1); + font-size:12.5px; font-weight:700; letter-spacing:.02em; + position:relative; + overflow:hidden; +} +.btn::before{ + content:""; + position:absolute; + inset:0; + background:linear-gradient(180deg, rgba(255,255,255,.1), transparent 50%); + opacity:0; + transition:opacity .2s ease; +} +.btn:hover{ + border-color:rgba(255,255,255,.15); + background:linear-gradient(180deg, rgba(255,255,255,.09), rgba(255,255,255,.04)); + transform:translateY(-2px); + box-shadow: + 0 4px 8px rgba(0,0,0,.2), + 0 12px 32px rgba(0,0,0,.3), + inset 0 1px 0 rgba(255,255,255,.12), + inset 0 -1px 0 rgba(0,0,0,.1); +} +.btn:hover::before{opacity:1;} +.btn:active{ + transform:translateY(0); + box-shadow: + 0 1px 2px rgba(0,0,0,.2), + 0 4px 12px rgba(0,0,0,.2), + inset 0 1px 0 rgba(255,255,255,.06); +} +.btn.primary{ + border-color:rgba(95,175,255,.3); + background:linear-gradient(180deg, rgba(95,175,255,.2), rgba(95,175,255,.08)); + color:#fff; + text-shadow:0 1px 2px rgba(0,0,0,.3); +} +.btn.primary::before{ + background:linear-gradient(180deg, rgba(255,255,255,.15), transparent 60%); +} +.btn.primary:hover{ + border-color:rgba(95,175,255,.45); + background:linear-gradient(180deg, rgba(95,175,255,.28), rgba(95,175,255,.12)); + box-shadow: + 0 4px 8px rgba(0,0,0,.2), + 0 12px 32px rgba(95,175,255,.2), + 0 0 0 1px rgba(95,175,255,.1), + inset 0 1px 0 rgba(255,255,255,.15); +} +.btn .fa{font-size:14px; opacity:.95; color:var(--iconStrong)!important; transition:transform .2s ease; position:relative; z-index:1;} +.btn.primary .fa{color:#fff!important;} +.btn:hover .fa{transform:scale(1.1);} + +.splitBtn{ + display:inline-flex; + border-radius:var(--r2); + overflow:hidden; + border:1px solid rgba(255,255,255,.08); + box-shadow: + 0 2px 4px rgba(0,0,0,.2), + 0 8px 24px rgba(0,0,0,.25), + inset 0 1px 0 rgba(255,255,255,.06); + background:linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.02)); + position:relative; z-index:8; + transition:all .2s ease; +} +.splitBtn:hover{ + border-color:rgba(255,255,255,.14); + box-shadow: + 0 4px 8px rgba(0,0,0,.2), + 0 12px 32px rgba(0,0,0,.3), + inset 0 1px 0 rgba(255,255,255,.08); + transform:translateY(-1px); +} +.splitBtn .btn{border:none; box-shadow:none; border-radius:0; background:transparent; padding:9px 12px; transform:none;} +.splitBtn .btn::before{display:none;} +.splitBtn .btn:hover{background:rgba(255,255,255,.06); transform:none; box-shadow:none;} +.splitBtn .drop{ + width:40px; padding:8px 0; + border-left:1px solid rgba(255,255,255,.08); + display:flex; align-items:center; justify-content:center; + transition:background .15s ease; + background:linear-gradient(180deg, rgba(255,255,255,.02), transparent); +} +.splitBtn .drop:hover{background:rgba(255,255,255,.06);} +.splitBtn .drop .fa{font-size:16px; opacity:.88; color:var(--iconStrong)!important; transition:transform .2s ease;} +.splitBtn .drop:hover .fa{transform:translateY(2px);} + +.dropdownPortal{ + position:fixed; z-index:99999; + min-width:320px; max-width:560px; max-height:360px; + overflow:auto; + border-radius:7px; + border:1px solid rgba(255,255,255,.12); + background:rgba(18,20,26,.94); + box-shadow:0 26px 70px rgba(0,0,0,.70); + backdrop-filter:blur(16px); + padding:6px; + display:none; + transform:scale(var(--zoom)); + transform-origin:top left; + scrollbar-width:thin; + scrollbar-color:rgba(255,255,255,.14) rgba(255,255,255,.02); +} +.dropdownPortal::-webkit-scrollbar{width:4px; height:4px;} +.dropdownPortal::-webkit-scrollbar-track{background:rgba(255,255,255,.015);} +.dropdownPortal::-webkit-scrollbar-thumb{background:rgba(255,255,255,.11); border-radius:999px;} +.dropdownPortal::-webkit-scrollbar-button{width:0; height:0; display:none;} + +.dropItem{display:flex; align-items:center; gap:10px; padding:10px 10px; border-radius:6px; cursor:pointer; user-select:none; color:rgba(246,248,255,.92); font-weight:760; font-size:12.7px; letter-spacing:.12px; line-height:1.25; transition:all .15s ease; position:relative;} +.dropItem:hover{background:rgba(255,255,255,.06); padding-right:36px;} +.dropItem:active{transform:none;} +.dropIcon{width:18px; height:18px; display:flex; align-items:center; justify-content:center; flex:0 0 auto; opacity:.9; transition:transform .2s ease;} +.dropItem:hover .dropIcon{transform:scale(1.1);} +.dropIcon .fa{font-size:14px; color:var(--iconStrong)!important;} +.dropName{white-space:nowrap; flex:1 1 auto; min-width:0; transition:mask-image .15s ease, -webkit-mask-image .15s ease;} +.dropItem:hover .dropName{overflow:hidden; mask-image:linear-gradient(90deg, #000 80%, transparent 100%); -webkit-mask-image:linear-gradient(90deg, #000 80%, transparent 100%);} +.dropRemove{position:absolute; right:8px; top:50%; transform:translateY(-50%); width:24px; height:24px; border-radius:8px; background:rgba(255,100,100,.15); border:1px solid rgba(255,100,100,.25); color:rgba(255,180,180,.9); display:none; align-items:center; justify-content:center; font-size:12px; cursor:pointer; transition:all .15s ease;} +.dropItem:hover .dropRemove{display:flex;} +.dropRemove:hover{background:rgba(255,100,100,.25); border-color:rgba(255,100,100,.4); color:rgba(255,220,220,1);} +.dropEmpty{padding:10px 10px; color:rgba(165,172,196,.78); font-size:12.5px;} + +.seg{display:inline-flex; border:1px solid rgba(255,255,255,.09); border-radius:var(--r2); overflow:hidden; background:rgba(255,255,255,.02); box-shadow:var(--shadow2); transition:border-color .15s ease;} +.seg:hover{border-color:rgba(255,255,255,.14);} +.seg .btn{border:none; box-shadow:none; border-radius:0; padding:8px 9px; background:transparent; font-weight:820; transform:none;} +.seg .btn:hover{background:rgba(255,255,255,.06); transform:none; box-shadow:none;} +.seg .btn:active{background:rgba(255,255,255,.08);} +.seg .mid{border-left:1px solid rgba(255,255,255,.10); border-right:1px solid rgba(255,255,255,.10); min-width:62px; font-variant-numeric:tabular-nums;} + +.switch{ + display:inline-flex; align-items:center; justify-content:center; gap:8px; + padding:6px 10px; + border-radius:8px; + border:1px solid rgba(255,255,255,.08); + background:linear-gradient(180deg, rgba(255,255,255,.04), rgba(255,255,255,.015)); + box-shadow:0 2px 6px rgba(0,0,0,.12), inset 0 1px 0 rgba(255,255,255,.05); + cursor:pointer; user-select:none; + font-size:11.5px; font-weight:650; letter-spacing:.01em; + color:var(--text); + line-height:1; + transition:all .2s ease; +} +.switch:hover{ + border-color:rgba(255,255,255,.14); + background:linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.025)); + box-shadow:0 4px 10px rgba(0,0,0,.18), inset 0 1px 0 rgba(255,255,255,.08); +} +.switch input{display:none;} +.track{ + width:32px; height:18px; + border-radius:999px; + background:rgba(255,255,255,.06); + border:1px solid rgba(255,255,255,.10); + position:relative; + transition:all .25s cubic-bezier(.4,0,.2,1); + flex:0 0 auto; + display:flex; align-items:center; +} +.knob{ + margin-left:2px; + width:14px; height:14px; + border-radius:999px; + background:linear-gradient(180deg, rgba(255,255,255,.35), rgba(255,255,255,.18)); + box-shadow:0 2px 6px rgba(0,0,0,.25); + transition:transform .2s cubic-bezier(.4,0,.2,1), background .2s ease, box-shadow .2s ease; +} +.switch input:checked + .track{ + background:linear-gradient(90deg, rgba(95,175,255,.25), rgba(130,200,255,.2)); + border-color:rgba(95,175,255,.3); + box-shadow:0 0 12px rgba(95,175,255,.15); +} +.switch input:checked + .track .knob{ + transform:translateX(14px); + background:linear-gradient(180deg, rgba(255,255,255,.9), rgba(200,230,255,.8)); + box-shadow:0 2px 8px rgba(95,175,255,.3), 0 0 0 2px rgba(95,175,255,.15); +} + +.content{flex:1 1 auto; min-height:0; padding:12px; display:grid; grid-template-columns:calc(62% - 7px) 14px calc(38% - 7px); gap:0; overflow:hidden;} + +@media (max-width:1100px){ + .content{grid-template-columns:1fr; gap:12px; padding:12px;} + .dividerWrap{display:none;} + .actions{flex-wrap:wrap;} + .seg{width:100%;} + .dock{grid-template-columns:1fr;} + .dockDividerWrap{display:none;} + .tagline{max-width:70vw;} +} diff --git a/src/styles/panels.css b/src/styles/panels.css new file mode 100644 index 0000000..6808269 --- /dev/null +++ b/src/styles/panels.css @@ -0,0 +1,144 @@ +.panel{border:1px solid var(--stroke); border-radius:var(--r); background:linear-gradient(180deg, var(--surface), rgba(255,255,255,.015)); box-shadow:0 8px 32px rgba(0,0,0,.35); overflow:hidden; min-height:0; display:flex; flex-direction:column; backdrop-filter:blur(12px);} +.panelHeader{padding:12px 12px 10px; border-bottom:1px solid var(--stroke); display:flex; align-items:flex-start; justify-content:space-between; gap:12px; flex:0 0 auto; min-width:0;} +.nowTitle{font-weight:860; font-size:13.4px; letter-spacing:.14px; max-width:60ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;} +.nowSub{margin-top:4px; color:var(--textMuted); font-size:11.6px; font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:80ch;} + +.dividerWrap{display:flex; align-items:stretch; justify-content:center;} +.divider{width:14px; cursor:col-resize; position:relative; background:transparent; border:none;} +.divider::after{ + content:""; position:absolute; top:50%; left:50%; + width:4px; height:54px; transform:translate(-50%,-50%); + border-radius:999px; + background: + radial-gradient(circle, rgba(255,255,255,.10) 35%, transparent 40%) 0 0/4px 12px, + radial-gradient(circle, rgba(255,255,255,.10) 35%, transparent 40%) 0 6px/4px 12px; + opacity:.20; pointer-events:none; transition:opacity .15s ease; +} +.divider:hover::after{opacity:.52;} + +.dock{flex:1 1 auto; min-height:0; border-top:1px solid rgba(255,255,255,.09); display:grid; grid-template-columns:62% 14px 38%; background:radial-gradient(900px 240px at 20% 0%, rgba(100,180,255,.06), transparent 60%), rgba(0,0,0,.10); overflow:hidden;} +.dockPane{min-height:0; display:flex; flex-direction:column; overflow:hidden;} +.dockInner{padding:12px; min-height:0; display:flex; flex-direction:column; gap:10px; height:100%;} +.dockHeader{padding:12px 14px 11px; border:1px solid rgba(255,255,255,.08); border-radius:7px; display:flex; align-items:center; justify-content:space-between; gap:10px; background:linear-gradient(180deg, rgba(255,255,255,.035), rgba(255,255,255,.012)); flex:0 0 auto; box-shadow:0 14px 36px rgba(0,0,0,.25);} +#notesHeader{border-bottom-right-radius:7px;} +#infoHeader{border-bottom-left-radius:7px; margin-right:12px;} +.dockTitle{font-family:var(--brand); font-weight:800; letter-spacing:.02px; font-size:13.5px; color:rgba(246,248,255,.95); display:flex; align-items:center; gap:10px;} +.dockTitle .fa{color:var(--iconStrong)!important; opacity:.88; font-size:14px;} + +.notesArea{flex:1 1 auto; min-height:0; overflow:hidden; position:relative;} +.notesSaved{ + position:absolute; + bottom:12px; right:12px; + padding:6px 10px; + border-radius:6px; + background:linear-gradient(135deg, rgba(100,200,130,.2), rgba(80,180,120,.15)); + border:1px solid rgba(100,200,130,.25); + color:rgba(150,230,170,.95); + font-size:11px; + font-weight:600; + letter-spacing:.02em; + display:flex; align-items:center; gap:6px; + opacity:0; + transform:translateY(4px); + transition:opacity .4s ease, transform .4s ease; + pointer-events:none; + box-shadow:0 4px 12px rgba(0,0,0,.2); +} +.notesSaved.show{ + opacity:1; + transform:translateY(0); +} +.notesSaved .fa{font-size:10px; color:rgba(130,220,160,.95)!important;} +.notes{width:100%; height:100%; resize:none; border-radius:6px; border:1px solid rgba(255,255,255,.10); background:radial-gradient(900px 280px at 18% 0%, rgba(100,180,255,.09), transparent 62%), rgba(255,255,255,.02); color:rgba(246,248,255,.92); padding:12px 12px; outline:none; font-family:var(--sans); font-size:12.9px; line-height:1.45; letter-spacing:.08px; box-shadow:0 18px 45px rgba(0,0,0,.40); overflow:auto; scrollbar-width:thin; scrollbar-color:rgba(255,255,255,.14) transparent;} +.notes::-webkit-scrollbar{width:2px; height:2px;} +.notes::-webkit-scrollbar-track{background:transparent;} +.notes::-webkit-scrollbar-thumb{background:rgba(255,255,255,.16); border-radius:0;} +.notes::-webkit-scrollbar-button{width:0; height:0; display:none;} +.notes::placeholder{color:rgba(165,172,196,.55);} + +.infoGrid{ + flex:1 1 auto; + min-height:0; + overflow:auto; + padding-right:12px; + padding-top:8px; + padding-bottom:8px; + scrollbar-width:none; + --fade-top:30px; + --fade-bottom:30px; + mask-image:linear-gradient(180deg, transparent 0%, #000 var(--fade-top), #000 calc(100% - var(--fade-bottom)), transparent 100%); + -webkit-mask-image:linear-gradient(180deg, transparent 0%, #000 var(--fade-top), #000 calc(100% - var(--fade-bottom)), transparent 100%); + transition:--fade-top 0.8s ease, --fade-bottom 0.8s ease; +} +@property --fade-top { + syntax: ''; + initial-value: 30px; + inherits: false; +} +@property --fade-bottom { + syntax: ''; + initial-value: 30px; + inherits: false; +} +.infoGrid.at-top{--fade-top:0px;} +.infoGrid.at-bottom{--fade-bottom:0px;} +.infoGrid::-webkit-scrollbar{width:0; height:0;} + +.kv{ + display:grid; + grid-template-columns:78px 1fr; + gap:4px 14px; + align-items:baseline; + padding:12px 14px; + border-radius:var(--r2); + border:1px solid var(--strokeLight); + background:linear-gradient(170deg, rgba(20,25,35,.65), rgba(14,18,26,.75)); + box-shadow:var(--shadow2), inset 0 1px 0 rgba(255,255,255,.02); + margin-bottom:8px; +} +.k{ + font-family:var(--sans); + font-size:9px; + font-weight:800; + text-transform:uppercase; + letter-spacing:.12em; + color:var(--textDim); + padding-top:3px; + white-space:nowrap; +} +.v{ + font-family:var(--brand); + font-size:12.5px; + font-weight:650; + color:var(--text); + letter-spacing:-.01em; + word-break:break-word; + overflow-wrap:anywhere; + line-height:1.35; + padding-left:6px; +} +.v.mono{ + font-family:var(--mono); + font-size:11px; + font-weight:500; + color:var(--textMuted); + letter-spacing:.01em; + font-variant-numeric:tabular-nums; + background:linear-gradient(90deg, var(--accentBg), transparent 80%); + padding:2px 6px; + border-radius:3px; + margin:-2px 0; +} + +.dockDividerWrap{display:flex; align-items:stretch; justify-content:center;} +.dockDivider{width:14px; cursor:col-resize; position:relative; background:transparent; border:none;} +.dockDivider::after{ + content:""; position:absolute; top:50%; left:50%; + width:4px; height:44px; transform:translate(-50%,-50%); + border-radius:999px; + background: + radial-gradient(circle, rgba(255,255,255,.10) 35%, transparent 40%) 0 0/4px 12px, + radial-gradient(circle, rgba(255,255,255,.10) 35%, transparent 40%) 0 6px/4px 12px; + opacity:.18; pointer-events:none; transition:opacity .15s ease; +} +.dockDivider:hover::after{opacity:.44;} diff --git a/src/styles/player.css b/src/styles/player.css new file mode 100644 index 0000000..aea1cff --- /dev/null +++ b/src/styles/player.css @@ -0,0 +1,278 @@ +.videoWrap{position:relative; background:#000; flex:0 0 auto;} +video{width:100%; height:auto; display:block; background:#000; aspect-ratio:16/9; outline:none; cursor:pointer;} +video::cue{ + background:transparent; + color:#fff; + font-family:var(--sans); + font-size:1.1em; + font-weight:600; + text-shadow: + -1px -1px 0 #000, + 1px -1px 0 #000, + -1px 1px 0 #000, + 1px 1px 0 #000, + -2px 0 0 #000, + 2px 0 0 #000, + 0 -2px 0 #000, + 0 2px 0 #000; +} + +.videoOverlay{ + position:absolute; + inset:0; + display:flex; + align-items:center; + justify-content:center; + pointer-events:none; + z-index:5; +} +.overlayIcon{ + position:relative; + width:100px; + height:100px; + display:flex; + align-items:center; + justify-content:center; + opacity:0; + transition:opacity 0.8s ease; + border-radius:50%; + background:rgba(20,25,35,.5); + backdrop-filter:blur(8px) saturate(1.3); + -webkit-backdrop-filter:blur(8px) saturate(1.3); + border:1.5px solid rgba(255,255,255,.15); + box-shadow: + 0 8px 32px rgba(0,0,0,.5), + 0 0 0 1px rgba(0,0,0,.3); +} +.overlayIcon.show{ + opacity:1; +} +.overlayIcon.pulse{ + animation:overlayPulse 0.4s ease-out; +} +@keyframes overlayPulse{ + 0%{transform:scale(1);} + 50%{transform:scale(1.15);} + 100%{transform:scale(1);} +} +.overlayIcon::before{ + content:""; + position:absolute; + inset:0; + border-radius:50%; + background: + radial-gradient(circle at 30% 30%, rgba(255,255,255,.1), transparent 50%), + radial-gradient(circle at 70% 70%, rgba(95,175,255,.08), transparent 50%); + pointer-events:none; +} +.overlayIcon.show:hover{ + border-color:rgba(255,255,255,.22); + box-shadow: + 0 12px 40px rgba(0,0,0,.6), + 0 0 0 1px rgba(0,0,0,.4), + 0 0 40px rgba(95,175,255,.1); +} +.overlayIcon i{ + font-size:36px; + color:rgba(255,255,255,.92)!important; + filter:drop-shadow(0 2px 10px rgba(0,0,0,.6)); + position:relative; + z-index:2; + transition:transform 0.3s ease, color 0.3s ease; + margin-left:4px; /* center play icon visually */ +} +.overlayIcon.pause i{ + margin-left:0; +} +.overlayIcon.show:hover i{ + transform:scale(1.1); + color:rgba(255,255,255,1)!important; +} + +.controls{display:flex; flex-direction:column; gap:10px; padding:12px; border-top:1px solid var(--stroke); flex:0 0 auto; background:linear-gradient(180deg, rgba(0,0,0,.06), rgba(0,0,0,.0));} +.controlsRow{display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap;} +.group{display:flex; align-items:center; gap:10px; flex-wrap:wrap;} + +.iconBtn{ + width:40px; height:36px; + border-radius:8px; + border:1px solid rgba(255,255,255,.08); + background:linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.02)); + box-shadow: + 0 2px 6px rgba(0,0,0,.12), + inset 0 1px 0 rgba(255,255,255,.06); + display:inline-flex; align-items:center; justify-content:center; + cursor:pointer; user-select:none; + transition:all .2s cubic-bezier(.4,0,.2,1); + position:relative; + overflow:hidden; +} +.iconBtn::before{ + content:""; + position:absolute; + inset:0; + background:radial-gradient(circle at 50% 0%, rgba(255,255,255,.15), transparent 70%); + opacity:0; + transition:opacity .2s ease; +} +.iconBtn:hover{ + border-color:rgba(255,255,255,.15); + background:linear-gradient(180deg, rgba(255,255,255,.08), rgba(255,255,255,.03)); + transform:translateY(-1px); + box-shadow: + 0 4px 12px rgba(0,0,0,.2), + inset 0 1px 0 rgba(255,255,255,.1); +} +.iconBtn:hover::before{opacity:1;} +.iconBtn:active{ + transform:translateY(0); + box-shadow:0 1px 4px rgba(0,0,0,.15); +} +.iconBtn.primary{ + border-color:rgba(95,175,255,.25); + background:linear-gradient(180deg, rgba(95,175,255,.15), rgba(95,175,255,.05)); + box-shadow: + 0 2px 8px rgba(0,0,0,.15), + 0 4px 16px rgba(95,175,255,.08), + inset 0 1px 0 rgba(255,255,255,.1); +} +.iconBtn.primary::before{ + background:radial-gradient(circle at 50% 0%, rgba(255,255,255,.2), transparent 70%); +} +.iconBtn.primary:hover{ + border-color:rgba(95,175,255,.4); + background:linear-gradient(180deg, rgba(95,175,255,.22), rgba(95,175,255,.08)); + box-shadow: + 0 4px 12px rgba(0,0,0,.2), + 0 8px 24px rgba(95,175,255,.12), + inset 0 1px 0 rgba(255,255,255,.15); +} +.iconBtn .fa{font-size:15px; color:var(--iconStrong)!important; opacity:.9; transition:transform .2s ease; position:relative; z-index:1;} +.iconBtn:hover .fa{transform:scale(1.1);} + +.timeChip{display:inline-flex; align-items:center; gap:10px; padding:8px 10px; border-radius:999px; border:1px solid var(--strokeMed); background:var(--surface); box-shadow:var(--shadow2); font-family:var(--mono); font-size:12px; color:var(--text); letter-spacing:.15px; font-variant-numeric:tabular-nums; transition:border-color .15s ease;} +.timeDot{width:8px; height:8px; border-radius:999px; background:radial-gradient(circle at 35% 35%, rgba(255,255,255,.90), rgba(130,230,180,.55)); box-shadow:0 0 0 3px rgba(130,230,180,.10); opacity:.95; transition:transform .3s ease; animation:pulse 2s ease-in-out infinite;} +@keyframes pulse{0%,100%{transform:scale(1);opacity:.95;} 50%{transform:scale(1.15);opacity:1;}} + +.seekWrap{display:flex; align-items:center; gap:10px; width:100%; position:relative;} +.seekTrack{ + position:absolute; + left:0; right:0; top:50%; + height:10px; + transform:translateY(-50%); + border-radius:999px; + background:rgba(255,255,255,.06); + border:1px solid rgba(255,255,255,.10); + box-shadow:0 8px 18px rgba(0,0,0,.28); + overflow:hidden; + pointer-events:none; +} +.seekFill{ + height:100%; + width:0%; + background:linear-gradient(90deg, rgba(95,175,255,.7), rgba(130,200,255,.5) 60%, rgba(180,230,200,.4)); + border-radius:999px 0 0 999px; + box-shadow:0 0 12px rgba(95,175,255,.3); + transition:width .1s linear; +} +.seek{-webkit-appearance:none; appearance:none; width:100%; height:18px; border-radius:999px; background:transparent; border:none; box-shadow:none; outline:none; position:relative; z-index:2; cursor:pointer; margin:0;} +.seek::-webkit-slider-runnable-track{background:transparent; height:18px;} +.seek::-webkit-slider-thumb{-webkit-appearance:none; appearance:none; width:18px; height:18px; border-radius:999px; border:2px solid rgba(255,255,255,.25); background:linear-gradient(180deg, rgba(255,255,255,.95), rgba(200,220,255,.8)); box-shadow:0 4px 12px rgba(0,0,0,.4), 0 0 0 4px rgba(95,175,255,.15); cursor:pointer; transition:transform .15s ease, box-shadow .15s ease; margin-top:0;} +.seek:hover::-webkit-slider-thumb{transform:scale(1.15); box-shadow:0 6px 16px rgba(0,0,0,.5), 0 0 0 6px rgba(95,175,255,.2);} +.seek:active::-webkit-slider-thumb{transform:scale(1.05); box-shadow:0 2px 8px rgba(0,0,0,.4), 0 0 0 8px rgba(95,175,255,.25);} +.seek::-moz-range-track{background:transparent; height:18px;} +.seek::-moz-range-thumb{width:18px; height:18px; border-radius:999px; border:2px solid rgba(255,255,255,.25); background:linear-gradient(180deg, rgba(255,255,255,.95), rgba(200,220,255,.8)); box-shadow:0 4px 12px rgba(0,0,0,.4); cursor:pointer;} + +.miniCtl{display:flex; align-items:center; gap:10px; padding:8px 10px; border-radius:999px; border:1px solid var(--strokeMed); background:var(--surface); box-shadow:var(--shadow2); position:relative; transition:border-color .15s ease;} +.miniCtl:hover{border-color:rgba(255,255,255,.16);} +.miniCtl .fa{font-size:14px; color:var(--iconStrong)!important; opacity:.95; flex:0 0 auto;} + +.volWrap{position:relative; width:120px; height:14px; display:flex; align-items:center;} +.volTrack{ + position:absolute; + left:0; right:0; top:50%; + height:6px; + transform:translateY(-50%); + border-radius:999px; + background:rgba(255,255,255,.06); + border:1px solid rgba(255,255,255,.10); + overflow:hidden; + pointer-events:none; +} +.volFill{ + height:100%; + width:100%; + background:linear-gradient(90deg, rgba(95,175,255,.6), rgba(130,200,255,.4)); + border-radius:999px 0 0 999px; + box-shadow:0 0 8px rgba(95,175,255,.2); + transition:width .05s linear; +} +.vol{-webkit-appearance:none; appearance:none; width:100%; height:14px; border-radius:999px; background:transparent; border:none; outline:none; position:relative; z-index:2; cursor:pointer; margin:0;} +.vol::-webkit-slider-runnable-track{background:transparent; height:14px;} +.vol::-webkit-slider-thumb{-webkit-appearance:none; appearance:none; width:14px; height:14px; border-radius:999px; border:2px solid rgba(255,255,255,.25); background:linear-gradient(180deg, rgba(255,255,255,.95), rgba(200,220,255,.8)); box-shadow:0 2px 8px rgba(0,0,0,.35), 0 0 0 3px rgba(95,175,255,.12); cursor:pointer; transition:transform .15s ease, box-shadow .15s ease;} +.vol:hover::-webkit-slider-thumb{transform:scale(1.15); box-shadow:0 3px 10px rgba(0,0,0,.4), 0 0 0 4px rgba(95,175,255,.18);} +.vol::-moz-range-track{background:transparent; height:14px;} +.vol::-moz-range-thumb{width:14px; height:14px; border-radius:999px; border:2px solid rgba(255,255,255,.25); background:linear-gradient(180deg, rgba(255,255,255,.95), rgba(200,220,255,.8)); box-shadow:0 2px 8px rgba(0,0,0,.35); cursor:pointer;} + +.volTooltip{ + position:absolute; + bottom:calc(100% + 12px); + left:0; + padding:8px 12px; + border-radius:var(--r2); + background: + radial-gradient(ellipse 120% 100% at 20% 0%, rgba(95,175,255,.12), transparent 50%), + linear-gradient(175deg, rgba(28,34,48,.97), rgba(16,20,30,.98)); + border:1px solid rgba(255,255,255,.12); + color:#fff; + font-family:var(--mono); + font-size:13px; + font-weight:600; + letter-spacing:.03em; + white-space:nowrap; + pointer-events:none; + opacity:0; + transform:translateX(-50%) translateY(4px); + transition:opacity .15s ease, transform .15s ease, left .05s linear; + box-shadow: + 0 0 0 1px rgba(0,0,0,.3), + 0 4px 8px rgba(0,0,0,.2), + 0 12px 24px rgba(0,0,0,.25), + inset 0 1px 0 rgba(255,255,255,.08); + backdrop-filter:blur(16px); + z-index:100; +} +.volTooltip::before{ + content:""; + position:absolute; + top:0; left:0; right:0; + height:1px; + background:linear-gradient(90deg, transparent, rgba(95,175,255,.4) 50%, transparent); + border-radius:var(--r2) var(--r2) 0 0; +} +.volTooltip::after{ + content:""; + position:absolute; + top:100%; + left:50%; + transform:translateX(-50%); + border:6px solid transparent; + border-top-color:rgba(20,26,36,.95); +} +.volTooltip.show{ + opacity:1; + transform:translateX(-50%) translateY(0); +} + +.speedBox{display:flex; align-items:center; gap:10px; position:relative;} +.speedBtn{border:none; outline:none; background:transparent; color:rgba(246,248,255,.92); font-family:var(--mono); font-size:12px; letter-spacing:.10px; padding:0 2px; cursor:pointer; line-height:1; display:inline-flex; align-items:center; gap:8px; transition:color .15s ease;} +.speedBtn span:first-child{min-width:3.5ch; text-align:right;} +.speedBtn:hover{color:rgba(255,255,255,1);} +.speedCaret .fa{font-size:12px; opacity:.85; color:var(--icon)!important; transition:transform .2s ease;} +.speedBtn:hover .speedCaret .fa{transform:translateY(2px);} + +.progressPill{flex:0 0 auto; display:flex; align-items:center; gap:10px; padding:8px 10px; border-radius:999px; border:1px solid var(--strokeMed); background:radial-gradient(400px 100px at 20% 0%, var(--accentGlow), transparent 60%), var(--surface); box-shadow:var(--shadow2); min-width:220px;} +.progressLabel{font-size:11.2px; font-weight:820; letter-spacing:.12px; text-transform:uppercase; color:rgba(190,198,220,.78); margin-right:2px;} +.progressBar{width:120px; height:8px; border-radius:999px; border:1px solid rgba(255,255,255,.09); background:rgba(255,255,255,.05); overflow:hidden;} +.progressBar>div{height:100%; width:0%; background:linear-gradient(90deg, rgba(100,180,255,.95), rgba(130,230,180,.88));} +.progressPct{font-family:var(--mono); font-size:11.6px; color:rgba(230,235,255,.92); font-variant-numeric:tabular-nums; letter-spacing:.10px; min-width:58px; text-align:right;} diff --git a/src/styles/playlist.css b/src/styles/playlist.css new file mode 100644 index 0000000..e589f03 --- /dev/null +++ b/src/styles/playlist.css @@ -0,0 +1,100 @@ +.listWrap{ + flex:1 1 auto; min-height:0; position:relative; overflow:hidden; +} +.list{ + position:absolute; inset:0; + overflow-y:scroll; overflow-x:hidden; + --fade-top:75px; --fade-bottom:75px; + mask-image:linear-gradient(180deg, transparent 0%, #000 var(--fade-top), #000 calc(100% - var(--fade-bottom)), transparent 100%); + -webkit-mask-image:linear-gradient(180deg, transparent 0%, #000 var(--fade-top), #000 calc(100% - var(--fade-bottom)), transparent 100%); + transition:--fade-top 0.8s ease, --fade-bottom 0.8s ease; + scrollbar-width:none; +} +.list::-webkit-scrollbar{display:none;} +@property --list-fade-top { + syntax: ''; + initial-value: 30px; + inherits: false; +} +@property --list-fade-bottom { + syntax: ''; + initial-value: 30px; + inherits: false; +} +.list.at-top{--fade-top:0px;} +.list.at-bottom{--fade-bottom:0px;} + +.listScrollbar{ + position:absolute; + top:12px; right:6px; bottom:12px; + width:3px; + border-radius:2px; + pointer-events:none; + opacity:0; + transition:opacity .4s ease; + z-index:10; +} +.listWrap:hover .listScrollbar, .listScrollbar.active{opacity:1;} +.listScrollbarThumb{ + position:absolute; + top:0; left:0; right:0; + min-height:24px; + background:linear-gradient(180deg, rgba(95,175,255,.3), rgba(95,175,255,.15)); + border:1px solid rgba(95,175,255,.2); + border-radius:2px; + box-shadow:0 2px 8px rgba(0,0,0,.2); + transition:background .2s ease, border-color .2s ease, box-shadow .2s ease; + cursor:grab; +} +.listScrollbarThumb:hover{ + background:linear-gradient(180deg, rgba(95,175,255,.45), rgba(95,175,255,.25)); + border-color:rgba(95,175,255,.35); +} +.listScrollbarThumb:active{ + cursor:grabbing; +} +.listScrollbar.active .listScrollbarThumb{ + background:linear-gradient(180deg, rgba(95,175,255,.5), rgba(95,175,255,.3)); + border-color:rgba(95,175,255,.4); + box-shadow:0 2px 12px rgba(95,175,255,.15); +} + +.row{position:relative; display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:11px 12px; border-bottom:1px solid var(--strokeLight); cursor:pointer; user-select:none; transition:background .2s ease, box-shadow .2s ease; box-shadow:inset 3px 0 0 transparent;} +.row:hover{background:var(--surfaceHover); box-shadow:inset 3px 0 0 rgba(95,175,255,.45);} +.row:active{transform:none;} +.row.active{background:linear-gradient(90deg, var(--accentBg), transparent); box-shadow:inset 3px 0 0 rgba(95,175,255,.7), 0 0 0 1px var(--accentGlow) inset;} +.row.dragging{opacity:.55;} + +.left{min-width:0; display:flex; align-items:flex-start; gap:10px; flex:1 1 auto;} +.numBadge{flex:0 0 auto; min-width:38px; height:22px; padding:0 8px; border-radius:999px; border:1px solid var(--strokeMed); background:radial-gradient(200px 60px at 20% 0%, var(--accentGlow), transparent 55%), var(--surface); box-shadow:var(--shadow2); display:flex; align-items:center; justify-content:center; font-family:var(--mono); font-size:11.8px; letter-spacing:.08px; color:var(--text); font-variant-numeric:tabular-nums; margin-top:1px; transition:all .2s ease; transform:translateX(0);} +.row:hover .numBadge{border-color:var(--accentBorder); background:radial-gradient(200px 60px at 20% 0%, var(--accentGlow), transparent 50%), var(--surfaceHover); transform:translateX(4px);} + +.treeSvg{flex:0 0 auto; margin-top:1px; overflow:visible;} +.treeSvg line{stroke:rgb(65,75,95); stroke-width:1.5;} +.treeSvg circle{fill:rgba(230,240,255,.70); stroke:rgba(100,180,255,.22); stroke-width:1; transition:all .15s ease;} +.row:hover .treeSvg circle{fill:rgba(240,250,255,.85); stroke:rgba(100,180,255,.35);} + +.textWrap{min-width:0; flex:1 1 auto; transition:transform .2s ease; transform:translateX(0);} +.row:hover .textWrap{transform:translateX(4px);} +.name{font-size:12.9px; font-weight:820; letter-spacing:.12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; transition:color .15s ease;} +.row:hover .name{color:rgba(255,255,255,.98);} +.small{margin-top:4px; font-size:11.4px; color:var(--textMuted); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; transition:color .15s ease;} +.row:hover .small{color:rgba(175,185,210,.85);} + +.tag{flex:0 0 auto; display:inline-flex; align-items:center; padding:5px 9px; border-radius:999px; border:1px solid var(--stroke); background:var(--surface); color:var(--textMuted); font-size:11px; font-weight:820; letter-spacing:.12px; text-transform:uppercase; margin-top:2px; transition:all .15s ease;} +.tag.now{border-color:var(--accentBorder); color:rgba(215,240,255,.90); background:var(--accentBg);} +.tag.done{border-color:var(--successBorder); color:rgba(210,255,230,.88); background:var(--successBg);} +.tag.hidden{display:none;} +.row:hover .tag{transform:scale(1.02);} + +.row.drop-before::before,.row.drop-after::after{ + content:""; position:absolute; left:10px; right:10px; border-top:2px solid var(--accent); + pointer-events:none; filter:drop-shadow(0 0 10px var(--accentGlow)); +} +.row.drop-before::before{top:-1px;} +.row.drop-after::after{bottom:-1px;} + +.empty{padding:14px 12px; color:var(--textMuted); font-size:12.5px; line-height:1.4;} + +.playlistHeader{font-weight:900; letter-spacing:.16px; font-size:13.8px; cursor:help; display:flex; align-items:center; gap:10px;} +.playlistHeader .fa{color:var(--iconStrong)!important; opacity:.92;} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..9d33dfc --- /dev/null +++ b/src/types.ts @@ -0,0 +1,188 @@ +// ===== Video Item (from get_library_info items array) ===== + +export interface VideoItem { + index: number; + fid: string; + name: string; + title: string; + relpath: string; + depth: number; + pipes: boolean[]; + is_last: boolean; + has_prev_in_parent: boolean; + pos: number; + watched: number; + duration: number | null; + finished: boolean; + note_len: number; + last_open: number; + has_sub: boolean; +} + +// ===== Library Info (from get_library / open_folder_path / select_folder) ===== + +export interface NextUp { + index: number; + title: string; +} + +export interface LibraryInfo { + ok: boolean; + error?: string; + cancelled?: boolean; + folder?: string; + library_id?: string; + count?: number; + current_index?: number; + current_fid?: string | null; + current_time?: number; + folder_volume?: number; + folder_autoplay?: boolean; + folder_rate?: number; + items?: VideoItem[]; + has_subdirs?: boolean; + overall_progress?: number | null; + durations_known?: number; + finished_count?: number; + remaining_count?: number; + remaining_seconds_known?: number | null; + top_folders?: [string, number][]; + next_up?: NextUp | null; +} + +// ===== Preferences ===== + +export interface WindowState { + width: number; + height: number; + x: number | null; + y: number | null; +} + +export interface Prefs { + version: number; + ui_zoom: number; + split_ratio: number; + dock_ratio: number; + always_on_top: boolean; + window: WindowState; + last_folder_path: string | null; + last_library_id: string | null; + updated_at: number; +} + +// ===== API Responses ===== + +export interface OkResponse { + ok: boolean; + error?: string; +} + +export interface PrefsResponse { + ok: boolean; + prefs: Prefs; +} + +export interface RecentItem { + name: string; + path: string; +} + +export interface RecentsResponse { + ok: boolean; + items: RecentItem[]; +} + +export interface NoteResponse { + ok: boolean; + note: string; + len?: number; +} + +// ===== Video Metadata ===== + +export interface BasicFileMeta { + ext: string; + size: number; + mtime: number; + folder: string; +} + +export interface SubtitleTrack { + index: number; + codec: string; + language: string; + title: string; +} + +export interface ProbeMeta { + v_codec?: string; + width?: number; + height?: number; + fps?: number; + v_bitrate?: number; + pix_fmt?: string; + color_space?: string; + a_codec?: string; + channels?: number; + sample_rate?: string; + a_bitrate?: number; + subtitle_tracks?: SubtitleTrack[]; + container_bitrate?: number; + duration?: number; + format_name?: string; + container_title?: string; + encoder?: string; +} + +export interface VideoMetaResponse { + ok: boolean; + error?: string; + fid?: string; + basic?: BasicFileMeta; + probe?: ProbeMeta | null; + ffprobe_found?: boolean; +} + +// ===== Subtitles ===== + +export interface SubtitleResponse { + ok: boolean; + has?: boolean; + url?: string; + label?: string; + cancelled?: boolean; + error?: string; +} + +export interface SidecarSub { + path: string; + label: string; + format: string; +} + +export interface EmbeddedSub { + index: number; + label: string; + codec: string; + language: string; +} + +export interface AvailableSubsResponse { + ok: boolean; + sidecar: SidecarSub[]; + embedded: EmbeddedSub[]; +} + +export interface EmbeddedSubsResponse { + ok: boolean; + tracks: SubtitleTrack[]; +} + +// ===== FFmpeg Download Progress ===== + +export interface FfmpegProgress { + percent: number; + downloaded_bytes: number; + total_bytes: number; +}