5282 lines
196 KiB
Python
5282 lines
196 KiB
Python
#!/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() |