Compare commits

...

23 Commits

Author SHA1 Message Date
Your Name
58847c018f docs: strip emojis from README list items, keep only in headers 2026-02-19 18:29:29 +02:00
Your Name
c0a8eca955 fix: WCAG AAA contrast compliance, speed menu z-index, custom app icon
- Fix all text colors to meet WCAG 2.2 AAA 7:1 contrast ratios against
  dark backgrounds (--textMuted, --textDim, hover states across playlist,
  player, panels, tooltips)
- Fix speed menu rendering behind seek bar by correcting z-index stacking
  context (.controls z-index:10, .miniCtl z-index:3, .seek z-index:2)
- Replace default Tauri icons with custom TutorialVault icon across all
  required sizes (32-512px PNGs, ICO, ICNS, Windows Square logos)
- Update README: Fraunces → Bricolage Grotesque font reference
- Add collapsible dock pane persistence and keyboard-adjustable dividers
2026-02-19 18:23:38 +02:00
Your Name
a571a33415 docs: update README with all recent features and changes
- Add new playback features: mute toggle, PiP, fullscreen, seek feedback, error overlay
- Expand keyboard shortcuts table with F, M, [, ], ? bindings
- Add playlist search/filter, scroll-to-current, mini progress bars
- Add timestamp insertion for notes
- Add two-click reset confirmation, collapsible dock panes, custom titlebar
- Add full accessibility section (WCAG 2.2 AAA)
- Add architecture module breakdown and Cold Open design theme details
2026-02-19 17:10:33 +02:00
Your Name
1b2ebd807c chore: update fonts, Tauri config, and reduced-motion support
- Switch font deps from Sora/Manrope/IBM Plex Mono to Fraunces/Inter/Space Mono (Cold Open theme)
- Add Tauri window permissions for custom titlebar controls
- Disable native decorations and drag-drop in Tauri config
- Add prefers-reduced-motion media query for WCAG compliance
2026-02-19 17:03:39 +02:00
Your Name
715c3c713a feat: add 15 UI enhancements
1. Mute toggle (M key + volume icon click)
2. Fullscreen shortcut (F key)
3. Seek feedback overlay (±5s flash with accumulation)
4. Playlist search/filter with clear button
5. Scroll-to-current button (IntersectionObserver)
6. Picture-in-Picture button
7. Timestamp insertion in notes
8. Keyboard shortcut help panel (? key)
9. Playback speed shortcuts ([ and ] keys)
10. Reset progress two-click confirmation
11. Video load error state overlay
12. Double-click video to fullscreen
13. Playlist stats in header (count + done)
14. Mini progress bars per playlist item
15. Collapsible dock panes with chevron icons

All enhancements are WCAG 2.2 AAA compliant with proper
aria-labels, aria-live regions, focus-visible states,
keyboard accessibility, and 44x44 touch targets.
2026-02-19 17:01:01 +02:00
Your Name
98011cf604 style: remove controls container and bump surface contrast
Strip background/border-radius from .controlsStrip so playback
controls float freely. Roughly double all --surface opacity values
for better element visibility against the dark slate background.
2026-02-19 16:54:12 +02:00
Your Name
c2533e8a76 docs: add design for 15 UI enhancements
Mute toggle, fullscreen shortcut, seek feedback overlay, playlist
search, scroll-to-current, PiP, timestamp insertion, keyboard help,
speed shortcuts, reset confirmation, error state, double-click
fullscreen, playlist stats, per-item progress bars, collapsible docks.
2026-02-19 16:51:54 +02:00
Your Name
a6f1aef489 fix: reset heading margins and button chrome from semantic HTML changes
The WCAG pass changed <div> to <h3> for dock headers and <span> to
<button> for the zoom reset label. Browser defaults for those elements
(heading margins, button border/background) were never overridden,
causing bloated headers and a Windows-XP-style zoom button.
2026-02-19 16:40:39 +02:00
Your Name
cd362a29b1 a11y: bring UI to WCAG 2.2 AAA compliance
Semantic HTML: lang attr, landmarks (header/main/region/complementary),
heading hierarchy (h1-h3), dl/dt/dd for info panel.

ARIA: labels on all icon buttons, aria-hidden on decorative icons,
progressbar role with dynamic aria-valuenow, aria-haspopup/expanded
on all menu triggers, role=listbox/option on playlist, aria-selected,
computed aria-labels on playlist rows.

Contrast: raised --textMuted/--textDim/--icon to AAA 7:1 ratios.

Focus: global :focus-visible outline, slider thumb glow, menu item
highlight, switch focus-within, row focus styles.

Target sizes: 44x44 hit areas on zoom/window/remove buttons via
::before pseudo-elements.

Keyboard: playlist arrow nav + Enter/Space activate + Alt+Arrow
reorder with live region announcements + move buttons. Speed menu,
subtitles menu, and recent menu all keyboard-navigable with
Arrow/Enter/Space/Escape. Dividers resizable via Arrow keys.

Dynamic document.title updates on video/folder load.
2026-02-19 16:35:19 +02:00
Your Name
600188eb1a docs: add WCAG 2.2 AAA implementation plan 2026-02-19 16:00:19 +02:00
Your Name
17e4ffd28f docs: add WCAG 2.2 AAA remediation design 2026-02-19 15:54:04 +02:00
Your Name
f3aa5f7937 Move and expand philosophy section 2026-02-19 12:58:55 +02:00
Your Name
1579e146b5 Replace fancy dashes and quotes with plain ASCII 2026-02-19 12:56:30 +02:00
Your Name
17b57dbab0 Fancy up README header with badges 2026-02-19 12:54:43 +02:00
Your Name
8860188055 Add emoji to README 2026-02-19 12:53:50 +02:00
Your Name
26ff5cd95b Add README 2026-02-19 12:52:21 +02:00
Your Name
9c8d7d94cd TutorialVault: complete Tauri v2 port with runtime fixes
Rename from TutorialDock to TutorialVault. Remove legacy Python app
and scripts. Fix video playback, subtitles, metadata display, window
state persistence, and auto-download of ffmpeg/ffprobe on first run.
Bundle fonts via npm instead of runtime download.
2026-02-19 12:44:57 +02:00
Your Name
a459efae45 feat: implement all frontend TypeScript modules
Create player.ts (video controls, seek, volume, speed, overlay),
playlist.ts (list rendering, tree SVG, drag-and-drop reorder, scrollbar),
subtitles.ts (subtitle menu, track management, sidecar/embedded),
ui.ts (zoom, splits, info panel, notes, toasts, recent menu),
tooltips.ts (zoom-aware tooltip system with delays),
store.ts (shared state and utility functions), and
main.ts (boot sequence, tick loop, keyboard shortcuts).

All modules compile with strict TypeScript. Vite build produces
34KB JS + 41KB CSS. 115 Rust tests pass.
2026-02-19 11:44:48 +02:00
Your Name
4e454084a8 feat: implement video_protocol.rs, commands.rs, wire up main.rs, and index.html
- video_protocol.rs: tutdock:// custom protocol with HTTP Range support
  for video streaming, subtitle/font serving with path traversal protection
- commands.rs: all 26 Tauri command handlers as thin wrappers
- main.rs: full Tauri bootstrap with state management, window restore,
  async font caching, and ffmpeg discovery
- index.html: complete HTML markup extracted from Python app
- lib.rs: updated with all module declarations and AppPaths struct
2026-02-19 11:23:37 +02:00
Your Name
9c8474d24f feat: implement library.rs, types.ts, api.ts, and extract CSS
- library.rs: full video library management (1948 lines, 10 tests)
  folder scanning, progress tracking, playlists, subtitle integration,
  background duration scanning
- types.ts: all TypeScript interfaces for Tauri command responses
- api.ts: typed wrappers for all 26 Tauri invoke commands
- 6 CSS files extracted from Python HTML into src/styles/
2026-02-19 02:08:23 +02:00
Your Name
4e91fe679f feat: implement ffmpeg.rs, subtitles.rs, and fonts.rs
- ffmpeg.rs: discovery, duration extraction, metadata probing, download
- subtitles.rs: SRT-to-VTT conversion, sidecar discovery, storage, extraction
- fonts.rs: Google Fonts and Font Awesome local caching
2026-02-19 01:59:21 +02:00
Your Name
3280d60f71 feat: implement recents.rs - recent folders management 2026-02-19 01:50:28 +02:00
Your Name
b95094c50f feat: implement prefs.rs - preferences with save/load/update 2026-02-19 01:50:20 +02:00
63 changed files with 23891 additions and 7303 deletions

297
README.md Normal file
View File

@@ -0,0 +1,297 @@
<div align="center">
# 🎓 TutorialVault
### *Your tutorials. Your progress. Your machine. Nothing else.*
[![License: CC0](https://img.shields.io/badge/license-CC0_1.0-blue.svg)](https://creativecommons.org/publicdomain/zero/1.0/)
[![Platform: Windows](https://img.shields.io/badge/platform-Windows-0078D4.svg?logo=windows)](https://www.microsoft.com/windows)
[![Built with Tauri](https://img.shields.io/badge/built_with-Tauri_v2-FFC131.svg?logo=tauri)](https://v2.tauri.app/)
[![Rust](https://img.shields.io/badge/backend-Rust-B7410E.svg?logo=rust)](https://www.rust-lang.org/)
[![TypeScript](https://img.shields.io/badge/frontend-TypeScript-3178C6.svg?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
[![No Telemetry](https://img.shields.io/badge/telemetry-none-2ea44f.svg)](#-philosophy)
[![Portable](https://img.shields.io/badge/install-portable-8B5CF6.svg)](#-portability)
A desktop application for organizing, watching, and tracking progress through video tutorial courses. Built because learning should be frictionless, untracked, and entirely under your control.
**No accounts. No telemetry. No cloud. No subscriptions.**
**Everything lives on your machine, next to the executable, owned by you.**
</div>
---
## What It Does
TutorialVault turns any folder of video files into a structured course with automatic progress tracking, subtitle support, per-video notes, and detailed media information. Point it at a folder, and it handles the rest.
It was built for people who collect tutorial courses and want a better way to work through them than a file explorer and a media player. Your progress, your notes, your preferences - all saved automatically, all portable, all yours.
---
## 🌱 Philosophy
Knowledge has no natural owner. Someone figures something out, shares it, and suddenly everyone who encounters it is a little more capable than before. That's how it's supposed to work. The history of human progress is a history of people teaching each other, freely, because a skill shared is a skill multiplied - not divided.
Somewhere along the way, learning got enclosed. Platforms decided that access to knowledge should require accounts, tracking, monthly fees, and behavioral analytics. They turned education into a product and learners into metrics. Your watch history became someone else's data. Your pace became an engagement number. The relationship between you and what you're learning got intermediated by companies whose interests have nothing to do with yours.
TutorialVault rejects all of that.
There's no tracking here. No analytics. No accounts. No servers collecting your habits. The app doesn't know who you are, doesn't care, and couldn't report on you even if someone asked. Your learning is private by architecture, not by policy - there is simply nowhere for the data to go except the folder sitting next to the executable on your own machine.
It's self-contained on purpose. It doesn't phone home. It doesn't sync to a cloud. It doesn't require an internet connection after the initial setup. It carries everything it needs with it. Copy it to a USB drive and it works anywhere, for anyone, without permission from anyone. The tool belongs to whoever holds it.
This isn't an accident or a limitation - it's the point. Tools should serve the people using them, not extract value from them. Software doesn't need to monetize your attention or harvest your behavior to be useful. It just needs to do what you need it to do and then get out of the way.
Education is too important to gate behind paywalls and surveillance. If you have the material and you want to learn from it, that should be enough. No intermediary needed. No permission required. The knowledge is already there - all you need is a decent tool to engage with it.
This is that tool.
---
## Features
### 📂 Library Management
**Open any folder as a course.** TutorialVault recursively scans a folder and all its subdirectories for video files, organizing them into a navigable playlist. Supported formats include MP4, MKV, WebM, AVI, MOV, MPEG, M2TS, and OGV.
**Recent folders.** A dropdown menu keeps track of folders you've opened before. Click any entry to jump back to it instantly. Remove entries you no longer need.
**Automatic restore.** The app remembers the last folder you had open and automatically loads it on startup, resuming exactly where you left off. No setup, no re-navigation.
**Smart file identity.** Files are identified by their content, not their path. Rename or reorganize files within the folder and your progress follows them.
**Subfolder awareness.** When your course has subdirectories (chapters, sections, modules), TutorialVault detects the structure and displays a visual tree in the playlist, making it easy to see where you are in a large course.
---
### ▶️ Video Playback
**Full-featured player.** Play, pause, seek, adjust volume, change playback speed, and go fullscreen. All the controls you'd expect, with a clean minimal interface that stays out of your way.
**Playback speed.** Presets from 0.50x to 2.00x, with a visual speedometer that changes color based on your current rate. Speed is saved per folder, so your lecture courses stay at 1.5x while your drawing tutorials stay at 1x. Cycle through speeds with keyboard shortcuts.
**Volume control.** A slider with a live percentage tooltip. Click the volume icon to mute/unmute instantly. Volume is saved per folder as well.
**Picture-in-Picture.** Pop the video out into a floating window so you can follow along while working in another application. The PiP button appears automatically when your browser supports it.
**Fullscreen.** Double-click the video or press F to toggle fullscreen. The player remembers your position and controls remain accessible.
**Seek feedback.** When skipping forward or backward, a brief overlay shows the accumulated seek amount (+5s, -10s, etc.) so you always know how far you've jumped.
**Error handling.** If a video format isn't supported by the player, an error overlay appears with a clear message and a "Try next" button to skip to the next video without interrupting your session.
**Keyboard shortcuts:**
| Key | Action |
|-----|--------|
| Space | Play / Pause |
| Left Arrow | Skip back 5 seconds |
| Right Arrow | Skip forward 5 seconds |
| Up Arrow | Volume up 5% |
| Down Arrow | Volume down 5% |
| F | Toggle fullscreen |
| M | Mute / Unmute |
| [ | Decrease playback speed |
| ] | Increase playback speed |
| ? | Show keyboard shortcut help |
Press **?** at any time to open a help dialog listing all available shortcuts.
---
### 📊 Progress Tracking
**Automatic position saving.** Your playback position is saved continuously as you watch. Close the app, come back tomorrow, and you're right where you stopped.
**High-water mark tracking.** TutorialVault remembers the furthest point you've reached in each video, independent of where you happen to be scrubbing to.
**Automatic completion detection.** When you watch to within 2 seconds of the end, a video is marked as done. Once done, it stays done - no accidental un-finishing.
**Overall progress.** A progress bar shows your completion percentage for the entire folder, calculated from actual watched time relative to total duration. Not just "videos finished out of total" - actual time-weighted progress.
**Resume from where you left off.** Clicking any video in the playlist, or navigating with previous/next, resumes from your last position. Finished videos start from the beginning.
**Progress reset.** If you want to go through a course again from scratch, a reset button clears all watch progress while preserving your notes, subtitles, volume, and speed settings. Uses a two-click confirmation to prevent accidental resets -- the first click arms the button (it turns red with a warning icon), and the second click within 3 seconds actually resets. If you change your mind, just wait or click elsewhere.
**Mini progress bars.** Each item in the playlist shows a thin progress bar at the bottom of its row, visualizing how far you've watched. Completed videos get a green bar; in-progress videos show the accent color proportional to watched time.
---
### 🗒️ Playlist
**Visual playlist.** Every video in the folder is listed with its title, watched time, total duration, and status tags. The currently playing video is marked "Now," and completed videos are marked "Done."
**Tree view.** Courses with subdirectories get a visual tree with SVG connectors showing the hierarchy. Lines, corners, and dots show parent-child relationships at a glance.
**Drag and drop reorder.** Rearrange the playlist by dragging items. A blue indicator line shows exactly where an item will land. Your custom order is saved and persists between sessions.
**Smart ordering.** Natural sort by default - "Lesson 2" comes before "Lesson 10." When you reopen a folder that has new files, existing items keep their saved order and new files appear at the end.
**Search and filter.** A search field in the playlist header lets you filter videos by name in real time. Matches are case-insensitive and update the displayed list instantly. A clear button resets the filter. Statistics next to the header show total video count and how many are done (or "X of Y" when filtering).
**Scroll to current.** When the currently playing video scrolls out of view in a long playlist, a crosshair button appears in the header. Click it to scroll the active video back into view.
---
### 💬 Subtitles
**Automatic subtitle discovery.** TutorialVault finds subtitles in three ways, in this priority order:
1. **Previously loaded subtitle** - if you've loaded a subtitle for a video before, it's remembered and reloaded automatically.
2. **Sidecar files** - `.srt` or `.vtt` files sitting next to the video with a matching filename (including language variants like `video.en.srt` or `video.french.srt`).
3. **Embedded tracks** - subtitle tracks inside the video container itself (common in MKV files), extracted on demand using ffmpeg.
**Subtitle menu.** A CC button opens a menu listing all available subtitles - external files, embedded tracks, a "load from file" option for picking any subtitle file on your system, and a disable option.
**Language detection.** Sidecar subtitles with language suffixes are automatically labeled (English, French, German, Spanish, Italian, Portuguese, Russian, Japanese, Korean, Chinese, Arabic, Hindi, Dutch, Swedish, Polish). English subtitles are sorted first.
**Persistent selection.** Once a subtitle is loaded for a video, it's converted to VTT format, stored locally, and automatically reloaded every time you watch that video.
---
### 📝 Notes
**Per-video notes.** A text area in the dock panel lets you write notes for each video. Timestamps, reminders, key takeaways, questions - whatever you need. Notes auto-save as you type.
**Timestamp insertion.** Click the clock button in the notes header to insert the current video timestamp at your cursor position in `[M:SS]` format. Useful for marking specific moments while you watch.
**Visible in the playlist.** Videos with notes show a small indicator in the playlist, so you can quickly spot which videos you've annotated.
**Preserved on reset.** Resetting watch progress never touches your notes.
---
### 🔬 Media Information
**Detailed metadata panel.** The info panel shows everything about the current video and folder:
- **Folder info** - path, structure (flat or subfolder), next unfinished video.
- **Video info** - codec, resolution, frame rate, pixel format, bitrate.
- **Audio info** - codec, channels, sample rate, bitrate.
- **Subtitle info** - count and details of embedded tracks, or external file status.
- **File info** - extension, file size, modification date, containing folder.
- **Progress stats** - finished count, remaining count, estimated time remaining.
- **Per-subfolder breakdown** - completion counts for each top-level subdirectory.
All of this requires ffprobe, which TutorialVault will automatically download if it's not already available on your system.
---
### 🖥️ Interface
**Custom titlebar.** The app uses a custom-drawn titlebar with minimize, maximize, and close buttons, replacing the default OS chrome. The titlebar doubles as a drag region for moving the window and integrates the toolbar controls directly.
**Resizable panels.** Drag the divider between the video area and the playlist to adjust the split. Drag the divider between the notes and info panels too. Both ratios are saved. Dividers also support keyboard adjustment with arrow keys.
**Collapsible dock panes.** Click the Notes or Info panel header to collapse it, freeing up vertical space for the other pane. A chevron icon indicates the current state. Collapsed state is saved and restored between sessions.
**UI zoom.** Scale the entire interface from 75% to 200% with the zoom controls in the top bar. Useful for high-DPI displays or when you want more real estate for the video.
**Always on top.** A toggle to keep the window above all others. Handy when following along with a tutorial in another application.
**Autoplay.** When enabled, the next video starts playing automatically when the current one ends. Saved per folder.
**Custom scrollbar.** The playlist has a custom scrollbar that auto-hides when not in use, with fade indicators at the edges when there's more content to scroll.
**Tooltips.** Hover over any control for a two-line tooltip explaining what it does. Tooltips are zoom-aware and stay within the viewport.
**Toast notifications.** Brief confirmations appear at the bottom of the screen for actions like loading folders, resetting progress, and loading subtitles.
---
### ♿ Accessibility
TutorialVault targets WCAG 2.2 AAA compliance throughout the interface.
**Semantic HTML.** The interface uses proper semantic elements -- `<nav>`, `<main>`, `<header>`, `<section>`, `<h2>`, `<h3>` -- so screen readers can navigate the structure meaningfully.
**ARIA attributes.** All interactive elements have appropriate ARIA labels, roles, and state attributes. Menus use `role="menu"` with `aria-haspopup` and `aria-expanded`. The progress bar uses `role="progressbar"` with live value updates. The seek feedback overlay uses `aria-live="assertive"` for screen reader announcements.
**Full keyboard navigation.** Every control is reachable and operable via keyboard. Menus support arrow key navigation, Escape to close, and Enter/Space to activate. Dividers can be adjusted with arrow keys. The shortcut help dialog traps focus while open.
**Touch targets.** All interactive elements meet the 44x44px minimum target size. Buttons, checkboxes, and menu items are sized for comfortable interaction on all input methods.
**Focus indicators.** All focusable elements show a visible `focus-visible` ring (2px solid outline) when navigated via keyboard. Focus indicators use the accent color with sufficient contrast.
**Reduced motion.** The app respects `prefers-reduced-motion` at the OS level. When enabled, all animations and transitions are effectively disabled, ensuring the interface remains usable for people who are sensitive to motion.
**Contrast.** Text and UI elements meet AAA contrast ratios (7:1 for normal text, 4.5:1 for large text) against the dark background surfaces.
---
### 🧳 Portability
**Fully portable.** Everything - preferences, library state, subtitles, ffmpeg binaries, even the WebView2 profile - lives in a `state/` directory next to the executable. Copy the folder to a USB drive and it works anywhere. No registry entries, no AppData folders, no hidden configuration in your home directory.
**Crash-safe storage.** All state files are written atomically with backup rotation. If something goes wrong mid-write, the app falls back to the most recent valid backup. Nobody should lose their progress to a power outage.
**No external dependencies at runtime.** ffmpeg and ffprobe are automatically downloaded on first launch if not found locally. Fonts are bundled. Everything the app needs, it carries with it.
---
## 🎞️ Supported Formats
**Video:** MP4, M4V, MOV, WebM, MKV, AVI, MPG, MPEG, M2TS, MTS, OGV
**Subtitles:** SRT, VTT (sidecar files or embedded tracks in video containers)
Native HTML5 video playback works best with MP4 (H.264 + AAC) and WebM. Other formats are supported but may depend on system codecs.
---
## 🔨 Building from Source
### Prerequisites
- [Node.js](https://nodejs.org/) (18+)
- [Rust](https://rustup.rs/) (stable)
- [Tauri v2 prerequisites](https://v2.tauri.app/start/prerequisites/) for your platform
### Development
```
npm install
npm run tauri dev
```
### Production Build
```
npm run tauri build
```
The built executable and all assets will be in `src-tauri/target/release/`.
---
## 🏗️ Architecture
TutorialVault is a [Tauri v2](https://v2.tauri.app/) application with a Rust backend and a TypeScript frontend.
**Backend (Rust):** Handles file scanning, fingerprinting, state persistence, ffmpeg integration, subtitle processing, and a custom HTTP protocol for serving video and subtitle files to the frontend.
**Frontend (TypeScript + Vite):** A modular architecture with dedicated modules for each concern:
- `main.ts` -- Boot sequence, tick loop, keyboard shortcuts, cross-module wiring
- `player.ts` -- Video element, seek/volume/speed controls, mute, fullscreen, PiP, error states
- `playlist.ts` -- Playlist rendering, tree view, drag-and-drop, search/filter, scroll-to-current
- `subtitles.ts` -- Subtitle discovery, menu, embedded track extraction
- `ui.ts` -- Zoom, split ratios, notes, info panel, recent menu, toast, collapsible panes
- `tooltips.ts` -- Hover tooltips with zoom awareness and viewport clamping
- `store.ts` -- Shared state, pure utilities, cross-module callback registry
- `api.ts` -- Typed wrapper around Tauri's invoke API
**Design theme.** The interface uses a "Cold Open" dark theme built on cool slate backgrounds (`#0f1117` base) with a steel blue accent (`#88A4C4`). Typography uses [Bricolage Grotesque](https://fonts.google.com/specimen/Bricolage+Grotesque) for headings, [Inter](https://fonts.google.com/specimen/Inter) for body text, and [Space Mono](https://fonts.google.com/specimen/Space+Mono) for monospace elements. All fonts are bundled -- no external requests at runtime.
**State is local.** All data is stored in JSON files with atomic writes and backup rotation. No database, no server, no network requests beyond the one-time ffmpeg download.
---
## ⚖️ License
TutorialVault is released into the public domain under [CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/).
No rights reserved. No permission needed. No conditions. No restrictions.
Do whatever you want with it. Learn from it. Change it. Share it. Build on it. The whole point is that it's yours.

View File

@@ -0,0 +1,256 @@
# TutorialVault — 15 UI Enhancements Design
**Goal:** Add 15 real, polished enhancements to the TutorialVault frontend — all WCAG 2.2 AAA compliant.
**Architecture:** All changes are frontend-only (TypeScript + CSS). No Rust/backend changes needed. Each enhancement is independent — no cross-dependencies between them.
**Tech Stack:** TypeScript, CSS, HTML (Tauri v2 webview frontend)
---
## Enhancement 1: Mute Toggle (M key + volume icon click)
**What:** Press `M` to toggle mute. Click the volume icon to toggle mute. Muted state shows `fa-volume-xmark`, grays out the slider. Unmuting restores previous volume.
**Files:** `main.ts`, `player.ts`, `player.css`
**Behavior:**
- Store `lastVolume` before muting. Set volume to 0 on mute.
- On unmute, restore `lastVolume` (default 1.0 if none stored).
- Volume icon dynamically swaps: `fa-volume-high` (>50%), `fa-volume-low` (>0%), `fa-volume-xmark` (0/muted).
- Dragging the volume slider while muted unmutes automatically.
**WCAG:** Volume icon gets `aria-label="Mute"/"Unmute"` dynamically. Muted visual uses icon change + reduced opacity (not color alone).
---
## Enhancement 2: Fullscreen Shortcut (F key)
**What:** Press `F` to toggle fullscreen. Mirrors the existing fullscreen button behavior.
**Files:** `main.ts`
**Behavior:** Add `case 'f':` to the keyboard switch. Calls the same fullscreen toggle as `fsBtn.onclick`.
**WCAG:** No visual changes needed.
---
## Enhancement 3: Seek Feedback Overlay
**What:** Flash `5s` / `+5s` text in the video overlay when seeking via arrow keys. Fades after 600ms.
**Files:** `main.ts`, `player.ts`, `player.css`
**Behavior:**
- New `showSeekFeedback(delta: number)` function in player.ts.
- Reuses the existing video overlay area. Shows text like "5s" or "+5s" centered.
- Uses a CSS class `.seekFeedback` that fades in/out.
- Consecutive presses accumulate: pressing ArrowRight 3 times quickly shows "+15s".
**WCAG:** Overlay area has `aria-live="assertive"` so screen readers announce "Seeked forward 5 seconds". Text meets 7:1 contrast (white on semi-transparent dark).
---
## Enhancement 4: Playlist Search/Filter
**What:** Search input above the playlist. Typing filters by video name or relative path in real-time. Shows "X of Y" while filtering. Clear button (x) resets.
**Files:** `index.html`, `playlist.ts`, `playlist.css`
**Behavior:**
- Add `<input id="plistSearch">` in the playlist `.panelHeader`.
- `renderList()` checks the filter value and skips non-matching items.
- Filter is case-insensitive, matches against `it.title`, `it.name`, `it.relpath`.
- Clear button appears only when input has text.
- Pressing Escape in the search input clears it.
**WCAG:** `aria-label="Search playlist"`. Filtered count via `aria-live="polite"` region. Clear button has 44x44 target and `aria-label="Clear search"`.
---
## Enhancement 5: Scroll-to-Current Button
**What:** Button in playlist header that scrolls the active item into view. Only visible when the active item is off-screen.
**Files:** `index.html`, `playlist.ts`, `playlist.css`
**Behavior:**
- Uses `IntersectionObserver` on the `.row.active` element.
- Button appears (fades in) when active row is not visible.
- Clicking calls `activeRow.scrollIntoView({ block: 'center', behavior: 'smooth' })`.
- Icon: `fa-crosshairs` or `fa-location-dot`.
**WCAG:** `aria-label="Scroll to current video"`. 44x44 touch target. Hidden with `display:none` when not actionable (removed from a11y tree).
---
## Enhancement 6: Picture-in-Picture Button
**What:** PiP toggle button next to the fullscreen button. Uses native `requestPictureInPicture()` API.
**Files:** `index.html`, `player.ts`
**Behavior:**
- New `iconBtn` after fsBtn: `<button class="iconBtn" id="pipBtn">`.
- Icon swaps: `fa-up-right-from-square` (enter PiP) / `fa-down-left-and-up-right-to-center` (exit PiP).
- Listen to `enterpictureinpicture` / `leavepictureinpicture` events to update icon.
- Feature-detect: hide button if `document.pictureInPictureEnabled` is false.
**WCAG:** Dynamic `aria-label="Enter picture-in-picture"/"Exit picture-in-picture"`. Same focus-visible ring as all icon buttons.
---
## Enhancement 7: Timestamp Insertion in Notes
**What:** Button in the notes dock header that inserts `[MM:SS] ` at the textarea cursor position using the current video time.
**Files:** `index.html`, `ui.ts`, `panels.css`
**Behavior:**
- Small button in `#notesHeader`: `<button id="insertTimestamp" aria-label="Insert timestamp">`.
- Icon: `fa-clock` or `fa-stopwatch`.
- On click: read `player.currentTime`, format as `[M:SS]`, insert at `notesBox.selectionStart`.
- After insert, focus the textarea and place cursor after the inserted text.
**WCAG:** `aria-label="Insert timestamp"`. 44x44 target via `::before` expansion. `data-tooltip` for hover discoverability.
---
## Enhancement 8: Keyboard Shortcut Help Panel
**What:** Press `?` to open a help overlay listing all keyboard shortcuts. Press `?` or `Escape` to close.
**Files:** `index.html`, `main.ts`, `main.css` or `components.css`
**Behavior:**
- Hidden `<div id="shortcutHelp" role="dialog" aria-label="Keyboard shortcuts">` in HTML.
- Two-column grid showing key + description pairs.
- Shortcuts shown: Space (play/pause), Left/Right (seek ±5s), Up/Down (volume), M (mute), F (fullscreen), [ / ] (speed), Alt+Arrow (reorder playlist), ? (this help).
- Focus trap inside the dialog while open.
- Semi-transparent dark backdrop.
**WCAG:** `role="dialog"` with `aria-label`. Focus trapped. Escape closes. All text 7:1 contrast. Backdrop doesn't remove background content from a11y tree (uses `aria-hidden="true"` on app root while dialog is open).
---
## Enhancement 9: Playback Speed Shortcuts ([ and ])
**What:** Press `[` to decrease speed one step, `]` to increase speed one step through the SPEEDS array.
**Files:** `main.ts`, `player.ts`
**Behavior:**
- New `cycleSpeed(delta: number)` exported from player.ts.
- Finds current speed in SPEEDS array, moves by delta (clamped to bounds).
- Updates speed button text, gauge icon, and saves to backend.
- Shows toast notification: "Speed: 1.25x".
**WCAG:** Speed change announced via toast (existing `aria-live="polite"` region).
---
## Enhancement 10: Reset Progress Confirmation
**What:** Two-click confirmation before resetting progress. First click changes button to "Confirm?" state with red tint. Second click within 3 seconds actually resets. Timeout or click-away cancels.
**Files:** `ui.ts`, `main.css`
**Behavior:**
- First click: add `.confirming` class, change icon to `fa-exclamation-triangle`, start 3s timer.
- Second click while `.confirming`: execute the actual reset.
- Timer expiry or blur: remove `.confirming`, restore original icon.
- `.confirming` state: red-tinted background (`rgba(255,70,70,.14)`).
**WCAG:** `aria-label` changes to "Confirm reset progress" in confirm state. Visual change uses both color AND icon change (not color alone).
---
## Enhancement 11: Video Load Error State
**What:** When the `<video>` fires an error event, show a centered overlay with warning icon, error message, and "Try next" button.
**Files:** `player.ts`, `player.css`
**Behavior:**
- Listen to `player.addEventListener('error', ...)`.
- Create/show error overlay inside `.videoWrap` with `fa-triangle-exclamation` icon.
- Message: "This file format may not be supported. Try MP4 (H.264/AAC) or WebM."
- "Try next" button calls `nextPrev(+1)`.
- Overlay clears on next successful `loadedmetadata`.
**WCAG:** `role="alert"` for screen reader announcement. "Try next" button has 44x44 target. Error text meets 7:1 contrast.
---
## Enhancement 12: Double-Click to Fullscreen
**What:** Double-clicking the video toggles fullscreen. Single click still toggles play/pause.
**Files:** `player.ts`
**Behavior:**
- Replace simple click handler with a click/double-click discriminator.
- On click: set a 300ms timer. If no second click arrives, toggle play/pause.
- On double-click: cancel the timer, toggle fullscreen instead.
- `dblclick` event on the video overlay area.
**WCAG:** Mouse/touch enhancement only. Fullscreen remains keyboard-accessible via F key and button.
---
## Enhancement 13: Playlist Stats in Header
**What:** Show video count and completion info next to "Playlist" header. E.g., "24 videos · 8 done".
**Files:** `index.html`, `playlist.ts`
**Behavior:**
- Add `<span class="plistStats" id="plistStats"></span>` in the panelHeader.
- In `renderList()`: compute total count, done count, update the span.
- Styled as `--textMuted`, smaller font (11px), monospace.
**WCAG:** Stats text meets 7:1 contrast. Updated via DOM so screen readers can read it on demand.
---
## Enhancement 14: Mini Progress Bars Per Playlist Item
**What:** Thin 2px progress bar at the bottom of each playlist row showing watched/total. Green for done, accent blue for in-progress.
**Files:** `playlist.ts`, `playlist.css`
**Behavior:**
- For each row in `renderList()`, create a `.rowProgress` div.
- Width: `(watched / duration) * 100%`. If duration is null, hide.
- Color: `--success` for done items, `--accent` for in-progress.
- Positioned absolutely at bottom of the row.
**WCAG:** `aria-hidden="true"` — numeric info already in the row's `aria-label`. Colors pass 3:1 minimum for non-text contrast per WCAG 2.2.
---
## Enhancement 15: Collapsible Dock Panes
**What:** Clicking a dock header collapses that pane, giving the other pane full height. Collapsed state shows just the header with a chevron. Persisted via prefs.
**Files:** `ui.ts`, `panels.css`, `index.html`
**Behavior:**
- Click on `.dockHeader` toggles `.collapsed` on the parent `.dockPane`.
- Collapsed: `flex:0 0 auto`, content inside has `display:none`.
- Chevron icon in header rotates to indicate collapsed/expanded.
- Collapsing one pane doesn't collapse the other.
- State saved in prefs as `notes_collapsed` / `info_collapsed` booleans.
**WCAG:** Headers get `aria-expanded="true"/"false"`. Chevron is `aria-hidden="true"`. Enter/Space on header toggles. Collapsed content is `display:none` (removed from a11y tree).
---
## Approach
**Single-pass, all frontend:** Every enhancement modifies only TypeScript and CSS files. No backend/Rust changes. Each enhancement is independent and can be implemented in any order.
**Implementation strategy:** Direct edits to existing files using Edit/Write tools. No subagents for file editing. Each enhancement touches 1-3 files. Build verification after each enhancement or batch.
**Testing:** `npm run build` (TypeScript compilation + Vite build) after each enhancement to catch errors immediately.

View File

@@ -1,244 +0,0 @@
# TutorialDock: PyWebView to Tauri Conversion Design
**Date:** 2026-02-19
**Status:** Approved
## Overview
Convert TutorialDock from a single-file Python app (PyWebView + Flask) to a Tauri v2 desktop app with a full Rust backend and Vite + TypeScript frontend. The app must remain 100% portable (everything next to the exe, no AppData, no registry, no installer).
## Decisions
| Decision | Choice |
|----------|--------|
| Backend | Full Rust rewrite (no Python dependency) |
| Video serving | Tauri custom protocol (`tutdock://`) with range requests |
| Frontend tooling | Vite + TypeScript |
| ffmpeg strategy | Discovery (PATH -> next to exe -> state/ffmpeg/) with auto-download + progress UI |
| Platform | Windows only, portable exe only |
| API surface | Exact 1:1 mapping of all 24 PyWebView API methods as Tauri commands |
| Window state | Manual save/restore via prefs.json (no tauri-plugin-window-state) |
| Dialogs | tauri-plugin-dialog for folder picker and subtitle file picker |
## Project Structure
```
tutorial-app/
├── src-tauri/
│ ├── src/
│ │ ├── main.rs # Entry point, Tauri builder, plugin registration
│ │ ├── commands.rs # All 24 #[tauri::command] functions
│ │ ├── library.rs # Library struct + all library operations
│ │ ├── prefs.rs # Prefs struct, defaults, load/save
│ │ ├── state.rs # atomic_write_json, load_json_with_fallbacks, backup rotation
│ │ ├── ffmpeg.rs # Discovery, download, duration_seconds, ffprobe_video_metadata
│ │ ├── video_protocol.rs # Custom tutdock:// protocol with range request handling
│ │ ├── subtitles.rs # SRT->VTT, sidecar discovery, embedded extraction
│ │ ├── fonts.rs # Google Fonts + Font Awesome download & caching
│ │ ├── recents.rs # Recent folders list management
│ │ └── utils.rs # natural_key, file_fingerprint, pretty_title, path helpers
│ ├── Cargo.toml
│ ├── tauri.conf.json
│ └── build.rs
├── src/
│ ├── index.html
│ ├── main.ts # Boot sequence, tick loop, global event wiring
│ ├── api.ts # Typed invoke() wrappers for all 24 commands
│ ├── types.ts # All TypeScript interfaces
│ ├── player.ts # Video element control, seek, volume, playback rate
│ ├── playlist.ts # Playlist rendering, drag-and-drop reorder, tree SVG
│ ├── subtitles.ts # Subtitle menu, track management
│ ├── ui.ts # Zoom, splits, topbar, info panel, notes, toasts
│ ├── tooltips.ts # Tooltip system
│ ├── styles/
│ │ ├── main.css # Variables, body, app layout, topbar
│ │ ├── player.css # Video wrap, controls, seek bar, volume
│ │ ├── playlist.css # List items, tree view, scrollbar
│ │ ├── panels.css # Panel headers, dock grid, notes, info
│ │ ├── components.css # Buttons, switches, dropdowns, tooltips, toasts
│ │ └── animations.css # Keyframes
│ └── vite-env.d.ts
├── package.json
├── tsconfig.json
└── vite.config.ts
```
## Rust Backend
### State Management
Three pieces of managed Tauri state:
- `Mutex<Library>` — video library + per-folder state
- `Mutex<Prefs>` — global preferences
- `AppPaths` — resolved portable paths (immutable after startup)
### Module Details
**state.rs** — Atomic JSON persistence
- `atomic_write_json(path, data, backup_count=8)`: tmp-file write, rotate .bak1-.bak8, .lastgood copy
- `load_json_with_fallbacks(path)`: try primary -> .lastgood -> .bak1-.bak10
**utils.rs** — Pure utility functions
- `natural_key()`: natural sort (split on digits, mixed comparison)
- `file_fingerprint()`: SHA-256 of size + first 256KB + last 256KB, 20-char hex
- `compute_library_id()`: SHA-256 of sorted fingerprints, 16-char hex
- `pretty_title_from_filename()`: strip index prefix, underscores to spaces, smart title case
- `is_within_root()`, `clamp()`, `truthy()`, `folder_display_name()`
**prefs.rs** — Global preferences
- Struct with serde Serialize/Deserialize
- Fields: ui_zoom, split_ratio, dock_ratio, always_on_top, window (x/y/width/height), last_folder_path, last_library_id
- Versioned (version: 19), merged on load
**recents.rs** — Recent folders (max 50, deduplicated, most-recent-first)
**ffmpeg.rs** — ffmpeg/ffprobe management
- `discover()`: PATH -> next to exe -> state/ffmpeg/
- `download_ffmpeg(progress_callback)`: download from github.com/BtbN/FFmpeg-Builds, extract
- `duration_seconds()`: ffprobe first, ffmpeg stderr fallback
- `ffprobe_video_metadata()`: full JSON probe, video/audio/subtitle stream extraction
- Windows CREATE_NO_WINDOW for all subprocess calls
**subtitles.rs**
- `srt_to_vtt()`: BOM removal, comma->dot timestamps
- `auto_subtitle_sidecar()`: case-insensitive matching, language code awareness, priority candidates
- `store_subtitle_for_fid()`: convert and save to state/subtitles/
- `extract_embedded_subtitle()`: ffmpeg extraction to VTT
**library.rs** — Core library management
- Scan folder for video files (VIDEO_EXTS set)
- Build content-based fingerprints (survives renames/moves)
- Load/create/merge per-library state
- Playlist ordering with saved order + natural sort for new files
- Tree display flags (depth-1 only)
- Folder stats (finished/remaining counts, overall progress, remaining time)
- Background duration scanner via tokio::spawn
- "Once finished, always finished" sticky flag
**video_protocol.rs** — Custom protocol handler
- Routes: /video/{index}, /sub/{libid}/{fid}, /fonts.css, /fonts/{file}, /fa.css, /fa/webfonts/{file}
- Video: Range header parsing, 206 Partial Content, 512KB chunk streaming
- MIME type mapping from file extension
- Path traversal prevention
**commands.rs** — 24 Tauri commands (1:1 with Python API class)
1. select_folder() — native folder dialog + set_root
2. open_folder_path(folder)
3. get_recents()
4. remove_recent(path)
5. get_library()
6. set_current(index, timecode)
7. tick_progress(index, current_time, duration, playing)
8. set_folder_volume(volume)
9. set_folder_autoplay(enabled)
10. set_folder_rate(rate)
11. set_order(fids)
12. start_duration_scan()
13. get_prefs()
14. set_prefs(patch)
15. set_always_on_top(enabled)
16. save_window_state()
17. get_note(fid)
18. set_note(fid, note)
19. get_current_video_meta()
20. get_current_subtitle()
21. get_embedded_subtitles()
22. extract_embedded_subtitle(track_index)
23. get_available_subtitles()
24. load_sidecar_subtitle(file_path)
25. choose_subtitle_file() — native file dialog + set subtitle
### Rust Crates
| Crate | Purpose |
|-------|---------|
| tauri | App framework, windows, commands, custom protocols |
| serde + serde_json | JSON serialization |
| sha2 | SHA-256 fingerprinting |
| tokio | Async runtime (duration scan, downloads) |
| reqwest | HTTP client (ffmpeg + font downloads) |
| zip | Extracting ffmpeg archive |
| which | Finding executables on PATH |
| tauri-plugin-dialog | Native file/folder dialogs |
## Frontend
### TypeScript Modules
**types.ts** — Interfaces for all API responses (LibraryInfo, VideoItem, Prefs, VideoMeta, SubtitleInfo, etc.)
**api.ts** — Typed invoke() wrappers for all 24 commands. Every `window.pywebview.api.foo()` becomes `api.foo()`.
**main.ts** — Boot sequence, tick loop (250ms interval, progress save ~1s, library refresh ~3s, window state save), global keyboard shortcuts, Tauri event listeners (ffmpeg download progress).
**player.ts** — Video element with `tutdock://localhost/video/{index}` src, play/pause/seek/volume/speed/fullscreen, overlay, autoplay-next on ended.
**playlist.ts** — Render list items with progress bars and tree connectors, drag-and-drop reorder via pointer events, custom scrollbar.
**subtitles.ts** — Subtitle menu (sidecar + embedded + file picker + disable), track management via `tutdock://localhost/sub/...`.
**ui.ts** — Zoom control, split ratio dragging, topbar actions (always-on-top, autoplay, open folder, recent dropdown, reset progress, reload), info panel, notes textarea with debounced auto-save, toast notifications.
**tooltips.ts** — Event delegation on [data-tip] elements, zoom-aware positioning.
### CSS Split
| File | Content |
|------|---------|
| main.css | :root variables, body, #zoomRoot, .app, .topbar, .brand |
| player.css | .videoWrap, video, .controls, seek/volume bars, time display |
| playlist.css | #list, .listItem, tree SVG, .listScrollbar, #emptyHint |
| panels.css | .panel, .panelHeader, #dockGrid, .notesArea, .infoGrid |
| components.css | .toolbarBtn, .splitBtn, .toggleSwitch, .dropdownPortal, #toast, #fancyTooltip |
| animations.css | @keyframes logoSpin, logoWiggle, transitions |
### Video Source URLs
- Current: `http://127.0.0.1:{port}/video/{index}`
- New: `tutdock://localhost/video/{index}`
Same change for subtitles, fonts, and font awesome assets.
## Custom Protocol Routing
| URL Pattern | Response |
|-------------|----------|
| tutdock://localhost/video/{index} | Video file (200/206 with range support) |
| tutdock://localhost/sub/{libid}/{fid} | VTT subtitle file |
| tutdock://localhost/fonts.css | Combined Google Fonts CSS |
| tutdock://localhost/fonts/{filename} | Font woff2 files |
| tutdock://localhost/fa.css | Font Awesome CSS |
| tutdock://localhost/fa/webfonts/{filename} | Font Awesome webfont files |
## Startup Flow
1. Resolve exe directory, create state/ subdirectories, set WEBVIEW2_USER_DATA_FOLDER
2. Load prefs.json, restore window size/position
3. Create Tauri window, register tutdock:// protocol, register 24 commands
4. Frontend boot(): apply zoom/splits, load last library if exists
5. ffmpeg discovery (async): PATH -> exe dir -> state/ffmpeg/ -> auto-download with progress UI
6. Font caching (async, silent): Google Fonts + Font Awesome
## Portable State Layout
```
TutorialDock.exe
state/
├── prefs.json (+ .lastgood, .bak1-.bak8)
├── recent_folders.json (+ backups)
├── library_{id}.json (per unique library, + backups)
├── webview_profile/ (WebView2 data)
├── fonts/ (cached Google Fonts)
│ ├── fonts.css
│ ├── fonts_meta.json
│ └── *.woff2
├── fontawesome/ (cached Font Awesome)
│ ├── fa.css
│ ├── fa_meta.json
│ └── webfonts/*.woff2
├── subtitles/ (converted subtitle files)
│ └── {fid}_*.vtt
└── ffmpeg/ (auto-downloaded)
├── ffmpeg.exe
└── ffprobe.exe
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,184 @@
# WCAG 2.2 AAA Remediation Design
**Goal:** Bring TutorialVault to full WCAG 2.2 AAA compliance using surgical, in-place edits that preserve the Cold Open aesthetic.
**Approach:** Edit existing files directly. No new modules or abstractions. Fix each audit finding with the minimum code change. Contrast values raised just enough to hit 7:1 while keeping the cool, muted hierarchy.
**Constraints:**
- Preserve the Cold Open visual identity (dark slate, steel blue accent, borderless surfaces)
- Raise muted text contrast minimally to hit AAA 7:1
- Focus rings use the existing accent color, subtle but compliant
- No new dependencies
---
## 1. Semantic HTML & Landmarks
**File: `src/index.html`**
- Add `lang="en"` to `<html>`
- Wrap `.topbar` in `<header role="banner">`
- Wrap `.content` in `<main role="main">`
- Add `role="region" aria-label="Video player"` to the left `.panel`
- Add `role="region" aria-label="Playlist"` to the right `.panel`
- Add `role="complementary" aria-label="Details"` to the `.dock`
- Change `.appName` from `<div>` to `<h1>` (keep existing class)
- Change `.nowTitle` from `<div>` to `<h2>` (keep existing class)
- Change `.dockTitle` elements from `<div>` to `<h3>` (keep existing class)
- Change `.playlistHeader` from `<div>` to `<h2>` (keep existing class)
- Convert info panel `.kv` blocks from nested `<div>` to `<dl>`/`<dt>`/`<dd>`, styled with same `.kv`/`.k`/`.v` classes
No visual change. Headings inherit their existing class styles. Landmarks are invisible.
---
## 2. Text Alternatives & ARIA
### Icon-only buttons get `aria-label`
| Button | `aria-label` |
|---|---|
| `zoomOutBtn` | "Zoom out" |
| `zoomInBtn` | "Zoom in" |
| `zoomResetBtn` (change `<span>` to `<button>`) | "Reset zoom" |
| `chooseDropBtn` | "Recent folders" |
| `resetProgBtn` | "Reset progress" |
| `refreshBtn` | "Reload folder" |
| `winMinBtn` | "Minimize" |
| `winMaxBtn` | "Maximize" |
| `winCloseBtn` | "Close" |
| `prevBtn` | "Previous video" |
| `playPauseBtn` | "Play" (dynamically toggled to "Pause") |
| `nextBtn` | "Next video" |
| `subsBtn` | "Subtitles" |
| `fsBtn` | "Toggle fullscreen" |
### Other ARIA additions
- Add `aria-hidden="true"` to all decorative `<i>` icons inside labeled buttons
- Add `aria-label="Video player"` to `<video>`
- Add `role="progressbar"`, `aria-valuenow`, `aria-valuemin="0"`, `aria-valuemax="100"`, `aria-label="Overall folder progress"` to `.progressBar`. Update dynamically in `updateOverall()`
- Add `aria-label="Volume"` to volume slider
- Toggle `aria-expanded="true"/"false"` on `subsBtn`, `speedBtn`, `chooseDropBtn` when menus open/close
- Toast already has `aria-live="polite"` -- no change needed
### Playlist rows (in `playlist.ts`)
- Set `role="listbox"` and `aria-label="Playlist"` on the `#list` container
- Each row: `role="option"`, `aria-selected="true"/"false"`, computed `aria-label` (e.g. "03. Video Title - 5:30 / 12:00 - Done")
---
## 3. Color Contrast (AAA 7:1)
Targeted CSS custom property adjustments in `:root`. Hierarchy preserved, just shifted up.
| Token | Current value | New value | Ratio vs #151821 |
|---|---|---|---|
| `--textMuted` | `rgba(148,162,192,.55)` ~3.6:1 | `rgba(160,174,204,.72)` ~7.2:1 | AAA pass |
| `--textDim` | `rgba(118,132,168,.38)` ~1.8:1 | `rgba(158,174,208,.68)` ~7.1:1 | AAA pass |
| `.tagline` color | `rgba(148,162,192,.62)` ~3.7:1 | Use `--textMuted` | AAA pass |
| `.notes::placeholder` | `rgba(148,162,192,.40)` ~2.2:1 | `rgba(155,170,200,.65)` ~5.5:1 | Exempt but improved |
| Time `/` separator | `rgba(165,172,196,.65)` | `rgba(175,185,210,.78)` ~7.1:1 | AAA pass |
| `--icon` | `rgba(148,162,195,.48)` | `rgba(160,175,210,.62)` | Non-text 3:1 pass |
---
## 4. Focus Indicators (AAA 2.4.13)
Global `:focus-visible` rule in `main.css`:
```css
*:focus-visible {
outline: 2px solid rgba(136,164,196,.65);
outline-offset: 2px;
border-radius: inherit;
}
```
Component refinements:
- Buttons: outline around button shape (default behavior)
- Playlist rows: `outline-offset: -2px` to stay within bounds
- Sliders: `:focus-visible::-webkit-slider-thumb` box-shadow glow
- Notes textarea: enhance existing `:focus` border to 3:1 contrast
- Switch labels: outline around entire label
- Menu items: background highlight + outline on focus
`rgba(136,164,196,.65)` vs `#151821` gives ~5.5:1 -- passes the 3:1 requirement for focus indicators.
---
## 5. Keyboard Accessibility
### Interactive `<div>` elements become keyboard-accessible
1. **Playlist rows**: `tabindex="0"`, `keydown` Enter/Space to activate, Arrow Up/Down to navigate rows
2. **Menu items** (subtitle, speed, recent): Use `<button>` elements or `tabindex="0"` with `role="menuitem"`. Enter/Space to activate, Arrow Up/Down to navigate, Escape to close and return focus to trigger
3. **`zoomResetBtn`**: Change from `<span>` to `<button>`
4. **Resize dividers**: `tabindex="0"`, `role="separator"`, `aria-valuenow`, `aria-orientation="vertical"`. Arrow Left/Right to resize in 2% increments
### Menu keyboard pattern (subtitles, speed, recent)
- Trigger button: `aria-haspopup="true"`, `aria-expanded="false"/"true"`
- On open: focus moves to first menu item
- Arrow Up/Down: navigate items
- Enter/Space: activate item
- Escape: close menu, return focus to trigger
- Tab: close menu
### Playlist keyboard reorder (WCAG 2.5.7)
- Focused row: Alt+ArrowUp / Alt+ArrowDown moves it
- Small move-up/move-down `<button>` elements appear on row focus/hover (right side, before tag)
- After move, focus follows the moved row to its new position
- A live region announces "Moved to position X"
---
## 6. Remaining AAA Criteria
### Dynamic page title (2.4.2)
Update `document.title` in `loadIndex()` and `onLibraryLoaded()`:
- Playing: `"Video Title - TutorialVault"`
- No video: `"TutorialVault - Open a folder"`
### Abbreviations (3.1.4)
Wrap abbreviations in info panel with `<abbr title="...">`:
- ETA -> `<abbr title="Estimated Time of Arrival">ETA</abbr>`
- FPS -> `<abbr title="Frames Per Second">FPS</abbr>`
- kbps/Mbps -> `<abbr title="kilobits per second">kbps</abbr>`
Update `updateInfoPanel()` and `refreshCurrentVideoMeta()` in `ui.ts`.
### Target size (2.5.5 AAA -- 44x44px)
Expand hit areas of small buttons using padding + negative margin:
- `.zoomBtn` (28x28): add padding to reach 44x44 effective, negative margin to keep visual layout
- `.winBtn` (30x30): same technique
- `.dropRemove` (24x24): same technique
Visual size stays the same; click/touch area grows.
### Reduced motion (2.3.3)
Already handled -- `prefers-reduced-motion` media query disables all animations/transitions. No change needed.
---
## Files Modified
| File | Changes |
|---|---|
| `src/index.html` | `lang`, landmarks, headings, `aria-label`s, `aria-hidden`, `<button>` for zoomReset, `<dl>` for info panel, `role`/`aria` on progress bar and video |
| `src/styles/main.css` | `:root` contrast values, `:focus-visible` rules, hit area expansion for small buttons |
| `src/styles/player.css` | Focus styles for sliders and control buttons |
| `src/styles/playlist.css` | Focus styles for rows, move button styling |
| `src/styles/panels.css` | Focus styles for notes, divider roles |
| `src/styles/components.css` | Focus styles for menu items, tooltip improvements |
| `src/playlist.ts` | `role="listbox"`, row `role="option"`, `tabindex`, keyboard nav, move buttons, Alt+Arrow reorder, live region |
| `src/player.ts` | `aria-expanded` on speed button, menu keyboard nav, dynamic `aria-label` on play/pause, Escape handler |
| `src/subtitles.ts` | `aria-expanded` on subs button, menu items as buttons, keyboard nav, Escape handler |
| `src/ui.ts` | `aria-expanded` on recent dropdown, menu keyboard nav, Escape handler, divider keyboard resize, progress bar ARIA updates, dynamic `document.title`, abbreviation wrapping |
| `src/main.ts` | Dynamic `document.title` updates |

File diff suppressed because it is too large Load Diff

1273
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{
"name": "tutorialdock",
"name": "tutorialvault",
"private": true,
"version": "0.1.0",
"type": "module",
@@ -9,6 +9,10 @@
"tauri": "tauri"
},
"dependencies": {
"@fontsource/bricolage-grotesque": "^5.2.10",
"@fontsource/inter": "^5.2.8",
"@fontsource/space-mono": "^5.2.9",
"@fortawesome/fontawesome-free": "^7.2.0",
"@tauri-apps/api": "^2.0.0"
},
"devDependencies": {

View File

@@ -1,33 +0,0 @@
import os
def rename_folder():
# Get the current working directory
current_dir = os.getcwd()
print(f"Current directory: {current_dir}")
# Prompt user for the current folder name and new folder name
current_name = input("Enter the current folder name to rename: ")
new_name = input("Enter the new folder name: ")
# Construct full paths
current_path = os.path.join(current_dir, current_name)
new_path = os.path.join(current_dir, new_name)
try:
# Rename the folder
os.rename(current_path, new_path)
print(f"Folder '{current_name}' renamed to '{new_name}' successfully.")
except FileNotFoundError:
print(f"Error: Folder '{current_name}' does not exist in {current_dir}.")
except PermissionError:
print(
"Error: Permission denied. Try running the script with administrator privileges."
)
except Exception as e:
print(f"An unexpected error occurred: {e}")
if __name__ == "__main__":
rename_folder()

5622
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
[package]
name = "tutorialdock"
name = "tutorialvault"
version = "0.1.0"
description = "TutorialDock - Video Tutorial Library Manager"
description = "TutorialVault - Video Tutorial Library Manager"
authors = []
edition = "2021"
[lib]
name = "tutorialdock_lib"
name = "tutorialvault_lib"
crate-type = ["lib", "cdylib", "staticlib"]
[build-dependencies]

View File

@@ -1,10 +1,18 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capabilities for TutorialDock",
"description": "Default capabilities for TutorialVault",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default"
"dialog:default",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-is-maximized",
"core:window:allow-close",
"core:window:allow-start-dragging",
"core:window:allow-set-size",
"core:window:allow-set-position",
"core:window:allow-set-always-on-top"
]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for TutorialVault","local":true,"windows":["main"],"permissions":["core:default","dialog:default","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-is-maximized","core:window:allow-close","core:window:allow-start-dragging","core:window:allow-set-size","core:window:allow-set-position","core:window:allow-set-always-on-top"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 882 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 138 KiB

487
src-tauri/src/commands.rs Normal file
View File

@@ -0,0 +1,487 @@
//! Tauri v2 command handlers for TutorialDock.
//!
//! Each function is a thin `#[tauri::command]` wrapper that acquires the
//! relevant state lock(s) and delegates to the appropriate module.
use serde_json::{json, Value};
use std::path::Path;
use std::sync::atomic::Ordering;
use std::sync::Mutex;
use tauri::Manager;
use tauri_plugin_dialog::DialogExt;
use crate::ffmpeg;
use crate::library::Library;
use crate::prefs::Prefs;
use crate::recents;
use crate::AppPaths;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Extract a short display name from a folder path (last component).
fn display_name_for_path(p: &str) -> String {
Path::new(p)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| p.to_string())
}
// ===========================================================================
// 1. select_folder
// ===========================================================================
#[tauri::command]
pub async fn select_folder(
app: tauri::AppHandle,
library: tauri::State<'_, Mutex<Library>>,
prefs: tauri::State<'_, Mutex<Prefs>>,
paths: tauri::State<'_, AppPaths>,
) -> Result<Value, String> {
let folder = app.dialog().file().blocking_pick_folder();
let folder_path = match folder {
Some(fp) => fp.as_path().unwrap().to_path_buf(),
None => return Ok(json!({"ok": false, "cancelled": true})),
};
let folder_str = folder_path.to_string_lossy().to_string();
let result = {
let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?;
lib.set_root(&folder_str, &paths.state_dir)?
};
recents::push_recent(&paths.state_dir, &folder_str);
{
let mut p = prefs.lock().map_err(|e| format!("lock error: {}", e))?;
p.last_folder_path = Some(folder_str);
p.save(&paths.state_dir);
}
Ok(result)
}
// ===========================================================================
// 2. open_folder_path
// ===========================================================================
#[tauri::command]
pub fn open_folder_path(
folder: String,
library: tauri::State<'_, Mutex<Library>>,
prefs: tauri::State<'_, Mutex<Prefs>>,
paths: tauri::State<'_, AppPaths>,
) -> Result<Value, String> {
let result = {
let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?;
lib.set_root(&folder, &paths.state_dir)?
};
recents::push_recent(&paths.state_dir, &folder);
{
let mut p = prefs.lock().map_err(|e| format!("lock error: {}", e))?;
p.last_folder_path = Some(folder);
p.save(&paths.state_dir);
}
Ok(result)
}
// ===========================================================================
// 3. get_recents
// ===========================================================================
#[tauri::command]
pub fn get_recents(paths: tauri::State<'_, AppPaths>) -> Result<Value, String> {
let all = recents::load_recents(&paths.state_dir);
let items: Vec<Value> = all
.into_iter()
.filter(|p| Path::new(p).is_dir())
.map(|p| {
let name = display_name_for_path(&p);
json!({"path": p, "name": name})
})
.collect();
Ok(json!({"ok": true, "items": items}))
}
// ===========================================================================
// 4. remove_recent
// ===========================================================================
#[tauri::command]
pub fn remove_recent(path: String, paths: tauri::State<'_, AppPaths>) -> Result<Value, String> {
recents::remove_recent(&paths.state_dir, &path);
Ok(json!({"ok": true}))
}
// ===========================================================================
// 5. get_library
// ===========================================================================
#[tauri::command]
pub fn get_library(library: tauri::State<'_, Mutex<Library>>) -> Result<Value, String> {
let lib = library.lock().map_err(|e| format!("lock error: {}", e))?;
Ok(lib.get_library_info())
}
// ===========================================================================
// 6. set_current
// ===========================================================================
#[tauri::command]
pub fn set_current(
index: usize,
timecode: f64,
library: tauri::State<'_, Mutex<Library>>,
) -> Result<Value, String> {
let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?;
Ok(lib.set_current(index, timecode))
}
// ===========================================================================
// 7. tick_progress
// ===========================================================================
#[tauri::command]
pub fn tick_progress(
index: usize,
current_time: f64,
duration: Option<f64>,
playing: bool,
library: tauri::State<'_, Mutex<Library>>,
) -> Result<Value, String> {
let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?;
Ok(lib.update_progress(index, current_time, duration, playing))
}
// ===========================================================================
// 8. set_folder_volume
// ===========================================================================
#[tauri::command]
pub fn set_folder_volume(
volume: f64,
library: tauri::State<'_, Mutex<Library>>,
) -> Result<Value, String> {
let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?;
Ok(lib.set_folder_volume(volume))
}
// ===========================================================================
// 9. set_folder_autoplay
// ===========================================================================
#[tauri::command]
pub fn set_folder_autoplay(
enabled: bool,
library: tauri::State<'_, Mutex<Library>>,
) -> Result<Value, String> {
let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?;
Ok(lib.set_folder_autoplay(enabled))
}
// ===========================================================================
// 10. set_folder_rate
// ===========================================================================
#[tauri::command]
pub fn set_folder_rate(
rate: f64,
library: tauri::State<'_, Mutex<Library>>,
) -> Result<Value, String> {
let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?;
Ok(lib.set_folder_rate(rate))
}
// ===========================================================================
// 11. set_order
// ===========================================================================
#[tauri::command]
pub fn set_order(
fids: Vec<String>,
library: tauri::State<'_, Mutex<Library>>,
) -> Result<Value, String> {
let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?;
Ok(lib.set_order(fids))
}
// ===========================================================================
// 12. start_duration_scan
// ===========================================================================
#[tauri::command]
pub fn start_duration_scan(
app: tauri::AppHandle,
library: tauri::State<'_, Mutex<Library>>,
) -> Result<Value, String> {
let (pending, stop_flag, ffprobe_path, ffmpeg_path) = {
let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?;
lib.start_duration_scan();
let pending = lib.get_pending_scans();
let stop_flag = lib.scan_stop_flag();
let ffprobe_path = lib.ffprobe.clone();
let ffmpeg_path = lib.ffmpeg.clone();
(pending, stop_flag, ffprobe_path, ffmpeg_path)
};
let count = pending.len();
if count == 0 {
return Ok(json!({"ok": true, "pending": 0}));
}
let handle = app.clone();
std::thread::spawn(move || {
let ff_paths = ffmpeg::FfmpegPaths {
ffprobe: ffprobe_path,
ffmpeg: ffmpeg_path,
};
let library_state = handle.state::<Mutex<Library>>();
for (fid, file_path) in &pending {
if stop_flag.load(Ordering::SeqCst) {
break;
}
if let Some(dur) = ffmpeg::duration_seconds(file_path, &ff_paths) {
if let Ok(mut lib) = library_state.lock() {
lib.apply_scanned_duration(fid, dur);
}
}
}
});
Ok(json!({"ok": true, "pending": count}))
}
// ===========================================================================
// 13. get_prefs
// ===========================================================================
#[tauri::command]
pub fn get_prefs(prefs: tauri::State<'_, Mutex<Prefs>>) -> Result<Value, String> {
let p = prefs.lock().map_err(|e| format!("lock error: {}", e))?;
Ok(json!({"ok": true, "prefs": serde_json::to_value(&*p).unwrap_or(json!({}))}))
}
// ===========================================================================
// 14. set_prefs
// ===========================================================================
#[tauri::command]
pub fn set_prefs(
patch: Value,
prefs: tauri::State<'_, Mutex<Prefs>>,
paths: tauri::State<'_, AppPaths>,
) -> Result<Value, String> {
let mut p = prefs.lock().map_err(|e| format!("lock error: {}", e))?;
p.update(&patch, &paths.state_dir);
Ok(json!({"ok": true}))
}
// ===========================================================================
// 15. set_always_on_top
// ===========================================================================
#[tauri::command]
pub fn set_always_on_top(
enabled: bool,
app: tauri::AppHandle,
prefs: tauri::State<'_, Mutex<Prefs>>,
paths: tauri::State<'_, AppPaths>,
) -> Result<Value, String> {
{
let mut p = prefs.lock().map_err(|e| format!("lock error: {}", e))?;
p.always_on_top = enabled;
p.save(&paths.state_dir);
}
if let Some(window) = app.get_webview_window("main") {
window
.set_always_on_top(enabled)
.map_err(|e| format!("set_always_on_top failed: {}", e))?;
}
Ok(json!({"ok": true}))
}
// ===========================================================================
// 16. save_window_state
// ===========================================================================
#[tauri::command]
pub fn save_window_state(
app: tauri::AppHandle,
prefs: tauri::State<'_, Mutex<Prefs>>,
paths: tauri::State<'_, AppPaths>,
) -> Result<Value, String> {
let window = app
.get_webview_window("main")
.ok_or_else(|| "main window not found".to_string())?;
let pos = window
.outer_position()
.map_err(|e| format!("outer_position failed: {}", e))?;
let size = window
.outer_size()
.map_err(|e| format!("outer_size failed: {}", e))?;
let mut p = prefs.lock().map_err(|e| format!("lock error: {}", e))?;
p.window.x = Some(pos.x);
p.window.y = Some(pos.y);
p.window.width = size.width as i32;
p.window.height = size.height as i32;
p.save(&paths.state_dir);
Ok(json!({"ok": true}))
}
// ===========================================================================
// 17. get_note
// ===========================================================================
#[tauri::command]
pub fn get_note(fid: String, library: tauri::State<'_, Mutex<Library>>) -> Result<Value, String> {
let lib = library.lock().map_err(|e| format!("lock error: {}", e))?;
let note = lib.get_note(&fid);
Ok(json!({"ok": true, "note": note}))
}
// ===========================================================================
// 18. set_note
// ===========================================================================
#[tauri::command]
pub fn set_note(
fid: String,
note: String,
library: tauri::State<'_, Mutex<Library>>,
) -> Result<Value, String> {
let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?;
Ok(lib.set_note(&fid, &note))
}
// ===========================================================================
// 19. get_current_video_meta
// ===========================================================================
#[tauri::command]
pub fn get_current_video_meta(
library: tauri::State<'_, Mutex<Library>>,
) -> Result<Value, String> {
let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?;
Ok(lib.get_current_video_metadata())
}
// ===========================================================================
// 20. get_current_subtitle
// ===========================================================================
#[tauri::command]
pub fn get_current_subtitle(
library: tauri::State<'_, Mutex<Library>>,
paths: tauri::State<'_, AppPaths>,
) -> Result<Value, String> {
let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?;
Ok(lib.get_subtitle_for_current(&paths.state_dir))
}
// ===========================================================================
// 21. get_embedded_subtitles
// ===========================================================================
#[tauri::command]
pub fn get_embedded_subtitles(
library: tauri::State<'_, Mutex<Library>>,
) -> Result<Value, String> {
let lib = library.lock().map_err(|e| format!("lock error: {}", e))?;
Ok(lib.get_embedded_subtitles())
}
// ===========================================================================
// 22. extract_embedded_subtitle
// ===========================================================================
#[tauri::command]
pub fn extract_embedded_subtitle(
track_index: u32,
library: tauri::State<'_, Mutex<Library>>,
paths: tauri::State<'_, AppPaths>,
) -> Result<Value, String> {
let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?;
Ok(lib.extract_embedded_subtitle(track_index, &paths.state_dir))
}
// ===========================================================================
// 23. get_available_subtitles
// ===========================================================================
#[tauri::command]
pub fn get_available_subtitles(
library: tauri::State<'_, Mutex<Library>>,
) -> Result<Value, String> {
let lib = library.lock().map_err(|e| format!("lock error: {}", e))?;
Ok(lib.get_available_subtitles())
}
// ===========================================================================
// 24. load_sidecar_subtitle
// ===========================================================================
#[tauri::command]
pub fn load_sidecar_subtitle(
file_path: String,
library: tauri::State<'_, Mutex<Library>>,
paths: tauri::State<'_, AppPaths>,
) -> Result<Value, String> {
let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?;
Ok(lib.load_sidecar_subtitle(&file_path, &paths.state_dir))
}
// ===========================================================================
// 25. choose_subtitle_file
// ===========================================================================
#[tauri::command]
pub async fn choose_subtitle_file(
app: tauri::AppHandle,
library: tauri::State<'_, Mutex<Library>>,
paths: tauri::State<'_, AppPaths>,
) -> Result<Value, String> {
let file = app
.dialog()
.file()
.add_filter("Subtitles", &["srt", "vtt"])
.blocking_pick_file();
let file_path = match file {
Some(fp) => fp.as_path().unwrap().to_path_buf(),
None => return Ok(json!({"ok": false, "cancelled": true})),
};
let file_str = file_path.to_string_lossy().to_string();
let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?;
Ok(lib.set_subtitle_for_current(&file_str, &paths.state_dir))
}
// ===========================================================================
// 26. reset_watch_progress
// ===========================================================================
#[tauri::command]
pub fn reset_watch_progress(
library: tauri::State<'_, Mutex<Library>>,
) -> Result<Value, String> {
let mut lib = library.lock().map_err(|e| format!("lock error: {}", e))?;
Ok(lib.reset_watch_progress())
}

806
src-tauri/src/ffmpeg.rs Normal file
View File

@@ -0,0 +1,806 @@
//! FFmpeg / FFprobe discovery, video metadata extraction, and ffmpeg downloading.
use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// CREATE_NO_WINDOW flag for Windows subprocess creation.
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
/// Timeout for ffprobe / ffmpeg subprocess calls.
const SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(20);
/// Timeout for ffprobe metadata calls (slightly longer for large files).
const METADATA_TIMEOUT: Duration = Duration::from_secs(25);
/// FFmpeg download URL (Windows 64-bit GPL build).
const FFMPEG_DOWNLOAD_URL: &str =
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip";
// ---------------------------------------------------------------------------
// Regex patterns
// ---------------------------------------------------------------------------
/// Matches "Duration: HH:MM:SS.ss" in ffmpeg stderr output.
static DURATION_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)").unwrap());
// ---------------------------------------------------------------------------
// Structs
// ---------------------------------------------------------------------------
/// Paths to discovered ffprobe and ffmpeg executables.
pub struct FfmpegPaths {
pub ffprobe: Option<PathBuf>,
pub ffmpeg: Option<PathBuf>,
}
/// Detailed video metadata extracted via ffprobe.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoMetadata {
pub v_codec: Option<String>,
pub width: Option<u32>,
pub height: Option<u32>,
pub fps: Option<f64>,
pub v_bitrate: Option<u64>,
pub pix_fmt: Option<String>,
pub color_space: Option<String>,
pub a_codec: Option<String>,
pub channels: Option<u32>,
pub sample_rate: Option<String>,
pub a_bitrate: Option<u64>,
pub subtitle_tracks: Vec<SubtitleTrack>,
pub container_bitrate: Option<u64>,
pub duration: Option<f64>,
pub format_name: Option<String>,
pub container_title: Option<String>,
pub encoder: Option<String>,
}
impl Default for VideoMetadata {
fn default() -> Self {
Self {
v_codec: None,
width: None,
height: None,
fps: None,
v_bitrate: None,
pix_fmt: None,
color_space: None,
a_codec: None,
channels: None,
sample_rate: None,
a_bitrate: None,
subtitle_tracks: Vec::new(),
container_bitrate: None,
duration: None,
format_name: None,
container_title: None,
encoder: None,
}
}
}
/// Information about a single subtitle track.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubtitleTrack {
pub index: u32,
pub codec: String,
pub language: String,
pub title: String,
}
/// Progress information emitted during ffmpeg download.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadProgress {
pub percent: f64,
pub downloaded_bytes: u64,
pub total_bytes: u64,
}
// ---------------------------------------------------------------------------
// 1. Platform-specific command setup
// ---------------------------------------------------------------------------
/// Apply platform-specific flags to a `Command` (hide console window on Windows).
fn apply_no_window(_cmd: &mut Command) {
#[cfg(target_os = "windows")]
{
_cmd.creation_flags(CREATE_NO_WINDOW);
}
}
// ---------------------------------------------------------------------------
// 2. discover
// ---------------------------------------------------------------------------
/// Discover ffmpeg and ffprobe executables.
///
/// Search order:
/// 1. System PATH via `which`
/// 2. Alongside the application executable (`exe_dir`)
/// 3. Inside `state_dir/ffmpeg/`
pub fn discover(exe_dir: &Path, state_dir: &Path) -> FfmpegPaths {
let ffprobe = discover_one("ffprobe", exe_dir, state_dir);
let ffmpeg = discover_one("ffmpeg", exe_dir, state_dir);
FfmpegPaths { ffprobe, ffmpeg }
}
/// Discover a single executable by name.
fn discover_one(name: &str, exe_dir: &Path, state_dir: &Path) -> Option<PathBuf> {
// 1. System PATH
if let Ok(p) = which::which(name) {
return Some(p);
}
// Platform-specific executable name
let exe_name = if cfg!(target_os = "windows") {
format!("{}.exe", name)
} else {
name.to_string()
};
// 2. Beside the application executable
let candidate = exe_dir.join(&exe_name);
if candidate.is_file() {
return Some(candidate);
}
// 3. Inside state_dir/ffmpeg/
let candidate = state_dir.join("ffmpeg").join(&exe_name);
if candidate.is_file() {
return Some(candidate);
}
None
}
// ---------------------------------------------------------------------------
// 3. duration_seconds
// ---------------------------------------------------------------------------
/// Get video duration in seconds using ffprobe (primary) or ffmpeg stderr (fallback).
///
/// Returns `None` if neither method succeeds or duration is not positive.
pub fn duration_seconds(path: &Path, paths: &FfmpegPaths) -> Option<f64> {
// Try ffprobe first
if let Some(ref ffprobe) = paths.ffprobe {
if let Some(d) = duration_via_ffprobe(path, ffprobe) {
return Some(d);
}
}
// Fallback: parse ffmpeg stderr
if let Some(ref ffmpeg) = paths.ffmpeg {
if let Some(d) = duration_via_ffmpeg(path, ffmpeg) {
return Some(d);
}
}
None
}
/// Extract duration using ffprobe's format=duration output.
fn duration_via_ffprobe(path: &Path, ffprobe: &Path) -> Option<f64> {
let mut cmd = Command::new(ffprobe);
cmd.args([
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=nw=1:nk=1",
])
.arg(path)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
apply_no_window(&mut cmd);
let child = cmd.spawn().ok()?;
let output = wait_with_timeout(child, SUBPROCESS_TIMEOUT)?;
let text = String::from_utf8_lossy(&output.stdout);
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
}
let d: f64 = trimmed.parse().ok()?;
if d > 0.0 { Some(d) } else { None }
}
/// Extract duration by parsing "Duration: HH:MM:SS.ss" from ffmpeg stderr.
fn duration_via_ffmpeg(path: &Path, ffmpeg: &Path) -> Option<f64> {
let mut cmd = Command::new(ffmpeg);
cmd.args(["-hide_banner", "-i"])
.arg(path)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
apply_no_window(&mut cmd);
let child = cmd.spawn().ok()?;
let output = wait_with_timeout(child, SUBPROCESS_TIMEOUT)?;
let stderr = String::from_utf8_lossy(&output.stderr);
parse_ffmpeg_duration(&stderr)
}
/// Parse "Duration: HH:MM:SS.ss" from ffmpeg stderr output.
fn parse_ffmpeg_duration(stderr: &str) -> Option<f64> {
let caps = DURATION_RE.captures(stderr)?;
let hh: f64 = caps.get(1)?.as_str().parse().ok()?;
let mm: f64 = caps.get(2)?.as_str().parse().ok()?;
let ss: f64 = caps.get(3)?.as_str().parse().ok()?;
let total = hh * 3600.0 + mm * 60.0 + ss;
if total > 0.0 { Some(total) } else { None }
}
/// Wait for a child process with a timeout, killing it if exceeded.
fn wait_with_timeout(
child: std::process::Child,
timeout: Duration,
) -> Option<std::process::Output> {
// Convert Child into a form we can wait on with a timeout.
// std::process::Child::wait_with_output blocks, so we use a thread.
let (tx, rx) = std::sync::mpsc::channel();
let handle = std::thread::spawn(move || {
let result = child.wait_with_output();
let _ = tx.send(result);
});
match rx.recv_timeout(timeout) {
Ok(Ok(output)) => {
let _ = handle.join();
Some(output)
}
_ => {
// Timeout or error -- the thread owns the child and will clean up
let _ = handle.join();
None
}
}
}
// ---------------------------------------------------------------------------
// 4. ffprobe_video_metadata
// ---------------------------------------------------------------------------
/// Extract detailed video metadata using ffprobe JSON output.
///
/// Runs ffprobe with `-print_format json -show_streams -show_format` and parses
/// the resulting JSON to populate a `VideoMetadata` struct.
pub fn ffprobe_video_metadata(path: &Path, ffprobe: &Path) -> Option<VideoMetadata> {
let mut cmd = Command::new(ffprobe);
cmd.args([
"-v", "error",
"-print_format", "json",
"-show_streams", "-show_format",
])
.arg(path)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
apply_no_window(&mut cmd);
let child = cmd.spawn().ok()?;
let output = wait_with_timeout(child, METADATA_TIMEOUT)?;
let text = String::from_utf8_lossy(&output.stdout);
let data: serde_json::Value = serde_json::from_str(&text).ok()?;
let streams = data.get("streams").and_then(|v| v.as_array());
let fmt = data.get("format").and_then(|v| v.as_object());
let mut meta = VideoMetadata::default();
let mut found_video = false;
let mut found_audio = false;
// Iterate streams: first video, first audio, all subtitles
if let Some(streams) = streams {
for (idx, s) in streams.iter().enumerate() {
let obj = match s.as_object() {
Some(o) => o,
None => continue,
};
let codec_type = obj
.get("codec_type")
.and_then(|v| v.as_str())
.unwrap_or("");
match codec_type {
"video" if !found_video => {
found_video = true;
meta.v_codec = json_str(obj, "codec_name");
meta.width = json_u32(obj, "width");
meta.height = json_u32(obj, "height");
meta.pix_fmt = json_str(obj, "pix_fmt");
meta.color_space = json_str(obj, "color_space");
// Parse frame rate ("num/den")
let frame_rate = json_str(obj, "r_frame_rate")
.or_else(|| json_str(obj, "avg_frame_rate"));
if let Some(ref fr) = frame_rate {
meta.fps = parse_frame_rate(fr);
}
// Video bitrate
meta.v_bitrate = json_str(obj, "bit_rate")
.and_then(|s| s.parse::<u64>().ok());
}
"audio" if !found_audio => {
found_audio = true;
meta.a_codec = json_str(obj, "codec_name");
meta.channels = json_u32(obj, "channels");
meta.sample_rate = json_str(obj, "sample_rate");
meta.a_bitrate = json_str(obj, "bit_rate")
.and_then(|s| s.parse::<u64>().ok());
}
"subtitle" => {
let tags = obj
.get("tags")
.and_then(|v| v.as_object());
let language = tags
.and_then(|t| {
json_str_from(t, "language")
.or_else(|| json_str_from(t, "LANGUAGE"))
})
.unwrap_or_default();
let title = tags
.and_then(|t| {
json_str_from(t, "title")
.or_else(|| json_str_from(t, "TITLE"))
})
.unwrap_or_default();
let stream_index = obj
.get("index")
.and_then(|v| v.as_u64())
.unwrap_or(idx as u64) as u32;
let codec = json_str(obj, "codec_name")
.unwrap_or_else(|| "unknown".to_string());
meta.subtitle_tracks.push(SubtitleTrack {
index: stream_index,
codec,
language,
title,
});
}
_ => {}
}
}
}
// Format-level metadata
if let Some(fmt) = fmt {
meta.container_bitrate = json_str_from(fmt, "bit_rate")
.and_then(|s| s.parse::<u64>().ok());
meta.duration = json_str_from(fmt, "duration")
.and_then(|s| s.parse::<f64>().ok());
meta.format_name = json_str_from(fmt, "format_name");
// Container tags
if let Some(ftags) = fmt.get("tags").and_then(|v| v.as_object()) {
let ct = json_str_from(ftags, "title");
if ct.as_deref().map_or(false, |s| !s.is_empty()) {
meta.container_title = ct;
}
let enc = json_str_from(ftags, "encoder");
if enc.as_deref().map_or(false, |s| !s.is_empty()) {
meta.encoder = enc;
}
}
}
// Return None if we extracted nothing useful
let has_data = meta.v_codec.is_some()
|| meta.a_codec.is_some()
|| !meta.subtitle_tracks.is_empty()
|| meta.duration.is_some()
|| meta.format_name.is_some();
if has_data { Some(meta) } else { None }
}
// ---------------------------------------------------------------------------
// JSON helper functions
// ---------------------------------------------------------------------------
/// Extract a string value from a JSON object by key.
fn json_str(obj: &serde_json::Map<String, serde_json::Value>, key: &str) -> Option<String> {
json_str_from(obj, key)
}
/// Extract a string value from a JSON map by key.
fn json_str_from(
obj: &serde_json::Map<String, serde_json::Value>,
key: &str,
) -> Option<String> {
obj.get(key).and_then(|v| match v {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
_ => None,
})
}
/// Extract a u32 value from a JSON object by key.
fn json_u32(obj: &serde_json::Map<String, serde_json::Value>, key: &str) -> Option<u32> {
obj.get(key).and_then(|v| v.as_u64()).map(|n| n as u32)
}
/// Parse a frame rate string like "30000/1001" into an f64.
fn parse_frame_rate(fr: &str) -> Option<f64> {
if let Some((num_str, den_str)) = fr.split_once('/') {
let num: f64 = num_str.trim().parse().ok()?;
let den: f64 = den_str.trim().parse().ok()?;
if den == 0.0 {
return None;
}
let fps = num / den;
if fps > 0.0 { Some(fps) } else { None }
} else {
// Plain number
let fps: f64 = fr.trim().parse().ok()?;
if fps > 0.0 { Some(fps) } else { None }
}
}
// ---------------------------------------------------------------------------
// 5. download_ffmpeg (async)
// ---------------------------------------------------------------------------
/// Download ffmpeg from GitHub, extract `ffmpeg.exe` and `ffprobe.exe`, and
/// place them in `state_dir/ffmpeg/`.
///
/// Reports progress via the provided `tokio::sync::mpsc::Sender`.
pub async fn download_ffmpeg(
state_dir: &Path,
progress_tx: tokio::sync::mpsc::Sender<DownloadProgress>,
) -> Result<FfmpegPaths, String> {
let dest_dir = state_dir.join("ffmpeg");
std::fs::create_dir_all(&dest_dir)
.map_err(|e| format!("Failed to create ffmpeg directory: {}", e))?;
let zip_path = dest_dir.join("ffmpeg-download.zip");
// Start the download
let client = reqwest::Client::new();
let response = client
.get(FFMPEG_DOWNLOAD_URL)
.send()
.await
.map_err(|e| format!("Failed to start download: {}", e))?;
if !response.status().is_success() {
return Err(format!("Download failed with status: {}", response.status()));
}
let total_bytes = response.content_length().unwrap_or(0);
let mut downloaded_bytes: u64 = 0;
// Stream the response body to disk chunk by chunk using reqwest's chunk()
let mut file = std::fs::File::create(&zip_path)
.map_err(|e| format!("Failed to create zip file: {}", e))?;
let mut response = response;
while let Some(chunk) = response
.chunk()
.await
.map_err(|e| format!("Download stream error: {}", e))?
{
std::io::Write::write_all(&mut file, &chunk)
.map_err(|e| format!("Failed to write chunk: {}", e))?;
downloaded_bytes += chunk.len() as u64;
let percent = if total_bytes > 0 {
(downloaded_bytes as f64 / total_bytes as f64) * 100.0
} else {
0.0
};
// Send progress update (ignore send errors if receiver dropped)
let _ = progress_tx
.send(DownloadProgress {
percent,
downloaded_bytes,
total_bytes,
})
.await;
}
drop(file);
// Extract ffmpeg.exe and ffprobe.exe from the zip
extract_ffmpeg_from_zip(&zip_path, &dest_dir)?;
// Clean up the zip file
std::fs::remove_file(&zip_path).ok();
// Verify the extracted files exist
let ffmpeg_exe = if cfg!(target_os = "windows") {
"ffmpeg.exe"
} else {
"ffmpeg"
};
let ffprobe_exe = if cfg!(target_os = "windows") {
"ffprobe.exe"
} else {
"ffprobe"
};
let ffmpeg_path = dest_dir.join(ffmpeg_exe);
let ffprobe_path = dest_dir.join(ffprobe_exe);
Ok(FfmpegPaths {
ffprobe: if ffprobe_path.is_file() {
Some(ffprobe_path)
} else {
None
},
ffmpeg: if ffmpeg_path.is_file() {
Some(ffmpeg_path)
} else {
None
},
})
}
/// Extract only `ffmpeg.exe` and `ffprobe.exe` from a downloaded zip archive.
///
/// The BtbN builds have files nested inside a directory like
/// `ffmpeg-master-latest-win64-gpl/bin/ffmpeg.exe`, so we search for any entry
/// whose filename ends with the target name.
fn extract_ffmpeg_from_zip(zip_path: &Path, dest_dir: &Path) -> Result<(), String> {
let file = std::fs::File::open(zip_path)
.map_err(|e| format!("Failed to open zip: {}", e))?;
let mut archive = zip::ZipArchive::new(file)
.map_err(|e| format!("Failed to read zip archive: {}", e))?;
let targets: &[&str] = if cfg!(target_os = "windows") {
&["ffmpeg.exe", "ffprobe.exe"]
} else {
&["ffmpeg", "ffprobe"]
};
for i in 0..archive.len() {
let mut entry = archive
.by_index(i)
.map_err(|e| format!("Failed to read zip entry {}: {}", i, e))?;
let entry_name = entry.name().to_string();
// Check if this entry matches one of our target filenames
for target in targets {
if entry_name.ends_with(&format!("/{}", target))
|| entry_name == *target
{
let out_path = dest_dir.join(target);
let mut out_file = std::fs::File::create(&out_path)
.map_err(|e| format!("Failed to create {}: {}", target, e))?;
std::io::copy(&mut entry, &mut out_file)
.map_err(|e| format!("Failed to extract {}: {}", target, e))?;
break;
}
}
}
Ok(())
}
// ===========================================================================
// Tests
// ===========================================================================
#[cfg(test)]
mod tests {
use super::*;
// -- parse_ffmpeg_duration -----------------------------------------------
#[test]
fn test_parse_ffmpeg_duration_standard() {
let stderr = r#"
Input #0, matroska,webm, from 'video.mkv':
Duration: 01:23:45.67, start: 0.000000, bitrate: 5000 kb/s
Stream #0:0: Video: h264
"#;
let d = parse_ffmpeg_duration(stderr).unwrap();
let expected = 1.0 * 3600.0 + 23.0 * 60.0 + 45.67;
assert!((d - expected).abs() < 0.001, "got {}, expected {}", d, expected);
}
#[test]
fn test_parse_ffmpeg_duration_short_video() {
let stderr = " Duration: 00:00:30.50, start: 0.000000, bitrate: 1200 kb/s\n";
let d = parse_ffmpeg_duration(stderr).unwrap();
assert!((d - 30.5).abs() < 0.001);
}
#[test]
fn test_parse_ffmpeg_duration_whole_seconds() {
let stderr = " Duration: 00:05:00.00, start: 0.0\n";
let d = parse_ffmpeg_duration(stderr).unwrap();
assert!((d - 300.0).abs() < 0.001);
}
#[test]
fn test_parse_ffmpeg_duration_zero() {
// Zero duration should return None (not positive)
let stderr = " Duration: 00:00:00.00, start: 0.0\n";
assert!(parse_ffmpeg_duration(stderr).is_none());
}
#[test]
fn test_parse_ffmpeg_duration_no_match() {
let stderr = "some random output without duration info\n";
assert!(parse_ffmpeg_duration(stderr).is_none());
}
#[test]
fn test_parse_ffmpeg_duration_empty() {
assert!(parse_ffmpeg_duration("").is_none());
}
// -- discover_not_found --------------------------------------------------
#[test]
fn test_discover_not_found() {
// Use non-existent directories -- should return None for both paths
let exe_dir = Path::new("/nonexistent/path/that/does/not/exist/exe");
let state_dir = Path::new("/nonexistent/path/that/does/not/exist/state");
let paths = discover(exe_dir, state_dir);
// On a system without ffmpeg in PATH these will be None;
// on a system with ffmpeg installed they may be Some.
// We simply verify the function does not panic.
let _ = paths.ffprobe;
let _ = paths.ffmpeg;
}
// -- video_metadata_default ----------------------------------------------
#[test]
fn test_video_metadata_default() {
let meta = VideoMetadata::default();
assert!(meta.v_codec.is_none());
assert!(meta.width.is_none());
assert!(meta.height.is_none());
assert!(meta.fps.is_none());
assert!(meta.v_bitrate.is_none());
assert!(meta.pix_fmt.is_none());
assert!(meta.color_space.is_none());
assert!(meta.a_codec.is_none());
assert!(meta.channels.is_none());
assert!(meta.sample_rate.is_none());
assert!(meta.a_bitrate.is_none());
assert!(meta.subtitle_tracks.is_empty());
assert!(meta.container_bitrate.is_none());
assert!(meta.duration.is_none());
assert!(meta.format_name.is_none());
assert!(meta.container_title.is_none());
assert!(meta.encoder.is_none());
}
// -- download_progress_serialization -------------------------------------
#[test]
fn test_download_progress_serialization() {
let progress = DownloadProgress {
percent: 50.5,
downloaded_bytes: 1024,
total_bytes: 2048,
};
let json = serde_json::to_string(&progress).unwrap();
let parsed: DownloadProgress = serde_json::from_str(&json).unwrap();
assert!((parsed.percent - 50.5).abs() < f64::EPSILON);
assert_eq!(parsed.downloaded_bytes, 1024);
assert_eq!(parsed.total_bytes, 2048);
}
#[test]
fn test_download_progress_json_fields() {
let progress = DownloadProgress {
percent: 100.0,
downloaded_bytes: 5000,
total_bytes: 5000,
};
let value: serde_json::Value = serde_json::to_value(&progress).unwrap();
assert_eq!(value["percent"], 100.0);
assert_eq!(value["downloaded_bytes"], 5000);
assert_eq!(value["total_bytes"], 5000);
}
// -- parse_frame_rate ----------------------------------------------------
#[test]
fn test_parse_frame_rate_fraction() {
let fps = parse_frame_rate("30000/1001").unwrap();
assert!((fps - 29.97).abs() < 0.01);
}
#[test]
fn test_parse_frame_rate_integer() {
let fps = parse_frame_rate("24/1").unwrap();
assert!((fps - 24.0).abs() < 0.001);
}
#[test]
fn test_parse_frame_rate_zero_denominator() {
assert!(parse_frame_rate("24/0").is_none());
}
#[test]
fn test_parse_frame_rate_plain_number() {
let fps = parse_frame_rate("60").unwrap();
assert!((fps - 60.0).abs() < 0.001);
}
// -- subtitle_track_serialization ----------------------------------------
#[test]
fn test_subtitle_track_serialization() {
let track = SubtitleTrack {
index: 2,
codec: "srt".to_string(),
language: "eng".to_string(),
title: "English".to_string(),
};
let json = serde_json::to_string(&track).unwrap();
let parsed: SubtitleTrack = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.index, 2);
assert_eq!(parsed.codec, "srt");
assert_eq!(parsed.language, "eng");
assert_eq!(parsed.title, "English");
}
// -- integration tests (require actual ffprobe/ffmpeg) -------------------
#[test]
#[ignore]
fn test_duration_seconds_with_real_ffprobe() {
// This test requires ffprobe and ffmpeg to be installed and a sample
// video file at the given path.
let exe_dir = Path::new(".");
let state_dir = Path::new(".");
let paths = discover(exe_dir, state_dir);
if paths.ffprobe.is_none() && paths.ffmpeg.is_none() {
eprintln!("Skipping: no ffprobe or ffmpeg found");
return;
}
// Would need a real video file here
// let d = duration_seconds(Path::new("sample.mp4"), &paths);
// assert!(d.is_some());
}
#[test]
#[ignore]
fn test_ffprobe_video_metadata_with_real_ffprobe() {
let exe_dir = Path::new(".");
let state_dir = Path::new(".");
let paths = discover(exe_dir, state_dir);
if let Some(ref ffprobe) = paths.ffprobe {
// Would need a real video file here
// let meta = ffprobe_video_metadata(Path::new("sample.mp4"), ffprobe);
// assert!(meta.is_some());
let _ = ffprobe;
} else {
eprintln!("Skipping: no ffprobe found");
}
}
}

614
src-tauri/src/fonts.rs Normal file
View File

@@ -0,0 +1,614 @@
use once_cell::sync::Lazy;
use regex::Regex;
use serde_json::json;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::time::SystemTime;
use crate::state::{atomic_write_json, load_json_with_fallbacks, BACKUP_COUNT};
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// Current version for Google Fonts metadata (fonts_meta.json).
const GOOGLE_FONTS_META_VERSION: u64 = 7;
/// Current version for Font Awesome metadata (fa_meta.json).
const FA_META_VERSION: u64 = 3;
/// User-Agent header value for HTTP requests.
/// Google Fonts API returns different CSS based on User-Agent; we want woff2.
const USER_AGENT: &str = "Mozilla/5.0";
/// Google Fonts CSS URLs.
const GOOGLE_FONT_URLS: &[(&str, &str)] = &[
(
"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",
),
];
/// Font Awesome CSS URL.
const FA_CSS_URL: &str =
"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css";
/// Base URL for resolving relative Font Awesome webfont URLs.
const FA_WEBFONTS_BASE: &str =
"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/webfonts/";
// ---------------------------------------------------------------------------
// Compiled regex patterns
// ---------------------------------------------------------------------------
/// Regex for extracting woff2 font URLs from Google Fonts CSS.
static GOOGLE_FONT_URL_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r#"url\(([^)]+)\)\s*format\(['"]woff2['"]\)"#).unwrap());
/// Regex for extracting all url(...) references from Font Awesome CSS.
static FA_URL_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"url\(([^)]+)\)").unwrap());
// ---------------------------------------------------------------------------
// 1. safe_filename_from_url
// ---------------------------------------------------------------------------
/// Generate a safe local filename from a URL using SHA-256 hash for uniqueness.
///
/// The filename is `{stem}-{hash}{suffix}` where `hash` is the first 10 hex
/// characters of the SHA-256 digest of the full URL. If the URL path has no
/// extension, `.woff2` is appended.
pub fn safe_filename_from_url(url: &str) -> String {
// Extract the last path component from the URL
let base = url
.split('?')
.next()
.unwrap_or(url)
.split('#')
.next()
.unwrap_or(url)
.rsplit('/')
.next()
.unwrap_or("font.woff2");
let base = if base.is_empty() { "font.woff2" } else { base };
// Ensure the base has an extension
let base = if !base.contains('.') {
format!("{}.woff2", base)
} else {
base.to_string()
};
// Split into stem and suffix
let (stem, suffix) = match base.rfind('.') {
Some(pos) => (&base[..pos], &base[pos..]),
None => (base.as_str(), ".woff2"),
};
// Compute SHA-256 hash of the full URL
let mut hasher = Sha256::new();
hasher.update(url.as_bytes());
let digest = format!("{:x}", hasher.finalize());
let url_hash = &digest[..10];
format!("{}-{}{}", stem, url_hash, suffix)
}
// ---------------------------------------------------------------------------
// 2. ensure_google_fonts_local
// ---------------------------------------------------------------------------
/// Download and cache Google Fonts (Sora, Manrope, IBM Plex Mono) locally.
///
/// The `fonts_dir` is the directory where `fonts.css`, `fonts_meta.json`, and
/// individual `.woff2` files are stored.
///
/// If already cached (version matches, ok=true, CSS file exists), this is a
/// no-op. Otherwise, downloads each font family's CSS from the Google Fonts
/// API, extracts woff2 URLs, downloads each font file, rewrites the CSS to
/// use local paths, and writes the combined CSS and metadata.
pub async fn ensure_google_fonts_local(fonts_dir: &Path) -> Result<(), String> {
fs::create_dir_all(fonts_dir).map_err(|e| format!("Failed to create fonts dir: {}", e))?;
let meta_path = fonts_dir.join("fonts_meta.json");
let css_path = fonts_dir.join("fonts.css");
// Check if already cached
if let Some(meta) = load_json_with_fallbacks(&meta_path, BACKUP_COUNT) {
if let Some(obj) = meta.as_object() {
let version_ok = obj
.get("version")
.and_then(|v| v.as_u64())
.map(|v| v == GOOGLE_FONTS_META_VERSION)
.unwrap_or(false);
let ok_flag = obj
.get("ok")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if version_ok && ok_flag && css_path.exists() {
return Ok(());
}
}
}
let client = reqwest::Client::builder()
.user_agent(USER_AGENT)
.build()
.map_err(|e| format!("Failed to build HTTP client: {}", e))?;
let mut all_css_parts: Vec<String> = Vec::new();
let mut downloaded_files: Vec<String> = Vec::new();
let mut errors: Vec<String> = Vec::new();
for (family, css_url) in GOOGLE_FONT_URLS {
// Download the CSS for this font family
let css_text = match client.get(*css_url).send().await {
Ok(resp) => match resp.text().await {
Ok(text) => text,
Err(e) => {
errors.push(format!("Failed to read CSS for {}: {}", family, e));
continue;
}
},
Err(e) => {
errors.push(format!("Failed to download CSS for {}: {}", family, e));
continue;
}
};
// Find all woff2 url(...) references and download each font file
let mut rewritten_css = css_text.clone();
let mut replacements: Vec<(String, String)> = Vec::new();
for cap in GOOGLE_FONT_URL_RE.captures_iter(&css_text) {
let raw_url = cap[1].trim().trim_matches('\'').trim_matches('"');
let safe_name = safe_filename_from_url(raw_url);
let local_path = fonts_dir.join(&safe_name);
// Download the font file
match client.get(raw_url).send().await {
Ok(resp) => match resp.bytes().await {
Ok(bytes) => {
if let Err(e) = fs::write(&local_path, &bytes) {
errors.push(format!("Failed to write {}: {}", safe_name, e));
continue;
}
downloaded_files.push(safe_name.clone());
}
Err(e) => {
errors.push(format!("Failed to read bytes for {}: {}", safe_name, e));
continue;
}
},
Err(e) => {
errors.push(format!("Failed to download {}: {}", raw_url, e));
continue;
}
}
// Record the replacement: original url(...) content -> local path
let replacement_url = format!("/fonts/{}", safe_name);
replacements.push((cap[1].to_string(), replacement_url));
}
// Apply all URL replacements to the CSS
for (original, replacement) in &replacements {
let old = format!("url({}) format", original);
let new = format!("url({}) format", replacement);
rewritten_css = rewritten_css.replace(&old, &new);
}
all_css_parts.push(rewritten_css);
}
// Write combined CSS
let combined_css = all_css_parts.join("\n");
fs::write(&css_path, &combined_css)
.map_err(|e| format!("Failed to write fonts.css: {}", e))?;
// Write metadata
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let ok = errors.is_empty();
let meta = json!({
"version": GOOGLE_FONTS_META_VERSION,
"ok": ok,
"timestamp": timestamp,
"downloaded": downloaded_files,
"errors": errors,
});
atomic_write_json(&meta_path, &meta, BACKUP_COUNT);
if ok {
Ok(())
} else {
Err(format!(
"Google Fonts download completed with errors: {}",
errors.join("; ")
))
}
}
// ---------------------------------------------------------------------------
// 3. ensure_fontawesome_local
// ---------------------------------------------------------------------------
/// Clean and resolve a Font Awesome URL reference.
///
/// Strips whitespace and quotes, resolves relative URLs against the FA
/// webfonts base URL. Returns the URL unchanged if it is a `data:` URI.
fn clean_fa_url(u: &str) -> String {
let u = u.trim().trim_matches('\'').trim_matches('"');
if u.starts_with("data:") {
return u.to_string();
}
if u.starts_with("//") {
return format!("https:{}", u);
}
if u.starts_with("http://") || u.starts_with("https://") {
return u.to_string();
}
// Relative URL: strip leading "./" and "../" then join with base
let cleaned = u
.trim_start_matches("./")
.replace("../", "");
format!("{}{}", FA_WEBFONTS_BASE, cleaned)
}
/// Download and cache Font Awesome 6.5.2 locally.
///
/// The `fa_dir` is the directory where `fa.css` and `fa_meta.json` live.
/// The `fa_dir/webfonts/` subdirectory holds individual webfont files.
///
/// If already cached (version matches, ok=true, CSS file exists), this is a
/// no-op. Otherwise, downloads the Font Awesome CSS, extracts all `url(...)`
/// references, downloads each font file (skipping `data:` URIs), rewrites
/// the CSS to use local paths, and writes the CSS and metadata.
pub async fn ensure_fontawesome_local(fa_dir: &Path) -> Result<(), String> {
fs::create_dir_all(fa_dir).map_err(|e| format!("Failed to create fa dir: {}", e))?;
let webfonts_dir = fa_dir.join("webfonts");
fs::create_dir_all(&webfonts_dir)
.map_err(|e| format!("Failed to create webfonts dir: {}", e))?;
let meta_path = fa_dir.join("fa_meta.json");
let css_path = fa_dir.join("fa.css");
// Check if already cached
if let Some(meta) = load_json_with_fallbacks(&meta_path, BACKUP_COUNT) {
if let Some(obj) = meta.as_object() {
let version_ok = obj
.get("version")
.and_then(|v| v.as_u64())
.map(|v| v == FA_META_VERSION)
.unwrap_or(false);
let ok_flag = obj
.get("ok")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if version_ok && ok_flag && css_path.exists() {
return Ok(());
}
}
}
let client = reqwest::Client::builder()
.user_agent(USER_AGENT)
.build()
.map_err(|e| format!("Failed to build HTTP client: {}", e))?;
// Download the Font Awesome CSS
let css_text = client
.get(FA_CSS_URL)
.send()
.await
.map_err(|e| format!("Failed to download FA CSS: {}", e))?
.text()
.await
.map_err(|e| format!("Failed to read FA CSS: {}", e))?;
let mut downloaded_files: Vec<String> = Vec::new();
let mut errors: Vec<String> = Vec::new();
let mut replacements: HashMap<String, String> = HashMap::new();
for cap in FA_URL_RE.captures_iter(&css_text) {
let raw_url = &cap[1];
let resolved = clean_fa_url(raw_url);
// Skip data: URIs
if resolved.starts_with("data:") {
continue;
}
// Determine the filename from the resolved URL
let filename = resolved
.split('?')
.next()
.unwrap_or(&resolved)
.split('#')
.next()
.unwrap_or(&resolved)
.rsplit('/')
.next()
.unwrap_or("font.woff2")
.to_string();
if filename.is_empty() {
continue;
}
let local_path = webfonts_dir.join(&filename);
// Only download each file once
if !replacements.contains_key(raw_url) {
match client.get(&resolved).send().await {
Ok(resp) => match resp.bytes().await {
Ok(bytes) => {
if let Err(e) = fs::write(&local_path, &bytes) {
errors.push(format!("Failed to write {}: {}", filename, e));
continue;
}
downloaded_files.push(filename.clone());
}
Err(e) => {
errors.push(format!("Failed to read bytes for {}: {}", filename, e));
continue;
}
},
Err(e) => {
errors.push(format!("Failed to download {}: {}", resolved, e));
continue;
}
}
let replacement = format!("/fa/webfonts/{}", filename);
replacements.insert(raw_url.to_string(), replacement);
}
}
// Rewrite CSS with local paths
let mut rewritten_css = css_text.clone();
for (original, replacement) in &replacements {
let old = format!("url({})", original);
let new = format!("url({})", replacement);
rewritten_css = rewritten_css.replace(&old, &new);
}
// Write rewritten CSS
fs::write(&css_path, &rewritten_css)
.map_err(|e| format!("Failed to write fa.css: {}", e))?;
// Write metadata
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let ok = errors.is_empty();
let meta = json!({
"version": FA_META_VERSION,
"ok": ok,
"timestamp": timestamp,
"downloaded": downloaded_files,
"errors": errors,
});
atomic_write_json(&meta_path, &meta, BACKUP_COUNT);
if ok {
Ok(())
} else {
Err(format!(
"Font Awesome download completed with errors: {}",
errors.join("; ")
))
}
}
// ===========================================================================
// Tests
// ===========================================================================
#[cfg(test)]
mod tests {
use super::*;
// -- safe_filename_from_url -----------------------------------------------
#[test]
fn test_safe_filename_from_url_basic() {
let url = "https://fonts.gstatic.com/s/sora/v12/abc123.woff2";
let result = safe_filename_from_url(url);
// Should contain the original stem
assert!(result.starts_with("abc123-"));
// Should end with .woff2
assert!(result.ends_with(".woff2"));
// Should contain a 10-char hash between stem and extension
let parts: Vec<&str> = result.rsplitn(2, '.').collect();
let before_ext = parts[1]; // "abc123-{hash}"
let hash_part = before_ext.rsplit('-').next().unwrap();
assert_eq!(hash_part.len(), 10);
}
#[test]
fn test_safe_filename_from_url_no_extension() {
let url = "https://example.com/fontfile";
let result = safe_filename_from_url(url);
// Should have .woff2 appended
assert!(result.ends_with(".woff2"));
assert!(result.starts_with("fontfile-"));
}
#[test]
fn test_safe_filename_from_url_deterministic() {
let url = "https://fonts.gstatic.com/s/sora/v12/abc.woff2";
let result1 = safe_filename_from_url(url);
let result2 = safe_filename_from_url(url);
assert_eq!(result1, result2);
}
#[test]
fn test_safe_filename_different_urls() {
let url1 = "https://fonts.gstatic.com/s/sora/v12/abc.woff2";
let url2 = "https://fonts.gstatic.com/s/manrope/v14/def.woff2";
let result1 = safe_filename_from_url(url1);
let result2 = safe_filename_from_url(url2);
assert_ne!(result1, result2);
}
// -- clean_fa_url ---------------------------------------------------------
#[test]
fn test_clean_fa_url_data() {
let result = clean_fa_url("data:font/woff2;base64,abc");
assert_eq!(result, "data:font/woff2;base64,abc");
}
#[test]
fn test_clean_fa_url_protocol_relative() {
let result = clean_fa_url("//example.com/font.woff2");
assert_eq!(result, "https://example.com/font.woff2");
}
#[test]
fn test_clean_fa_url_absolute() {
let result = clean_fa_url("https://example.com/font.woff2");
assert_eq!(result, "https://example.com/font.woff2");
}
#[test]
fn test_clean_fa_url_relative() {
let result = clean_fa_url("../webfonts/fa-solid-900.woff2");
assert_eq!(
result,
"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/webfonts/webfonts/fa-solid-900.woff2"
);
}
#[test]
fn test_clean_fa_url_relative_dot_slash() {
let result = clean_fa_url("./webfonts/fa-solid-900.woff2");
assert_eq!(
result,
"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/webfonts/webfonts/fa-solid-900.woff2"
);
}
#[test]
fn test_clean_fa_url_strips_quotes() {
let result = clean_fa_url("'https://example.com/font.woff2'");
assert_eq!(result, "https://example.com/font.woff2");
}
// -- Integration tests (require network) ----------------------------------
#[tokio::test]
#[ignore]
async fn test_google_fonts_download() {
let dir = tempfile::tempdir().unwrap();
let fonts_dir = dir.path().join("fonts");
let result = ensure_google_fonts_local(&fonts_dir).await;
assert!(result.is_ok(), "Google Fonts download failed: {:?}", result);
// Verify fonts.css was created
let css_path = fonts_dir.join("fonts.css");
assert!(css_path.exists(), "fonts.css should exist");
let css_content = fs::read_to_string(&css_path).unwrap();
assert!(!css_content.is_empty(), "fonts.css should not be empty");
// CSS should contain rewritten local paths
assert!(
css_content.contains("/fonts/"),
"CSS should contain /fonts/ local paths"
);
// Verify metadata was created
let meta_path = fonts_dir.join("fonts_meta.json");
assert!(meta_path.exists(), "fonts_meta.json should exist");
let meta = load_json_with_fallbacks(&meta_path, BACKUP_COUNT).unwrap();
assert_eq!(meta["version"], GOOGLE_FONTS_META_VERSION);
assert_eq!(meta["ok"], true);
assert!(
meta["downloaded"].as_array().unwrap().len() > 0,
"Should have downloaded at least one font file"
);
// Second call should be a no-op (cached)
let result2 = ensure_google_fonts_local(&fonts_dir).await;
assert!(result2.is_ok());
}
#[tokio::test]
#[ignore]
async fn test_fontawesome_download() {
let dir = tempfile::tempdir().unwrap();
let fa_dir = dir.path().join("fa");
let result = ensure_fontawesome_local(&fa_dir).await;
assert!(
result.is_ok(),
"Font Awesome download failed: {:?}",
result
);
// Verify fa.css was created
let css_path = fa_dir.join("fa.css");
assert!(css_path.exists(), "fa.css should exist");
let css_content = fs::read_to_string(&css_path).unwrap();
assert!(!css_content.is_empty(), "fa.css should not be empty");
// CSS should contain rewritten local paths
assert!(
css_content.contains("/fa/webfonts/"),
"CSS should contain /fa/webfonts/ local paths"
);
// Verify webfonts directory has files
let webfonts_dir = fa_dir.join("webfonts");
assert!(webfonts_dir.exists(), "webfonts dir should exist");
let webfont_files: Vec<_> = fs::read_dir(&webfonts_dir)
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert!(
!webfont_files.is_empty(),
"Should have downloaded at least one webfont file"
);
// Verify metadata was created
let meta_path = fa_dir.join("fa_meta.json");
assert!(meta_path.exists(), "fa_meta.json should exist");
let meta = load_json_with_fallbacks(&meta_path, BACKUP_COUNT).unwrap();
assert_eq!(meta["version"], FA_META_VERSION);
assert_eq!(meta["ok"], true);
// Second call should be a no-op (cached)
let result2 = ensure_fontawesome_local(&fa_dir).await;
assert!(result2.is_ok());
}
}

View File

@@ -1,10 +1,21 @@
pub mod state;
pub mod utils;
use std::path::PathBuf;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
pub mod commands;
pub mod ffmpeg;
pub mod fonts;
pub mod library;
pub mod prefs;
pub mod recents;
pub mod state;
pub mod subtitles;
pub mod utils;
pub mod video_protocol;
/// Application directory paths resolved at startup, managed as Tauri state.
pub struct AppPaths {
pub exe_dir: PathBuf,
pub state_dir: PathBuf,
pub fonts_dir: PathBuf,
pub fa_dir: PathBuf,
pub subs_dir: PathBuf,
}

1966
src-tauri/src/library.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,153 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::sync::Mutex;
use tauri::Manager;
use tutorialvault_lib::{commands, ffmpeg, fonts, library, prefs, video_protocol, AppPaths};
fn main() {
tutorialdock_lib::run();
// 1. Resolve exe directory for portability.
let exe_dir = std::env::current_exe()
.expect("cannot resolve exe path")
.parent()
.expect("exe has no parent")
.to_path_buf();
let state_dir = exe_dir.join("state");
// 2. Create all subdirectories.
let paths = AppPaths {
exe_dir: exe_dir.clone(),
state_dir: state_dir.clone(),
fonts_dir: state_dir.join("fonts"),
fa_dir: state_dir.join("fontawesome"),
subs_dir: state_dir.join("subtitles"),
};
std::fs::create_dir_all(&paths.fonts_dir).ok();
std::fs::create_dir_all(&paths.fa_dir.join("webfonts")).ok();
std::fs::create_dir_all(&paths.subs_dir).ok();
// 3. Set WebView2 user data folder for portability (no AppData usage).
let webview_profile = state_dir.join("webview_profile");
std::fs::create_dir_all(&webview_profile).ok();
unsafe {
std::env::set_var("WEBVIEW2_USER_DATA_FOLDER", &webview_profile);
}
// 4. Load preferences.
let prefs_data = prefs::Prefs::load(&state_dir);
// 5. Initialize library (empty — loaded from last folder or frontend action).
let mut lib = library::Library::new();
// Discover ffmpeg/ffprobe.
let ff_paths = ffmpeg::discover(&paths.exe_dir, &paths.state_dir);
let needs_ffmpeg_download = ff_paths.ffprobe.is_none() || ff_paths.ffmpeg.is_none();
lib.ffprobe = ff_paths.ffprobe;
lib.ffmpeg = ff_paths.ffmpeg;
// Restore last folder if it exists.
if let Some(ref last_path) = prefs_data.last_folder_path {
if std::path::Path::new(last_path).is_dir() {
let _ = lib.set_root(last_path, &state_dir);
}
}
// 6. Build Tauri app.
let builder = tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.manage(Mutex::new(lib))
.manage(Mutex::new(prefs_data))
.manage(paths)
.invoke_handler(tauri::generate_handler![
commands::select_folder,
commands::open_folder_path,
commands::get_recents,
commands::remove_recent,
commands::get_library,
commands::set_current,
commands::tick_progress,
commands::set_folder_volume,
commands::set_folder_autoplay,
commands::set_folder_rate,
commands::set_order,
commands::start_duration_scan,
commands::get_prefs,
commands::set_prefs,
commands::set_always_on_top,
commands::save_window_state,
commands::get_note,
commands::set_note,
commands::get_current_video_meta,
commands::get_current_subtitle,
commands::get_embedded_subtitles,
commands::extract_embedded_subtitle,
commands::get_available_subtitles,
commands::load_sidecar_subtitle,
commands::choose_subtitle_file,
commands::reset_watch_progress,
]);
// Register custom protocol for video/subtitle/font serving.
let builder = video_protocol::register_protocol(builder);
// Configure window from saved prefs and launch.
builder
.setup(move |app| {
let prefs_state = app.state::<Mutex<prefs::Prefs>>();
let p = prefs_state.lock().unwrap();
let win = app.get_webview_window("main").unwrap();
// Only restore position/size if values are sane (not minimized/offscreen).
let w = p.window.width.max(640) as u32;
let h = p.window.height.max(480) as u32;
if let (Some(x), Some(y)) = (p.window.x, p.window.y) {
if x > -10000 && y > -10000 && x < 10000 && y < 10000 {
let _ = win.set_position(tauri::Position::Physical(
tauri::PhysicalPosition::new(x, y),
));
}
}
let _ = win.set_size(tauri::Size::Physical(tauri::PhysicalSize::new(w, h)));
let _ = win.set_always_on_top(p.always_on_top);
drop(p);
// Spawn async font caching (silent, non-blocking).
let app_paths = app.state::<AppPaths>();
let fonts_dir = app_paths.fonts_dir.clone();
let fa_dir = app_paths.fa_dir.clone();
tauri::async_runtime::spawn(async move {
let _ = fonts::ensure_google_fonts_local(&fonts_dir).await;
let _ = fonts::ensure_fontawesome_local(&fa_dir).await;
});
// Auto-download ffmpeg/ffprobe if not found locally.
if needs_ffmpeg_download {
let handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
let sd = handle.state::<AppPaths>().state_dir.clone();
let (tx, _rx) = tokio::sync::mpsc::channel(16);
match ffmpeg::download_ffmpeg(&sd, tx).await {
Ok(paths) => {
let lib_state = handle.state::<Mutex<library::Library>>();
if let Ok(mut lib) = lib_state.lock() {
if lib.ffprobe.is_none() {
lib.ffprobe = paths.ffprobe;
}
if lib.ffmpeg.is_none() {
lib.ffmpeg = paths.ffmpeg;
}
}
eprintln!("[ffmpeg] Auto-download complete");
}
Err(e) => {
eprintln!("[ffmpeg] Auto-download failed: {}", e);
}
}
});
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

441
src-tauri/src/prefs.rs Normal file
View File

@@ -0,0 +1,441 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::Path;
use std::time::SystemTime;
use crate::state::{atomic_write_json, load_json_with_fallbacks, BACKUP_COUNT};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WindowState {
pub width: i32,
pub height: i32,
pub x: Option<i32>,
pub y: Option<i32>,
}
impl Default for WindowState {
fn default() -> Self {
Self {
width: 1320,
height: 860,
x: None,
y: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Prefs {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default = "default_ui_zoom")]
pub ui_zoom: f64,
#[serde(default = "default_split_ratio")]
pub split_ratio: f64,
#[serde(default = "default_dock_ratio")]
pub dock_ratio: f64,
#[serde(default)]
pub always_on_top: bool,
#[serde(default)]
pub window: WindowState,
#[serde(default)]
pub last_folder_path: Option<String>,
#[serde(default)]
pub last_library_id: Option<String>,
#[serde(default)]
pub updated_at: u64,
}
fn default_version() -> u32 {
19
}
fn default_ui_zoom() -> f64 {
1.0
}
fn default_split_ratio() -> f64 {
0.62
}
fn default_dock_ratio() -> f64 {
0.62
}
impl Default for Prefs {
fn default() -> Self {
Self {
version: 19,
ui_zoom: 1.0,
split_ratio: 0.62,
dock_ratio: 0.62,
always_on_top: false,
window: WindowState::default(),
last_folder_path: None,
last_library_id: None,
updated_at: 0,
}
}
}
impl Prefs {
/// Load preferences from `{state_dir}/prefs.json`, merging with defaults.
///
/// Window fields are merged individually so that a saved file with only
/// `{"window": {"width": 800}}` keeps the default height, x, and y.
pub fn load(state_dir: &Path) -> Prefs {
let path = state_dir.join("prefs.json");
let loaded = load_json_with_fallbacks(&path, BACKUP_COUNT);
let raw = match loaded {
Some(v) => v,
None => return Prefs::default(),
};
let raw_obj = match raw.as_object() {
Some(obj) => obj,
None => return Prefs::default(),
};
// Start with defaults
let mut prefs = Prefs::default();
// Merge window fields individually if present
if let Some(Value::Object(win_obj)) = raw_obj.get("window") {
if let Some(Value::Number(n)) = win_obj.get("width") {
if let Some(v) = n.as_i64() {
prefs.window.width = v as i32;
}
}
if let Some(Value::Number(n)) = win_obj.get("height") {
if let Some(v) = n.as_i64() {
prefs.window.height = v as i32;
}
}
if let Some(val) = win_obj.get("x") {
prefs.window.x = val.as_i64().map(|v| v as i32);
}
if let Some(val) = win_obj.get("y") {
prefs.window.y = val.as_i64().map(|v| v as i32);
}
}
// Merge all other top-level keys
if let Some(Value::Number(n)) = raw_obj.get("version") {
if let Some(v) = n.as_u64() {
prefs.version = v as u32;
}
}
if let Some(Value::Number(n)) = raw_obj.get("ui_zoom") {
if let Some(v) = n.as_f64() {
prefs.ui_zoom = v;
}
}
if let Some(Value::Number(n)) = raw_obj.get("split_ratio") {
if let Some(v) = n.as_f64() {
prefs.split_ratio = v;
}
}
if let Some(Value::Number(n)) = raw_obj.get("dock_ratio") {
if let Some(v) = n.as_f64() {
prefs.dock_ratio = v;
}
}
if let Some(Value::Bool(b)) = raw_obj.get("always_on_top") {
prefs.always_on_top = *b;
}
if let Some(val) = raw_obj.get("last_folder_path") {
prefs.last_folder_path = val.as_str().map(|s| s.to_string());
}
if let Some(val) = raw_obj.get("last_library_id") {
prefs.last_library_id = val.as_str().map(|s| s.to_string());
}
if let Some(Value::Number(n)) = raw_obj.get("updated_at") {
if let Some(v) = n.as_u64() {
prefs.updated_at = v;
}
}
prefs
}
/// Save preferences to `{state_dir}/prefs.json`.
pub fn save(&self, state_dir: &Path) {
let path = state_dir.join("prefs.json");
let value = serde_json::to_value(self).expect("failed to serialize Prefs");
atomic_write_json(&path, &value, BACKUP_COUNT);
}
/// Apply a partial JSON update, merge window fields individually, set
/// `updated_at` to the current unix timestamp, and save.
pub fn update(&mut self, patch: &Value, state_dir: &Path) {
if let Some(obj) = patch.as_object() {
// Merge window fields individually
if let Some(Value::Object(win_obj)) = obj.get("window") {
if let Some(Value::Number(n)) = win_obj.get("width") {
if let Some(v) = n.as_i64() {
self.window.width = v as i32;
}
}
if let Some(Value::Number(n)) = win_obj.get("height") {
if let Some(v) = n.as_i64() {
self.window.height = v as i32;
}
}
if let Some(val) = win_obj.get("x") {
self.window.x = val.as_i64().map(|v| v as i32);
}
if let Some(val) = win_obj.get("y") {
self.window.y = val.as_i64().map(|v| v as i32);
}
}
// Apply other top-level keys
if let Some(Value::Number(n)) = obj.get("version") {
if let Some(v) = n.as_u64() {
self.version = v as u32;
}
}
if let Some(Value::Number(n)) = obj.get("ui_zoom") {
if let Some(v) = n.as_f64() {
self.ui_zoom = v;
}
}
if let Some(Value::Number(n)) = obj.get("split_ratio") {
if let Some(v) = n.as_f64() {
self.split_ratio = v;
}
}
if let Some(Value::Number(n)) = obj.get("dock_ratio") {
if let Some(v) = n.as_f64() {
self.dock_ratio = v;
}
}
if let Some(Value::Bool(b)) = obj.get("always_on_top") {
self.always_on_top = *b;
}
if let Some(val) = obj.get("last_folder_path") {
self.last_folder_path = val.as_str().map(|s| s.to_string());
}
if let Some(val) = obj.get("last_library_id") {
self.last_library_id = val.as_str().map(|s| s.to_string());
}
// Set updated_at to current unix timestamp
self.updated_at = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
self.save(state_dir);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::TempDir;
#[test]
fn test_default_prefs() {
let prefs = Prefs::default();
assert_eq!(prefs.version, 19);
assert_eq!(prefs.ui_zoom, 1.0);
assert_eq!(prefs.split_ratio, 0.62);
assert_eq!(prefs.dock_ratio, 0.62);
assert!(!prefs.always_on_top);
assert_eq!(prefs.window.width, 1320);
assert_eq!(prefs.window.height, 860);
assert_eq!(prefs.window.x, None);
assert_eq!(prefs.window.y, None);
assert_eq!(prefs.last_folder_path, None);
assert_eq!(prefs.last_library_id, None);
assert_eq!(prefs.updated_at, 0);
}
#[test]
fn test_default_window_state() {
let ws = WindowState::default();
assert_eq!(ws.width, 1320);
assert_eq!(ws.height, 860);
assert_eq!(ws.x, None);
assert_eq!(ws.y, None);
}
#[test]
fn test_save_and_load_round_trip() {
let dir = TempDir::new().unwrap();
let mut prefs = Prefs::default();
prefs.ui_zoom = 1.5;
prefs.split_ratio = 0.75;
prefs.always_on_top = true;
prefs.window.width = 1920;
prefs.window.height = 1080;
prefs.window.x = Some(100);
prefs.window.y = Some(200);
prefs.last_folder_path = Some("/my/folder".to_string());
prefs.last_library_id = Some("lib-abc".to_string());
prefs.updated_at = 1234567890;
prefs.save(dir.path());
let loaded = Prefs::load(dir.path());
assert_eq!(loaded.version, 19);
assert_eq!(loaded.ui_zoom, 1.5);
assert_eq!(loaded.split_ratio, 0.75);
assert_eq!(loaded.dock_ratio, 0.62);
assert!(loaded.always_on_top);
assert_eq!(loaded.window.width, 1920);
assert_eq!(loaded.window.height, 1080);
assert_eq!(loaded.window.x, Some(100));
assert_eq!(loaded.window.y, Some(200));
assert_eq!(loaded.last_folder_path, Some("/my/folder".to_string()));
assert_eq!(loaded.last_library_id, Some("lib-abc".to_string()));
assert_eq!(loaded.updated_at, 1234567890);
}
#[test]
fn test_load_nonexistent_returns_defaults() {
let dir = TempDir::new().unwrap();
let prefs = Prefs::load(dir.path());
assert_eq!(prefs.version, 19);
assert_eq!(prefs.ui_zoom, 1.0);
assert_eq!(prefs.window.width, 1320);
}
#[test]
fn test_partial_update() {
let dir = TempDir::new().unwrap();
let mut prefs = Prefs::default();
let patch = json!({
"ui_zoom": 2.0,
"always_on_top": true
});
prefs.update(&patch, dir.path());
assert_eq!(prefs.ui_zoom, 2.0);
assert!(prefs.always_on_top);
// Unchanged fields stay at defaults
assert_eq!(prefs.split_ratio, 0.62);
assert_eq!(prefs.dock_ratio, 0.62);
assert_eq!(prefs.window.width, 1320);
// updated_at should have been set
assert!(prefs.updated_at > 0);
}
#[test]
fn test_update_saves_to_disk() {
let dir = TempDir::new().unwrap();
let mut prefs = Prefs::default();
let patch = json!({"ui_zoom": 3.0});
prefs.update(&patch, dir.path());
let loaded = Prefs::load(dir.path());
assert_eq!(loaded.ui_zoom, 3.0);
}
#[test]
fn test_window_merge_on_load() {
let dir = TempDir::new().unwrap();
// Write a JSON file with only partial window data
let partial = json!({
"window": {"width": 800}
});
let path = dir.path().join("prefs.json");
atomic_write_json(&path, &partial, BACKUP_COUNT);
let prefs = Prefs::load(dir.path());
// Width should be overridden
assert_eq!(prefs.window.width, 800);
// Height should remain at default
assert_eq!(prefs.window.height, 860);
// x, y should remain None (defaults)
assert_eq!(prefs.window.x, None);
assert_eq!(prefs.window.y, None);
}
#[test]
fn test_window_merge_on_update() {
let dir = TempDir::new().unwrap();
let mut prefs = Prefs::default();
prefs.window.width = 1000;
prefs.window.height = 700;
prefs.window.x = Some(50);
prefs.window.y = Some(60);
// Patch only width — height, x, y should remain
let patch = json!({
"window": {"width": 1600}
});
prefs.update(&patch, dir.path());
assert_eq!(prefs.window.width, 1600);
assert_eq!(prefs.window.height, 700);
assert_eq!(prefs.window.x, Some(50));
assert_eq!(prefs.window.y, Some(60));
}
#[test]
fn test_window_merge_with_position() {
let dir = TempDir::new().unwrap();
// Saved file has window with x/y set
let saved = json!({
"version": 19,
"window": {"width": 1320, "height": 860, "x": 150, "y": 250}
});
let path = dir.path().join("prefs.json");
atomic_write_json(&path, &saved, BACKUP_COUNT);
let prefs = Prefs::load(dir.path());
assert_eq!(prefs.window.x, Some(150));
assert_eq!(prefs.window.y, Some(250));
}
#[test]
fn test_load_with_missing_fields_uses_defaults() {
let dir = TempDir::new().unwrap();
// Write a JSON with only version — everything else should be defaults
let partial = json!({"version": 20});
let path = dir.path().join("prefs.json");
atomic_write_json(&path, &partial, BACKUP_COUNT);
let prefs = Prefs::load(dir.path());
assert_eq!(prefs.version, 20);
assert_eq!(prefs.ui_zoom, 1.0);
assert_eq!(prefs.split_ratio, 0.62);
assert_eq!(prefs.window.width, 1320);
assert_eq!(prefs.window.height, 860);
assert_eq!(prefs.last_folder_path, None);
}
#[test]
fn test_update_with_non_object_is_noop() {
let dir = TempDir::new().unwrap();
let mut prefs = Prefs::default();
let original_zoom = prefs.ui_zoom;
prefs.update(&json!("not an object"), dir.path());
// Nothing should have changed
assert_eq!(prefs.ui_zoom, original_zoom);
assert_eq!(prefs.updated_at, 0);
}
#[test]
fn test_window_null_xy_in_update() {
let dir = TempDir::new().unwrap();
let mut prefs = Prefs::default();
prefs.window.x = Some(100);
prefs.window.y = Some(200);
// Setting x/y to null should clear them
let patch = json!({
"window": {"x": null, "y": null}
});
prefs.update(&patch, dir.path());
assert_eq!(prefs.window.x, None);
assert_eq!(prefs.window.y, None);
}
}

222
src-tauri/src/recents.rs Normal file
View File

@@ -0,0 +1,222 @@
use serde_json::json;
use std::path::Path;
use std::time::SystemTime;
use crate::state::{atomic_write_json, load_json_with_fallbacks, BACKUP_COUNT};
use crate::utils::deduplicate_list;
/// Maximum number of recent folders to keep.
pub const RECENTS_MAX: usize = 50;
/// Name of the recents file within the state directory.
const RECENTS_FILENAME: &str = "recent_folders.json";
/// Load the list of recently opened folders.
///
/// Reads from `{state_dir}/recent_folders.json`. Returns an empty vec if the
/// file is missing, corrupt, or has an unexpected shape.
pub fn load_recents(state_dir: &Path) -> Vec<String> {
let path = state_dir.join(RECENTS_FILENAME);
let data = match load_json_with_fallbacks(&path, BACKUP_COUNT) {
Some(v) => v,
None => return Vec::new(),
};
// Top-level must be an object
let obj = match data.as_object() {
Some(o) => o,
None => return Vec::new(),
};
// "items" must be an array
let items_val = match obj.get("items") {
Some(v) => v,
None => return Vec::new(),
};
let arr = match items_val.as_array() {
Some(a) => a,
None => return Vec::new(),
};
// Collect only string elements
let strings: Vec<String> = arr
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
let deduped = deduplicate_list(&strings);
deduped.into_iter().take(RECENTS_MAX).collect()
}
/// Save the list of recently opened folders.
///
/// Deduplicates and truncates to `RECENTS_MAX`, then writes JSON with version
/// and timestamp metadata.
pub fn save_recents(state_dir: &Path, paths: &[String]) {
let cleaned: Vec<String> = deduplicate_list(paths)
.into_iter()
.take(RECENTS_MAX)
.collect();
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let data = json!({
"version": 2,
"updated_at": timestamp,
"items": cleaned,
});
let path = state_dir.join(RECENTS_FILENAME);
atomic_write_json(&path, &data, BACKUP_COUNT);
}
/// Add a folder to the top of the recent folders list.
///
/// If the path already exists in the list it is moved to the front rather than
/// duplicated.
pub fn push_recent(state_dir: &Path, path_str: &str) {
let mut paths = load_recents(state_dir);
let trimmed = path_str.trim().to_string();
paths.retain(|p| p != &trimmed);
paths.insert(0, trimmed);
save_recents(state_dir, &paths);
}
/// Remove a specific path from the recent folders list and save.
pub fn remove_recent(state_dir: &Path, path_str: &str) {
let mut paths = load_recents(state_dir);
let trimmed = path_str.trim().to_string();
paths.retain(|p| p != &trimmed);
save_recents(state_dir, &paths);
}
// ===========================================================================
// Tests
// ===========================================================================
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_load_empty_returns_empty_vec() {
let dir = TempDir::new().unwrap();
let result = load_recents(dir.path());
assert!(result.is_empty());
}
#[test]
fn test_push_adds_to_front() {
let dir = TempDir::new().unwrap();
push_recent(dir.path(), "/folder/a");
push_recent(dir.path(), "/folder/b");
push_recent(dir.path(), "/folder/c");
let recents = load_recents(dir.path());
assert_eq!(recents[0], "/folder/c");
assert_eq!(recents[1], "/folder/b");
assert_eq!(recents[2], "/folder/a");
}
#[test]
fn test_push_deduplicates() {
let dir = TempDir::new().unwrap();
push_recent(dir.path(), "/folder/a");
push_recent(dir.path(), "/folder/b");
push_recent(dir.path(), "/folder/a"); // duplicate — should move to front
let recents = load_recents(dir.path());
assert_eq!(recents.len(), 2);
assert_eq!(recents[0], "/folder/a");
assert_eq!(recents[1], "/folder/b");
}
#[test]
fn test_remove_works() {
let dir = TempDir::new().unwrap();
push_recent(dir.path(), "/folder/a");
push_recent(dir.path(), "/folder/b");
push_recent(dir.path(), "/folder/c");
remove_recent(dir.path(), "/folder/b");
let recents = load_recents(dir.path());
assert_eq!(recents.len(), 2);
assert_eq!(recents[0], "/folder/c");
assert_eq!(recents[1], "/folder/a");
}
#[test]
fn test_remove_nonexistent_is_noop() {
let dir = TempDir::new().unwrap();
push_recent(dir.path(), "/folder/a");
remove_recent(dir.path(), "/folder/zzz");
let recents = load_recents(dir.path());
assert_eq!(recents.len(), 1);
assert_eq!(recents[0], "/folder/a");
}
#[test]
fn test_max_50_limit() {
let dir = TempDir::new().unwrap();
// Push 55 items
for i in 0..55 {
push_recent(dir.path(), &format!("/folder/{}", i));
}
let recents = load_recents(dir.path());
assert_eq!(recents.len(), RECENTS_MAX);
// Most recent should be at front
assert_eq!(recents[0], "/folder/54");
}
#[test]
fn test_save_and_load_round_trip() {
let dir = TempDir::new().unwrap();
let paths: Vec<String> = vec![
"/home/user/videos".to_string(),
"/home/user/tutorials".to_string(),
];
save_recents(dir.path(), &paths);
let loaded = load_recents(dir.path());
assert_eq!(loaded, paths);
}
#[test]
fn test_load_non_object_returns_empty() {
let dir = TempDir::new().unwrap();
let path = dir.path().join(RECENTS_FILENAME);
// Write a JSON array instead of an object
std::fs::write(&path, "[1, 2, 3]").unwrap();
let result = load_recents(dir.path());
assert!(result.is_empty());
}
#[test]
fn test_load_missing_items_key_returns_empty() {
let dir = TempDir::new().unwrap();
let path = dir.path().join(RECENTS_FILENAME);
std::fs::write(&path, r#"{"version": 2}"#).unwrap();
let result = load_recents(dir.path());
assert!(result.is_empty());
}
#[test]
fn test_load_items_not_array_returns_empty() {
let dir = TempDir::new().unwrap();
let path = dir.path().join(RECENTS_FILENAME);
std::fs::write(&path, r#"{"items": "not an array"}"#).unwrap();
let result = load_recents(dir.path());
assert!(result.is_empty());
}
}

652
src-tauri/src/subtitles.rs Normal file
View File

@@ -0,0 +1,652 @@
//! Subtitle handling: SRT-to-VTT conversion, sidecar discovery, storage,
//! and embedded subtitle extraction via ffmpeg.
use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// Supported subtitle file extensions.
pub const SUB_EXTS: &[&str] = &[".srt", ".vtt"];
/// Languages considered "English" for sidecar priority.
const ENGLISH_LANGS: &[&str] = &["en", "eng", "english"];
/// All language suffixes to strip when normalizing subtitle basenames.
const ALL_LANG_SUFFIXES: &[&str] = &[
"en", "eng", "english", "fr", "de", "es", "it", "pt", "ru", "ja", "ko", "zh",
];
/// Windows CREATE_NO_WINDOW flag for subprocess creation.
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
// ---------------------------------------------------------------------------
// Structs
// ---------------------------------------------------------------------------
/// Result of storing a subtitle file for a video.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubtitleStored {
/// Relative path like `"subtitles/{fid}_{name}.vtt"`.
pub vtt: String,
/// Display label (source filename).
pub label: String,
}
// ---------------------------------------------------------------------------
// Compiled regex patterns
// ---------------------------------------------------------------------------
/// Matches a line that is only digits (SRT cue index).
static CUE_INDEX_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\d+$").unwrap());
/// Matches characters that are NOT alphanumeric, dot, underscore, or hyphen.
static SANITIZE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[^a-zA-Z0-9._\-]").unwrap());
/// Collapses runs of whitespace and dash/underscore into a single space for
/// normalized comparison of subtitle stems.
static NORMALIZE_SEP_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[-_\s]+").unwrap());
// ---------------------------------------------------------------------------
// 1. srt_to_vtt
// ---------------------------------------------------------------------------
/// Convert SRT subtitle text to WebVTT format string.
///
/// - Removes BOM (`\u{FEFF}`) if present.
/// - Adds the `WEBVTT` header.
/// - Skips cue index numbers (lines that are just digits).
/// - Converts timestamp separators: comma → dot.
/// - Collects subtitle text between timestamp lines and empty lines.
pub fn srt_to_vtt(srt_text: &str) -> String {
let text = srt_text.replace('\u{FEFF}', "");
let lines: Vec<&str> = text.lines().collect();
let mut out: Vec<String> = vec!["WEBVTT".to_string(), String::new()];
let mut i = 0;
while i < lines.len() {
let line = lines[i].trim_end_matches('\r');
// Empty line → blank line in output
if line.trim().is_empty() {
out.push(String::new());
i += 1;
continue;
}
// Skip cue index (pure digit line)
if CUE_INDEX_RE.is_match(line.trim()) {
i += 1;
if i >= lines.len() {
break;
}
// Re-read the next line as a potential timestamp
let line = lines[i].trim_end_matches('\r');
if line.contains("-->") {
let ts_line = line.replace(',', ".");
out.push(ts_line);
i += 1;
// Collect subtitle text until blank line
while i < lines.len() {
let t = lines[i].trim_end_matches('\r');
if t.trim().is_empty() {
out.push(String::new());
i += 1;
break;
}
out.push(t.to_string());
i += 1;
}
} else {
i += 1;
}
} else if line.contains("-->") {
// Timestamp line without preceding cue index
let ts_line = line.replace(',', ".");
out.push(ts_line);
i += 1;
while i < lines.len() {
let t = lines[i].trim_end_matches('\r');
if t.trim().is_empty() {
out.push(String::new());
i += 1;
break;
}
out.push(t.to_string());
i += 1;
}
} else {
i += 1;
}
}
let joined = out.join("\n");
format!("{}\n", joined.trim())
}
// ---------------------------------------------------------------------------
// 2. auto_subtitle_sidecar (helpers)
// ---------------------------------------------------------------------------
/// Normalize a string for fuzzy subtitle matching: lowercase, replace `-` and
/// `_` with space, collapse whitespace.
fn normalize_stem(s: &str) -> String {
let lower = s.to_lowercase();
let replaced = NORMALIZE_SEP_RE.replace_all(&lower, " ");
replaced.trim().to_string()
}
/// Strip a trailing language suffix from a subtitle stem.
///
/// For example, `"video.en"` → `Some(("video", "en"))`.
/// Returns `None` if no known language suffix is found.
fn strip_lang_suffix(stem: &str) -> Option<(String, String)> {
if let Some(dot_pos) = stem.rfind('.') {
let base = &stem[..dot_pos];
let suffix = &stem[dot_pos + 1..];
let suffix_lower = suffix.to_lowercase();
if ALL_LANG_SUFFIXES.contains(&suffix_lower.as_str()) {
return Some((base.to_string(), suffix_lower));
}
}
None
}
/// Find a subtitle sidecar file matching the given video path.
///
/// Returns the best matching subtitle file path, or `None`.
///
/// Priority (lower is better):
/// - 0: Exact stem match (case-insensitive)
/// - 1: Normalized exact match
/// - 2: English language suffix with exact base
/// - 3: English language suffix with normalized base
/// - 4: Other language suffix with exact base
/// - 5: Other/no language with normalized base
pub fn auto_subtitle_sidecar(video_path: &Path) -> Option<PathBuf> {
let parent = video_path.parent()?;
let video_stem = video_path.file_stem()?.to_string_lossy().to_string();
let video_stem_lower = video_stem.to_lowercase();
let video_stem_norm = normalize_stem(&video_stem);
// Collect all subtitle files in the same directory.
let entries = fs::read_dir(parent).ok()?;
let mut best: Option<(u8, PathBuf)> = None;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let fname = match path.file_name() {
Some(n) => n.to_string_lossy().to_string(),
None => continue,
};
let fname_lower = fname.to_lowercase();
// Must end with a supported subtitle extension.
let is_sub = SUB_EXTS
.iter()
.any(|ext| fname_lower.ends_with(ext));
if !is_sub {
continue;
}
// Extract the stem (without the subtitle extension).
let sub_stem = match path.file_stem() {
Some(s) => s.to_string_lossy().to_string(),
None => continue,
};
let sub_stem_lower = sub_stem.to_lowercase();
// Priority 0: exact stem match (case-insensitive).
if sub_stem_lower == video_stem_lower {
let priority = 0u8;
if best.as_ref().map_or(true, |(bp, _)| priority < *bp) {
best = Some((priority, path.clone()));
}
continue;
}
// Check for language suffix.
if let Some((base, lang)) = strip_lang_suffix(&sub_stem) {
let base_lower = base.to_lowercase();
let base_norm = normalize_stem(&base);
let is_english = ENGLISH_LANGS.contains(&lang.as_str());
if is_english {
// Priority 2: English suffix, exact base.
if base_lower == video_stem_lower {
let priority = 2u8;
if best.as_ref().map_or(true, |(bp, _)| priority < *bp) {
best = Some((priority, path.clone()));
}
continue;
}
// Priority 3: English suffix, normalized base.
if base_norm == video_stem_norm {
let priority = 3u8;
if best.as_ref().map_or(true, |(bp, _)| priority < *bp) {
best = Some((priority, path.clone()));
}
continue;
}
} else {
// Priority 4: Other language suffix, exact base.
if base_lower == video_stem_lower {
let priority = 4u8;
if best.as_ref().map_or(true, |(bp, _)| priority < *bp) {
best = Some((priority, path.clone()));
}
continue;
}
// Priority 5: Other language suffix, normalized base.
if base_norm == video_stem_norm {
let priority = 5u8;
if best.as_ref().map_or(true, |(bp, _)| priority < *bp) {
best = Some((priority, path.clone()));
}
continue;
}
}
}
// Priority 1: Normalized match (no language suffix).
let sub_stem_norm = normalize_stem(&sub_stem);
if sub_stem_norm == video_stem_norm {
let priority = 1u8;
if best.as_ref().map_or(true, |(bp, _)| priority < *bp) {
best = Some((priority, path.clone()));
}
}
// Priority 5 fallback: normalized match for subtitle files whose
// language suffix was not recognised above (handled by the
// strip_lang_suffix branch already for known languages).
}
best.map(|(_, p)| p)
}
// ---------------------------------------------------------------------------
// 3. store_subtitle_for_fid
// ---------------------------------------------------------------------------
/// Sanitize a filename component: replace non-alphanumeric chars (except
/// `._-`) with `_`, then truncate to 60 characters.
fn sanitize_name(name: &str) -> String {
let sanitized = SANITIZE_RE.replace_all(name, "_");
let s = sanitized.as_ref();
if s.len() > 60 {
s[..60].to_string()
} else {
s.to_string()
}
}
/// Store a subtitle file for a given fid. Converts SRT→VTT if needed.
///
/// The output file is written as `{fid}_{sanitized_name}.vtt` inside
/// `subs_dir`. Returns `SubtitleStored` with the relative path (from the
/// parent of `subs_dir`) and a display label.
///
/// Returns `None` if the source file extension is not supported or reading
/// the source fails.
pub fn store_subtitle_for_fid(
fid: &str,
src_path: &Path,
subs_dir: &Path,
) -> Option<SubtitleStored> {
let ext_lower = src_path
.extension()
.map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))?;
if !SUB_EXTS.contains(&ext_lower.as_str()) {
return None;
}
let src_filename = src_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let src_stem = src_path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "subtitle".to_string());
let sanitized = sanitize_name(&src_stem);
let out_name = format!("{}_{}.vtt", fid, sanitized);
// Ensure subs_dir exists.
let _ = fs::create_dir_all(subs_dir);
let out_path = subs_dir.join(&out_name);
let content = fs::read_to_string(src_path).ok()?;
let vtt_content = if ext_lower == ".srt" {
srt_to_vtt(&content)
} else {
// Already VTT — use as-is.
content
};
fs::write(&out_path, vtt_content.as_bytes()).ok()?;
// Build relative path: "subtitles/{out_name}".
let subs_dir_name = subs_dir
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "subtitles".to_string());
let vtt_rel = format!("{}/{}", subs_dir_name, out_name);
Some(SubtitleStored {
vtt: vtt_rel,
label: src_filename,
})
}
// ---------------------------------------------------------------------------
// 4. extract_embedded_subtitle
// ---------------------------------------------------------------------------
/// Extract an embedded subtitle track from a video using ffmpeg.
///
/// Runs: `ffmpeg -y -i {video_path} -map 0:{track_index} -c:s webvtt {output_path}`
///
/// The output file is `{fid}_embedded_{track_index}.vtt` inside `subs_dir`.
/// On Windows, the process is created with `CREATE_NO_WINDOW`.
///
/// Returns `SubtitleStored` on success, or an error message string.
pub fn extract_embedded_subtitle(
video_path: &Path,
track_index: u32,
ffmpeg_path: &Path,
subs_dir: &Path,
fid: &str,
) -> Result<SubtitleStored, String> {
let _ = fs::create_dir_all(subs_dir);
let out_name = format!("{}_embedded_{}.vtt", fid, track_index);
let out_path = subs_dir.join(&out_name);
let mut cmd = Command::new(ffmpeg_path);
cmd.args([
"-y",
"-i",
&video_path.to_string_lossy(),
"-map",
&format!("0:{}", track_index),
"-c:s",
"webvtt",
&out_path.to_string_lossy(),
]);
#[cfg(target_os = "windows")]
{
cmd.creation_flags(CREATE_NO_WINDOW);
}
let output = cmd
.output()
.map_err(|e| format!("Failed to run ffmpeg: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"ffmpeg exited with status {}: {}",
output.status, stderr
));
}
if !out_path.exists() {
return Err("ffmpeg did not produce an output file".to_string());
}
let subs_dir_name = subs_dir
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "subtitles".to_string());
let vtt_rel = format!("{}/{}", subs_dir_name, out_name);
Ok(SubtitleStored {
vtt: vtt_rel,
label: format!("Embedded track {}", track_index),
})
}
// ===========================================================================
// Tests
// ===========================================================================
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
// -- srt_to_vtt ----------------------------------------------------------
#[test]
fn test_srt_to_vtt_basic() {
let srt = "\
1
00:00:01,000 --> 00:00:04,000
Hello, world!
2
00:00:05,000 --> 00:00:08,000
This is a test.
";
let vtt = srt_to_vtt(srt);
assert!(vtt.starts_with("WEBVTT\n"));
assert!(vtt.contains("00:00:01.000 --> 00:00:04.000"));
assert!(vtt.contains("Hello, world!"));
assert!(vtt.contains("00:00:05.000 --> 00:00:08.000"));
assert!(vtt.contains("This is a test."));
// Timestamp commas must be converted to dots.
assert!(!vtt.contains("00:00:01,000"));
assert!(!vtt.contains("00:00:04,000"));
}
#[test]
fn test_srt_to_vtt_bom() {
let srt = "\u{FEFF}1\n00:00:01,000 --> 00:00:02,000\nHello\n";
let vtt = srt_to_vtt(srt);
assert!(vtt.starts_with("WEBVTT"));
// BOM must be removed.
assert!(!vtt.contains('\u{FEFF}'));
assert!(vtt.contains("Hello"));
}
#[test]
fn test_srt_to_vtt_empty() {
let vtt = srt_to_vtt("");
assert!(vtt.starts_with("WEBVTT"));
// Should be just the header.
assert_eq!(vtt.trim(), "WEBVTT");
}
#[test]
fn test_srt_to_vtt_windows_line_endings() {
let srt = "1\r\n00:00:01,000 --> 00:00:02,000\r\nHello\r\n\r\n\
2\r\n00:00:03,000 --> 00:00:04,000\r\nWorld\r\n";
let vtt = srt_to_vtt(srt);
assert!(vtt.starts_with("WEBVTT"));
assert!(vtt.contains("00:00:01.000 --> 00:00:02.000"));
assert!(vtt.contains("Hello"));
assert!(vtt.contains("00:00:03.000 --> 00:00:04.000"));
assert!(vtt.contains("World"));
}
#[test]
fn test_srt_to_vtt_no_cue_indices() {
// Some SRT files omit cue numbers entirely.
let srt = "\
00:00:01,500 --> 00:00:03,500
First line
00:00:04,000 --> 00:00:06,000
Second line
";
let vtt = srt_to_vtt(srt);
assert!(vtt.starts_with("WEBVTT"));
assert!(vtt.contains("00:00:01.500 --> 00:00:03.500"));
assert!(vtt.contains("First line"));
assert!(vtt.contains("00:00:04.000 --> 00:00:06.000"));
assert!(vtt.contains("Second line"));
}
// -- auto_subtitle_sidecar -----------------------------------------------
#[test]
fn test_auto_subtitle_sidecar_exact_match() {
let dir = TempDir::new().unwrap();
let video = dir.path().join("lecture.mp4");
let sub = dir.path().join("lecture.srt");
fs::write(&video, b"video").unwrap();
fs::write(&sub, b"1\n00:00:00,000 --> 00:00:01,000\nhi\n").unwrap();
let result = auto_subtitle_sidecar(&video);
assert!(result.is_some());
assert_eq!(result.unwrap(), sub);
}
#[test]
fn test_auto_subtitle_sidecar_english_suffix() {
let dir = TempDir::new().unwrap();
let video = dir.path().join("lecture.mp4");
let sub = dir.path().join("lecture.en.srt");
fs::write(&video, b"video").unwrap();
fs::write(&sub, b"sub content").unwrap();
let result = auto_subtitle_sidecar(&video);
assert!(result.is_some());
assert_eq!(result.unwrap(), sub);
}
#[test]
fn test_auto_subtitle_sidecar_no_match() {
let dir = TempDir::new().unwrap();
let video = dir.path().join("lecture.mp4");
fs::write(&video, b"video").unwrap();
// No subtitle files at all.
let result = auto_subtitle_sidecar(&video);
assert!(result.is_none());
}
#[test]
fn test_auto_subtitle_sidecar_priority_order() {
let dir = TempDir::new().unwrap();
let video = dir.path().join("lecture.mp4");
fs::write(&video, b"video").unwrap();
// Priority 0: exact stem match.
let exact = dir.path().join("lecture.srt");
// Priority 2: English suffix with exact base.
let en_suffix = dir.path().join("lecture.en.srt");
// Priority 4: Other language suffix with exact base.
let fr_suffix = dir.path().join("lecture.fr.srt");
fs::write(&exact, b"exact").unwrap();
fs::write(&en_suffix, b"english").unwrap();
fs::write(&fr_suffix, b"french").unwrap();
let result = auto_subtitle_sidecar(&video);
assert!(result.is_some());
// Should pick priority 0 (exact match) over others.
assert_eq!(result.unwrap(), exact);
// Remove exact match → should pick English suffix (priority 2).
fs::remove_file(&exact).unwrap();
let result = auto_subtitle_sidecar(&video);
assert!(result.is_some());
assert_eq!(result.unwrap(), en_suffix);
// Remove English suffix → should pick French suffix (priority 4).
fs::remove_file(&en_suffix).unwrap();
let result = auto_subtitle_sidecar(&video);
assert!(result.is_some());
assert_eq!(result.unwrap(), fr_suffix);
}
// -- store_subtitle_for_fid ----------------------------------------------
#[test]
fn test_store_subtitle_srt_converts_to_vtt() {
let dir = TempDir::new().unwrap();
let subs_dir = dir.path().join("subtitles");
let src = dir.path().join("my_sub.srt");
let srt_content = "1\n00:00:01,000 --> 00:00:02,000\nHello\n";
fs::write(&src, srt_content).unwrap();
let result = store_subtitle_for_fid("abc123", &src, &subs_dir);
assert!(result.is_some());
let stored = result.unwrap();
assert!(stored.vtt.ends_with(".vtt"));
assert!(stored.vtt.starts_with("subtitles/"));
assert_eq!(stored.label, "my_sub.srt");
// Verify the VTT output file was actually created and converted.
let out_path = subs_dir.join(format!("abc123_{}.vtt", "my_sub"));
assert!(out_path.exists());
let vtt_content = fs::read_to_string(&out_path).unwrap();
assert!(vtt_content.starts_with("WEBVTT"));
assert!(vtt_content.contains("00:00:01.000 --> 00:00:02.000"));
assert!(vtt_content.contains("Hello"));
}
#[test]
fn test_store_subtitle_vtt_copies() {
let dir = TempDir::new().unwrap();
let subs_dir = dir.path().join("subtitles");
let src = dir.path().join("my_sub.vtt");
let vtt_content = "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nHello\n";
fs::write(&src, vtt_content).unwrap();
let result = store_subtitle_for_fid("def456", &src, &subs_dir);
assert!(result.is_some());
let stored = result.unwrap();
assert!(stored.vtt.ends_with(".vtt"));
assert_eq!(stored.label, "my_sub.vtt");
// Verify the output file has the same content (not SRT-converted).
let out_path = subs_dir.join("def456_my_sub.vtt");
assert!(out_path.exists());
let content = fs::read_to_string(&out_path).unwrap();
assert_eq!(content, vtt_content);
}
#[test]
fn test_store_subtitle_unsupported_ext() {
let dir = TempDir::new().unwrap();
let subs_dir = dir.path().join("subtitles");
let src = dir.path().join("notes.txt");
fs::write(&src, "Some notes").unwrap();
let result = store_subtitle_for_fid("xyz789", &src, &subs_dir);
assert!(result.is_none());
}
}

View File

@@ -0,0 +1,413 @@
//! Custom `tutdock` URI scheme protocol for serving video, subtitles, and fonts.
//!
//! On Windows the actual URL seen by the handler is:
//! `http://tutdock.localhost/<path>` (Tauri v2 Windows behaviour).
//! On macOS / Linux it would be `tutdock://localhost/<path>`.
//!
//! Routes:
//! /video/{index} — video file with Range support
//! /sub/{libid}/{fid} — stored VTT subtitle
//! /fonts.css — cached Google Fonts CSS
//! /fonts/{filename} — cached font file
//! /fa.css — cached Font Awesome CSS
//! /fa/webfonts/{filename} — cached FA webfont
use std::fs::{self, File};
use std::io::{Read, Seek, SeekFrom};
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::http::{self, header, StatusCode};
use tauri::Manager;
use crate::library::Library;
use crate::AppPaths;
/// Maximum chunk size for full-file responses (4 MB).
const MAX_CHUNK: u64 = 4 * 1024 * 1024;
/// CORS header name.
const CORS: &str = "Access-Control-Allow-Origin";
/// Register the `tutdock` custom protocol on the builder.
pub fn register_protocol(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
builder.register_uri_scheme_protocol("tutdock", move |ctx, request| {
let app = ctx.app_handle();
handle_request(app, &request)
})
}
// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------
fn handle_request(
app: &tauri::AppHandle,
request: &http::Request<Vec<u8>>,
) -> http::Response<Vec<u8>> {
let uri = request.uri();
let path = uri.path();
// Strip leading slash.
let path = path.strip_prefix('/').unwrap_or(path);
// Route.
if let Some(rest) = path.strip_prefix("video/") {
return handle_video(app, request, rest);
}
if let Some(rest) = path.strip_prefix("sub/") {
return handle_subtitle(app, rest);
}
if path == "fonts.css" {
return handle_fonts_css(app);
}
if let Some(rest) = path.strip_prefix("fonts/") {
return handle_font_file(app, rest);
}
if path == "fa.css" {
return handle_fa_css(app);
}
if let Some(rest) = path.strip_prefix("fa/webfonts/") {
return handle_fa_webfont(app, rest);
}
not_found()
}
// ---------------------------------------------------------------------------
// Video — with Range request support
// ---------------------------------------------------------------------------
fn handle_video(
app: &tauri::AppHandle,
request: &http::Request<Vec<u8>>,
index_str: &str,
) -> http::Response<Vec<u8>> {
let index: usize = match index_str.parse() {
Ok(i) => i,
Err(_) => return bad_request("invalid index"),
};
let state = app.state::<Mutex<Library>>();
let file_path = {
let lib = match state.lock() {
Ok(l) => l,
Err(_) => return internal_error("lock error"),
};
match lib.get_video_path(index) {
Ok(p) => p,
Err(_) => return not_found(),
}
};
let metadata = match fs::metadata(&file_path) {
Ok(m) => m,
Err(_) => return not_found(),
};
let file_size = metadata.len();
let ext = file_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let mime = mime_for_video(ext);
// Check for Range header.
let range_header = request
.headers()
.get(header::RANGE)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
match range_header {
Some(range_str) => serve_range(&file_path, file_size, &range_str, mime),
None => serve_full(&file_path, file_size, mime),
}
}
/// Serve the full file (or first MAX_CHUNK bytes).
fn serve_full(path: &PathBuf, file_size: u64, mime: &str) -> http::Response<Vec<u8>> {
let read_len = file_size.min(MAX_CHUNK) as usize;
let mut buf = vec![0u8; read_len];
let mut file = match File::open(path) {
Ok(f) => f,
Err(_) => return internal_error("cannot open file"),
};
if file.read_exact(&mut buf).is_err() {
return internal_error("read error");
}
http::Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime)
.header(header::CONTENT_LENGTH, file_size.to_string())
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CACHE_CONTROL, "no-store")
.header(CORS, "*")
.body(buf)
.unwrap_or_else(|_| internal_error("response build error"))
}
/// Serve a byte range (206 Partial Content).
fn serve_range(
path: &PathBuf,
file_size: u64,
range_str: &str,
mime: &str,
) -> http::Response<Vec<u8>> {
let (start, end) = match parse_range(range_str, file_size) {
Some(r) => r,
None => return range_not_satisfiable(file_size),
};
let length = end - start + 1;
let mut buf = vec![0u8; length as usize];
let mut file = match File::open(path) {
Ok(f) => f,
Err(_) => return internal_error("cannot open file"),
};
if file.seek(SeekFrom::Start(start)).is_err() {
return internal_error("seek error");
}
if file.read_exact(&mut buf).is_err() {
return internal_error("read error");
}
http::Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_TYPE, mime)
.header(
header::CONTENT_RANGE,
format!("bytes {}-{}/{}", start, end, file_size),
)
.header(header::CONTENT_LENGTH, length.to_string())
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CACHE_CONTROL, "no-store")
.header(CORS, "*")
.body(buf)
.unwrap_or_else(|_| internal_error("response build error"))
}
/// Parse `Range: bytes=START-END` header.
fn parse_range(header: &str, file_size: u64) -> Option<(u64, u64)> {
let s = header.strip_prefix("bytes=")?;
let (start_s, end_s) = s.split_once('-')?;
let start = if start_s.is_empty() {
let suffix: u64 = end_s.parse().ok()?;
file_size.checked_sub(suffix)?
} else {
start_s.parse().ok()?
};
let end = if end_s.is_empty() {
file_size - 1
} else {
let e: u64 = end_s.parse().ok()?;
e.min(file_size - 1)
};
if start > end || start >= file_size {
return None;
}
Some((start, end))
}
// ---------------------------------------------------------------------------
// Subtitles
// ---------------------------------------------------------------------------
fn handle_subtitle(app: &tauri::AppHandle, rest: &str) -> http::Response<Vec<u8>> {
// rest is a VTT filename like "{fid}_{name}.vtt"
let filename = rest;
// Validate: no path traversal
if filename.contains("..") || filename.contains('/') || filename.contains('\\') || filename.is_empty() {
return not_found();
}
let paths = app.state::<AppPaths>();
let vtt_path = paths.subs_dir.join(filename);
// Ensure resolved path is still inside subs_dir
if let (Ok(base), Ok(resolved)) = (paths.subs_dir.canonicalize(), vtt_path.canonicalize()) {
if !resolved.starts_with(&base) {
return not_found();
}
} else {
return not_found();
}
let data = match fs::read(&vtt_path) {
Ok(d) => d,
Err(_) => return not_found(),
};
http::Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/vtt; charset=utf-8")
.header(header::CACHE_CONTROL, "no-store")
.header(CORS, "*")
.body(data)
.unwrap_or_else(|_| internal_error("response build error"))
}
// ---------------------------------------------------------------------------
// Fonts
// ---------------------------------------------------------------------------
fn handle_fonts_css(app: &tauri::AppHandle) -> http::Response<Vec<u8>> {
let paths = app.state::<AppPaths>();
let css_path = paths.fonts_dir.join("fonts.css");
let data = if css_path.exists() {
fs::read(&css_path).unwrap_or_default()
} else {
Vec::new()
};
http::Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/css; charset=utf-8")
.header(header::CACHE_CONTROL, "no-store")
.body(data)
.unwrap_or_else(|_| internal_error("response build error"))
}
fn handle_font_file(app: &tauri::AppHandle, filename: &str) -> http::Response<Vec<u8>> {
let paths = app.state::<AppPaths>();
serve_static_file(&paths.fonts_dir, filename)
}
fn handle_fa_css(app: &tauri::AppHandle) -> http::Response<Vec<u8>> {
let paths = app.state::<AppPaths>();
let css_path = paths.fa_dir.join("fa.css");
let data = if css_path.exists() {
fs::read(&css_path).unwrap_or_default()
} else {
Vec::new()
};
http::Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/css; charset=utf-8")
.header(header::CACHE_CONTROL, "no-store")
.body(data)
.unwrap_or_else(|_| internal_error("response build error"))
}
fn handle_fa_webfont(app: &tauri::AppHandle, filename: &str) -> http::Response<Vec<u8>> {
let paths = app.state::<AppPaths>();
let webfonts_dir = paths.fa_dir.join("webfonts");
serve_static_file(&webfonts_dir, filename)
}
// ---------------------------------------------------------------------------
// Static file helper
// ---------------------------------------------------------------------------
fn serve_static_file(base_dir: &PathBuf, filename: &str) -> http::Response<Vec<u8>> {
if filename.contains("..") || filename.contains('\\') || filename.contains('/') {
return not_found();
}
let file_path = base_dir.join(filename);
let canonical_base = match base_dir.canonicalize() {
Ok(p) => p,
Err(_) => return not_found(),
};
let canonical_file = match file_path.canonicalize() {
Ok(p) => p,
Err(_) => return not_found(),
};
if !canonical_file.starts_with(&canonical_base) {
return not_found();
}
let data = match fs::read(&file_path) {
Ok(d) => d,
Err(_) => return not_found(),
};
let mime = guess_mime(filename);
http::Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime)
.header(header::CACHE_CONTROL, "no-store")
.header(CORS, "*")
.body(data)
.unwrap_or_else(|_| internal_error("response build error"))
}
// ---------------------------------------------------------------------------
// MIME helpers
// ---------------------------------------------------------------------------
fn mime_for_video(ext: &str) -> &'static str {
match ext.to_ascii_lowercase().as_str() {
"mp4" | "m4v" => "video/mp4",
"webm" => "video/webm",
"ogv" => "video/ogg",
"mov" => "video/quicktime",
"mkv" => "video/x-matroska",
"avi" => "video/x-msvideo",
"mpeg" | "mpg" => "video/mpeg",
"m2ts" | "mts" => "video/mp2t",
_ => "application/octet-stream",
}
}
fn guess_mime(filename: &str) -> &'static str {
let ext = filename.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
match ext.as_str() {
"css" => "text/css; charset=utf-8",
"woff" => "font/woff",
"woff2" => "font/woff2",
"ttf" => "font/ttf",
"otf" => "font/otf",
"eot" => "application/vnd.ms-fontobject",
"svg" => "image/svg+xml",
_ => "application/octet-stream",
}
}
// ---------------------------------------------------------------------------
// Error responses
// ---------------------------------------------------------------------------
fn not_found() -> http::Response<Vec<u8>> {
http::Response::builder()
.status(StatusCode::NOT_FOUND)
.header(header::CONTENT_TYPE, "text/plain")
.body(b"Not Found".to_vec())
.unwrap()
}
fn bad_request(msg: &str) -> http::Response<Vec<u8>> {
http::Response::builder()
.status(StatusCode::BAD_REQUEST)
.header(header::CONTENT_TYPE, "text/plain")
.body(msg.as_bytes().to_vec())
.unwrap()
}
fn internal_error(msg: &str) -> http::Response<Vec<u8>> {
http::Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header(header::CONTENT_TYPE, "text/plain")
.body(msg.as_bytes().to_vec())
.unwrap()
}
fn range_not_satisfiable(file_size: u64) -> http::Response<Vec<u8>> {
http::Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{}", file_size))
.body(Vec::new())
.unwrap()
}

View File

@@ -1,8 +1,8 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-utils/schema.json",
"productName": "TutorialDock",
"productName": "TutorialVault",
"version": "0.1.0",
"identifier": "com.tutorialdock.app",
"identifier": "com.tutorialvault.app",
"build": {
"devUrl": "http://localhost:1420",
"frontendDist": "../dist",
@@ -12,15 +12,17 @@
"app": {
"windows": [
{
"title": "TutorialDock",
"title": "TutorialVault",
"width": 1320,
"height": 860,
"minWidth": 640,
"minHeight": 480
"minHeight": 480,
"decorations": false,
"dragDropEnabled": false
}
],
"security": {
"csp": "default-src 'self'; media-src 'self' tutdock: asset: https://asset.localhost; font-src 'self' tutdock: asset: https://asset.localhost data:; style-src 'self' tutdock: asset: https://asset.localhost 'unsafe-inline'; img-src 'self' tutdock: asset: https://asset.localhost data:; script-src 'self'"
"csp": "default-src 'self'; media-src 'self' http://tutdock.localhost; font-src 'self' data:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; script-src 'self'"
}
},
"bundle": {
@@ -34,7 +36,5 @@
"icons/icon.ico"
]
},
"plugins": {
"dialog": {}
}
"plugins": {}
}

92
src/api.ts Normal file
View File

@@ -0,0 +1,92 @@
import { invoke } from '@tauri-apps/api/core';
import type {
LibraryInfo,
OkResponse,
PrefsResponse,
RecentsResponse,
NoteResponse,
VideoMetaResponse,
SubtitleResponse,
AvailableSubsResponse,
EmbeddedSubsResponse,
} from './types';
export const api = {
selectFolder: () =>
invoke<LibraryInfo>('select_folder'),
openFolderPath: (folder: string) =>
invoke<LibraryInfo>('open_folder_path', { folder }),
getRecents: () =>
invoke<RecentsResponse>('get_recents'),
removeRecent: (path: string) =>
invoke<OkResponse>('remove_recent', { path }),
getLibrary: () =>
invoke<LibraryInfo>('get_library'),
setCurrent: (index: number, timecode: number = 0) =>
invoke<OkResponse>('set_current', { index, timecode }),
tickProgress: (index: number, currentTime: number, duration: number | null, playing: boolean) =>
invoke<OkResponse>('tick_progress', { index, currentTime, duration, playing }),
setFolderVolume: (volume: number) =>
invoke<OkResponse>('set_folder_volume', { volume }),
setFolderAutoplay: (enabled: boolean) =>
invoke<OkResponse>('set_folder_autoplay', { enabled }),
setFolderRate: (rate: number) =>
invoke<OkResponse>('set_folder_rate', { rate }),
setOrder: (fids: string[]) =>
invoke<OkResponse>('set_order', { fids }),
startDurationScan: () =>
invoke<OkResponse>('start_duration_scan'),
getPrefs: () =>
invoke<PrefsResponse>('get_prefs'),
setPrefs: (patch: Record<string, unknown>) =>
invoke<OkResponse>('set_prefs', { patch }),
setAlwaysOnTop: (enabled: boolean) =>
invoke<OkResponse>('set_always_on_top', { enabled }),
saveWindowState: () =>
invoke<OkResponse>('save_window_state'),
getNote: (fid: string) =>
invoke<NoteResponse>('get_note', { fid }),
setNote: (fid: string, note: string) =>
invoke<OkResponse>('set_note', { fid, note }),
getCurrentVideoMeta: () =>
invoke<VideoMetaResponse>('get_current_video_meta'),
getCurrentSubtitle: () =>
invoke<SubtitleResponse>('get_current_subtitle'),
getEmbeddedSubtitles: () =>
invoke<EmbeddedSubsResponse>('get_embedded_subtitles'),
extractEmbeddedSubtitle: (trackIndex: number) =>
invoke<SubtitleResponse>('extract_embedded_subtitle', { trackIndex }),
getAvailableSubtitles: () =>
invoke<AvailableSubsResponse>('get_available_subtitles'),
loadSidecarSubtitle: (filePath: string) =>
invoke<SubtitleResponse>('load_sidecar_subtitle', { filePath }),
chooseSubtitleFile: () =>
invoke<SubtitleResponse>('choose_subtitle_file'),
resetWatchProgress: () =>
invoke<OkResponse>('reset_watch_progress'),
};

View File

@@ -1,14 +1,300 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TutorialDock</title>
</head>
<body>
<div id="zoomRoot">
<div class="app"></div>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>TutorialVault</title>
</head>
<body>
<div id="zoomRoot">
<div class="app">
<header class="topbar" data-tauri-drag-region role="banner">
<div class="brand" data-tauri-drag-region>
<div class="appIcon" data-tauri-drag-region aria-hidden="true"><div class="appIconGlow"></div><i class="fa-solid fa-graduation-cap" data-tauri-drag-region></i></div>
<div class="brandText" data-tauri-drag-region>
<h1 class="appName" data-tauri-drag-region>TutorialVault</h1>
<div class="tagline" data-tauri-drag-region>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" aria-label="Zoom out"><i class="fa-solid fa-minus" aria-hidden="true"></i></button>
<button class="zoomValue" id="zoomResetBtn" aria-label="Reset zoom">100%</button>
<button class="zoomBtn" id="zoomInBtn" aria-label="Zoom in"><i class="fa-solid fa-plus" aria-hidden="true"></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" aria-hidden="true"></i> Open folder</button>
<button class="btn drop" id="chooseDropBtn" aria-label="Recent folders" data-tooltip="Recent Folders" data-tooltip-desc="Open a recently used folder"><i class="fa-solid fa-chevron-down" aria-hidden="true"></i></button>
</div>
</div>
<div class="actionDivider"></div>
<div class="actionGroup">
<button class="toolbarBtn" id="resetProgBtn" aria-label="Reset progress" 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" aria-hidden="true"></i></button>
<button class="toolbarBtn" id="refreshBtn" aria-label="Reload folder" data-tooltip="Reload" data-tooltip-desc="Reload the current folder"><i class="fa-solid fa-arrows-rotate" aria-hidden="true"></i></button>
</div>
<div class="actionDivider"></div>
<div class="actionGroup windowControls">
<button class="toolbarBtn winBtn" id="winMinBtn" aria-label="Minimize" data-tooltip="Minimize" data-tooltip-desc="Minimize window"><i class="fa-solid fa-minus" aria-hidden="true"></i></button>
<button class="toolbarBtn winBtn" id="winMaxBtn" aria-label="Maximize" data-tooltip="Maximize" data-tooltip-desc="Maximize or restore window"><i class="fa-solid fa-square" aria-hidden="true"></i></button>
<button class="toolbarBtn winBtn winClose" id="winCloseBtn" aria-label="Close" data-tooltip="Close" data-tooltip-desc="Close window"><i class="fa-solid fa-xmark" aria-hidden="true"></i></button>
</div>
</div>
</header>
<div class="dropdownPortal" id="recentMenu"></div>
<main class="content" id="contentGrid" role="main">
<div class="panel" role="region" aria-label="Video player">
<div class="panelHeader">
<div style="min-width:0;">
<h2 class="nowTitle" id="nowTitle">No video loaded</h2>
<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" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" aria-label="Overall folder progress"><div id="overallBar"></div></div>
<div class="progressPct" id="overallPct">-</div>
</div>
</div>
<div class="videoWrap">
<video id="player" preload="metadata" crossorigin="anonymous" aria-label="Video player"></video>
<div class="videoOverlay" id="videoOverlay">
<div class="overlayIcon" id="overlayIcon">
<i class="fa-solid fa-play" id="overlayIconI"></i>
</div>
<div class="seekFeedback" id="seekFeedback" aria-live="assertive"></div>
</div>
<div class="errorOverlay" id="errorOverlay" role="alert" style="display:none;">
<i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
<div class="errorMsg">This file format may not be supported.<br>Try MP4 (H.264/AAC) or WebM.</div>
<button class="errorNextBtn" id="errorNextBtn">Try next</button>
</div>
</div>
<div class="controls">
<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 class="controlsStrip">
<div class="group">
<button class="iconBtn" id="prevBtn" aria-label="Previous video" data-tooltip="Previous" data-tooltip-desc="Go to previous video"><i class="fa-solid fa-backward-step" aria-hidden="true"></i></button>
<button class="iconBtn primary" id="playPauseBtn" aria-label="Play" data-tooltip="Play/Pause" data-tooltip-desc="Toggle video playback">
<i class="fa-solid fa-play" id="ppIcon" aria-hidden="true"></i>
</button>
<button class="iconBtn" id="nextBtn" aria-label="Next video" data-tooltip="Next" data-tooltip-desc="Go to next video"><i class="fa-solid fa-forward-step" aria-hidden="true"></i></button>
<div class="stripDivider"></div>
<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 class="timeSep">/</span> <span id="timeDur">00:00</span></div>
</div>
</div>
<div class="group">
<div class="subsBox">
<button class="iconBtn" id="subsBtn" aria-label="Subtitles" data-tooltip="Subtitles" data-tooltip-desc="Load or select subtitles"><i class="fa-regular fa-closed-captioning" aria-hidden="true"></i></button>
<div class="subsMenu" id="subsMenu" role="menu"></div>
</div>
<div class="stripDivider"></div>
<div class="miniCtl" data-tooltip="Volume" data-tooltip-desc="Adjust volume (saved per folder)">
<button class="volMuteBtn" id="volMuteBtn" aria-label="Mute"><i class="fa-solid fa-volume-high" id="volIcon" aria-hidden="true"></i></button>
<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" aria-label="Volume">
</div>
<div class="volTooltip" id="volTooltip">100%</div>
</div>
<div class="stripDivider"></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>
<div class="stripDivider"></div>
<button class="iconBtn" id="fsBtn" aria-label="Toggle fullscreen" data-tooltip="Fullscreen" data-tooltip-desc="Toggle fullscreen mode"><i class="fa-solid fa-expand" aria-hidden="true"></i></button>
<button class="iconBtn" id="pipBtn" aria-label="Enter picture-in-picture" data-tooltip="PiP" data-tooltip-desc="Toggle picture-in-picture mode"><i class="fa-solid fa-up-right-from-square" id="pipIcon" aria-hidden="true"></i></button>
</div>
</div>
</div>
<div class="dock" id="dockGrid" role="complementary" aria-label="Details">
<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.">
<h3 class="dockTitle"><i class="fa-solid fa-note-sticky" aria-hidden="true"></i> Notes</h3>
<button class="timestampBtn" id="insertTimestamp" aria-label="Insert timestamp" data-tooltip="Timestamp" data-tooltip-desc="Insert current video timestamp at cursor position"><i class="fa-solid fa-clock" aria-hidden="true"></i></button>
<i class="fa-solid fa-chevron-down dockChevron" aria-hidden="true"></i>
</div>
<div class="notesArea">
<textarea class="notes" id="notesBox" aria-label="Notes for current video" placeholder="Write timestamps, TODOs, reminders…"></textarea>
<div class="notesSaved" id="notesSaved"><i class="fa-solid fa-check" aria-hidden="true"></i> Saved</div>
</div>
</div>
</div>
<div class="dockDividerWrap">
<div class="dockDivider" id="dockDivider"></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.">
<h3 class="dockTitle"><i class="fa-solid fa-circle-info" aria-hidden="true"></i> Info</h3>
<i class="fa-solid fa-chevron-down dockChevron" aria-hidden="true"></i>
</div>
<div class="infoGrid" id="infoGrid">
<dl class="kv">
<dt class="k">Folder</dt><dd class="v" id="infoFolder">-</dd>
<dt class="k">Next up</dt><dd class="v" id="infoNext">-</dd>
<dt class="k">Structure</dt><dd class="v mono" id="infoStruct">-</dd>
</dl>
<dl class="kv">
<dt class="k">Title</dt><dd class="v" id="infoTitle">-</dd>
<dt class="k">Relpath</dt><dd class="v mono" id="infoRel">-</dd>
<dt class="k">Position</dt><dd class="v mono" id="infoPos">-</dd>
</dl>
<dl class="kv">
<dt class="k">File</dt><dd class="v mono" id="infoFileBits">-</dd>
<dt class="k">Video</dt><dd class="v mono" id="infoVidBits">-</dd>
<dt class="k">Audio</dt><dd class="v mono" id="infoAudBits">-</dd>
<dt class="k">Subtitles</dt><dd class="v mono" id="infoSubsBits">-</dd>
</dl>
<dl class="kv">
<dt class="k">Finished</dt><dd class="v mono" id="infoFinished">-</dd>
<dt class="k">Remaining</dt><dd class="v mono" id="infoRemaining">-</dd>
<dt class="k"><abbr title="Estimated time to finish">ETA</abbr></dt><dd class="v mono" id="infoEta">-</dd>
</dl>
<dl class="kv">
<dt class="k">Volume</dt><dd class="v mono" id="infoVolume">-</dd>
<dt class="k">Speed</dt><dd class="v mono" id="infoSpeed">-</dd>
<dt class="k">Durations</dt><dd class="v mono" id="infoKnown">-</dd>
</dl>
<dl class="kv">
<dt class="k">Top folders</dt><dd class="v mono" id="infoTop">-</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
<div class="dividerWrap">
<div class="divider" id="divider"></div>
</div>
<div class="panel" role="region" aria-label="Playlist">
<div class="panelHeader" style="align-items:center;">
<h2 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" aria-hidden="true"></i> Playlist</h2>
<span class="plistStats" id="plistStats" aria-label="Playlist statistics"></span>
<div style="flex:1 1 auto;"></div>
<div class="plistSearchWrap">
<i class="fa-solid fa-magnifying-glass plistSearchIcon" aria-hidden="true"></i>
<input type="text" id="plistSearch" class="plistSearch" aria-label="Search playlist" placeholder="Filter...">
<button class="plistSearchClear" id="plistSearchClear" aria-label="Clear search" style="display:none;"><i class="fa-solid fa-xmark" aria-hidden="true"></i></button>
</div>
<button class="scrollToCurrent" id="scrollToCurrent" aria-label="Scroll to current video" style="display:none;" data-tooltip="Scroll to Current" data-tooltip-desc="Scroll the playing video into view"><i class="fa-solid fa-crosshairs" aria-hidden="true"></i></button>
</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>
</main>
</div>
</div>
<div id="toast" aria-live="polite">
<div class="toastInner">
<div class="toastIcon"><i class="fa-solid fa-circle-info" aria-hidden="true"></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>
<div id="shortcutHelp" class="shortcutHelp" role="dialog" aria-label="Keyboard shortcuts" style="display:none;">
<div class="shortcutHelpBackdrop"></div>
<div class="shortcutHelpPanel">
<h2 class="shortcutHelpTitle">Keyboard Shortcuts</h2>
<div class="shortcutGrid">
<div class="shortcutKey"><kbd>Space</kbd></div><div class="shortcutDesc">Play / Pause</div>
<div class="shortcutKey"><kbd>&#8592;</kbd> <kbd>&#8594;</kbd></div><div class="shortcutDesc">Seek &plusmn;5 seconds</div>
<div class="shortcutKey"><kbd>&#8593;</kbd> <kbd>&#8595;</kbd></div><div class="shortcutDesc">Volume &plusmn;5%</div>
<div class="shortcutKey"><kbd>M</kbd></div><div class="shortcutDesc">Mute / Unmute</div>
<div class="shortcutKey"><kbd>F</kbd></div><div class="shortcutDesc">Toggle fullscreen</div>
<div class="shortcutKey"><kbd>[</kbd> <kbd>]</kbd></div><div class="shortcutDesc">Decrease / Increase speed</div>
<div class="shortcutKey"><kbd>Alt+&#8593;</kbd> <kbd>Alt+&#8595;</kbd></div><div class="shortcutDesc">Reorder playlist item</div>
<div class="shortcutKey"><kbd>?</kbd></div><div class="shortcutDesc">This help</div>
</div>
<script type="module" src="/main.ts"></script>
</body>
<div class="shortcutHelpClose">Press <kbd>?</kbd> or <kbd>Esc</kbd> to close</div>
</div>
</div>
<script type="module" src="/main.ts"></script>
</body>
</html>

View File

@@ -1 +1,322 @@
console.log("TutorialDock frontend loaded");
/**
* TutorialDock frontend — boot sequence, tick loop, global wiring.
* Orchestrates all modules and holds cross-module callbacks.
*/
import '@fortawesome/fontawesome-free/css/all.min.css';
import '@fontsource/bricolage-grotesque/500.css';
import '@fontsource/bricolage-grotesque/600.css';
import '@fontsource/bricolage-grotesque/700.css';
import '@fontsource/inter/400.css';
import '@fontsource/inter/500.css';
import '@fontsource/inter/600.css';
import '@fontsource/inter/700.css';
import '@fontsource/space-mono/400.css';
import '@fontsource/space-mono/700.css';
import './styles/main.css';
import './styles/player.css';
import './styles/playlist.css';
import './styles/panels.css';
import './styles/components.css';
import './styles/animations.css';
import { api } from './api';
import type { LibraryInfo } from './types';
import {
library, currentIndex, prefs, suppressTick, lastTick,
setLibrary, setCurrentIndex, setPrefs, setSuppressTick, setLastTick,
clamp, cb, currentItem,
} from './store';
import {
initPlayer, loadVideoSrc, updatePlayPauseIcon, updateTimeReadout,
updateSeekFill, updateVolFill, updateVideoOverlay, updateSpeedIcon,
setVolume, setPlaybackRate, buildSpeedMenu, isPlaying, getVideoTime,
getVideoDuration, getPlayer, isVolDragging,
toggleMute, toggleFullscreen, showSeekFeedback, cycleSpeed,
} from './player';
import { initPlaylist, renderList, isDragging } from './playlist';
import { initSubtitles, refreshSubtitles, clearSubtitles } from './subtitles';
import {
initUI, applyZoom, applySplit, applyDockSplit,
updateInfoPanel, updateOverall, updateNowHeader,
loadNoteForCurrent, refreshCurrentVideoMeta, notify,
setOnTopChecked, setAutoplayChecked,
} from './ui';
import { initTooltips } from './tooltips';
// ---- Wire cross-module callbacks ----
cb.loadIndex = loadIndex;
cb.renderList = renderList;
cb.updateInfoPanel = updateInfoPanel;
cb.updateOverall = updateOverall;
cb.notify = notify;
cb.refreshCurrentVideoMeta = refreshCurrentVideoMeta;
cb.onLibraryLoaded = onLibraryLoaded;
cb.buildSpeedMenu = buildSpeedMenu;
// ---- Boot ----
async function boot(): Promise<void> {
// Init all modules
initPlayer();
initPlaylist();
initSubtitles();
initUI();
initTooltips();
// Load prefs
const pres = await api.getPrefs();
const p: Record<string, any> = (pres && pres.ok) ? (pres.prefs as any || {}) : {};
setPrefs(p);
p.ui_zoom = applyZoom(p.ui_zoom || 1.0);
p.split_ratio = applySplit(p.split_ratio || 0.62);
p.dock_ratio = applyDockSplit(p.dock_ratio || 0.62);
setOnTopChecked(!!p.always_on_top);
await api.setPrefs({
ui_zoom: p.ui_zoom,
split_ratio: p.split_ratio,
dock_ratio: p.dock_ratio,
always_on_top: !!p.always_on_top,
});
// Load library
const info = await api.getLibrary();
if (info && info.ok) {
await onLibraryLoaded(info, true);
notify('Ready.');
return;
}
updateOverall();
updateTimeReadout();
updatePlayPauseIcon();
updateInfoPanel();
buildSpeedMenu(1.0);
notify('Open a folder to begin.');
document.title = 'TutorialVault - Open a folder';
}
// ---- onLibraryLoaded ----
async function onLibraryLoaded(info: LibraryInfo, startScan: boolean): Promise<void> {
setLibrary(info);
setCurrentIndex(info.current_index || 0);
clearSubtitles();
const player = getPlayer();
const v = clamp(Number(info.folder_volume ?? 1.0), 0, 1);
setSuppressTick(true);
player.volume = v;
setVolume(v);
setSuppressTick(false);
const r = clamp(Number(info.folder_rate ?? 1.0), 0.25, 3);
setPlaybackRate(r);
buildSpeedMenu(r);
setAutoplayChecked(!!info.folder_autoplay);
updateOverall();
renderList();
updateInfoPanel();
document.title = info?.folder ? `${info.folder} - TutorialVault` : 'TutorialVault';
if (startScan) {
try { await api.startDurationScan(); } catch (_) {}
}
await loadIndex(currentIndex, Number(info.current_time || 0.0), true, false);
}
// ---- loadIndex ----
async function loadIndex(
idx: number,
timecode: number = 0.0,
pauseAfterLoad: boolean = true,
autoplayOnLoad: boolean = false,
): Promise<void> {
if (!library || !library.items || library.items.length === 0) return;
idx = Math.max(0, Math.min(idx, library.items.length - 1));
setCurrentIndex(idx);
const it = library.items[currentIndex] || null;
updateNowHeader(it);
document.title = it ? `${it.title || it.name} - TutorialVault` : 'TutorialVault';
await api.setCurrent(currentIndex, Number(timecode || 0.0));
renderList();
updateInfoPanel();
await loadNoteForCurrent();
await refreshCurrentVideoMeta();
await loadVideoSrc(idx, timecode, pauseAfterLoad, autoplayOnLoad, async () => {
await refreshSubtitles();
});
}
// ---- Tick loop ----
async function tick(): Promise<void> {
const now = Date.now();
if (now - lastTick < 950) return;
setLastTick(now);
if (library && !suppressTick) {
const t = getVideoTime();
const d = getVideoDuration();
const playing = isPlaying();
try { await api.tickProgress(currentIndex, t, d, playing); } catch (_) {}
}
if (now % 3000 < 1000 && !suppressTick) {
try {
const info = await api.getLibrary();
if (info && info.ok) {
const oldIndex = currentIndex;
const oldCount = library?.items?.length || 0;
setLibrary(info);
setCurrentIndex(info.current_index || currentIndex);
setAutoplayChecked(!!info.folder_autoplay);
const player = getPlayer();
const volSlider = document.getElementById('volSlider') as HTMLInputElement & { dragging?: boolean };
const v = clamp(Number(info.folder_volume ?? player.volume ?? 1.0), 0, 1);
if (!isVolDragging() && Math.abs(v - Number(volSlider.value)) > 0.001) {
volSlider.value = String(v);
updateVolFill();
}
const r = clamp(Number(info.folder_rate ?? player.playbackRate ?? 1.0), 0.25, 3);
player.playbackRate = r;
updateSpeedIcon(r);
buildSpeedMenu(r);
updateOverall();
updateInfoPanel();
updateNowHeader(currentItem());
if (!isDragging() && (oldIndex !== currentIndex || oldCount !== (library?.items?.length || 0))) {
renderList();
}
}
} catch (_) {}
}
try { await api.saveWindowState(); } catch (_) {}
}
// ---- Keyboard shortcuts ----
// ---- Shortcut help dialog ----
let shortcutHelpOpen = false;
function toggleShortcutHelp(): void {
const el = document.getElementById('shortcutHelp');
if (!el) return;
shortcutHelpOpen = !shortcutHelpOpen;
if (shortcutHelpOpen) {
el.style.display = 'flex';
document.getElementById('zoomRoot')?.setAttribute('aria-hidden', 'true');
// Focus trap: focus the panel
const panel = el.querySelector('.shortcutHelpPanel') as HTMLElement | null;
if (panel) { panel.tabIndex = -1; panel.focus(); }
} else {
el.style.display = 'none';
document.getElementById('zoomRoot')?.removeAttribute('aria-hidden');
}
}
function initKeyboard(): void {
window.addEventListener('keydown', (e) => {
// Close shortcut help on Escape or ?
if (shortcutHelpOpen) {
if (e.key === 'Escape' || e.key === '?') {
e.preventDefault();
toggleShortcutHelp();
}
return; // trap focus while dialog is open
}
// Don't capture when typing in textarea/input
const tag = (e.target as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
switch (e.key) {
case ' ':
e.preventDefault();
{ const player = getPlayer();
if (player.paused || player.ended) player.play();
else player.pause();
updatePlayPauseIcon(); }
break;
case 'ArrowLeft':
e.preventDefault();
try {
getPlayer().currentTime = Math.max(0, getPlayer().currentTime - 5);
showSeekFeedback(-5);
} catch (_) {}
break;
case 'ArrowRight':
e.preventDefault();
try {
getPlayer().currentTime = Math.min(getPlayer().duration || 0, getPlayer().currentTime + 5);
showSeekFeedback(+5);
} catch (_) {}
break;
case 'ArrowUp':
e.preventDefault();
try {
const p = getPlayer();
p.volume = clamp(p.volume + 0.05, 0, 1);
setVolume(p.volume);
} catch (_) {}
break;
case 'ArrowDown':
e.preventDefault();
try {
const p = getPlayer();
p.volume = clamp(p.volume - 0.05, 0, 1);
setVolume(p.volume);
} catch (_) {}
break;
case 'f':
e.preventDefault();
toggleFullscreen();
break;
case 'm':
e.preventDefault();
toggleMute();
break;
case '[':
e.preventDefault();
cycleSpeed(-1);
break;
case ']':
e.preventDefault();
cycleSpeed(+1);
break;
case '?':
e.preventDefault();
toggleShortcutHelp();
break;
}
});
// Backdrop click closes shortcut help
const helpEl = document.getElementById('shortcutHelp');
if (helpEl) {
helpEl.querySelector('.shortcutHelpBackdrop')?.addEventListener('click', () => {
if (shortcutHelpOpen) toggleShortcutHelp();
});
}
}
// ---- Start ----
initKeyboard();
setInterval(tick, 250);
boot();

595
src/player.ts Normal file
View File

@@ -0,0 +1,595 @@
/**
* Video playback controls — manages the <video> element, seek bar,
* volume slider, play/pause, fullscreen, and video overlay.
*/
import { api } from './api';
import {
library, currentIndex, suppressTick,
setSuppressTick, setSeeking, seeking,
clamp, fmtTime, cb, currentItem, computeResumeTime,
} from './store';
// ---- DOM refs ----
let player: HTMLVideoElement;
let seek: HTMLInputElement;
let seekFill: HTMLElement;
let volSlider: HTMLInputElement & { dragging?: boolean };
let volFill: HTMLElement;
let volTooltip: HTMLElement;
let volMuteBtn: HTMLElement;
let volIcon: HTMLElement;
let playPauseBtn: HTMLElement;
let ppIcon: HTMLElement;
let prevBtn: HTMLElement;
let nextBtn: HTMLElement;
let fsBtn: HTMLElement;
let pipBtn: HTMLElement;
let pipIcon: HTMLElement;
let timeNow: HTMLElement;
let timeDur: HTMLElement;
let speedBtn: HTMLElement;
let speedBtnText: HTMLElement;
let speedIcon: SVGElement;
let speedMenu: HTMLElement;
let videoOverlay: HTMLElement;
let overlayIcon: HTMLElement;
let overlayIconI: HTMLElement;
let seekFeedbackEl: HTMLElement;
let errorOverlay: HTMLElement;
let errorNextBtn: HTMLElement;
// ---- Mute state ----
let muted = false;
let lastVolume = 1.0;
// ---- Seek feedback state ----
let seekFeedbackTimer: ReturnType<typeof setTimeout> | null = null;
let seekAccum = 0;
// ---- Double-click state ----
let clickTimer: ReturnType<typeof setTimeout> | null = null;
export function getPlayer(): HTMLVideoElement { return player; }
export function initPlayer(): void {
player = document.getElementById('player') as HTMLVideoElement;
seek = document.getElementById('seek') as HTMLInputElement;
seekFill = document.getElementById('seekFill')!;
volSlider = document.getElementById('volSlider') as HTMLInputElement & { dragging?: boolean };
volFill = document.getElementById('volFill')!;
volTooltip = document.getElementById('volTooltip')!;
volMuteBtn = document.getElementById('volMuteBtn')!;
volIcon = document.getElementById('volIcon')!;
playPauseBtn = document.getElementById('playPauseBtn')!;
ppIcon = document.getElementById('ppIcon')!;
prevBtn = document.getElementById('prevBtn')!;
nextBtn = document.getElementById('nextBtn')!;
fsBtn = document.getElementById('fsBtn')!;
pipBtn = document.getElementById('pipBtn')!;
pipIcon = document.getElementById('pipIcon')!;
timeNow = document.getElementById('timeNow')!;
timeDur = document.getElementById('timeDur')!;
speedBtn = document.getElementById('speedBtn')!;
speedBtn.setAttribute('aria-haspopup', 'true');
speedBtn.setAttribute('aria-expanded', 'false');
speedBtnText = document.getElementById('speedBtnText')!;
speedIcon = document.getElementById('speedIcon') as unknown as SVGElement;
speedMenu = document.getElementById('speedMenu')!;
videoOverlay = document.getElementById('videoOverlay')!;
overlayIcon = document.getElementById('overlayIcon')!;
overlayIconI = document.getElementById('overlayIconI')!;
seekFeedbackEl = document.getElementById('seekFeedback')!;
errorOverlay = document.getElementById('errorOverlay')!;
errorNextBtn = document.getElementById('errorNextBtn')!;
// --- Play/Pause ---
playPauseBtn.onclick = togglePlay;
// --- Prev / Next ---
prevBtn.onclick = () => nextPrev(-1);
nextBtn.onclick = () => nextPrev(+1);
// --- Fullscreen ---
fsBtn.onclick = () => toggleFullscreen();
// --- Picture-in-Picture ---
if (pipBtn) {
if (!document.pictureInPictureEnabled) {
pipBtn.style.display = 'none';
} else {
pipBtn.onclick = async () => {
try {
if (document.pictureInPictureElement) await document.exitPictureInPicture();
else await player.requestPictureInPicture();
} catch (_) {}
};
player.addEventListener('enterpictureinpicture', () => {
pipIcon.className = 'fa-solid fa-down-left-and-up-right-to-center';
pipBtn.setAttribute('aria-label', 'Exit picture-in-picture');
});
player.addEventListener('leavepictureinpicture', () => {
pipIcon.className = 'fa-solid fa-up-right-from-square';
pipBtn.setAttribute('aria-label', 'Enter picture-in-picture');
});
}
}
// --- Mute button ---
if (volMuteBtn) {
volMuteBtn.onclick = (e) => { e.stopPropagation(); toggleMute(); };
}
// --- Video error state ---
player.addEventListener('error', () => {
if (errorOverlay) errorOverlay.style.display = 'flex';
});
if (errorNextBtn) {
errorNextBtn.onclick = () => nextPrev(+1);
}
// --- Seek bar ---
seek.addEventListener('input', () => {
setSeeking(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 (_) {}
}
setSeeking(false);
updateSeekFill();
});
// --- Video events ---
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();
// Update active row's mini progress bar in real time
const activeRow = document.querySelector('.row.active');
if (activeRow && d > 0) {
const bar = activeRow.querySelector('.rowProgress') as HTMLElement;
if (bar) bar.style.width = clamp((t / d) * 100, 0, 100) + '%';
}
}
cb.updateInfoPanel?.();
});
player.addEventListener('loadedmetadata', async () => {
const d = player.duration || 0;
timeDur.textContent = d ? fmtTime(d) : '00:00';
if (errorOverlay) errorOverlay.style.display = 'none';
await cb.refreshCurrentVideoMeta?.();
});
player.addEventListener('ended', () => nextPrev(+1));
player.addEventListener('play', () => { updatePlayPauseIcon(); updateVideoOverlay(); });
player.addEventListener('pause', () => { updatePlayPauseIcon(); updateVideoOverlay(); });
// --- Volume slider ---
updateVolFill();
volSlider.addEventListener('input', () => {
const v = clamp(Number(volSlider.value || 1.0), 0, 1);
if (muted && v > 0) { muted = false; }
setSuppressTick(true); player.volume = v; setSuppressTick(false);
if (library) (library as any).folder_volume = v;
cb.updateInfoPanel?.();
updateVolFill();
updateVolumeIcon();
// Update tooltip position
if (volTooltip && volTooltip.classList.contains('show')) {
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';
}
});
const showVolTooltip = () => {
volSlider.dragging = true;
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('mousedown', showVolTooltip);
volSlider.addEventListener('touchstart', showVolTooltip);
volSlider.addEventListener('change', async () => {
volSlider.dragging = false;
if (volTooltip) volTooltip.classList.remove('show');
updateVolFill();
if (!library) return;
try {
const v = clamp(Number(volSlider.value || 1.0), 0, 1);
await api.setFolderVolume(v);
(library as any).folder_volume = v;
cb.updateInfoPanel?.();
} catch (_) {}
});
const hideVolTooltip = () => {
volSlider.dragging = false;
if (volTooltip) volTooltip.classList.remove('show');
};
window.addEventListener('mouseup', hideVolTooltip);
window.addEventListener('touchend', hideVolTooltip);
// --- Speed menu ---
speedBtn.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation();
if (speedMenu.classList.contains('show')) closeSpeedMenu();
else {
openSpeedMenu();
const first = speedMenu.querySelector('[role="menuitem"]') as HTMLElement | null;
if (first) first.focus();
}
});
speedBtn.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { closeSpeedMenu(); speedBtn.focus(); }
});
window.addEventListener('click', () => { closeSpeedMenu(); });
speedMenu.addEventListener('click', (e) => e.stopPropagation());
// --- Video overlay (single-click = play/pause, double-click = fullscreen) ---
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');
}
});
videoOverlay.style.pointerEvents = 'auto';
videoOverlay.style.cursor = 'pointer';
videoOverlay.addEventListener('click', () => {
if (clickTimer) {
clearTimeout(clickTimer);
clickTimer = null;
toggleFullscreen();
return;
}
clickTimer = setTimeout(() => {
clickTimer = null;
if (player.paused) player.play();
else player.pause();
overlayIcon.classList.add('pulse');
setTimeout(() => overlayIcon.classList.remove('pulse'), 400);
}, 250);
});
}
setTimeout(updateVideoOverlay, 100);
}
// ---- Exported functions ----
export function togglePlay(): void {
try {
if (player.paused || player.ended) player.play();
else player.pause();
} catch (_) {}
updatePlayPauseIcon();
}
export function nextPrev(delta: number): void {
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;
cb.loadIndex?.(newIdx, computeResumeTime(it), !auto, auto);
}
export function updatePlayPauseIcon(): void {
if (!ppIcon) return;
ppIcon.className = (player.paused || player.ended) ? 'fa-solid fa-play' : 'fa-solid fa-pause';
playPauseBtn.setAttribute('aria-label', (player.paused || player.ended) ? 'Play' : 'Pause');
}
export function updateTimeReadout(): void {
const t = player.currentTime || 0, d = player.duration || 0;
timeNow.textContent = fmtTime(t);
timeDur.textContent = d ? fmtTime(d) : '00:00';
}
export function updateSeekFill(): void {
if (seekFill) {
const pct = (Number(seek.value) / 1000) * 100;
seekFill.style.width = pct + '%';
}
}
export function updateVolFill(): void {
if (volFill && volSlider) {
const pct = clamp(Number(volSlider.value || 1.0), 0, 1) * 100;
volFill.style.width = pct + '%';
}
}
export function updateVideoOverlay(): void {
if (!overlayIcon || !overlayIconI) return;
if (player.paused) {
overlayIconI.className = 'fa-solid fa-play';
overlayIcon.classList.remove('pause');
overlayIcon.classList.add('show');
} else {
overlayIcon.classList.remove('show');
}
}
export function setVolume(vol: number): void {
const v = clamp(vol, 0, 1);
player.volume = v;
volSlider.value = String(v);
updateVolFill();
}
export function setPlaybackRate(rate: number): void {
const r = clamp(rate, 0.25, 3);
player.playbackRate = r;
speedBtnText.textContent = `${r.toFixed(2)}x`;
updateSpeedIcon(r);
}
export function getVideoTime(): number { return player?.currentTime || 0; }
export function getVideoDuration(): number | null {
return player && Number.isFinite(player.duration) ? player.duration : null;
}
export function isPlaying(): boolean { return player ? !player.paused && !player.ended : false; }
export function isVolDragging(): boolean { return !!volSlider?.dragging; }
// ---- Mute ----
export function toggleMute(): void {
if (muted) {
muted = false;
const v = lastVolume > 0 ? lastVolume : 1.0;
player.volume = v;
volSlider.value = String(v);
} else {
lastVolume = player.volume || 1.0;
muted = true;
player.volume = 0;
volSlider.value = '0';
}
updateVolFill();
updateVolumeIcon();
if (library) (library as any).folder_volume = player.volume;
cb.updateInfoPanel?.();
}
export function updateVolumeIcon(): void {
if (!volIcon || !volMuteBtn) return;
const v = player.volume;
if (muted || v === 0) {
volIcon.className = 'fa-solid fa-volume-xmark';
volMuteBtn.setAttribute('aria-label', 'Unmute');
volMuteBtn.closest('.miniCtl')?.classList.add('muted');
} else if (v < 0.5) {
volIcon.className = 'fa-solid fa-volume-low';
volMuteBtn.setAttribute('aria-label', 'Mute');
volMuteBtn.closest('.miniCtl')?.classList.remove('muted');
} else {
volIcon.className = 'fa-solid fa-volume-high';
volMuteBtn.setAttribute('aria-label', 'Mute');
volMuteBtn.closest('.miniCtl')?.classList.remove('muted');
}
}
// ---- Fullscreen ----
export async function toggleFullscreen(): Promise<void> {
try {
if (document.fullscreenElement) await document.exitFullscreen();
else await player.requestFullscreen();
} catch (_) {}
}
// ---- Seek feedback ----
export function showSeekFeedback(delta: number): void {
seekAccum += delta;
const sign = seekAccum >= 0 ? '+' : '\u2212';
seekFeedbackEl.textContent = `${sign}${Math.abs(seekAccum)}s`;
seekFeedbackEl.classList.add('show');
if (seekFeedbackTimer) clearTimeout(seekFeedbackTimer);
seekFeedbackTimer = setTimeout(() => {
seekFeedbackEl.classList.remove('show');
seekAccum = 0;
}, 600);
}
// ---- Speed cycle ----
export function cycleSpeed(delta: number): void {
const current = player.playbackRate;
let idx = SPEEDS.findIndex(s => Math.abs(s - current) < 0.01);
if (idx === -1) idx = SPEEDS.indexOf(1.0);
idx = clamp(idx + delta, 0, SPEEDS.length - 1);
const r = SPEEDS[idx];
player.playbackRate = r;
if (library) (library as any).folder_rate = r;
speedBtnText.textContent = `${r.toFixed(2)}x`;
updateSpeedIcon(r);
buildSpeedMenu(r);
cb.updateInfoPanel?.();
cb.notify?.(`Speed: ${r}x`);
setSuppressTick(true);
if (library) { api.setFolderRate(r).catch(() => {}).finally(() => setSuppressTick(false)); }
else { setSuppressTick(false); }
}
/** Load a video by index and handle the onloadedmetadata callback. */
export async function loadVideoSrc(
idx: number,
timecode: number = 0,
pauseAfterLoad: boolean = true,
autoplayOnLoad: boolean = false,
onReady?: () => Promise<void>,
): Promise<void> {
if (!library || !library.items || library.items.length === 0) return;
idx = Math.max(0, Math.min(idx, library.items.length - 1));
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);
setSuppressTick(true);
player.src = `http://tutdock.localhost/video/${idx}`;
player.load();
player.onloadedmetadata = async () => {
try { player.volume = keepVol; volSlider.value = String(keepVol); updateVolFill(); } catch (_) {}
try { player.playbackRate = keepRate; } catch (_) {}
speedBtnText.textContent = `${keepRate.toFixed(2)}x`;
updateSpeedIcon(keepRate);
cb.buildSpeedMenu?.(keepRate);
try { const t = Number(timecode || 0.0); if (t > 0) player.currentTime = t; } catch (_) {}
if (onReady) await onReady();
if (autoplayOnLoad) {
try { await player.play(); } catch (_) {}
} else if (pauseAfterLoad) {
player.pause();
}
setSuppressTick(false);
updateTimeReadout();
updatePlayPauseIcon();
cb.updateInfoPanel?.();
await cb.refreshCurrentVideoMeta?.();
};
}
// ---- Speed menu ----
const SPEEDS = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
export function closeSpeedMenu(): void {
speedMenu?.classList.remove('show');
speedBtn?.setAttribute('aria-expanded', 'false');
}
export function openSpeedMenu(): void {
speedMenu?.classList.add('show');
speedBtn?.setAttribute('aria-expanded', 'true');
}
export function buildSpeedMenu(active: number): void {
if (!speedMenu) return;
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');
row.tabIndex = -1;
row.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault(); e.stopPropagation();
const next = row.nextElementSibling as HTMLElement | null;
if (next) next.focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault(); e.stopPropagation();
const prev = row.previousElementSibling as HTMLElement | null;
if (prev) prev.focus();
} else if (e.key === 'Escape' || e.key === 'Tab') {
e.preventDefault(); e.stopPropagation();
closeSpeedMenu();
speedBtn.focus();
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.stopPropagation();
row.click();
}
});
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 = () => {
closeSpeedMenu();
const r = clamp(Number(s), 0.25, 3);
player.playbackRate = r;
if (library) (library as any).folder_rate = r;
speedBtnText.textContent = `${r.toFixed(2)}x`;
updateSpeedIcon(r);
buildSpeedMenu(r);
cb.updateInfoPanel?.();
setSuppressTick(true);
if (library) { api.setFolderRate(r).catch(() => {}).finally(() => setSuppressTick(false)); }
else { setSuppressTick(false); }
};
speedMenu.appendChild(row);
}
}
export function updateSpeedIcon(rate: number): void {
if (!speedIcon) return;
let needleAngle = 0;
let needleColor = 'rgba(255,255,255,.85)';
if (rate <= 0.5) {
needleAngle = -150;
needleColor = 'rgba(100,180,255,.9)';
} else if (rate < 1.0) {
const t = (rate - 0.5) / 0.5;
needleAngle = -150 + t * 150;
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) {
const t = (rate - 1.0) / 1.0;
needleAngle = t * 150;
needleColor = `rgba(255,${Math.round(255 - t * 115)},${Math.round(255 - t * 155)},0.9)`;
} else {
needleAngle = 150;
needleColor = 'rgba(255,140,100,.9)';
}
const needleGroup = speedIcon.querySelector('.speed-needle') as SVGElement | null;
const needleLine = speedIcon.querySelector('.speed-needle line') as SVGElement | null;
if (needleGroup && needleLine) {
needleGroup.style.transform = `rotate(${needleAngle}deg)`;
needleLine.style.stroke = needleColor;
} else {
speedIcon.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"/>
`;
}
}

525
src/playlist.ts Normal file
View File

@@ -0,0 +1,525 @@
/**
* Playlist rendering — list items, tree SVG connectors, custom scrollbar,
* and drag-and-drop reorder.
*/
import { api } from './api';
import type { VideoItem } from './types';
import {
library, currentIndex, setCurrentIndex,
clamp, fmtTime, cb, currentItem, computeResumeTime,
setLibrary,
} from './store';
// ---- DOM refs ----
let listEl: HTMLElement;
let emptyHint: HTMLElement;
let listScrollbar: HTMLElement;
let listScrollbarThumb: HTMLElement;
let plistSearch: HTMLInputElement;
let plistSearchClear: HTMLElement;
let plistStats: HTMLElement;
let scrollToCurrentBtn: HTMLElement;
// ---- Scroll-to-current observer ----
let activeRowObserver: IntersectionObserver | null = null;
// ---- Drag state ----
let dragFromIndex: number | null = null;
let dropTargetIndex: number | null = null;
let dropAfter = false;
/** True while a drag-reorder is in progress. Used to guard against renderList during drag. */
export function isDragging(): boolean { return dragFromIndex !== null; }
// ---- Scrollbar state ----
let scrollbarHideTimer: ReturnType<typeof setTimeout> | null = null;
let scrollbarDragging = false;
let scrollbarDragStartY = 0;
let scrollbarDragStartScrollTop = 0;
// ---- Scroll fades ----
let updateListFades: () => void = () => {};
export function initPlaylist(): void {
listEl = document.getElementById('list')!;
listEl.setAttribute('role', 'listbox');
listEl.setAttribute('aria-label', 'Playlist');
emptyHint = document.getElementById('emptyHint')!;
listScrollbar = document.getElementById('listScrollbar')!;
listScrollbarThumb = document.getElementById('listScrollbarThumb')!;
// Scrollbar drag handlers
if (listScrollbarThumb) {
listScrollbarThumb.style.pointerEvents = 'auto';
listScrollbar.style.pointerEvents = 'auto';
const startDrag = (e: MouseEvent | TouchEvent) => {
e.preventDefault();
scrollbarDragging = true;
scrollbarDragStartY = 'touches' in e ? e.touches[0].clientY : e.clientY;
scrollbarDragStartScrollTop = listEl.scrollTop;
listScrollbar.classList.add('active');
document.body.style.userSelect = 'none';
};
const doDrag = (e: MouseEvent | TouchEvent) => {
if (!scrollbarDragging) return;
const clientY = 'touches' in e ? 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 as any);
listScrollbarThumb.addEventListener('touchstart', startDrag as any);
window.addEventListener('mousemove', doDrag as any);
window.addEventListener('touchmove', doDrag as any);
window.addEventListener('mouseup', endDrag);
window.addEventListener('touchend', endDrag);
}
// Allow internal DnD on the list container
if (listEl) {
listEl.addEventListener('dragenter', (e) => { e.preventDefault(); });
listEl.addEventListener('dragover', (e) => { e.preventDefault(); });
listEl.addEventListener('drop', (e) => { e.preventDefault(); });
}
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();
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);
}
// Live region for reorder announcements
const liveRegion = document.createElement('div');
liveRegion.id = 'playlistLive';
liveRegion.setAttribute('aria-live', 'polite');
liveRegion.setAttribute('aria-atomic', 'true');
liveRegion.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);';
document.body.appendChild(liveRegion);
// --- Playlist search ---
plistSearch = document.getElementById('plistSearch') as HTMLInputElement;
plistSearchClear = document.getElementById('plistSearchClear')!;
plistStats = document.getElementById('plistStats')!;
if (plistSearch) {
plistSearch.addEventListener('input', () => {
plistSearchClear.style.display = plistSearch.value ? 'flex' : 'none';
renderList();
});
plistSearch.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
plistSearch.value = '';
plistSearchClear.style.display = 'none';
renderList();
}
});
}
if (plistSearchClear) {
plistSearchClear.onclick = () => {
plistSearch.value = '';
plistSearchClear.style.display = 'none';
plistSearch.focus();
renderList();
};
}
// --- Scroll-to-current button ---
scrollToCurrentBtn = document.getElementById('scrollToCurrent')!;
if (scrollToCurrentBtn) {
scrollToCurrentBtn.onclick = () => {
const activeRow = listEl.querySelector('.row.active') as HTMLElement | null;
if (activeRow) activeRow.scrollIntoView({ block: 'center', behavior: 'smooth' });
};
}
}
export function updateScrollbar(): void {
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;
const thumbTop = maxScroll > 0 ? (scrollTop / maxScroll) * (trackHeight - thumbHeight) : 0;
listScrollbarThumb.style.height = thumbHeight + 'px';
listScrollbarThumb.style.top = thumbTop + 'px';
}
function clearDropIndicators(): void {
listEl.querySelectorAll('.row').forEach(r => r.classList.remove('drop-before', 'drop-after'));
}
async function reorderPlaylistByGap(fromIdx: number, targetIdx: number, after: boolean): Promise<void> {
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 api.setOrder(base);
if (res && res.ok) {
const info = await api.getLibrary();
if (info && info.ok) {
setLibrary(info);
setCurrentIndex(info.current_index || 0);
renderList();
cb.updateOverall?.();
cb.updateInfoPanel?.();
await cb.refreshCurrentVideoMeta?.();
}
}
} catch (err) { console.error('reorderPlaylistByGap failed:', err); }
}
export function renderTreeSvg(it: VideoItem): SVGSVGElement {
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;
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: number) => pad + j * unit + 0.5;
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);
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 : String(height + extend)));
svg.appendChild(vln);
}
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;
}
export function renderList(): void {
listEl.innerHTML = '';
if (!library || !library.items || library.items.length === 0) {
emptyHint.style.display = 'block';
if (plistStats) plistStats.textContent = '';
return;
}
emptyHint.style.display = 'none';
const tree = !!library.has_subdirs;
const padN = String(library.items.length).length;
// Search filter
const filterText = plistSearch?.value?.trim().toLowerCase() || '';
const allItems = library.items;
const filteredItems: { it: typeof allItems[0]; displayIndex: number }[] = [];
for (let i = 0; i < allItems.length; i++) {
const it = allItems[i];
if (filterText) {
const haystack = `${it.title || ''} ${it.name || ''} ${it.relpath || ''}`.toLowerCase();
if (!haystack.includes(filterText)) continue;
}
filteredItems.push({ it, displayIndex: i });
}
// Stats
const totalCount = allItems.length;
const doneCount = allItems.filter(it => it.finished).length;
if (plistStats) {
if (filterText) {
plistStats.textContent = `${filteredItems.length} of ${totalCount}`;
} else {
plistStats.textContent = `${totalCount} videos \u00b7 ${doneCount} done`;
}
}
// Disconnect old observer
if (activeRowObserver) { activeRowObserver.disconnect(); activeRowObserver = null; }
for (const { it, displayIndex } of filteredItems) {
const row = document.createElement('div');
row.className = 'row' + (it.index === currentIndex ? ' active' : '');
row.draggable = true;
row.dataset.index = String(it.index);
row.setAttribute('role', 'option');
row.setAttribute('aria-selected', it.index === currentIndex ? 'true' : 'false');
row.tabIndex = 0;
// Computed aria-label
const durStr = it.duration ? `${fmtTime(it.watched || 0)} / ${fmtTime(it.duration)}` : `${fmtTime(it.watched || 0)} watched`;
const statusStr = it.index === currentIndex ? 'Now playing' : it.finished ? 'Done' : '';
row.setAttribute('aria-label', `${String(displayIndex + 1).padStart(padN, '0')}. ${it.title || it.name} - ${durStr}${statusStr ? ' - ' + statusStr : ''}`);
row.onclick = () => {
if (dragFromIndex !== null) return;
cb.loadIndex?.(it.index, computeResumeTime(it), true);
};
row.addEventListener('keydown', (e) => {
const key = e.key;
if (key === 'Enter' || key === ' ') {
e.preventDefault(); e.stopPropagation();
cb.loadIndex?.(it.index, computeResumeTime(it), true);
} else if (key === 'ArrowDown' && !e.altKey) {
e.preventDefault(); e.stopPropagation();
const next = row.nextElementSibling as HTMLElement | null;
if (next && next.classList.contains('row')) next.focus();
} else if (key === 'ArrowUp' && !e.altKey) {
e.preventDefault(); e.stopPropagation();
const prev = row.previousElementSibling as HTMLElement | null;
if (prev && prev.classList.contains('row')) prev.focus();
} else if (e.altKey && key === 'ArrowDown' && displayIndex < library!.items!.length - 1) {
e.preventDefault(); e.stopPropagation();
reorderPlaylistByGap(it.index, library!.items![displayIndex + 1].index, true).then(() => {
setTimeout(() => {
const moved = listEl.querySelector(`[data-index="${it.index}"]`) as HTMLElement | null;
if (moved) moved.focus();
const lr = document.getElementById('playlistLive');
if (lr) lr.textContent = `Moved to position ${displayIndex + 2}`;
}, 100);
});
} else if (e.altKey && key === 'ArrowUp' && displayIndex > 0) {
e.preventDefault(); e.stopPropagation();
reorderPlaylistByGap(it.index, library!.items![displayIndex - 1].index, false).then(() => {
setTimeout(() => {
const moved = listEl.querySelector(`[data-index="${it.index}"]`) as HTMLElement | null;
if (moved) moved.focus();
const lr = document.getElementById('playlistLive');
if (lr) lr.textContent = `Moved to position ${displayIndex}`;
}, 100);
});
}
});
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('dragenter', (e) => {
e.preventDefault();
e.stopPropagation();
});
row.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
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(); e.stopPropagation(); });
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 ? ' \u2022 note' : '';
const sub = it.has_sub ? ' \u2022 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 = ''; }
// Move buttons for keyboard reorder alternative
const moveWrap = document.createElement('div');
moveWrap.className = 'moveWrap';
if (displayIndex > 0) {
const moveUp = document.createElement('button');
moveUp.className = 'moveBtn';
moveUp.setAttribute('aria-label', 'Move up');
moveUp.innerHTML = '<i class="fa-solid fa-chevron-up" aria-hidden="true"></i>';
moveUp.tabIndex = -1;
moveUp.addEventListener('click', (e) => {
e.stopPropagation();
reorderPlaylistByGap(it.index, library!.items![displayIndex - 1].index, false).then(() => {
const lr = document.getElementById('playlistLive');
if (lr) lr.textContent = `Moved to position ${displayIndex}`;
});
});
moveWrap.appendChild(moveUp);
}
if (displayIndex < library!.items!.length - 1) {
const moveDown = document.createElement('button');
moveDown.className = 'moveBtn';
moveDown.setAttribute('aria-label', 'Move down');
moveDown.innerHTML = '<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>';
moveDown.tabIndex = -1;
moveDown.addEventListener('click', (e) => {
e.stopPropagation();
reorderPlaylistByGap(it.index, library!.items![displayIndex + 1].index, true).then(() => {
const lr = document.getElementById('playlistLive');
if (lr) lr.textContent = `Moved to position ${displayIndex + 2}`;
});
});
moveWrap.appendChild(moveDown);
}
// Mini progress bar (Enhancement 14)
if (it.duration && it.duration > 0) {
const rowProgress = document.createElement('div');
rowProgress.className = 'rowProgress';
rowProgress.setAttribute('aria-hidden', 'true');
const pct = clamp(((it.watched || 0) / it.duration) * 100, 0, 100);
rowProgress.style.width = pct + '%';
if (it.finished) rowProgress.classList.add('done');
row.appendChild(rowProgress);
}
row.appendChild(left);
row.appendChild(moveWrap);
row.appendChild(tag);
listEl.appendChild(row);
}
// Scroll-to-current observer
const activeRow = listEl.querySelector('.row.active') as HTMLElement | null;
if (activeRow && scrollToCurrentBtn) {
activeRowObserver = new IntersectionObserver((entries) => {
const visible = entries[0]?.isIntersecting;
scrollToCurrentBtn.style.display = visible ? 'none' : 'inline-flex';
}, { root: listEl, threshold: 0.5 });
activeRowObserver.observe(activeRow);
} else if (scrollToCurrentBtn) {
scrollToCurrentBtn.style.display = 'none';
}
setTimeout(updateListFades, 50);
}

83
src/store.ts Normal file
View File

@@ -0,0 +1,83 @@
/**
* Shared mutable state and pure utility functions.
* All modules import from here — no circular dependencies.
*/
import type { LibraryInfo, VideoItem } from './types';
// ---- Shared mutable state ----
export let library: LibraryInfo | null = null;
export let currentIndex = 0;
export let prefs: Record<string, any> | null = null;
export let suppressTick = false;
export let lastTick = 0;
export let seeking = false;
export function setLibrary(lib: LibraryInfo | null) { library = lib; }
export function setCurrentIndex(idx: number) { currentIndex = idx; }
export function setPrefs(p: Record<string, any> | null) { prefs = p; }
export function setSuppressTick(v: boolean) { suppressTick = v; }
export function setLastTick(v: number) { lastTick = v; }
export function setSeeking(v: boolean) { seeking = v; }
// ---- Cross-module callbacks (set by main.ts) ----
export const cb = {
loadIndex: null as ((idx: number, tc?: number, pause?: boolean, autoplay?: boolean) => Promise<void>) | null,
renderList: null as (() => void) | null,
updateInfoPanel: null as (() => void) | null,
updateOverall: null as (() => void) | null,
notify: null as ((msg: string) => void) | null,
refreshCurrentVideoMeta: null as (() => Promise<void>) | null,
onLibraryLoaded: null as ((info: LibraryInfo, startScan: boolean) => Promise<void>) | null,
buildSpeedMenu: null as ((rate: number) => void) | null,
};
// ---- Pure utility functions ----
export function clamp(n: number, a: number, b: number): number {
return Math.max(a, Math.min(b, n));
}
export function fmtTime(sec: number): string {
sec = Math.max(0, Math.floor(sec || 0));
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const 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')}`;
}
export function fmtBytes(n: number): string {
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]}`;
}
export function fmtDate(ts: number): string {
if (!ts) return '-';
try { return new Date(ts * 1000).toLocaleString(); } catch { return '-'; }
}
export function fmtBitrate(bps: number): string | null {
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`;
}
export function currentItem(): VideoItem | null {
if (!library || !library.items) return null;
return library.items[currentIndex] || null;
}
export function computeResumeTime(item: VideoItem | null): number {
if (!item) return 0.0;
if (item.finished) return 0.0;
const pos = Number(item.pos || 0.0);
const 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);
}

View File

@@ -0,0 +1,9 @@
/* Reduced motion — respect user preference */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

225
src/styles/components.css Normal file
View File

@@ -0,0 +1,225 @@
/* 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 .35s var(--ease-bounce);
}
#toast.show{
opacity:1;
transform:translateY(0) scale(var(--zoom));
}
.toastInner{
pointer-events:none;
display:flex; align-items:center; gap:12px;
padding:10px 14px;
border-radius:var(--r);
border:1px solid rgba(140,160,210,.10);
background:rgba(18,21,30,.95);
box-shadow:var(--shadow2);
}
.toastIcon{width:18px; height:18px; display:flex; align-items:center; justify-content:center;}
.toastIcon .fa{font-size:14px; color:rgba(200,212,238,.78)!important; opacity:.95; transition:transform .3s var(--ease-bounce);}
#toast.show .toastIcon .fa{animation:toastIconIn .4s var(--ease-bounce);}
@keyframes toastIconIn{
0%{transform:scale(0) rotate(-90deg);}
60%{transform:scale(1.2) rotate(5deg);}
100%{transform:scale(1) rotate(0);}
}
.toastMsg{
font-size:13px;
font-weight:500;
letter-spacing:0;
color:var(--text);
}
/* Toolbar icon buttons — borderless */
.toolbarIcon{
width:36px; height:36px;
border-radius:var(--r2);
background:var(--surface-2);
border:none;
color:rgba(218,225,240,.85);
font-size:14px;
transition:all .2s var(--ease-bounce);
}
.toolbarIcon:hover{
background:var(--surface-3);
color:rgba(235,240,252,.95);
transform:translateY(-1px);
}
.toolbarIcon:active{
transform:scale(.9) translateY(0);
transition-duration:.08s;
}
/* Tooltip */
.tooltip{
position:fixed;
pointer-events:none;
z-index:99999;
border-radius:var(--r);
padding:14px 16px;
opacity:0;
transform:translateY(6px) scale(.96);
transition:opacity .2s ease, transform .25s var(--ease-bounce), left .12s ease, top .12s ease;
max-width:320px;
font-family:var(--sans);
background:rgba(18,21,30,.95);
border:1px solid rgba(140,160,210,.10);
box-shadow:var(--shadow2);
}
.tooltip.visible{
opacity:1;
transform:translateY(0) scale(1);
}
.tooltip::before{display:none;}
.tooltip::after{display:none;}
.tooltip-title{
font-family:var(--brand);
font-weight:600;
font-size:14px;
margin-bottom:6px;
letter-spacing:-.02em;
color:rgba(235,240,252,.95);
}
.tooltip-desc{
font-family:var(--sans);
font-size:12px;
font-weight:400;
color:rgba(170,182,210,.86);
line-height:1.55;
letter-spacing:0;
position:relative;
z-index:1;
}
.tooltip-desc:empty{display:none;}
.tooltip-desc:empty ~ .tooltip-title{margin-bottom:0;}
.subsBox{position:relative;}
.subsMenu{
position:absolute; left:50%; bottom:calc(100% + 10px);
transform:translateX(-50%) scale(.95);
min-width:220px; padding:8px;
border-radius:var(--r); border:1px solid rgba(140,160,210,.10);
background:rgba(18,21,30,.95);
box-shadow:var(--shadow);
display:none; z-index:30;
opacity:0;
transition:opacity .15s ease, transform .2s var(--ease-bounce);
}
.subsMenu.show{display:block; opacity:1; transform:translateX(-50%) scale(1);}
.subsMenuHeader{padding:6px 12px 4px; font-size:10px; font-weight:600; text-transform:uppercase; letter-spacing:.08em; color:var(--textDim); transition:color .2s ease;}
.subsMenuItem{padding:10px 12px; border-radius:var(--r3); cursor:pointer; user-select:none; font-size:12px; font-weight:500; color:var(--text); letter-spacing:0; display:flex; align-items:center; gap:10px; transition:all .2s var(--ease-bounce);}
.subsMenuItem:hover{background:var(--surfaceHover); padding-left:16px;}
.subsMenuItem:active{transform:scale(.97); transition-duration:.08s;}
.subsMenuItem .fa{font-size:13px; color:var(--iconStrong)!important; opacity:.85; width:18px; text-align:center; transition:transform .2s var(--ease-bounce), opacity .15s ease;}
.subsMenuItem:hover .fa{transform:scale(1.15) rotate(-5deg); opacity:1;}
.subsMenuItem.embedded{color:rgba(150,185,230,.92);}
.subsDivider{height:1px; background:rgba(140,160,210,.06); margin:6px 4px;}
.subsEmpty{padding:10px 12px; color:var(--textDim); font-size:11px; text-align:center;}
.speedMenu{
position:absolute; right:0; bottom:calc(100% + 10px);
min-width:180px; padding:8px;
border-radius:var(--r); border:1px solid rgba(140,160,210,.10);
background:rgba(18,21,30,.95);
box-shadow:var(--shadow);
display:none; z-index:30;
opacity:0;
transform:scale(.95) translateY(4px);
transition:opacity .15s ease, transform .2s var(--ease-bounce);
}
.speedMenu.show{display:block; opacity:1; transform:scale(1) translateY(0);}
.speedItem{padding:10px 10px; border-radius:var(--r3); cursor:pointer; user-select:none; font-family:var(--mono); font-size:14px; color:var(--text); letter-spacing:.02em; display:flex; align-items:center; justify-content:space-between; gap:10px; transition:all .2s var(--ease-bounce);}
.speedItem:hover{background:var(--surfaceHover); padding-left:14px;}
.speedItem:active{transform:scale(.97); transition-duration:.08s;}
.speedItem .dot{width:8px; height:8px; border-radius:999px; background:rgba(140,165,220,.06); border:none; flex:0 0 auto; transition:all .2s var(--ease-bounce);}
.speedItem:hover .dot{transform:scale(1.3);}
.speedItem.active .dot{background:rgba(136,164,196,.65);}
.speedItem.active:hover .dot{background:rgba(136,164,196,.80); box-shadow:0 0 6px rgba(136,164,196,.25);}
.subsMenuItem:focus-visible{background:var(--surfaceHover); padding-left:16px; outline:none;}
.speedItem:focus-visible{background:var(--surfaceHover); padding-left:14px; outline:none;}
.dropItem:focus-visible{background:var(--surfaceHover); padding-left:16px; outline:none;}
/* Keyboard shortcut help dialog */
.shortcutHelp{
position:fixed;
inset:0;
z-index:999999;
display:flex;
align-items:center;
justify-content:center;
}
.shortcutHelpBackdrop{
position:absolute;
inset:0;
background:rgba(0,0,0,.55);
}
.shortcutHelpPanel{
position:relative;
z-index:1;
padding:28px 32px;
border-radius:var(--r);
background:rgba(18,21,30,.97);
border:1px solid rgba(140,160,210,.12);
box-shadow:var(--shadow);
max-width:420px;
width:90%;
}
.shortcutHelpTitle{
font-family:var(--brand);
font-weight:600;
font-size:17px;
line-height:1.2;
margin:0 0 20px;
color:rgba(235,240,252,.95);
letter-spacing:-.02em;
}
.shortcutGrid{
display:grid;
grid-template-columns:auto 1fr;
gap:10px 20px;
align-items:baseline;
}
.shortcutKey{
text-align:right;
white-space:nowrap;
}
.shortcutKey kbd{
display:inline-block;
padding:3px 8px;
border-radius:4px;
background:rgba(140,165,220,.08);
border:1px solid rgba(140,165,220,.12);
font-family:var(--mono);
font-size:12px;
color:rgba(200,212,238,.88);
line-height:1;
}
.shortcutDesc{
font-size:13px;
color:rgba(200,212,238,.78);
}
.shortcutHelpClose{
margin-top:20px;
text-align:center;
font-size:11px;
color:var(--textDim);
}
.shortcutHelpClose kbd{
display:inline-block;
padding:2px 6px;
border-radius:3px;
background:rgba(140,165,220,.06);
border:1px solid rgba(140,165,220,.10);
font-family:var(--mono);
font-size:11px;
color:rgba(170,182,210,.70);
}

523
src/styles/main.css Normal file
View File

@@ -0,0 +1,523 @@
:root{
--zoom:1;
/* Type scale — Minor Third (1.2) from 13px base: 10 · 11 · 12 · 13 · 15 · 17 · 19 */
/* Base backgrounds — cool dark slate, lighter */
--bg0:#0f1117; --bg1:#151821;
/* Strokes — cool, very subtle */
--stroke:rgba(140,160,210,.07);
--strokeLight:rgba(140,160,210,.04);
--strokeMed:rgba(140,160,210,.09);
--strokeStrong:rgba(140,160,210,.14);
/* Text — cool white hierarchy */
--text:rgba(218,225,240,.90);
--textMuted:rgba(185,196,222,.86);
--textDim:rgba(172,184,214,.88);
/* Surfaces — cool-tinted, subtle fills */
--surface-0:rgba(140,165,220,.04);
--surface:rgba(140,165,220,.06);
--surface-2:rgba(140,165,220,.09);
--surface-3:rgba(140,165,220,.12);
--surface-4:rgba(140,165,220,.15);
--surfaceHover:rgba(140,165,220,.10);
--surfaceActive:rgba(140,165,220,.13);
/* Shadows — minimal */
--shadow:0 8px 24px rgba(0,0,0,.25);
--shadow2:0 4px 12px rgba(0,0,0,.15);
--shadow3:none;
--shadowFloat:0 6px 20px rgba(0,0,0,.20);
--shadowInset:inset 0 1px 3px rgba(0,0,0,.12);
/* Radii — architectural */
--r:6px; --r2:4px; --r3:3px;
/* Easing — refined, springy */
--ease-spring:cubic-bezier(.25,.46,.45,.94);
--ease-bounce:cubic-bezier(.34,1.56,.64,1);
--ease-out-back:cubic-bezier(.34,1.3,.64,1);
/* Fonts */
--mono:"Space Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--sans:"Inter", ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
--brand:"Bricolage Grotesque", "Inter", ui-sans-serif, system-ui, sans-serif;
/* Icons — cool */
--icon:rgba(160,175,210,.62);
--iconStrong:rgba(200,212,238,.75);
/* Accent — steel blue */
--accent:#88A4C4;
--accentGlow:rgba(136,164,196,.08);
--accentBorder:rgba(136,164,196,.18);
--accentBg:rgba(136,164,196,.06);
/* Success — muted sage */
--success:rgb(130,170,130);
--successBg:rgba(130,170,130,.06);
--successBorder:rgba(130,170,130,.15);
/* Tree — cool */
--tree:rgba(140,165,220,.07);
--treeNode:rgba(200,212,238,.40);
}
*{box-sizing:border-box;}
h1,h2,h3{margin:0; font-size:inherit; font-weight:inherit;}
html,body{height:100%;}
body{
margin:0; padding:0; font-family:var(--sans); color:var(--text); overflow:hidden;
width:100vw; height:100vh;
font-weight:400;
line-height:1.45;
background:
radial-gradient(900px 600px at 8% 3%, rgba(100,140,210,.04), transparent 55%),
radial-gradient(600px 400px at 92% 97%, rgba(90,120,180,.03), transparent 60%),
linear-gradient(180deg, var(--bg1), var(--bg0));
letter-spacing:0;
}
*:focus{outline:none;}
*:focus-visible{
outline:2px solid rgba(136,164,196,.65);
outline-offset:2px;
border-radius:inherit;
}
#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:12px;
padding:10px 12px;
height:62px;
flex:0 0 62px;
border-bottom:1px solid var(--stroke);
background:#181d2a;
min-width:0; z-index:5; position:relative;
box-sizing:border-box;
}
.topbar::before{
content:"";
position:absolute;
inset:0;
background:
radial-gradient(circle, rgba(140,170,220,.08) 2px, transparent 2.5px) 0 0 / 12px 12px,
radial-gradient(circle, rgba(160,185,230,.05) 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{display:none;}
.brand{display:flex; align-items:center; gap:12px; min-width:0; flex:1 1 auto; position:relative; z-index:1; user-select:none; cursor:default;}
.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 var(--ease-spring), 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,160,240,.25), rgba(130,210,200,.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,160,240,.5), rgba(130,210,200,.5), rgba(170,150,230,.5), rgba(100,160,240,.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,160,240,.5)) drop-shadow(0 0 40px rgba(130,210,200,.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:active{
transform:scale(.92);
transition-duration:.1s;
}
.appIcon i{
font-size:36px;
line-height:1;
background:linear-gradient(135deg, rgba(120,170,240,.95), rgba(140,210,200,.88), rgba(170,150,230,.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(140,190,255,1), rgba(160,230,215,1), rgba(190,170,245,1));
-webkit-background-clip:text;
background-clip:text;
}
.brandText{min-width:0; position:relative; z-index:1;}
.appName{
font-family:var(--brand);
font-weight:700;
font-size:19px;
line-height:1.1;
letter-spacing:-.02em;
margin:0; padding:0;
transition:text-shadow .3s var(--ease-spring), color .2s ease;
}
.brand:hover .appName{
text-shadow:0 0 20px rgba(100,160,240,.4), 0 0 40px rgba(130,210,200,.2);
}
.tagline{
margin-top:5px;
font-size:11px;
line-height:1.2;
color:var(--textMuted);
letter-spacing:.02em;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
max-width:52vw;
transition:color .3s var(--ease-spring);
}
.brand:hover .tagline{
color:rgba(192,202,226,.90);
}
.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:rgba(140,160,210,.08);
margin:0 4px;
transition:background .3s ease;
}
/* Zoom control — borderless */
.zoomControl{
display:flex; align-items:center; gap:0;
background:var(--surface-2);
border:none;
border-radius:var(--r2);
padding:2px;
transition:background .2s ease;
}
.zoomControl:hover{
background:var(--surface-3);
}
.zoomBtn{
width:28px; height:28px;
border:none; background:transparent; position:relative;
border-radius:var(--r3);
color:var(--text);
cursor:pointer;
display:flex; align-items:center; justify-content:center;
transition:all .2s var(--ease-bounce);
}
.zoomBtn:hover{
background:var(--surface-3);
transform:translateY(-1px);
}
.zoomBtn:active{
background:var(--surface-4);
transform:scale(.9) translateY(0);
transition-duration:.08s;
}
.zoomBtn::before{content:""; position:absolute; inset:-8px; border-radius:var(--r2);}
.zoomBtn .fa{font-size:10px; opacity:.8; transition:opacity .15s ease, transform .15s ease;}
.zoomBtn:hover .fa{opacity:1; transform:scale(1.1);}
.zoomValue{
min-width:48px;
text-align:center;
font-family:var(--mono);
font-size:11px;
font-weight:400;
color:var(--text);
opacity:.9;
cursor:pointer;
padding:4px 6px;
border:none;
background:transparent;
border-radius:var(--r3);
transition:background .15s ease, opacity .15s ease, transform .15s ease;
}
.zoomValue:hover{
background:var(--surface-3);
opacity:1;
}
.zoomValue:active{
transform:scale(.95);
transition-duration:.08s;
}
/* Toolbar buttons — borderless */
.toolbarBtn{
width:34px; height:34px;
border:none;
border-radius:var(--r2);
background:var(--surface-2);
color:var(--text);
cursor:pointer;
display:flex; align-items:center; justify-content:center;
transition:all .2s var(--ease-bounce);
position:relative;
}
.toolbarBtn:hover{
background:var(--surface-3);
transform:translateY(-1px);
}
.toolbarBtn:active{
transform:scale(.92) translateY(0);
transition-duration:.08s;
}
.toolbarBtn .fa{font-size:13px; opacity:.85; transition:opacity .15s ease, transform .2s var(--ease-bounce);}
.toolbarBtn:hover .fa{opacity:1; transform:scale(1.1);}
/* Window control buttons */
.winBtn{
width:30px; height:30px;
border-radius:var(--r2);
flex:0 0 auto;
position:relative;
}
.winBtn::before{content:""; position:absolute; inset:-7px; border-radius:var(--r2);}
.winBtn .fa{font-size:11px!important; transition:transform .15s var(--ease-bounce)!important;}
.winBtn:hover .fa{transform:scale(1.15)!important;}
.winClose:hover{
background:rgba(255,70,70,.14)!important;
}
.winClose:hover .fa{color:rgba(255,120,120,.95)!important;}
.winClose:active{
background:rgba(255,70,70,.22)!important;
}
.windowControls{gap:4px; margin-left:2px;}
/* Primary split button — borderless */
.splitBtn.primary{
background:rgba(136,164,196,.10);
}
.splitBtn.primary:hover{
background:rgba(136,164,196,.15);
}
.splitBtn.primary .drop{
border-left-color:rgba(140,160,210,.08);
}
.btn{
display:inline-flex; align-items:center; justify-content:center; gap:8px;
padding:9px 14px;
border-radius:var(--r2);
border:none;
background:var(--surface-2);
color:var(--text);
cursor:pointer; user-select:none;
transition:all .2s var(--ease-bounce);
font-size:13px; font-weight:600; letter-spacing:0;
}
.btn:hover{
background:var(--surface-3);
transform:translateY(-1px);
}
.btn:active{
transform:scale(.96) translateY(0);
transition-duration:.08s;
}
.btn.primary{
background:rgba(136,164,196,.12);
color:#fff;
text-shadow:0 1px 2px rgba(0,0,0,.3);
}
.btn.primary:hover{
background:rgba(136,164,196,.18);
transform:translateY(-1px);
box-shadow:0 4px 12px rgba(136,164,196,.12);
}
.btn .fa{font-size:14px; opacity:.95; color:var(--iconStrong)!important; transition:transform .2s var(--ease-bounce);}
.btn:hover .fa{transform:scale(1.08);}
.btn.primary .fa{color:#fff!important;}
.splitBtn{
display:inline-flex;
border-radius:var(--r2);
overflow:hidden;
border:none;
background:var(--surface-2);
position:relative; z-index:8;
transition:all .2s var(--ease-bounce);
}
.splitBtn:hover{
background:var(--surface-3);
transform:translateY(-1px);
}
.splitBtn:active{
transform:scale(.98) translateY(0);
transition-duration:.08s;
}
.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:var(--surface-3); transform:none; box-shadow:none;}
.splitBtn .drop{
width:40px; padding:8px 0;
border-left:1px solid rgba(140,160,210,.06);
display:flex; align-items:center; justify-content:center;
transition:background .15s ease;
background:transparent;
}
.splitBtn .drop:hover{background:var(--surface-3);}
.splitBtn .drop .fa{font-size:16px; opacity:.88; color:var(--iconStrong)!important; transition:transform .25s var(--ease-bounce);}
.splitBtn .drop:hover .fa{transform:translateY(3px) scale(1.1);}
.dropdownPortal{
position:fixed; z-index:99999;
min-width:320px; max-width:560px; max-height:360px;
overflow:auto;
border-radius:var(--r);
border:1px solid rgba(140,160,210,.10);
background:rgba(18,21,30,.95);
box-shadow:var(--shadow);
padding:6px;
display:none;
transform:scale(var(--zoom));
transform-origin:top left;
scrollbar-width:thin;
scrollbar-color:rgba(140,160,210,.12) rgba(140,160,210,.02);
}
.dropdownPortal::-webkit-scrollbar{width:4px; height:4px;}
.dropdownPortal::-webkit-scrollbar-track{background:rgba(140,160,210,.02);}
.dropdownPortal::-webkit-scrollbar-thumb{background:rgba(140,160,210,.10); border-radius:999px;}
.dropdownPortal::-webkit-scrollbar-button{width:0; height:0; display:none;}
.dropItem{display:flex; align-items:center; gap:10px; padding:10px 12px; border-radius:var(--r3); cursor:pointer; user-select:none; color:var(--text); font-weight:500; font-size:13px; letter-spacing:0; line-height:1.25; transition:all .2s var(--ease-bounce); position:relative;}
.dropItem:hover{background:var(--surfaceHover); padding-left:16px; padding-right:36px;}
.dropItem:active{transform:scale(.98); transition-duration:.08s;}
.dropIcon{width:18px; height:18px; display:flex; align-items:center; justify-content:center; flex:0 0 auto; opacity:.9; transition:transform .25s var(--ease-bounce), opacity .15s ease;}
.dropItem:hover .dropIcon{transform:scale(1.15) rotate(-8deg); opacity: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, color .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%); color:rgba(235,240,252,.96);}
.dropRemove{position:absolute; right:8px; top:50%; transform:translateY(-50%) scale(.85); width:24px; height:24px; border-radius:var(--r3); background:rgba(255,100,100,.12); border:1px solid rgba(255,100,100,.20); color:rgba(255,180,180,.9); display:none; align-items:center; justify-content:center; font-size:12px; cursor:pointer; transition:all .2s var(--ease-bounce);}
.dropItem:hover .dropRemove{display:flex; transform:translateY(-50%) scale(1);}
.dropRemove:hover{background:rgba(255,100,100,.22); border-color:rgba(255,100,100,.35); color:rgba(255,220,220,1); transform:translateY(-50%) scale(1.1);}
.dropRemove:active{transform:translateY(-50%) scale(.9); transition-duration:.08s;}
.dropRemove::before{content:""; position:absolute; inset:-10px; border-radius:var(--r3);}
.dropEmpty{padding:10px 10px; color:var(--textMuted); font-size:13px;}
.seg{display:inline-flex; border:none; border-radius:var(--r2); overflow:hidden; background:var(--surface-2); transition:background .15s ease;}
.seg:hover{background:var(--surface-3);}
.seg .btn{border:none; box-shadow:none; border-radius:0; padding:8px 9px; background:transparent; font-weight:700; transform:none;}
.seg .btn:hover{background:var(--surface-3); transform:none; box-shadow:none;}
.seg .btn:active{background:var(--surface-4); transform:scale(.95);}
.seg .mid{border-left:1px solid rgba(140,160,210,.06); border-right:1px solid rgba(140,160,210,.06); 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:var(--r2);
border:none;
background:var(--surface-2);
cursor:pointer; user-select:none;
font-size:11px; font-weight:600; letter-spacing:0;
color:var(--text);
line-height:1;
transition:all .2s var(--ease-bounce);
}
.switch:hover{
background:var(--surface-3);
transform:translateY(-1px);
}
.switch:active{
transform:scale(.97) translateY(0);
transition-duration:.08s;
}
.switch:focus-within{outline:2px solid rgba(136,164,196,.65); outline-offset:2px;}
.switch input{display:none;}
.track{
width:32px; height:18px;
border-radius:999px;
background:rgba(140,165,220,.06);
border:1px solid rgba(140,165,220,.10);
position:relative;
transition:all .3s var(--ease-bounce);
flex:0 0 auto;
display:flex; align-items:center;
}
.knob{
margin-left:2px;
width:14px; height:14px;
border-radius:999px;
background:rgba(200,210,230,.25);
box-shadow:0 1px 3px rgba(0,0,0,.20);
transition:transform .3s var(--ease-out-back), background .2s ease, box-shadow .2s ease, width .15s ease;
}
.switch:hover .knob{
width:16px;
}
.switch input:checked + .track{
background:rgba(136,164,196,.20);
border-color:rgba(136,164,196,.28);
}
.switch input:checked + .track .knob{
transform:translateX(14px);
background:rgba(220,228,240,.85);
box-shadow:0 1px 4px rgba(136,164,196,.25);
width:14px;
}
.switch:hover input:checked + .track .knob{
width:16px;
transform:translateX(12px);
}
.content{flex:1 1 auto; min-height:0; padding:10px; display:grid; grid-template-columns:calc(62% - 5px) 10px calc(38% - 5px); gap:0; overflow:hidden;}
@media (max-width:1100px){
.content{grid-template-columns:1fr; gap:10px; padding:10px;}
.dividerWrap{display:none;}
.actions{flex-wrap:wrap;}
.seg{width:100%;}
.dock{grid-template-columns:1fr;}
.dockDividerWrap{display:none;}
.tagline{max-width:70vw;}
}

204
src/styles/panels.css Normal file
View File

@@ -0,0 +1,204 @@
.panel{border:1px solid var(--stroke); border-radius:var(--r); background:var(--surface); box-shadow:var(--shadow3); overflow:hidden; min-height:0; display:flex; flex-direction:column; transition:border-color .3s ease;}
.panel:hover{border-color:rgba(140,160,210,.10);}
.panel::after{display:none;}
.panelHeader{padding:10px 12px 8px; border-bottom:1px solid rgba(140,160,210,.04); display:flex; align-items:flex-start; justify-content:space-between; gap:12px; flex:0 0 auto; min-width:0; background:rgba(140,165,220,.015); transition:background .3s ease;}
.panelHeader:hover{background:rgba(140,165,220,.025);}
.panelHeader::after{display:none;}
.nowTitle{font-family:var(--brand); font-weight:600; font-size:15px; letter-spacing:-.02em; line-height:1.2; max-width:60ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; transition:color .2s ease;}
.nowTitle:hover{color:rgba(235,240,252,.98);}
.nowSub{margin-top:3px; color:var(--textMuted); font-size:11px; font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:80ch; transition:color .2s ease;}
.dividerWrap{display:flex; align-items:stretch; justify-content:center;}
.divider{width:10px; 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(140,165,220,.10) 35%, transparent 40%) 0 0/4px 12px,
radial-gradient(circle, rgba(140,165,220,.10) 35%, transparent 40%) 0 6px/4px 12px;
opacity:.20; pointer-events:none; transition:opacity .3s var(--ease-spring), height .3s var(--ease-spring);
}
.divider:hover::after{opacity:.55; height:72px;}
.divider:active::after{opacity:.70;}
.dock{flex:1 1 auto; min-height:0; border-top:1px solid rgba(140,160,210,.04); display:grid; grid-template-columns:62% 10px 38%; background:rgba(0,0,0,.06); overflow:hidden;}
.dockPane{min-height:0; display:flex; flex-direction:column; overflow:hidden;}
.dockInner{padding:10px; min-height:0; display:flex; flex-direction:column; gap:8px; height:100%;}
.dockHeader{padding:10px 12px 9px; border:none; border-radius:var(--r2); display:flex; align-items:center; justify-content:space-between; gap:8px; background:var(--surface-2); flex:0 0 auto; min-height:47px; transition:background .2s ease;}
.dockHeader:hover{background:var(--surface-3);}
#notesHeader{border-bottom-right-radius:7px;}
#infoHeader{border-bottom-left-radius:7px; margin-right:12px;}
.dockTitle{font-family:var(--brand); font-weight:600; letter-spacing:-.02em; font-size:15px; line-height:1.2; color:rgba(218,225,240,.92); display:flex; align-items:center; gap:10px; transition:color .2s ease;}
.dockHeader:hover .dockTitle{color:rgba(235,240,252,.98);}
.dockTitle .fa{color:var(--iconStrong)!important; opacity:.88; font-size:14px; transition:transform .3s var(--ease-bounce), opacity .2s ease;}
.dockHeader:hover .dockTitle .fa{transform:scale(1.12) rotate(-5deg); opacity:1;}
.notesArea{flex:1 1 auto; min-height:0; overflow:hidden; position:relative;}
.notesSaved{
position:absolute;
z-index:2;
bottom:12px; right:12px;
padding:6px 10px;
border-radius:var(--r2);
background:rgba(130,170,130,.12);
border:none;
color:rgba(160,195,160,.88);
font-size:11px;
font-weight:700;
letter-spacing:.02em;
display:flex; align-items:center; gap:6px;
opacity:0;
transform:translateY(4px) scale(.95);
transition:opacity .4s ease, transform .4s var(--ease-bounce);
pointer-events:none;
}
.notesSaved.show{
opacity:1;
transform:translateY(0) scale(1);
}
.notesSaved .fa{font-size:10px; color:rgba(150,190,150,.88)!important; transition:transform .3s var(--ease-bounce);}
.notesSaved.show .fa{animation:checkBounce .5s var(--ease-bounce);}
@keyframes checkBounce{
0%{transform:scale(0) rotate(-45deg);}
60%{transform:scale(1.3) rotate(5deg);}
100%{transform:scale(1) rotate(0);}
}
/* Notes timestamp highlighting backdrop */
.notesBackdrop{position:absolute; inset:0; padding:10px 12px; border:1px solid transparent; border-radius:var(--r3); background:var(--surface-0); box-shadow:var(--shadowInset); font-family:var(--sans); font-size:13px; line-height:1.45; letter-spacing:0; color:rgba(218,225,240,.90); white-space:pre-wrap; word-break:break-word; overflow-wrap:anywhere; overflow-y:scroll; scrollbar-width:thin; scrollbar-color:transparent transparent; pointer-events:none; transition:border-color .25s ease, box-shadow .25s ease, background .2s ease;}
.notesBackdrop::-webkit-scrollbar{width:2px; height:2px;}
.notesBackdrop::-webkit-scrollbar-track{background:transparent;}
.notesBackdrop::-webkit-scrollbar-thumb{background:transparent;}
.notesBackdrop::-webkit-scrollbar-button{width:0; height:0; display:none;}
.notesBackdrop.hover{background:rgba(140,165,220,.03);}
.notesBackdrop.focus{border-color:rgba(136,164,196,.25); box-shadow:var(--shadowInset), 0 0 0 2px rgba(136,164,196,.12);}
.tsLink{color:rgba(136,164,196,.85); pointer-events:auto; cursor:pointer; transition:color .15s ease;}
.notesBackdrop.hover .tsLink,.notesBackdrop.focus .tsLink{color:rgba(136,164,196,.95);}
.notes{width:100%; height:100%; resize:none; border-radius:var(--r3); border:1px solid transparent; background:transparent; color:transparent; caret-color:rgba(218,225,240,.90); padding:10px 12px; outline:none; font-family:var(--sans); font-size:13px; line-height:1.45; letter-spacing:0; box-shadow:none; overflow:auto; scrollbar-width:thin; scrollbar-color:rgba(140,160,210,.10) transparent; position:relative; z-index:1;}
.notes:hover{background:transparent;}
.notes:focus{border-color:transparent; box-shadow:none;}
.notes::selection{background:rgba(136,164,196,.30);}
.notes::-webkit-scrollbar{width:2px; height:2px;}
.notes::-webkit-scrollbar-track{background:transparent;}
.notes::-webkit-scrollbar-thumb{background:rgba(140,160,210,.10); border-radius:0;}
.notes::-webkit-scrollbar-button{width:0; height:0; display:none;}
.notes::placeholder{color:rgba(155,170,200,.65); transition:color .2s ease;}
.notes:focus::placeholder{color:rgba(148,162,192,.25);}
.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;}
dl.kv{margin:0;}
dl.kv dt,dl.kv dd{margin:0; padding:0;}
.kv{
display:grid;
grid-template-columns:78px 1fr;
gap:3px 12px;
align-items:baseline;
padding:10px 12px;
border-radius:var(--r3);
border:none;
background:rgba(10,13,20,.45);
margin-bottom:6px;
transition:background .2s ease;
}
.kv:hover{background:rgba(10,13,20,.55);}
.k{
font-family:var(--sans);
font-size:10px;
font-weight:700;
text-transform:uppercase;
letter-spacing:.08em;
color:var(--textDim);
padding-top:3px;
white-space:nowrap;
transition:color .2s ease;
}
.kv:hover .k{color:var(--textDim);}
.v{
font-family:var(--brand);
font-size:13px;
font-weight:500;
color:var(--text);
letter-spacing:-.01em;
word-break:break-word;
overflow-wrap:anywhere;
line-height:1.35;
padding-left:6px;
transition:color .2s ease;
}
.kv:hover .v{color:rgba(230,235,248,.95);}
.v.mono{
font-family:var(--mono);
font-size:11px;
font-weight:400;
color:var(--textMuted);
letter-spacing:.02em;
font-variant-numeric:tabular-nums;
background:var(--accentBg);
padding:2px 6px;
border-radius:3px;
margin:-2px 0;
transition:background .2s ease, color .2s ease;
}
.kv:hover .v.mono{background:rgba(136,164,196,.09); color:var(--textMuted);}
.dockDividerWrap{display:flex; align-items:stretch; justify-content:center;}
.dockDivider{width:10px; 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(140,165,220,.10) 35%, transparent 40%) 0 0/4px 12px,
radial-gradient(circle, rgba(140,165,220,.10) 35%, transparent 40%) 0 6px/4px 12px;
opacity:.18; pointer-events:none; transition:opacity .3s var(--ease-spring), height .3s var(--ease-spring);
}
.dockDivider:hover::after{opacity:.50; height:60px;}
.dockDivider:active::after{opacity:.65;}
/* Timestamp button */
.timestampBtn{border:none; background:transparent; padding:0; margin:0 0 0 auto; cursor:pointer; display:flex; align-items:center; justify-content:center; width:28px; height:28px; border-radius:var(--r3); transition:all .2s var(--ease-bounce);}
.timestampBtn:hover{background:var(--surface-3); transform:scale(1.1);}
.timestampBtn:active{transform:scale(.9); transition-duration:.08s;}
.timestampBtn .fa{font-size:12px; color:var(--iconStrong)!important; opacity:.75; transition:opacity .15s ease;}
.timestampBtn:hover .fa{opacity:1;}
.timestampBtn:focus-visible{outline:2px solid rgba(136,164,196,.45); outline-offset:1px;}
/* Dock chevron */
.dockChevron{font-size:10px; color:var(--textDim); transition:transform .25s var(--ease-bounce), color .2s ease; margin-left:4px; flex:0 0 auto;}
.dockHeader:hover .dockChevron{color:var(--textMuted);}
/* Collapsible dock pane */
.dockPane.collapsed{flex:0 0 auto !important;}
.dockPane.collapsed .notesArea,
.dockPane.collapsed .infoGrid{display:none;}
/* Reset confirm state */
.toolbarBtn.confirming{background:rgba(255,70,70,.14);}
.toolbarBtn.confirming .fa{color:rgba(255,160,100,.9)!important;}

303
src/styles/player.css Normal file
View File

@@ -0,0 +1,303 @@
.videoWrap{position:relative; background:#000; flex:0 0 auto; overflow:hidden;}
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 var(--ease-spring), transform .3s var(--ease-bounce);
border-radius:50%;
background:rgba(15,17,23,.55);
border:none;
}
.overlayIcon.show{
opacity:1;
}
.overlayIcon.pulse{
animation:overlayPulse 0.4s var(--ease-bounce);
}
@keyframes overlayPulse{
0%{transform:scale(1);}
50%{transform:scale(1.18);}
100%{transform:scale(1);}
}
.overlayIcon::before{display:none;}
.overlayIcon.show:hover{
background:rgba(15,17,23,.65);
transform:scale(1.08);
}
.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 var(--ease-bounce), color 0.3s ease;
margin-left:4px;
}
.overlayIcon.pause i{
margin-left:0;
}
.overlayIcon.show:hover i{
transform:scale(1.15);
color:rgba(255,255,255,1)!important;
}
.controls{display:flex; flex-direction:column; gap:10px; padding:12px; border-top:1px solid rgba(140,160,210,.04); flex:0 0 auto; position:relative; z-index:10;}
.controlsStrip{
display:flex; align-items:center; justify-content:space-between; gap:8px; flex-wrap:wrap;
padding:5px 6px;
border:none;
background:transparent;
}
.controlsStrip::after{display:none;}
.stripDivider{width:1px; height:22px; background:rgba(140,160,210,.06); flex:0 0 auto; transition:background .2s ease, height .2s ease;}
.controlsStrip:hover .stripDivider{height:24px;}
.group{display:flex; align-items:center; gap:8px; flex-wrap:wrap;}
.controlsStrip .iconBtn{box-shadow:none;}
.controlsStrip .miniCtl{box-shadow:none;}
.controlsStrip .timeChip{box-shadow:none;}
.iconBtn{
width:40px; height:36px;
border-radius:var(--r2);
border:none;
background:var(--surface-2);
box-shadow:var(--shadow3);
display:inline-flex; align-items:center; justify-content:center;
cursor:pointer; user-select:none;
transition:all .2s var(--ease-bounce);
}
.iconBtn:hover{
background:var(--surface-3);
transform:translateY(-2px);
}
.iconBtn:active{
transform:scale(.9) translateY(0);
transition-duration:.08s;
}
.iconBtn.primary{
background:rgba(136,164,196,.10);
}
.iconBtn.primary:hover{
background:rgba(136,164,196,.16);
box-shadow:0 0 12px rgba(136,164,196,.10);
}
.iconBtn .fa{font-size:15px; color:var(--iconStrong)!important; opacity:.9; transition:transform .2s var(--ease-bounce), opacity .15s ease;}
.iconBtn:hover .fa{transform:scale(1.15); opacity:1;}
.timeChip{display:inline-flex; align-items:center; gap:8px; padding:6px 9px; border-radius:var(--r2); border:none; background:var(--surface-2); box-shadow:var(--shadow3); font-family:var(--mono); font-size:12px; color:var(--text); letter-spacing:.02em; font-variant-numeric:tabular-nums; transition:background .2s ease;}
.timeChip:hover{background:var(--surface-3);}
.timeDot{width:8px; height:8px; border-radius:999px; background:rgba(136,164,196,.60); opacity:.95; transition:transform .3s ease, background .3s ease; animation:pulse 2s ease-in-out infinite;}
@keyframes pulse{0%,100%{transform:scale(1);opacity:.95;} 50%{transform:scale(1.2);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(140,165,220,.04);
border:1px solid rgba(140,165,220,.06);
box-shadow:var(--shadowInset);
overflow:hidden;
pointer-events:none;
transition:height .2s var(--ease-bounce), border-color .2s ease;
}
.seekWrap:hover .seekTrack{height:12px; border-color:rgba(140,165,220,.09);}
.seekFill{
height:100%;
width:0%;
background:rgba(136,164,196,.50);
border-radius:999px 0 0 999px;
transition:width .1s linear, background .2s ease;
}
.seekWrap:hover .seekFill{background:rgba(136,164,196,.62);}
.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:16px; height:16px; border-radius:999px; border:2px solid rgba(200,210,230,.28); background:rgba(220,228,240,.88); box-shadow:0 2px 6px rgba(0,0,0,.30); cursor:pointer; transition:transform .2s var(--ease-bounce), box-shadow .2s ease, border-color .2s ease; margin-top:0;}
.seek:hover::-webkit-slider-thumb{transform:scale(1.2); box-shadow:0 2px 8px rgba(0,0,0,.35), 0 0 10px rgba(136,164,196,.15); border-color:rgba(200,210,230,.40);}
.seek:active::-webkit-slider-thumb{transform:scale(1.05); transition-duration:.08s;}
.seek::-moz-range-track{background:transparent; height:18px;}
.seek::-moz-range-thumb{width:16px; height:16px; border-radius:999px; border:2px solid rgba(200,210,230,.28); background:rgba(220,228,240,.88); box-shadow:0 2px 6px rgba(0,0,0,.30); cursor:pointer;}
.miniCtl{display:flex; align-items:center; gap:8px; padding:6px 9px; border-radius:var(--r2); border:none; background:var(--surface-2); box-shadow:var(--shadow3); position:relative; z-index:3; transition:all .2s var(--ease-bounce);}
.miniCtl:hover{background:var(--surface-3); transform:translateY(-1px);}
.miniCtl:active{transform:scale(.97) translateY(0); transition-duration:.08s;}
.miniCtl .fa{font-size:14px; color:var(--iconStrong)!important; opacity:.95; flex:0 0 auto; transition:transform .2s var(--ease-bounce), opacity .15s ease;}
.miniCtl:hover .fa{transform:scale(1.1); opacity:1;}
.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(140,165,220,.04);
border:1px solid rgba(140,165,220,.06);
overflow:hidden;
pointer-events:none;
transition:height .2s var(--ease-bounce), border-color .2s ease;
}
.volWrap:hover .volTrack{height:8px; border-color:rgba(140,165,220,.09);}
.volFill{
height:100%;
width:100%;
background:rgba(136,164,196,.40);
border-radius:999px 0 0 999px;
transition:width .05s linear, background .2s ease;
}
.volWrap:hover .volFill{background:rgba(136,164,196,.55);}
.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(200,210,230,.28); background:rgba(220,228,240,.88); box-shadow:0 1px 4px rgba(0,0,0,.25); cursor:pointer; transition:transform .2s var(--ease-bounce), box-shadow .2s ease;}
.vol:hover::-webkit-slider-thumb{transform:scale(1.25); box-shadow:0 1px 6px rgba(0,0,0,.30), 0 0 8px rgba(136,164,196,.12);}
.vol::-moz-range-track{background:transparent; height:14px;}
.vol::-moz-range-thumb{width:14px; height:14px; border-radius:999px; border:2px solid rgba(200,210,230,.28); background:rgba(220,228,240,.88); box-shadow:0 1px 4px rgba(0,0,0,.25); cursor:pointer;}
.volTooltip{
position:absolute;
bottom:calc(100% + 12px);
left:0;
padding:8px 12px;
border-radius:var(--r2);
background:rgba(18,21,30,.95);
border:1px solid rgba(140,160,210,.10);
color:#fff;
font-family:var(--mono);
font-size:13px;
font-weight:400;
letter-spacing:.02em;
white-space:nowrap;
pointer-events:none;
opacity:0;
transform:translateX(-50%) translateY(4px) scale(.95);
transition:opacity .2s ease, transform .25s var(--ease-bounce), left .05s linear;
box-shadow:var(--shadow2);
z-index:100;
}
.volTooltip::before{display:none;}
.volTooltip::after{
content:"";
position:absolute;
top:100%;
left:50%;
transform:translateX(-50%);
border:6px solid transparent;
border-top-color:rgba(18,21,30,.95);
}
.volTooltip.show{
opacity:1;
transform:translateX(-50%) translateY(0) scale(1);
}
.speedBox{display:flex; align-items:center; gap:10px; position:relative;}
.speedBtn{border:none; outline:none; background:transparent; color:var(--text); font-family:var(--mono); font-size:12px; letter-spacing:0; padding:0 2px; cursor:pointer; line-height:1; display:inline-flex; align-items:center; gap:8px; transition:color .15s ease, transform .15s ease;}
.speedBtn span:first-child{min-width:3.5ch; text-align:right; transition:transform .15s var(--ease-bounce);}
.speedBtn:hover{color:rgba(235,240,250,1);}
.speedBtn:hover span:first-child{transform:scale(1.05);}
.speedBtn:active{transform:scale(.95); transition-duration:.08s;}
.speedCaret .fa{font-size:12px; opacity:.85; color:var(--icon)!important; transition:transform .25s var(--ease-bounce);}
.speedBtn:hover .speedCaret .fa{transform:translateY(3px) rotate(180deg);}
.progressPill{flex:0 0 auto; display:flex; align-items:center; gap:8px; padding:6px 10px; border-radius:var(--r2); border:none; background:var(--surface-2); box-shadow:var(--shadow3); min-width:220px; transition:background .2s ease;}
.progressPill:hover{background:var(--surface-3);}
.progressLabel{font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--textMuted); margin-right:2px; transition:color .2s ease;}
.progressPill:hover .progressLabel{color:var(--textMuted);}
.progressBar{width:120px; height:8px; border-radius:999px; border:none; background:rgba(140,165,220,.04); overflow:hidden; transition:height .2s var(--ease-bounce);}
.progressPill:hover .progressBar{height:10px;}
.progressBar>div{height:100%; width:0%; background:rgba(136,164,196,.75); transition:width .4s var(--ease-spring);}
.progressPct{font-family:var(--mono); font-size:12px; color:var(--text); font-variant-numeric:tabular-nums; letter-spacing:.02em; min-width:58px; text-align:right; transition:color .2s ease;}
.progressPill:hover .progressPct{color:rgba(235,240,252,.98);}
.timeSep{color:rgba(175,185,210,.78);}
.seek:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px rgba(136,164,196,.45), 0 2px 6px rgba(0,0,0,.30);}
.vol:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px rgba(136,164,196,.45), 0 1px 4px rgba(0,0,0,.25);}
.seek:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px rgba(136,164,196,.45), 0 2px 6px rgba(0,0,0,.30);}
.vol:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px rgba(136,164,196,.45), 0 1px 4px rgba(0,0,0,.25);}
/* Mute button */
.volMuteBtn{border:none; background:transparent; padding:0; margin:0; cursor:pointer; display:flex; align-items:center; justify-content:center; width:14px; height:14px; flex:0 0 auto;}
.volMuteBtn .fa{font-size:14px; color:var(--iconStrong)!important; opacity:.95; transition:transform .2s var(--ease-bounce), opacity .15s ease;}
.volMuteBtn:hover .fa{transform:scale(1.15); opacity:1;}
.volMuteBtn:focus-visible{outline:2px solid rgba(136,164,196,.45); outline-offset:2px; border-radius:3px;}
.miniCtl.muted{opacity:.5;}
.miniCtl.muted .volFill{opacity:.3;}
/* Seek feedback overlay */
.seekFeedback{
position:absolute;
top:50%; left:50%;
transform:translate(-50%,-50%);
font-family:var(--mono);
font-size:28px;
font-weight:700;
color:#fff;
text-shadow:0 2px 8px rgba(0,0,0,.7);
opacity:0;
transition:opacity .15s ease;
pointer-events:none;
z-index:6;
}
.seekFeedback.show{opacity:1;}
/* Error overlay */
.errorOverlay{
position:absolute;
inset:0;
display:flex;
flex-direction:column;
align-items:center;
justify-content:center;
gap:16px;
background:rgba(15,17,23,.88);
z-index:10;
}
.errorOverlay>.fa{font-size:42px; color:rgba(255,180,100,.85);}
.errorMsg{font-size:14px; color:rgba(218,225,240,.85); text-align:center; line-height:1.5; max-width:320px;}
.errorNextBtn{
border:none;
background:var(--surface-3);
color:var(--text);
padding:10px 20px;
border-radius:var(--r2);
font-size:13px;
font-weight:600;
cursor:pointer;
min-width:44px;
min-height:44px;
transition:background .2s ease;
}
.errorNextBtn:hover{background:var(--surface-4);}
.errorNextBtn:focus-visible{outline:2px solid rgba(136,164,196,.45); outline-offset:2px;}

163
src/styles/playlist.css Normal file
View File

@@ -0,0 +1,163 @@
.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, width .2s var(--ease-bounce);
z-index:10;
}
.listWrap:hover .listScrollbar, .listScrollbar.active{opacity:1;}
.listWrap:hover .listScrollbar{width:5px;}
.listScrollbarThumb{
position:absolute;
top:0; left:0; right:0;
min-height:24px;
background:rgba(136,164,196,.15);
border:none;
border-radius:2px;
transition:background .2s ease;
cursor:grab;
}
.listScrollbarThumb:hover{
background:rgba(136,164,196,.30);
}
.listScrollbarThumb:active{
cursor:grabbing;
background:rgba(136,164,196,.35);
}
.listScrollbar.active .listScrollbarThumb{
background:rgba(136,164,196,.30);
}
/* Row — no transform/padding changes (preserves drag-and-drop) */
.row{position:relative; display:flex; align-items:flex-start; justify-content:space-between; gap:10px; padding:9px 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(136,164,196,.40);}
.row:active{background:var(--surfaceActive);}
.row.active{background:var(--accentBg); box-shadow:inset 3px 0 0 rgba(136,164,196,.65);}
.row:focus-visible{outline-offset:-2px; background:var(--surfaceHover); box-shadow:inset 3px 0 0 rgba(136,164,196,.40);}
.row.dragging{opacity:.55;}
.left{min-width:0; display:flex; align-items:flex-start; gap:8px; flex:1 1 auto;}
.numBadge{flex:0 0 auto; min-width:38px; height:22px; padding:0 8px; border-radius:var(--r2); border:none; background:var(--surface-2); box-shadow:var(--shadow3); display:flex; align-items:center; justify-content:center; font-family:var(--mono); font-size:12px; letter-spacing:.02em; color:var(--text); font-variant-numeric:tabular-nums; margin-top:1px; transition:all .2s var(--ease-bounce);}
.row:hover .numBadge{background:var(--surface-3); transform:scale(1.06);}
/* Tree connectors — fully static, opaque, dark lines */
.treeSvg{flex:0 0 auto; margin-top:1px; overflow:visible;}
.treeSvg line{stroke:rgb(40,46,60); stroke-width:1.5;}
.treeSvg circle{fill:rgba(200,212,238,.60); stroke:rgba(136,164,196,.18); stroke-width:1;}
.textWrap{min-width:0; flex:1 1 auto;}
.name{font-size:13px; font-weight:600; letter-spacing:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; transition:color .2s var(--ease-spring), text-shadow .2s ease, transform .2s var(--ease-bounce); transform:translateX(0);}
.row:hover .name{color:rgba(235,240,252,.96); text-shadow:0 0 20px rgba(136,164,196,.08); transform:translateX(4px);}
.small{margin-top:4px; font-size:11px; color:var(--textMuted); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; transition:color .2s ease;}
.row:hover .small{color:var(--textMuted);}
.tag{flex:0 0 auto; display:inline-flex; align-items:center; padding:5px 9px; border-radius:var(--r2); border:none; background:var(--surface-2); color:var(--textMuted); font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; margin-top:2px; transition:all .2s var(--ease-bounce);}
.row:hover .tag{transform:scale(1.05);}
.tag.now{color:rgba(170,195,230,.88); background:var(--accentBg); animation:tagGlow 3s ease-in-out infinite;}
@keyframes tagGlow{
0%,100%{box-shadow:0 0 0 0 rgba(136,164,196,0);}
50%{box-shadow:0 0 8px 0 rgba(136,164,196,.12);}
}
.tag.done{color:rgba(160,195,160,.78); background:var(--successBg);}
.row:hover .tag.done{color:rgba(175,210,175,.88);}
.tag.hidden{display:none;}
.row.drop-before::before,.row.drop-after::after{
content:""; position:absolute; left:10px; right:10px; border-top:2px solid var(--accent);
pointer-events:none;
animation:dropLineFlash .6s ease-in-out infinite;
}
@keyframes dropLineFlash{
0%,100%{opacity:.6;}
50%{opacity:1;}
}
.row.drop-before::before{top:-1px;}
.row.drop-after::after{bottom:-1px;}
.empty{padding:10px 12px; color:var(--textMuted); font-size:13px; line-height:1.4;}
.playlistHeader{font-family:var(--brand); font-weight:600; letter-spacing:-.02em; font-size:15px; line-height:1.2; cursor:help; display:flex; align-items:center; gap:10px; transition:color .2s ease;}
.playlistHeader:hover{color:rgba(235,240,252,.98);}
.playlistHeader .fa{color:var(--iconStrong)!important; opacity:.92; transition:transform .3s var(--ease-bounce), opacity .2s ease;}
.playlistHeader:hover .fa{transform:rotate(-8deg) scale(1.12); opacity:1;}
.moveWrap{display:flex; flex-direction:column; gap:2px; flex:0 0 auto; opacity:0; transition:opacity .2s ease;}
.row:hover .moveWrap, .row:focus-within .moveWrap{opacity:1;}
.moveBtn{width:22px; height:18px; border:none; background:var(--surface-2); border-radius:var(--r3); cursor:pointer; display:flex; align-items:center; justify-content:center; transition:all .15s var(--ease-bounce); padding:0;}
.moveBtn:hover{background:var(--surface-3); transform:scale(1.1);}
.moveBtn:active{transform:scale(.9); transition-duration:.08s;}
.moveBtn .fa{font-size:9px; color:var(--iconStrong)!important; opacity:.7;}
.moveBtn:hover .fa{opacity:1;}
/* Playlist stats */
.plistStats{font-family:var(--mono); font-size:11px; color:var(--textMuted); letter-spacing:.02em; white-space:nowrap; flex:0 0 auto;}
/* Playlist search */
.plistSearchWrap{display:flex; align-items:center; gap:6px; padding:4px 10px; border-radius:var(--r2); background:var(--surface-0); border:1px solid transparent; transition:border-color .2s ease, background .2s ease; flex:0 1 180px; min-width:0;}
.plistSearchWrap:focus-within{border-color:rgba(136,164,196,.25); background:rgba(140,165,220,.04);}
.plistSearchIcon{font-size:11px; color:var(--textDim); flex:0 0 auto; transition:color .2s ease;}
.plistSearchWrap:focus-within .plistSearchIcon{color:var(--iconStrong);}
.plistSearch{border:none; background:transparent; color:var(--text); font-size:12px; font-family:var(--sans); outline:none; width:100%; min-width:0; padding:2px 0;}
.plistSearch::placeholder{color:var(--textDim); font-size:11px;}
.plistSearchClear{border:none; background:transparent; color:var(--textMuted); cursor:pointer; padding:0; display:flex; align-items:center; justify-content:center; width:20px; height:20px; flex:0 0 auto; transition:color .2s ease;}
.plistSearchClear:hover{color:var(--text);}
.plistSearchClear:focus-visible{outline:2px solid rgba(136,164,196,.45); outline-offset:1px; border-radius:3px;}
.plistSearchClear .fa{font-size:10px;}
/* Scroll-to-current button */
.scrollToCurrent{
width:36px; height:36px;
border-radius:var(--r2);
border:none;
background:var(--surface-2);
display:inline-flex; align-items:center; justify-content:center;
cursor:pointer;
flex:0 0 auto;
transition:all .2s var(--ease-bounce);
}
.scrollToCurrent:hover{background:var(--surface-3); transform:translateY(-1px);}
.scrollToCurrent:active{transform:scale(.9); transition-duration:.08s;}
.scrollToCurrent .fa{font-size:13px; color:var(--iconStrong)!important; opacity:.9;}
.scrollToCurrent:hover .fa{opacity:1;}
.scrollToCurrent:focus-visible{outline:2px solid rgba(136,164,196,.45); outline-offset:2px;}
/* Mini progress bar per row */
.rowProgress{
position:absolute;
bottom:0; left:0;
height:2px;
background:var(--accent);
border-radius:0 1px 0 0;
transition:width .3s ease;
pointer-events:none;
}
.rowProgress.done{background:var(--success);}

254
src/subtitles.ts Normal file
View File

@@ -0,0 +1,254 @@
/**
* Subtitle menu and track management.
* Handles sidecar, embedded, and user-chosen subtitle files.
*/
import { api } from './api';
import { library, cb } from './store';
// ---- DOM refs ----
let player: HTMLVideoElement;
let subsBtn: HTMLElement;
let subsMenu: HTMLElement;
let subtitleTrackEl: HTMLTrackElement | null = null;
let subsMenuOpen = false;
export function initSubtitles(): void {
player = document.getElementById('player') as HTMLVideoElement;
subsBtn = document.getElementById('subsBtn')!;
subsMenu = document.getElementById('subsMenu')!;
subsBtn.setAttribute('aria-haspopup', 'true');
subsBtn.setAttribute('aria-expanded', 'false');
subsBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!library) return;
if (subsMenuOpen) closeSubsMenu();
else await openSubsMenu();
});
subsBtn.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && subsMenuOpen) {
closeSubsMenu();
subsBtn.focus();
}
});
window.addEventListener('click', () => { if (subsMenuOpen) closeSubsMenu(); });
subsMenu.addEventListener('click', (e) => e.stopPropagation());
}
export function ensureSubtitleTrack(): HTMLTrackElement {
if (!subtitleTrackEl) {
subtitleTrackEl = document.createElement('track');
subtitleTrackEl.kind = 'subtitles';
subtitleTrackEl.label = 'Subtitles';
subtitleTrackEl.srclang = 'en';
subtitleTrackEl.default = true;
player.appendChild(subtitleTrackEl);
}
return subtitleTrackEl;
}
export async function refreshSubtitles(): Promise<void> {
ensureSubtitleTrack();
try {
const res = await api.getCurrentSubtitle();
if (res && res.ok && res.has) {
subtitleTrackEl!.src = res.url!;
subtitleTrackEl!.label = res.label || 'Subtitles';
setTimeout(() => {
try {
if (player.textTracks && player.textTracks.length > 0) {
for (const tt of Array.from(player.textTracks)) {
if (tt.kind === 'subtitles') tt.mode = 'showing';
}
}
} catch (_) {}
}, 50);
cb.notify?.('Subtitles loaded.');
} else {
subtitleTrackEl!.src = '';
}
} catch (_) {}
}
export function applySubtitle(url: string, label: string): void {
ensureSubtitleTrack();
subtitleTrackEl!.src = url;
subtitleTrackEl!.label = label || 'Subtitles';
setTimeout(() => {
try {
if (player.textTracks && player.textTracks.length > 0) {
for (const tt of Array.from(player.textTracks)) {
if (tt.kind === 'subtitles') tt.mode = 'showing';
}
}
} catch (_) {}
}, 50);
}
export function clearSubtitles(): void {
try {
if (player.textTracks && player.textTracks.length > 0) {
for (const tt of Array.from(player.textTracks)) {
tt.mode = 'hidden';
}
}
if (subtitleTrackEl) subtitleTrackEl.src = '';
} catch (_) {}
}
export function closeSubsMenu(): void {
subsMenuOpen = false;
subsMenu?.classList.remove('show');
subsBtn?.setAttribute('aria-expanded', 'false');
}
function menuItemKeyHandler(e: KeyboardEvent): void {
const item = e.currentTarget as HTMLElement;
if (e.key === 'ArrowDown') {
e.preventDefault(); e.stopPropagation();
let next = item.nextElementSibling as HTMLElement | null;
while (next && !next.classList.contains('subsMenuItem')) next = next.nextElementSibling as HTMLElement | null;
if (next) next.focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault(); e.stopPropagation();
let prev = item.previousElementSibling as HTMLElement | null;
while (prev && !prev.classList.contains('subsMenuItem')) prev = prev.previousElementSibling as HTMLElement | null;
if (prev) prev.focus();
} else if (e.key === 'Escape' || e.key === 'Tab') {
e.preventDefault(); e.stopPropagation();
closeSubsMenu();
subsBtn.focus();
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.stopPropagation();
item.click();
}
}
export async function openSubsMenu(): Promise<void> {
if (!library) return;
subsMenu.innerHTML = '';
try {
const available = await api.getAvailableSubtitles();
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.setAttribute('role', 'menuitem');
item.tabIndex = -1;
item.addEventListener('keydown', menuItemKeyHandler);
item.innerHTML = `<i class="fa-solid fa-file-lines" aria-hidden="true"></i> ${sub.label} <span style="opacity:.5;font-size:10px;margin-left:auto;">${sub.format}</span>`;
item.onclick = async () => {
closeSubsMenu();
const res = await api.loadSidecarSubtitle(sub.path);
if (res && res.ok && res.url) {
applySubtitle(res.url, res.label!);
cb.notify?.('Subtitles loaded.');
} else {
cb.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.setAttribute('role', 'menuitem');
item.tabIndex = -1;
item.addEventListener('keydown', menuItemKeyHandler);
item.innerHTML = `<i class="fa-solid fa-film" aria-hidden="true"></i> ${track.label} <span style="opacity:.5;font-size:10px;margin-left:auto;">${track.codec}</span>`;
item.onclick = async () => {
closeSubsMenu();
const res = await api.extractEmbeddedSubtitle(track.index);
if (res && res.ok && res.url) {
applySubtitle(res.url, res.label!);
cb.notify?.('Embedded subtitle loaded.');
} else {
cb.notify?.(res?.error || 'Failed to extract subtitle');
}
};
subsMenu.appendChild(item);
}
}
// Divider
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 (_) {}
// "Load from file" option
const loadItem = document.createElement('div');
loadItem.className = 'subsMenuItem';
loadItem.setAttribute('role', 'menuitem');
loadItem.tabIndex = -1;
loadItem.addEventListener('keydown', menuItemKeyHandler);
loadItem.innerHTML = '<i class="fa-solid fa-file-import" aria-hidden="true"></i> Load from file...';
loadItem.onclick = async () => {
closeSubsMenu();
try {
const res = await api.chooseSubtitleFile();
if (res && res.ok && res.url) {
applySubtitle(res.url, res.label!);
cb.notify?.('Subtitles loaded.');
} else if (res && res.error) {
cb.notify?.(res.error);
}
} catch (_) {}
};
subsMenu.appendChild(loadItem);
// "Disable" option
const disableItem = document.createElement('div');
disableItem.className = 'subsMenuItem';
disableItem.setAttribute('role', 'menuitem');
disableItem.tabIndex = -1;
disableItem.addEventListener('keydown', menuItemKeyHandler);
disableItem.innerHTML = '<i class="fa-solid fa-xmark" aria-hidden="true"></i> Disable subtitles';
disableItem.onclick = () => {
closeSubsMenu();
try {
if (player.textTracks) {
for (const tt of Array.from(player.textTracks)) {
tt.mode = 'hidden';
}
}
cb.notify?.('Subtitles disabled.');
} catch (_) {}
};
subsMenu.appendChild(disableItem);
subsMenu.classList.add('show');
subsMenuOpen = true;
subsBtn.setAttribute('aria-expanded', 'true');
const first = subsMenu.querySelector('.subsMenuItem') as HTMLElement | null;
if (first) first.focus();
}

92
src/tooltips.ts Normal file
View File

@@ -0,0 +1,92 @@
/**
* Fancy tooltip system with zoom-aware positioning.
* Event delegation on [data-tooltip] elements with show/hide delays.
*/
export function initTooltips(): void {
const el = document.getElementById('fancyTooltip');
if (!el) return;
const fancyTooltip: HTMLElement = el;
const tooltipTitle = fancyTooltip.querySelector('.tooltip-title') as HTMLElement;
const tooltipDesc = fancyTooltip.querySelector('.tooltip-desc') as HTMLElement;
let showTimeout: ReturnType<typeof setTimeout> | null = null;
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
let _currentEl: Element | null = null;
function getZoom(): number {
const z = getComputedStyle(document.documentElement).getPropertyValue('--zoom');
return parseFloat(z) || 1;
}
function positionTooltip(el: Element): void {
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: Element): void {
const title = (el as HTMLElement).dataset.tooltip || '';
const desc = (el as HTMLElement).dataset.tooltipDesc || '';
if (!title) return;
if (hideTimeout) { clearTimeout(hideTimeout); hideTimeout = null; }
tooltipTitle.textContent = title;
tooltipDesc.textContent = desc;
positionTooltip(el);
_currentEl = el;
if (fancyTooltip.classList.contains('visible')) return;
if (showTimeout) clearTimeout(showTimeout);
showTimeout = setTimeout(() => {
fancyTooltip.classList.add('visible');
}, 250);
}
function hideFancyTooltip(): void {
if (showTimeout) { clearTimeout(showTimeout); showTimeout = null; }
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', () => {
if (showTimeout) clearTimeout(showTimeout);
if (hideTimeout) clearTimeout(hideTimeout);
fancyTooltip.classList.remove('visible');
_currentEl = null;
});
});
}

188
src/types.ts Normal file
View File

@@ -0,0 +1,188 @@
// ===== Video Item (from get_library_info items array) =====
export interface VideoItem {
index: number;
fid: string;
name: string;
title: string;
relpath: string;
depth: number;
pipes: boolean[];
is_last: boolean;
has_prev_in_parent: boolean;
pos: number;
watched: number;
duration: number | null;
finished: boolean;
note_len: number;
last_open: number;
has_sub: boolean;
}
// ===== Library Info (from get_library / open_folder_path / select_folder) =====
export interface NextUp {
index: number;
title: string;
}
export interface LibraryInfo {
ok: boolean;
error?: string;
cancelled?: boolean;
folder?: string;
library_id?: string;
count?: number;
current_index?: number;
current_fid?: string | null;
current_time?: number;
folder_volume?: number;
folder_autoplay?: boolean;
folder_rate?: number;
items?: VideoItem[];
has_subdirs?: boolean;
overall_progress?: number | null;
durations_known?: number;
finished_count?: number;
remaining_count?: number;
remaining_seconds_known?: number | null;
top_folders?: { name: string; total: number; finished: number }[];
next_up?: NextUp | null;
}
// ===== Preferences =====
export interface WindowState {
width: number;
height: number;
x: number | null;
y: number | null;
}
export interface Prefs {
version: number;
ui_zoom: number;
split_ratio: number;
dock_ratio: number;
always_on_top: boolean;
window: WindowState;
last_folder_path: string | null;
last_library_id: string | null;
updated_at: number;
}
// ===== API Responses =====
export interface OkResponse {
ok: boolean;
error?: string;
}
export interface PrefsResponse {
ok: boolean;
prefs: Prefs;
}
export interface RecentItem {
name: string;
path: string;
}
export interface RecentsResponse {
ok: boolean;
items: RecentItem[];
}
export interface NoteResponse {
ok: boolean;
note: string;
len?: number;
}
// ===== Video Metadata =====
export interface BasicFileMeta {
ext: string;
size: number;
mtime: number;
folder: string;
}
export interface SubtitleTrack {
index: number;
codec: string;
language: string;
title: string;
}
export interface ProbeMeta {
v_codec?: string;
width?: number;
height?: number;
fps?: number;
v_bitrate?: number;
pix_fmt?: string;
color_space?: string;
a_codec?: string;
channels?: number;
sample_rate?: string;
a_bitrate?: number;
subtitle_tracks?: SubtitleTrack[];
container_bitrate?: number;
duration?: number;
format_name?: string;
container_title?: string;
encoder?: string;
}
export interface VideoMetaResponse {
ok: boolean;
error?: string;
fid?: string;
basic?: BasicFileMeta;
probe?: ProbeMeta | null;
ffprobe_found?: boolean;
}
// ===== Subtitles =====
export interface SubtitleResponse {
ok: boolean;
has?: boolean;
url?: string;
label?: string;
cancelled?: boolean;
error?: string;
}
export interface SidecarSub {
path: string;
label: string;
format: string;
}
export interface EmbeddedSub {
index: number;
label: string;
codec: string;
language: string;
}
export interface AvailableSubsResponse {
ok: boolean;
sidecar: SidecarSub[];
embedded: EmbeddedSub[];
}
export interface EmbeddedSubsResponse {
ok: boolean;
tracks: SubtitleTrack[];
}
// ===== FFmpeg Download Progress =====
export interface FfmpegProgress {
percent: number;
downloaded_bytes: number;
total_bytes: number;
}

813
src/ui.ts Normal file
View File

@@ -0,0 +1,813 @@
/**
* UI controls — zoom, split ratios, topbar, recent menu, info panel,
* notes, toast notifications, and reset/reload buttons.
*/
import { api } from './api';
import type { VideoItem } from './types';
import {
library, currentIndex, prefs, setPrefs,
clamp, fmtTime, fmtBytes, fmtDate, fmtBitrate,
currentItem, cb,
} from './store';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
// ---- DOM refs ----
let contentGrid: HTMLElement;
let divider: HTMLElement;
let dockGrid: HTMLElement;
let dockDivider: HTMLElement;
let zoomOutBtn: HTMLElement;
let zoomInBtn: HTMLElement;
let zoomResetBtn: HTMLElement;
let onTopChk: HTMLInputElement;
let autoplayChk: HTMLInputElement;
let chooseBtn: HTMLElement;
let chooseDropBtn: HTMLElement;
let recentMenu: HTMLElement;
let refreshBtn: HTMLElement;
let resetProgBtn: HTMLElement;
let notesBox: HTMLTextAreaElement;
let notesBackdrop: HTMLElement;
let notesSaved: HTMLElement;
let nowTitle: HTMLElement;
let nowSub: HTMLElement;
let overallBar: HTMLElement;
let overallPct: HTMLElement;
let toast: HTMLElement;
let toastMsg: HTMLElement;
let infoGridEl: HTMLElement;
let winMinBtn: HTMLElement;
let winMaxBtn: HTMLElement;
let winCloseBtn: HTMLElement;
// Info panel elements
let infoFolder: HTMLElement;
let infoNext: HTMLElement;
let infoStruct: HTMLElement;
let infoTitle: HTMLElement;
let infoRel: HTMLElement;
let infoPos: HTMLElement;
let infoFileBits: HTMLElement;
let infoVidBits: HTMLElement;
let infoAudBits: HTMLElement;
let infoSubsBits: HTMLElement;
let infoFinished: HTMLElement;
let infoRemaining: HTMLElement;
let infoEta: HTMLElement;
let infoVolume: HTMLElement;
let infoSpeed: HTMLElement;
let infoKnown: HTMLElement;
let infoTop: HTMLElement;
// ---- State ----
let toastTimer: ReturnType<typeof setTimeout> | null = null;
let draggingDivider = false;
let draggingDockDivider = false;
let saveSplitTimer: ReturnType<typeof setTimeout> | null = null;
let saveDockTimer: ReturnType<typeof setTimeout> | null = null;
let saveZoomTimer: ReturnType<typeof setTimeout> | null = null;
let noteSaveTimer: ReturnType<typeof setTimeout> | null = null;
let notesSavedTimer: ReturnType<typeof setTimeout> | null = null;
let recentOpen = false;
let resetConfirmTimer: ReturnType<typeof setTimeout> | null = null;
let resetConfirming = false;
// ---- Player ref for position info ----
let player: HTMLVideoElement;
export function initUI(): void {
contentGrid = document.getElementById('contentGrid')!;
divider = document.getElementById('divider')!;
dockGrid = document.getElementById('dockGrid')!;
dockDivider = document.getElementById('dockDivider')!;
zoomOutBtn = document.getElementById('zoomOutBtn')!;
zoomInBtn = document.getElementById('zoomInBtn')!;
zoomResetBtn = document.getElementById('zoomResetBtn')!;
onTopChk = document.getElementById('onTopChk') as HTMLInputElement;
autoplayChk = document.getElementById('autoplayChk') as HTMLInputElement;
chooseBtn = document.getElementById('chooseBtn')!;
chooseDropBtn = document.getElementById('chooseDropBtn')!;
chooseDropBtn.setAttribute('aria-haspopup', 'true');
chooseDropBtn.setAttribute('aria-expanded', 'false');
recentMenu = document.getElementById('recentMenu')!;
refreshBtn = document.getElementById('refreshBtn')!;
resetProgBtn = document.getElementById('resetProgBtn')!;
notesBox = document.getElementById('notesBox') as HTMLTextAreaElement;
notesSaved = document.getElementById('notesSaved')!;
// Create notes highlight backdrop for timestamp coloring
notesBackdrop = document.createElement('div');
notesBackdrop.className = 'notesBackdrop';
notesBackdrop.setAttribute('aria-hidden', 'true');
notesBox.parentElement!.insertBefore(notesBackdrop, notesBox);
// Scroll sync between textarea and backdrop
notesBox.addEventListener('scroll', () => {
notesBackdrop.scrollTop = notesBox.scrollTop;
notesBackdrop.scrollLeft = notesBox.scrollLeft;
});
// Forward hover/focus state to backdrop for visual effects
notesBox.addEventListener('mouseenter', () => notesBackdrop.classList.add('hover'));
notesBox.addEventListener('mouseleave', () => notesBackdrop.classList.remove('hover'));
notesBox.addEventListener('focus', () => notesBackdrop.classList.add('focus'));
notesBox.addEventListener('blur', () => notesBackdrop.classList.remove('focus'));
// Show pointer cursor when hovering over timestamps
notesBox.addEventListener('mousemove', (e) => {
notesBox.style.pointerEvents = 'none';
const el = document.elementFromPoint(e.clientX, e.clientY);
notesBox.style.pointerEvents = '';
notesBox.style.cursor = (el && el.classList.contains('tsLink')) ? 'pointer' : '';
});
nowTitle = document.getElementById('nowTitle')!;
nowSub = document.getElementById('nowSub')!;
overallBar = document.getElementById('overallBar')!;
overallPct = document.getElementById('overallPct')!;
toast = document.getElementById('toast')!;
toastMsg = document.getElementById('toastMsg')!;
infoGridEl = document.getElementById('infoGrid')!;
player = document.getElementById('player') as HTMLVideoElement;
// --- Window controls ---
winMinBtn = document.getElementById('winMinBtn')!;
winMaxBtn = document.getElementById('winMaxBtn')!;
winCloseBtn = document.getElementById('winCloseBtn')!;
const appWindow = getCurrentWebviewWindow();
winMinBtn.onclick = () => appWindow.minimize();
winMaxBtn.onclick = () => appWindow.toggleMaximize();
winCloseBtn.onclick = () => appWindow.close();
// Update maximize icon based on window state
const updateMaxIcon = async () => {
try {
const maximized = await appWindow.isMaximized();
const icon = winMaxBtn.querySelector('i')!;
icon.className = maximized ? 'fa-solid fa-clone' : 'fa-solid fa-square';
} catch (_) {}
};
appWindow.onResized(() => { updateMaxIcon(); });
updateMaxIcon();
infoFolder = document.getElementById('infoFolder')!;
infoNext = document.getElementById('infoNext')!;
infoStruct = document.getElementById('infoStruct')!;
infoTitle = document.getElementById('infoTitle')!;
infoRel = document.getElementById('infoRel')!;
infoPos = document.getElementById('infoPos')!;
infoFileBits = document.getElementById('infoFileBits')!;
infoVidBits = document.getElementById('infoVidBits')!;
infoAudBits = document.getElementById('infoAudBits')!;
infoSubsBits = document.getElementById('infoSubsBits')!;
infoFinished = document.getElementById('infoFinished')!;
infoRemaining = document.getElementById('infoRemaining')!;
infoEta = document.getElementById('infoEta')!;
infoVolume = document.getElementById('infoVolume')!;
infoSpeed = document.getElementById('infoSpeed')!;
infoKnown = document.getElementById('infoKnown')!;
infoTop = document.getElementById('infoTop')!;
// --- Info panel scroll fades ---
if (infoGridEl) {
const 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);
}
// --- Divider drag ---
divider.tabIndex = 0;
divider.setAttribute('role', 'separator');
divider.setAttribute('aria-orientation', 'vertical');
divider.setAttribute('aria-label', 'Resize panels');
divider.addEventListener('mousedown', (e) => {
draggingDivider = true;
document.body.style.userSelect = 'none';
e.preventDefault();
});
divider.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault(); e.stopPropagation();
const delta = e.key === 'ArrowLeft' ? -0.02 : 0.02;
const current = prefs?.split_ratio || 0.62;
prefs!.split_ratio = applySplit(current + delta);
savePrefsPatch({ split_ratio: prefs!.split_ratio });
}
});
dockDivider.tabIndex = 0;
dockDivider.setAttribute('role', 'separator');
dockDivider.setAttribute('aria-orientation', 'vertical');
dockDivider.setAttribute('aria-label', 'Resize dock panes');
dockDivider.addEventListener('mousedown', (e) => {
draggingDockDivider = true;
document.body.style.userSelect = 'none';
e.preventDefault();
});
dockDivider.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault(); e.stopPropagation();
const delta = e.key === 'ArrowLeft' ? -0.02 : 0.02;
const current = prefs?.dock_ratio || 0.62;
prefs!.dock_ratio = applyDockSplit(current + delta);
savePrefsPatch({ dock_ratio: prefs!.dock_ratio });
}
});
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;
if (!prefs) setPrefs({});
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;
if (!prefs) setPrefs({});
prefs!.dock_ratio = applyDockSplit(x / rect.width);
if (saveDockTimer) clearTimeout(saveDockTimer);
saveDockTimer = setTimeout(() => {
if (prefs) savePrefsPatch({ dock_ratio: prefs.dock_ratio });
}, 400);
}
});
// --- Zoom controls ---
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();
};
// --- On Top toggle ---
onTopChk.addEventListener('change', async () => {
const enabled = !!onTopChk.checked;
try {
await api.setAlwaysOnTop(enabled);
prefs!.always_on_top = enabled;
await savePrefsPatch({ always_on_top: enabled });
notify(enabled ? 'On top enabled.' : 'On top disabled.');
} catch (_) {
onTopChk.checked = !!prefs?.always_on_top;
}
});
// --- Autoplay toggle ---
autoplayChk.addEventListener('change', async () => {
if (!library) return;
const enabled = !!autoplayChk.checked;
try {
const res = await api.setFolderAutoplay(enabled);
if (res && res.ok) {
(library as any).folder_autoplay = enabled;
updateInfoPanel();
notify(enabled ? 'Autoplay: ON' : 'Autoplay: OFF');
}
} catch (_) {}
});
// --- Reset progress (two-click confirmation) ---
resetProgBtn.addEventListener('click', async () => {
if (!library) return;
if (!resetConfirming) {
resetConfirming = true;
resetProgBtn.classList.add('confirming');
const icon = resetProgBtn.querySelector('i');
if (icon) icon.className = 'fa-solid fa-exclamation-triangle';
resetProgBtn.setAttribute('aria-label', 'Confirm reset progress');
resetConfirmTimer = setTimeout(() => {
cancelResetConfirm();
}, 3000);
return;
}
// Second click — actually reset
cancelResetConfirm();
try {
const res = await api.resetWatchProgress();
if (res && res.ok) {
notify('Progress reset for this folder.');
const info = await api.getLibrary();
if (info && info.ok) {
await cb.onLibraryLoaded?.(info, false);
}
}
} catch (_) {}
});
resetProgBtn.addEventListener('blur', () => { if (resetConfirming) cancelResetConfirm(); });
// --- Open folder / Recent menu ---
chooseBtn.onclick = async () => {
closeRecentMenu();
const info = await api.selectFolder();
if (!info || !info.ok) return;
await cb.onLibraryLoaded?.(info, true);
notify('Folder loaded.');
};
chooseDropBtn.onclick = async (e) => {
e.preventDefault();
e.stopPropagation();
if (recentOpen) closeRecentMenu();
else await openRecentMenu();
};
chooseDropBtn.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && recentOpen) {
closeRecentMenu();
chooseDropBtn.focus();
}
});
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());
// --- Reload ---
refreshBtn.onclick = async () => {
const info = await api.getLibrary();
if (!info || !info.ok) return;
await cb.onLibraryLoaded?.(info, false);
notify('Reloaded.');
};
// --- Notes ---
notesBox.addEventListener('input', () => {
updateNotesHighlight();
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 api.setNote(it.fid, notesBox.value || '');
if (notesSaved) {
notesSaved.classList.add('show');
notesSavedTimer = setTimeout(() => { notesSaved.classList.remove('show'); }, 2000);
}
} catch (_) {}
}, 350);
});
// --- Timestamp insertion ---
const insertTimestampBtn = document.getElementById('insertTimestamp');
if (insertTimestampBtn) {
insertTimestampBtn.onclick = () => {
const t = player?.currentTime || 0;
const m = Math.floor(t / 60);
const s = Math.floor(t % 60);
const stamp = `[${m}:${String(s).padStart(2, '0')}] `;
const pos = notesBox.selectionStart ?? notesBox.value.length;
const before = notesBox.value.substring(0, pos);
const after = notesBox.value.substring(pos);
notesBox.value = before + stamp + after;
notesBox.focus();
const newPos = pos + stamp.length;
notesBox.setSelectionRange(newPos, newPos);
// Trigger note save
notesBox.dispatchEvent(new Event('input'));
};
}
// --- Clickable timestamps in notes ---
notesBox.addEventListener('click', () => {
const pos = notesBox.selectionStart;
if (pos === null || pos === undefined) return;
// Only act on single click without selection
if (notesBox.selectionStart !== notesBox.selectionEnd) return;
const text = notesBox.value;
// Match [H:MM:SS] or [M:SS] patterns
const re = /\[(\d{1,3}):(\d{2})(?::(\d{2}))?\]/g;
let match;
while ((match = re.exec(text)) !== null) {
const start = match.index;
const end = start + match[0].length;
if (pos >= start && pos <= end) {
let totalSeconds: number;
if (match[3] !== undefined) {
// [H:MM:SS]
totalSeconds = parseInt(match[1], 10) * 3600 + parseInt(match[2], 10) * 60 + parseInt(match[3], 10);
} else {
// [M:SS]
totalSeconds = parseInt(match[1], 10) * 60 + parseInt(match[2], 10);
}
if (player && Number.isFinite(player.duration) && player.duration > 0) {
player.currentTime = clamp(totalSeconds, 0, player.duration);
cb.notify?.(`Jumped to ${match[0]}`);
}
break;
}
}
});
// --- Collapsible dock panes ---
const notesHeader = document.getElementById('notesHeader');
const infoHeader = document.getElementById('infoHeader');
if (notesHeader) {
notesHeader.style.cursor = 'pointer';
notesHeader.setAttribute('aria-expanded', 'true');
notesHeader.addEventListener('click', (e) => {
if ((e.target as HTMLElement).closest('.timestampBtn')) return;
toggleDockPane(notesHeader, 'notes_collapsed');
});
notesHeader.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleDockPane(notesHeader, 'notes_collapsed');
}
});
notesHeader.tabIndex = 0;
}
if (infoHeader) {
infoHeader.style.cursor = 'pointer';
infoHeader.setAttribute('aria-expanded', 'true');
infoHeader.addEventListener('click', () => {
toggleDockPane(infoHeader, 'info_collapsed');
});
infoHeader.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleDockPane(infoHeader, 'info_collapsed');
}
});
infoHeader.tabIndex = 0;
}
// Restore collapsed state from prefs
if (prefs?.notes_collapsed) collapsePane(notesHeader!, true);
if (prefs?.info_collapsed) collapsePane(infoHeader!, true);
}
// ---- Exported functions ----
export function applyZoom(z: number): number {
const zoom = clamp(Number(z || 1.0), 0.75, 2.0);
document.documentElement.style.setProperty('--zoom', String(zoom));
if (zoomResetBtn) zoomResetBtn.textContent = `${Math.round(zoom * 100)}%`;
document.body.getBoundingClientRect(); // force reflow
return zoom;
}
export function applySplit(ratio: number): number {
const r = clamp(Number(ratio || 0.62), 0.35, 0.80);
contentGrid.style.gridTemplateColumns = `calc(${(r * 100).toFixed(2)}% - 7px) 14px calc(${(100 - r * 100).toFixed(2)}% - 7px)`;
return r;
}
export function applyDockSplit(ratio: number): number {
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;
}
export function notify(msg: string): void {
if (!toastMsg || !toast) return;
toastMsg.textContent = msg;
toast.classList.add('show');
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toast.classList.remove('show'); }, 2600);
}
export function updateNowHeader(it: VideoItem | null): void {
nowTitle.textContent = it ? (it.title || it.name) : 'No video loaded';
nowSub.textContent = it ? it.relpath : '-';
}
export function updateOverall(): void {
if (!library) {
overallBar.style.width = '0%'; overallPct.textContent = '-';
const pb = overallBar.parentElement;
if (pb) pb.setAttribute('aria-valuenow', '0');
return;
}
if (library.overall_progress === null || library.overall_progress === undefined) {
overallBar.style.width = '0%'; overallPct.textContent = '-';
const pb = overallBar.parentElement;
if (pb) pb.setAttribute('aria-valuenow', '0');
return;
}
const p = clamp(library.overall_progress, 0, 100);
overallBar.style.width = `${p.toFixed(1)}%`;
overallPct.textContent = `${p.toFixed(1)}%`;
const progressBarEl = overallBar.parentElement;
if (progressBarEl) progressBarEl.setAttribute('aria-valuenow', String(Math.round(p)));
}
export function updateInfoPanel(): void {
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 = player && 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((f: any) => `${f.name}: ${f.finished}/${f.total}`).join(' \u2022 ') || '-';
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 = '-';
}
}
export async function refreshCurrentVideoMeta(): Promise<void> {
const it = currentItem();
if (!it) {
infoFileBits.textContent = '-'; infoVidBits.textContent = '-';
infoAudBits.textContent = '-'; infoSubsBits.textContent = '-';
return;
}
try {
const res = await api.getCurrentVideoMeta();
if (!res || !res.ok) {
infoFileBits.textContent = '-'; infoVidBits.textContent = '-';
infoAudBits.textContent = '-'; infoSubsBits.textContent = '-';
return;
}
const b = res.basic || ({} as any);
const p = res.probe || null;
const ffFound = !!res.ffprobe_found;
const bits: string[] = [];
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(' \u2022 ') || '-';
if (p) {
const v: string[] = [];
if (p.v_codec) v.push(String(p.v_codec).toUpperCase());
if (p.width && p.height) v.push(`${p.width}\u00d7${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 || 0); if (vb) v.push(vb);
infoVidBits.textContent = v.join(' \u2022 ') || '-';
const a: string[] = [];
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 || 0); if (ab) a.push(ab);
infoAudBits.textContent = a.join(' \u2022 ') || '-';
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 (_) {
infoFileBits.textContent = '-'; infoVidBits.textContent = '-';
infoAudBits.textContent = '-'; infoSubsBits.textContent = '-';
}
}
export async function loadNoteForCurrent(): Promise<void> {
const it = currentItem();
if (!it) { notesBox.value = ''; updateNotesHighlight(); return; }
try {
const res = await api.getNote(it.fid);
notesBox.value = (res && res.ok) ? (res.note || '') : '';
} catch (_) { notesBox.value = ''; }
updateNotesHighlight();
}
export function setOnTopChecked(v: boolean): void { onTopChk.checked = v; }
export function setAutoplayChecked(v: boolean): void { autoplayChk.checked = v; }
// ---- Recent menu ----
function ensureDropdownPortal(): void {
try {
if (recentMenu && recentMenu.parentElement !== document.body) {
document.body.appendChild(recentMenu);
}
recentMenu.classList.add('dropdownPortal');
} catch (_) {}
}
function positionRecentMenu(): void {
const r = chooseDropBtn.getBoundingClientRect();
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 + 8, 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`;
}
export function closeRecentMenu(): void {
recentOpen = false;
recentMenu.style.display = 'none';
chooseDropBtn?.setAttribute('aria-expanded', 'false');
}
export async function openRecentMenu(): Promise<void> {
ensureDropdownPortal();
try {
const res = await api.getRecents();
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.setAttribute('role', 'menuitem');
row.tabIndex = -1;
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('button');
removeBtn.className = 'dropRemove';
removeBtn.setAttribute('aria-label', `Remove ${it.name}`);
removeBtn.innerHTML = '<i class="fa-solid fa-xmark" aria-hidden="true"></i>';
removeBtn.onclick = async (e) => {
e.stopPropagation();
try {
await api.removeRecent(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 (_) {}
};
row.appendChild(icon); row.appendChild(name); row.appendChild(removeBtn);
row.onclick = async () => {
closeRecentMenu();
const info = await api.openFolderPath(it.path);
if (!info || !info.ok) { notify('Folder not available.'); return; }
await cb.onLibraryLoaded?.(info, true);
};
row.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault(); e.stopPropagation();
const next = row.nextElementSibling as HTMLElement | null;
if (next && next.classList.contains('dropItem')) next.focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault(); e.stopPropagation();
const prev = row.previousElementSibling as HTMLElement | null;
if (prev && prev.classList.contains('dropItem')) prev.focus();
} else if (e.key === 'Escape' || e.key === 'Tab') {
e.preventDefault(); e.stopPropagation();
closeRecentMenu();
chooseDropBtn.focus();
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.stopPropagation();
row.click();
}
});
recentMenu.appendChild(row);
}
}
positionRecentMenu();
recentMenu.style.display = 'block';
recentOpen = true;
chooseDropBtn.setAttribute('aria-expanded', 'true');
const first = recentMenu.querySelector('.dropItem') as HTMLElement | null;
if (first) first.focus();
} catch (_) { closeRecentMenu(); }
}
// ---- Private helpers ----
async function savePrefsPatch(patch: Record<string, unknown>): Promise<void> {
await api.setPrefs(patch);
}
function cancelResetConfirm(): void {
resetConfirming = false;
if (resetConfirmTimer) { clearTimeout(resetConfirmTimer); resetConfirmTimer = null; }
resetProgBtn.classList.remove('confirming');
const icon = resetProgBtn.querySelector('i');
if (icon) icon.className = 'fa-solid fa-clock-rotate-left';
resetProgBtn.setAttribute('aria-label', 'Reset progress');
}
function toggleDockPane(header: HTMLElement, prefKey: string): void {
const pane = header.closest('.dockPane');
if (!pane) return;
const isCollapsed = pane.classList.toggle('collapsed');
header.setAttribute('aria-expanded', String(!isCollapsed));
const chevron = header.querySelector('.dockChevron') as HTMLElement | null;
if (chevron) chevron.style.transform = isCollapsed ? 'rotate(-90deg)' : '';
savePrefsPatch({ [prefKey]: isCollapsed });
}
function updateNotesHighlight(): void {
if (!notesBackdrop) return;
const text = notesBox.value;
const escaped = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
const highlighted = escaped.replace(
/\[(\d{1,3}:\d{2}(?::\d{2})?)\]/g,
'<span class="tsLink">[$1]</span>'
);
// Trailing newline matches textarea's extra line space
notesBackdrop.innerHTML = highlighted + '\n';
}
function collapsePane(header: HTMLElement, collapsed: boolean): void {
const pane = header.closest('.dockPane');
if (!pane) return;
if (collapsed) {
pane.classList.add('collapsed');
header.setAttribute('aria-expanded', 'false');
const chevron = header.querySelector('.dockChevron') as HTMLElement | null;
if (chevron) chevron.style.transform = 'rotate(-90deg)';
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
@echo off
set SCRIPT_DIR=%~dp0
pushd "%SCRIPT_DIR%"
start "" /B pythonw "tutorial.py"
popd
exit