Files
tutorialvault/tutorial.py

5282 lines
196 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""TutorialDock - A video tutorial library manager with progress tracking."""
import hashlib
import json
import mimetypes
import os
import re
import shutil
import socket
import subprocess
import threading
import time
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
from urllib.parse import urlparse
from urllib.request import Request, urlopen
from flask import Flask, Response, abort, request, send_file
# =============================================================================
# Configuration and Constants
# =============================================================================
APP_DIR = Path(__file__).resolve().parent
STATE_DIR = APP_DIR / "state"
STATE_DIR.mkdir(parents=True, exist_ok=True)
WEBVIEW_PROFILE_DIR = STATE_DIR / "webview_profile"
WEBVIEW_PROFILE_DIR.mkdir(parents=True, exist_ok=True)
FONTS_DIR = STATE_DIR / "fonts"
FONTS_DIR.mkdir(parents=True, exist_ok=True)
FONTS_CSS_PATH = FONTS_DIR / "fonts.css"
FONTS_META_PATH = FONTS_DIR / "fonts_meta.json"
FA_DIR = STATE_DIR / "fontawesome"
FA_DIR.mkdir(parents=True, exist_ok=True)
FA_WEBFONTS_DIR = FA_DIR / "webfonts"
FA_WEBFONTS_DIR.mkdir(parents=True, exist_ok=True)
FA_CSS_PATH = FA_DIR / "fa.css"
FA_META_PATH = FA_DIR / "fa_meta.json"
SUBS_DIR = STATE_DIR / "subtitles"
SUBS_DIR.mkdir(parents=True, exist_ok=True)
RECENTS_PATH = STATE_DIR / "recent_folders.json"
RECENTS_MAX = 50
# Register additional MIME types
mimetypes.add_type("font/woff2", ".woff2")
mimetypes.add_type("font/woff", ".woff")
mimetypes.add_type("font/ttf", ".ttf")
mimetypes.add_type("application/vnd.ms-fontobject", ".eot")
mimetypes.add_type("image/svg+xml", ".svg")
mimetypes.add_type("text/vtt", ".vtt")
if os.name == "nt":
os.environ["WEBVIEW2_USER_DATA_FOLDER"] = str(WEBVIEW_PROFILE_DIR)
import webview # noqa: E402 # Must import after setting env vars
# Supported file extensions
VIDEO_EXTS: set[str] = {
".mp4", ".m4v", ".mov", ".webm", ".mkv",
".avi", ".mpg", ".mpeg", ".m2ts", ".mts",
".ogv"
}
SUB_EXTS: set[str] = {".vtt", ".srt"}
BACKUP_COUNT: int = 8
HOST: str = "127.0.0.1"
PREFS_PATH: Path = STATE_DIR / "prefs.json"
app = Flask(__name__)
# =============================================================================
# Utility Functions
# =============================================================================
def _subprocess_no_window_kwargs() -> dict[str, Any]:
"""Return subprocess kwargs to hide console windows on Windows."""
if os.name != "nt":
return {}
si = subprocess.STARTUPINFO()
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
return {"startupinfo": si, "creationflags": subprocess.CREATE_NO_WINDOW}
def clamp(v: float, a: float, b: float) -> float:
"""Clamp value v to the range [a, b]."""
return max(a, min(b, v))
def atomic_write_json(path: Path, data: Dict[str, Any], backup_count: int = BACKUP_COUNT) -> None:
"""
Write JSON data to a file atomically with backup rotation.
Creates rolling backups (.bak1 through .bakN) and a .lastgood copy
for crash recovery.
"""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
payload = json.dumps(data, ensure_ascii=False, indent=2)
if path.exists():
# Rotate existing backups
for i in range(backup_count, 0, -1):
src = path.with_suffix(path.suffix + f".bak{i}")
dst = path.with_suffix(path.suffix + f".bak{i+1}")
if src.exists():
try:
if dst.exists():
dst.unlink()
src.rename(dst)
except OSError:
pass
# Move current to .bak1
bak1 = path.with_suffix(path.suffix + ".bak1")
try:
if bak1.exists():
bak1.unlink()
path.rename(bak1)
except OSError:
pass
# Write atomically via tmp file
tmp.write_text(payload, encoding="utf-8")
tmp.replace(path)
# Keep a lastgood copy for recovery
try:
lastgood = path.with_suffix(path.suffix + ".lastgood")
lastgood.write_text(payload, encoding="utf-8")
except OSError:
pass
def load_json_with_fallbacks(path: Path) -> Optional[Dict[str, Any]]:
"""
Load JSON from path, falling back to backups if primary is corrupted.
Tries: path -> .lastgood -> .bak1 -> .bak2 -> ...
"""
candidates = [path, path.with_suffix(path.suffix + ".lastgood")]
for i in range(1, BACKUP_COUNT + 3):
candidates.append(path.with_suffix(path.suffix + f".bak{i}"))
for p in candidates:
if p.exists():
try:
return json.loads(p.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
continue
return None
def is_within_root(root: Path, target: Path) -> bool:
"""Check if target path is within the root directory (prevents path traversal)."""
try:
root = root.resolve()
target = target.resolve()
return str(target).startswith(str(root) + os.sep) or target == root
except OSError:
return False
def truthy(v: Any) -> bool:
"""Convert various types to boolean (handles string 'true', 'yes', '1', etc.)."""
if isinstance(v, bool):
return v
if isinstance(v, (int, float)):
return v != 0
if isinstance(v, str):
return v.strip().lower() in {"1", "true", "yes", "y", "on"}
return False
def pick_free_port(host: str = HOST) -> int:
"""Find and return an available TCP port on the specified host."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((host, 0))
return s.getsockname()[1]
def folder_display_name(path_str: str) -> str:
"""Get a display-friendly name for a folder path."""
try:
p = Path(path_str)
return p.name or str(p)
except (ValueError, OSError):
return path_str
def _deduplicate_list(items: List[str]) -> List[str]:
"""Remove duplicates from a list while preserving order."""
seen: set[str] = set()
result: List[str] = []
for item in items:
if isinstance(item, str) and item.strip():
s = item.strip()
if s not in seen:
result.append(s)
seen.add(s)
return result
def load_recents() -> List[str]:
"""Load the list of recently opened folders."""
data = load_json_with_fallbacks(RECENTS_PATH)
if not isinstance(data, dict):
return []
items = data.get("items", [])
if not isinstance(items, list):
return []
return _deduplicate_list(items)[:RECENTS_MAX]
def save_recents(paths: List[str]) -> None:
"""Save the list of recently opened folders."""
cleaned = _deduplicate_list(paths)[:RECENTS_MAX]
atomic_write_json(RECENTS_PATH, {
"version": 2,
"updated_at": int(time.time()),
"items": cleaned
})
def push_recent(path_str: str) -> None:
"""Add a folder to the top of the recent folders list."""
paths = load_recents()
path_str = path_str.strip()
paths = [p for p in paths if p != path_str]
paths.insert(0, path_str)
save_recents(paths)
# Compiled regex patterns (compiled once for efficiency)
CSS_URL_RE = re.compile(r"url\((https://[^)]+)\)\s*format\('([^']+)'\)")
def _http_get_text(url: str, timeout: int = 20) -> str:
"""Fetch text content from a URL."""
req = Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urlopen(req, timeout=timeout) as r:
return r.read().decode("utf-8", errors="replace")
def _http_get_bytes(url: str, timeout: int = 30) -> bytes:
"""Fetch binary content from a URL."""
req = Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urlopen(req, timeout=timeout) as r:
return r.read()
def _safe_filename_from_url(u: str) -> str:
"""Generate a safe local filename from a URL with a hash for uniqueness."""
p = urlparse(u)
base = Path(p.path).name or "font.woff2"
url_hash = hashlib.sha256(u.encode("utf-8")).hexdigest()[:10]
if "." not in base:
base += ".woff2"
stem, suffix = Path(base).stem, Path(base).suffix
return f"{stem}-{url_hash}{suffix}"
def ensure_google_fonts_local() -> None:
"""Download and cache Google Fonts locally for offline use."""
wanted = {
"Sora": "https://fonts.googleapis.com/css2?family=Sora:wght@500;600;700;800&display=swap",
"Manrope": "https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap",
"IBM Plex Mono": "https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap",
}
meta = load_json_with_fallbacks(FONTS_META_PATH) or {}
if (FONTS_CSS_PATH.exists()
and isinstance(meta, dict)
and meta.get("ok") is True
and meta.get("version") == 7):
return
combined_css_parts: List[str] = []
downloaded: Dict[str, int] = {}
ok = True
errors: List[str] = []
for family, css_url in wanted.items():
try:
css = _http_get_text(css_url)
def replace_match(m: re.Match) -> str:
url, fmt = m.group(1), m.group(2)
if fmt.lower() != "woff2":
return m.group(0)
fn = _safe_filename_from_url(url)
local_path = FONTS_DIR / fn
if not local_path.exists():
data = _http_get_bytes(url)
local_path.write_bytes(data)
downloaded[fn] = len(data)
return f"url(/fonts/{fn}) format('woff2')"
css2 = CSS_URL_RE.sub(replace_match, css)
combined_css_parts.append(css2.strip())
except Exception as e:
ok = False
errors.append(f"{family}: {e!s}")
combined_css = "\n\n".join(combined_css_parts).strip()
try:
FONTS_CSS_PATH.write_text(combined_css, encoding="utf-8")
except OSError as e:
ok = False
errors.append(f"write fonts.css: {e!s}")
atomic_write_json(FONTS_META_PATH, {
"version": 7,
"ok": ok,
"updated_at": int(time.time()),
"downloaded_files": downloaded,
"errors": errors
})
FA_URL_RE = re.compile(r"url\(([^)]+)\)")
def ensure_fontawesome_local() -> None:
"""Download and cache Font Awesome locally for offline use."""
meta = load_json_with_fallbacks(FA_META_PATH) or {}
if (FA_CSS_PATH.exists()
and isinstance(meta, dict)
and meta.get("ok") is True
and meta.get("version") == 3):
return
ok = True
errors: List[str] = []
downloaded: Dict[str, int] = {}
css_url = "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
try:
css = _http_get_text(css_url, timeout=25)
except Exception as e:
ok = False
errors.append(f"download fa css: {e!s}")
css = ""
def _clean_url(u: str) -> str:
"""Normalize a URL from CSS to an absolute URL."""
u = u.strip().strip("'\"")
if u.startswith("data:"):
return u
if u.startswith("//"):
return "https:" + u
if u.startswith(("http://", "https://")):
return u
return "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/webfonts/" + u.lstrip("./").replace("../", "")
def repl(m: re.Match) -> str:
nonlocal ok
raw = m.group(1)
u = _clean_url(raw)
if u.startswith("data:"):
return m.group(0)
fn = Path(urlparse(u).path).name
if not fn:
return m.group(0)
local_path = FA_WEBFONTS_DIR / fn
if not local_path.exists():
try:
data = _http_get_bytes(u, timeout=30)
local_path.write_bytes(data)
downloaded[fn] = len(data)
except Exception as e:
ok = False
errors.append(f"download {fn}: {e!s}")
return m.group(0)
return f"url(/fa/webfonts/{fn})"
css2 = FA_URL_RE.sub(repl, css)
try:
FA_CSS_PATH.write_text(css2, encoding="utf-8")
except OSError as e:
ok = False
errors.append(f"write fa.css: {e!s}")
atomic_write_json(FA_META_PATH, {
"version": 3,
"ok": ok,
"updated_at": int(time.time()),
"downloaded_files": downloaded,
"errors": errors,
"source_css": css_url
})
# =============================================================================
# File and String Processing
# =============================================================================
_NUM_SPLIT_RE = re.compile(r"(\d+)")
def natural_key(s: str) -> List[Any]:
"""
Generate a sort key for natural sorting (e.g., '2' before '10').
Splits string into numeric and non-numeric parts, converting numeric
parts to integers for proper ordering.
"""
parts = _NUM_SPLIT_RE.split(s)
return [int(p) if p.isdigit() else p.casefold() for p in parts]
_LEADING_INDEX_RE = re.compile(r"^\s*(?:\(?\s*)?(?P<num>\d+)(?:\s*[.\-_]\s*\d+)*(?:\s*[.)\]-]\s*|\s+)")
# Words that should remain lowercase in title case (except at start)
_SMALL_WORDS: set[str] = {
"a", "an", "the", "and", "or", "but", "for", "nor", "as", "at",
"by", "in", "of", "on", "per", "to", "vs", "via", "with", "into", "from"
}
def _smart_title_case(text: str) -> str:
"""Convert text to title case, keeping small words lowercase (except at start)."""
words = re.split(r"(\s+)", text.strip())
out: List[str] = []
for i, w in enumerate(words):
if w.isspace():
out.append(w)
continue
# Preserve words with digits or all-caps acronyms
if any(ch.isdigit() for ch in w) or w.isupper():
out.append(w)
continue
lw = w.lower()
# First word always capitalized, small words stay lowercase elsewhere
if i != 0 and lw in _SMALL_WORDS:
out.append(lw)
else:
out.append(lw[:1].upper() + lw[1:])
return "".join(out).strip()
def pretty_title_from_filename(filename: str) -> str:
"""
Convert a filename to a human-readable title.
Removes leading indices, underscores, and applies smart title case.
Example: '01_introduction_to_python.mp4' -> 'Introduction to Python'
"""
base = Path(filename).stem
# Replace underscores with spaces
base = re.sub(r"[_]+", " ", base)
base = re.sub(r"\s+", " ", base).strip()
# Remove leading index numbers (e.g., "01.", "1)", "(2)")
m = _LEADING_INDEX_RE.match(base)
if m:
base = base[m.end():].strip()
# Remove leading punctuation
base = re.sub(r"^\s*[-–—:.\)]\s*", "", base).strip()
base = re.sub(r"\s+", " ", base).strip()
# Fall back to original stem if nothing left
if not base:
base = Path(filename).stem
return _smart_title_case(base)
def _windows_where(exe_name: str) -> Optional[str]:
"""Find an executable using Windows 'where' command."""
try:
out = subprocess.check_output(
["where", exe_name],
stderr=subprocess.STDOUT,
text=True,
**_subprocess_no_window_kwargs()
)
for line in out.splitlines():
line = line.strip()
if line and Path(line).exists():
return line
except (subprocess.SubprocessError, OSError):
pass
return None
def find_ffprobe_ffmpeg() -> Tuple[Optional[str], Optional[str]]:
"""
Locate ffprobe and ffmpeg executables.
Searches in PATH, then Windows 'where', then local app directory.
"""
ffprobe = shutil.which("ffprobe")
ffmpeg = shutil.which("ffmpeg")
# Try Windows 'where' command
if os.name == "nt":
if not ffprobe:
ffprobe = _windows_where("ffprobe.exe") or _windows_where("ffprobe")
if not ffmpeg:
ffmpeg = _windows_where("ffmpeg.exe") or _windows_where("ffmpeg")
# Check for local copies in app directory
if not ffprobe:
exe_name = "ffprobe.exe" if os.name == "nt" else "ffprobe"
cand = APP_DIR / exe_name
if cand.exists():
ffprobe = str(cand)
if not ffmpeg:
exe_name = "ffmpeg.exe" if os.name == "nt" else "ffmpeg"
cand = APP_DIR / exe_name
if cand.exists():
ffmpeg = str(cand)
return ffprobe, ffmpeg
def duration_seconds(
path: Path,
ffprobe_path: Optional[str],
ffmpeg_path: Optional[str]
) -> Optional[float]:
"""Get video duration in seconds using ffprobe or ffmpeg."""
# Try ffprobe first (more reliable)
if ffprobe_path:
try:
cmd = [
ffprobe_path, "-v", "error",
"-show_entries", "format=duration",
"-of", "default=nw=1:nk=1",
str(path)
]
out = subprocess.check_output(
cmd,
stderr=subprocess.STDOUT,
text=True,
timeout=20,
**_subprocess_no_window_kwargs()
).strip()
if out:
d = float(out)
if d > 0:
return d
except (subprocess.SubprocessError, ValueError, OSError):
pass
# Fall back to parsing ffmpeg stderr
if ffmpeg_path:
try:
p = subprocess.run(
[ffmpeg_path, "-hide_banner", "-i", str(path)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=20,
**_subprocess_no_window_kwargs()
)
err = p.stderr or ""
marker = "Duration:"
idx = err.find(marker)
if idx != -1:
s = err[idx + len(marker):].strip()
timepart = s.split(",")[0].strip()
hh, mm, ss = timepart.split(":")
total = int(hh) * 3600 + int(mm) * 60 + float(ss)
if total > 0:
return float(total)
except (subprocess.SubprocessError, ValueError, OSError):
pass
return None
def ffprobe_video_metadata(path: Path, ffprobe_path: Optional[str]) -> Optional[Dict[str, Any]]:
"""Extract detailed video metadata using ffprobe, including subtitle tracks."""
if not ffprobe_path:
return None
try:
cmd = [
ffprobe_path, "-v", "error",
"-print_format", "json",
"-show_streams", "-show_format",
str(path)
]
out = subprocess.check_output(
cmd,
stderr=subprocess.STDOUT,
text=True,
timeout=25,
**_subprocess_no_window_kwargs()
)
data = json.loads(out)
streams = data.get("streams", [])
fmt = data.get("format", {})
if not isinstance(streams, list):
streams = []
meta: Dict[str, Any] = {}
video_stream: Optional[Dict] = None
audio_stream: Optional[Dict] = None
subtitle_tracks: List[Dict[str, Any]] = []
# Find first video/audio streams and all subtitle streams
for idx, s in enumerate(streams):
if not isinstance(s, dict):
continue
codec_type = s.get("codec_type")
if codec_type == "video" and video_stream is None:
video_stream = s
elif codec_type == "audio" and audio_stream is None:
audio_stream = s
elif codec_type == "subtitle":
# Extract subtitle track info
tags = s.get("tags", {})
if not isinstance(tags, dict):
tags = {}
sub_info = {
"index": s.get("index", idx),
"codec": s.get("codec_name", "unknown"),
"language": tags.get("language", tags.get("LANGUAGE", "")),
"title": tags.get("title", tags.get("TITLE", "")),
}
subtitle_tracks.append(sub_info)
# Extract video metadata
if video_stream:
meta["v_codec"] = video_stream.get("codec_name")
meta["width"] = video_stream.get("width")
meta["height"] = video_stream.get("height")
# Parse frame rate
frame_rate = video_stream.get("r_frame_rate") or video_stream.get("avg_frame_rate")
if isinstance(frame_rate, str) and "/" in frame_rate:
try:
num, den = frame_rate.split("/", 1)
fps = float(num) / float(den)
if fps > 0:
meta["fps"] = fps
except (ValueError, ZeroDivisionError):
pass
# Video bitrate
vb = video_stream.get("bit_rate")
if vb is not None:
try:
meta["v_bitrate"] = int(vb)
except ValueError:
pass
# Pixel format and color info
meta["pix_fmt"] = video_stream.get("pix_fmt")
meta["color_space"] = video_stream.get("color_space")
# Extract audio metadata
if audio_stream:
meta["a_codec"] = audio_stream.get("codec_name")
meta["channels"] = audio_stream.get("channels")
meta["sample_rate"] = audio_stream.get("sample_rate")
ab = audio_stream.get("bit_rate")
if ab is not None:
try:
meta["a_bitrate"] = int(ab)
except ValueError:
pass
# Add subtitle tracks
if subtitle_tracks:
meta["subtitle_tracks"] = subtitle_tracks
# Extract format metadata
if isinstance(fmt, dict):
if fmt.get("bit_rate") is not None:
try:
meta["container_bitrate"] = int(fmt.get("bit_rate"))
except ValueError:
pass
if fmt.get("duration") is not None:
try:
meta["duration"] = float(fmt.get("duration"))
except ValueError:
pass
meta["format_name"] = fmt.get("format_name")
# Extract container tags (title, encoder, etc)
ftags = fmt.get("tags", {})
if isinstance(ftags, dict):
if ftags.get("title"):
meta["container_title"] = ftags.get("title")
if ftags.get("encoder"):
meta["encoder"] = ftags.get("encoder")
return meta if meta else None
except (subprocess.SubprocessError, json.JSONDecodeError, OSError):
return None
def file_fingerprint(path: Path) -> str:
"""
Generate a content-based fingerprint that survives file renames/moves.
Uses sha256 of: file size + first 256KB + last 256KB
This allows identifying the same video even if renamed or moved.
"""
CHUNK_SIZE = 256 * 1024 # 256KB
try:
size = path.stat().st_size
except OSError:
size = 0
h = hashlib.sha256()
h.update(b"VIDFIDv1\0")
h.update(str(size).encode("ascii", errors="ignore"))
h.update(b"\0")
try:
with path.open("rb") as f:
# Read head
head = f.read(CHUNK_SIZE)
h.update(head)
# Read tail if file is large enough
if size > CHUNK_SIZE:
try:
f.seek(max(0, size - CHUNK_SIZE))
tail = f.read(CHUNK_SIZE)
h.update(tail)
except OSError:
pass
except OSError:
pass
return h.hexdigest()[:20]
def compute_library_id_from_fids(fids: List[str]) -> str:
"""
Compute a stable library ID from a list of file fingerprints.
The same set of files will always produce the same library ID,
regardless of file order.
"""
# Filter and sort for deterministic output
valid_fids = sorted(fid for fid in fids if isinstance(fid, str) and fid)
h = hashlib.sha256()
h.update(b"LIBFIDv2\0")
for fid in valid_fids:
h.update(fid.encode("ascii", errors="ignore"))
h.update(b"\n")
return h.hexdigest()[:16]
def srt_to_vtt_bytes(srt_text: str) -> bytes:
"""
Convert SRT subtitle format to WebVTT format.
Handles BOM removal and timestamp format conversion (comma -> dot).
"""
# Remove BOM if present
text = srt_text.replace("\ufeff", "")
lines = text.splitlines()
out: List[str] = ["WEBVTT", ""]
i = 0
while i < len(lines):
line = lines[i].strip("\r\n")
# Skip empty lines
if not line.strip():
out.append("")
i += 1
continue
# Skip cue index numbers (e.g., "1", "2", "3")
if re.fullmatch(r"\d+", line.strip()):
i += 1
if i >= len(lines):
break
line = lines[i].strip("\r\n")
# Process timestamp lines
if "-->" in line:
# Convert SRT timestamp format (comma) to VTT format (dot)
line = line.replace(",", ".")
out.append(line)
i += 1
# Collect subtitle text until empty line
while i < len(lines):
t = lines[i].rstrip("\r\n")
if not t.strip():
out.append("")
i += 1
break
out.append(t)
i += 1
else:
i += 1
return ("\n".join(out).strip() + "\n").encode("utf-8")
# =============================================================================
# State Management Classes
# =============================================================================
class Prefs:
"""Thread-safe preferences manager with persistent storage."""
DEFAULT_PREFS: Dict[str, Any] = {
"version": 19,
"ui_zoom": 1.0,
"split_ratio": 0.62,
"dock_ratio": 0.62,
"always_on_top": False,
"window": {"width": 1320, "height": 860, "x": None, "y": None},
"last_folder_path": None,
"last_library_id": None,
}
def __init__(self) -> None:
self.lock = threading.Lock()
self.data: Dict[str, Any] = dict(self.DEFAULT_PREFS)
self.data["window"] = dict(self.DEFAULT_PREFS["window"])
# Load saved preferences
loaded = load_json_with_fallbacks(PREFS_PATH)
if isinstance(loaded, dict):
# Handle window dict specially to merge rather than replace
win = loaded.get("window")
if isinstance(win, dict):
self.data["window"].update(win)
# Update other preferences
for k, v in loaded.items():
if k != "window":
self.data[k] = v
def get(self) -> Dict[str, Any]:
"""Get a deep copy of current preferences."""
with self.lock:
# Deep copy via JSON round-trip
return json.loads(json.dumps(self.data))
def update(self, patch: Dict[str, Any]) -> None:
"""Update preferences with a partial dict and save to disk."""
with self.lock:
# Handle window dict specially
if "window" in patch and isinstance(patch["window"], dict):
self.data.setdefault("window", {})
if not isinstance(self.data["window"], dict):
self.data["window"] = {}
self.data["window"].update(patch["window"])
patch = {k: v for k, v in patch.items() if k != "window"}
self.data.update(patch)
self.data["updated_at"] = int(time.time())
atomic_write_json(PREFS_PATH, self.data)
PREFS = Prefs()
class Library:
"""
Manages a video library with progress tracking, ordering, and metadata.
Thread-safe for concurrent access from Flask routes and background scanners.
State is persisted to disk and survives application restarts.
"""
def __init__(self) -> None:
# Library contents
self.root: Optional[Path] = None
self.files: List[Path] = []
self.fids: List[str] = [] # File fingerprints
self.relpaths: List[str] = []
self.rel_to_fid: Dict[str, str] = {}
self.fid_to_rel: Dict[str, str] = {}
# Persistent state
self.state_path: Optional[Path] = None
self.state: Dict[str, Any] = {}
self.lock = threading.Lock()
# Background duration scanner
self._scan_lock = threading.Lock()
self._scan_thread: Optional[threading.Thread] = None
self._scan_stop = False
# Metadata cache
self._meta_cache: Dict[str, Dict[str, Any]] = {} # fid -> probe meta
self._meta_lock = threading.Lock()
self._ffprobe, self._ffmpeg = find_ffprobe_ffmpeg()
@property
def ffprobe_found(self) -> bool:
"""Check if ffprobe is available for metadata extraction."""
return bool(self._ffprobe)
def _sorted_default(self, relpaths: List[str]) -> List[str]:
"""Sort relative paths using natural sort order."""
return sorted(relpaths, key=natural_key)
def _apply_saved_order(
self,
all_fids: List[str],
fid_to_rel: Dict[str, str],
order_fids: Optional[List[str]]
) -> List[str]:
"""Apply saved ordering to file IDs, handling additions and removals."""
if not order_fids or not isinstance(order_fids, list):
# Default: sort by relative path using the provided fid_to_rel mapping
rel_to_fid_local = {v: k for k, v in fid_to_rel.items()}
return [
rel_to_fid_local[rp]
for rp in self._sorted_default(list(fid_to_rel.values()))
if rp in rel_to_fid_local
]
existing = set(all_fids)
ordered: List[str] = []
used: set[str] = set()
# Add files in saved order
for fid in order_fids:
if isinstance(fid, str) and fid in existing and fid not in used:
ordered.append(fid)
used.add(fid)
# Append any new files not in saved order
remaining = [fid for fid in all_fids if fid not in used]
remaining.sort(key=lambda fid: natural_key(fid_to_rel.get(fid, fid)))
return ordered + remaining
def set_root(self, folder: str) -> Dict[str, Any]:
"""
Set a new root folder for the library.
Scans for video files, loads/creates state, and starts duration scanning.
Returns library info dict on success, raises ValueError for invalid folder.
"""
root = Path(folder).expanduser().resolve()
if not root.exists() or not root.is_dir():
raise ValueError("Invalid folder")
# Discover video files
all_files: List[Path] = [
p for p in root.rglob("*")
if p.is_file() and p.suffix.lower() in VIDEO_EXTS
]
# Build relative paths and fingerprints
relpaths: List[str] = []
fids: List[str] = []
rel_to_fid: Dict[str, str] = {}
fid_to_rel: Dict[str, str] = {}
for p in all_files:
try:
rel = str(p.relative_to(root)).replace("\\", "/")
except ValueError:
rel = p.name
fid = file_fingerprint(p)
relpaths.append(rel)
fids.append(fid)
rel_to_fid[rel] = fid
# On fingerprint collision, keep first (rare)
fid_to_rel.setdefault(fid, rel)
library_id = compute_library_id_from_fids(fids)
state_path = STATE_DIR / f"library_{library_id}.json"
loaded = load_json_with_fallbacks(state_path) or {}
# Create baseline state for new libraries
now = int(time.time())
baseline: Dict[str, Any] = {
"version": 19,
"library_id": library_id,
"last_path": str(root),
"updated_at": now,
"current_fid": fids[0] if fids else None,
"current_time": 0.0,
"volume": 1.0,
"autoplay": True, # Default ON for new folders
"playback_rate": 1.0,
"order_fids": [],
"videos": {}, # fid -> video metadata
}
# Merge with loaded state if same library
state = dict(baseline)
if isinstance(loaded, dict) and loaded.get("library_id") == library_id:
for key in baseline.keys():
if key in loaded:
state[key] = loaded[key]
for key, value in loaded.items():
if key not in state:
state[key] = value
state["last_path"] = str(root)
# Normalize video metadata
vids = state.get("videos", {})
if not isinstance(vids, dict):
vids = {}
normalized: Dict[str, Any] = {}
for fid in fids:
m = vids.get(fid) if isinstance(vids.get(fid), dict) else {}
watched = float(m.get("watched") or 0.0)
normalized[fid] = {
"pos": float(m.get("pos") if m.get("pos") is not None else watched),
"watched": watched,
"duration": m.get("duration"),
"finished": bool(m.get("finished")), # Once done, stays done
"note": str(m.get("note") or ""),
"last_open": int(m.get("last_open") or 0),
"subtitle": m.get("subtitle"), # {"vtt": "...", "label": "..."}
}
state["videos"] = normalized
# Normalize settings
try:
state["volume"] = clamp(float(state.get("volume", 1.0)), 0.0, 1.0)
except (ValueError, TypeError):
state["volume"] = 1.0
state["autoplay"] = truthy(state.get("autoplay", True))
try:
state["playback_rate"] = clamp(float(state.get("playback_rate", 1.0)), 0.25, 3.0)
except (ValueError, TypeError):
state["playback_rate"] = 1.0
# Clean up order_fids to only include existing files
if not isinstance(state.get("order_fids"), list):
state["order_fids"] = []
else:
fid_set = set(fids)
state["order_fids"] = [
fid for fid in state["order_fids"]
if isinstance(fid, str) and fid in fid_set
]
ordered_fids = self._apply_saved_order(fids, fid_to_rel, state.get("order_fids"))
# Build ordered file lists
ordered_relpaths = [
fid_to_rel.get(fid) for fid in ordered_fids
]
ordered_relpaths = [rp for rp in ordered_relpaths if isinstance(rp, str)]
ordered_files: List[Path] = []
for rp in ordered_relpaths:
p = (root / rp)
if p.exists():
ordered_files.append(p)
# current fid
cur_fid = state.get("current_fid")
if not isinstance(cur_fid, str) or cur_fid not in set(ordered_fids):
cur_fid = ordered_fids[0] if ordered_fids else None
state["current_fid"] = cur_fid
try:
state["current_time"] = max(0.0, float(state.get("current_time", 0.0)))
except Exception:
state["current_time"] = 0.0
with self.lock:
self.root = root
self.files = ordered_files
self.fids = ordered_fids
self.relpaths = ordered_relpaths
self.rel_to_fid = rel_to_fid
self.fid_to_rel = {fid: fid_to_rel.get(fid, fid) for fid in ordered_fids}
self.state_path = state_path
self.state = state
self.save_state()
self.start_duration_scan()
PREFS.update({"last_folder_path": str(root), "last_library_id": library_id})
push_recent(str(root))
return self.get_library_info()
def save_state(self) -> None:
with self.lock:
if not self.state_path: return
self.state["updated_at"] = int(time.time())
atomic_write_json(self.state_path, self.state)
def _folder_stats(self, fids: List[str], state: Dict[str, Any]) -> Dict[str, Any]:
vids = state.get("videos", {})
if not isinstance(vids, dict): vids = {}
finished = 0
remaining = 0
total_dur = 0.0
total_watch = 0.0
known = 0
# simple "top folders" based on current relpaths (still useful)
top_counts: Dict[str, int] = {}
fid_to_rel = self.fid_to_rel or {}
for fid in fids:
rp = fid_to_rel.get(fid, "")
parts = rp.split("/")
top = parts[0] if len(parts) > 1 else "(root)"
top_counts[top] = top_counts.get(top, 0) + 1
m = vids.get(fid) if isinstance(vids.get(fid), dict) else {}
d = m.get("duration", None)
w = float(m.get("watched") or 0.0)
fin = bool(m.get("finished") or False)
finished += 1 if fin else 0
remaining += 0 if fin else 1
if isinstance(d, (int, float)) and d and d > 0:
known += 1
total_dur += float(d)
total_watch += min(w, float(d))
remaining_sec = 0.0
for fid in fids:
m = vids.get(fid) if isinstance(vids.get(fid), dict) else {}
d = m.get("duration", None)
if isinstance(d, (int, float)) and d and d > 0:
w = float(m.get("watched") or 0.0)
remaining_sec += max(0.0, float(d) - min(w, float(d)))
top_list = sorted(top_counts.items(), key=lambda kv: (-kv[1], natural_key(kv[0])))
return {
"finished_count": finished,
"remaining_count": remaining,
"remaining_seconds_known": remaining_sec if known > 0 else None,
"top_folders": top_list[:12],
"durations_known": known,
"overall_progress": (total_watch / total_dur) if total_dur > 0 else None,
}
def _tree_flags(self, rels: List[str]) -> Dict[str, Dict[str, Any]]:
"""
Calculate tree display flags for each file.
Limits depth to 1 level (immediate parent folder only) to avoid
overly deep nesting in the UI.
"""
# First, normalize paths to only consider immediate parent folder
normalized: List[str] = []
for rp in rels:
parts = rp.split("/")
if len(parts) >= 2:
# Use only immediate parent + filename
normalized.append(parts[-2] + "/" + parts[-1])
else:
# File in root
normalized.append(parts[-1])
last_index: Dict[str, int] = {}
first_index: Dict[str, int] = {}
for i, rp in enumerate(normalized):
parts = rp.split("/")
last_index[""] = i
first_index.setdefault("", i)
for d in range(1, len(parts)):
folder = "/".join(parts[:d])
last_index[folder] = i
if folder not in first_index:
first_index[folder] = i
out: Dict[str, Dict[str, Any]] = {}
for i, rp in enumerate(rels):
norm_rp = normalized[i]
parts = norm_rp.split("/")
depth = len(parts) - 1 # Max 1 now
parent = norm_rp.rsplit("/", 1)[0] if "/" in norm_rp else ""
is_last = (last_index.get(parent, i) == i)
has_prev_in_parent = i > first_index.get(parent, i)
pipes: List[bool] = []
for j in range(depth):
folder = "/".join(parts[:j+1])
pipes.append(last_index.get(folder, i) > i)
out[rp] = {
"depth": depth,
"pipes": pipes,
"is_last": is_last,
"has_prev_in_parent": has_prev_in_parent
}
return out
def _basic_file_meta(self, fid: str) -> Dict[str, Any]:
with self.lock:
root = self.root
fid_to_rel = dict(self.fid_to_rel)
if not root: return {}
rp = fid_to_rel.get(fid)
if not rp: return {}
try:
p = (root / rp).resolve()
if not is_within_root(root, p) or not p.exists(): return {}
st = p.stat()
ext = p.suffix.lower()
folder = str(Path(rp).parent).replace("\\", "/")
if folder == ".": folder = "(root)"
return {"ext": ext[1:] if ext.startswith(".") else ext, "size": st.st_size, "mtime": int(st.st_mtime), "folder": folder}
except Exception:
return {}
def _get_cached_meta(self, fid: str) -> Dict[str, Any]:
with self._meta_lock:
return dict(self._meta_cache.get(fid, {}))
def _set_cached_meta(self, fid: str, meta: Dict[str, Any]) -> None:
with self._meta_lock:
self._meta_cache[fid] = dict(meta)
def _auto_subtitle_sidecar(self, video_path: Path) -> Optional[Path]:
"""Find subtitle file with same name as video (case-insensitive, flexible matching).
Handles patterns like:
- video.srt / video.vtt (exact match)
- video.en.srt / video.en.vtt (with language code)
- video.eng.srt / video.english.srt (other language patterns)
- Flexible matching: ignores dashes, underscores, extra spaces
"""
import re
def normalize(s: str) -> str:
"""Normalize string for flexible comparison."""
s = s.lower()
# Remove common separators and normalize spaces
s = re.sub(r'[-_]+', ' ', s)
# Collapse multiple spaces
s = re.sub(r'\s+', ' ', s)
return s.strip()
base_stem = video_path.stem
base_norm = normalize(base_stem)
parent = video_path.parent
# Check all files in the same directory
try:
candidates = []
for f in parent.iterdir():
if not f.is_file():
continue
ext = f.suffix.lower()
if ext not in [".vtt", ".srt"]:
continue
f_stem = f.stem
f_norm = normalize(f_stem)
# Exact match (video.srt) - case insensitive
if f_stem.lower() == base_stem.lower():
candidates.append((0, f)) # Priority 0 = exact match
continue
# Normalized exact match
if f_norm == base_norm:
candidates.append((1, f)) # Priority 1 = normalized exact
continue
# Check if it starts with the video name and has a language suffix
# e.g., "video.en" matches "video", "video.eng" matches "video"
if f_stem.lower().startswith(base_stem.lower() + "."):
lang_part = f_stem[len(base_stem) + 1:].lower()
if lang_part in ["en", "eng", "english"]:
candidates.append((2, f)) # Priority 2 = English with exact base
else:
candidates.append((4, f)) # Priority 4 = other lang with exact base
continue
# Normalized match with language suffix
# Remove potential language code from subtitle stem for comparison
f_stem_no_lang = re.sub(r'\.(en|eng|english|fr|de|es|it|pt|ru|ja|ko|zh)$', '', f_stem, flags=re.IGNORECASE)
f_norm_no_lang = normalize(f_stem_no_lang)
if f_norm_no_lang == base_norm:
# Check if it had an English language code
lang_match = re.search(r'\.(en|eng|english)$', f_stem, flags=re.IGNORECASE)
if lang_match:
candidates.append((3, f)) # Priority 3 = English with normalized base
else:
candidates.append((5, f)) # Priority 5 = other/no lang with normalized base
# Sort by priority and return best match
if candidates:
candidates.sort(key=lambda x: x[0])
return candidates[0][1]
except Exception:
pass
return None
def _store_subtitle_for_fid(self, fid: str, src_path: Path) -> Optional[Dict[str, Any]]:
try:
ext = src_path.suffix.lower()
if ext not in SUB_EXTS:
return None
# create stable file name based on fid + subtitle source basename
name_part = re.sub(r"[^a-zA-Z0-9._-]+", "_", src_path.stem)[:60] or "subtitle"
out_name = f"{fid}_{name_part}.vtt"
out_path = (SUBS_DIR / out_name).resolve()
if ext == ".vtt":
out_path.write_bytes(src_path.read_bytes())
else:
data = src_path.read_text(encoding="utf-8", errors="replace")
out_path.write_bytes(srt_to_vtt_bytes(data))
rel = f"subtitles/{out_name}"
return {"vtt": rel, "label": src_path.name}
except Exception:
return None
def get_subtitle_for_current(self) -> Dict[str, Any]:
with self.lock:
root = self.root
state = dict(self.state)
cur = state.get("current_fid")
vids = state.get("videos", {})
files = list(self.files) if self.files else []
fids = list(self.fids) if self.fids else []
if not root or not cur or not isinstance(vids, dict):
return {"ok": True, "has": False}
# Check for already-stored subtitle
m = vids.get(cur)
if isinstance(m, dict) and isinstance(m.get("subtitle"), dict):
sub = m["subtitle"]
vtt = sub.get("vtt")
if isinstance(vtt, str):
p = (STATE_DIR / vtt).resolve()
if p.exists():
return {"ok": True, "has": True, "url": f"/sub/{state.get('library_id')}/{cur}", "label": sub.get("label", "Subtitles")}
# Get video path from files/fids
vp = None
try:
if cur in fids:
idx = fids.index(cur)
if idx < len(files):
vp = files[idx]
except (ValueError, IndexError):
pass
if not vp or not vp.exists():
return {"ok": True, "has": False}
# 1. First try: sidecar subtitle file (case-insensitive)
sc = self._auto_subtitle_sidecar(vp)
if sc and sc.exists():
# store it so it becomes persistent and portable (stored next to script)
stored = self._store_subtitle_for_fid(cur, sc)
if stored:
with self.lock:
mm = self.state["videos"].get(cur, {})
if not isinstance(mm, dict):
mm = {}
mm["subtitle"] = stored
self.state["videos"][cur] = mm
self.save_state()
return {"ok": True, "has": True, "url": f"/sub/{state.get('library_id')}/{cur}", "label": stored.get("label", "Subtitles")}
# 2. Second try: embedded subtitles (prefer English)
if self._ffprobe and self._ffmpeg:
try:
meta = ffprobe_video_metadata(vp, self._ffprobe)
if meta and "subtitle_tracks" in meta:
tracks = meta["subtitle_tracks"]
if tracks:
# Try to find English track first
english_track = None
first_track = None
for t in tracks:
if first_track is None:
first_track = t
lang = (t.get("language") or "").lower()
title = (t.get("title") or "").lower()
if "eng" in lang or "english" in title or lang == "en":
english_track = t
break
# Use English if found, otherwise first track
chosen = english_track or first_track
if chosen:
track_idx = chosen.get("index")
if track_idx is not None:
# Extract the subtitle
result = self.extract_embedded_subtitle(track_idx)
if result.get("ok"):
return {"ok": True, "has": True, "url": result.get("url"), "label": result.get("label", "Subtitles")}
except Exception:
pass
return {"ok": True, "has": False}
def set_subtitle_for_current(self, file_path: str) -> Dict[str, Any]:
with self.lock:
root = self.root
cur = self.state.get("current_fid")
if not root or not cur:
return {"ok": False, "error": "No video loaded"}
p = Path(file_path).expanduser()
if not p.exists() or not p.is_file():
return {"ok": False, "error": "Subtitle file not found"}
stored = self._store_subtitle_for_fid(cur, p)
if not stored:
return {"ok": False, "error": "Unsupported subtitle type (use .srt or .vtt)"}
with self.lock:
mm = self.state["videos"].get(cur, {})
if not isinstance(mm, dict):
mm = {"pos": 0.0, "watched": 0.0, "duration": None, "finished": False, "note": "", "last_open": 0, "subtitle": None}
mm["subtitle"] = stored
self.state["videos"][cur] = mm
self.save_state()
return {"ok": True, "url": f"/sub/{self.state.get('library_id')}/{cur}", "label": stored.get("label", "Subtitles")}
def get_embedded_subtitles(self) -> Dict[str, Any]:
"""Get list of embedded subtitle tracks for current video."""
with self.lock:
root = self.root
files = list(self.files) if self.files else []
fids = list(self.fids) if self.fids else []
state = dict(self.state)
if not root or not files or not fids:
return {"ok": False, "tracks": []}
cur = state.get("current_fid")
if not isinstance(cur, str) or cur not in set(fids):
return {"ok": False, "tracks": []}
try:
idx = fids.index(cur)
video_path = files[idx]
except (ValueError, IndexError):
return {"ok": False, "tracks": []}
# Use ffprobe to get subtitle tracks
meta = ffprobe_video_metadata(video_path, self._ffprobe)
if not meta or "subtitle_tracks" not in meta:
return {"ok": True, "tracks": []}
return {"ok": True, "tracks": meta["subtitle_tracks"]}
def extract_embedded_subtitle(self, track_index: int) -> Dict[str, Any]:
"""Extract an embedded subtitle track using ffmpeg."""
with self.lock:
root = self.root
files = list(self.files) if self.files else []
fids = list(self.fids) if self.fids else []
cur = self.state.get("current_fid")
library_id = self.state.get("library_id")
if not root or not files or not fids or not cur:
return {"ok": False, "error": "No video loaded"}
if not self._ffmpeg:
return {"ok": False, "error": "ffmpeg not found"}
try:
idx = fids.index(cur)
video_path = files[idx]
except (ValueError, IndexError):
return {"ok": False, "error": "Video not found"}
# Extract subtitle to VTT format
SUBS_DIR.mkdir(parents=True, exist_ok=True)
out_name = f"{cur}_embedded_{track_index}.vtt"
out_path = SUBS_DIR / out_name
try:
cmd = [
self._ffmpeg, "-y", "-i", str(video_path),
"-map", f"0:{track_index}",
"-c:s", "webvtt",
str(out_path)
]
subprocess.run(
cmd,
capture_output=True,
timeout=60,
**_subprocess_no_window_kwargs()
)
if not out_path.exists():
return {"ok": False, "error": "Failed to extract subtitle"}
# Get track label
meta = ffprobe_video_metadata(video_path, self._ffprobe)
label = "Embedded"
if meta and "subtitle_tracks" in meta:
for t in meta["subtitle_tracks"]:
if t.get("index") == track_index:
lang = t.get("language", "")
title = t.get("title", "")
if title:
label = title
elif lang:
label = lang.upper()
break
# Store in state
stored = {"vtt": f"subtitles/{out_name}", "label": label}
with self.lock:
mm = self.state["videos"].get(cur, {})
if not isinstance(mm, dict):
mm = {}
mm["subtitle"] = stored
self.state["videos"][cur] = mm
self.save_state()
return {"ok": True, "url": f"/sub/{library_id}/{cur}", "label": label}
except Exception as e:
return {"ok": False, "error": str(e)}
def get_available_subtitles(self) -> Dict[str, Any]:
"""Get all available subtitle options (sidecar files + embedded tracks)."""
import re
with self.lock:
root = self.root
files = list(self.files) if self.files else []
fids = list(self.fids) if self.fids else []
state = dict(self.state)
cur = state.get("current_fid")
if not root or not files or not fids or not cur:
return {"ok": False, "sidecar": [], "embedded": []}
try:
idx = fids.index(cur)
video_path = files[idx]
except (ValueError, IndexError):
return {"ok": False, "sidecar": [], "embedded": []}
sidecar_subs = []
embedded_subs = []
# Find all sidecar subtitle files
def normalize(s: str) -> str:
s = s.lower()
s = re.sub(r'[-_]+', ' ', s)
s = re.sub(r'\s+', ' ', s)
return s.strip()
base_stem = video_path.stem
base_norm = normalize(base_stem)
parent = video_path.parent
seen_labels = set() # To dedupe vtt/srt with same name
try:
for f in parent.iterdir():
if not f.is_file():
continue
ext = f.suffix.lower()
if ext not in [".vtt", ".srt"]:
continue
f_stem = f.stem
f_norm = normalize(f_stem)
# Check if this subtitle matches the video
is_match = False
# Exact match
if f_stem.lower() == base_stem.lower():
is_match = True
# Normalized match
elif f_norm == base_norm:
is_match = True
# With language suffix (exact base)
elif f_stem.lower().startswith(base_stem.lower() + "."):
is_match = True
# With language suffix (normalized base)
else:
f_stem_no_lang = re.sub(r'\.(en|eng|english|fr|fra|french|de|deu|german|es|spa|spanish|it|ita|italian|pt|por|portuguese|ru|rus|russian|ja|jpn|japanese|ko|kor|korean|zh|chi|chinese|nl|nld|dutch|pl|pol|polish|sv|swe|swedish|ar|ara|arabic)$', '', f_stem, flags=re.IGNORECASE)
if normalize(f_stem_no_lang) == base_norm:
is_match = True
if is_match:
# Extract language from filename
lang_match = re.search(r'\.([a-z]{2,3})$', f_stem, flags=re.IGNORECASE)
if lang_match:
lang = lang_match.group(1).upper()
# Map common codes to full names
lang_map = {"EN": "English", "ENG": "English", "FR": "French", "FRA": "French",
"DE": "German", "DEU": "German", "ES": "Spanish", "SPA": "Spanish",
"IT": "Italian", "ITA": "Italian", "PT": "Portuguese", "POR": "Portuguese",
"RU": "Russian", "RUS": "Russian", "JA": "Japanese", "JPN": "Japanese",
"KO": "Korean", "KOR": "Korean", "ZH": "Chinese", "CHI": "Chinese",
"NL": "Dutch", "NLD": "Dutch", "PL": "Polish", "POL": "Polish",
"SV": "Swedish", "SWE": "Swedish", "AR": "Arabic", "ARA": "Arabic"}
label = lang_map.get(lang, lang)
else:
label = "Subtitles"
# Dedupe: if we already have this label, skip (prefer vtt over srt)
dedupe_key = label.lower()
if dedupe_key in seen_labels:
continue
seen_labels.add(dedupe_key)
sidecar_subs.append({
"path": str(f),
"label": label,
"format": ext[1:].upper()
})
except Exception:
pass
# Sort sidecar subs: English first, then alphabetically
def sub_sort_key(s):
label = s.get("label", "").lower()
if "english" in label or label == "en":
return (0, label)
return (1, label)
sidecar_subs.sort(key=sub_sort_key)
# Get embedded subtitles
if self._ffprobe:
try:
meta = ffprobe_video_metadata(video_path, self._ffprobe)
if meta and "subtitle_tracks" in meta:
for track in meta["subtitle_tracks"]:
lang = track.get("language", "")
title = track.get("title", "")
if title:
label = title
elif lang:
label = lang.upper()
else:
label = f"Track {track.get('index', '?')}"
embedded_subs.append({
"index": track.get("index"),
"label": label,
"codec": track.get("codec", ""),
"language": lang
})
except Exception:
pass
return {"ok": True, "sidecar": sidecar_subs, "embedded": embedded_subs}
def load_sidecar_subtitle(self, file_path: str) -> Dict[str, Any]:
"""Load a specific sidecar subtitle file."""
with self.lock:
cur = self.state.get("current_fid")
library_id = self.state.get("library_id")
if not cur:
return {"ok": False, "error": "No video loaded"}
p = Path(file_path)
if not p.exists() or not p.is_file():
return {"ok": False, "error": "Subtitle file not found"}
stored = self._store_subtitle_for_fid(cur, p)
if not stored:
return {"ok": False, "error": "Unsupported subtitle type"}
with self.lock:
mm = self.state["videos"].get(cur, {})
if not isinstance(mm, dict):
mm = {}
mm["subtitle"] = stored
self.state["videos"][cur] = mm
self.save_state()
return {"ok": True, "url": f"/sub/{library_id}/{cur}", "label": stored.get("label", "Subtitles")}
def reset_watch_progress(self) -> Dict[str, Any]:
# Reset only progress fields; keep notes, duration, subtitles, volume, rate, etc.
with self.lock:
vids = self.state.get("videos", {})
if not isinstance(vids, dict): return {"ok": False, "error": "No library"}
for fid, m in list(vids.items()):
if not isinstance(m, dict): continue
m["pos"] = 0.0
m["watched"] = 0.0
m["finished"] = False
vids[fid] = m
self.state["videos"] = vids
self.state["current_time"] = 0.0
self.save_state()
return {"ok": True}
def get_current_video_metadata(self) -> Dict[str, Any]:
with self.lock:
root = self.root
files = list(self.files) if self.files else []
fids = list(self.fids) if self.fids else []
state = dict(self.state)
if not root or not files or not fids:
return {"ok": False, "error": "No library", "ffprobe_found": self.ffprobe_found}
cur = state.get("current_fid")
if not isinstance(cur, str) or cur not in set(fids):
cur = fids[0]
idx = fids.index(cur)
p = files[idx]
basic = self._basic_file_meta(cur)
cached = self._get_cached_meta(cur)
out = {"ok": True, "fid": cur, "basic": basic, "probe": cached or None, "ffprobe_found": self.ffprobe_found}
if cached:
return out
probe = ffprobe_video_metadata(p, self._ffprobe)
if probe:
self._set_cached_meta(cur, probe)
out["probe"] = probe
else:
out["probe"] = None
return out
def get_library_info(self) -> Dict[str, Any]:
with self.lock:
root = self.root
files = list(self.files)
fids = list(self.fids)
rels = list(self.relpaths)
state = dict(self.state)
if not root:
return {"ok": False, "error": "No folder selected"}
has_subdirs = any("/" in rp for rp in rels)
tree = self._tree_flags(rels) if has_subdirs else {}
vids = state.get("videos", {})
if not isinstance(vids, dict): vids = {}
items = []
for i, (fid, f) in enumerate(zip(fids, files)):
rel = rels[i] if i < len(rels) else str(f.relative_to(root)).replace("\\", "/")
meta = vids.get(fid, {"pos": 0.0, "watched": 0.0, "duration": None, "finished": False, "note": "", "last_open": 0, "subtitle": None})
nice = pretty_title_from_filename(f.name)
tf = tree.get(rel, {"depth": 0, "pipes": [], "is_last": False, "has_prev_in_parent": False})
items.append({
"index": i,
"fid": fid,
"name": f.name,
"title": nice,
"relpath": rel,
"depth": int(tf.get("depth", 0)),
"pipes": tf.get("pipes", []),
"is_last": bool(tf.get("is_last", False)),
"has_prev_in_parent": bool(tf.get("has_prev_in_parent", False)),
"pos": float(meta.get("pos") or 0.0),
"watched": float(meta.get("watched") or 0.0),
"duration": meta.get("duration"),
"finished": bool(meta.get("finished") or False),
"note_len": len(str(meta.get("note") or "")),
"last_open": int(meta.get("last_open") or 0),
"has_sub": isinstance(meta.get("subtitle"), dict),
})
stats = self._folder_stats(fids, state)
cur_fid = state.get("current_fid")
cur_idx = fids.index(cur_fid) if isinstance(cur_fid, str) and cur_fid in fids else 0
next_up = None
for j in range(cur_idx + 1, len(items)):
if not items[j]["finished"]:
next_up = items[j]; break
return {
"ok": True,
"folder": str(root),
"library_id": state.get("library_id"),
"count": len(files),
"current_index": cur_idx,
"current_fid": fids[cur_idx] if fids else None,
"current_time": float(state.get("current_time", 0.0)),
"folder_volume": float(state.get("volume", 1.0)),
"folder_autoplay": bool(state.get("autoplay", True)),
"folder_rate": float(state.get("playback_rate", 1.0)),
"items": items,
"has_subdirs": has_subdirs,
"overall_progress": stats["overall_progress"],
"durations_known": stats["durations_known"],
"finished_count": stats["finished_count"],
"remaining_count": stats["remaining_count"],
"remaining_seconds_known": stats["remaining_seconds_known"],
"top_folders": stats["top_folders"],
"next_up": {"index": next_up["index"], "title": next_up["title"]} if next_up else None,
}
def update_progress(self, index: int, current_time: float, duration: Optional[float], playing: bool) -> Dict[str, Any]:
now = int(time.time())
with self.lock:
if not self.root or not self.files or not self.fids:
return {"ok": False, "error": "No library"}
index = max(0, min(int(index), len(self.files) - 1))
fid = self.fids[index]
current_time = max(0.0, float(current_time or 0.0))
if duration is not None:
try: duration = float(duration)
except Exception: duration = None
self.state["current_fid"] = fid
self.state["current_time"] = current_time
meta = self.state["videos"].get(fid, {"pos": 0.0, "watched": 0.0, "duration": None, "finished": False, "note": "", "last_open": 0, "subtitle": None})
meta["pos"] = current_time
meta["watched"] = max(float(meta.get("watched") or 0.0), current_time)
if isinstance(duration, (int, float)) and duration and duration > 0:
meta["duration"] = duration
# once finished, always finished (do NOT flip it back)
d2 = meta.get("duration")
if isinstance(d2, (int, float)) and d2 and d2 > 0:
meta["finished"] = bool(meta.get("finished") or (current_time >= max(0.0, float(d2) - 2.0)))
if playing:
meta["last_open"] = now
self.state["videos"][fid] = meta
self.save_state()
return {"ok": True}
def set_current(self, index: int, timecode: float = 0.0) -> Dict[str, Any]:
with self.lock:
if not self.root or not self.files or not self.fids:
return {"ok": False, "error": "No library"}
index = max(0, min(int(index), len(self.files) - 1))
self.state["current_fid"] = self.fids[index]
self.state["current_time"] = max(0.0, float(timecode or 0.0))
self.save_state()
return {"ok": True}
def set_folder_volume(self, volume: float) -> Dict[str, Any]:
with self.lock:
if not self.root: return {"ok": False, "error": "No library"}
try: v = float(volume)
except Exception: v = 1.0
self.state["volume"] = clamp(v, 0.0, 1.0)
self.save_state()
return {"ok": True}
def set_folder_autoplay(self, enabled: bool) -> Dict[str, Any]:
with self.lock:
if not self.root: return {"ok": False, "error": "No library"}
self.state["autoplay"] = bool(enabled)
self.save_state()
return {"ok": True}
def set_folder_rate(self, rate: float) -> Dict[str, Any]:
with self.lock:
if not self.root: return {"ok": False, "error": "No library"}
try: r = float(rate)
except Exception: r = 1.0
self.state["playback_rate"] = clamp(r, 0.25, 3.0)
self.save_state()
return {"ok": True}
def set_order(self, fids: List[str]) -> Dict[str, Any]:
with self.lock:
if not self.root or not self.files or not self.fids:
return {"ok": False, "error": "No library"}
if not isinstance(fids, list) or not all(isinstance(x, str) for x in fids):
return {"ok": False, "error": "order must be a list of fids"}
existing = set(self.fids)
seen, cleaned = set(), []
for fid in fids:
if fid in existing and fid not in seen:
cleaned.append(fid); seen.add(fid)
# append remaining
remaining = [fid for fid in self.fids if fid not in seen]
remaining.sort(key=lambda fid: natural_key(self.fid_to_rel.get(fid, fid)))
new_fids = cleaned + remaining
# rebuild lists
fid_to_rel = dict(self.fid_to_rel)
rel_to_file = {str(p.relative_to(self.root)).replace("\\", "/"): p for p in self.root.rglob("*") if p.is_file() and p.suffix.lower() in VIDEO_EXTS}
new_rels = [fid_to_rel.get(fid) for fid in new_fids]
new_rels = [rp for rp in new_rels if isinstance(rp, str)]
new_files = [rel_to_file[rp] for rp in new_rels if rp in rel_to_file]
cur = self.state.get("current_fid")
if not isinstance(cur, str) or cur not in set(new_fids):
cur = new_fids[0] if new_fids else None
self.fids = new_fids
self.relpaths = new_rels
self.files = new_files
self.state["order_fids"] = cleaned
self.state["current_fid"] = cur
self.save_state()
return {"ok": True}
def get_video_path(self, index: int) -> Path:
with self.lock:
if not self.root or not self.files:
raise FileNotFoundError("No library")
index = max(0, min(int(index), len(self.files) - 1))
p, root = self.files[index], self.root
if not is_within_root(root, p):
raise PermissionError("Invalid path")
return p
def get_note(self, fid: str) -> str:
with self.lock:
vids = self.state.get("videos", {})
if not isinstance(vids, dict): return ""
meta = vids.get(fid)
if not isinstance(meta, dict): return ""
return str(meta.get("note") or "")
def set_note(self, fid: str, note: str) -> Dict[str, Any]:
note = str(note or "")
with self.lock:
if not self.root: return {"ok": False, "error": "No library"}
vids = self.state.get("videos", {})
if not isinstance(vids, dict):
vids = {}; self.state["videos"] = vids
meta = vids.get(fid)
if not isinstance(meta, dict):
meta = {"pos": 0.0, "watched": 0.0, "duration": None, "finished": False, "note": "", "last_open": 0, "subtitle": None}
meta["note"] = note
vids[fid] = meta
self.save_state()
return {"ok": True, "len": len(note)}
def start_duration_scan(self) -> None:
with self._scan_lock:
if self._scan_thread and self._scan_thread.is_alive():
return
self._scan_stop = False
t = threading.Thread(target=self._duration_scan_worker, daemon=True)
self._scan_thread = t
t.start()
def _duration_scan_worker(self) -> None:
ffprobe, ffmpeg = self._ffprobe, self._ffmpeg
if not ffprobe and not ffmpeg:
return
with self.lock:
if not self.root or not self.files or not self.fids:
return
root = self.root
files = list(self.files)
fids = list(self.fids)
for fid, f in zip(fids, files):
if self._scan_stop:
return
with self.lock:
if not self.root or str(self.root) != str(root):
return
meta = self.state["videos"].get(fid)
if not isinstance(meta, dict):
continue
d = meta.get("duration")
if isinstance(d, (int, float)) and d and d > 0:
continue
dur = duration_seconds(f, ffprobe, ffmpeg)
if dur and dur > 0:
with self.lock:
if not self.root or str(self.root) != str(root):
return
m = self.state["videos"].get(fid, {"pos": 0.0, "watched": 0.0, "duration": None, "finished": False, "note": "", "last_open": 0, "subtitle": None})
m["duration"] = float(dur)
# do NOT unset finished if it was already true
try:
m["finished"] = bool(m.get("finished") or (float(m.get("watched") or 0.0) >= max(0.0, float(dur) - 2.0)))
except Exception:
pass
self.state["videos"][fid] = m
self.save_state()
LIB = Library()
def _send_file_range(path: Path) -> Response:
file_size = path.stat().st_size
range_header = request.headers.get("Range", None)
if not range_header:
def gen():
with path.open("rb") as f:
while True:
chunk = f.read(1024 * 512)
if not chunk: break
yield chunk
resp = Response(gen(), status=200, mimetype="application/octet-stream")
resp.headers["Content-Length"] = str(file_size)
resp.headers["Accept-Ranges"] = "bytes"
resp.headers["Cache-Control"] = "no-store"
return resp
try:
units, rng = range_header.split("=", 1)
if units.strip() != "bytes": raise ValueError
start_s, end_s = rng.split("-", 1)
start = int(start_s) if start_s else 0
end = int(end_s) if end_s else (file_size - 1)
if start < 0 or end < start: raise ValueError
end = min(end, file_size - 1)
except Exception:
return abort(416)
length = end - start + 1
def gen():
with path.open("rb") as f:
f.seek(start)
remaining = length
while remaining > 0:
chunk = f.read(min(1024 * 512, remaining))
if not chunk: break
remaining -= len(chunk)
yield chunk
resp = Response(gen(), status=206, mimetype="application/octet-stream")
resp.headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
resp.headers["Content-Length"] = str(length)
resp.headers["Accept-Ranges"] = "bytes"
resp.headers["Cache-Control"] = "no-store"
return resp
@app.get("/")
def index():
return Response(HTML, mimetype="text/html")
@app.get("/fonts.css")
def fonts_css():
if not FONTS_CSS_PATH.exists():
return Response("", mimetype="text/css")
return Response(FONTS_CSS_PATH.read_text(encoding="utf-8", errors="replace"), mimetype="text/css")
@app.get("/fonts/<path:filename>")
def fonts_file(filename: str):
path = (FONTS_DIR / filename).resolve()
if not path.exists() or not str(path).startswith(str(FONTS_DIR.resolve())):
abort(404)
mime, _ = mimetypes.guess_type(str(path))
return send_file(str(path), mimetype=mime or "application/octet-stream", conditional=True, etag=True, max_age=0)
@app.get("/fa.css")
def fa_css():
if not FA_CSS_PATH.exists():
return Response("", mimetype="text/css")
return Response(FA_CSS_PATH.read_text(encoding="utf-8", errors="replace"), mimetype="text/css")
@app.get("/fa/webfonts/<path:filename>")
def fa_webfont(filename: str):
path = (FA_WEBFONTS_DIR / filename).resolve()
if not path.exists() or not str(path).startswith(str(FA_WEBFONTS_DIR.resolve())):
abort(404)
mime, _ = mimetypes.guess_type(str(path))
return send_file(str(path), mimetype=mime or "application/octet-stream", conditional=True, etag=True, max_age=0)
@app.get("/video/<int:index>")
def video(index: int):
try:
p = LIB.get_video_path(index)
except Exception:
return abort(404)
ext = p.suffix.lower()
mime_map = {
".mp4": "video/mp4",
".m4v": "video/mp4",
".webm": "video/webm",
".ogv": "video/ogg",
".mov": "video/quicktime",
".mkv": "video/x-matroska",
".avi": "video/x-msvideo",
".mpeg": "video/mpeg",
".mpg": "video/mpeg",
".m2ts": "video/mp2t",
".mts": "video/mp2t",
}
resp = _send_file_range(p)
resp.mimetype = mime_map.get(ext, "application/octet-stream")
return resp
@app.get("/sub/<libid>/<fid>")
def subtitle(libid: str, fid: str):
# libid is informational; fid is the actual key we store subtitles under
with LIB.lock:
state = dict(LIB.state)
vids = state.get("videos", {})
if not isinstance(vids, dict):
abort(404)
m = vids.get(fid)
if not isinstance(m, dict):
abort(404)
sub = m.get("subtitle")
if not isinstance(sub, dict):
abort(404)
rel = sub.get("vtt")
if not isinstance(rel, str):
abort(404)
p = (STATE_DIR / rel).resolve()
if not p.exists() or not str(p).startswith(str(SUBS_DIR.resolve().parent)):
abort(404)
return send_file(str(p), mimetype="text/vtt", conditional=True, etag=True, max_age=0)
def run_flask(port: int):
import logging
logging.getLogger("werkzeug").setLevel(logging.ERROR)
app.run(host=HOST, port=port, debug=False, use_reloader=False, threaded=True)
class API:
def select_folder(self) -> Dict[str, Any]:
win = webview.windows[0]
res = win.create_file_dialog(webview.FOLDER_DIALOG if not hasattr(webview, 'FileDialog') else webview.FileDialog.FOLDER, allow_multiple=False)
if not res: return {"ok": False, "cancelled": True}
folder = res[0]
try: return LIB.set_root(folder)
except Exception as e: return {"ok": False, "error": str(e)}
def open_folder_path(self, folder: str) -> Dict[str, Any]:
try: return LIB.set_root(folder)
except Exception as e: return {"ok": False, "error": str(e)}
def get_recents(self) -> Dict[str, Any]:
items = load_recents()
valid: List[str] = []
for p in items:
try:
if Path(p).expanduser().exists() and Path(p).expanduser().is_dir():
valid.append(p)
except Exception:
pass
# if anything invalid was removed, persist the cleaned list
if valid != items:
save_recents(valid)
out = [{"name": folder_display_name(p), "path": p} for p in valid]
return {"ok": True, "items": out}
def remove_recent(self, path: str) -> Dict[str, Any]:
"""Remove a specific folder from the recent folders list."""
items = load_recents()
if path in items:
items.remove(path)
save_recents(items)
return {"ok": True}
def get_library(self) -> Dict[str, Any]:
return LIB.get_library_info()
def set_current(self, index: int, timecode: float = 0.0) -> Dict[str, Any]:
return LIB.set_current(index, timecode)
def tick_progress(self, index: int, current_time: float, duration: Optional[float], playing: bool) -> Dict[str, Any]:
return LIB.update_progress(index, current_time, duration, playing)
def set_folder_volume(self, volume: float) -> Dict[str, Any]:
return LIB.set_folder_volume(volume)
def set_folder_autoplay(self, enabled: bool) -> Dict[str, Any]:
return LIB.set_folder_autoplay(bool(enabled))
def set_folder_rate(self, rate: float) -> Dict[str, Any]:
return LIB.set_folder_rate(float(rate))
def set_order(self, fids: List[str]) -> Dict[str, Any]:
return LIB.set_order(fids)
def start_duration_scan(self) -> Dict[str, Any]:
LIB.start_duration_scan()
return {"ok": True}
def get_prefs(self) -> Dict[str, Any]:
return {"ok": True, "prefs": PREFS.get()}
def set_prefs(self, patch: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(patch, dict): return {"ok": False, "error": "patch must be an object"}
if "ui_zoom" in patch:
try: patch["ui_zoom"] = float(patch["ui_zoom"])
except Exception: patch.pop("ui_zoom", None)
if "split_ratio" in patch:
try: patch["split_ratio"] = float(patch["split_ratio"])
except Exception: patch.pop("split_ratio", None)
if "dock_ratio" in patch:
try: patch["dock_ratio"] = float(patch["dock_ratio"])
except Exception: patch.pop("dock_ratio", None)
if "always_on_top" in patch:
patch["always_on_top"] = bool(patch["always_on_top"])
PREFS.update(patch)
return {"ok": True}
def set_always_on_top(self, enabled: bool) -> Dict[str, Any]:
enabled = bool(enabled)
PREFS.update({"always_on_top": enabled})
try:
win = webview.windows[0]
if hasattr(win, "on_top"):
setattr(win, "on_top", enabled)
except Exception:
pass
return {"ok": True}
def save_window_state(self) -> Dict[str, Any]:
try:
win = webview.windows[0]
w = getattr(win, "width", None)
h = getattr(win, "height", None)
x = getattr(win, "x", None)
y = getattr(win, "y", None)
patch: Dict[str, Any] = {"window": {}}
if isinstance(w, int) and w > 100: patch["window"]["width"] = w
if isinstance(h, int) and h > 100: patch["window"]["height"] = h
if isinstance(x, int): patch["window"]["x"] = x
if isinstance(y, int): patch["window"]["y"] = y
PREFS.update(patch)
return {"ok": True}
except Exception as e:
return {"ok": False, "error": str(e)}
def get_note(self, fid: str) -> Dict[str, Any]:
if not isinstance(fid, str): return {"ok": False, "error": "bad fid"}
return {"ok": True, "note": LIB.get_note(fid)}
def set_note(self, fid: str, note: str) -> Dict[str, Any]:
if not isinstance(fid, str): return {"ok": False, "error": "bad fid"}
return LIB.set_note(fid, note)
def get_current_video_meta(self) -> Dict[str, Any]:
return LIB.get_current_video_metadata()
def get_current_subtitle(self) -> Dict[str, Any]:
return LIB.get_subtitle_for_current()
def get_embedded_subtitles(self) -> Dict[str, Any]:
"""Get list of embedded subtitle tracks for current video."""
return LIB.get_embedded_subtitles()
def extract_embedded_subtitle(self, track_index: int) -> Dict[str, Any]:
"""Extract an embedded subtitle track and return its URL."""
return LIB.extract_embedded_subtitle(track_index)
def get_available_subtitles(self) -> Dict[str, Any]:
"""Get all available subtitle options (sidecar + embedded)."""
return LIB.get_available_subtitles()
def load_sidecar_subtitle(self, file_path: str) -> Dict[str, Any]:
"""Load a specific sidecar subtitle file."""
return LIB.load_sidecar_subtitle(file_path)
def choose_subtitle_file(self) -> Dict[str, Any]:
win = webview.windows[0]
dialog_type = webview.OPEN_DIALOG if not hasattr(webview, 'FileDialog') else webview.FileDialog.OPEN
res = win.create_file_dialog(
dialog_type,
allow_multiple=False,
file_types=("Subtitle files (*.srt;*.vtt)",)
)
if not res:
return {"ok": False, "cancelled": True}
return LIB.set_subtitle_for_current(res[0])
def reset_watch_progress(self) -> Dict[str, Any]:
return LIB.reset_watch_progress()
HTML = r"""<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>TutorialDock</title>
<link rel="stylesheet" href="/fonts.css">
<link rel="stylesheet" href="/fa.css">
<style>
: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;}
.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;}
.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;}
.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);}
.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);}
.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;}
.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;}
.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: '<length>';
initial-value: 30px;
inherits: false;
}
@property --list-fade-bottom {
syntax: '<length>';
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;}
.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: '<length>';
initial-value: 30px;
inherits: false;
}
@property --fade-bottom {
syntax: '<length>';
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;}
.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;}
@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;}
}
/* 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;}
</style>
</head>
<body>
<div id="zoomRoot">
<div class="app">
<div class="topbar">
<div class="brand">
<div class="appIcon" aria-hidden="true"><div class="appIconGlow"></div><i class="fa-solid fa-graduation-cap"></i></div>
<div class="brandText">
<div class="appName">TutorialDock</div>
<div class="tagline">Watch local tutorials, resume instantly, and actually finish them.</div>
</div>
</div>
<div class="actions">
<div class="actionGroup">
<label class="switch" data-tooltip="On Top" data-tooltip-desc="Keep this window above others (global)">
<input type="checkbox" id="onTopChk">
<span class="track"><span class="knob"></span></span>
<span>On top</span>
</label>
<label class="switch" data-tooltip="Autoplay" data-tooltip-desc="Autoplay next/prev (saved per folder)">
<input type="checkbox" id="autoplayChk">
<span class="track"><span class="knob"></span></span>
<span>Autoplay</span>
</label>
</div>
<div class="actionDivider"></div>
<div class="actionGroup">
<div class="zoomControl" data-tooltip="UI Zoom" data-tooltip-desc="Adjust the interface zoom level">
<button class="zoomBtn" id="zoomOutBtn"><i class="fa-solid fa-minus"></i></button>
<span class="zoomValue" id="zoomResetBtn">100%</span>
<button class="zoomBtn" id="zoomInBtn"><i class="fa-solid fa-plus"></i></button>
</div>
</div>
<div class="actionDivider"></div>
<div class="actionGroup">
<div class="splitBtn primary">
<button class="btn primary" id="chooseBtn" data-tooltip="Open Folder" data-tooltip-desc="Browse and select a folder containing videos"><i class="fa-solid fa-folder-open"></i> Open folder</button>
<button class="btn drop" id="chooseDropBtn" data-tooltip="Recent Folders" data-tooltip-desc="Open a recently used folder"><i class="fa-solid fa-chevron-down"></i></button>
</div>
</div>
<div class="actionDivider"></div>
<div class="actionGroup">
<button class="toolbarBtn" id="resetProgBtn" data-tooltip="Reset Progress" data-tooltip-desc="Reset DONE / NOW progress for this folder (keeps notes, volume, etc.)"><i class="fa-solid fa-clock-rotate-left"></i></button>
<button class="toolbarBtn" id="refreshBtn" data-tooltip="Reload" data-tooltip-desc="Reload the current folder"><i class="fa-solid fa-arrows-rotate"></i></button>
</div>
</div>
</div>
<div class="dropdownPortal" id="recentMenu"></div>
<div class="content" id="contentGrid">
<div class="panel">
<div class="panelHeader">
<div style="min-width:0;">
<div class="nowTitle" id="nowTitle">No video loaded</div>
<div class="nowSub" id="nowSub">-</div>
</div>
<div class="progressPill" data-tooltip="Overall Progress" data-tooltip-desc="Folder completion (time-based, using cached durations when available)">
<div class="progressLabel">Overall</div>
<div class="progressBar"><div id="overallBar"></div></div>
<div class="progressPct" id="overallPct">-</div>
</div>
</div>
<div class="videoWrap">
<video id="player" preload="metadata"></video>
<div class="videoOverlay" id="videoOverlay">
<div class="overlayIcon" id="overlayIcon">
<i class="fa-solid fa-play" id="overlayIconI"></i>
</div>
</div>
</div>
<div class="controls">
<div class="controlsRow">
<div class="group">
<button class="iconBtn" id="prevBtn" data-tooltip="Previous" data-tooltip-desc="Go to previous video"><i class="fa-solid fa-backward-step"></i></button>
<button class="iconBtn primary" id="playPauseBtn" data-tooltip="Play/Pause" data-tooltip-desc="Toggle video playback">
<i class="fa-solid fa-play" id="ppIcon"></i>
</button>
<button class="iconBtn" id="nextBtn" data-tooltip="Next" data-tooltip-desc="Go to next video"><i class="fa-solid fa-forward-step"></i></button>
<div class="timeChip" data-tooltip="Time" data-tooltip-desc="Current position / Total duration">
<div class="timeDot"></div>
<div><span id="timeNow">00:00</span> <span style="color:rgba(165,172,196,.65)">/</span> <span id="timeDur">00:00</span></div>
</div>
</div>
<div class="group">
<div class="subsBox">
<button class="iconBtn" id="subsBtn" data-tooltip="Subtitles" data-tooltip-desc="Load or select subtitles"><i class="fa-regular fa-closed-captioning"></i></button>
<div class="subsMenu" id="subsMenu" role="menu"></div>
</div>
<div class="miniCtl" data-tooltip="Volume" data-tooltip-desc="Adjust volume (saved per folder)">
<i class="fa-solid fa-volume-high"></i>
<div class="volWrap">
<div class="volTrack">
<div class="volFill" id="volFill"></div>
</div>
<input type="range" id="volSlider" class="vol" min="0" max="1" step="0.01" value="1">
</div>
<div class="volTooltip" id="volTooltip">100%</div>
</div>
<div class="miniCtl" data-tooltip="Speed" data-tooltip-desc="Playback speed (saved per folder)">
<svg id="speedIcon" width="14" height="14" viewBox="0 0 24 24" style="overflow:visible; color:var(--iconStrong);">
<path d="M12 22C6.5 22 2 17.5 2 12S6.5 2 12 2s10 4.5 10 10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity=".5"/>
<path d="M12 22c5.5 0 10-4.5 10-10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity=".3"/>
<g transform="rotate(0, 12, 13)">
<line x1="12" y1="13" x2="12" y2="5" stroke="rgba(255,255,255,.85)" stroke-width="2.5" stroke-linecap="round"/>
</g>
<circle cx="12" cy="13" r="2" fill="currentColor" opacity=".7"/>
</svg>
<div class="speedBox">
<button class="speedBtn" id="speedBtn" aria-label="Playback speed">
<span id="speedBtnText">1.0x</span>
<span class="speedCaret" aria-hidden="true"><i class="fa-solid fa-chevron-up"></i></span>
</button>
<div class="speedMenu" id="speedMenu" role="menu" aria-label="Speed menu"></div>
</div>
</div>
<button class="iconBtn" id="fsBtn" data-tooltip="Fullscreen" data-tooltip-desc="Toggle fullscreen mode"><i class="fa-solid fa-expand"></i></button>
</div>
</div>
<div class="seekWrap">
<div class="seekTrack">
<div class="seekFill" id="seekFill"></div>
</div>
<input type="range" id="seek" class="seek" min="0" max="1000" step="1" value="0" aria-label="Seek">
</div>
</div>
<div class="dock" id="dockGrid">
<div class="dockPane">
<div class="dockInner">
<div class="dockHeader" id="notesHeader" data-tooltip="Notes" data-tooltip-desc="Your notes are automatically saved for each video file. Write timestamps, TODOs, or reminders.">
<div class="dockTitle"><i class="fa-solid fa-note-sticky"></i> Notes</div>
</div>
<div class="notesArea">
<textarea class="notes" id="notesBox" placeholder="Write timestamps, TODOs, reminders…"></textarea>
<div class="notesSaved" id="notesSaved"><i class="fa-solid fa-check"></i> Saved</div>
</div>
</div>
</div>
<div class="dockDividerWrap">
<div class="dockDivider" id="dockDivider" data-tooltip="Resize" data-tooltip-desc="Drag to resize panels"></div>
</div>
<div class="dockPane">
<div class="dockInner">
<div class="dockHeader" id="infoHeader" data-tooltip="Info" data-tooltip-desc="Metadata and progress info for the current folder and video. Updates automatically.">
<div class="dockTitle"><i class="fa-solid fa-circle-info"></i> Info</div>
</div>
<div class="infoGrid" id="infoGrid">
<div class="kv">
<div class="k">Folder</div><div class="v" id="infoFolder">-</div>
<div class="k">Next up</div><div class="v" id="infoNext">-</div>
<div class="k">Structure</div><div class="v mono" id="infoStruct">-</div>
</div>
<div class="kv">
<div class="k">Title</div><div class="v" id="infoTitle">-</div>
<div class="k">Relpath</div><div class="v mono" id="infoRel">-</div>
<div class="k">Position</div><div class="v mono" id="infoPos">-</div>
</div>
<div class="kv">
<div class="k">File</div><div class="v mono" id="infoFileBits">-</div>
<div class="k">Video</div><div class="v mono" id="infoVidBits">-</div>
<div class="k">Audio</div><div class="v mono" id="infoAudBits">-</div>
<div class="k">Subtitles</div><div class="v mono" id="infoSubsBits">-</div>
</div>
<div class="kv">
<div class="k">Finished</div><div class="v mono" id="infoFinished">-</div>
<div class="k">Remaining</div><div class="v mono" id="infoRemaining">-</div>
<div class="k">ETA</div><div class="v mono" id="infoEta">-</div>
</div>
<div class="kv">
<div class="k">Volume</div><div class="v mono" id="infoVolume">-</div>
<div class="k">Speed</div><div class="v mono" id="infoSpeed">-</div>
<div class="k">Durations</div><div class="v mono" id="infoKnown">-</div>
</div>
<div class="kv">
<div class="k">Top folders</div><div class="v mono" id="infoTop">-</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="dividerWrap">
<div class="divider" id="divider" data-tooltip="Resize" data-tooltip-desc="Drag to resize panels"></div>
</div>
<div class="panel">
<div class="panelHeader" style="align-items:center;">
<div class="playlistHeader" id="plistHeader" data-tooltip="Playlist" data-tooltip-desc="Drag items to reorder. The blue line shows where it will drop."><i class="fa-solid fa-list"></i> Playlist</div>
<div style="flex:1 1 auto;"></div>
</div>
<div class="listWrap">
<div class="list" id="list"></div>
<div class="listScrollbar" id="listScrollbar"><div class="listScrollbarThumb" id="listScrollbarThumb"></div></div>
</div>
<div class="empty" id="emptyHint" style="display:none;">
No videos found (searched recursively).<br/>Native playback is happiest with MP4 (H.264/AAC) or WebM.
</div>
</div>
</div>
</div>
</div>
<div id="toast" aria-live="polite">
<div class="toastInner">
<div class="toastIcon"><i class="fa-solid fa-circle-info"></i></div>
<div class="toastMsg" id="toastMsg">-</div>
</div>
</div>
<div class="tooltip" id="fancyTooltip"><div class="tooltip-title"></div><div class="tooltip-desc"></div></div>
<script>
const player=document.getElementById("player"), listEl=document.getElementById("list");
const nowTitle=document.getElementById("nowTitle"), nowSub=document.getElementById("nowSub");
const overallBar=document.getElementById("overallBar"), overallPct=document.getElementById("overallPct");
const emptyHint=document.getElementById("emptyHint");
const chooseBtn=document.getElementById("chooseBtn"), chooseDropBtn=document.getElementById("chooseDropBtn");
const recentMenu=document.getElementById("recentMenu");
const refreshBtn=document.getElementById("refreshBtn");
const resetProgBtn=document.getElementById("resetProgBtn");
const prevBtn=document.getElementById("prevBtn"), nextBtn=document.getElementById("nextBtn");
const playPauseBtn=document.getElementById("playPauseBtn"), ppIcon=document.getElementById("ppIcon");
const timeNow=document.getElementById("timeNow"), timeDur=document.getElementById("timeDur");
const zoomOutBtn=document.getElementById("zoomOutBtn"), zoomInBtn=document.getElementById("zoomInBtn"), zoomResetBtn=document.getElementById("zoomResetBtn");
const onTopChk=document.getElementById("onTopChk"), autoplayChk=document.getElementById("autoplayChk");
const contentGrid=document.getElementById("contentGrid"), divider=document.getElementById("divider");
const dockGrid=document.getElementById("dockGrid"), dockDivider=document.getElementById("dockDivider");
const notesBox=document.getElementById("notesBox");
const seek=document.getElementById("seek"), volSlider=document.getElementById("volSlider"), fsBtn=document.getElementById("fsBtn");
const speedBtn=document.getElementById("speedBtn"), speedBtnText=document.getElementById("speedBtnText"), speedIcon=document.getElementById("speedIcon");
const speedMenu=document.getElementById("speedMenu");
const subsBtn=document.getElementById("subsBtn");
const subsMenu=document.getElementById("subsMenu");
const infoFolder=document.getElementById("infoFolder"), infoNext=document.getElementById("infoNext"), infoStruct=document.getElementById("infoStruct");
const infoTitle=document.getElementById("infoTitle"), infoRel=document.getElementById("infoRel"), infoPos=document.getElementById("infoPos");
const infoFileBits=document.getElementById("infoFileBits"), infoVidBits=document.getElementById("infoVidBits"), infoAudBits=document.getElementById("infoAudBits"), infoSubsBits=document.getElementById("infoSubsBits");
const infoFinished=document.getElementById("infoFinished"), infoRemaining=document.getElementById("infoRemaining"), infoEta=document.getElementById("infoEta");
const infoVolume=document.getElementById("infoVolume"), infoSpeed=document.getElementById("infoSpeed"), infoKnown=document.getElementById("infoKnown"), infoTop=document.getElementById("infoTop");
const infoGridEl=document.getElementById("infoGrid");
// Handle info panel scroll fades
let updateInfoFades = ()=>{};
if(infoGridEl){
updateInfoFades = ()=>{
const atTop = infoGridEl.scrollTop < 5;
const atBottom = infoGridEl.scrollTop + infoGridEl.clientHeight >= infoGridEl.scrollHeight - 5;
infoGridEl.classList.toggle("at-top", atTop);
infoGridEl.classList.toggle("at-bottom", atBottom);
};
infoGridEl.addEventListener("scroll", updateInfoFades);
setTimeout(updateInfoFades, 100);
setTimeout(updateInfoFades, 500);
}
// Handle playlist scroll fades
let updateListFades = ()=>{};
const listScrollbar = document.getElementById("listScrollbar");
const listScrollbarThumb = document.getElementById("listScrollbarThumb");
let scrollbarHideTimer = null;
let scrollbarDragging = false;
let scrollbarDragStartY = 0;
let scrollbarDragStartScrollTop = 0;
function updateScrollbar(){
if(!listEl || !listScrollbarThumb) return;
const scrollHeight = listEl.scrollHeight;
const clientHeight = listEl.clientHeight;
if(scrollHeight <= clientHeight){
listScrollbar.style.display = "none";
return;
}
listScrollbar.style.display = "block";
const scrollRatio = clientHeight / scrollHeight;
const thumbHeight = Math.max(24, clientHeight * scrollRatio);
const maxScroll = scrollHeight - clientHeight;
const scrollTop = listEl.scrollTop;
const trackHeight = clientHeight - 24; // account for top/bottom margins
const thumbTop = maxScroll > 0 ? (scrollTop / maxScroll) * (trackHeight - thumbHeight) : 0;
listScrollbarThumb.style.height = thumbHeight + "px";
listScrollbarThumb.style.top = thumbTop + "px";
}
// Scrollbar drag handlers
if(listScrollbarThumb){
listScrollbarThumb.style.pointerEvents = "auto";
listScrollbar.style.pointerEvents = "auto";
const startDrag = (e)=>{
e.preventDefault();
scrollbarDragging = true;
scrollbarDragStartY = e.type.includes("touch") ? e.touches[0].clientY : e.clientY;
scrollbarDragStartScrollTop = listEl.scrollTop;
listScrollbar.classList.add("active");
document.body.style.userSelect = "none";
};
const doDrag = (e)=>{
if(!scrollbarDragging) return;
const clientY = e.type.includes("touch") ? e.touches[0].clientY : e.clientY;
const deltaY = clientY - scrollbarDragStartY;
const trackHeight = listEl.clientHeight - 24;
const thumbHeight = Math.max(24, listEl.clientHeight * (listEl.clientHeight / listEl.scrollHeight));
const scrollableTrack = trackHeight - thumbHeight;
const maxScroll = listEl.scrollHeight - listEl.clientHeight;
if(scrollableTrack > 0){
const scrollDelta = (deltaY / scrollableTrack) * maxScroll;
listEl.scrollTop = scrollbarDragStartScrollTop + scrollDelta;
}
};
const endDrag = ()=>{
if(scrollbarDragging){
scrollbarDragging = false;
document.body.style.userSelect = "";
if(scrollbarHideTimer) clearTimeout(scrollbarHideTimer);
scrollbarHideTimer = setTimeout(()=>{ listScrollbar.classList.remove("active"); }, 1200);
}
};
listScrollbarThumb.addEventListener("mousedown", startDrag);
listScrollbarThumb.addEventListener("touchstart", startDrag);
window.addEventListener("mousemove", doDrag);
window.addEventListener("touchmove", doDrag);
window.addEventListener("mouseup", endDrag);
window.addEventListener("touchend", endDrag);
}
if(listEl){
updateListFades = ()=>{
const atTop = listEl.scrollTop < 5;
const atBottom = listEl.scrollTop + listEl.clientHeight >= listEl.scrollHeight - 5;
listEl.classList.toggle("at-top", atTop);
listEl.classList.toggle("at-bottom", atBottom);
updateScrollbar();
// Show scrollbar briefly when scrolling
if(listScrollbar && !scrollbarDragging){
listScrollbar.classList.add("active");
if(scrollbarHideTimer) clearTimeout(scrollbarHideTimer);
scrollbarHideTimer = setTimeout(()=>{ listScrollbar.classList.remove("active"); }, 1200);
}
};
listEl.addEventListener("scroll", updateListFades);
setTimeout(updateListFades, 100);
setTimeout(updateListFades, 500);
}
function updateSpeedIcon(rate){
// Custom SVG gauge icons for different speed states
const icon = document.getElementById("speedIcon");
if(!icon) return;
// SVG gauge with needle position based on rate
// 0.5x = -60deg (8 o'clock), 1.0x = 0deg (12 o'clock), 2.0x = 75deg (5 o'clock)
let needleAngle = 0;
let needleColor = "rgba(255,255,255,.85)";
if(rate <= 0.5){
needleAngle = -60;
needleColor = "rgba(100,180,255,.9)";
} else if(rate < 1.0){
// Interpolate from -60 (0.5x) to 0 (1.0x)
const t = (rate - 0.5) / 0.5;
needleAngle = -60 + t * 60;
// Color from blue to white
needleColor = `rgba(${Math.round(100 + t*155)},${Math.round(180 + t*75)},${Math.round(255)},0.9)`;
} else if(rate <= 1.0){
needleAngle = 0;
needleColor = "rgba(255,255,255,.85)";
} else if(rate < 2.0){
// Interpolate from 0 (1.0x) to 75 (2.0x)
const t = (rate - 1.0) / 1.0;
needleAngle = t * 75;
// Color from white to orange
needleColor = `rgba(255,${Math.round(255 - t*115)},${Math.round(255 - t*155)},0.9)`;
} else {
needleAngle = 75;
needleColor = "rgba(255,140,100,.9)";
}
// Check if needle group exists, if so just update transform for smooth animation
const needleGroup = icon.querySelector('.speed-needle');
const needleLine = icon.querySelector('.speed-needle line');
if(needleGroup && needleLine){
needleGroup.style.transform = `rotate(${needleAngle}deg)`;
needleLine.style.stroke = needleColor;
} else {
icon.innerHTML = `
<path d="M12 22C6.5 22 2 17.5 2 12S6.5 2 12 2s10 4.5 10 10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity=".5"/>
<path d="M12 22c5.5 0 10-4.5 10-10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity=".3"/>
<g class="speed-needle" style="transform-origin:12px 13px; transform:rotate(${needleAngle}deg); transition:transform 0.8s cubic-bezier(.4,0,.2,1);">
<line x1="12" y1="13" x2="12" y2="5" stroke="${needleColor}" stroke-width="2.5" stroke-linecap="round" style="transition:stroke 0.8s ease;"/>
</g>
<circle cx="12" cy="13" r="2" fill="currentColor" opacity=".7"/>
`;
}
}
const toast=document.getElementById("toast");
const toastMsg=document.getElementById("toastMsg");
let toastTimer=null;
let prefs=null, library=null, currentIndex=0, lastTick=0, suppressTick=false;
let draggingDivider=false, saveSplitTimer=null, saveZoomTimer=null;
let draggingDockDivider=false, saveDockTimer=null;
let dragFromIndex=null, dropTargetIndex=null, dropAfter=false;
let seeking=false;
let subtitleTrackEl=null;
function notify(msg){
toastMsg.textContent = msg;
toast.classList.add("show");
if(toastTimer) clearTimeout(toastTimer);
toastTimer=setTimeout(()=>{ toast.classList.remove("show"); }, 2600);
}
function clamp(n,a,b){return Math.max(a, Math.min(b,n));}
function fmtTime(sec){
sec=Math.max(0, Math.floor(sec||0));
const h=Math.floor(sec/3600), m=Math.floor((sec%3600)/60), s=sec%60;
if(h>0) return `${h}:${String(m).padStart(2,"0")}:${String(s).padStart(2,"0")}`;
return `${m}:${String(s).padStart(2,"0")}`;
}
function fmtBytes(n){
n=Number(n||0); if(!isFinite(n)||n<=0) return "-";
const u=["B","KB","MB","GB","TB"]; let i=0;
while(n>=1024&&i<u.length-1){n/=1024;i++;}
return `${n.toFixed(i===0?0:1)} ${u[i]}`;
}
function fmtDate(ts){ if(!ts) return "-"; try{ return new Date(ts*1000).toLocaleString(); }catch(e){ return "-"; } }
function applyZoom(z){
const zoom=clamp(Number(z||1.0),0.75,2.0);
document.documentElement.style.setProperty("--zoom", String(zoom));
zoomResetBtn.textContent = `${Math.round(zoom*100)}%`;
document.body.getBoundingClientRect();
return zoom;
}
function applySplit(ratio){
const r=clamp(Number(ratio||0.62),0.35,0.80);
// Use calc to ensure the percentages account for the 14px divider
contentGrid.style.gridTemplateColumns = `calc(${(r*100).toFixed(2)}% - 7px) 14px calc(${(100-r*100).toFixed(2)}% - 7px)`;
return r;
}
function applyDockSplit(ratio){
const r=clamp(Number(ratio||0.62),0.35,0.80);
dockGrid.style.gridTemplateColumns = `calc(${(r*100).toFixed(2)}% - 7px) 14px calc(${(100-r*100).toFixed(2)}% - 7px)`;
return r;
}
async function savePrefsPatch(patch){ await window.pywebview.api.set_prefs(patch); }
function computeResumeTime(item){
if(!item) return 0.0;
if(item.finished) return 0.0; // DONE stays done; rewatch from start
const pos=Number(item.pos||0.0), dur=Number(item.duration||0.0);
if(dur>0) return clamp(pos, 0.0, Math.max(0.0, dur-0.25));
return Math.max(0.0, pos);
}
function updateOverall(){
if(!library){ overallBar.style.width="0%"; overallPct.textContent="-"; return; }
if(library.overall_progress===null||library.overall_progress===undefined){ overallBar.style.width="0%"; overallPct.textContent="-"; return; }
const p=clamp(library.overall_progress,0,1);
overallBar.style.width=`${(p*100).toFixed(1)}%`;
overallPct.textContent=`${(p*100).toFixed(1)}%`;
}
function updateTimeReadout(){
const t=player.currentTime||0, d=player.duration||0;
timeNow.textContent=fmtTime(t);
timeDur.textContent=d?fmtTime(d):"00:00";
}
function updatePlayPauseIcon(){
if(player.paused||player.ended){
ppIcon.className="fa-solid fa-play";
}else{
ppIcon.className="fa-solid fa-pause";
}
}
function clearDropIndicators(){ listEl.querySelectorAll(".row").forEach(r=>r.classList.remove("drop-before","drop-after")); }
function currentItem(){ if(!library||!library.items) return null; return library.items[currentIndex]||null; }
function fmtBitrate(bps){
const n=Number(bps||0); if(!isFinite(n)||n<=0) return null;
const kb=n/1000.0; if(kb<1000) return `${kb.toFixed(0)} kbps`;
return `${(kb/1000).toFixed(2)} Mbps`;
}
async function refreshCurrentVideoMeta(){
const it=currentItem();
if(!it){ infoFileBits.textContent="-"; infoVidBits.textContent="-"; infoAudBits.textContent="-"; infoSubsBits.textContent="-"; return; }
try{
const res=await window.pywebview.api.get_current_video_meta();
if(!res||!res.ok){ infoFileBits.textContent="-"; infoVidBits.textContent="-"; infoAudBits.textContent="-"; infoSubsBits.textContent="-"; return; }
const b=res.basic||{}, p=res.probe||null, ffFound=!!res.ffprobe_found;
const bits=[];
if(b.ext) bits.push(String(b.ext).toUpperCase());
if(b.size) bits.push(fmtBytes(b.size));
if(b.mtime) bits.push(`modified ${fmtDate(b.mtime)}`);
if(b.folder) bits.push(`folder ${b.folder}`);
infoFileBits.textContent = bits.join("") || "-";
if(p){
const v=[];
if(p.v_codec) v.push(String(p.v_codec).toUpperCase());
if(p.width&&p.height) v.push(`${p.width}×${p.height}`);
if(p.fps) v.push(`${Number(p.fps).toFixed(2)} fps`);
if(p.pix_fmt) v.push(p.pix_fmt);
const vb=fmtBitrate(p.v_bitrate); if(vb) v.push(vb);
infoVidBits.textContent = v.join("") || "-";
const a=[];
if(p.a_codec) a.push(String(p.a_codec).toUpperCase());
if(p.channels) a.push(`${p.channels} ch`);
if(p.sample_rate) a.push(`${(Number(p.sample_rate)/1000).toFixed(1)} kHz`);
const ab=fmtBitrate(p.a_bitrate); if(ab) a.push(ab);
infoAudBits.textContent = a.join("") || "-";
// Subtitle info
const subs = p.subtitle_tracks || [];
if(subs.length > 0){
const subInfo = subs.map(s => {
const lang = s.language?.toUpperCase() || "";
const title = s.title || "";
return title || lang || s.codec || "Track";
}).join(", ");
infoSubsBits.textContent = `${subs.length} embedded (${subInfo})`;
}else if(it.has_sub){
infoSubsBits.textContent = "External file loaded";
}else{
infoSubsBits.textContent = "None";
}
}else{
infoVidBits.textContent = ffFound ? "(ffprobe available, metadata not read for this file)" : "(ffprobe not found)";
infoAudBits.textContent = "-";
infoSubsBits.textContent = it.has_sub ? "External file loaded" : "-";
}
}catch(e){
infoFileBits.textContent="-"; infoVidBits.textContent="-"; infoAudBits.textContent="-"; infoSubsBits.textContent="-";
}
}
function updateInfoPanel(){
const it=currentItem();
infoFolder.textContent = library?.folder || "-";
infoNext.textContent = library?.next_up ? library.next_up.title : "-";
infoStruct.textContent = library ? (library.has_subdirs ? "Subfolders detected" : "Flat folder") : "-";
infoTitle.textContent = it?.title || "-";
infoRel.textContent = it?.relpath || "-";
const t=player.currentTime||0;
const d=Number.isFinite(player.duration)?player.duration:0;
infoPos.textContent = d>0 ? `${fmtTime(t)} / ${fmtTime(d)}` : fmtTime(t);
if(library){
infoFinished.textContent = `${library.finished_count ?? 0}`;
infoRemaining.textContent = `${library.remaining_count ?? 0}`;
infoEta.textContent = (library.remaining_seconds_known!=null) ? fmtTime(library.remaining_seconds_known) : "-";
infoKnown.textContent = `${library.durations_known||0}/${library.count||0}`;
infoTop.textContent = (library.top_folders||[]).map(([n,c])=>`${n}:${c}`).join("") || "-";
infoVolume.textContent = `${Math.round(clamp(Number(library.folder_volume ?? 1),0,1)*100)}%`;
infoSpeed.textContent = `${Number(library.folder_rate ?? 1).toFixed(2)}x`;
}else{
infoFinished.textContent="-"; infoRemaining.textContent="-"; infoEta.textContent="-";
infoKnown.textContent="-"; infoTop.textContent="-"; infoVolume.textContent="-"; infoSpeed.textContent="-";
}
}
async function loadNoteForCurrent(){
const it=currentItem();
if(!it){ notesBox.value=""; return; }
try{
const res=await window.pywebview.api.get_note(it.fid);
notesBox.value = (res&&res.ok) ? (res.note||"") : "";
}catch(e){ notesBox.value=""; }
}
let noteSaveTimer=null;
let notesSavedTimer=null;
const notesSaved=document.getElementById("notesSaved");
notesBox.addEventListener("input", ()=>{
if(!library) return;
const it=currentItem(); if(!it) return;
if(noteSaveTimer) clearTimeout(noteSaveTimer);
if(notesSavedTimer) clearTimeout(notesSavedTimer);
if(notesSaved) notesSaved.classList.remove("show");
noteSaveTimer=setTimeout(async ()=>{
try{
await window.pywebview.api.set_note(it.fid, notesBox.value||"");
// Show saved indicator
if(notesSaved){
notesSaved.classList.add("show");
notesSavedTimer = setTimeout(()=>{ notesSaved.classList.remove("show"); }, 2000);
}
}catch(e){}
}, 350);
});
function renderTreeSvg(it){
const depth=Number(it.depth||0);
const pipes=Array.isArray(it.pipes)?it.pipes:[];
const isLast=!!it.is_last;
const hasPrev=!!it.has_prev_in_parent;
const unit=14;
const pad=8;
const height=28;
const mid=Math.round(height/2);
const extend=20; // How far to extend lines beyond SVG for visual connection
const width = pad + Math.max(1, depth) * unit + 18;
const ns="http://www.w3.org/2000/svg";
const svg=document.createElementNS(ns,"svg");
svg.setAttribute("class","treeSvg");
svg.setAttribute("width", String(width));
svg.setAttribute("height", String(height));
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
svg.style.overflow = "visible";
const xCol = (j)=> pad + j*unit + 0.5;
// Continuation pipes - these always extend full height + beyond
for(let j=0; j<depth-1; j++){
if(pipes[j]){
const ln=document.createElementNS(ns,"line");
ln.setAttribute("x1", String(xCol(j)));
ln.setAttribute("y1", String(-extend));
ln.setAttribute("x2", String(xCol(j)));
ln.setAttribute("y2", String(height + extend));
svg.appendChild(ln);
}
}
if(depth<=0){
const c=document.createElementNS(ns,"circle");
c.setAttribute("cx", String(pad + 3));
c.setAttribute("cy", String(mid));
c.setAttribute("r", "3.2");
c.setAttribute("opacity","0.40");
svg.appendChild(c);
return svg;
}
const parentCol = depth-1;
const px = xCol(parentCol);
// Vertical line at parent column - extends up if has prev, down if not last
if(hasPrev || !isLast){
const vln=document.createElementNS(ns,"line");
vln.setAttribute("x1", String(px));
vln.setAttribute("y1", String(hasPrev ? -extend : mid));
vln.setAttribute("x2", String(px));
vln.setAttribute("y2", String(isLast ? mid : height + extend));
svg.appendChild(vln);
}
// Horizontal connector
const hx1 = px;
const hx2 = px + unit;
const h=document.createElementNS(ns,"line");
h.setAttribute("x1", String(hx1));
h.setAttribute("y1", String(mid));
h.setAttribute("x2", String(hx2));
h.setAttribute("y2", String(mid));
svg.appendChild(h);
const node=document.createElementNS(ns,"circle");
node.setAttribute("cx", String(hx2));
node.setAttribute("cy", String(mid));
node.setAttribute("r", "3.4");
svg.appendChild(node);
return svg;
}
async function reorderPlaylistByGap(fromIdx, targetIdx, after){
if(!library || !library.items) return;
const base = library.items.slice().sort((a,b)=>a.index-b.index).map(x=>x.fid);
if(fromIdx<0||fromIdx>=base.length) return;
if(targetIdx<0||targetIdx>=base.length) return;
const moving=base[fromIdx];
base.splice(fromIdx,1);
let insertAt=targetIdx;
if(fromIdx<targetIdx) insertAt -= 1;
if(after) insertAt += 1;
insertAt = clamp(insertAt, 0, base.length);
base.splice(insertAt, 0, moving);
try{
const res=await window.pywebview.api.set_order(base);
if(res && res.ok){
const info=await window.pywebview.api.get_library();
if(info && info.ok){
library=info;
currentIndex=library.current_index||0;
renderList(); updateOverall(); updateInfoPanel();
await refreshCurrentVideoMeta();
}
}
}catch(e){}
}
function renderList(){
listEl.innerHTML="";
if(!library||!library.items||library.items.length===0){
emptyHint.style.display="block";
return;
}
emptyHint.style.display="none";
const tree=!!library.has_subdirs;
const padN=String(library.items.length).length;
for(let displayIndex=0; displayIndex<library.items.length; displayIndex++){
const it=library.items[displayIndex];
const row=document.createElement("div");
row.className="row"+(it.index===currentIndex ? " active":"");
row.draggable=true;
row.dataset.index=String(it.index);
row.onclick=()=>{ if(dragFromIndex!==null) return; loadIndex(it.index, computeResumeTime(it), true); };
row.addEventListener("dragstart",(e)=>{
dragFromIndex=Number(row.dataset.index);
row.classList.add("dragging");
e.dataTransfer.effectAllowed="move";
try{ e.dataTransfer.setData("text/plain", String(dragFromIndex)); }catch(_){}
});
row.addEventListener("dragend", async ()=>{
row.classList.remove("dragging");
if(dragFromIndex!==null && dropTargetIndex!==null){
await reorderPlaylistByGap(dragFromIndex, dropTargetIndex, dropAfter);
}
dragFromIndex=null; dropTargetIndex=null; dropAfter=false; clearDropIndicators();
});
row.addEventListener("dragover",(e)=>{
e.preventDefault();
e.dataTransfer.dropEffect="move";
const rect=row.getBoundingClientRect();
const y=e.clientY - rect.top;
const after=y > rect.height/2;
dropTargetIndex=Number(row.dataset.index);
dropAfter=after;
clearDropIndicators();
row.classList.add(after ? "drop-after" : "drop-before");
});
row.addEventListener("drop",(e)=>e.preventDefault());
const left=document.createElement("div");
left.className="left";
const num=document.createElement("div");
num.className="numBadge";
num.textContent=String(displayIndex+1).padStart(padN,"0");
left.appendChild(num);
if(tree) left.appendChild(renderTreeSvg(it));
const textWrap=document.createElement("div");
textWrap.className="textWrap";
const name=document.createElement("div");
name.className="name";
name.textContent = it.title || it.name;
const small=document.createElement("div");
small.className="small";
const d=it.duration, w=it.watched||0;
const note=it.note_len ? " • note" : "";
const sub = it.has_sub ? " • subs" : "";
small.textContent = (d ? `${fmtTime(w)} / ${fmtTime(d)}` : `${fmtTime(w)} watched`) + note + sub + ` - ${it.relpath}`;
textWrap.appendChild(name);
textWrap.appendChild(small);
left.appendChild(textWrap);
const tag=document.createElement("div");
tag.className="tag";
if(it.index===currentIndex){ tag.classList.add("now"); tag.textContent="Now"; }
else if(it.finished){ tag.classList.add("done"); tag.textContent="Done"; }
else { tag.classList.add("hidden"); tag.textContent=""; }
row.appendChild(left);
row.appendChild(tag);
listEl.appendChild(row);
}
// Update scroll fades after content changes
setTimeout(updateListFades, 50);
}
function ensureDropdownPortal(){
try{
if(recentMenu && recentMenu.parentElement !== document.body){ document.body.appendChild(recentMenu); }
recentMenu.classList.add("dropdownPortal");
}catch(e){}
}
let recentOpen=false;
function positionRecentMenu(){
const r=chooseDropBtn.getBoundingClientRect();
const pad=8;
const zoom=clamp(Number(prefs?.ui_zoom||1),0.75,2.0);
const baseW=460, baseH=360;
const effW=baseW*zoom, effH=baseH*zoom;
const left=clamp(r.right-effW, 10, window.innerWidth-effW-10);
const top=clamp(r.bottom+pad, 10, window.innerHeight-effH-10);
recentMenu.style.left=`${left}px`;
recentMenu.style.top=`${top}px`;
recentMenu.style.width=`${baseW}px`;
recentMenu.style.maxHeight=`${baseH}px`;
}
function closeRecentMenu(){ recentOpen=false; recentMenu.style.display="none"; }
async function openRecentMenu(){
ensureDropdownPortal();
try{
const res=await window.pywebview.api.get_recents();
recentMenu.innerHTML="";
if(!res||!res.ok||!res.items||res.items.length===0){
const div=document.createElement("div");
div.className="dropEmpty";
div.textContent="No recent folders yet.";
recentMenu.appendChild(div);
}else{
for(const it of res.items){
const row=document.createElement("div");
row.className="dropItem";
row.dataset.tooltip=it.name;
row.dataset.tooltipDesc=it.path;
const icon=document.createElement("div");
icon.className="dropIcon";
icon.innerHTML='<i class="fa-solid fa-folder"></i>';
const name=document.createElement("div");
name.className="dropName";
name.textContent=it.name;
const removeBtn=document.createElement("div");
removeBtn.className="dropRemove";
removeBtn.innerHTML='<i class="fa-solid fa-xmark"></i>';
removeBtn.onclick=async (e)=>{
e.stopPropagation();
try{
await window.pywebview.api.remove_recent(it.path);
row.remove();
if(recentMenu.querySelectorAll(".dropItem").length===0){
const div=document.createElement("div");
div.className="dropEmpty";
div.textContent="No recent folders yet.";
recentMenu.innerHTML="";
recentMenu.appendChild(div);
}
}catch(err){}
};
row.appendChild(icon); row.appendChild(name); row.appendChild(removeBtn);
row.onclick=async ()=>{
closeRecentMenu();
const info=await window.pywebview.api.open_folder_path(it.path);
if(!info||!info.ok) { notify("Folder not available."); return; }
await onLibraryLoaded(info,true);
};
recentMenu.appendChild(row);
}
}
positionRecentMenu();
recentMenu.style.display="block";
recentOpen=true;
}catch(e){ closeRecentMenu(); }
}
chooseDropBtn.onclick=async (e)=>{
e.preventDefault(); e.stopPropagation();
if(recentOpen) closeRecentMenu();
else await openRecentMenu();
};
window.addEventListener("resize", ()=>{ if(recentOpen) positionRecentMenu(); });
window.addEventListener("scroll", ()=>{ if(recentOpen) positionRecentMenu(); }, true);
window.addEventListener("click", ()=>{ if(recentOpen) closeRecentMenu(); });
recentMenu.addEventListener("click", (e)=>e.stopPropagation());
divider.addEventListener("mousedown",(e)=>{ draggingDivider=true; document.body.style.userSelect="none"; e.preventDefault(); });
dockDivider.addEventListener("mousedown",(e)=>{ draggingDockDivider=true; document.body.style.userSelect="none"; e.preventDefault(); });
window.addEventListener("mouseup", async ()=>{
if(draggingDivider){
draggingDivider=false; document.body.style.userSelect="";
if(prefs && typeof prefs.split_ratio==="number"){ await savePrefsPatch({split_ratio:prefs.split_ratio}); }
}
if(draggingDockDivider){
draggingDockDivider=false; document.body.style.userSelect="";
if(prefs && typeof prefs.dock_ratio==="number"){ await savePrefsPatch({dock_ratio:prefs.dock_ratio}); }
}
});
window.addEventListener("mousemove",(e)=>{
if(draggingDivider){
const rect=contentGrid.getBoundingClientRect();
const x=e.clientX-rect.left;
prefs=prefs||{};
prefs.split_ratio=applySplit(x/rect.width);
if(saveSplitTimer) clearTimeout(saveSplitTimer);
saveSplitTimer=setTimeout(()=>{ if(prefs) savePrefsPatch({split_ratio:prefs.split_ratio}); }, 400);
}
if(draggingDockDivider){
const rect=dockGrid.getBoundingClientRect();
const x=e.clientX-rect.left;
prefs=prefs||{};
prefs.dock_ratio=applyDockSplit(x/rect.width);
if(saveDockTimer) clearTimeout(saveDockTimer);
saveDockTimer=setTimeout(()=>{ if(prefs) savePrefsPatch({dock_ratio:prefs.dock_ratio}); }, 400);
}
});
zoomOutBtn.onclick=()=>{
prefs.ui_zoom=applyZoom(Number(prefs.ui_zoom||1.0)-0.1);
if(saveZoomTimer) clearTimeout(saveZoomTimer);
saveZoomTimer=setTimeout(()=>savePrefsPatch({ui_zoom:prefs.ui_zoom}),120);
if(recentOpen) positionRecentMenu();
};
zoomInBtn.onclick=()=>{
prefs.ui_zoom=applyZoom(Number(prefs.ui_zoom||1.0)+0.1);
if(saveZoomTimer) clearTimeout(saveZoomTimer);
saveZoomTimer=setTimeout(()=>savePrefsPatch({ui_zoom:prefs.ui_zoom}),120);
if(recentOpen) positionRecentMenu();
};
zoomResetBtn.onclick=()=>{
prefs.ui_zoom=applyZoom(1.0);
if(saveZoomTimer) clearTimeout(saveZoomTimer);
saveZoomTimer=setTimeout(()=>savePrefsPatch({ui_zoom:prefs.ui_zoom}),120);
if(recentOpen) positionRecentMenu();
};
onTopChk.addEventListener("change", async ()=>{
const enabled=!!onTopChk.checked;
try{
await window.pywebview.api.set_always_on_top(enabled);
prefs.always_on_top=enabled;
await savePrefsPatch({always_on_top:enabled});
notify(enabled ? "On top enabled." : "On top disabled.");
}catch(e){ onTopChk.checked=!!prefs.always_on_top; }
});
autoplayChk.addEventListener("change", async ()=>{
if(!library) return;
const enabled=!!autoplayChk.checked;
try{
const res=await window.pywebview.api.set_folder_autoplay(enabled);
if(res && res.ok){
library.folder_autoplay=enabled; updateInfoPanel();
notify(enabled ? "Autoplay: ON" : "Autoplay: OFF");
}
}catch(e){}
});
resetProgBtn.addEventListener("click", async ()=>{
if(!library) return;
try{
const res=await window.pywebview.api.reset_watch_progress();
if(res && res.ok){
notify("Progress reset for this folder.");
const info=await window.pywebview.api.get_library();
if(info && info.ok){
library=info;
currentIndex=library.current_index||0;
renderList(); updateOverall(); updateInfoPanel();
await loadIndex(currentIndex, 0.0, true, false);
}
}
}catch(e){}
});
async function togglePlay(){
try{
if(player.paused||player.ended) await player.play();
else player.pause();
}catch(e){}
updatePlayPauseIcon();
}
playPauseBtn.onclick=togglePlay;
player.addEventListener("click",(e)=>{ e.preventDefault(); togglePlay(); });
prevBtn.onclick=()=>nextPrev(-1);
nextBtn.onclick=()=>nextPrev(+1);
fsBtn.onclick=async ()=>{
try{
if(document.fullscreenElement) await document.exitFullscreen();
else await player.requestFullscreen();
}catch(e){}
};
const volFill = document.getElementById("volFill");
function updateVolFill(){
if(volFill){
const pct = clamp(Number(volSlider.value||1.0),0,1) * 100;
volFill.style.width = pct + "%";
}
}
// Initial update
updateVolFill();
volSlider.addEventListener("input", ()=>{
const v=clamp(Number(volSlider.value||1.0),0,1);
suppressTick=true; player.volume=v; suppressTick=false;
if(library) library.folder_volume=v;
updateInfoPanel();
updateVolFill();
// Update volume tooltip text and position
const volTooltip = document.getElementById("volTooltip");
if(volTooltip && volTooltip.classList.contains("show")) {
volTooltip.textContent = Math.round(v * 100) + "%";
// Slider thumb is 14px wide, so track range is (sliderWidth - 14px)
// Thumb center at v=0 is at 7px, at v=1 is at (sliderWidth - 7px)
const sliderWidth = volSlider.offsetWidth;
const thumbRadius = 7;
const trackRange = sliderWidth - thumbRadius * 2;
const thumbCenter = thumbRadius + v * trackRange;
// Add offset for: padding(10) + icon(14) + gap(10)
volTooltip.style.left = (10 + 14 + 10 + 8 + thumbCenter) + "px";
}
});
volSlider.addEventListener("mousedown", ()=>{
volSlider.dragging=true;
const volTooltip = document.getElementById("volTooltip");
if(volTooltip) {
const v = clamp(Number(volSlider.value||1.0),0,1);
volTooltip.textContent = Math.round(v * 100) + "%";
const sliderWidth = volSlider.offsetWidth;
const thumbRadius = 7;
const trackRange = sliderWidth - thumbRadius * 2;
const thumbCenter = thumbRadius + v * trackRange;
volTooltip.style.left = (10 + 14 + 10 + 8 + thumbCenter) + "px";
volTooltip.classList.add("show");
}
});
volSlider.addEventListener("touchstart", ()=>{
volSlider.dragging=true;
const volTooltip = document.getElementById("volTooltip");
if(volTooltip) {
const v = clamp(Number(volSlider.value||1.0),0,1);
volTooltip.textContent = Math.round(v * 100) + "%";
const sliderWidth = volSlider.offsetWidth;
const thumbRadius = 7;
const trackRange = sliderWidth - thumbRadius * 2;
const thumbCenter = thumbRadius + v * trackRange;
volTooltip.style.left = (10 + 14 + 10 + 8 + thumbCenter) + "px";
volTooltip.classList.add("show");
}
});
volSlider.addEventListener("change", async ()=>{
volSlider.dragging=false;
const volTooltip = document.getElementById("volTooltip");
if(volTooltip) volTooltip.classList.remove("show");
updateVolFill();
if(!library) return;
try{
const v=clamp(Number(volSlider.value||1.0),0,1);
await window.pywebview.api.set_folder_volume(v);
library.folder_volume=v; updateInfoPanel();
}catch(e){}
});
window.addEventListener("mouseup", ()=>{
volSlider.dragging=false;
const volTooltip = document.getElementById("volTooltip");
if(volTooltip) volTooltip.classList.remove("show");
});
window.addEventListener("touchend", ()=>{
volSlider.dragging=false;
const volTooltip = document.getElementById("volTooltip");
if(volTooltip) volTooltip.classList.remove("show");
});
const SPEEDS=[0.5,0.75,1.0,1.25,1.5,1.75,2.0];
function closeSpeedMenu(){ speedMenu.classList.remove("show"); }
function openSpeedMenu(){ speedMenu.classList.add("show"); }
function buildSpeedMenu(active){
speedMenu.innerHTML="";
for(const s of SPEEDS){
const row=document.createElement("div");
row.className="speedItem"+(Math.abs(s-active)<0.0001?" active":"");
row.setAttribute("role","menuitem");
const left=document.createElement("div");
left.style.display="flex"; left.style.alignItems="center"; left.style.gap="10px";
const dot=document.createElement("div"); dot.className="dot";
const txt=document.createElement("div");
txt.textContent=`${s.toFixed(2)}x`;
left.appendChild(dot); left.appendChild(txt);
row.appendChild(left);
row.onclick=async ()=>{
closeSpeedMenu();
const r=clamp(Number(s),0.25,3);
player.playbackRate=r;
if(library) library.folder_rate=r;
speedBtnText.textContent=`${r.toFixed(2)}x`;
updateSpeedIcon(r);
buildSpeedMenu(r); updateInfoPanel();
suppressTick=true;
if(library){ try{ await window.pywebview.api.set_folder_rate(r); }catch(e){} }
suppressTick=false;
};
speedMenu.appendChild(row);
}
}
speedBtn.addEventListener("click",(e)=>{
e.preventDefault(); e.stopPropagation();
if(speedMenu.classList.contains("show")) closeSpeedMenu();
else openSpeedMenu();
});
window.addEventListener("click", ()=>{ closeSpeedMenu(); });
speedMenu.addEventListener("click",(e)=>e.stopPropagation());
seek.addEventListener("input", ()=>{
seeking=true;
const d=player.duration||0;
if(d>0){
const t=(Number(seek.value)/1000)*d;
timeNow.textContent=fmtTime(t);
}
updateSeekFill();
});
seek.addEventListener("change", ()=>{
const d=player.duration||0;
if(d>0){
const t=(Number(seek.value)/1000)*d;
try{ player.currentTime=t; }catch(e){}
}
seeking=false;
updateSeekFill();
});
const seekFill = document.getElementById("seekFill");
function updateSeekFill(){
if(seekFill){
const pct = (Number(seek.value) / 1000) * 100;
seekFill.style.width = pct + "%";
}
}
player.addEventListener("timeupdate", ()=>{
if(!seeking){
const d=player.duration||0, t=player.currentTime||0;
if(d>0) seek.value=String(Math.round((t/d)*1000));
updateSeekFill();
updateTimeReadout();
}
updateInfoPanel();
});
player.addEventListener("loadedmetadata", async ()=>{
const d=player.duration||0;
timeDur.textContent=d?fmtTime(d):"00:00";
await refreshCurrentVideoMeta();
});
player.addEventListener("ended", ()=>nextPrev(+1));
player.addEventListener("play", ()=>{
updatePlayPauseIcon();
updateVideoOverlay();
});
player.addEventListener("pause", ()=>{
updatePlayPauseIcon();
updateVideoOverlay();
});
// Video overlay play/pause
const videoOverlay = document.getElementById("videoOverlay");
const overlayIcon = document.getElementById("overlayIcon");
const overlayIconI = document.getElementById("overlayIconI");
let overlayHideTimer = null;
function updateVideoOverlay(){
if(!overlayIcon || !overlayIconI) return;
const paused = player.paused;
if(paused){
// Show play icon when paused
overlayIconI.className = "fa-solid fa-play";
overlayIcon.classList.remove("pause");
overlayIcon.classList.add("show");
} else {
// Hide when playing
overlayIcon.classList.remove("show");
}
}
// Show pause icon on hover when playing
if(videoOverlay){
const videoWrap = videoOverlay.parentElement;
videoWrap.addEventListener("mouseenter", ()=>{
if(!player.paused && overlayIcon){
overlayIconI.className = "fa-solid fa-pause";
overlayIcon.classList.add("pause");
overlayIcon.classList.add("show");
}
});
videoWrap.addEventListener("mouseleave", ()=>{
if(!player.paused && overlayIcon){
overlayIcon.classList.remove("show");
}
});
// Allow clicking overlay to toggle play/pause
videoOverlay.style.pointerEvents = "auto";
videoOverlay.style.cursor = "pointer";
videoOverlay.addEventListener("click", ()=>{
if(player.paused){
player.play();
} else {
player.pause();
}
// Pulse animation
overlayIcon.classList.add("pulse");
setTimeout(()=> overlayIcon.classList.remove("pulse"), 400);
});
}
// Initial state
setTimeout(updateVideoOverlay, 100);
function ensureSubtitleTrack(){
if(!subtitleTrackEl){
subtitleTrackEl = document.createElement("track");
subtitleTrackEl.kind = "subtitles";
subtitleTrackEl.label = "Subtitles";
subtitleTrackEl.srclang = "en";
subtitleTrackEl.default = true;
player.appendChild(subtitleTrackEl);
}
}
async function refreshSubtitles(){
ensureSubtitleTrack();
try{
const res=await window.pywebview.api.get_current_subtitle();
if(res && res.ok && res.has){
subtitleTrackEl.src = res.url;
subtitleTrackEl.label = res.label || "Subtitles";
// Attempt to show
setTimeout(()=>{
try{
if(player.textTracks && player.textTracks.length>0){
for(const tt of player.textTracks){
if(tt.kind==="subtitles") tt.mode="showing";
}
}
}catch(e){}
}, 50);
notify("Subtitles loaded.");
}else{
subtitleTrackEl.src = "";
}
}catch(e){}
}
let subsMenuOpen = false;
function closeSubsMenu(){
subsMenuOpen = false;
subsMenu.classList.remove("show");
}
async function openSubsMenu(){
if(!library) return;
subsMenu.innerHTML = "";
try{
const available = await window.pywebview.api.get_available_subtitles();
if(available && available.ok){
// Sidecar subtitle files
if(available.sidecar && available.sidecar.length > 0){
const header = document.createElement("div");
header.className = "subsMenuHeader";
header.textContent = "External Files";
subsMenu.appendChild(header);
for(const sub of available.sidecar){
const item = document.createElement("div");
item.className = "subsMenuItem";
item.innerHTML = `<i class="fa-solid fa-file-lines"></i> ${sub.label} <span style="opacity:.5;font-size:10px;margin-left:auto;">${sub.format}</span>`;
item.onclick = async ()=>{
closeSubsMenu();
const res = await window.pywebview.api.load_sidecar_subtitle(sub.path);
if(res && res.ok && res.url){
applySubtitle(res.url, res.label);
notify("Subtitles loaded.");
}else{
notify(res?.error || "Failed to load subtitle");
}
};
subsMenu.appendChild(item);
}
}
// Embedded subtitle tracks
if(available.embedded && available.embedded.length > 0){
if(available.sidecar && available.sidecar.length > 0){
const div = document.createElement("div");
div.className = "subsDivider";
subsMenu.appendChild(div);
}
const header = document.createElement("div");
header.className = "subsMenuHeader";
header.textContent = "Embedded Tracks";
subsMenu.appendChild(header);
for(const track of available.embedded){
const item = document.createElement("div");
item.className = "subsMenuItem embedded";
item.innerHTML = `<i class="fa-solid fa-film"></i> ${track.label} <span style="opacity:.5;font-size:10px;margin-left:auto;">${track.codec}</span>`;
item.onclick = async ()=>{
closeSubsMenu();
const res = await window.pywebview.api.extract_embedded_subtitle(track.index);
if(res && res.ok && res.url){
applySubtitle(res.url, res.label);
notify("Embedded subtitle loaded.");
}else{
notify(res?.error || "Failed to extract subtitle");
}
};
subsMenu.appendChild(item);
}
}
// Add divider if we had any subs
if((available.sidecar && available.sidecar.length > 0) || (available.embedded && available.embedded.length > 0)){
const div = document.createElement("div");
div.className = "subsDivider";
subsMenu.appendChild(div);
}
}
}catch(e){}
// Add "Load from file" option
const loadItem = document.createElement("div");
loadItem.className = "subsMenuItem";
loadItem.innerHTML = '<i class="fa-solid fa-file-import"></i> Load from file...';
loadItem.onclick = async ()=>{
closeSubsMenu();
try{
const res = await window.pywebview.api.choose_subtitle_file();
if(res && res.ok && res.url){
applySubtitle(res.url, res.label);
notify("Subtitles loaded.");
}else if(res && res.error){
notify(res.error);
}
}catch(e){}
};
subsMenu.appendChild(loadItem);
// Add "Disable" option if subtitles are active
const disableItem = document.createElement("div");
disableItem.className = "subsMenuItem";
disableItem.innerHTML = '<i class="fa-solid fa-xmark"></i> Disable subtitles';
disableItem.onclick = ()=>{
closeSubsMenu();
try{
if(player.textTracks){
for(const tt of player.textTracks){
tt.mode = "hidden";
}
}
notify("Subtitles disabled.");
}catch(e){}
};
subsMenu.appendChild(disableItem);
subsMenu.classList.add("show");
subsMenuOpen = true;
}
function applySubtitle(url, label){
ensureSubtitleTrack();
subtitleTrackEl.src = url;
subtitleTrackEl.label = label || "Subtitles";
setTimeout(()=>{
try{
if(player.textTracks && player.textTracks.length>0){
for(const tt of player.textTracks){
if(tt.kind==="subtitles") tt.mode="showing";
}
}
}catch(e){}
}, 50);
}
function clearSubtitles(){
try{
// Hide all text tracks
if(player.textTracks && player.textTracks.length>0){
for(const tt of player.textTracks){
tt.mode = "hidden";
}
}
// Clear subtitle track source
if(subtitleTrackEl){
subtitleTrackEl.src = "";
}
}catch(e){}
}
subsBtn.addEventListener("click", async (e)=>{
e.stopPropagation();
if(!library) return;
if(subsMenuOpen){
closeSubsMenu();
}else{
await openSubsMenu();
}
});
window.addEventListener("click", ()=>{ if(subsMenuOpen) closeSubsMenu(); });
subsMenu.addEventListener("click", (e)=>e.stopPropagation());
async function chooseFolder(){
closeRecentMenu();
const info=await window.pywebview.api.select_folder();
if(!info||!info.ok) return;
await onLibraryLoaded(info,true);
notify("Folder loaded.");
}
async function onLibraryLoaded(info,startScan){
library=info;
currentIndex=library.current_index||0;
// Clear any active subtitles
clearSubtitles();
const v=clamp(Number(library.folder_volume ?? 1.0),0,1);
suppressTick=true; player.volume=v; volSlider.value=String(v); suppressTick=false;
updateVolFill();
const r=clamp(Number(library.folder_rate ?? 1.0),0.25,3);
player.playbackRate=r;
speedBtnText.textContent=`${r.toFixed(2)}x`;
updateSpeedIcon(r);
buildSpeedMenu(r);
autoplayChk.checked=!!library.folder_autoplay;
updateOverall(); renderList(); updateInfoPanel();
if(startScan){ try{ await window.pywebview.api.start_duration_scan(); }catch(e){} }
await loadIndex(currentIndex, Number(library.current_time||0.0), true, false);
}
async function reloadLibrary(){
const info=await window.pywebview.api.get_library();
if(!info||!info.ok) return;
await onLibraryLoaded(info,false);
notify("Reloaded.");
}
function updateNowHeader(it){
nowTitle.textContent = it ? (it.title || it.name) : "No video loaded";
nowSub.textContent = it ? it.relpath : "-";
}
async function loadIndex(idx,timecode=0.0,pauseAfterLoad=true,autoplayOnLoad=false){
if(!library||!library.items||library.items.length===0) return;
idx=Math.max(0, Math.min(idx, library.items.length-1));
currentIndex=idx;
const keepVol=clamp(Number(library.folder_volume ?? player.volume ?? 1.0),0,1);
const keepRate=clamp(Number(library.folder_rate ?? player.playbackRate ?? 1.0),0.25,3);
suppressTick=true;
player.src=`/video/${currentIndex}`;
player.load();
const it=library.items[currentIndex]||null;
updateNowHeader(it);
await window.pywebview.api.set_current(currentIndex, Number(timecode||0.0));
renderList(); updateInfoPanel();
await loadNoteForCurrent();
await refreshCurrentVideoMeta();
player.onloadedmetadata = async ()=>{
try{ player.volume=keepVol; volSlider.value=String(keepVol); updateVolFill(); }catch(e){}
try{ player.playbackRate=keepRate; }catch(e){}
speedBtnText.textContent=`${keepRate.toFixed(2)}x`;
updateSpeedIcon(keepRate);
buildSpeedMenu(keepRate);
try{ const t=Number(timecode||0.0); if(t>0) player.currentTime=t; }catch(e){}
// subtitle check (auto sidecar + saved)
await refreshSubtitles();
if(autoplayOnLoad){
try{ await player.play(); }catch(e){}
}else if(pauseAfterLoad){
player.pause();
}
suppressTick=false;
updateTimeReadout(); updatePlayPauseIcon(); updateInfoPanel();
await refreshCurrentVideoMeta();
};
}
function nextPrev(delta){
if(!library||!library.items||library.items.length===0) return;
const newIdx=Math.max(0, Math.min(currentIndex+delta, library.items.length-1));
const it=library.items[newIdx];
const auto=!!library.folder_autoplay;
loadIndex(newIdx, computeResumeTime(it), !auto, auto);
}
chooseBtn.onclick=chooseFolder;
refreshBtn.onclick=reloadLibrary;
async function tick(){
const now=Date.now();
if(now-lastTick<950) return;
lastTick=now;
if(library && !suppressTick){
const t=player.currentTime||0.0;
const d=Number.isFinite(player.duration)?player.duration:null;
const playing=!player.paused && !player.ended;
try{ await window.pywebview.api.tick_progress(currentIndex,t,d,playing); }catch(e){}
}
if(now % 3000 < 1000){
try{
const info=await window.pywebview.api.get_library();
if(info && info.ok){
const oldIndex = currentIndex;
const oldCount = library?.items?.length || 0;
library=info; currentIndex=library.current_index||currentIndex;
autoplayChk.checked=!!library.folder_autoplay;
const v=clamp(Number(library.folder_volume ?? player.volume ?? 1.0),0,1);
if(!volSlider.dragging && Math.abs(v-Number(volSlider.value))>0.001){ volSlider.value=String(v); updateVolFill(); }
const r=clamp(Number(library.folder_rate ?? player.playbackRate ?? 1.0),0.25,3);
player.playbackRate=r;
speedBtnText.textContent=`${r.toFixed(2)}x`;
updateSpeedIcon(r);
buildSpeedMenu(r);
updateOverall(); updateInfoPanel(); updateNowHeader(currentItem());
// Only re-render list if index changed or item count changed
if(oldIndex !== currentIndex || oldCount !== (library?.items?.length || 0)){
renderList();
}
}
}catch(e){}
}
try{ await window.pywebview.api.save_window_state(); }catch(e){}
}
setInterval(tick, 250);
async function boot(){
ensureDropdownPortal();
const pres=await window.pywebview.api.get_prefs();
prefs=(pres && pres.ok) ? (pres.prefs||{}) : {};
prefs.ui_zoom=applyZoom(prefs.ui_zoom||1.0);
prefs.split_ratio=applySplit(prefs.split_ratio||0.62);
prefs.dock_ratio=applyDockSplit(prefs.dock_ratio||0.62);
onTopChk.checked=!!prefs.always_on_top;
await savePrefsPatch({
ui_zoom:prefs.ui_zoom,
split_ratio:prefs.split_ratio,
dock_ratio:prefs.dock_ratio,
always_on_top:!!prefs.always_on_top
});
const info=await window.pywebview.api.get_library();
if(info && info.ok){
await onLibraryLoaded(info,true);
notify("Ready.");
return;
}
updateOverall(); updateTimeReadout(); updatePlayPauseIcon(); updateInfoPanel();
buildSpeedMenu(1.0);
notify("Open a folder to begin.");
}
// Fancy tooltip system
(function initTooltips(){
const fancyTooltip = document.getElementById("fancyTooltip");
if(!fancyTooltip) return;
const tooltipTitle = fancyTooltip.querySelector(".tooltip-title");
const tooltipDesc = fancyTooltip.querySelector(".tooltip-desc");
let showTimeout = null;
let hideTimeout = null;
let currentEl = null;
function getZoom(){
const z = getComputedStyle(document.documentElement).getPropertyValue('--zoom');
return parseFloat(z) || 1;
}
function positionTooltip(el){
const zoom = getZoom();
const margin = 16;
fancyTooltip.style.transform = `scale(${zoom})`;
fancyTooltip.style.transformOrigin = 'top left';
const rect = el.getBoundingClientRect();
const tipRect = fancyTooltip.getBoundingClientRect();
const tipW = tipRect.width;
const tipH = tipRect.height;
let left = rect.left + rect.width/2 - tipW/2;
let top = rect.bottom + 8;
if(left < margin) left = margin;
if(left + tipW > window.innerWidth - margin) left = window.innerWidth - tipW - margin;
if(top + tipH > window.innerHeight - margin){
top = rect.top - tipH - 8;
fancyTooltip.style.transformOrigin = 'bottom left';
} else {
fancyTooltip.style.transformOrigin = 'top left';
}
if(top < margin) top = margin;
fancyTooltip.style.left = left + "px";
fancyTooltip.style.top = top + "px";
}
function showFancyTooltip(el){
const title = el.dataset.tooltip || "";
const desc = el.dataset.tooltipDesc || "";
if(!title) return;
// Cancel any pending hide
clearTimeout(hideTimeout);
hideTimeout = null;
// Update content
tooltipTitle.textContent = title;
tooltipDesc.textContent = desc;
positionTooltip(el);
currentEl = el;
// If already visible, we're done (instant switch)
if(fancyTooltip.classList.contains("visible")){
return;
}
// Otherwise, delay before showing
clearTimeout(showTimeout);
showTimeout = setTimeout(()=>{
fancyTooltip.classList.add("visible");
}, 250);
}
function hideFancyTooltip(){
clearTimeout(showTimeout);
showTimeout = null;
// Small delay before hiding to allow moving to another tooltip element
hideTimeout = setTimeout(()=>{
fancyTooltip.classList.remove("visible");
currentEl = null;
}, 80);
}
document.querySelectorAll("[data-tooltip]").forEach(el=>{
el.addEventListener("mouseenter", ()=>showFancyTooltip(el));
el.addEventListener("mouseleave", hideFancyTooltip);
el.addEventListener("mousedown", ()=>{
clearTimeout(showTimeout);
clearTimeout(hideTimeout);
fancyTooltip.classList.remove("visible");
currentEl = null;
});
});
})();
window.addEventListener("pywebviewready", ()=>{ boot(); });
</script>
</body>
</html>
"""
# =============================================================================
# Application Entry Point
# =============================================================================
def main() -> None:
"""Initialize and run the TutorialDock application."""
# Download and cache fonts for offline use (fail silently)
try:
ensure_google_fonts_local()
except Exception:
pass
try:
ensure_fontawesome_local()
except Exception:
pass
# Restore last opened folder if it exists
prefs = PREFS.get()
last_path = prefs.get("last_folder_path")
if isinstance(last_path, str) and last_path.strip():
try:
if Path(last_path).expanduser().exists():
LIB.set_root(last_path)
except Exception:
pass
# Start Flask server on a free port
port = pick_free_port(HOST)
flask_thread = threading.Thread(target=run_flask, args=(port,), daemon=True)
flask_thread.start()
# Get window dimensions from saved preferences
window_prefs = prefs.get("window", {}) if isinstance(prefs.get("window"), dict) else {}
width = int(window_prefs.get("width") or 1320)
height = int(window_prefs.get("height") or 860)
x = window_prefs.get("x")
y = window_prefs.get("y")
on_top = bool(prefs.get("always_on_top", False))
# Create and start the webview window
api = API()
webview.create_window(
"TutorialDock",
url=f"http://{HOST}:{port}/",
width=width,
height=height,
x=x if isinstance(x, int) else None,
y=y if isinstance(y, int) else None,
on_top=on_top,
js_api=api,
)
webview.start(debug=False)
if __name__ == "__main__":
main()