Compare commits
74 Commits
bac6665b3d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70460232f0 | ||
|
|
046aa67108 | ||
|
|
8c0776f97d | ||
|
|
94dba16ff6 | ||
|
|
6fff68884b | ||
|
|
3afb22b305 | ||
|
|
70b5214ce8 | ||
|
|
88c89d66af | ||
|
|
b72c079fb4 | ||
|
|
327a67004d | ||
|
|
b8c7ad4ad1 | ||
|
|
94140a5433 | ||
|
|
048817bcf4 | ||
|
|
76f8ec525d | ||
|
|
ba05f0dafe | ||
|
|
ed9f93b0b7 | ||
|
|
2af2eebacc | ||
|
|
722457312c | ||
|
|
a73781a048 | ||
|
|
4a18990058 | ||
|
|
e6f032a6f2 | ||
|
|
6039dc80c1 | ||
|
|
dd71f6b2ab | ||
|
|
f36f6aeee1 | ||
|
|
8018060d26 | ||
|
|
42ff13b135 | ||
|
|
18ad8152bf | ||
|
|
395af38246 | ||
|
|
50cf655b71 | ||
|
|
ffeef5d520 | ||
|
|
7cf3517dd3 | ||
|
|
2b195d3b6b | ||
|
|
d01f51a7d0 | ||
|
|
ebe60a7be2 | ||
|
|
79359dba44 | ||
|
|
30a05d3ebd | ||
|
|
fec069479f | ||
|
|
041fc6a974 | ||
|
|
6738644d4b | ||
|
|
d9371fdea4 | ||
|
|
fc61defba5 | ||
|
|
136e21f1fa | ||
|
|
02153d877d | ||
|
|
05e78677b0 | ||
|
|
f266c4ffb9 | ||
|
|
84d6487125 | ||
|
|
847e99cd51 | ||
|
|
4efa1d1182 | ||
|
|
8a30fcfc4f | ||
|
|
fea26fd435 | ||
|
|
68d4921149 | ||
|
|
20cd2f29ba | ||
|
|
3ad2e40606 | ||
|
|
f497eecebd | ||
|
|
38511fa450 | ||
|
|
44fa24ca52 | ||
|
|
95dc9e3f98 | ||
|
|
b67e40348b | ||
|
|
d15d9021b7 | ||
|
|
c6b8dfceb8 | ||
|
|
c1855a1a91 | ||
|
|
4376cd9e88 | ||
|
|
0ba0f56a54 | ||
|
|
a4d6b825d5 | ||
|
|
ddfa1494c6 | ||
|
|
81aced9bc5 | ||
|
|
c676786144 | ||
|
|
da2ad27244 | ||
|
|
460aaae234 | ||
|
|
4d5d105e70 | ||
|
|
7ab486f15f | ||
|
|
fed2aef969 | ||
|
|
63560a8e61 | ||
|
|
5375843c82 |
32
.agents/skills/deploy-prod/SKILL.md
Normal file
32
.agents/skills/deploy-prod/SKILL.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
name: deploy-prod
|
||||||
|
description: Deploy ytplayer to production (worship.hesed.sbs) via the Dokploy API and verify the deploy landed. Use when the user says deploy, ship to prod, release, push to production, or asks why production doesn't show a change (pushing git does NOT deploy). Manual-only — never deploy without an explicit request.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Deploy ytplayer to production
|
||||||
|
|
||||||
|
Production is the web PWA at **https://worship.hesed.sbs**, running on the homelab Dokploy remote node; the control plane is Dokploy on the VPS. **Pushing to git does NOT deploy** — deploys are triggered explicitly.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Confirm the change is pushed: `git -C ~/development/personal/ytplayer status -sb` must show no unpushed commits (`git push origin main` pushes both remotes).
|
||||||
|
2. Record the current build tag: `curl -s https://worship.hesed.sbs/api/version` → note `buildTag`.
|
||||||
|
3. Trigger the deploy (API key in `~/development/.secrets/dokploy-api.env`; compose ID `wprYCM8T51f7JtSHb983p`, project `ytplayer`, env `production`):
|
||||||
|
```bash
|
||||||
|
ssh -i ~/.ssh/tmp_vps/dokploy_session root@193.160.119.172 \
|
||||||
|
"curl -s -X POST -H 'x-api-key: $KEY' -H 'Content-Type: application/json' \
|
||||||
|
-d '{\"composeId\":\"wprYCM8T51f7JtSHb983p\"}' http://localhost:3000/api/compose.deploy"
|
||||||
|
```
|
||||||
|
4. Poll until done (build ≈ 5–6 min; poll every ~60s, give up after ~12 min):
|
||||||
|
```bash
|
||||||
|
# composeStatus: idle|running|done|error
|
||||||
|
... http://localhost:3000/api/compose.one?composeId=wprYCM8T51f7JtSHb983p
|
||||||
|
```
|
||||||
|
5. Verify: `curl -s https://worship.hesed.sbs/api/version` — **`buildTag` must differ** from step 2. Unchanged tag = the deploy did not land; report that, don't claim success.
|
||||||
|
6. Report old tag → new tag and the compose status to the user.
|
||||||
|
|
||||||
|
## If something goes wrong
|
||||||
|
|
||||||
|
- `composeStatus: error` → fetch the deployment logs via the Dokploy API and report the failing step verbatim.
|
||||||
|
- Homelab node unreachable on LAN is normal; everything above goes through the VPS hop (`ssh root@193.160.119.172`, then `ssh root@10.8.0.2` over WireGuard if you need the node itself). App container: `ytplayer-main-1dihzn-ytplayer-1`.
|
||||||
|
- Prod DB (for cleanup of `Probe */Recon *` test records): libsql file `/app/data/ytplayer.db` inside the container — delete children (`video_history`) first.
|
||||||
149
.agents/skills/mobile-app-ui-design/SKILL.md
Normal file
149
.agents/skills/mobile-app-ui-design/SKILL.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
---
|
||||||
|
name: mobile-app-ui-design
|
||||||
|
description: Design high-quality mobile app UI/UX screens, flows, and components. Use this skill whenever the user asks to design a mobile app screen, create app mockups, build mobile UI components, improve an existing mobile app design, create onboarding flows, design mobile navigation, or requests any mobile-first interface work. Also trigger when the user mentions app design, mobile UI, mobile UX, screen design, app mockups, wireframes, or wants to build React Native / Flutter / SwiftUI style interfaces as visual prototypes. Even if the user just says "design an app" or "make this screen look better", use this skill.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Mobile App UI/UX Design Skill
|
||||||
|
|
||||||
|
This skill guides the creation of professional, polished mobile app interfaces that follow proven design principles used by top-tier apps like Airbnb, Duolingo, Spotify, Revolut, and Phantom.
|
||||||
|
|
||||||
|
## Core Philosophy
|
||||||
|
|
||||||
|
Great mobile UI isn't about flashiness — it's about intentionality. Every pixel, every spacing value, every color choice should serve the user. The goal is to create interfaces that feel smooth, personal, and alive — not just functional.
|
||||||
|
|
||||||
|
Before designing anything, understand three things:
|
||||||
|
1. **What is the user trying to accomplish?** (reduce friction to that goal)
|
||||||
|
2. **How should this make the user feel?** (trust, delight, confidence, calm)
|
||||||
|
3. **What's the one thing they should notice first?** (visual hierarchy)
|
||||||
|
|
||||||
|
## Design Process
|
||||||
|
|
||||||
|
Follow this sequence for any mobile screen:
|
||||||
|
|
||||||
|
### Step 1: Understand the Context
|
||||||
|
- What type of app? (fitness, finance, social, productivity, health, crypto, etc.)
|
||||||
|
- Who is the user? (new, returning, power user — adapt the experience)
|
||||||
|
- What's the primary action on this screen?
|
||||||
|
- What industry design conventions apply? (See `references/industry-conventions.md`)
|
||||||
|
|
||||||
|
### Step 2: Structure First (UX Lens)
|
||||||
|
- Map the user flow: what screen comes before and after?
|
||||||
|
- Identify the MVP elements — only what's essential for this screen
|
||||||
|
- Place primary actions in the **thumb zone** (bottom 1/3 of screen)
|
||||||
|
- Follow the **F-pattern** reading order for content layout
|
||||||
|
- Reduce interaction cost: expose content directly instead of hiding behind taps
|
||||||
|
- Turn empty states into opportunities with guidance, illustration, and a CTA
|
||||||
|
- Choose the right input method: sliders/scroll wheels for one-time setup, text fields for repeated/precise entry
|
||||||
|
|
||||||
|
### Step 3: Apply Visual Design (UI Lens)
|
||||||
|
Follow these rules in order:
|
||||||
|
|
||||||
|
#### Typography
|
||||||
|
- Use **one font family** (two max, with clear hierarchy purpose)
|
||||||
|
- Maximum **4 font sizes** and **2 font weights**
|
||||||
|
- Use monospace variants for large numbers (prices, stats, metrics)
|
||||||
|
- Keep text containers under 600px wide for readability
|
||||||
|
- Create hierarchy with size, weight, and opacity — not just bold everything
|
||||||
|
|
||||||
|
#### Color System (60/30/10 Rule)
|
||||||
|
- **60%** — neutral base (white, light gray, or dark background)
|
||||||
|
- **30%** — complementary color (black text, dark elements)
|
||||||
|
- **10%** — brand/accent color (CTAs, key indicators, icons)
|
||||||
|
- Use **opacity variations** of the neutral color for text hierarchy: 100% for headings, 80% for body, 60-70% for secondary text
|
||||||
|
- Use the accent color at 5% opacity for secondary buttons and subtle card highlights
|
||||||
|
- Match shadow colors to the background (tint shadows, never pure gray/black on colored backgrounds)
|
||||||
|
- Save strong colors (like red) for meaningful moments — overuse kills hierarchy
|
||||||
|
|
||||||
|
#### Spacing (8-Point Grid System)
|
||||||
|
- All spacing values must be divisible by **8 or 4** (8, 12, 16, 24, 32, 48, 64, 80, 96)
|
||||||
|
- Use **relationship-based spacing**: related elements closer together, unrelated further apart
|
||||||
|
- Multiplier rule: if related text elements are 16px apart, the gap to the next group should be 2× (32px)
|
||||||
|
- Section vertical padding: at least 80-96px (160px for major sections on larger screens)
|
||||||
|
- Card internal padding: 24-32px baseline
|
||||||
|
- Larger text = larger spacing needed
|
||||||
|
|
||||||
|
#### Shadows
|
||||||
|
- Always use **soft shadows** — never harsh/distinct
|
||||||
|
- Match shadow color to the background with a tinted hue
|
||||||
|
- Use subtle white inner shadows on buttons to add dimension
|
||||||
|
- Add faded drop shadows for depth without heaviness
|
||||||
|
|
||||||
|
#### Visual Cues & Imagery
|
||||||
|
- Use icons, emojis, illustrations, and images to make information digestible
|
||||||
|
- User avatars/photos > initials > generic icons (for representing people)
|
||||||
|
- Color-coded categories with soft solid backgrounds + clean isolated images
|
||||||
|
- Keep visual style consistent across the entire app — no random stock photo mix
|
||||||
|
- Use AI-generated or curated visuals with matching color palettes
|
||||||
|
|
||||||
|
### Step 4: Design for Emotion (Peak-End Rule)
|
||||||
|
The user will remember two moments: the **peak** (most intense) and the **end** (last impression).
|
||||||
|
|
||||||
|
- **Identify your peak moment**: completing a core task, hitting a milestone, finding what they want
|
||||||
|
- **Design the peak**: micro-animations, celebratory feedback, sparkles, badges, encouraging copy
|
||||||
|
- **Design the ending**: summary card, progress affirmation, gentle nudge to return
|
||||||
|
- Add **emotional feedback loops**: success states should feel rewarding (bounce, glow, sparkle)
|
||||||
|
- Celebrate small wins — success states don't need to be huge, but they should feel intentional
|
||||||
|
- Use motion and animation as trust signals, especially in high-stakes domains (finance, crypto, health)
|
||||||
|
|
||||||
|
### Step 5: Polish & Details
|
||||||
|
- Add subtle glow effects behind key elements (blur + opacity)
|
||||||
|
- Use tiny white inner shadows on primary buttons
|
||||||
|
- Add 5% opacity primary-color borders on secondary elements
|
||||||
|
- Consider micro-animations for state changes
|
||||||
|
- Ensure all tap targets are at least 44×44pt
|
||||||
|
- Check contrast ratios for accessibility
|
||||||
|
- Design error states, empty states, loading states, and success states
|
||||||
|
|
||||||
|
## Smart Patterns to Apply
|
||||||
|
|
||||||
|
### Personalization by User Stage
|
||||||
|
- **New users**: simple welcome, guided setup, minimal options
|
||||||
|
- **Returning users**: personalized content, routine-focused, progress indicators
|
||||||
|
- **Power users**: advanced stats, optimization tools, dense information
|
||||||
|
|
||||||
|
### Smarter Search
|
||||||
|
Never show a blank search screen. Include:
|
||||||
|
- Recent searches
|
||||||
|
- Popular/trending items
|
||||||
|
- Personalized recommendations
|
||||||
|
|
||||||
|
### Order/Status Tracking
|
||||||
|
- Open with a confident status message
|
||||||
|
- Humanize with photos, names, quick-action buttons
|
||||||
|
- Use visual timelines instead of text-based date lists
|
||||||
|
|
||||||
|
### Category Screens
|
||||||
|
- Use color-coded cards with soft backgrounds and clean isolated images
|
||||||
|
- Ensure visual consistency across all category items
|
||||||
|
- Create rhythm in the layout for effortless scanning
|
||||||
|
|
||||||
|
### Selection Over Manual Input
|
||||||
|
- Offer tappable selections for common options (job titles, preferences, etc.)
|
||||||
|
- Include icons/emojis alongside options for personality
|
||||||
|
- Provide an "Other" option with manual input as fallback
|
||||||
|
|
||||||
|
## Anti-Patterns to Avoid
|
||||||
|
- Overusing flashy gradients and blur effects (unless you can truly pull it off)
|
||||||
|
- More than 4 font sizes or 3 font weights
|
||||||
|
- Random spacing values (use the 8-point grid!)
|
||||||
|
- Hiding key content behind banners or extra taps
|
||||||
|
- Placing CTAs outside the thumb zone
|
||||||
|
- Generic empty states with no guidance
|
||||||
|
- Using sliders for frequent/precise data entry
|
||||||
|
- Making all information the same visual weight (no hierarchy)
|
||||||
|
- Emphasizing labels over values (e.g., making "Sales" bigger than "591")
|
||||||
|
- Pure gray/black shadows on colored backgrounds
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
When building these designs as React artifacts or HTML:
|
||||||
|
- Use Tailwind CSS utility classes for spacing, colors, and typography
|
||||||
|
- Import Lucide React for clean, consistent iconography
|
||||||
|
- Use Recharts for any data visualization
|
||||||
|
- Apply CSS transitions for micro-interactions and state changes
|
||||||
|
- Use CSS variables for the color system
|
||||||
|
- Mobile-first: design for 375px width (iPhone SE) as baseline
|
||||||
|
- Use `rounded-2xl` or `rounded-3xl` for modern card aesthetics
|
||||||
|
- Apply `backdrop-blur` for glassmorphism effects where appropriate
|
||||||
|
|
||||||
|
For deeper guidance on industry-specific conventions and emotional design patterns, read `references/industry-conventions.md`.
|
||||||
1
.claude/skills/deploy-prod
Symbolic link
1
.claude/skills/deploy-prod
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/deploy-prod
|
||||||
1
.claude/skills/mobile-app-ui-design
Symbolic link
1
.claude/skills/mobile-app-ui-design
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/mobile-app-ui-design
|
||||||
71
.claude/skills/plan-queue/SKILL.md
Normal file
71
.claude/skills/plan-queue/SKILL.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
name: plan-queue
|
||||||
|
description: Author detailed, weak-model-executable plan files into the current repo's plans/ queue (plans/queue|active|done|failed + INDEX.md). The strong model does ALL exploration and thinking up front so a cheap executor can later apply the plan with zero codebase discovery. Use when the user says "/plan-queue <task>", "queue a plan for X", "add X to the plan queue", "plan this for the weak model". Companion skill: run-queue executes the queue. Manually-triggered only.
|
||||||
|
---
|
||||||
|
|
||||||
|
# plan-queue
|
||||||
|
|
||||||
|
Turn a task description into a **self-contained plan file** in the repo's
|
||||||
|
`plans/queue/`, detailed enough that a weak/cheap model can execute it without
|
||||||
|
exploring the codebase. Multiple tasks in one invocation → one plan file each.
|
||||||
|
|
||||||
|
## Layout (create on first use in a repo)
|
||||||
|
|
||||||
|
```
|
||||||
|
plans/
|
||||||
|
INDEX.md # ordered table of all plans
|
||||||
|
queue/ # not started
|
||||||
|
active/ # currently executing (max 1)
|
||||||
|
done/
|
||||||
|
failed/
|
||||||
|
```
|
||||||
|
|
||||||
|
`INDEX.md` starts as:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Plan Queue Index
|
||||||
|
|
||||||
|
| Seq | ID | Title | Status | Commit | Notes |
|
||||||
|
|-----|----|-------|--------|--------|-------|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
1. **Scaffold**: if `plans/INDEX.md` doesn't exist, create the layout above
|
||||||
|
(`.gitkeep` in the four dirs so git tracks them).
|
||||||
|
2. **Explore deeply** (strong-model work): read every file the change touches,
|
||||||
|
find existing utilities to reuse, note exact paths, line anchors, and
|
||||||
|
signatures. The goal: the executor must need **zero** exploration.
|
||||||
|
3. **Name the plan**: next `NNN` = highest seq across all four dirs + 1
|
||||||
|
(zero-padded 3 digits). `HASH` = first 6 chars of
|
||||||
|
`sha1(title + timestamp)`. Filename: `NNN-<kebab-slug>-<HASH>.md` in
|
||||||
|
`plans/queue/`.
|
||||||
|
4. **Write the plan** following `plan-template.md` in this skill directory.
|
||||||
|
Every section is mandatory. Be near-diff-level specific in Steps for
|
||||||
|
anything non-trivial; paste real code snippets into Context.
|
||||||
|
5. **Index**: append a row to `INDEX.md` (`Status: queued`, Commit blank).
|
||||||
|
6. **Commit** the new plan file(s) + INDEX.md: `plan: add NNN-<slug>`.
|
||||||
|
Do NOT push. Never add AI attribution to the commit.
|
||||||
|
|
||||||
|
## Rules for plan quality (this is the whole point)
|
||||||
|
|
||||||
|
- **Front-load context.** Paste the actual current code of the region being
|
||||||
|
changed into the plan. The executor should never have to grep.
|
||||||
|
- **Steps are mechanical.** "Edit `src/auth.ts:42`: change X to Y" — not
|
||||||
|
"improve the auth flow".
|
||||||
|
- **Guardrails.** Always fill "Out of scope / do NOT touch" — weak models
|
||||||
|
drift without it.
|
||||||
|
- **Verification is copy-pasteable.** Exact commands + expected output.
|
||||||
|
- **Independence.** Each plan must leave the repo working (buildable/tests
|
||||||
|
green) on its own, since it gets its own commit. If a task can't, split it
|
||||||
|
differently or use `depends_on`.
|
||||||
|
- `depends_on:` in frontmatter lists plan IDs (the NNN-slug-hash basename)
|
||||||
|
that must be in `done/` first. Use it so many plans can be queued without
|
||||||
|
conflict.
|
||||||
|
|
||||||
|
## Conflict avoidance when queueing many plans
|
||||||
|
|
||||||
|
When authoring several plans in one batch, keep their file footprints
|
||||||
|
disjoint where possible; where they must overlap, chain them with
|
||||||
|
`depends_on` and write the later plan against the code **as it will be after
|
||||||
|
the earlier plan** (say so explicitly in its Context section).
|
||||||
59
.claude/skills/plan-queue/plan-template.md
Normal file
59
.claude/skills/plan-queue/plan-template.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
id: NNN-slug-hash # matches filename (without .md)
|
||||||
|
title: Short imperative title
|
||||||
|
created: YYYY-MM-DD
|
||||||
|
depends_on: [] # e.g. [001-add-user-model-a3f9c2]
|
||||||
|
est_files: 2 # rough count of files the executor will touch
|
||||||
|
---
|
||||||
|
|
||||||
|
# NNN — Title
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
One paragraph: what "done" looks like, in behavioral terms.
|
||||||
|
|
||||||
|
## Context the executor must NOT rediscover
|
||||||
|
|
||||||
|
Everything a weak model needs so it never explores:
|
||||||
|
|
||||||
|
- Exact file paths with line anchors (`src/foo.ts:120-145`) and what's there.
|
||||||
|
- Signatures of functions/utilities to REUSE (with their paths).
|
||||||
|
- Pasted snippets of the current code around each edit site.
|
||||||
|
- Any project conventions that apply (naming, error handling, test style).
|
||||||
|
|
||||||
|
```lang
|
||||||
|
// paste the relevant current code here
|
||||||
|
```
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Edit `path/to/file.ext` — concrete, mechanical instruction. For tricky
|
||||||
|
edits, show the exact before/after or a unified diff.
|
||||||
|
2. Create `path/to/new-file.ext` with: (full contents or precise spec).
|
||||||
|
3. ...
|
||||||
|
|
||||||
|
## Out of scope / do NOT touch
|
||||||
|
|
||||||
|
- Files/behaviors the executor must leave alone.
|
||||||
|
- Refactors NOT to attempt, even if tempting.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run exactly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
<build/test/lint commands>
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: <what passing looks like, specific output to check>.
|
||||||
|
|
||||||
|
## Report format (executor: follow exactly)
|
||||||
|
|
||||||
|
Output ONLY the following, no other prose:
|
||||||
|
|
||||||
|
1. `git diff` (unified) of all changes.
|
||||||
|
2. Raw output of the Verification commands.
|
||||||
|
3. `Findings:` — max 10 lines: surprises, deviations from the steps, anything
|
||||||
|
skipped and why.
|
||||||
|
|
||||||
|
Do not commit. Do not push. Do not touch files outside the Steps.
|
||||||
17
.claude/skills/plan-queue/sync-to-repos.sh
Executable file
17
.claude/skills/plan-queue/sync-to-repos.sh
Executable file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# sync-to-repos.sh — copy the canonical plan-queue + run-queue skills from
|
||||||
|
# ~/.claude/skills/ into .claude/skills/ of every git repo under
|
||||||
|
# ~/development/personal/. Re-run after editing the skills to prevent drift.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SRC="$HOME/.claude/skills"
|
||||||
|
BASE="$HOME/development/personal"
|
||||||
|
|
||||||
|
for gitdir in "$BASE"/*/.git; do
|
||||||
|
repo="$(dirname "$gitdir")"
|
||||||
|
dest="$repo/.claude/skills"
|
||||||
|
mkdir -p "$dest"
|
||||||
|
rm -rf "$dest/plan-queue" "$dest/run-queue"
|
||||||
|
cp -r "$SRC/plan-queue" "$SRC/run-queue" "$dest/"
|
||||||
|
echo "synced: $repo"
|
||||||
|
done
|
||||||
93
.claude/skills/run-queue/SKILL.md
Normal file
93
.claude/skills/run-queue/SKILL.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
---
|
||||||
|
name: run-queue
|
||||||
|
description: Execute the repo's plan queue (plans/queue/) sequentially - dispatch each plan to a cheap executor (claude-custom gateway via delegate.sh by default, or an in-session haiku Agent), review the returned diff + findings, verify, fix if needed, commit per plan, and push to all remotes only when the entire queue is done and verified. Resumable after token exhaustion or crashes via plans/active/. Use when the user says "/run-queue", "run the plan queue", "execute the queued plans". Companion skill: plan-queue authors the plans. Manually-triggered only.
|
||||||
|
---
|
||||||
|
|
||||||
|
# run-queue
|
||||||
|
|
||||||
|
Sequentially execute every plan in `plans/queue/` (authored by `plan-queue`).
|
||||||
|
The strong model (you) orchestrates and reviews; a weak model executes.
|
||||||
|
Token contract: you read only plan files, returned diffs/findings, and
|
||||||
|
verification output — never re-explore the repo yourself unless a fix round
|
||||||
|
requires it.
|
||||||
|
|
||||||
|
## Invocation
|
||||||
|
|
||||||
|
```
|
||||||
|
/run-queue [--executor gateway|agent] [--model TIER] [--max-fix N] [--dry-run]
|
||||||
|
```
|
||||||
|
|
||||||
|
Defaults: `--executor gateway --model haiku --max-fix 2`.
|
||||||
|
`--dry-run`: print the resolved execution order (respecting `depends_on`) and
|
||||||
|
exit without dispatching.
|
||||||
|
|
||||||
|
## Orchestration loop
|
||||||
|
|
||||||
|
### 0. Preconditions
|
||||||
|
- Repo has `plans/INDEX.md`; working tree is clean (if not, stop and ask —
|
||||||
|
never clobber uncommitted user work).
|
||||||
|
|
||||||
|
### 1. Resume check
|
||||||
|
If `plans/active/` contains a plan (previous run died / tokens exhausted):
|
||||||
|
- The plan-start commit is the checkpoint, so `git checkout -- . && git clean -fd`
|
||||||
|
(confirm nothing untracked is user work first) to reset any half-applied
|
||||||
|
edits, then re-dispatch that plan (step 3). This is always safe because
|
||||||
|
every plan starts from a clean committed state.
|
||||||
|
|
||||||
|
### 2. Pick next plan
|
||||||
|
- Lowest `NNN` in `plans/queue/` whose `depends_on` entries are ALL in
|
||||||
|
`plans/done/`. If a plan is blocked, skip to the next unblocked one; if
|
||||||
|
everything remaining is blocked, report and stop.
|
||||||
|
- `git mv plans/queue/<plan> plans/active/`, set its INDEX.md row to
|
||||||
|
`in-progress`, commit: `plan: start NNN-<slug>`. This commit is the resume
|
||||||
|
checkpoint.
|
||||||
|
|
||||||
|
### 3. Dispatch to the executor
|
||||||
|
Build the prompt with `run-plan.sh` (this skill dir):
|
||||||
|
|
||||||
|
- **gateway** (default): `~/.claude/skills/run-queue/run-plan.sh -d <repo-root> [-m TIER] plans/active/<plan>.md`
|
||||||
|
— wraps the plan in the executor preamble and calls
|
||||||
|
`~/.claude/skills/delegate-task/delegate.sh` (timeout 1800s, retries on).
|
||||||
|
- **agent**: `run-plan.sh -p plans/active/<plan>.md` prints the prompt only;
|
||||||
|
pass it to the Agent tool (`subagent_type: general-purpose`,
|
||||||
|
`model: haiku`, `run_in_background: false`, cwd = repo).
|
||||||
|
|
||||||
|
### 4. Review (strong model, diff-only)
|
||||||
|
Read ONLY the executor's report (diff + verification output + findings).
|
||||||
|
Then **run the plan's Verification commands yourself** — never trust the
|
||||||
|
executor's pasted output.
|
||||||
|
|
||||||
|
- **Pass** → step 5.
|
||||||
|
- **Fail** → up to `--max-fix` rounds:
|
||||||
|
- Small gap: fix it directly yourself (Edit tool).
|
||||||
|
- Larger miss: reset the tree (`git checkout -- . && git clean -fd`),
|
||||||
|
re-dispatch with a corrective addendum appended to the prompt
|
||||||
|
(`run-plan.sh -a "addendum text" ...`).
|
||||||
|
- **Exhausted fix rounds** → reset tree, append `## Failure notes` (what
|
||||||
|
failed, last error) to the plan file, `git mv` it to `plans/failed/`,
|
||||||
|
INDEX row → `failed`, commit `plan: fail NNN-<slug>`, continue with the
|
||||||
|
next plan that doesn't depend on it.
|
||||||
|
|
||||||
|
### 5. Complete the plan
|
||||||
|
- Append `## Execution log` to the plan file: executor+model, attempts,
|
||||||
|
fix rounds, the executor's Findings verbatim.
|
||||||
|
- `git mv plans/active/<plan> plans/done/`, INDEX row → `done` + commit hash
|
||||||
|
placeholder, then ONE commit containing code changes + plan move + INDEX:
|
||||||
|
message = the plan's title, plain human style. **Never** add
|
||||||
|
Co-Authored-By/AI attribution (global rule). Backfill the commit hash into
|
||||||
|
the INDEX row on the next commit or amend before creating it.
|
||||||
|
- Loop to step 2.
|
||||||
|
|
||||||
|
### 6. Ship gate (only when queue/ is empty)
|
||||||
|
- If `plans/failed/` is non-empty: report the failures, do NOT push. Done.
|
||||||
|
- Else: run the repo's full verification once more (union of the plans'
|
||||||
|
Verification commands, or the project's standard build/test), then push
|
||||||
|
every local branch's current state to **all** configured remotes
|
||||||
|
(`git remote` loop), ship-it style.
|
||||||
|
- **Webhook warning**: if the repo auto-deploys on push (e.g. BarangaySystem
|
||||||
|
via Gitea webhooks), say so before pushing and ask, unless the user already
|
||||||
|
told you to ship in this conversation.
|
||||||
|
|
||||||
|
## Reporting
|
||||||
|
End with: plans completed/failed (titles), one-line finding per plan, commits
|
||||||
|
created, and whether the push happened.
|
||||||
67
.claude/skills/run-queue/run-plan.sh
Executable file
67
.claude/skills/run-queue/run-plan.sh
Executable file
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# run-plan.sh — dispatch one plan-queue plan file to a cheap executor.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# run-plan.sh [-d REPO_DIR] [-m TIER] [-t SECS] [-a "addendum"] [-p] plans/active/NNN-slug-hash.md
|
||||||
|
#
|
||||||
|
# -d DIR repo root the executor works in (default: cwd)
|
||||||
|
# -m TIER model tier alias for delegate.sh (default: haiku)
|
||||||
|
# -t SECS timeout (default: 1800)
|
||||||
|
# -a TEXT corrective addendum appended to the prompt (fix rounds)
|
||||||
|
# -p print the built prompt to stdout and exit (for Agent-tool mode)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DIR="$(pwd)"
|
||||||
|
MODEL="haiku"
|
||||||
|
TIMEOUT=1800
|
||||||
|
ADDENDUM=""
|
||||||
|
PRINT_ONLY=0
|
||||||
|
|
||||||
|
while getopts "d:m:t:a:p" opt; do
|
||||||
|
case "$opt" in
|
||||||
|
d) DIR="$OPTARG" ;;
|
||||||
|
m) MODEL="$OPTARG" ;;
|
||||||
|
t) TIMEOUT="$OPTARG" ;;
|
||||||
|
a) ADDENDUM="$OPTARG" ;;
|
||||||
|
p) PRINT_ONLY=1 ;;
|
||||||
|
*) exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
shift $((OPTIND - 1))
|
||||||
|
PLAN="${1:?usage: run-plan.sh [opts] <plan-file>}"
|
||||||
|
[ -f "$PLAN" ] || { echo "plan file not found: $PLAN" >&2; exit 1; }
|
||||||
|
|
||||||
|
PROMPT_FILE="$(mktemp)"
|
||||||
|
trap 'rm -f "$PROMPT_FILE"' EXIT
|
||||||
|
|
||||||
|
{
|
||||||
|
cat <<'PREAMBLE'
|
||||||
|
You are a plan EXECUTOR. Apply the plan below exactly.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Follow the Steps in order. Do not explore beyond the files the plan names.
|
||||||
|
- Do not refactor, rename, or "improve" anything outside the Steps.
|
||||||
|
- Respect the "Out of scope / do NOT touch" section absolutely.
|
||||||
|
- Run the Verification commands after making the changes.
|
||||||
|
- Do NOT commit, do NOT push, do NOT create branches.
|
||||||
|
- Your final output must be ONLY, in this order:
|
||||||
|
1. The full `git diff` (unified) of your changes.
|
||||||
|
2. The raw output of the Verification commands.
|
||||||
|
3. `Findings:` followed by at most 10 lines (surprises, deviations, skips).
|
||||||
|
No other prose, no explanations, no step-by-step narration.
|
||||||
|
|
||||||
|
=== PLAN ===
|
||||||
|
PREAMBLE
|
||||||
|
cat "$PLAN"
|
||||||
|
if [ -n "$ADDENDUM" ]; then
|
||||||
|
printf '\n=== CORRECTION (a previous attempt failed — apply this too) ===\n%s\n' "$ADDENDUM"
|
||||||
|
fi
|
||||||
|
} > "$PROMPT_FILE"
|
||||||
|
|
||||||
|
if [ "$PRINT_ONLY" -eq 1 ]; then
|
||||||
|
cat "$PROMPT_FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$HOME/.claude/skills/delegate-task/delegate.sh" \
|
||||||
|
-m "$MODEL" -d "$DIR" -t "$TIMEOUT" -f "$PROMPT_FILE"
|
||||||
6
.gitignore
vendored
Normal file → Executable file
6
.gitignore
vendored
Normal file → Executable file
@@ -24,3 +24,9 @@ node_modules/
|
|||||||
# local data
|
# local data
|
||||||
*.tmp
|
*.tmp
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# runtime/local artifacts
|
||||||
|
server/data/
|
||||||
|
test-results/
|
||||||
|
.claude/worktrees/
|
||||||
|
server/public
|
||||||
|
|||||||
53
BACKLOG.md
Executable file
53
BACKLOG.md
Executable file
@@ -0,0 +1,53 @@
|
|||||||
|
# YT Player — Improvement Backlog
|
||||||
|
|
||||||
|
The `/loop` works through this top-to-bottom, **one item per iteration**. Tick an item
|
||||||
|
(`[x]`) only after it builds, verifies, and is committed + pushed. Add new ideas to the
|
||||||
|
bottom as they come up.
|
||||||
|
|
||||||
|
## Build & verify protocol (every iteration)
|
||||||
|
1. Edit the **canonical source** in WSL (`~/development/personal/ytplayer`).
|
||||||
|
2. Sync changed files **individually** into `C:\ytbuild\ytplayer` — never
|
||||||
|
`Copy-Item -Recurse` onto an existing dir (it nests as `frontend\frontend`).
|
||||||
|
3. `npx tauri build` in `C:\ytbuild\ytplayer`.
|
||||||
|
4. Launch with `--remote-debugging-port=922x` and verify via CDP/screenshot
|
||||||
|
(retry the launch a few times — Smart App Control is intermittent).
|
||||||
|
5. **Only if it builds and verifies:** refresh `releases/`, commit (no AI attribution),
|
||||||
|
`git push origin main`, then tick the item here.
|
||||||
|
|
||||||
|
## UX / polish
|
||||||
|
- [x] Empty-state designs for empty playlists and empty history (match the new hero).
|
||||||
|
- [x] Loading skeletons for search results instead of a bare "Searching…".
|
||||||
|
- [x] Keyboard-shortcut help overlay (press `?`), listing space / arrows / f / m.
|
||||||
|
- [x] Download/preload progress indicator on cards (percent or a bar, not just a pulse).
|
||||||
|
- [x] Remember and restore the last playback position per video.
|
||||||
|
- [x] Toast queue (stack multiple toasts instead of replacing).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- [x] Drag-to-reorder videos within a playlist.
|
||||||
|
- [x] "Up next" queue panel showing the autoplay queue.
|
||||||
|
- [x] Persistent mini now-playing bar when browsing other views.
|
||||||
|
- [x] Cache size cap in Settings (auto-evict oldest past a limit).
|
||||||
|
- [x] Export / import playlists as JSON.
|
||||||
|
- [x] More / trending quick-search chips on the hero, rotated.
|
||||||
|
|
||||||
|
## Robustness
|
||||||
|
- [x] Sanitize `video_id` in `cache_download` (reject path separators).
|
||||||
|
- [x] Gate the embedded yt-dlp behind a build flag so debug builds compile faster.
|
||||||
|
- [x] Surface yt-dlp extraction failures with a retry button.
|
||||||
|
|
||||||
|
## Done
|
||||||
|
- [x] Empty-state designs for empty playlists and empty history (match the new hero). `460aaae`
|
||||||
|
- [x] Loading skeletons for search results instead of a bare "Searching…". `da2ad27`
|
||||||
|
- [x] Keyboard-shortcut help overlay (press `?`), listing space / arrows / f / m. `c676786`
|
||||||
|
- [x] Download/preload progress indicator on cards (percent or a bar, not just a pulse). `81aced9`
|
||||||
|
- [x] Remember and restore the last playback position per video. `ddfa149`
|
||||||
|
- [x] Toast queue (stack multiple toasts instead of replacing). `a4d6b82`
|
||||||
|
- [x] Drag-to-reorder videos within a playlist. `0ba0f56`
|
||||||
|
- [x] "Up next" queue panel showing the autoplay queue. `4376cd9`
|
||||||
|
- [x] Persistent mini now-playing bar when browsing other views. `c1855a1`
|
||||||
|
- [x] Cache size cap in Settings (auto-evict oldest past a limit). `d15d902`
|
||||||
|
- [x] Export / import playlists as JSON. `d15d902`
|
||||||
|
- [x] More / trending quick-search chips on the hero, rotated. `c6b8dfc`
|
||||||
|
- [x] Sanitize `video_id` in `cache_download` (reject path separators). `c6b8dfc`
|
||||||
|
- [x] Gate the embedded yt-dlp behind a build flag so debug builds compile faster. `d15d902`
|
||||||
|
- [x] Surface yt-dlp extraction failures with a retry button. `c6b8dfc`
|
||||||
105
CLAUDE.md
Normal file
105
CLAUDE.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# ytplayer — project facts for agents
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
Ad-free YouTube player. Three shells share `frontend/`: **web PWA** (Bun + Hono in
|
||||||
|
`server/`, the production deployment), Tauri (Windows), zero-native (Linux/macOS).
|
||||||
|
The PWA is what runs in production; `legacy/` holds the old native-only docs.
|
||||||
|
|
||||||
|
## Repo map
|
||||||
|
|
||||||
|
| Path | What lives there |
|
||||||
|
|------|------------------|
|
||||||
|
| `frontend/app.js` | The entire UI (~3.6k lines, no framework): player, playlists, search, settings, sync |
|
||||||
|
| `frontend/sw.js` + `frontend/sw-update.js` | Service worker + update flow (FRAGILE — see below) |
|
||||||
|
| `frontend/opfs.js` / `opfs-worker.js` | OPFS offline audio cache |
|
||||||
|
| `frontend/fingerprint.js` | Browser fingerprint used as the sync key |
|
||||||
|
| `frontend/async-guard.js` | Stale-async-response guard (unit-tested) |
|
||||||
|
| `server/server.js` | Bun + Hono backend — endpoint list is in its header comment |
|
||||||
|
| `server/db.js` | libsql schema/queries (`users`, `profiles`, `video_history`…) |
|
||||||
|
| `bin/yt-dlp` | Downloaded by `npm run setup`, gitignored |
|
||||||
|
| `scripts/` | icon generation, yt-dlp setup, push helper |
|
||||||
|
| `tests/` | Playwright e2e specs · unit tests live next to sources in `frontend/*.test.js` |
|
||||||
|
| `legacy/` | Old native-shell docs — do not treat as current |
|
||||||
|
|
||||||
|
API endpoints: `GET /api/search|channel|streams|download/:id|version|user/data|profile/load`,
|
||||||
|
`POST /api/user/sync|profile/create|profile/save`, `GET /sw.js` (BUILD_TAG-injected), `GET /*` static.
|
||||||
|
JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone.
|
||||||
|
|
||||||
|
## Local dev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run setup # download bin/yt-dlp (once)
|
||||||
|
cd server && bun install
|
||||||
|
ln -s ../frontend public # once — the server serves ONLY ./public (Docker copies frontend/ there)
|
||||||
|
bun --hot server.js # http://localhost:3000
|
||||||
|
node --test frontend/ # unit tests (run from repo root)
|
||||||
|
npx playwright test # e2e (see Testing below)
|
||||||
|
```
|
||||||
|
|
||||||
|
Local DB file: `server/data/ytplayer.db` (gitignored). `BUILD_TAG` is computed from `./public` contents.
|
||||||
|
|
||||||
|
## Production deployment (web PWA)
|
||||||
|
- URL: **https://worship.hesed.sbs** (Traefik label in `docker-compose.yml`)
|
||||||
|
- Runs on the **homelab** Dokploy remote node; control plane is Dokploy on the VPS
|
||||||
|
(`193.160.119.172`, API key in `~/development/.secrets/dokploy-api.env`).
|
||||||
|
- Compose ID: **`wprYCM8T51f7JtSHb983p`** (project `ytplayer`, env `production`).
|
||||||
|
- **Pushing to git does NOT deploy.** Trigger explicitly (build ≈ 5–6 min):
|
||||||
|
```bash
|
||||||
|
ssh -i ~/.ssh/tmp_vps/dokploy_session root@193.160.119.172 \
|
||||||
|
"curl -s -X POST -H 'x-api-key: $KEY' -H 'Content-Type: application/json' \
|
||||||
|
-d '{\"composeId\":\"wprYCM8T51f7JtSHb983p\"}' http://localhost:3000/api/compose.deploy"
|
||||||
|
# poll composeStatus via /api/compose.one?composeId=... until done|error
|
||||||
|
```
|
||||||
|
- Confirm the deploy landed: `curl https://worship.hesed.sbs/api/version` — the
|
||||||
|
`buildTag` (content hash of every file under `./public`) must change.
|
||||||
|
- Homelab node is NOT always reachable on LAN; SSH via the VPS hop:
|
||||||
|
`ssh root@193.160.119.172` → `ssh root@10.8.0.2` (WireGuard). Container name:
|
||||||
|
`ytplayer-main-1dihzn-ytplayer-1`. DB: libsql file `/app/data/ytplayer.db`
|
||||||
|
(query with `docker exec <c> bun -e '...' `using `@libsql/client`).
|
||||||
|
|
||||||
|
## Update-flow architecture (fragile — read before touching)
|
||||||
|
- `GET /sw.js` is served by the server with the real `BUILD_TAG` **injected by
|
||||||
|
regex** over the fallback expression in `frontend/sw.js`. Never switch back to
|
||||||
|
an exact-string replace: when the fallback literal was bumped (`v1.0.3`→`v1.0.4`)
|
||||||
|
the exact match silently failed, the SW version froze, and **no client ever
|
||||||
|
received another update** while `/api/version` kept announcing one — the
|
||||||
|
"Update available keeps showing" bug.
|
||||||
|
- `BUILD_TAG` hashes **every** file under `./public` recursively. Don't reduce it
|
||||||
|
to a file subset; a change to an unlisted shell file would stop busting caches.
|
||||||
|
- The update banner only shows when a waiting SW exists **and the page already
|
||||||
|
has a controller** — a first install (fresh visit, or after Settings → Force
|
||||||
|
refresh unregisters) passes through `waiting` transiently and must not banner.
|
||||||
|
- "Refresh UI" (`frontend/sw-update.js`): if no worker is waiting yet (banner came
|
||||||
|
from the `/api/version` poll), it calls `reg.update()`, waits for `installed`,
|
||||||
|
posts SKIP_WAITING, waits for `controllerchange`, then reloads once.
|
||||||
|
|
||||||
|
## Data model quirks
|
||||||
|
- Client state persists in localStorage key **`_ytpdata`** and syncs (debounced
|
||||||
|
400 ms) to `POST /api/user/sync`, keyed by a browser fingerprint.
|
||||||
|
- **A-B loop markers are per-song-per-playlist**: stored on the playlist's own
|
||||||
|
copy of the video (`entry.ab = {a, b}`) when playback source is that playlist;
|
||||||
|
`data.abMarkers[videoId]` is only the fallback for non-playlist playback.
|
||||||
|
- **Online profiles** (`profiles` table, `/api/profile/*`): named cross-device
|
||||||
|
sync where the lowercase profile NAME is the only credential (passkey-style,
|
||||||
|
by design). Client stores `data.profile = {name, syncedAt}`; sync is
|
||||||
|
last-write-wins — push debounced on every persist(), pull on app launch when
|
||||||
|
the server's `updated_at` is newer than the local `syncedAt`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- Unit: `node --test frontend/` (sw, sw-update, async-guard).
|
||||||
|
- E2E: `npx playwright test` — WebKit iPhone-12 profile against a static serve of
|
||||||
|
`frontend/` (needs `npx playwright install webkit`). A spurious update banner
|
||||||
|
will make the settings-panel specs fail with `#modal intercepts pointer events`
|
||||||
|
— that failure mode is a real app bug, not test flake.
|
||||||
|
- Test records on prod use `Probe */Recon *` names; clean via the container DB,
|
||||||
|
children (`video_history`) first.
|
||||||
|
|
||||||
|
## Harness
|
||||||
|
|
||||||
|
Skills live in `.agents/skills/` (symlinked into `.claude/skills/`):
|
||||||
|
- **deploy-prod** — the Dokploy deploy + buildTag verification flow (manual-only; use for any "deploy"/"release" request).
|
||||||
|
- **mobile-app-ui-design** — UI/UX design work on the PWA screens.
|
||||||
|
|
||||||
|
## Commit rules
|
||||||
|
One changeset = one commit, single-line imperative message, **no AI attribution
|
||||||
|
of any kind** (global rule). `git push origin main` pushes to both remotes.
|
||||||
59
Dockerfile
Normal file
59
Dockerfile
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# YT Player PWA — Docker image
|
||||||
|
#
|
||||||
|
# Base: oven/bun:1-debian (Bun runtime on Debian slim)
|
||||||
|
# yt-dlp: downloaded from GitHub releases at build time (stays current)
|
||||||
|
# libsql: embedded via @libsql/client (no separate DB container needed)
|
||||||
|
#
|
||||||
|
# Build: docker compose build
|
||||||
|
# Run: docker compose up
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
FROM oven/bun:1-debian
|
||||||
|
|
||||||
|
# ---- System dependencies ----
|
||||||
|
# python3 is required by yt-dlp for some extraction paths
|
||||||
|
# ca-certificates for HTTPS fetches from yt-dlp
|
||||||
|
# ffmpeg lets yt-dlp merge bestvideo+bestaudio into a single mp4 for the
|
||||||
|
# "Save before playing" download path (GET /api/download?mux=1)
|
||||||
|
RUN apt-get update -qq && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
python3 \
|
||||||
|
ffmpeg \
|
||||||
|
ca-certificates && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# ---- Install yt-dlp ----
|
||||||
|
RUN curl -fsSL \
|
||||||
|
https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp \
|
||||||
|
-o /usr/local/bin/yt-dlp && \
|
||||||
|
chmod +x /usr/local/bin/yt-dlp
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# ---- Install Node/Bun deps ----
|
||||||
|
COPY server/package.json ./
|
||||||
|
RUN bun install --production
|
||||||
|
|
||||||
|
# ---- Copy server source ----
|
||||||
|
COPY server/ ./
|
||||||
|
|
||||||
|
# ---- Copy built frontend (served as static files from ./public) ----
|
||||||
|
COPY frontend/ ./public/
|
||||||
|
|
||||||
|
# ---- Stamp the build time (shown in Settings → About) ----
|
||||||
|
# Runs after the COPY layers, so any source change produces a fresh stamp
|
||||||
|
# while a fully-cached (unchanged) build keeps its original one.
|
||||||
|
RUN date -u +"%Y-%m-%dT%H:%M:%SZ" > /app/build-time.txt
|
||||||
|
|
||||||
|
# ---- Persistent data directory (volume-mounted) ----
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Healthcheck — ping the version endpoint
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD curl -sf http://localhost:3000/api/version || exit 1
|
||||||
|
|
||||||
|
CMD ["bun", "server.js"]
|
||||||
33
README.md
Normal file → Executable file
33
README.md
Normal file → Executable file
@@ -18,6 +18,13 @@ or Node runtime.
|
|||||||
- 🎵 **Audio-only mode** — great for music, saves bandwidth
|
- 🎵 **Audio-only mode** — great for music, saves bandwidth
|
||||||
- 📂 **On-device playlists** — create, rename, delete, add/remove videos. Stored
|
- 📂 **On-device playlists** — create, rename, delete, add/remove videos. Stored
|
||||||
locally as JSON; nothing leaves your machine
|
locally as JSON; nothing leaves your machine
|
||||||
|
- 💾 **Offline cache / preload** — a **Save** button and an **Add to playlist**
|
||||||
|
button on the now-playing video download a self-contained copy into a permanent
|
||||||
|
file cache, so it plays instantly and works offline. Videos added to a playlist
|
||||||
|
are auto-preloaded and kept until you remove them.
|
||||||
|
- ⚙ **Settings page** — playback defaults (quality, volume, audio-only) plus cache
|
||||||
|
management: see storage used, toggle auto-preload, and delete cached videos
|
||||||
|
individually or all at once
|
||||||
- 🕘 **Watch history**
|
- 🕘 **Watch history**
|
||||||
- ⏯ Full controls: seek, volume, playback speed, quality switching, fullscreen,
|
- ⏯ Full controls: seek, volume, playback speed, quality switching, fullscreen,
|
||||||
next/prev, and keyboard shortcuts (`space`, `←/→`, `f`, `m`)
|
next/prev, and keyboard shortcuts (`space`, `←/→`, `f`, `m`)
|
||||||
@@ -57,6 +64,17 @@ The web UI talks to the native side over whichever bridge is present
|
|||||||
| `yt.streams { videoId }` | `ytStreams` | `{ ok, data:{ meta, audioUrl, qualities[] } }` |
|
| `yt.streams { videoId }` | `ytStreams` | `{ ok, data:{ meta, audioUrl, qualities[] } }` |
|
||||||
| `store.load {}` | `storeLoad` | playlists / history / settings |
|
| `store.load {}` | `storeLoad` | playlists / history / settings |
|
||||||
| `store.save { data }` | `storeSave` | `{ ok }` |
|
| `store.save { data }` | `storeSave` | `{ ok }` |
|
||||||
|
| `cache.download { videoId }` | `cache_download` | downloads a single-file copy into the offline cache |
|
||||||
|
| `cache.status { videoId }` | `cache_status` | `{ ok, cached, path?, size? }` |
|
||||||
|
| `cache.list {}` | `cache_list` | `{ ok, items:[{id,size,path}], total }` |
|
||||||
|
| `cache.delete { videoId }` | `cache_delete` | removes one cached file |
|
||||||
|
| `cache.clear {}` | `cache_clear` | removes all cached files |
|
||||||
|
|
||||||
|
> The offline cache is implemented in the **Tauri (Windows)** shell. Files live
|
||||||
|
> in `<app_cache_dir>/videos/<videoId>.<ext>` and persist until deleted from the
|
||||||
|
> Settings page. The frontend falls back to live streaming if a cached file is
|
||||||
|
> missing, and the cache calls degrade gracefully on shells that don't implement
|
||||||
|
> them.
|
||||||
|
|
||||||
Because the bridge is size-limited, the Zig handlers parse `yt-dlp`'s large JSON
|
Because the bridge is size-limited, the Zig handlers parse `yt-dlp`'s large JSON
|
||||||
and return only the compact fields the UI needs.
|
and return only the compact fields the UI needs.
|
||||||
@@ -98,6 +116,21 @@ The installer lands in
|
|||||||
`src-tauri\target\release\bundle\` (`nsis\*-setup.exe` and `msi\*.msi`).
|
`src-tauri\target\release\bundle\` (`nsis\*-setup.exe` and `msi\*.msi`).
|
||||||
`yt-dlp.exe` is bundled as an app resource, so the installed app is self-contained.
|
`yt-dlp.exe` is bundled as an app resource, so the installed app is self-contained.
|
||||||
|
|
||||||
|
### One-shot release
|
||||||
|
|
||||||
|
`scripts\release.ps1` does the whole flow — build, copy installers into
|
||||||
|
`.\releases`, then commit and push through WSL git:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh -File scripts\release.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
To only commit + push from WSL (e.g. after building separately):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/push.sh "your commit message"
|
||||||
|
```
|
||||||
|
|
||||||
> Already in WSL and just want it running fast? Your WSL is WSLg-enabled, so you
|
> Already in WSL and just want it running fast? Your WSL is WSLg-enabled, so you
|
||||||
> can instead build the **Linux** (zero-native) target and its window appears on
|
> can instead build the **Linux** (zero-native) target and its window appears on
|
||||||
> your Windows desktop — see [Setup & run](#setup--run). That needs WSL running
|
> your Windows desktop — see [Setup & run](#setup--run). That needs WSL running
|
||||||
|
|||||||
0
appicon.png
Normal file → Executable file
0
appicon.png
Normal file → Executable file
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
38
docker-compose.yml
Normal file
38
docker-compose.yml
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
services:
|
||||||
|
ytplayer:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
# libsql DB file persists across container rebuilds
|
||||||
|
- ytplayer-data:/app/data
|
||||||
|
environment:
|
||||||
|
PORT: "3000"
|
||||||
|
DB_PATH: "/app/data/ytplayer.db"
|
||||||
|
APP_VERSION: "1.0.0"
|
||||||
|
# Optional: override yt-dlp binary path if you mount a custom one
|
||||||
|
# YTDLP_PATH: "/usr/local/bin/yt-dlp"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-sf", "http://localhost:3000/api/version"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
networks:
|
||||||
|
- dokploy-network
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.ytplayer-http.rule=Host(`worship.hesed.sbs`)"
|
||||||
|
- "traefik.http.routers.ytplayer-http.entrypoints=web"
|
||||||
|
- "traefik.http.routers.ytplayer-http.middlewares=redirect-to-https"
|
||||||
|
- "traefik.http.routers.ytplayer-https.rule=Host(`worship.hesed.sbs`)"
|
||||||
|
- "traefik.http.routers.ytplayer-https.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.ytplayer-https.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.ytplayer-svc.loadbalancer.server.port=3000"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
ytplayer-data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dokploy-network:
|
||||||
|
external: true
|
||||||
3410
frontend/app.js
Normal file → Executable file
3410
frontend/app.js
Normal file → Executable file
File diff suppressed because it is too large
Load Diff
45
frontend/async-guard.js
Executable file
45
frontend/async-guard.js
Executable file
@@ -0,0 +1,45 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
* async-guard — tiny concurrency helper shared by the frontend.
|
||||||
|
*
|
||||||
|
* Keeps long-running native operations (offline-cache download/delete, playlist
|
||||||
|
* mutations) from being triggered twice at once for the same target, so the UI
|
||||||
|
* stays responsive and on-device state can't be corrupted by rapid clicks.
|
||||||
|
*
|
||||||
|
* Framework-free and dependency-free on purpose:
|
||||||
|
* • Loads as a plain <script> under CSP `script-src 'self'` (browser global
|
||||||
|
* `window.AsyncGuard`).
|
||||||
|
* • `require`-able by `node --test` (CommonJS `module.exports`).
|
||||||
|
* ========================================================================== */
|
||||||
|
(function (root) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn` exclusively for `key`: if an operation for `key` is already
|
||||||
|
* in-flight (tracked in `set`), this call is a no-op and resolves to
|
||||||
|
* `undefined`. Otherwise `key` is added to `set`, `fn` runs, and `key` is
|
||||||
|
* removed once it settles — on success AND on failure — so a failed
|
||||||
|
* operation can be retried.
|
||||||
|
*
|
||||||
|
* @param {Set<string>} set in-flight key registry (caller owns it)
|
||||||
|
* @param {string} key identifies the target being operated on
|
||||||
|
* @param {() => (any|Promise<any>)} fn the work to run exclusively
|
||||||
|
* @returns {Promise<any>} fn's resolved value, or undefined if skipped
|
||||||
|
*/
|
||||||
|
async function runExclusive(set, key, fn) {
|
||||||
|
if (set.has(key)) return undefined;
|
||||||
|
set.add(key);
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
set.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const AsyncGuard = { runExclusive };
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = AsyncGuard;
|
||||||
|
} else {
|
||||||
|
root.AsyncGuard = AsyncGuard;
|
||||||
|
}
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||||
68
frontend/async-guard.test.js
Executable file
68
frontend/async-guard.test.js
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { runExclusive } = require('./async-guard');
|
||||||
|
|
||||||
|
// A controllable promise so a test can hold an operation "in flight".
|
||||||
|
function deferred() {
|
||||||
|
let resolve, reject;
|
||||||
|
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('runs fn and returns its resolved value', async () => {
|
||||||
|
const set = new Set();
|
||||||
|
const out = await runExclusive(set, 'a', async () => 42);
|
||||||
|
assert.strictEqual(out, 42);
|
||||||
|
assert.strictEqual(set.size, 0, 'key freed after success');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips a second concurrent call for the same key (fn runs once)', async () => {
|
||||||
|
const set = new Set();
|
||||||
|
let calls = 0;
|
||||||
|
const d = deferred();
|
||||||
|
const first = runExclusive(set, 'k', async () => { calls++; await d.promise; return 'first'; });
|
||||||
|
// Second call while the first is still in flight — must be a no-op.
|
||||||
|
const second = await runExclusive(set, 'k', async () => { calls++; return 'second'; });
|
||||||
|
assert.strictEqual(second, undefined, 'skipped call resolves to undefined');
|
||||||
|
assert.strictEqual(calls, 1, 'fn invoked only once');
|
||||||
|
d.resolve();
|
||||||
|
assert.strictEqual(await first, 'first');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('different keys run in parallel', async () => {
|
||||||
|
const set = new Set();
|
||||||
|
const dA = deferred();
|
||||||
|
const dB = deferred();
|
||||||
|
let aDone = false;
|
||||||
|
const a = runExclusive(set, 'A', async () => { await dA.promise; aDone = true; return 'A'; });
|
||||||
|
const b = runExclusive(set, 'B', async () => { await dB.promise; return 'B'; });
|
||||||
|
assert.strictEqual(set.size, 2, 'both keys in flight at once');
|
||||||
|
// B can finish before A — proves they are not serialized.
|
||||||
|
dB.resolve();
|
||||||
|
assert.strictEqual(await b, 'B');
|
||||||
|
assert.strictEqual(aDone, false, 'A still in flight while B completed');
|
||||||
|
dA.resolve();
|
||||||
|
assert.strictEqual(await a, 'A');
|
||||||
|
assert.strictEqual(set.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('frees the key after failure so a retry is possible', async () => {
|
||||||
|
const set = new Set();
|
||||||
|
await assert.rejects(
|
||||||
|
runExclusive(set, 'x', async () => { throw new Error('boom'); }),
|
||||||
|
/boom/,
|
||||||
|
);
|
||||||
|
assert.strictEqual(set.has('x'), false, 'key freed even on throw');
|
||||||
|
// Retry now succeeds because the key was released.
|
||||||
|
const out = await runExclusive(set, 'x', async () => 'ok');
|
||||||
|
assert.strictEqual(out, 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports a synchronous fn', async () => {
|
||||||
|
const set = new Set();
|
||||||
|
const out = await runExclusive(set, 's', () => 7);
|
||||||
|
assert.strictEqual(out, 7);
|
||||||
|
assert.strictEqual(set.size, 0);
|
||||||
|
});
|
||||||
81
frontend/fingerprint.js
Normal file
81
frontend/fingerprint.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
* fingerprint.js — stable browser identifier for server-side playlist sync
|
||||||
|
*
|
||||||
|
* Combines canvas rendering, UA, screen geometry, and hardware hints into a
|
||||||
|
* short hex string. The result is stored in localStorage so the same ID
|
||||||
|
* survives page reloads, and only regenerated when localStorage is cleared.
|
||||||
|
*
|
||||||
|
* Not for tracking — used exclusively so the server can associate playlist
|
||||||
|
* and history rows with this browser without requiring a login.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// djb2 hash over a string → 32-bit unsigned int
|
||||||
|
function djb2(str) {
|
||||||
|
let h = 5381;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
h = (((h << 5) + h) + str.charCodeAt(i)) >>> 0;
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render a small canvas to capture GPU/font rasterisation differences,
|
||||||
|
// then hash the pixel data. Falls back to empty string if canvas is blocked.
|
||||||
|
function canvasHash() {
|
||||||
|
try {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = 200; c.height = 40;
|
||||||
|
const ctx = c.getContext('2d');
|
||||||
|
if (!ctx) return '';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.font = '14px Arial';
|
||||||
|
ctx.fillStyle = '#f60';
|
||||||
|
ctx.fillRect(125, 1, 62, 20);
|
||||||
|
ctx.fillStyle = '#069';
|
||||||
|
ctx.fillText('YTPlayer🎵', 2, 15);
|
||||||
|
ctx.fillStyle = 'rgba(102,204,0,0.7)';
|
||||||
|
ctx.fillText('YTPlayer🎵', 4, 17);
|
||||||
|
return djb2(c.toDataURL()).toString(16);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateFingerprint() {
|
||||||
|
const parts = [
|
||||||
|
navigator.userAgent || '',
|
||||||
|
String(screen.width) + 'x' + String(screen.height),
|
||||||
|
String(screen.colorDepth),
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone || '',
|
||||||
|
navigator.language || '',
|
||||||
|
String(navigator.hardwareConcurrency || 0),
|
||||||
|
String(navigator.deviceMemory || 0),
|
||||||
|
canvasHash(),
|
||||||
|
];
|
||||||
|
// Combine all component hashes into one 16-char hex fingerprint
|
||||||
|
const combined = parts.reduce((acc, p) => acc + '|' + p, '');
|
||||||
|
const h1 = djb2(combined);
|
||||||
|
const h2 = djb2(combined.split('').reverse().join(''));
|
||||||
|
return h1.toString(16).padStart(8, '0') + h2.toString(16).padStart(8, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.getFingerprint = function getFingerprint() {
|
||||||
|
try {
|
||||||
|
let fp = localStorage.getItem('_ytpfp');
|
||||||
|
if (!fp || fp.length < 8) {
|
||||||
|
fp = generateFingerprint();
|
||||||
|
localStorage.setItem('_ytpfp', fp);
|
||||||
|
}
|
||||||
|
return fp;
|
||||||
|
} catch {
|
||||||
|
// localStorage blocked (e.g. private mode on some browsers) — generate
|
||||||
|
// ephemeral fingerprint that survives the page session via a closure.
|
||||||
|
if (!window._ytpfpEphemeral) {
|
||||||
|
window._ytpfpEphemeral = generateFingerprint();
|
||||||
|
}
|
||||||
|
return window._ytpfpEphemeral;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}());
|
||||||
BIN
frontend/icons/icon-192.png
Normal file
BIN
frontend/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/icons/icon-512.png
Normal file
BIN
frontend/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
181
frontend/index.html
Normal file → Executable file
181
frontend/index.html
Normal file → Executable file
@@ -2,12 +2,18 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#1a1311" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="YT Player" />
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self'; img-src 'self' https: data: asset: http://asset.localhost; media-src 'self' https: blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com data:; script-src 'self'; connect-src 'self' https: ipc: http://ipc.localhost;"
|
content="default-src 'self'; img-src 'self' https: data: asset: http://asset.localhost blob:; media-src 'self' https: blob: asset: http://asset.localhost; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com data:; script-src 'self'; connect-src 'self' https: ipc: http://ipc.localhost blob:; worker-src 'self';"
|
||||||
/>
|
/>
|
||||||
<title>YT Player</title>
|
<title>YT Player</title>
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
@@ -19,14 +25,18 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<aside class="sidebar">
|
<aside id="sidebar" class="sidebar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<span class="brand-mark">▶</span> YT Player
|
<span class="brand-mark">▶</span> YT Player
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<button class="nav-item active" data-view="search">🔍 Search</button>
|
<button class="nav-item active" data-view="search">🔍 Search</button>
|
||||||
|
<button class="nav-item" data-view="queue">▶ Queue <span id="navQueueCount" class="nav-badge hidden">0</span></button>
|
||||||
<button class="nav-item" data-view="history">🕘 History</button>
|
<button class="nav-item" data-view="history">🕘 History</button>
|
||||||
|
<button class="nav-item" data-view="saved">💾 Saved</button>
|
||||||
|
<button class="nav-item" data-view="downloads">⬇ Downloads <span id="navDlCount" class="nav-badge hidden">0</span></button>
|
||||||
|
<button class="nav-item" data-view="settings">⚙ Settings</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="pl-header">
|
<div class="pl-header">
|
||||||
@@ -35,6 +45,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="playlistList" class="playlist-list"></div>
|
<div id="playlistList" class="playlist-list"></div>
|
||||||
|
|
||||||
|
<div class="pl-header smart-pl-header">
|
||||||
|
<span>Auto Playlists</span>
|
||||||
|
</div>
|
||||||
|
<div id="smartPlaylistList" class="playlist-list smart-pl-list"></div>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<label class="switch">
|
<label class="switch">
|
||||||
<input type="checkbox" id="audioOnlyToggle" />
|
<input type="checkbox" id="audioOnlyToggle" />
|
||||||
@@ -43,9 +58,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<!-- Sidebar backdrop (mobile drawer) -->
|
||||||
|
<div id="sidebarBackdrop" class="sidebar-backdrop"></div>
|
||||||
|
|
||||||
<!-- Main -->
|
<!-- Main -->
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
|
<button
|
||||||
|
id="sidebarToggle"
|
||||||
|
class="sidebar-toggle"
|
||||||
|
type="button"
|
||||||
|
aria-label="Toggle sidebar"
|
||||||
|
aria-controls="sidebar"
|
||||||
|
aria-expanded="false"
|
||||||
|
>☰</button>
|
||||||
<form id="searchForm" class="search-form">
|
<form id="searchForm" class="search-form">
|
||||||
<input
|
<input
|
||||||
id="searchInput"
|
id="searchInput"
|
||||||
@@ -67,8 +93,23 @@
|
|||||||
<img id="artImg" alt="" />
|
<img id="artImg" alt="" />
|
||||||
</div>
|
</div>
|
||||||
<div id="playerPlaceholder" class="player-placeholder">
|
<div id="playerPlaceholder" class="player-placeholder">
|
||||||
|
<div class="hero">
|
||||||
<div class="ph-logo">▶</div>
|
<div class="ph-logo">▶</div>
|
||||||
<p>Search and pick a video to start watching — ad-free.</p>
|
<h1 class="hero-title">YT Player</h1>
|
||||||
|
<p class="hero-tagline">Ad-free YouTube — no login, no tracking. Search, play, and keep playlists right on your device.</p>
|
||||||
|
<div class="hero-pills">
|
||||||
|
<span class="hero-pill">No ads</span>
|
||||||
|
<span class="hero-pill">No sign-in</span>
|
||||||
|
<span class="hero-pill">Offline playlists</span>
|
||||||
|
</div>
|
||||||
|
<div class="hero-suggests">
|
||||||
|
<span class="hero-suggests-label">Try</span>
|
||||||
|
<button class="chip" data-q="lofi hip hop radio">lofi beats</button>
|
||||||
|
<button class="chip" data-q="live news">live news</button>
|
||||||
|
<button class="chip" data-q="relaxing music">relaxing music</button>
|
||||||
|
<button class="chip" data-q="podcast highlights">podcasts</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="bufferSpinner" class="spinner hidden"></div>
|
<div id="bufferSpinner" class="spinner hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,7 +117,14 @@
|
|||||||
<div id="controls" class="controls hidden">
|
<div id="controls" class="controls hidden">
|
||||||
<div class="seek-row">
|
<div class="seek-row">
|
||||||
<span id="curTime" class="time">0:00</span>
|
<span id="curTime" class="time">0:00</span>
|
||||||
|
<div class="seek-wrap">
|
||||||
<input id="seek" class="seek" type="range" min="0" max="1000" value="0" />
|
<input id="seek" class="seek" type="range" min="0" max="1000" value="0" />
|
||||||
|
<div id="abIndicator" class="ab-indicator hidden">
|
||||||
|
<span id="abALabel" class="ab-label">A: --</span>
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<span id="abBLabel" class="ab-label">B: --</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<span id="durTime" class="time">0:00</span>
|
<span id="durTime" class="time">0:00</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
@@ -84,6 +132,10 @@
|
|||||||
<button id="playBtn" class="ctrl play" title="Play/Pause">▶</button>
|
<button id="playBtn" class="ctrl play" title="Play/Pause">▶</button>
|
||||||
<button id="nextBtn" class="ctrl" title="Next">⏭</button>
|
<button id="nextBtn" class="ctrl" title="Next">⏭</button>
|
||||||
|
|
||||||
|
<button id="abABtn" class="ctrl ab-ctrl" title="Set A-B loop start [A]">A</button>
|
||||||
|
<button id="abBBtn" class="ctrl ab-ctrl" title="Set A-B loop end [B]">B</button>
|
||||||
|
<button id="abClearBtn" class="ctrl ab-ctrl hidden" title="Clear A-B loop">↺</button>
|
||||||
|
<button id="rememberPosBtn" class="ctrl" title="Remember playback position for this song (per playlist)">📍</button>
|
||||||
<div class="vol">
|
<div class="vol">
|
||||||
<button id="muteBtn" class="ctrl" title="Mute">🔊</button>
|
<button id="muteBtn" class="ctrl" title="Mute">🔊</button>
|
||||||
<input id="volume" type="range" min="0" max="1" step="0.01" value="1" />
|
<input id="volume" type="range" min="0" max="1" step="0.01" value="1" />
|
||||||
@@ -108,14 +160,49 @@
|
|||||||
<select id="qualitySelect"></select>
|
<select id="qualitySelect"></select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<button id="sleepTimerBtn" class="ctrl" title="Sleep timer">⏱</button>
|
||||||
|
<button id="shuffleBtn" class="ctrl" title="Shuffle upcoming tracks (S)">🔀</button>
|
||||||
|
<button id="loopBtn" class="ctrl" title="Loop current video (L)">🔂</button>
|
||||||
|
<button id="repeatBtn" class="ctrl" title="Repeat list when finished (R)">🔁</button>
|
||||||
<button id="fsBtn" class="ctrl" title="Fullscreen">⛶</button>
|
<button id="fsBtn" class="ctrl" title="Fullscreen">⛶</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="sleepStatus" class="sleep-status hidden">
|
||||||
|
<span id="sleepCountdown" class="sleep-countdown"></span>
|
||||||
|
<button id="sleepCancelBtn" class="sleep-cancel">✕ Cancel</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="nowPlayingMeta" class="now-meta hidden">
|
<div id="nowPlayingMeta" class="now-meta hidden">
|
||||||
|
<div class="np-text">
|
||||||
<div class="np-title" id="npTitle"></div>
|
<div class="np-title" id="npTitle"></div>
|
||||||
<div class="np-channel" id="npChannel"></div>
|
<div class="np-channel" id="npChannel"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="np-actions">
|
||||||
|
<button id="queueBtn" class="np-btn" title="Add to the temporary queue">+ Queue</button>
|
||||||
|
<button id="saveBtn" class="np-btn" title="Save this video for offline playback">⬇ Save</button>
|
||||||
|
<button id="editBtn" class="np-btn" title="Cut parts out and save a custom edited copy">✂ Edit & download</button>
|
||||||
|
<button id="addPlaylistBtn" class="np-btn" title="Add to a playlist">+ Playlist</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Up next queue -->
|
||||||
|
<div id="upnext" class="upnext hidden">
|
||||||
|
<div class="upnext-header">
|
||||||
|
<span>Up next</span>
|
||||||
|
<span id="upnextCount" class="upnext-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div id="upnextList" class="upnext-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Related videos -->
|
||||||
|
<div id="relatedPanel" class="related-panel hidden">
|
||||||
|
<div class="related-header">
|
||||||
|
<span class="related-label">Related</span>
|
||||||
|
<button id="relatedToggleBtn" class="related-toggle-btn">−</button>
|
||||||
|
</div>
|
||||||
|
<div id="relatedList" class="related-list"></div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- List pane -->
|
<!-- List pane -->
|
||||||
@@ -124,6 +211,16 @@
|
|||||||
<h2 id="listTitle">Search</h2>
|
<h2 id="listTitle">Search</h2>
|
||||||
<div id="listActions" class="list-actions"></div>
|
<div id="listActions" class="list-actions"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="listFilterBar" class="list-filter-bar hidden">
|
||||||
|
<input id="listFilterInput" type="text" placeholder="Filter…" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div id="batchBar" class="batch-bar hidden">
|
||||||
|
<span id="batchCount" class="batch-count">0 selected</span>
|
||||||
|
<button id="batchQueueBtn" class="batch-btn">+ Queue</button>
|
||||||
|
<button id="batchAddPlaylistBtn" class="batch-btn">+ Playlist</button>
|
||||||
|
<button id="batchDeleteBtn" class="batch-btn batch-danger">Delete</button>
|
||||||
|
<button id="batchCancelBtn" class="batch-btn">Cancel</button>
|
||||||
|
</div>
|
||||||
<div id="status" class="status hidden"></div>
|
<div id="status" class="status hidden"></div>
|
||||||
<div id="cards" class="cards"></div>
|
<div id="cards" class="cards"></div>
|
||||||
</section>
|
</section>
|
||||||
@@ -140,8 +237,82 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="toast" class="toast hidden"></div>
|
<div id="toastContainer" class="toast-container"></div>
|
||||||
|
|
||||||
|
<!-- Mini now-playing bar -->
|
||||||
|
<div id="miniBar" class="mini-bar hidden">
|
||||||
|
<div class="mini-bar-inner">
|
||||||
|
<button id="miniPlayBtn" class="mini-btn">▶</button>
|
||||||
|
<div class="mini-info">
|
||||||
|
<div id="miniTitle" class="mini-title"></div>
|
||||||
|
<div id="miniChannel" class="mini-channel"></div>
|
||||||
|
</div>
|
||||||
|
<span id="miniTime" class="mini-time">0:00</span>
|
||||||
|
<button id="miniCloseBtn" class="mini-btn mini-close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="miniProgress" class="mini-progress"><div id="miniProgressFill" class="mini-progress-fill"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- YouTube-style bottom navigation bar (portrait PWA mode only) -->
|
||||||
|
<nav id="bottomNav" class="bottom-nav" aria-label="Main navigation">
|
||||||
|
<button class="bottom-nav-btn active" data-view="search" aria-label="Search">
|
||||||
|
<span class="bottom-nav-icon">🔍</span>
|
||||||
|
<span class="bottom-nav-label">Search</span>
|
||||||
|
</button>
|
||||||
|
<button class="bottom-nav-btn" data-view="queue" aria-label="Queue">
|
||||||
|
<span class="bottom-nav-icon">▶</span>
|
||||||
|
<span class="bottom-nav-label">Queue</span>
|
||||||
|
<span id="bnQueueCount" class="bottom-nav-badge hidden">0</span>
|
||||||
|
</button>
|
||||||
|
<button class="bottom-nav-btn" data-view="history" aria-label="History">
|
||||||
|
<span class="bottom-nav-icon">🕘</span>
|
||||||
|
<span class="bottom-nav-label">History</span>
|
||||||
|
</button>
|
||||||
|
<button class="bottom-nav-btn" data-view="saved" aria-label="Saved">
|
||||||
|
<span class="bottom-nav-icon">💾</span>
|
||||||
|
<span class="bottom-nav-label">Saved</span>
|
||||||
|
</button>
|
||||||
|
<button class="bottom-nav-btn" data-view="downloads" aria-label="Downloads">
|
||||||
|
<span class="bottom-nav-icon">⬇</span>
|
||||||
|
<span class="bottom-nav-label">Downloads</span>
|
||||||
|
<span id="bnDlCount" class="bottom-nav-badge hidden">0</span>
|
||||||
|
</button>
|
||||||
|
<button class="bottom-nav-btn" data-view="settings" aria-label="Settings">
|
||||||
|
<span class="bottom-nav-icon">⚙</span>
|
||||||
|
<span class="bottom-nav-label">More</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Keyboard shortcuts help overlay -->
|
||||||
|
<div id="shortcutHelp" class="shortcut-overlay hidden">
|
||||||
|
<div class="shortcut-panel">
|
||||||
|
<div class="shortcut-header">
|
||||||
|
<h2>Keyboard Shortcuts</h2>
|
||||||
|
<button id="shortcutClose" class="shortcut-close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="shortcut-grid">
|
||||||
|
<div class="shortcut-row"><kbd>Space</kbd><span>Play / Pause</span></div>
|
||||||
|
<div class="shortcut-row"><kbd>←</kbd><kbd>→</kbd><span>Seek backward / forward 5s</span></div>
|
||||||
|
<div class="shortcut-row"><kbd>↑</kbd><kbd>↓</kbd><span>Volume up / down</span></div>
|
||||||
|
<div class="shortcut-row"><kbd>F</kbd><span>Toggle fullscreen</span></div>
|
||||||
|
<div class="shortcut-row"><kbd>M</kbd><span>Toggle mute</span></div>
|
||||||
|
<div class="shortcut-row"><kbd>S</kbd><span>Shuffle upcoming tracks</span></div>
|
||||||
|
<div class="shortcut-row"><kbd>L</kbd><span>Loop current video</span></div>
|
||||||
|
<div class="shortcut-row"><kbd>R</kbd><span>Repeat list when finished</span></div>
|
||||||
|
<div class="shortcut-row"><kbd>Q</kbd><span>Add current video to queue</span></div>
|
||||||
|
<div class="shortcut-row"><kbd>A</kbd><span>Set A-B loop start point</span></div>
|
||||||
|
<div class="shortcut-row"><kbd>B</kbd><span>Set A-B loop end point</span></div>
|
||||||
|
<div class="shortcut-row"><kbd>?</kbd><span>Show this help</span></div>
|
||||||
|
<div class="shortcut-row"><kbd>Esc</kbd><span>Close overlay / cancel selection</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="fingerprint.js"></script>
|
||||||
|
<script src="opfs.js"></script>
|
||||||
|
<script src="video-edit.js"></script>
|
||||||
|
<script src="async-guard.js"></script>
|
||||||
|
<script src="sw-update.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
41
frontend/manifest.webmanifest
Normal file
41
frontend/manifest.webmanifest
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "YT Player",
|
||||||
|
"short_name": "YTPlayer",
|
||||||
|
"description": "Ad-free YouTube player — search, play, and keep playlists offline. No login, no tracking.",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"theme_color": "#1a1311",
|
||||||
|
"background_color": "#1a1311",
|
||||||
|
"categories": ["music", "entertainment"],
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"screenshots": [],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Search",
|
||||||
|
"short_name": "Search",
|
||||||
|
"url": "/?view=search",
|
||||||
|
"description": "Search YouTube"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "History",
|
||||||
|
"short_name": "History",
|
||||||
|
"url": "/?view=history",
|
||||||
|
"description": "Recently watched videos"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
114
frontend/opfs-worker.js
Normal file
114
frontend/opfs-worker.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
* opfs-worker.js — off-main-thread video download → OPFS
|
||||||
|
*
|
||||||
|
* One dedicated Worker per download (spawned by OPFS.downloadVideo in
|
||||||
|
* opfs.js, terminated when finished). The worker does the whole job itself —
|
||||||
|
* fetch from /api/download plus streaming writes via createSyncAccessHandle —
|
||||||
|
* so a multi-hundred-MB save never allocates buffers or runs stream pumps on
|
||||||
|
* the main thread. createSyncAccessHandle is worker-only but has wider
|
||||||
|
* support than createWritable (Safari 15.2+ vs 18.2+), which also removes
|
||||||
|
* the whole-file ArrayBuffer fallback the main-thread path needs on WebKit.
|
||||||
|
*
|
||||||
|
* In message: { videoId, url }
|
||||||
|
* Out messages:
|
||||||
|
* { type: 'unsupported' } → caller falls back to main thread
|
||||||
|
* { type: 'progress', received } → bytes written so far
|
||||||
|
* { type: 'done', ext } → file stored as <videoId>.<ext>
|
||||||
|
* { type: 'error', error } → failed; .part cleaned up
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
async function getVideosDir() {
|
||||||
|
const root = await navigator.storage.getDirectory();
|
||||||
|
return root.getDirectoryHandle('videos', { create: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function extFromContentType(ct) {
|
||||||
|
ct = ct || 'video/mp4';
|
||||||
|
return ct.includes('webm') ? 'webm' : ct.includes('ogg') ? 'ogg' : 'mp4';
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onmessage = async (e) => {
|
||||||
|
const { videoId, url } = e.data || {};
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof navigator === 'undefined' ||
|
||||||
|
!navigator.storage ||
|
||||||
|
typeof navigator.storage.getDirectory !== 'function' ||
|
||||||
|
typeof FileSystemFileHandle === 'undefined' ||
|
||||||
|
typeof FileSystemFileHandle.prototype.createSyncAccessHandle !== 'function'
|
||||||
|
) {
|
||||||
|
self.postMessage({ type: 'unsupported' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = null;
|
||||||
|
let partName = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = 'HTTP ' + res.status;
|
||||||
|
try { msg = (await res.json()).error || msg; } catch { /* non-JSON */ }
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = extFromContentType(res.headers.get('content-type'));
|
||||||
|
const filename = videoId + '.' + ext;
|
||||||
|
partName = filename + '.part';
|
||||||
|
|
||||||
|
dir = await getVideosDir();
|
||||||
|
const partHandle = await dir.getFileHandle(partName, { create: true });
|
||||||
|
const access = await partHandle.createSyncAccessHandle();
|
||||||
|
let offset = 0;
|
||||||
|
try {
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
access.write(value, { at: offset });
|
||||||
|
offset += value.byteLength;
|
||||||
|
self.postMessage({ type: 'progress', received: offset });
|
||||||
|
}
|
||||||
|
access.truncate(offset);
|
||||||
|
access.flush();
|
||||||
|
} finally {
|
||||||
|
access.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize: .part → permanent name. Prefer the native rename, but treat
|
||||||
|
// ANY move() failure as "unavailable" and fall back to a chunked copy —
|
||||||
|
// WebKit's move() has a different signature/behavior than Chrome's and
|
||||||
|
// throws TypeError ("Not enough arguments") rather than being absent.
|
||||||
|
try { await dir.removeEntry(filename); } catch { /* no previous copy */ }
|
||||||
|
let renamed = false;
|
||||||
|
if (typeof partHandle.move === 'function') {
|
||||||
|
try { await partHandle.move(filename); renamed = true; } catch { /* copy below */ }
|
||||||
|
}
|
||||||
|
if (!renamed) {
|
||||||
|
const finalHandle = await dir.getFileHandle(filename, { create: true });
|
||||||
|
const out = await finalHandle.createSyncAccessHandle();
|
||||||
|
try {
|
||||||
|
const file = await partHandle.getFile();
|
||||||
|
const CHUNK = 8 * 1024 * 1024;
|
||||||
|
let pos = 0;
|
||||||
|
while (pos < file.size) {
|
||||||
|
const buf = await file.slice(pos, pos + CHUNK).arrayBuffer();
|
||||||
|
out.write(new Uint8Array(buf), { at: pos });
|
||||||
|
pos += buf.byteLength;
|
||||||
|
}
|
||||||
|
out.truncate(file.size);
|
||||||
|
out.flush();
|
||||||
|
} finally {
|
||||||
|
out.close();
|
||||||
|
}
|
||||||
|
await dir.removeEntry(partName);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.postMessage({ type: 'done', ext });
|
||||||
|
} catch (err) {
|
||||||
|
// Never leave a corrupt partial behind
|
||||||
|
try { if (dir && partName) await dir.removeEntry(partName); } catch { /* gone */ }
|
||||||
|
self.postMessage({ type: 'error', error: err && err.message ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
};
|
||||||
244
frontend/opfs.js
Normal file
244
frontend/opfs.js
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
* opfs.js — Origin Private File System video storage for PWA mode
|
||||||
|
*
|
||||||
|
* Exposes window.OPFS with methods that mirror the Tauri cache_* commands
|
||||||
|
* so app.js can call them transparently in WEB mode.
|
||||||
|
*
|
||||||
|
* Videos are stored under the OPFS root at:
|
||||||
|
* videos/<videoId>.<ext>
|
||||||
|
*
|
||||||
|
* Object URLs created by getFileUrl() are tracked so they can be revoked
|
||||||
|
* when no longer needed — call OPFS.revokeUrl(url) after the <video> unloads.
|
||||||
|
*
|
||||||
|
* OPFS is available in all modern browsers (Chrome 86+, Firefox 111+,
|
||||||
|
* Safari 15.2+). Calls degrade gracefully if the API is absent.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Root directory handle, lazily initialised
|
||||||
|
let _rootPromise = null;
|
||||||
|
|
||||||
|
async function getRoot() {
|
||||||
|
if (!_rootPromise) {
|
||||||
|
_rootPromise = (async () => {
|
||||||
|
const root = await navigator.storage.getDirectory();
|
||||||
|
return root.getDirectoryHandle('videos', { create: true });
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return _rootPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate the videos/ directory and find a file whose stem matches videoId
|
||||||
|
// (ignoring the extension). Returns [FileSystemFileHandle, filename] or null.
|
||||||
|
async function findHandle(videoId) {
|
||||||
|
const dir = await getRoot();
|
||||||
|
for await (const [name, handle] of dir.entries()) {
|
||||||
|
if (handle.kind !== 'file') continue;
|
||||||
|
const dot = name.lastIndexOf('.');
|
||||||
|
const stem = dot > -1 ? name.slice(0, dot) : name;
|
||||||
|
if (stem === videoId) return [handle, name];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extension → MIME type for Content-Type headers on playback
|
||||||
|
function extToMime(ext) {
|
||||||
|
const map = { mp4: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska',
|
||||||
|
m4a: 'audio/mp4', ogg: 'audio/ogg', opus: 'audio/ogg' };
|
||||||
|
return map[ext] || 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
const _blobUrls = new Set();
|
||||||
|
|
||||||
|
window.OPFS = {
|
||||||
|
// Does a file exist for this videoId?
|
||||||
|
async hasVideo(videoId) {
|
||||||
|
try {
|
||||||
|
return (await findHandle(videoId)) !== null;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Return a blob: URL usable as <video src>. Returns null if not cached.
|
||||||
|
// The caller is responsible for calling OPFS.revokeUrl(url) when done.
|
||||||
|
async getFileUrl(videoId) {
|
||||||
|
try {
|
||||||
|
const found = await findHandle(videoId);
|
||||||
|
if (!found) return null;
|
||||||
|
const [handle, name] = found;
|
||||||
|
const file = await handle.getFile();
|
||||||
|
const ext = name.slice(name.lastIndexOf('.') + 1);
|
||||||
|
// Wrap the File (a lazy disk-backed Blob) instead of buffering it:
|
||||||
|
// `await file.arrayBuffer()` here pulled the ENTIRE video into main-
|
||||||
|
// thread memory just to retype it, which froze the UI and stuttered
|
||||||
|
// audio on phones. Blob parts reference the File without reading it.
|
||||||
|
const blob = new Blob([file], { type: extToMime(ext) });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
_blobUrls.add(url);
|
||||||
|
return url;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Revoke an object URL previously returned by getFileUrl().
|
||||||
|
revokeUrl(url) {
|
||||||
|
if (url && _blobUrls.has(url)) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
_blobUrls.delete(url);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Download a video entirely inside a dedicated Web Worker — the fetch and
|
||||||
|
// the OPFS writes both happen off the main thread, so saves can never
|
||||||
|
// jank the UI. One worker per download; concurrent saves get concurrent
|
||||||
|
// workers. Resolves { ok:true } on success, { ok:false, error } on a real
|
||||||
|
// failure, or { ok:false, fallback:true } when the worker path is
|
||||||
|
// unavailable and the caller should use writeFromResponse instead.
|
||||||
|
downloadVideo(videoId, url) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let worker;
|
||||||
|
try {
|
||||||
|
worker = new Worker('/opfs-worker.js');
|
||||||
|
} catch {
|
||||||
|
resolve({ ok: false, fallback: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const finish = (result) => {
|
||||||
|
worker.terminate();
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
worker.onmessage = (e) => {
|
||||||
|
const m = e.data || {};
|
||||||
|
if (m.type === 'done') finish({ ok: true });
|
||||||
|
else if (m.type === 'unsupported') finish({ ok: false, fallback: true });
|
||||||
|
else if (m.type === 'error') finish({ ok: false, error: m.error });
|
||||||
|
// 'progress' messages are informational; ignored here
|
||||||
|
};
|
||||||
|
worker.onerror = () => finish({ ok: false, fallback: true });
|
||||||
|
worker.postMessage({ videoId, url });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Stream a fetch Response body into OPFS. Uses a writable stream so only
|
||||||
|
// a small chunk lives in memory at a time (no full-file buffering).
|
||||||
|
// Falls back to ArrayBuffer if WritableStream is unavailable.
|
||||||
|
// Main-thread fallback for downloadVideo — used when Workers or
|
||||||
|
// createSyncAccessHandle are unavailable.
|
||||||
|
async writeFromResponse(videoId, ext, response) {
|
||||||
|
const dir = await getRoot();
|
||||||
|
const filename = videoId + '.' + (ext || 'mp4');
|
||||||
|
|
||||||
|
// Write to a temporary file first so a partial download doesn't leave
|
||||||
|
// a corrupt permanent entry.
|
||||||
|
const tmpName = filename + '.part';
|
||||||
|
const tmpHandle = await dir.getFileHandle(tmpName, { create: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ('createWritable' in tmpHandle) {
|
||||||
|
const writable = await tmpHandle.createWritable();
|
||||||
|
try {
|
||||||
|
if (response.body && typeof response.body.pipeTo === 'function') {
|
||||||
|
await response.body.pipeTo(writable);
|
||||||
|
} else {
|
||||||
|
// Safari < 16.4 doesn't support pipeTo — buffer the whole response
|
||||||
|
const buf = await response.arrayBuffer();
|
||||||
|
await writable.write(buf);
|
||||||
|
await writable.close();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await writable.abort();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No createWritable on this browser — main-thread OPFS writes are
|
||||||
|
// impossible (sync access handles are worker-only). Surface a real
|
||||||
|
// error instead of the old branch that called the missing API.
|
||||||
|
throw new Error('Offline saving is not supported in this browser');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename tmp → final. Prefer the native rename; else stream-copy so
|
||||||
|
// the whole file is never buffered in main-thread memory at once.
|
||||||
|
// move() failures (WebKit's signature differs from Chrome's and
|
||||||
|
// throws TypeError) fall back to the copy path too.
|
||||||
|
let renamed = false;
|
||||||
|
if (typeof tmpHandle.move === 'function') {
|
||||||
|
try { await tmpHandle.move(filename); renamed = true; } catch { /* copy below */ }
|
||||||
|
}
|
||||||
|
if (!renamed) {
|
||||||
|
const finalHandle = await dir.getFileHandle(filename, { create: true });
|
||||||
|
const finalWritable = await finalHandle.createWritable();
|
||||||
|
const tmpFile = await tmpHandle.getFile();
|
||||||
|
const tmpStream = typeof tmpFile.stream === 'function' ? tmpFile.stream() : null;
|
||||||
|
if (tmpStream && typeof tmpStream.pipeTo === 'function') {
|
||||||
|
await tmpStream.pipeTo(finalWritable); // pipeTo closes the writable
|
||||||
|
} else {
|
||||||
|
await finalWritable.write(await tmpFile.arrayBuffer());
|
||||||
|
await finalWritable.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Remove .part file regardless
|
||||||
|
try { await dir.removeEntry(tmpName); } catch { /* already gone */ }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// List all cached videos — returns [{ id, size, name }]
|
||||||
|
async listVideos() {
|
||||||
|
const dir = await getRoot();
|
||||||
|
const items = [];
|
||||||
|
try {
|
||||||
|
for await (const [name, handle] of dir.entries()) {
|
||||||
|
if (handle.kind !== 'file') continue;
|
||||||
|
// Skip .part temporary files
|
||||||
|
if (name.endsWith('.part')) continue;
|
||||||
|
const dot = name.lastIndexOf('.');
|
||||||
|
const id = dot > -1 ? name.slice(0, dot) : name;
|
||||||
|
const file = await handle.getFile();
|
||||||
|
items.push({ id, name, size: file.size });
|
||||||
|
}
|
||||||
|
} catch { /* OPFS not available */ }
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Total bytes stored
|
||||||
|
async totalSize() {
|
||||||
|
const items = await this.listVideos();
|
||||||
|
return items.reduce((s, i) => s + i.size, 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete one video
|
||||||
|
async deleteVideo(videoId) {
|
||||||
|
try {
|
||||||
|
const found = await findHandle(videoId);
|
||||||
|
if (!found) return;
|
||||||
|
const [, name] = found;
|
||||||
|
const dir = await getRoot();
|
||||||
|
await dir.removeEntry(name);
|
||||||
|
} catch { /* already gone */ }
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete all cached videos
|
||||||
|
async clearAll() {
|
||||||
|
try {
|
||||||
|
const dir = await getRoot();
|
||||||
|
const names = [];
|
||||||
|
for await (const [name] of dir.entries()) names.push(name);
|
||||||
|
await Promise.all(names.map((n) => dir.removeEntry(n).catch(() => {})));
|
||||||
|
// Revoke any outstanding blob URLs
|
||||||
|
for (const url of _blobUrls) URL.revokeObjectURL(url);
|
||||||
|
_blobUrls.clear();
|
||||||
|
} catch { /* OPFS not available */ }
|
||||||
|
},
|
||||||
|
|
||||||
|
// Is the OPFS API supported in this browser?
|
||||||
|
isSupported() {
|
||||||
|
return typeof navigator !== 'undefined' &&
|
||||||
|
typeof navigator.storage !== 'undefined' &&
|
||||||
|
typeof navigator.storage.getDirectory === 'function';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}());
|
||||||
1701
frontend/styles.css
Normal file → Executable file
1701
frontend/styles.css
Normal file → Executable file
File diff suppressed because it is too large
Load Diff
101
frontend/sw-update.js
Normal file
101
frontend/sw-update.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
* sw-update — applies a waiting service-worker update in place.
|
||||||
|
*
|
||||||
|
* Framework-free and dependency-free on purpose (same pattern as
|
||||||
|
* async-guard.js):
|
||||||
|
* • Loads as a plain <script> under CSP `script-src 'self'` (browser
|
||||||
|
* global `window.SwUpdate`).
|
||||||
|
* • `require`-able by `node --test` (CommonJS `module.exports`).
|
||||||
|
*
|
||||||
|
* Bug this fixes (Task #61): "Update ready" kept reappearing right after the
|
||||||
|
* user clicked "Reload now" / "Refresh UI". The old flow called
|
||||||
|
* hardReloadUI(), which unregisters the service worker and wipes every cache
|
||||||
|
* before navigating — forcing a brand-new install on the next load. That
|
||||||
|
* fresh install briefly has `reg.waiting` truthy again (a normal but
|
||||||
|
* transient SW lifecycle state), which registerServiceWorker() misread as a
|
||||||
|
* genuinely new update and re-showed the banner immediately.
|
||||||
|
*
|
||||||
|
* Fix: activate the *already-installed* waiting worker in place —
|
||||||
|
* postMessage SKIP_WAITING to it, wait for it to actually take control
|
||||||
|
* (`controllerchange`), and only then reload. The reloaded page is served by
|
||||||
|
* the new SW from its very first request, and no fresh install/registration
|
||||||
|
* cycle happens, so the banner has nothing to spuriously re-trigger on.
|
||||||
|
* ========================================================================== */
|
||||||
|
(function (root) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a pending SW update: messages the waiting worker to skipWaiting(),
|
||||||
|
* waits for controllerchange, then reloads exactly once.
|
||||||
|
*
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {ServiceWorkerRegistration|null} opts.reg the current registration
|
||||||
|
* @param {ServiceWorkerContainer} opts.container navigator.serviceWorker
|
||||||
|
* @param {() => void} opts.reload called at most once
|
||||||
|
* @param {(fn: () => void, ms: number) => any} [opts.setTimeout] injectable for tests
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function applyUpdate({ reg, container, reload, setTimeout: setTimeoutFn }) {
|
||||||
|
const scheduleTimeout = setTimeoutFn || (typeof setTimeout !== 'undefined' ? setTimeout : null);
|
||||||
|
let waiting = reg && reg.waiting;
|
||||||
|
|
||||||
|
if (!waiting && reg && typeof reg.update === 'function') {
|
||||||
|
// The banner can be triggered by the server buildTag poll before the
|
||||||
|
// browser has fetched the new sw.js at all. With no waiting worker, a
|
||||||
|
// bare reload would be served the OLD cache-first shell, the new SW
|
||||||
|
// would then install in the background, and the banner would reappear
|
||||||
|
// — the "update available keeps showing" loop. Fetch the update now
|
||||||
|
// and wait (bounded) for it to reach `installed` so a single click
|
||||||
|
// activates the new version.
|
||||||
|
try { await reg.update(); } catch { /* offline / fetch failed — fall through */ }
|
||||||
|
waiting = reg.waiting || (await waitForInstalled(reg, scheduleTimeout, 8000));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!waiting) {
|
||||||
|
// Nothing to activate (e.g. banner was shown from a broadcast message
|
||||||
|
// rather than an actual waiting worker) — just reload.
|
||||||
|
reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let reloaded = false;
|
||||||
|
const reloadOnce = () => {
|
||||||
|
if (reloaded) return;
|
||||||
|
reloaded = true;
|
||||||
|
reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener('controllerchange', reloadOnce, { once: true });
|
||||||
|
// Safety net in case controllerchange never fires (e.g. no controller yet).
|
||||||
|
if (scheduleTimeout) scheduleTimeout(reloadOnce, 3000);
|
||||||
|
|
||||||
|
waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits for reg.installing to reach the `installed` state (at which point
|
||||||
|
* it becomes reg.waiting), bounded by a timeout. Resolves with the waiting
|
||||||
|
* worker or null.
|
||||||
|
*/
|
||||||
|
function waitForInstalled(reg, scheduleTimeout, ms) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const sw = reg.installing;
|
||||||
|
if (!sw || typeof sw.addEventListener !== 'function') { resolve(null); return; }
|
||||||
|
let settled = false;
|
||||||
|
const settle = (v) => { if (!settled) { settled = true; resolve(v); } };
|
||||||
|
sw.addEventListener('statechange', () => {
|
||||||
|
if (sw.state === 'installed') settle(reg.waiting || sw);
|
||||||
|
else if (sw.state === 'redundant') settle(null);
|
||||||
|
});
|
||||||
|
if (scheduleTimeout) scheduleTimeout(() => settle(reg.waiting || null), ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const SwUpdate = { applyUpdate };
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = SwUpdate;
|
||||||
|
} else {
|
||||||
|
root.SwUpdate = SwUpdate;
|
||||||
|
}
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||||
200
frontend/sw-update.test.js
Normal file
200
frontend/sw-update.test.js
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for sw-update.js (Task #61).
|
||||||
|
*
|
||||||
|
* Bug: "Update ready / Reload now" reappeared immediately after the user
|
||||||
|
* clicked "Reload now". Root cause — the old flow unregistered the service
|
||||||
|
* worker and wiped every cache before navigating, forcing a brand-new
|
||||||
|
* install on the next load; that fresh install's registration briefly has
|
||||||
|
* `reg.waiting` truthy again (a normal but transient SW lifecycle state),
|
||||||
|
* which was misread as a new pending update and re-showed the banner.
|
||||||
|
*
|
||||||
|
* These tests drive applyUpdate() directly against mocked registration /
|
||||||
|
* container / reload objects — no real service worker or browser needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { applyUpdate } = require('./sw-update');
|
||||||
|
|
||||||
|
// A minimal fake ServiceWorkerContainer supporting addEventListener/once.
|
||||||
|
function fakeContainer() {
|
||||||
|
const listeners = {};
|
||||||
|
return {
|
||||||
|
addEventListener(type, fn, opts) {
|
||||||
|
(listeners[type] = listeners[type] || []).push({ fn, once: !!(opts && opts.once) });
|
||||||
|
},
|
||||||
|
fireControllerChange() {
|
||||||
|
const fns = (listeners.controllerchange || []).slice();
|
||||||
|
for (const { fn, once } of fns) {
|
||||||
|
fn();
|
||||||
|
if (once) {
|
||||||
|
listeners.controllerchange = listeners.controllerchange.filter((l) => l.fn !== fn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('messages the waiting worker to skipWaiting and reloads only after controllerchange', async () => {
|
||||||
|
const messages = [];
|
||||||
|
const waiting = { postMessage: (m) => messages.push(m) };
|
||||||
|
const container = fakeContainer();
|
||||||
|
let reloadCount = 0;
|
||||||
|
|
||||||
|
const done = applyUpdate({
|
||||||
|
reg: { waiting },
|
||||||
|
container,
|
||||||
|
reload: () => { reloadCount++; },
|
||||||
|
setTimeout: () => {}, // no-op — we drive controllerchange manually
|
||||||
|
});
|
||||||
|
|
||||||
|
// SKIP_WAITING should be sent immediately, before any reload.
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.deepStrictEqual(messages, [{ type: 'SKIP_WAITING' }]);
|
||||||
|
assert.strictEqual(reloadCount, 0, 'must not reload before the new SW has taken control');
|
||||||
|
|
||||||
|
container.fireControllerChange();
|
||||||
|
await done;
|
||||||
|
assert.strictEqual(reloadCount, 1, 'reloads exactly once after controllerchange');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reloads only once even if controllerchange fires more than once (no reload loop)', async () => {
|
||||||
|
const waiting = { postMessage: () => {} };
|
||||||
|
const container = fakeContainer();
|
||||||
|
let reloadCount = 0;
|
||||||
|
|
||||||
|
const done = applyUpdate({
|
||||||
|
reg: { waiting },
|
||||||
|
container,
|
||||||
|
reload: () => { reloadCount++; },
|
||||||
|
setTimeout: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
container.fireControllerChange();
|
||||||
|
container.fireControllerChange(); // simulate a spurious second event
|
||||||
|
await done;
|
||||||
|
|
||||||
|
assert.strictEqual(reloadCount, 1, 'reload must be idempotent — no loop');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to a plain reload when there is no waiting worker', async () => {
|
||||||
|
const container = fakeContainer();
|
||||||
|
let reloadCount = 0;
|
||||||
|
|
||||||
|
await applyUpdate({
|
||||||
|
reg: { waiting: null },
|
||||||
|
container,
|
||||||
|
reload: () => { reloadCount++; },
|
||||||
|
setTimeout: () => { throw new Error('timeout should not be scheduled without a waiting worker'); },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(reloadCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with no waiting worker, fetches the SW update and activates the newly installed worker (buildTag-poll path)', async () => {
|
||||||
|
// Simulates: server redeployed (banner shown by the /api/version poll) but
|
||||||
|
// the browser hasn't fetched the new sw.js yet — reg.waiting is null until
|
||||||
|
// reg.update() is called and the new worker finishes installing.
|
||||||
|
const messages = [];
|
||||||
|
const container = fakeContainer();
|
||||||
|
let reloadCount = 0;
|
||||||
|
|
||||||
|
const stateListeners = [];
|
||||||
|
const installing = {
|
||||||
|
state: 'installing',
|
||||||
|
addEventListener: (type, fn) => { if (type === 'statechange') stateListeners.push(fn); },
|
||||||
|
postMessage: (m) => messages.push(m),
|
||||||
|
};
|
||||||
|
const reg = {
|
||||||
|
waiting: null,
|
||||||
|
installing: null,
|
||||||
|
update() {
|
||||||
|
// Browser found a byte-different sw.js → a new worker starts installing.
|
||||||
|
this.installing = installing;
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const done = applyUpdate({
|
||||||
|
reg,
|
||||||
|
container,
|
||||||
|
reload: () => { reloadCount++; },
|
||||||
|
setTimeout: () => {}, // no-op — we drive state transitions manually
|
||||||
|
});
|
||||||
|
|
||||||
|
// Let applyUpdate reach the waitForInstalled stage, then finish the install.
|
||||||
|
await Promise.resolve(); await Promise.resolve();
|
||||||
|
installing.state = 'installed';
|
||||||
|
reg.waiting = installing;
|
||||||
|
stateListeners.forEach((fn) => fn());
|
||||||
|
await Promise.resolve(); await Promise.resolve();
|
||||||
|
|
||||||
|
assert.deepStrictEqual(messages, [{ type: 'SKIP_WAITING' }], 'skip-waiting sent to the freshly installed worker');
|
||||||
|
assert.strictEqual(reloadCount, 0, 'must not reload before the new SW takes control');
|
||||||
|
|
||||||
|
container.fireControllerChange();
|
||||||
|
await done;
|
||||||
|
assert.strictEqual(reloadCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with no waiting worker and no update found, reloads once after the bounded wait', async () => {
|
||||||
|
const container = fakeContainer();
|
||||||
|
let reloadCount = 0;
|
||||||
|
const timeouts = [];
|
||||||
|
|
||||||
|
const reg = {
|
||||||
|
waiting: null,
|
||||||
|
installing: null,
|
||||||
|
update: () => Promise.resolve(), // update check ran; nothing new
|
||||||
|
};
|
||||||
|
|
||||||
|
const done = applyUpdate({
|
||||||
|
reg,
|
||||||
|
container,
|
||||||
|
reload: () => { reloadCount++; },
|
||||||
|
setTimeout: (fn) => { timeouts.push(fn); },
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.resolve(); await Promise.resolve();
|
||||||
|
// reg.installing is null → waitForInstalled resolves immediately with null.
|
||||||
|
await done;
|
||||||
|
assert.strictEqual(reloadCount, 1, 'plain reload when the update check finds nothing');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to a plain reload when there is no registration at all', async () => {
|
||||||
|
const container = fakeContainer();
|
||||||
|
let reloadCount = 0;
|
||||||
|
|
||||||
|
await applyUpdate({
|
||||||
|
reg: null,
|
||||||
|
container,
|
||||||
|
reload: () => { reloadCount++; },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(reloadCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the timeout safety net reloads once if controllerchange never fires', async () => {
|
||||||
|
const waiting = { postMessage: () => {} };
|
||||||
|
const container = fakeContainer();
|
||||||
|
let reloadCount = 0;
|
||||||
|
let scheduledFn = null;
|
||||||
|
|
||||||
|
await applyUpdate({
|
||||||
|
reg: { waiting },
|
||||||
|
container,
|
||||||
|
reload: () => { reloadCount++; },
|
||||||
|
setTimeout: (fn) => { scheduledFn = fn; }, // capture instead of real timer
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(reloadCount, 0, 'not reloaded yet — timeout not fired');
|
||||||
|
scheduledFn(); // simulate the timeout elapsing
|
||||||
|
assert.strictEqual(reloadCount, 1);
|
||||||
|
|
||||||
|
// A late controllerchange after the timeout already reloaded must not
|
||||||
|
// trigger a second reload.
|
||||||
|
container.fireControllerChange();
|
||||||
|
assert.strictEqual(reloadCount, 1, 'no double reload once the timeout fallback has fired');
|
||||||
|
});
|
||||||
174
frontend/sw.js
Normal file
174
frontend/sw.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
* sw.js — YT Player Service Worker
|
||||||
|
*
|
||||||
|
* Strategy:
|
||||||
|
* App shell (HTML/CSS/JS) → cache-first, versioned cache
|
||||||
|
* /api/* requests → network-only (never cache yt-dlp results)
|
||||||
|
* /api/download/* → network-only (streamed binary, never cache)
|
||||||
|
* YouTube thumbnails (i.ytimg.com) → stale-while-revalidate
|
||||||
|
* Everything else → network, fallback to cache
|
||||||
|
*
|
||||||
|
* Auto-update flow:
|
||||||
|
* 1. New SW installs alongside the old one and waits (skipWaiting() is NOT
|
||||||
|
* called automatically — see the install handler below).
|
||||||
|
* 2. Client (app.js registerServiceWorker()) notices the waiting worker
|
||||||
|
* and shows the "Update ready" banner.
|
||||||
|
* 3. User clicks "Reload now" → client (applyUpdate() in sw-update.js)
|
||||||
|
* posts SKIP_WAITING to the waiting worker and waits for it to take
|
||||||
|
* control (controllerchange) before reloading — it does NOT unregister
|
||||||
|
* or wipe caches, so the reload doesn't force a brand-new install.
|
||||||
|
* 4. activate: if an older *versioned shell cache* is found (i.e. this
|
||||||
|
* activation is genuinely replacing a previous deploy, not just the
|
||||||
|
* first-ever install of a freshly (re)registered worker), broadcast
|
||||||
|
* SW_UPDATE_AVAILABLE to all clients as a secondary/fallback signal.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
// BUILD_TAG is injected by the server at request time (GET /sw.js).
|
||||||
|
// It changes on every deploy/restart so the cache is busted automatically
|
||||||
|
// without any manual version bump.
|
||||||
|
const VERSION = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v1.0.4';
|
||||||
|
const CACHE = 'ytplayer-' + VERSION;
|
||||||
|
|
||||||
|
// Prefix shared by every versioned app-shell cache (ytplayer-<VERSION>).
|
||||||
|
// Utility caches (ytplayer-thumbs, ytplayer-fonts) intentionally do NOT
|
||||||
|
// match this — they aren't versioned and must survive every activate.
|
||||||
|
const SHELL_CACHE_PREFIX = 'ytplayer-';
|
||||||
|
const UTILITY_CACHES = new Set(['ytplayer-thumbs', 'ytplayer-fonts']);
|
||||||
|
function isVersionedShellCache(key) {
|
||||||
|
return key.startsWith(SHELL_CACHE_PREFIX) && !UTILITY_CACHES.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files that form the installable app shell.
|
||||||
|
const SHELL = [
|
||||||
|
'/',
|
||||||
|
'/index.html',
|
||||||
|
'/styles.css',
|
||||||
|
'/async-guard.js',
|
||||||
|
'/sw-update.js',
|
||||||
|
'/fingerprint.js',
|
||||||
|
'/opfs.js',
|
||||||
|
'/opfs-worker.js',
|
||||||
|
'/app.js',
|
||||||
|
'/manifest.webmanifest',
|
||||||
|
'/icons/icon-192.png',
|
||||||
|
'/icons/icon-512.png',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---- Install: pre-cache the app shell ----
|
||||||
|
self.addEventListener('install', (e) => {
|
||||||
|
e.waitUntil(
|
||||||
|
caches.open(CACHE).then((cache) => cache.addAll(SHELL))
|
||||||
|
// skipWaiting() is NOT called here — we wait for the client to confirm
|
||||||
|
// before activating, so the update banner can appear first.
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Activate: evict old caches, claim clients, notify about update ----
|
||||||
|
self.addEventListener('activate', (e) => {
|
||||||
|
e.waitUntil((async () => {
|
||||||
|
const keys = await caches.keys();
|
||||||
|
|
||||||
|
// Was there a *previous deploy's* app-shell cache lying around? If so,
|
||||||
|
// this activation is a genuine version bump — worth telling the client
|
||||||
|
// about. If the only versioned shell cache present is our own CACHE (or
|
||||||
|
// none at all), this is the first-ever activation of a freshly
|
||||||
|
// (re)registered worker — e.g. right after hardReloadUI() unregisters
|
||||||
|
// the old SW and hard-navigates — and there is nothing new to report.
|
||||||
|
// Without this check, that harmless re-install would re-broadcast
|
||||||
|
// SW_UPDATE_AVAILABLE and immediately reopen the "Update ready" banner
|
||||||
|
// the user just dismissed by clicking "Reload now".
|
||||||
|
const staleShellCaches = keys.filter((k) => isVersionedShellCache(k) && k !== CACHE);
|
||||||
|
const isGenuineUpdate = staleShellCaches.length > 0;
|
||||||
|
|
||||||
|
// Delete every stale *versioned shell* cache — never the utility caches
|
||||||
|
// (thumbs/fonts), which aren't tied to a deploy version and should
|
||||||
|
// survive every activate.
|
||||||
|
await Promise.all(staleShellCaches.map((k) => caches.delete(k)));
|
||||||
|
|
||||||
|
// Claim all open clients immediately (new installs)
|
||||||
|
await self.clients.claim();
|
||||||
|
|
||||||
|
if (isGenuineUpdate) {
|
||||||
|
// Broadcast to every open window so the app can show an update banner
|
||||||
|
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||||
|
all.forEach((c) => c.postMessage({ type: 'SW_UPDATE_AVAILABLE', version: VERSION }));
|
||||||
|
}
|
||||||
|
})());
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Fetch: routing logic ----
|
||||||
|
self.addEventListener('fetch', (e) => {
|
||||||
|
const { request } = e;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Only intercept GET/HEAD — let POST (sync endpoint) go through unmodified
|
||||||
|
if (request.method !== 'GET' && request.method !== 'HEAD') return;
|
||||||
|
|
||||||
|
// API calls and binary downloads → network only, no caching
|
||||||
|
if (url.pathname.startsWith('/api/')) {
|
||||||
|
e.respondWith(fetch(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// YouTube thumbnails → stale-while-revalidate (fast load, fresh in background)
|
||||||
|
if (url.hostname === 'i.ytimg.com') {
|
||||||
|
e.respondWith(staleWhileRevalidate(request, 'ytplayer-thumbs'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google Fonts CSS — stale-while-revalidate so offline doesn't break type
|
||||||
|
if (url.hostname === 'fonts.googleapis.com' || url.hostname === 'fonts.gstatic.com') {
|
||||||
|
e.respondWith(staleWhileRevalidate(request, 'ytplayer-fonts'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// App shell → cache-first, then network, then generic offline fallback
|
||||||
|
e.respondWith(cacheFirst(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Message: handle SKIP_WAITING from the client ----
|
||||||
|
self.addEventListener('message', (e) => {
|
||||||
|
if (e.data && e.data.type === 'SKIP_WAITING') {
|
||||||
|
self.skipWaiting();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Fetch helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Cache-first: serve from cache; if missing, fetch, cache, return.
|
||||||
|
async function cacheFirst(request) {
|
||||||
|
const cache = await caches.open(CACHE);
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
if (cached) return cached;
|
||||||
|
try {
|
||||||
|
const response = await fetch(request);
|
||||||
|
// Only cache successful, non-opaque responses
|
||||||
|
if (response && response.status === 200 && response.type !== 'opaque') {
|
||||||
|
cache.put(request, response.clone());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
// Network failed and nothing in cache — return a minimal offline page
|
||||||
|
// for navigation requests; let sub-resources fail naturally.
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
const nav = await cache.match('/index.html');
|
||||||
|
if (nav) return nav;
|
||||||
|
}
|
||||||
|
return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stale-while-revalidate: return cached immediately, update in background.
|
||||||
|
async function staleWhileRevalidate(request, cacheName) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
// Start a background revalidation — don't await it before responding
|
||||||
|
const networkFetch = fetch(request).then((r) => {
|
||||||
|
if (r && r.status === 200) cache.put(request, r.clone());
|
||||||
|
return r;
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
return cached || networkFetch;
|
||||||
|
}
|
||||||
117
frontend/sw.test.js
Normal file
117
frontend/sw.test.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for the service worker's activate handler (Task #55).
|
||||||
|
*
|
||||||
|
* Bug: "Update ready / Reload now" got stuck in a loop. Root cause —
|
||||||
|
* activate() unconditionally broadcast SW_UPDATE_AVAILABLE to every open
|
||||||
|
* client, even when this activation was just the first-ever install of a
|
||||||
|
* freshly (re)registered worker (e.g. right after the "Refresh UI" button's
|
||||||
|
* hardReloadUI() wipes every cache, unregisters the SW, and hard-navigates).
|
||||||
|
* That harmless re-install re-announced "update available" and immediately
|
||||||
|
* reopened the banner the user had just dismissed.
|
||||||
|
*
|
||||||
|
* sw.js runs in a ServiceWorkerGlobalScope, which Node doesn't provide, so
|
||||||
|
* these tests load the real source into a minimal vm sandbox that mocks
|
||||||
|
* `self`, `caches`, and `self.clients`, then drive the registered
|
||||||
|
* `activate` listener directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const SW_SOURCE = fs.readFileSync(path.join(__dirname, 'sw.js'), 'utf8');
|
||||||
|
|
||||||
|
// Builds a fresh sandboxed SW environment with the given starting cache keys
|
||||||
|
// and returns handles to drive/inspect it.
|
||||||
|
function loadSw(initialCacheKeys) {
|
||||||
|
const cacheStore = new Set(initialCacheKeys);
|
||||||
|
const deleted = [];
|
||||||
|
const clientMessages = [];
|
||||||
|
|
||||||
|
const listeners = {};
|
||||||
|
const self_ = {
|
||||||
|
addEventListener(type, fn) {
|
||||||
|
(listeners[type] = listeners[type] || []).push(fn);
|
||||||
|
},
|
||||||
|
clients: {
|
||||||
|
async claim() {},
|
||||||
|
async matchAll() {
|
||||||
|
return [{ postMessage: (msg) => clientMessages.push(msg) }];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caches_ = {
|
||||||
|
async keys() { return Array.from(cacheStore); },
|
||||||
|
async delete(key) { deleted.push(key); return cacheStore.delete(key); },
|
||||||
|
async open(key) {
|
||||||
|
cacheStore.add(key);
|
||||||
|
return { addAll: async () => {}, match: async () => undefined, put: async () => {} };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const sandbox = {
|
||||||
|
self: self_,
|
||||||
|
caches: caches_,
|
||||||
|
fetch: async () => { throw new Error('fetch not mocked'); },
|
||||||
|
Response: class { constructor(body, init) { this.body = body; Object.assign(this, init); } },
|
||||||
|
URL,
|
||||||
|
console,
|
||||||
|
};
|
||||||
|
vm.createContext(sandbox);
|
||||||
|
vm.runInContext(SW_SOURCE, sandbox, { filename: 'sw.js' });
|
||||||
|
|
||||||
|
async function triggerActivate() {
|
||||||
|
let waitPromise = Promise.resolve();
|
||||||
|
const event = { waitUntil: (p) => { waitPromise = p; } };
|
||||||
|
for (const fn of listeners.activate || []) fn(event);
|
||||||
|
await waitPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { triggerActivate, deleted, clientMessages, cacheStoreRemaining: () => Array.from(cacheStore) };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('activate does NOT broadcast on a fresh install (only the current-version cache exists)', async () => {
|
||||||
|
const sw = loadSw(['ytplayer-v1.0.4']); // default VERSION fallback in sw.js is 'v1.0.4'
|
||||||
|
await sw.triggerActivate();
|
||||||
|
|
||||||
|
assert.deepStrictEqual(sw.clientMessages, [], 'no update banner should be triggered on first-ever install');
|
||||||
|
assert.deepStrictEqual(sw.deleted, [], 'nothing stale to evict on a fresh install');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activate DOES broadcast when an older versioned shell cache is present (genuine update)', async () => {
|
||||||
|
const sw = loadSw(['ytplayer-v1.0.4', 'ytplayer-v1.0.2']);
|
||||||
|
await sw.triggerActivate();
|
||||||
|
|
||||||
|
assert.strictEqual(sw.clientMessages.length, 1, 'a genuine version bump should notify clients once');
|
||||||
|
assert.strictEqual(sw.clientMessages[0].type, 'SW_UPDATE_AVAILABLE');
|
||||||
|
assert.deepStrictEqual(sw.deleted, ['ytplayer-v1.0.2'], 'the stale shell cache should be evicted');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activate never deletes the utility caches (thumbs/fonts), broadcast or not', async () => {
|
||||||
|
const sw = loadSw(['ytplayer-v1.0.4', 'ytplayer-thumbs', 'ytplayer-fonts']);
|
||||||
|
await sw.triggerActivate();
|
||||||
|
|
||||||
|
assert.deepStrictEqual(sw.clientMessages, [], 'utility caches alone are not a version bump');
|
||||||
|
assert.deepStrictEqual(sw.deleted, [], 'utility caches must survive activate');
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
sw.cacheStoreRemaining().sort(),
|
||||||
|
['ytplayer-fonts', 'ytplayer-thumbs', 'ytplayer-v1.0.4'].sort()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activate reports an update and still preserves utility caches together', async () => {
|
||||||
|
const sw = loadSw(['ytplayer-v1.0.4', 'ytplayer-v1.0.2', 'ytplayer-thumbs', 'ytplayer-fonts']);
|
||||||
|
await sw.triggerActivate();
|
||||||
|
|
||||||
|
assert.strictEqual(sw.clientMessages.length, 1);
|
||||||
|
assert.deepStrictEqual(sw.deleted, ['ytplayer-v1.0.2']);
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
sw.cacheStoreRemaining().sort(),
|
||||||
|
['ytplayer-fonts', 'ytplayer-thumbs', 'ytplayer-v1.0.4'].sort()
|
||||||
|
);
|
||||||
|
});
|
||||||
161
frontend/video-edit.js
Normal file
161
frontend/video-edit.js
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
* video-edit.js — pure helpers for the "Edit & download" custom-video feature
|
||||||
|
*
|
||||||
|
* The video editor lets a user mark one or more CUT ranges (parts to delete)
|
||||||
|
* on a source video before saving it offline. Everything here is pure maths on
|
||||||
|
* {start,end} second ranges so it can be unit-tested with `node --test` and
|
||||||
|
* reused identically by the browser (window.VideoEdit) and, conceptually, by
|
||||||
|
* the server when it validates the same ?keep= parameter.
|
||||||
|
*
|
||||||
|
* Vocabulary:
|
||||||
|
* cut — a [start,end] span the user wants REMOVED from the final video.
|
||||||
|
* keep — a [start,end] span that SURVIVES into the final video. The keep
|
||||||
|
* list is the complement of the (merged, clamped) cut list over
|
||||||
|
* [0,duration].
|
||||||
|
*
|
||||||
|
* The wire format for the server is a compact string of keep segments:
|
||||||
|
* "12.5-40,95-130.2" → keep 12.5s‥40s and 95s‥130.2s, drop everything else.
|
||||||
|
* ========================================================================== */
|
||||||
|
(function (root) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Round to milliseconds so float noise from the <video> clock doesn't leak
|
||||||
|
// into filenames / ffmpeg args, while still being precise enough for frames.
|
||||||
|
function round3(n) { return Math.round(n * 1000) / 1000; }
|
||||||
|
|
||||||
|
// Parse "H:MM:SS(.mmm)", "MM:SS(.mmm)" or a bare seconds number to seconds.
|
||||||
|
// Returns null when the input can't be understood.
|
||||||
|
function parseTime(input) {
|
||||||
|
if (typeof input === 'number' && isFinite(input)) return input < 0 ? null : round3(input);
|
||||||
|
if (typeof input !== 'string') return null;
|
||||||
|
const s = input.trim();
|
||||||
|
if (!s) return null;
|
||||||
|
// Bare number of seconds (may be fractional).
|
||||||
|
if (/^\d+(\.\d+)?$/.test(s)) return round3(parseFloat(s));
|
||||||
|
// Colon-separated clock. 1-3 components (ss, mm:ss, hh:mm:ss).
|
||||||
|
const parts = s.split(':');
|
||||||
|
if (parts.length < 2 || parts.length > 3) return null;
|
||||||
|
let total = 0;
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const p = parts[i];
|
||||||
|
if (!/^\d+(\.\d+)?$/.test(p)) return null;
|
||||||
|
const val = parseFloat(p);
|
||||||
|
// Only the leading component may exceed 59.
|
||||||
|
if (i > 0 && val >= 60) return null;
|
||||||
|
total = total * 60 + val;
|
||||||
|
}
|
||||||
|
return round3(total);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format seconds → "M:SS" or "H:MM:SS", mirroring app.js fmtTime but kept
|
||||||
|
// local so this module has no dependencies. Fractions are dropped for
|
||||||
|
// display (labels), never for the maths.
|
||||||
|
function fmtTime(sec) {
|
||||||
|
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;
|
||||||
|
const mm = h ? String(m).padStart(2, '0') : String(m);
|
||||||
|
const ss = String(s).padStart(2, '0');
|
||||||
|
return (h ? h + ':' : '') + mm + ':' + ss;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalise a raw list of cut ranges: coerce to numbers, drop invalid /
|
||||||
|
// zero-length spans, clamp to [0,duration], sort, and merge overlaps so the
|
||||||
|
// downstream complement is clean. Never mutates the input.
|
||||||
|
function normalizeCuts(cuts, duration) {
|
||||||
|
const dur = isFinite(duration) && duration > 0 ? round3(duration) : Infinity;
|
||||||
|
const clean = [];
|
||||||
|
for (const c of cuts || []) {
|
||||||
|
if (!c) continue;
|
||||||
|
let a = Number(c.start);
|
||||||
|
let b = Number(c.end);
|
||||||
|
if (!isFinite(a) || !isFinite(b)) continue;
|
||||||
|
if (b < a) { const t = a; a = b; b = t; } // tolerate reversed input
|
||||||
|
a = round3(Math.max(0, a));
|
||||||
|
b = round3(Math.min(dur, b));
|
||||||
|
if (b - a <= 0) continue; // zero-length or fully out of range
|
||||||
|
clean.push({ start: a, end: b });
|
||||||
|
}
|
||||||
|
clean.sort((x, y) => x.start - y.start);
|
||||||
|
const merged = [];
|
||||||
|
for (const c of clean) {
|
||||||
|
const last = merged[merged.length - 1];
|
||||||
|
if (last && c.start <= last.end) {
|
||||||
|
last.end = Math.max(last.end, c.end);
|
||||||
|
} else {
|
||||||
|
merged.push({ start: c.start, end: c.end });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complement of the cut list over [0,duration] → the keep segments.
|
||||||
|
// With no cuts the whole video is kept. Requires a finite positive duration.
|
||||||
|
function invertCuts(cuts, duration) {
|
||||||
|
if (!isFinite(duration) || duration <= 0) return [];
|
||||||
|
const dur = round3(duration);
|
||||||
|
const merged = normalizeCuts(cuts, dur);
|
||||||
|
const keep = [];
|
||||||
|
let cursor = 0;
|
||||||
|
for (const c of merged) {
|
||||||
|
if (c.start > cursor) keep.push({ start: round3(cursor), end: round3(c.start) });
|
||||||
|
cursor = Math.max(cursor, c.end);
|
||||||
|
}
|
||||||
|
if (cursor < dur) keep.push({ start: round3(cursor), end: dur });
|
||||||
|
// Drop any degenerate zero-length keeps that rounding could produce.
|
||||||
|
return keep.filter((k) => k.end - k.start > 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total surviving duration for a keep list.
|
||||||
|
function keepDuration(keep) {
|
||||||
|
return round3((keep || []).reduce((s, k) => s + (k.end - k.start), 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialise keep segments to the compact wire string "s-e,s-e".
|
||||||
|
function keepToParam(keep) {
|
||||||
|
return (keep || []).map((k) => round3(k.start) + '-' + round3(k.end)).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the wire string back into keep segments. Invalid tokens are skipped;
|
||||||
|
// returns [] on empty/garbage input. Used by the server to validate ?keep=.
|
||||||
|
function parseKeepParam(str) {
|
||||||
|
if (typeof str !== 'string') return [];
|
||||||
|
const out = [];
|
||||||
|
for (const tok of str.split(',')) {
|
||||||
|
const t = tok.trim();
|
||||||
|
if (!t) continue;
|
||||||
|
const m = t.match(/^(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)$/);
|
||||||
|
if (!m) continue;
|
||||||
|
const a = parseFloat(m[1]);
|
||||||
|
const b = parseFloat(m[2]);
|
||||||
|
if (!isFinite(a) || !isFinite(b) || b <= a) continue;
|
||||||
|
out.push({ start: round3(a), end: round3(b) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when the cut list actually changes the video (i.e. there is at least
|
||||||
|
// one real cut inside [0,duration]). A no-op edit should just save normally.
|
||||||
|
function hasEdits(cuts, duration) {
|
||||||
|
return normalizeCuts(cuts, duration).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VideoEdit = {
|
||||||
|
round3,
|
||||||
|
parseTime,
|
||||||
|
fmtTime,
|
||||||
|
normalizeCuts,
|
||||||
|
invertCuts,
|
||||||
|
keepDuration,
|
||||||
|
keepToParam,
|
||||||
|
parseKeepParam,
|
||||||
|
hasEdits,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = VideoEdit;
|
||||||
|
} else {
|
||||||
|
root.VideoEdit = VideoEdit;
|
||||||
|
}
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||||
89
frontend/video-edit.test.js
Normal file
89
frontend/video-edit.test.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
'use strict';
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const VE = require('./video-edit');
|
||||||
|
|
||||||
|
test('parseTime: bare seconds, clock forms, fractions', () => {
|
||||||
|
assert.strictEqual(VE.parseTime('90'), 90);
|
||||||
|
assert.strictEqual(VE.parseTime('1:30'), 90);
|
||||||
|
assert.strictEqual(VE.parseTime('1:00:00'), 3600);
|
||||||
|
assert.strictEqual(VE.parseTime('0:05.5'), 5.5);
|
||||||
|
assert.strictEqual(VE.parseTime(42), 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseTime: rejects garbage and negatives', () => {
|
||||||
|
assert.strictEqual(VE.parseTime(''), null);
|
||||||
|
assert.strictEqual(VE.parseTime('abc'), null);
|
||||||
|
assert.strictEqual(VE.parseTime('1:99'), null); // seconds field out of range
|
||||||
|
assert.strictEqual(VE.parseTime('-3'), null);
|
||||||
|
assert.strictEqual(VE.parseTime('1:2:3:4'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeCuts: clamps, drops zero-length, merges overlaps', () => {
|
||||||
|
const cuts = [
|
||||||
|
{ start: -5, end: 10 }, // clamps to 0-10
|
||||||
|
{ start: 8, end: 15 }, // overlaps → merge to 0-15
|
||||||
|
{ start: 40, end: 40 }, // zero-length → dropped
|
||||||
|
{ start: 200, end: 300 }, // clamps end to duration 120 → 120-120 dropped? start>dur
|
||||||
|
{ start: 50, end: 60 },
|
||||||
|
];
|
||||||
|
const out = VE.normalizeCuts(cuts, 120);
|
||||||
|
assert.deepStrictEqual(out, [
|
||||||
|
{ start: 0, end: 15 },
|
||||||
|
{ start: 50, end: 60 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeCuts: reversed range tolerated', () => {
|
||||||
|
assert.deepStrictEqual(VE.normalizeCuts([{ start: 30, end: 10 }], 60), [{ start: 10, end: 30 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invertCuts: complement over [0,duration]', () => {
|
||||||
|
const keep = VE.invertCuts([{ start: 10, end: 20 }], 60);
|
||||||
|
assert.deepStrictEqual(keep, [{ start: 0, end: 10 }, { start: 20, end: 60 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invertCuts: cut at the very start and end', () => {
|
||||||
|
const keep = VE.invertCuts([{ start: 0, end: 5 }, { start: 55, end: 60 }], 60);
|
||||||
|
assert.deepStrictEqual(keep, [{ start: 5, end: 55 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invertCuts: no cuts keeps whole video', () => {
|
||||||
|
assert.deepStrictEqual(VE.invertCuts([], 60), [{ start: 0, end: 60 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invertCuts: cutting the entire video yields no keep segments', () => {
|
||||||
|
assert.deepStrictEqual(VE.invertCuts([{ start: 0, end: 60 }], 60), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invertCuts: needs a finite positive duration', () => {
|
||||||
|
assert.deepStrictEqual(VE.invertCuts([{ start: 1, end: 2 }], 0), []);
|
||||||
|
assert.deepStrictEqual(VE.invertCuts([{ start: 1, end: 2 }], Infinity), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keepDuration sums surviving spans', () => {
|
||||||
|
const keep = VE.invertCuts([{ start: 10, end: 20 }], 60);
|
||||||
|
assert.strictEqual(VE.keepDuration(keep), 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keepToParam / parseKeepParam round-trip', () => {
|
||||||
|
const keep = [{ start: 12.5, end: 40 }, { start: 95, end: 130.2 }];
|
||||||
|
const param = VE.keepToParam(keep);
|
||||||
|
assert.strictEqual(param, '12.5-40,95-130.2');
|
||||||
|
assert.deepStrictEqual(VE.parseKeepParam(param), keep);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseKeepParam skips malformed / non-increasing tokens', () => {
|
||||||
|
assert.deepStrictEqual(VE.parseKeepParam('0-10,bad,20-15,30-40'), [
|
||||||
|
{ start: 0, end: 10 },
|
||||||
|
{ start: 30, end: 40 },
|
||||||
|
]);
|
||||||
|
assert.deepStrictEqual(VE.parseKeepParam(''), []);
|
||||||
|
assert.deepStrictEqual(VE.parseKeepParam(null), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasEdits reflects whether a real cut exists', () => {
|
||||||
|
assert.strictEqual(VE.hasEdits([], 60), false);
|
||||||
|
assert.strictEqual(VE.hasEdits([{ start: 0, end: 0 }], 60), false);
|
||||||
|
assert.strictEqual(VE.hasEdits([{ start: 5, end: 10 }], 60), true);
|
||||||
|
});
|
||||||
0
app.zon → legacy/app.zon
Normal file → Executable file
0
app.zon → legacy/app.zon
Normal file → Executable file
BIN
legacy/releases/YT Player_1.0.0_x64-setup.exe
Executable file
BIN
legacy/releases/YT Player_1.0.0_x64-setup.exe
Executable file
Binary file not shown.
BIN
legacy/releases/YT Player_1.0.0_x64_en-US.msi
Executable file
BIN
legacy/releases/YT Player_1.0.0_x64_en-US.msi
Executable file
Binary file not shown.
BIN
legacy/releases/YTPlayer_1.0.0_x64-setup.exe
Executable file
BIN
legacy/releases/YTPlayer_1.0.0_x64-setup.exe
Executable file
Binary file not shown.
BIN
legacy/releases/YTPlayer_1.0.0_x64_en-US.msi
Executable file
BIN
legacy/releases/YTPlayer_1.0.0_x64_en-US.msi
Executable file
Binary file not shown.
BIN
legacy/releases/ytplayer.exe
Executable file
BIN
legacy/releases/ytplayer.exe
Executable file
Binary file not shown.
4397
legacy/src-tauri/Cargo.lock
generated
Executable file
4397
legacy/src-tauri/Cargo.lock
generated
Executable file
File diff suppressed because it is too large
Load Diff
6
src-tauri/Cargo.toml → legacy/src-tauri/Cargo.toml
Normal file → Executable file
6
src-tauri/Cargo.toml → legacy/src-tauri/Cargo.toml
Normal file → Executable file
@@ -10,10 +10,14 @@ rust-version = "1.77"
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = ["protocol-asset"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["embed-ytdlp"]
|
||||||
|
embed-ytdlp = []
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = "s"
|
opt-level = "s"
|
||||||
lto = true
|
lto = true
|
||||||
0
src-tauri/build.rs → legacy/src-tauri/build.rs
Normal file → Executable file
0
src-tauri/build.rs → legacy/src-tauri/build.rs
Normal file → Executable file
0
src-tauri/capabilities/default.json → legacy/src-tauri/capabilities/default.json
Normal file → Executable file
0
src-tauri/capabilities/default.json → legacy/src-tauri/capabilities/default.json
Normal file → Executable file
614
legacy/src-tauri/src/main.rs
Executable file
614
legacy/src-tauri/src/main.rs
Executable file
@@ -0,0 +1,614 @@
|
|||||||
|
// YT Player — Tauri (Windows / WebView2) backend.
|
||||||
|
//
|
||||||
|
// Mirrors the zero-native Zig bridge, but as Tauri commands. The same frontend
|
||||||
|
// in ../frontend calls these via window.__TAURI__.core.invoke(...). Returns the
|
||||||
|
// exact JSON shapes the UI expects:
|
||||||
|
// yt_search { query } -> { ok, results:[…] }
|
||||||
|
// yt_channel { channel } -> { ok, channel, channelUrl, results:[…] }
|
||||||
|
// yt_streams { videoId } -> { ok, data:{ meta, audioUrl, qualities[] } }
|
||||||
|
// store_load {} -> playlists / history / settings
|
||||||
|
// store_save { data } -> { ok }
|
||||||
|
//
|
||||||
|
// Unlike the size-limited zero-native bridge, Tauri's IPC has no fixed buffer,
|
||||||
|
// but we still return only the slim fields the UI needs.
|
||||||
|
//
|
||||||
|
// Threading: the yt-dlp calls and the offline downloads are blocking and can
|
||||||
|
// take seconds. They are dispatched onto a background thread pool with
|
||||||
|
// `tauri::async_runtime::spawn_blocking` so the WebView/main thread stays
|
||||||
|
// responsive and multiple downloads can run concurrently.
|
||||||
|
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
const SEARCH_LIMIT: u32 = 25;
|
||||||
|
const CHANNEL_LIMIT: u32 = 60;
|
||||||
|
|
||||||
|
// yt-dlp.exe is baked straight into this binary at compile time, so the standalone
|
||||||
|
// `ytplayer.exe` is fully self-contained — no sibling file and nothing on PATH
|
||||||
|
// required. On first run we materialize it into the app cache dir and run from there.
|
||||||
|
// (Windows only; other platforms fall back to a resource/dev/PATH copy.)
|
||||||
|
// Disable with --no-default-features for faster debug builds.
|
||||||
|
#[cfg(all(windows, feature = "embed-ytdlp"))]
|
||||||
|
static YTDLP_EMBEDDED: &[u8] = include_bytes!("../../bin/yt-dlp.exe");
|
||||||
|
|
||||||
|
/// Write the embedded yt-dlp to the cache dir if it isn't already there (size-checked),
|
||||||
|
/// and return its path.
|
||||||
|
#[cfg(all(windows, feature = "embed-ytdlp"))]
|
||||||
|
fn embedded_ytdlp(app: &tauri::AppHandle) -> Option<PathBuf> {
|
||||||
|
let dir = app.path().app_cache_dir().ok()?;
|
||||||
|
std::fs::create_dir_all(&dir).ok()?;
|
||||||
|
let dest = dir.join("yt-dlp.exe");
|
||||||
|
let fresh = std::fs::metadata(&dest)
|
||||||
|
.map(|m| m.len() == YTDLP_EMBEDDED.len() as u64)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !fresh {
|
||||||
|
std::fs::write(&dest, YTDLP_EMBEDDED).ok()?;
|
||||||
|
}
|
||||||
|
Some(dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locate yt-dlp: prefer a bundled resource copy, then a dev ./bin, then the
|
||||||
|
/// embedded self-contained copy (Windows), then finally PATH.
|
||||||
|
fn ytdlp_path(app: &tauri::AppHandle) -> PathBuf {
|
||||||
|
let name = if cfg!(windows) { "yt-dlp.exe" } else { "yt-dlp" };
|
||||||
|
if let Ok(res) = app.path().resource_dir() {
|
||||||
|
let p = res.join("bin").join(name);
|
||||||
|
if p.exists() {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let dev = PathBuf::from("bin").join(name);
|
||||||
|
if dev.exists() {
|
||||||
|
return dev;
|
||||||
|
}
|
||||||
|
#[cfg(all(windows, feature = "embed-ytdlp"))]
|
||||||
|
{
|
||||||
|
if let Some(p) = embedded_ytdlp(app) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PathBuf::from(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_ytdlp(app: &tauri::AppHandle, args: &[&str]) -> Result<String, String> {
|
||||||
|
let exe = ytdlp_path(app);
|
||||||
|
let mut cmd = Command::new(&exe);
|
||||||
|
cmd.args(args);
|
||||||
|
|
||||||
|
// Don't flash a console window when spawning yt-dlp on Windows.
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||||
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = cmd
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("failed to launch yt-dlp ({}): {e}", exe.display()))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let err = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(if err.trim().is_empty() {
|
||||||
|
"yt-dlp failed".to_string()
|
||||||
|
} else {
|
||||||
|
err.trim().to_string()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn str_or<'a>(v: &'a Value, key: &str, fallback: &'a str) -> &'a str {
|
||||||
|
v.get(key).and_then(|x| x.as_str()).unwrap_or(fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick the channel display name from a yt-dlp record (channel, then uploader).
|
||||||
|
fn pick_channel(v: &Value) -> &str {
|
||||||
|
let c = str_or(v, "channel", "");
|
||||||
|
if c.is_empty() {
|
||||||
|
str_or(v, "uploader", "")
|
||||||
|
} else {
|
||||||
|
c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick a usable channel URL (channel_url, then uploader_url).
|
||||||
|
fn pick_channel_url(v: &Value) -> &str {
|
||||||
|
let u = str_or(v, "channel_url", "");
|
||||||
|
if u.is_empty() {
|
||||||
|
str_or(v, "uploader_url", "")
|
||||||
|
} else {
|
||||||
|
u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick a channel id (channel_id, then uploader_id).
|
||||||
|
fn pick_channel_id(v: &Value) -> &str {
|
||||||
|
let id = str_or(v, "channel_id", "");
|
||||||
|
if id.is_empty() {
|
||||||
|
str_or(v, "uploader_id", "")
|
||||||
|
} else {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn one yt-dlp flat-playlist record into the slim card the UI expects.
|
||||||
|
fn slim_entry(j: &Value) -> Option<Value> {
|
||||||
|
let id = str_or(j, "id", "");
|
||||||
|
if id.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(json!({
|
||||||
|
"id": id,
|
||||||
|
"title": str_or(j, "title", "(untitled)"),
|
||||||
|
"channel": pick_channel(j),
|
||||||
|
"channelId": pick_channel_id(j),
|
||||||
|
"channelUrl": pick_channel_url(j),
|
||||||
|
"duration": j.get("duration").and_then(|x| x.as_f64()).unwrap_or(0.0),
|
||||||
|
"thumbnail": format!("https://i.ytimg.com/vi/{}/mqdefault.jpg", id),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Search
|
||||||
|
// ============================================================================
|
||||||
|
#[tauri::command]
|
||||||
|
async fn yt_search(app: tauri::AppHandle, query: String) -> Value {
|
||||||
|
let q = query.trim().to_string();
|
||||||
|
if q.is_empty() {
|
||||||
|
return json!({ "ok": false, "error": "empty query" });
|
||||||
|
}
|
||||||
|
tauri::async_runtime::spawn_blocking(move || yt_search_blocking(&app, &q))
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| json!({ "ok": false, "error": e.to_string() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn yt_search_blocking(app: &tauri::AppHandle, q: &str) -> Value {
|
||||||
|
let search = format!("ytsearch{}:{}", SEARCH_LIMIT, q);
|
||||||
|
let out = match run_ytdlp(
|
||||||
|
app,
|
||||||
|
&[
|
||||||
|
&search,
|
||||||
|
"--dump-json",
|
||||||
|
"--flat-playlist",
|
||||||
|
"--no-warnings",
|
||||||
|
"--ignore-errors",
|
||||||
|
],
|
||||||
|
) {
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(e) => return json!({ "ok": false, "error": e }),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for line in out.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let j: Value = match serde_json::from_str(line) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if let Some(card) = slim_entry(&j) {
|
||||||
|
results.push(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
json!({ "ok": true, "results": results })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Channel — list a channel's recent uploads
|
||||||
|
// ============================================================================
|
||||||
|
#[tauri::command]
|
||||||
|
async fn yt_channel(app: tauri::AppHandle, channel: String) -> Value {
|
||||||
|
let c = channel.trim().to_string();
|
||||||
|
if c.is_empty() {
|
||||||
|
return json!({ "ok": false, "error": "missing channel" });
|
||||||
|
}
|
||||||
|
tauri::async_runtime::spawn_blocking(move || yt_channel_blocking(&app, &c))
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| json!({ "ok": false, "error": e.to_string() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn yt_channel_blocking(app: &tauri::AppHandle, channel: &str) -> Value {
|
||||||
|
// Accept either a full channel/uploader URL or a bare channel id. Always
|
||||||
|
// resolve to the "/videos" tab so we list uploads, not the channel home.
|
||||||
|
let base = if channel.starts_with("http") {
|
||||||
|
channel.trim_end_matches('/').to_string()
|
||||||
|
} else if channel.starts_with('@') {
|
||||||
|
format!("https://www.youtube.com/{}", channel)
|
||||||
|
} else if channel.starts_with("UC") {
|
||||||
|
format!("https://www.youtube.com/channel/{}", channel)
|
||||||
|
} else {
|
||||||
|
format!("https://www.youtube.com/@{}", channel)
|
||||||
|
};
|
||||||
|
let url = if base.ends_with("/videos") {
|
||||||
|
base
|
||||||
|
} else {
|
||||||
|
format!("{}/videos", base)
|
||||||
|
};
|
||||||
|
|
||||||
|
let end = CHANNEL_LIMIT.to_string();
|
||||||
|
let out = match run_ytdlp(
|
||||||
|
app,
|
||||||
|
&[
|
||||||
|
&url,
|
||||||
|
"--dump-json",
|
||||||
|
"--flat-playlist",
|
||||||
|
"--no-warnings",
|
||||||
|
"--ignore-errors",
|
||||||
|
"--playlist-end",
|
||||||
|
&end,
|
||||||
|
],
|
||||||
|
) {
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(e) => return json!({ "ok": false, "error": e }),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
let mut name = String::new();
|
||||||
|
let mut chan_url = String::new();
|
||||||
|
for line in out.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let j: Value = match serde_json::from_str(line) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if name.is_empty() {
|
||||||
|
let n = pick_channel(&j);
|
||||||
|
if !n.is_empty() {
|
||||||
|
name = n.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if chan_url.is_empty() {
|
||||||
|
let u = pick_channel_url(&j);
|
||||||
|
if !u.is_empty() {
|
||||||
|
chan_url = u.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(card) = slim_entry(&j) {
|
||||||
|
results.push(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"ok": true,
|
||||||
|
"channel": name,
|
||||||
|
"channelUrl": chan_url,
|
||||||
|
"results": results,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Streams
|
||||||
|
// ============================================================================
|
||||||
|
#[tauri::command]
|
||||||
|
async fn yt_streams(app: tauri::AppHandle, video_id: String) -> Value {
|
||||||
|
let id = video_id.trim().to_string();
|
||||||
|
if id.is_empty() {
|
||||||
|
return json!({ "ok": false, "error": "missing videoId" });
|
||||||
|
}
|
||||||
|
tauri::async_runtime::spawn_blocking(move || yt_streams_blocking(&app, &id))
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| json!({ "ok": false, "error": e.to_string() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn yt_streams_blocking(app: &tauri::AppHandle, video_id: &str) -> Value {
|
||||||
|
let url = format!("https://www.youtube.com/watch?v={}", video_id);
|
||||||
|
let out = match run_ytdlp(app, &["-J", "--no-warnings", &url]) {
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(e) => return json!({ "ok": false, "error": e }),
|
||||||
|
};
|
||||||
|
let info: Value = match serde_json::from_str(&out) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => return json!({ "ok": false, "error": format!("parse error: {e}") }),
|
||||||
|
};
|
||||||
|
|
||||||
|
let empty: Vec<Value> = Vec::new();
|
||||||
|
let formats = info.get("formats").and_then(|f| f.as_array()).unwrap_or(&empty);
|
||||||
|
|
||||||
|
// Best audio-only stream (prefer m4a/mp4a, then by bitrate).
|
||||||
|
let mut best_audio_url = String::new();
|
||||||
|
let mut best_audio_score = -1.0_f64;
|
||||||
|
for f in formats {
|
||||||
|
let vcodec = str_or(f, "vcodec", "none");
|
||||||
|
let acodec = str_or(f, "acodec", "none");
|
||||||
|
let furl = str_or(f, "url", "");
|
||||||
|
if furl.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let has_video = vcodec != "none" && !vcodec.is_empty();
|
||||||
|
let has_audio = acodec != "none" && !acodec.is_empty();
|
||||||
|
if !has_video && has_audio {
|
||||||
|
let mut score = f.get("abr").and_then(|x| x.as_f64()).unwrap_or(0.0);
|
||||||
|
if acodec.contains("mp4a") {
|
||||||
|
score += 1000.0;
|
||||||
|
}
|
||||||
|
if score > best_audio_score {
|
||||||
|
best_audio_score = score;
|
||||||
|
best_audio_url = furl.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quality list: progressive (single-file, has audio) first so it survives the
|
||||||
|
// per-height dedupe — it's the most reliable fallback when the dual adaptive
|
||||||
|
// path fails. Adaptive video-only fills in the heights progressive doesn't cover.
|
||||||
|
let mut qualities = Vec::new();
|
||||||
|
let mut seen: HashSet<i64> = HashSet::new();
|
||||||
|
for want_progressive in [true, false] {
|
||||||
|
for f in formats {
|
||||||
|
let vcodec = str_or(f, "vcodec", "none");
|
||||||
|
let acodec = str_or(f, "acodec", "none");
|
||||||
|
let furl = str_or(f, "url", "");
|
||||||
|
if furl.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let has_video = vcodec != "none" && !vcodec.is_empty();
|
||||||
|
let has_audio = acodec != "none" && !acodec.is_empty();
|
||||||
|
if !has_video {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let is_progressive = has_audio;
|
||||||
|
if is_progressive != want_progressive {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let height = f.get("height").and_then(|x| x.as_i64()).unwrap_or(0);
|
||||||
|
if height <= 0 || seen.contains(&height) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.insert(height);
|
||||||
|
qualities.push(json!({
|
||||||
|
"label": format!("{}p", height),
|
||||||
|
"height": height,
|
||||||
|
"hasAudio": is_progressive,
|
||||||
|
"url": furl,
|
||||||
|
"ext": str_or(f, "ext", ""),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
qualities.sort_by(|a, b| {
|
||||||
|
b["height"]
|
||||||
|
.as_i64()
|
||||||
|
.unwrap_or(0)
|
||||||
|
.cmp(&a["height"].as_i64().unwrap_or(0))
|
||||||
|
});
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"ok": true,
|
||||||
|
"data": {
|
||||||
|
"meta": {
|
||||||
|
"id": video_id,
|
||||||
|
"title": str_or(&info, "title", "(untitled)"),
|
||||||
|
"channel": pick_channel(&info),
|
||||||
|
"channelId": pick_channel_id(&info),
|
||||||
|
"channelUrl": pick_channel_url(&info),
|
||||||
|
"duration": info.get("duration").and_then(|x| x.as_f64()).unwrap_or(0.0),
|
||||||
|
"thumbnail": format!("https://i.ytimg.com/vi/{}/hqdefault.jpg", video_id),
|
||||||
|
},
|
||||||
|
"audioUrl": if best_audio_url.is_empty() { Value::Null } else { Value::String(best_audio_url) },
|
||||||
|
"qualities": qualities,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data_file(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||||
|
std::fs::create_dir_all(&dir).ok();
|
||||||
|
Ok(dir.join("ytplayer-data.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Offline cache — download a single self-contained file per video so playlist
|
||||||
|
// items play instantly and work offline. Files live in <app_cache_dir>/videos
|
||||||
|
// and are named "<videoId>.<ext>". They persist until explicitly deleted.
|
||||||
|
// ============================================================================
|
||||||
|
fn cache_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let dir = app
|
||||||
|
.path()
|
||||||
|
.app_cache_dir()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.join("videos");
|
||||||
|
std::fs::create_dir_all(&dir).ok();
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find an already-cached file for a video id (any extension), if present.
|
||||||
|
fn cached_file(dir: &PathBuf, video_id: &str) -> Option<PathBuf> {
|
||||||
|
let entries = std::fs::read_dir(dir).ok()?;
|
||||||
|
for e in entries.flatten() {
|
||||||
|
let p = e.path();
|
||||||
|
if p.file_stem().and_then(|s| s.to_str()) == Some(video_id) {
|
||||||
|
// Ignore partial yt-dlp downloads.
|
||||||
|
let ext = p.extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||||
|
if ext != "part" && ext != "ytdl" {
|
||||||
|
return Some(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_size(p: &PathBuf) -> u64 {
|
||||||
|
std::fs::metadata(p).map(|m| m.len()).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download a video into the cache as a single progressive mp4 (audio+video in
|
||||||
|
/// one file, so it needs no ffmpeg muxing and plays offline). Idempotent.
|
||||||
|
///
|
||||||
|
/// Async + spawn_blocking so several saves can run in parallel without blocking
|
||||||
|
/// the UI thread.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn cache_download(app: tauri::AppHandle, video_id: String) -> Value {
|
||||||
|
let id = video_id.trim().to_string();
|
||||||
|
if id.is_empty() {
|
||||||
|
return json!({ "ok": false, "error": "missing videoId" });
|
||||||
|
}
|
||||||
|
tauri::async_runtime::spawn_blocking(move || cache_download_blocking(&app, &id))
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| json!({ "ok": false, "error": e.to_string() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_download_blocking(app: &tauri::AppHandle, video_id: &str) -> Value {
|
||||||
|
let dir = match cache_dir(app) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => return json!({ "ok": false, "error": e }),
|
||||||
|
};
|
||||||
|
if let Some(p) = cached_file(&dir, video_id) {
|
||||||
|
return json!({ "ok": true, "cached": true, "path": p.to_string_lossy(), "size": file_size(&p) });
|
||||||
|
}
|
||||||
|
let url = format!("https://www.youtube.com/watch?v={}", video_id);
|
||||||
|
let out_tmpl = dir.join(format!("{}.%(ext)s", video_id));
|
||||||
|
let out_tmpl = out_tmpl.to_string_lossy().to_string();
|
||||||
|
// Prefer a progressive mp4 (single file with audio); fall back to best single
|
||||||
|
// file. Avoids ffmpeg by not requesting separate streams that need merging.
|
||||||
|
let res = run_ytdlp(
|
||||||
|
app,
|
||||||
|
&[
|
||||||
|
"--no-playlist",
|
||||||
|
"--no-warnings",
|
||||||
|
"-f",
|
||||||
|
"best[ext=mp4][acodec!=none][vcodec!=none]/best[acodec!=none][vcodec!=none]/best",
|
||||||
|
"-o",
|
||||||
|
&out_tmpl,
|
||||||
|
&url,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if let Err(e) = res {
|
||||||
|
return json!({ "ok": false, "error": e });
|
||||||
|
}
|
||||||
|
match cached_file(&dir, video_id) {
|
||||||
|
Some(p) => json!({ "ok": true, "cached": true, "path": p.to_string_lossy(), "size": file_size(&p) }),
|
||||||
|
None => json!({ "ok": false, "error": "download finished but no file was produced" }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cache_status(app: tauri::AppHandle, video_id: String) -> Value {
|
||||||
|
let dir = match cache_dir(&app) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => return json!({ "ok": false, "error": e }),
|
||||||
|
};
|
||||||
|
match cached_file(&dir, &video_id) {
|
||||||
|
Some(p) => json!({ "ok": true, "cached": true, "path": p.to_string_lossy(), "size": file_size(&p) }),
|
||||||
|
None => json!({ "ok": true, "cached": false }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cache_list(app: tauri::AppHandle) -> Value {
|
||||||
|
let dir = match cache_dir(&app) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => return json!({ "ok": false, "error": e }),
|
||||||
|
};
|
||||||
|
let mut items = Vec::new();
|
||||||
|
let mut total: u64 = 0;
|
||||||
|
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||||
|
for e in entries.flatten() {
|
||||||
|
let p = e.path();
|
||||||
|
if !p.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ext = p.extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||||
|
if ext == "part" || ext == "ytdl" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(id) = p.file_stem().and_then(|s| s.to_str()) {
|
||||||
|
let size = file_size(&p);
|
||||||
|
total += size;
|
||||||
|
items.push(json!({ "id": id, "size": size, "path": p.to_string_lossy() }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
json!({ "ok": true, "items": items, "total": total })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cache_delete(app: tauri::AppHandle, video_id: String) -> Value {
|
||||||
|
let dir = match cache_dir(&app) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => return json!({ "ok": false, "error": e }),
|
||||||
|
};
|
||||||
|
if let Some(p) = cached_file(&dir, &video_id) {
|
||||||
|
match std::fs::remove_file(&p) {
|
||||||
|
Ok(_) => json!({ "ok": true }),
|
||||||
|
Err(e) => json!({ "ok": false, "error": e.to_string() }),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
json!({ "ok": true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cache_clear(app: tauri::AppHandle) -> Value {
|
||||||
|
let dir = match cache_dir(&app) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => return json!({ "ok": false, "error": e }),
|
||||||
|
};
|
||||||
|
let mut removed = 0u32;
|
||||||
|
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||||
|
for e in entries.flatten() {
|
||||||
|
let p = e.path();
|
||||||
|
if p.is_file() && std::fs::remove_file(&p).is_ok() {
|
||||||
|
removed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
json!({ "ok": true, "removed": removed })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn store_load(app: tauri::AppHandle) -> Value {
|
||||||
|
let default = json!({
|
||||||
|
"playlists": [],
|
||||||
|
"history": [],
|
||||||
|
"settings": { "quality": "auto", "volume": 1, "audioOnly": false }
|
||||||
|
});
|
||||||
|
let path = match data_file(&app) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => return default,
|
||||||
|
};
|
||||||
|
match std::fs::read_to_string(&path) {
|
||||||
|
Ok(s) => serde_json::from_str::<Value>(&s).unwrap_or(default),
|
||||||
|
Err(_) => default,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn store_save(app: tauri::AppHandle, data: String) -> Value {
|
||||||
|
let path = match data_file(&app) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => return json!({ "ok": false, "error": e }),
|
||||||
|
};
|
||||||
|
match std::fs::write(&path, data) {
|
||||||
|
Ok(_) => json!({ "ok": true }),
|
||||||
|
Err(e) => json!({ "ok": false, "error": e.to_string() }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
yt_search,
|
||||||
|
yt_channel,
|
||||||
|
yt_streams,
|
||||||
|
store_load,
|
||||||
|
store_save,
|
||||||
|
cache_download,
|
||||||
|
cache_status,
|
||||||
|
cache_list,
|
||||||
|
cache_delete,
|
||||||
|
cache_clear
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
11
src-tauri/tauri.conf.json → legacy/src-tauri/tauri.conf.json
Normal file → Executable file
11
src-tauri/tauri.conf.json → legacy/src-tauri/tauri.conf.json
Normal file → Executable file
@@ -19,7 +19,11 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self'; img-src 'self' https: data: asset: http://asset.localhost; media-src 'self' https: blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com data:; script-src 'self'; connect-src 'self' https: ipc: http://ipc.localhost"
|
"csp": "default-src 'self'; img-src 'self' https: data: asset: http://asset.localhost; media-src 'self' https: blob: asset: http://asset.localhost; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com data:; script-src 'self'; connect-src 'self' https: ipc: http://ipc.localhost",
|
||||||
|
"assetProtocol": {
|
||||||
|
"enable": true,
|
||||||
|
"scope": ["**"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
@@ -31,9 +35,6 @@
|
|||||||
"icons/128x128@2x.png",
|
"icons/128x128@2x.png",
|
||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
],
|
]
|
||||||
"resources": {
|
|
||||||
"../bin/yt-dlp.exe": "bin/yt-dlp.exe"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
145
src/bridge.zig → legacy/src/bridge.zig
Normal file → Executable file
145
src/bridge.zig → legacy/src/bridge.zig
Normal file → Executable file
@@ -65,6 +65,42 @@ fn jsonNum(v: ?std.json.Value) f64 {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Channel display name, URL and id, falling back to the uploader_* variants.
|
||||||
|
fn pickChannel(obj: std.json.ObjectMap) []const u8 {
|
||||||
|
const c = jsonStr(obj.get("channel"));
|
||||||
|
return if (c.len > 0) c else jsonStr(obj.get("uploader"));
|
||||||
|
}
|
||||||
|
fn pickChannelUrl(obj: std.json.ObjectMap) []const u8 {
|
||||||
|
const u = jsonStr(obj.get("channel_url"));
|
||||||
|
return if (u.len > 0) u else jsonStr(obj.get("uploader_url"));
|
||||||
|
}
|
||||||
|
fn pickChannelId(obj: std.json.ObjectMap) []const u8 {
|
||||||
|
const id = jsonStr(obj.get("channel_id"));
|
||||||
|
return if (id.len > 0) id else jsonStr(obj.get("uploader_id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write one slim video card object into `w` from a flat-playlist record.
|
||||||
|
fn writeCard(w: anytype, a: std.mem.Allocator, obj: std.json.ObjectMap) !bool {
|
||||||
|
const id = jsonStr(obj.get("id"));
|
||||||
|
if (id.len == 0) return false;
|
||||||
|
try w.writeAll("{\"id\":");
|
||||||
|
try writeJsonString(w, id);
|
||||||
|
try w.writeAll(",\"title\":");
|
||||||
|
try writeJsonString(w, jsonStr(obj.get("title")));
|
||||||
|
try w.writeAll(",\"channel\":");
|
||||||
|
try writeJsonString(w, pickChannel(obj));
|
||||||
|
try w.writeAll(",\"channelId\":");
|
||||||
|
try writeJsonString(w, pickChannelId(obj));
|
||||||
|
try w.writeAll(",\"channelUrl\":");
|
||||||
|
try writeJsonString(w, pickChannelUrl(obj));
|
||||||
|
try w.print(",\"duration\":{d}", .{jsonNum(obj.get("duration"))});
|
||||||
|
try w.writeAll(",\"thumbnail\":");
|
||||||
|
const thumb = try std.fmt.allocPrint(a, "https://i.ytimg.com/vi/{s}/mqdefault.jpg", .{id});
|
||||||
|
try writeJsonString(w, thumb);
|
||||||
|
try w.writeByte('}');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Locate the yt-dlp binary: prefer the bundled ./bin copy, fall back to PATH.
|
// Locate the yt-dlp binary: prefer the bundled ./bin copy, fall back to PATH.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -141,24 +177,95 @@ pub fn ytSearch(context: *anyopaque, invocation: Invocation, output: []u8) anyer
|
|||||||
if (parsed.value != .object) continue;
|
if (parsed.value != .object) continue;
|
||||||
const obj = parsed.value.object;
|
const obj = parsed.value.object;
|
||||||
|
|
||||||
const id = jsonStr(obj.get("id"));
|
if (jsonStr(obj.get("id")).len == 0) continue;
|
||||||
if (id.len == 0) continue;
|
|
||||||
|
|
||||||
if (!first) try w.writeByte(',');
|
if (!first) try w.writeByte(',');
|
||||||
first = false;
|
const wrote = try writeCard(w, a, obj);
|
||||||
|
if (wrote) first = false;
|
||||||
|
}
|
||||||
|
|
||||||
try w.writeAll("{\"id\":");
|
try w.writeAll("]}");
|
||||||
try writeJsonString(w, id);
|
return fbs.getWritten();
|
||||||
try w.writeAll(",\"title\":");
|
}
|
||||||
try writeJsonString(w, jsonStr(obj.get("title")));
|
|
||||||
try w.writeAll(",\"channel\":");
|
// ===========================================================================
|
||||||
const ch = if (jsonStr(obj.get("channel")).len > 0) jsonStr(obj.get("channel")) else jsonStr(obj.get("uploader"));
|
// Handler: yt.channel — list a channel's recent uploads
|
||||||
try writeJsonString(w, ch);
|
// ===========================================================================
|
||||||
try w.print(",\"duration\":{d}", .{jsonNum(obj.get("duration"))});
|
pub fn ytChannel(context: *anyopaque, invocation: Invocation, output: []u8) anyerror![]const u8 {
|
||||||
try w.writeAll(",\"thumbnail\":");
|
_ = context;
|
||||||
const thumb = try std.fmt.allocPrint(a, "https://i.ytimg.com/vi/{s}/mqdefault.jpg", .{id});
|
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||||
try writeJsonString(w, thumb);
|
defer arena.deinit();
|
||||||
try w.writeByte('}');
|
const a = arena.allocator();
|
||||||
|
|
||||||
|
const channel = (try payloadField(a, invocation.request.payload, "channel")) orelse "";
|
||||||
|
if (channel.len == 0) return errorJson(output, "missing channel");
|
||||||
|
|
||||||
|
// Resolve to a "/videos" tab URL whether we were handed a URL, @handle,
|
||||||
|
// UC… id, or a bare name.
|
||||||
|
var base: []const u8 = undefined;
|
||||||
|
if (std.mem.startsWith(u8, channel, "http")) {
|
||||||
|
base = std.mem.trimRight(u8, channel, "/");
|
||||||
|
} else if (std.mem.startsWith(u8, channel, "@")) {
|
||||||
|
base = try std.fmt.allocPrint(a, "https://www.youtube.com/{s}", .{channel});
|
||||||
|
} else if (std.mem.startsWith(u8, channel, "UC")) {
|
||||||
|
base = try std.fmt.allocPrint(a, "https://www.youtube.com/channel/{s}", .{channel});
|
||||||
|
} else {
|
||||||
|
base = try std.fmt.allocPrint(a, "https://www.youtube.com/@{s}", .{channel});
|
||||||
|
}
|
||||||
|
const url = if (std.mem.endsWith(u8, base, "/videos"))
|
||||||
|
base
|
||||||
|
else
|
||||||
|
try std.fmt.allocPrint(a, "{s}/videos", .{base});
|
||||||
|
|
||||||
|
const exe = ytDlpPath(a);
|
||||||
|
const argv = [_][]const u8{
|
||||||
|
exe, url, "--dump-json", "--flat-playlist", "--no-warnings", "--ignore-errors", "--playlist-end", "60",
|
||||||
|
};
|
||||||
|
|
||||||
|
const out = runYtDlp(a, &argv) catch |e| {
|
||||||
|
return errorJson(output, @errorName(e));
|
||||||
|
};
|
||||||
|
|
||||||
|
var fbs = std.io.fixedBufferStream(output);
|
||||||
|
const w = fbs.writer();
|
||||||
|
|
||||||
|
// Two passes: first scan for the channel name/url, then stream the cards.
|
||||||
|
var name: []const u8 = "";
|
||||||
|
var chan_url: []const u8 = "";
|
||||||
|
{
|
||||||
|
var it = std.mem.splitScalar(u8, out, '\n');
|
||||||
|
while (it.next()) |line| {
|
||||||
|
const trimmed = std.mem.trim(u8, line, " \r\t");
|
||||||
|
if (trimmed.len == 0) continue;
|
||||||
|
var parsed = std.json.parseFromSlice(std.json.Value, a, trimmed, .{}) catch continue;
|
||||||
|
defer parsed.deinit();
|
||||||
|
if (parsed.value != .object) continue;
|
||||||
|
const obj = parsed.value.object;
|
||||||
|
if (name.len == 0) name = a.dupe(u8, pickChannel(obj)) catch "";
|
||||||
|
if (chan_url.len == 0) chan_url = a.dupe(u8, pickChannelUrl(obj)) catch "";
|
||||||
|
if (name.len > 0 and chan_url.len > 0) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try w.writeAll("{\"ok\":true,\"channel\":");
|
||||||
|
try writeJsonString(w, name);
|
||||||
|
try w.writeAll(",\"channelUrl\":");
|
||||||
|
try writeJsonString(w, chan_url);
|
||||||
|
try w.writeAll(",\"results\":[");
|
||||||
|
|
||||||
|
var first = true;
|
||||||
|
var it = std.mem.splitScalar(u8, out, '\n');
|
||||||
|
while (it.next()) |line| {
|
||||||
|
const trimmed = std.mem.trim(u8, line, " \r\t");
|
||||||
|
if (trimmed.len == 0) continue;
|
||||||
|
var parsed = std.json.parseFromSlice(std.json.Value, a, trimmed, .{}) catch continue;
|
||||||
|
defer parsed.deinit();
|
||||||
|
if (parsed.value != .object) continue;
|
||||||
|
const obj = parsed.value.object;
|
||||||
|
if (jsonStr(obj.get("id")).len == 0) continue;
|
||||||
|
if (!first) try w.writeByte(',');
|
||||||
|
const wrote = try writeCard(w, a, obj);
|
||||||
|
if (wrote) first = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try w.writeAll("]}");
|
try w.writeAll("]}");
|
||||||
@@ -193,7 +300,9 @@ pub fn ytStreams(context: *anyopaque, invocation: Invocation, output: []u8) anye
|
|||||||
const info = parsed.value.object;
|
const info = parsed.value.object;
|
||||||
|
|
||||||
const title = jsonStr(info.get("title"));
|
const title = jsonStr(info.get("title"));
|
||||||
const channel = if (jsonStr(info.get("channel")).len > 0) jsonStr(info.get("channel")) else jsonStr(info.get("uploader"));
|
const channel = pickChannel(info);
|
||||||
|
const channel_id = pickChannelId(info);
|
||||||
|
const channel_url = pickChannelUrl(info);
|
||||||
const duration = jsonNum(info.get("duration"));
|
const duration = jsonNum(info.get("duration"));
|
||||||
|
|
||||||
var fbs = std.io.fixedBufferStream(output);
|
var fbs = std.io.fixedBufferStream(output);
|
||||||
@@ -205,6 +314,10 @@ pub fn ytStreams(context: *anyopaque, invocation: Invocation, output: []u8) anye
|
|||||||
try writeJsonString(w, title);
|
try writeJsonString(w, title);
|
||||||
try w.writeAll(",\"channel\":");
|
try w.writeAll(",\"channel\":");
|
||||||
try writeJsonString(w, channel);
|
try writeJsonString(w, channel);
|
||||||
|
try w.writeAll(",\"channelId\":");
|
||||||
|
try writeJsonString(w, channel_id);
|
||||||
|
try w.writeAll(",\"channelUrl\":");
|
||||||
|
try writeJsonString(w, channel_url);
|
||||||
try w.print(",\"duration\":{d}", .{duration});
|
try w.print(",\"duration\":{d}", .{duration});
|
||||||
try w.writeAll(",\"thumbnail\":");
|
try w.writeAll(",\"thumbnail\":");
|
||||||
const thumb = try std.fmt.allocPrint(a, "https://i.ytimg.com/vi/{s}/hqdefault.jpg", .{video_id});
|
const thumb = try std.fmt.allocPrint(a, "https://i.ytimg.com/vi/{s}/hqdefault.jpg", .{video_id});
|
||||||
4
src/main.zig → legacy/src/main.zig
Normal file → Executable file
4
src/main.zig → legacy/src/main.zig
Normal file → Executable file
@@ -16,13 +16,14 @@ const Handler = zero_native.bridge.Handler;
|
|||||||
// Commands the UI is allowed to call, matched against window.zero.invoke names.
|
// Commands the UI is allowed to call, matched against window.zero.invoke names.
|
||||||
const policies = [_]zero_native.bridge.CommandPolicy{
|
const policies = [_]zero_native.bridge.CommandPolicy{
|
||||||
.{ .command = "yt.search" },
|
.{ .command = "yt.search" },
|
||||||
|
.{ .command = "yt.channel" },
|
||||||
.{ .command = "yt.streams" },
|
.{ .command = "yt.streams" },
|
||||||
.{ .command = "store.load" },
|
.{ .command = "store.load" },
|
||||||
.{ .command = "store.save" },
|
.{ .command = "store.save" },
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const App = struct {
|
pub const App = struct {
|
||||||
handlers: [4]Handler = undefined,
|
handlers: [5]Handler = undefined,
|
||||||
|
|
||||||
pub fn app(self: *App) zero_native.App {
|
pub fn app(self: *App) zero_native.App {
|
||||||
return .{
|
return .{
|
||||||
@@ -37,6 +38,7 @@ pub const App = struct {
|
|||||||
fn bridge(self: *App) zero_native.BridgeDispatcher {
|
fn bridge(self: *App) zero_native.BridgeDispatcher {
|
||||||
self.handlers = .{
|
self.handlers = .{
|
||||||
.{ .name = "yt.search", .context = self, .invoke_fn = handlers_impl.ytSearch },
|
.{ .name = "yt.search", .context = self, .invoke_fn = handlers_impl.ytSearch },
|
||||||
|
.{ .name = "yt.channel", .context = self, .invoke_fn = handlers_impl.ytChannel },
|
||||||
.{ .name = "yt.streams", .context = self, .invoke_fn = handlers_impl.ytStreams },
|
.{ .name = "yt.streams", .context = self, .invoke_fn = handlers_impl.ytStreams },
|
||||||
.{ .name = "store.load", .context = self, .invoke_fn = handlers_impl.storeLoad },
|
.{ .name = "store.load", .context = self, .invoke_fn = handlers_impl.storeLoad },
|
||||||
.{ .name = "store.save", .context = self, .invoke_fn = handlers_impl.storeSave },
|
.{ .name = "store.save", .context = self, .invoke_fn = handlers_impl.storeSave },
|
||||||
1340
package-lock.json
generated
Executable file
1340
package-lock.json
generated
Executable file
File diff suppressed because it is too large
Load Diff
8
package.json
Normal file → Executable file
8
package.json
Normal file → Executable file
@@ -6,13 +6,17 @@
|
|||||||
"setup": "node scripts/setup-ytdlp.js",
|
"setup": "node scripts/setup-ytdlp.js",
|
||||||
"update-ytdlp": "node scripts/setup-ytdlp.js --force",
|
"update-ytdlp": "node scripts/setup-ytdlp.js --force",
|
||||||
"make-icon": "node scripts/make-icon.js",
|
"make-icon": "node scripts/make-icon.js",
|
||||||
|
"make-pwa-icons": "node scripts/make-pwa-icons.js",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"tauri:dev": "tauri dev",
|
"tauri:dev": "tauri dev",
|
||||||
"tauri:build": "tauri build"
|
"tauri:build": "tauri build",
|
||||||
|
"test": "node --test frontend/"
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2"
|
"@playwright/test": "^1.61.1",
|
||||||
|
"@tauri-apps/cli": "^2",
|
||||||
|
"serve": "^14.2.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
playwright.config.js
Normal file
28
playwright.config.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const { defineConfig, devices } = require('@playwright/test');
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
timeout: 30000,
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:8080',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'portrait-pwa',
|
||||||
|
use: {
|
||||||
|
...devices['iPhone 12'],
|
||||||
|
// Simulate standalone PWA display mode
|
||||||
|
contextOptions: {
|
||||||
|
// portrait 390x844
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// No webServer needed — tests serve a static page via a local server
|
||||||
|
webServer: {
|
||||||
|
command: 'npx serve frontend -l 8080 --no-clipboard',
|
||||||
|
port: 8080,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 10000,
|
||||||
|
},
|
||||||
|
});
|
||||||
0
scripts/make-icon.js
Normal file → Executable file
0
scripts/make-icon.js
Normal file → Executable file
113
scripts/make-pwa-icons.js
Normal file
113
scripts/make-pwa-icons.js
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Generates frontend/icons/icon-192.png and icon-512.png for the PWA manifest.
|
||||||
|
* Same vermilion play-button design as appicon.png — no image libraries needed.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/make-pwa-icons.js
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const zlib = require('zlib');
|
||||||
|
|
||||||
|
// ---- PNG encoder (same approach as make-icon.js) ----
|
||||||
|
const CRC_TABLE = (() => {
|
||||||
|
const t = new Uint32Array(256);
|
||||||
|
for (let n = 0; n < 256; n++) {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||||
|
t[n] = c >>> 0;
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
})();
|
||||||
|
function crc32(buf) {
|
||||||
|
let c = 0xffffffff;
|
||||||
|
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||||
|
return (c ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
function chunk(type, data) {
|
||||||
|
const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
|
||||||
|
const tb = Buffer.from(type, 'ascii');
|
||||||
|
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(Buffer.concat([tb, data])), 0);
|
||||||
|
return Buffer.concat([len, tb, data, crc]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePng(SIZE) {
|
||||||
|
const px = Buffer.alloc(SIZE * SIZE * 4);
|
||||||
|
function set(x, y, r, g, b, a = 255) {
|
||||||
|
const i = (y * SIZE + x) * 4;
|
||||||
|
px[i] = r; px[i + 1] = g; px[i + 2] = b; px[i + 3] = a;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rounded-rectangle background (vermilion gradient)
|
||||||
|
const radius = Math.round(SIZE * 0.22);
|
||||||
|
for (let y = 0; y < SIZE; y++) {
|
||||||
|
const t = y / SIZE;
|
||||||
|
const r = Math.round(0xff + (0xd4 - 0xff) * t);
|
||||||
|
const g = Math.round(0x6a + (0x32 - 0x6a) * t);
|
||||||
|
const b = Math.round(0x52 + (0x1d - 0x52) * t);
|
||||||
|
for (let x = 0; x < SIZE; x++) {
|
||||||
|
// Simple corner rounding via distance check
|
||||||
|
const dx = Math.max(0, Math.abs(x - SIZE / 2) - (SIZE / 2 - radius));
|
||||||
|
const dy = Math.max(0, Math.abs(y - SIZE / 2) - (SIZE / 2 - radius));
|
||||||
|
if (dx * dx + dy * dy > radius * radius) {
|
||||||
|
set(x, y, 0, 0, 0, 0); // transparent outside rounded rect
|
||||||
|
} else {
|
||||||
|
set(x, y, r, g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// White play triangle, centered
|
||||||
|
const scale = SIZE / 512;
|
||||||
|
const ax = Math.round(196 * scale), ay = Math.round(150 * scale);
|
||||||
|
const bx = Math.round(196 * scale), by = Math.round(362 * scale);
|
||||||
|
const cx = Math.round(384 * scale), cy = Math.round(256 * scale);
|
||||||
|
function edge(px1, py1, px2, py2, x, y) {
|
||||||
|
return (x - px1) * (py2 - py1) - (y - py1) * (px2 - px1);
|
||||||
|
}
|
||||||
|
const yMin = Math.round(120 * scale), yMax = Math.round(392 * scale);
|
||||||
|
const xMin = Math.round(170 * scale), xMax = Math.round(400 * scale);
|
||||||
|
for (let y = yMin; y < yMax; y++) {
|
||||||
|
for (let x = xMin; x < xMax; x++) {
|
||||||
|
const w0 = edge(bx, by, cx, cy, x, y);
|
||||||
|
const w1 = edge(cx, cy, ax, ay, x, y);
|
||||||
|
const w2 = edge(ax, ay, bx, by, x, y);
|
||||||
|
if ((w0 <= 0 && w1 <= 0 && w2 <= 0) || (w0 >= 0 && w1 >= 0 && w2 >= 0)) {
|
||||||
|
// Only paint if inside the rounded-rect background
|
||||||
|
const cur = (y * SIZE + x) * 4;
|
||||||
|
if (px[cur + 3] > 0) {
|
||||||
|
px[cur] = 255; px[cur + 1] = 255; px[cur + 2] = 255; px[cur + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode to PNG
|
||||||
|
const raw = Buffer.alloc((SIZE * 4 + 1) * SIZE);
|
||||||
|
for (let y = 0; y < SIZE; y++) {
|
||||||
|
raw[y * (SIZE * 4 + 1)] = 0;
|
||||||
|
px.copy(raw, y * (SIZE * 4 + 1) + 1, y * SIZE * 4, (y + 1) * SIZE * 4);
|
||||||
|
}
|
||||||
|
const ihdr = Buffer.alloc(13);
|
||||||
|
ihdr.writeUInt32BE(SIZE, 0);
|
||||||
|
ihdr.writeUInt32BE(SIZE, 4);
|
||||||
|
ihdr[8] = 8; ihdr[9] = 6; // 8-bit RGBA
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||||
|
chunk('IHDR', ihdr),
|
||||||
|
chunk('IDAT', zlib.deflateSync(raw, { level: 6 })),
|
||||||
|
chunk('IEND', Buffer.alloc(0)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outDir = path.join(__dirname, '..', 'frontend', 'icons');
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const size of [192, 512]) {
|
||||||
|
const out = path.join(outDir, `icon-${size}.png`);
|
||||||
|
const data = makePng(size);
|
||||||
|
fs.writeFileSync(out, data);
|
||||||
|
console.log(`Wrote ${out} (${size}×${size}, ${data.length} bytes)`);
|
||||||
|
}
|
||||||
|
console.log('Done — PWA icons ready in frontend/icons/');
|
||||||
17
scripts/push.sh
Executable file
17
scripts/push.sh
Executable file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# push.sh — commit and push YT Player from WSL.
|
||||||
|
# Usage: bash scripts/push.sh ["optional commit message"]
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
msg="${1:-Offline cache + Save/Add-to-playlist + Settings page; refresh Windows release}"
|
||||||
|
|
||||||
|
git add -A
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "Nothing to commit."
|
||||||
|
else
|
||||||
|
git commit -m "$msg"
|
||||||
|
fi
|
||||||
|
git push
|
||||||
|
echo "Pushed."
|
||||||
52
scripts/release.ps1
Executable file
52
scripts/release.ps1
Executable file
@@ -0,0 +1,52 @@
|
|||||||
|
<#
|
||||||
|
release.ps1 — one-shot Windows release for YT Player.
|
||||||
|
|
||||||
|
Builds the Tauri (WebView2) app on Windows, copies the installers into
|
||||||
|
.\releases, then commits and pushes through WSL git (as requested).
|
||||||
|
|
||||||
|
Run from a *native Windows* PowerShell (not WSL) inside the repo:
|
||||||
|
|
||||||
|
pwsh -File scripts\release.ps1
|
||||||
|
# or: powershell -ExecutionPolicy Bypass -File scripts\release.ps1
|
||||||
|
|
||||||
|
Prereqs (one time): Rust (MSVC), Microsoft C++ Build Tools, Node.js,
|
||||||
|
WebView2 runtime (preinstalled on Win11).
|
||||||
|
#>
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
|
Set-Location $root
|
||||||
|
|
||||||
|
function Step($m) { Write-Host "==> $m" -ForegroundColor Cyan }
|
||||||
|
|
||||||
|
Step "Installing npm deps + Tauri CLI"
|
||||||
|
npm install
|
||||||
|
|
||||||
|
Step "Fetching yt-dlp.exe into .\bin"
|
||||||
|
npm run setup
|
||||||
|
|
||||||
|
if (-not (Test-Path "src-tauri\icons\icon.ico")) {
|
||||||
|
Step "Generating app icons"
|
||||||
|
npm run make-icon
|
||||||
|
npm run tauri icon .\appicon.png
|
||||||
|
}
|
||||||
|
|
||||||
|
Step "Building Windows app (npm run tauri:build)"
|
||||||
|
npm run tauri:build
|
||||||
|
|
||||||
|
Step "Collecting artifacts into .\releases"
|
||||||
|
New-Item -ItemType Directory -Force -Path releases | Out-Null
|
||||||
|
$bundle = "src-tauri\target\release\bundle"
|
||||||
|
Get-ChildItem "$bundle\nsis\*.exe" -ErrorAction SilentlyContinue | Copy-Item -Destination releases -Force
|
||||||
|
Get-ChildItem "$bundle\msi\*.msi" -ErrorAction SilentlyContinue | Copy-Item -Destination releases -Force
|
||||||
|
Copy-Item "src-tauri\target\release\ytplayer.exe" releases -Force -ErrorAction SilentlyContinue
|
||||||
|
Get-ChildItem releases | Format-Table Name, Length -AutoSize
|
||||||
|
|
||||||
|
Step "Committing + pushing via WSL git"
|
||||||
|
$msg = "Offline cache + Save/Add-to-playlist + Settings page; refresh Windows release"
|
||||||
|
# Resolve the repo's path inside WSL and run git there.
|
||||||
|
$wslPath = (wsl wslpath -a "$($root -replace '\\','/')") 2>$null
|
||||||
|
if (-not $wslPath) { $wslPath = "~/development/personal/ytplayer" }
|
||||||
|
wsl bash -lc "cd '$wslPath' && git add -A && git commit -m '$msg' && git push"
|
||||||
|
|
||||||
|
Write-Host "Done — installers are in .\releases and changes are pushed." -ForegroundColor Green
|
||||||
0
scripts/setup-ytdlp.js
Normal file → Executable file
0
scripts/setup-ytdlp.js
Normal file → Executable file
187
server/db.js
Normal file
187
server/db.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
* db.js — libsql (embedded SQLite) database setup and query helpers
|
||||||
|
*
|
||||||
|
* The DB file lives at $DB_PATH (mounted volume in Docker so data persists
|
||||||
|
* across container rebuilds). libsql is an open-source SQLite fork by Turso
|
||||||
|
* with WAL-by-default for concurrent reads.
|
||||||
|
*
|
||||||
|
* Schema (three small tables):
|
||||||
|
* users — one row per fingerprint; tracks last app version seen
|
||||||
|
* playlists — one row per fingerprint; full playlist JSON blob
|
||||||
|
* video_history — one row per (fingerprint, video_id); recent 200 entries
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { createClient } from '@libsql/client';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const DB_PATH = process.env.DB_PATH || join(process.cwd(), 'data', 'ytplayer.db');
|
||||||
|
|
||||||
|
export const db = createClient({
|
||||||
|
url: 'file:' + DB_PATH,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Schema ----------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function initDb() {
|
||||||
|
await db.executeMultiple(`
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
PRAGMA synchronous = NORMAL;
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
fingerprint TEXT PRIMARY KEY,
|
||||||
|
last_version TEXT,
|
||||||
|
last_seen INTEGER NOT NULL DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS playlists (
|
||||||
|
fingerprint TEXT PRIMARY KEY,
|
||||||
|
data TEXT NOT NULL DEFAULT '[]',
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS video_history (
|
||||||
|
fingerprint TEXT NOT NULL,
|
||||||
|
video_id TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
channel TEXT,
|
||||||
|
thumbnail TEXT,
|
||||||
|
duration REAL DEFAULT 0,
|
||||||
|
accessed_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||||
|
PRIMARY KEY (fingerprint, video_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vh_fp_time
|
||||||
|
ON video_history (fingerprint, accessed_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS profiles (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
data TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Profiles (named cross-device sync; the name acts as the passkey) ------
|
||||||
|
|
||||||
|
// Insert a new profile. Returns false when the name is already taken.
|
||||||
|
export async function createProfile(name, dataJson) {
|
||||||
|
try {
|
||||||
|
await db.execute({
|
||||||
|
sql: `INSERT INTO profiles (name, data, created_at, updated_at)
|
||||||
|
VALUES (?, ?, unixepoch(), unixepoch())`,
|
||||||
|
args: [name, dataJson],
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
const msg = String(err && err.message || err);
|
||||||
|
if (msg.includes('UNIQUE') || msg.includes('PRIMARY KEY')) return false;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProfile(name) {
|
||||||
|
const r = await db.execute({
|
||||||
|
sql: 'SELECT data, updated_at FROM profiles WHERE name = ?',
|
||||||
|
args: [name],
|
||||||
|
});
|
||||||
|
const row = r.rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
return { data: row.data, updatedAt: Number(row.updated_at) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update an EXISTING profile's data blob. Returns false when it doesn't exist
|
||||||
|
// (saving must never implicitly create a profile — creation is a deliberate,
|
||||||
|
// uniqueness-checked act).
|
||||||
|
export async function saveProfile(name, dataJson) {
|
||||||
|
const r = await db.execute({
|
||||||
|
sql: 'UPDATE profiles SET data = ?, updated_at = unixepoch() WHERE name = ?',
|
||||||
|
args: [dataJson, name],
|
||||||
|
});
|
||||||
|
return (r.rowsAffected || 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// Upsert the users row and optionally update playlists.
|
||||||
|
export async function upsertUser({ fingerprint, appVersion, playlists }) {
|
||||||
|
await db.execute({
|
||||||
|
sql: `INSERT INTO users (fingerprint, last_version, last_seen)
|
||||||
|
VALUES (?, ?, unixepoch())
|
||||||
|
ON CONFLICT(fingerprint) DO UPDATE SET
|
||||||
|
last_version = excluded.last_version,
|
||||||
|
last_seen = excluded.last_seen`,
|
||||||
|
args: [fingerprint, appVersion || null],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Array.isArray(playlists)) {
|
||||||
|
await db.execute({
|
||||||
|
sql: `INSERT INTO playlists (fingerprint, data, updated_at)
|
||||||
|
VALUES (?, ?, unixepoch())
|
||||||
|
ON CONFLICT(fingerprint) DO UPDATE SET
|
||||||
|
data = excluded.data,
|
||||||
|
updated_at = excluded.updated_at`,
|
||||||
|
args: [fingerprint, JSON.stringify(playlists)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record a video access (insert or bump accessed_at).
|
||||||
|
export async function recordVideoAccess(fingerprint, video) {
|
||||||
|
await db.execute({
|
||||||
|
sql: `INSERT INTO video_history
|
||||||
|
(fingerprint, video_id, title, channel, thumbnail, duration, accessed_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, unixepoch())
|
||||||
|
ON CONFLICT(fingerprint, video_id) DO UPDATE SET
|
||||||
|
title = excluded.title,
|
||||||
|
channel = excluded.channel,
|
||||||
|
thumbnail = excluded.thumbnail,
|
||||||
|
duration = excluded.duration,
|
||||||
|
accessed_at = excluded.accessed_at`,
|
||||||
|
args: [
|
||||||
|
fingerprint,
|
||||||
|
video.id,
|
||||||
|
video.title || null,
|
||||||
|
video.channel || null,
|
||||||
|
video.thumbnail || null,
|
||||||
|
video.duration || 0,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep only the 200 most recent entries per user to avoid unbounded growth
|
||||||
|
await db.execute({
|
||||||
|
sql: `DELETE FROM video_history
|
||||||
|
WHERE fingerprint = ?
|
||||||
|
AND video_id NOT IN (
|
||||||
|
SELECT video_id FROM video_history
|
||||||
|
WHERE fingerprint = ?
|
||||||
|
ORDER BY accessed_at DESC
|
||||||
|
LIMIT 200
|
||||||
|
)`,
|
||||||
|
args: [fingerprint, fingerprint],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch stored data for a fingerprint.
|
||||||
|
export async function getUserData(fingerprint) {
|
||||||
|
const [userRow, plRow, histRows] = await Promise.all([
|
||||||
|
db.execute({ sql: 'SELECT last_version FROM users WHERE fingerprint = ?', args: [fingerprint] }),
|
||||||
|
db.execute({ sql: 'SELECT data FROM playlists WHERE fingerprint = ?', args: [fingerprint] }),
|
||||||
|
db.execute({
|
||||||
|
sql: `SELECT video_id AS id, title, channel, thumbnail, duration, accessed_at
|
||||||
|
FROM video_history WHERE fingerprint = ?
|
||||||
|
ORDER BY accessed_at DESC LIMIT 50`,
|
||||||
|
args: [fingerprint],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const lastVersion = userRow.rows[0]?.last_version || null;
|
||||||
|
const playlists = plRow.rows[0]?.data ? JSON.parse(plRow.rows[0].data) : [];
|
||||||
|
const history = histRows.rows.map((r) => ({
|
||||||
|
id: r.id, title: r.title, channel: r.channel,
|
||||||
|
thumbnail: r.thumbnail, duration: r.duration,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { lastVersion, playlists, history };
|
||||||
|
}
|
||||||
15
server/package.json
Normal file
15
server/package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "ytplayer-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "YT Player PWA server — Bun + Hono + libsql",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "bun server.js",
|
||||||
|
"dev": "bun --hot server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "^4.7.10",
|
||||||
|
"@hono/node-server": "^1.14.0",
|
||||||
|
"@libsql/client": "^0.14.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
714
server/server.js
Normal file
714
server/server.js
Normal file
@@ -0,0 +1,714 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
* server.js — YT Player PWA backend
|
||||||
|
*
|
||||||
|
* Runtime: Bun (https://bun.sh)
|
||||||
|
* Framework: Hono v4
|
||||||
|
* DB: libsql (concurrent SQLite fork, embedded file mode)
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* GET /api/search?q=<query> yt-dlp search → slim card array
|
||||||
|
* GET /api/channel?c=<channel> yt-dlp channel uploads → slim card array
|
||||||
|
* GET /api/streams?v=<videoId> yt-dlp stream info → {meta, audioUrl, qualities}
|
||||||
|
* GET /api/download/:videoId proxy best progressive stream → binary
|
||||||
|
* GET /api/version { version }
|
||||||
|
* POST /api/user/sync upsert user playlists + last-seen version
|
||||||
|
* GET /api/user/data?fp=<fp> retrieve stored playlists + history
|
||||||
|
* GET /* serve frontend/public static files
|
||||||
|
*
|
||||||
|
* JSON shapes mirror the Tauri (Rust) bridge exactly so the existing app.js
|
||||||
|
* UI code works without modification in WEB mode.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { serveStatic } from 'hono/bun';
|
||||||
|
import { logger } from 'hono/logger';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { readFileSync, readdirSync, statSync, openSync, unlinkSync, createReadStream } from 'node:fs';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile } from './db.js';
|
||||||
|
|
||||||
|
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||||
|
const APP_VERSION = process.env.APP_VERSION || '1.0.0';
|
||||||
|
const YTDLP = process.env.YTDLP_PATH || 'yt-dlp';
|
||||||
|
const FFMPEG = process.env.FFMPEG_PATH || 'ffmpeg';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// BUILD_TAG — must be DETERMINISTIC across restarts of identical code.
|
||||||
|
//
|
||||||
|
// Previously this was `Date.now().toString(36)`, which changes every time the
|
||||||
|
// process starts even if nothing was deployed (crash-loop, healthcheck
|
||||||
|
// restart, container reschedule). The frontend's checkBuildTag() polls
|
||||||
|
// /api/version and re-shows the "Update available" modal the instant the tag
|
||||||
|
// drifts — so a restarting-but-unchanged server kept re-announcing an update
|
||||||
|
// that never actually happened, and clicking "Refresh UI" (which itself
|
||||||
|
// reloads the page and re-polls) never made the prompt go away for good.
|
||||||
|
//
|
||||||
|
// Fix: hash the actual served frontend files. Identical code → identical
|
||||||
|
// hash → identical tag, no matter how many times the process restarts. A
|
||||||
|
// real deploy (changed files) still produces a new tag as intended.
|
||||||
|
// process.env.BUILD_TAG still wins if a CI pipeline already injects a git
|
||||||
|
// SHA — that's an even better source of truth than a content hash.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
function computeBuildTag() {
|
||||||
|
try {
|
||||||
|
// Hash EVERY served frontend file (recursively, in sorted order), not a
|
||||||
|
// hand-picked subset — a change to any shell file (e.g. sw-update.js or
|
||||||
|
// opfs.js) must produce a new tag, or clients keep their old SW cache
|
||||||
|
// and never receive the change.
|
||||||
|
const hash = createHash('sha256');
|
||||||
|
const walk = (dir) => {
|
||||||
|
for (const name of readdirSync(dir).sort()) {
|
||||||
|
const path = `${dir}/${name}`;
|
||||||
|
if (statSync(path).isDirectory()) walk(path);
|
||||||
|
else { hash.update(path); hash.update(readFileSync(path)); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk('./public');
|
||||||
|
return hash.digest('hex').slice(0, 12);
|
||||||
|
} catch {
|
||||||
|
// Frontend files not readable (e.g. unit tests run outside ./public) —
|
||||||
|
// fall back to a fixed tag rather than Date.now(), so it still never
|
||||||
|
// drifts spuriously between restarts.
|
||||||
|
return 'dev-build';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUILD_TAG = process.env.BUILD_TAG || computeBuildTag();
|
||||||
|
|
||||||
|
// BUILD_TIME — human-readable "when was this image built". Written by the
|
||||||
|
// Dockerfile at image build time (never at container start, so restarts
|
||||||
|
// don't drift it). Kept OUTSIDE ./public so it can't perturb BUILD_TAG.
|
||||||
|
const BUILD_TIME = process.env.BUILD_TIME || (() => {
|
||||||
|
try { return readFileSync('./build-time.txt', 'utf8').trim(); }
|
||||||
|
catch { return null; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
const SEARCH_LIMIT = 25;
|
||||||
|
const CHANNEL_LIMIT = 60;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// yt-dlp helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Run yt-dlp asynchronously and resolve stdout as a string.
|
||||||
|
// MUST stay async (spawn, not spawnSync): a sync child process blocks Bun's
|
||||||
|
// event loop for the full yt-dlp runtime (~2-3s per call), which stalls every
|
||||||
|
// concurrent request — including in-flight /api/download proxy streams, which
|
||||||
|
// Bun then kills at its idle timeout ("fetch failed" mid-download on clients).
|
||||||
|
// Rejects on non-zero exit.
|
||||||
|
function runYtdlp(args) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(YTDLP, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let out = '';
|
||||||
|
let err = '';
|
||||||
|
child.stdout.setEncoding('utf8');
|
||||||
|
child.stderr.setEncoding('utf8');
|
||||||
|
child.stdout.on('data', (d) => { out += d; });
|
||||||
|
child.stderr.on('data', (d) => { err += d; });
|
||||||
|
child.on('error', (e) => reject(new Error('yt-dlp not found: ' + e.message)));
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code !== 0) reject(new Error(err.trim() || 'yt-dlp exited with code ' + code));
|
||||||
|
else resolve(out);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run ffmpeg the same way — async spawn so a multi-minute trim/concat never
|
||||||
|
// blocks Bun's event loop. Rejects on non-zero exit with ffmpeg's stderr tail.
|
||||||
|
function runFfmpeg(args) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(FFMPEG, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||||
|
let err = '';
|
||||||
|
child.stderr.setEncoding('utf8');
|
||||||
|
// ffmpeg is extremely chatty on stderr; keep only the tail so an error
|
||||||
|
// message stays useful without buffering the whole progress log.
|
||||||
|
child.stderr.on('data', (d) => { err = (err + d).slice(-4000); });
|
||||||
|
child.on('error', (e) => reject(new Error('ffmpeg not found: ' + e.message)));
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code !== 0) reject(new Error(err.trim() || 'ffmpeg exited with code ' + code));
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the compact "s-e,s-e" keep-segment string (see frontend/video-edit.js)
|
||||||
|
// into an array of {start,end} second ranges. Skips malformed / non-increasing
|
||||||
|
// tokens; returns [] on empty or all-garbage input. Kept in lockstep with the
|
||||||
|
// frontend parseKeepParam so both ends agree on the wire format.
|
||||||
|
function parseKeepParam(str) {
|
||||||
|
if (typeof str !== 'string') return [];
|
||||||
|
const out = [];
|
||||||
|
for (const tok of str.split(',')) {
|
||||||
|
const t = tok.trim();
|
||||||
|
if (!t) continue;
|
||||||
|
const m = t.match(/^(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)$/);
|
||||||
|
if (!m) continue;
|
||||||
|
const a = parseFloat(m[1]);
|
||||||
|
const b = parseFloat(m[2]);
|
||||||
|
if (!isFinite(a) || !isFinite(b) || b <= a) continue;
|
||||||
|
out.push({ start: a, end: b });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build an ffmpeg filter_complex that trims `src` to the keep segments and
|
||||||
|
// concatenates them back into a single continuous stream. Re-encodes (the cut
|
||||||
|
// points rarely fall on keyframes, so stream-copy would glitch), producing one
|
||||||
|
// clean mp4. Returns the ffmpeg argv (input already appended by the caller).
|
||||||
|
function buildTrimArgs(keep) {
|
||||||
|
const parts = [];
|
||||||
|
keep.forEach((k, i) => {
|
||||||
|
parts.push(
|
||||||
|
`[0:v]trim=start=${k.start}:end=${k.end},setpts=PTS-STARTPTS[v${i}]`,
|
||||||
|
`[0:a]atrim=start=${k.start}:end=${k.end},asetpts=PTS-STARTPTS[a${i}]`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const concatInputs = keep.map((_, i) => `[v${i}][a${i}]`).join('');
|
||||||
|
const filter = parts.join(';') + ';' +
|
||||||
|
`${concatInputs}concat=n=${keep.length}:v=1:a=1[outv][outa]`;
|
||||||
|
return [
|
||||||
|
'-filter_complex', filter,
|
||||||
|
'-map', '[outv]', '-map', '[outa]',
|
||||||
|
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20',
|
||||||
|
'-c:a', 'aac', '-b:a', '160k',
|
||||||
|
'-movflags', '+faststart',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers to pick the right field from a yt-dlp JSON record
|
||||||
|
function pick(obj, ...keys) {
|
||||||
|
for (const k of keys) {
|
||||||
|
const v = obj[k];
|
||||||
|
if (v && typeof v === 'string' && v.trim()) return v.trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickChannel(obj) { return pick(obj, 'channel', 'uploader'); }
|
||||||
|
function pickChannelUrl(obj) { return pick(obj, 'channel_url', 'uploader_url'); }
|
||||||
|
function pickChannelId(obj) { return pick(obj, 'channel_id', 'uploader_id'); }
|
||||||
|
|
||||||
|
// Normalise a flat-playlist yt-dlp record into the slim UI card shape
|
||||||
|
function slimEntry(j) {
|
||||||
|
const id = pick(j, 'id');
|
||||||
|
if (!id) return null;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: pick(j, 'title') || '(untitled)',
|
||||||
|
channel: pickChannel(j),
|
||||||
|
channelId: pickChannelId(j),
|
||||||
|
channelUrl: pickChannelUrl(j),
|
||||||
|
duration: typeof j.duration === 'number' ? j.duration : 0,
|
||||||
|
thumbnail: `https://i.ytimg.com/vi/${id}/mqdefault.jpg`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse multi-line JSON output from yt-dlp --dump-json --flat-playlist
|
||||||
|
function parseCards(output) {
|
||||||
|
const results = [];
|
||||||
|
for (const line of output.split('\n')) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t) continue;
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(t);
|
||||||
|
const card = slimEntry(j);
|
||||||
|
if (card) results.push(card);
|
||||||
|
} catch { /* skip malformed lines */ }
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a channel identifier to a /videos URL yt-dlp can fetch
|
||||||
|
function channelToUrl(c) {
|
||||||
|
let base = c.trim();
|
||||||
|
if (!base.startsWith('http')) {
|
||||||
|
base = base.startsWith('@') ? `https://www.youtube.com/${base}`
|
||||||
|
: base.startsWith('UC') ? `https://www.youtube.com/channel/${base}`
|
||||||
|
: `https://www.youtube.com/@${base}`;
|
||||||
|
}
|
||||||
|
base = base.replace(/\/$/, '');
|
||||||
|
return base.endsWith('/videos') ? base : base + '/videos';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// App setup
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
app.use('*', logger());
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API routes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// GET /api/version
|
||||||
|
// Returns version string + a build tag that changes on every server restart/deploy.
|
||||||
|
// Clients poll this to detect when a new build is live and prompt a reload.
|
||||||
|
app.get('/api/version', (c) =>
|
||||||
|
c.json(
|
||||||
|
{ version: APP_VERSION, buildTag: BUILD_TAG, buildTime: BUILD_TIME },
|
||||||
|
200,
|
||||||
|
{ 'Cache-Control': 'no-store, no-cache, must-revalidate' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/search?q=<query>
|
||||||
|
app.get('/api/search', async (c) => {
|
||||||
|
const q = (c.req.query('q') || '').trim();
|
||||||
|
if (!q) return c.json({ ok: false, error: 'empty query' }, 400);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const out = await runYtdlp([
|
||||||
|
`ytsearch${SEARCH_LIMIT}:${q}`,
|
||||||
|
'--dump-json', '--flat-playlist',
|
||||||
|
'--no-warnings', '--ignore-errors',
|
||||||
|
]);
|
||||||
|
return c.json({ ok: true, results: parseCards(out) });
|
||||||
|
} catch (err) {
|
||||||
|
return c.json({ ok: false, error: err.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/channel?c=<channel>
|
||||||
|
app.get('/api/channel', async (c) => {
|
||||||
|
const chan = (c.req.query('c') || '').trim();
|
||||||
|
if (!chan) return c.json({ ok: false, error: 'missing channel' }, 400);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = channelToUrl(chan);
|
||||||
|
const out = await runYtdlp([
|
||||||
|
url,
|
||||||
|
'--dump-json', '--flat-playlist',
|
||||||
|
'--no-warnings', '--ignore-errors',
|
||||||
|
'--playlist-end', String(CHANNEL_LIMIT),
|
||||||
|
]);
|
||||||
|
const results = parseCards(out);
|
||||||
|
// Extract channel name + URL from the first record
|
||||||
|
const first = results[0];
|
||||||
|
let channelName = '', channelUrl = '';
|
||||||
|
for (const line of out.split('\n')) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t) continue;
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(t);
|
||||||
|
channelName = channelName || pickChannel(j);
|
||||||
|
channelUrl = channelUrl || pickChannelUrl(j);
|
||||||
|
if (channelName && channelUrl) break;
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
return c.json({ ok: true, channel: channelName || (first?.channel || ''), channelUrl, results });
|
||||||
|
} catch (err) {
|
||||||
|
return c.json({ ok: false, error: err.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/streams?v=<videoId>
|
||||||
|
app.get('/api/streams', async (c) => {
|
||||||
|
const videoId = (c.req.query('v') || '').replace(/[/\\:?<>|*"]/g, '').trim();
|
||||||
|
if (!videoId) return c.json({ ok: false, error: 'missing videoId' }, 400);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://www.youtube.com/watch?v=${videoId}`;
|
||||||
|
const out = await runYtdlp(['-J', '--no-warnings', url]);
|
||||||
|
const info = JSON.parse(out);
|
||||||
|
const formats = Array.isArray(info.formats) ? info.formats : [];
|
||||||
|
|
||||||
|
// Best audio-only stream (prefer mp4a/m4a by bitrate)
|
||||||
|
let bestAudioUrl = null;
|
||||||
|
let bestAudioScore = -1;
|
||||||
|
for (const f of formats) {
|
||||||
|
if (!f.url) continue;
|
||||||
|
const hasVideo = f.vcodec && f.vcodec !== 'none';
|
||||||
|
const hasAudio = f.acodec && f.acodec !== 'none';
|
||||||
|
if (hasVideo || !hasAudio) continue;
|
||||||
|
let score = f.abr || 0;
|
||||||
|
if (f.acodec && f.acodec.includes('mp4a')) score += 1000;
|
||||||
|
if (score > bestAudioScore) { bestAudioScore = score; bestAudioUrl = f.url; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quality list — progressive (single-file, hasAudio) first, then adaptive
|
||||||
|
const qualities = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const wantProg of [true, false]) {
|
||||||
|
for (const f of formats) {
|
||||||
|
if (!f.url) continue;
|
||||||
|
const hasVideo = f.vcodec && f.vcodec !== 'none';
|
||||||
|
const hasAudio = f.acodec && f.acodec !== 'none';
|
||||||
|
if (!hasVideo) continue;
|
||||||
|
if ((hasAudio) !== wantProg) continue;
|
||||||
|
const h = f.height || 0;
|
||||||
|
if (h <= 0 || seen.has(h)) continue;
|
||||||
|
seen.add(h);
|
||||||
|
qualities.push({ label: h + 'p', height: h, hasAudio: !!hasAudio, url: f.url, ext: f.ext || '' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
qualities.sort((a, b) => b.height - a.height);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
meta: {
|
||||||
|
id: videoId,
|
||||||
|
title: pick(info, 'title') || '(untitled)',
|
||||||
|
channel: pickChannel(info),
|
||||||
|
channelId: pickChannelId(info),
|
||||||
|
channelUrl: pickChannelUrl(info),
|
||||||
|
duration: typeof info.duration === 'number' ? info.duration : 0,
|
||||||
|
thumbnail: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
|
||||||
|
},
|
||||||
|
audioUrl: bestAudioUrl,
|
||||||
|
qualities,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return c.json({ ok: false, error: err.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download via yt-dlp into a self-cleaning temp file, then stream it.
|
||||||
|
// yt-dlp MUST perform the HTTP fetch itself: googlevideo stream URLs are
|
||||||
|
// bound to the innertube client that extracted them, so resolving the URL
|
||||||
|
// with --get-url and re-fetching it server-side with hand-rolled browser
|
||||||
|
// headers intermittently got 403s from the YouTube CDN when the User-Agent
|
||||||
|
// didn't match the extraction client.
|
||||||
|
async function ytdlpDownloadResponse(videoId, fp, formatArgs) {
|
||||||
|
const tmpBase = `ytp-dl-${videoId}-${Date.now()}`;
|
||||||
|
const tmp = `${tmpdir()}/${tmpBase}.mp4`;
|
||||||
|
let size, fd;
|
||||||
|
try {
|
||||||
|
await runYtdlp([
|
||||||
|
`https://www.youtube.com/watch?v=${videoId}`,
|
||||||
|
'--no-warnings', '--no-playlist',
|
||||||
|
...formatArgs,
|
||||||
|
'-N', '4',
|
||||||
|
'-o', tmp,
|
||||||
|
]);
|
||||||
|
size = statSync(tmp).size;
|
||||||
|
// Open the fd BEFORE the finally unlinks: on Linux the data stays
|
||||||
|
// readable until the fd closes, so the temp file cleans itself up even
|
||||||
|
// if the client disconnects mid-transfer.
|
||||||
|
fd = openSync(tmp, 'r');
|
||||||
|
} finally {
|
||||||
|
// Sweep everything yt-dlp may have left under this request's unique
|
||||||
|
// prefix: the output itself, .part partials, and .fNNN single-format
|
||||||
|
// intermediates (left when ffmpeg is missing — yt-dlp then downloads
|
||||||
|
// the streams separately, exits 0 without merging, and statSync above
|
||||||
|
// throws on the absent merged file).
|
||||||
|
for (const name of readdirSync(tmpdir())) {
|
||||||
|
if (name.startsWith(tmpBase)) {
|
||||||
|
try { unlinkSync(`${tmpdir()}/${name}`); } catch { /* already gone */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const stream = createReadStream('', { fd });
|
||||||
|
|
||||||
|
if (fp) recordVideoAccess(fp, { id: videoId }).catch(() => {});
|
||||||
|
|
||||||
|
return new Response(Readable.toWeb(stream), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'video/mp4',
|
||||||
|
'Content-Length': String(size),
|
||||||
|
'Content-Disposition': `attachment; filename="${videoId}.mp4"`,
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Edit & download": fetch the source with yt-dlp (muxed up to 720p, same as
|
||||||
|
// the mux path), then run ffmpeg to KEEP only the requested segments and
|
||||||
|
// concatenate them into one continuous mp4 — the user's custom cut. The result
|
||||||
|
// is streamed to the browser exactly like a normal save, so OPFS stores it
|
||||||
|
// under the caller-chosen custom id. Every temp file is swept afterwards.
|
||||||
|
async function ytdlpEditedDownloadResponse(videoId, fp, keep) {
|
||||||
|
const tmpBase = `ytp-edit-${videoId}-${Date.now()}`;
|
||||||
|
const srcTmp = `${tmpdir()}/${tmpBase}.src.mp4`;
|
||||||
|
const outTmp = `${tmpdir()}/${tmpBase}.out.mp4`;
|
||||||
|
let size, fd;
|
||||||
|
try {
|
||||||
|
// 1) Grab the full source (video+audio merged) so ffmpeg has both streams.
|
||||||
|
await runYtdlp([
|
||||||
|
`https://www.youtube.com/watch?v=${videoId}`,
|
||||||
|
'--no-warnings', '--no-playlist',
|
||||||
|
'-f', 'bv*[height<=720][ext=mp4]+ba[ext=m4a]/bv*[height<=720]+ba/b[ext=mp4]/b',
|
||||||
|
'--merge-output-format', 'mp4',
|
||||||
|
'-N', '4',
|
||||||
|
'-o', srcTmp,
|
||||||
|
]);
|
||||||
|
// 2) Trim + concat the keep segments into the final custom video.
|
||||||
|
await runFfmpeg([
|
||||||
|
'-y', '-hide_banner', '-loglevel', 'error',
|
||||||
|
'-i', srcTmp,
|
||||||
|
...buildTrimArgs(keep),
|
||||||
|
outTmp,
|
||||||
|
]);
|
||||||
|
size = statSync(outTmp).size;
|
||||||
|
fd = openSync(outTmp, 'r');
|
||||||
|
} finally {
|
||||||
|
for (const name of readdirSync(tmpdir())) {
|
||||||
|
if (name.startsWith(tmpBase)) {
|
||||||
|
try { unlinkSync(`${tmpdir()}/${name}`); } catch { /* already gone */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const stream = createReadStream('', { fd });
|
||||||
|
|
||||||
|
if (fp) recordVideoAccess(fp, { id: videoId }).catch(() => {});
|
||||||
|
|
||||||
|
return new Response(Readable.toWeb(stream), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'video/mp4',
|
||||||
|
'Content-Length': String(size),
|
||||||
|
'Content-Disposition': `attachment; filename="${videoId}-edited.mp4"`,
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/download/:videoId
|
||||||
|
// Downloads the video server-side via yt-dlp and streams the finished file
|
||||||
|
// to the browser so OPFS can store it. The browser never contacts YouTube
|
||||||
|
// CDN directly (CORS would block it).
|
||||||
|
app.get('/api/download/:videoId', async (c) => {
|
||||||
|
const videoId = (c.req.param('videoId') || '').replace(/[/\\:?<>|*"]/g, '').trim();
|
||||||
|
if (!videoId) return c.json({ ok: false, error: 'missing videoId' }, 400);
|
||||||
|
const fp = c.req.query('fp');
|
||||||
|
|
||||||
|
// ?edit=1&keep=s-e,s-e — "Edit & download" path: download the source, then
|
||||||
|
// ffmpeg-trim it to the requested keep segments and stream the custom cut.
|
||||||
|
// Requires ffmpeg; there is no progressive fallback because the whole point
|
||||||
|
// is the server-side edit. Invalid/empty keep params are rejected up front.
|
||||||
|
if (c.req.query('edit') === '1') {
|
||||||
|
const keep = parseKeepParam(c.req.query('keep') || '');
|
||||||
|
if (!keep.length) return c.json({ ok: false, error: 'missing or invalid keep segments' }, 400);
|
||||||
|
try {
|
||||||
|
return await ytdlpEditedDownloadResponse(videoId, fp, keep);
|
||||||
|
} catch (err) {
|
||||||
|
return c.json({ ok: false, error: err.message }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ?mux=1 — "Save before playing" path: bestvideo up to 720p PLUS bestaudio
|
||||||
|
// compiled into one mp4 with ffmpeg on the server. Falls back to the
|
||||||
|
// progressive single-file save below when ffmpeg is missing or the merge
|
||||||
|
// fails.
|
||||||
|
if (c.req.query('mux') === '1') {
|
||||||
|
try {
|
||||||
|
return await ytdlpDownloadResponse(videoId, fp, [
|
||||||
|
'-f', 'bv*[height<=720][ext=mp4]+ba[ext=m4a]/bv*[height<=720]+ba/b[ext=mp4]/b',
|
||||||
|
'--merge-output-format', 'mp4',
|
||||||
|
]);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[ytplayer] mux download failed for ${videoId}, falling back to progressive:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default save — best progressive (audio+video single-file) format, so no
|
||||||
|
// ffmpeg is required anywhere in the chain.
|
||||||
|
try {
|
||||||
|
return await ytdlpDownloadResponse(videoId, fp, [
|
||||||
|
'-f', 'bestvideo[ext=mp4][acodec!=none]/bestvideo[acodec!=none]/best[ext=mp4]/best',
|
||||||
|
]);
|
||||||
|
} catch (err) {
|
||||||
|
return c.json({ ok: false, error: err.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Online profiles — named cross-device sync. The NAME IS THE PASSKEY:
|
||||||
|
// anyone who knows it can load and overwrite that profile, so clients are
|
||||||
|
// encouraged to use the random generator. No other auth by design.
|
||||||
|
// ============================================================================
|
||||||
|
const PROFILE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{2,39}$/;
|
||||||
|
const PROFILE_MAX_BYTES = 2_000_000; // full data blob; typical payloads are ~KBs
|
||||||
|
|
||||||
|
const RAND_ADJ = ['amber', 'brave', 'calm', 'coral', 'crimson', 'dusty', 'gentle', 'golden',
|
||||||
|
'hidden', 'ivory', 'jade', 'lunar', 'mellow', 'misty', 'noble', 'quiet',
|
||||||
|
'rapid', 'silver', 'solar', 'stormy', 'swift', 'velvet', 'wild', 'zesty'];
|
||||||
|
const RAND_NOUN = ['falcon', 'harbor', 'willow', 'ember', 'canyon', 'meadow', 'otter', 'pine',
|
||||||
|
'raven', 'reef', 'sparrow', 'summit', 'thicket', 'tundra', 'brook', 'cedar',
|
||||||
|
'dune', 'fjord', 'glade', 'heron', 'lagoon', 'maple', 'prairie', 'wren'];
|
||||||
|
function randomProfileName() {
|
||||||
|
const a = RAND_ADJ[Math.floor(Math.random() * RAND_ADJ.length)];
|
||||||
|
const n = RAND_NOUN[Math.floor(Math.random() * RAND_NOUN.length)];
|
||||||
|
return `${a}-${n}-${1000 + Math.floor(Math.random() * 9000)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/profile/create
|
||||||
|
// Body: { name?, data? } — empty/absent name asks the server to generate a
|
||||||
|
// unique random one. Fails with 409 when the requested name is taken.
|
||||||
|
app.post('/api/profile/create', async (c) => {
|
||||||
|
let body;
|
||||||
|
try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); }
|
||||||
|
|
||||||
|
let name = (body.name || '').trim().toLowerCase();
|
||||||
|
const dataJson = JSON.stringify(body.data && typeof body.data === 'object' ? body.data : {});
|
||||||
|
if (dataJson.length > PROFILE_MAX_BYTES) return c.json({ ok: false, error: 'profile data too large' }, 413);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (name) {
|
||||||
|
if (!PROFILE_NAME_RE.test(name)) {
|
||||||
|
return c.json({ ok: false, error: 'invalid name — 3-40 characters: letters, digits, - or _' }, 400);
|
||||||
|
}
|
||||||
|
if (!(await createProfile(name, dataJson))) {
|
||||||
|
return c.json({ ok: false, error: `“${name}” is already taken — pick another name` }, 409);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let created = false;
|
||||||
|
for (let tries = 0; tries < 20 && !created; tries++) {
|
||||||
|
name = randomProfileName();
|
||||||
|
created = await createProfile(name, dataJson);
|
||||||
|
}
|
||||||
|
if (!created) return c.json({ ok: false, error: 'could not generate a unique name — try again' }, 500);
|
||||||
|
}
|
||||||
|
const row = await getProfile(name);
|
||||||
|
return c.json({ ok: true, name, updatedAt: row ? row.updatedAt : 0 });
|
||||||
|
} catch (err) {
|
||||||
|
return c.json({ ok: false, error: err.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/profile/load?name=<name>
|
||||||
|
app.get('/api/profile/load', async (c) => {
|
||||||
|
const name = (c.req.query('name') || '').trim().toLowerCase();
|
||||||
|
if (!name) return c.json({ ok: false, error: 'missing name' }, 400);
|
||||||
|
try {
|
||||||
|
const row = await getProfile(name);
|
||||||
|
if (!row) return c.json({ ok: false, error: 'profile not found' }, 404);
|
||||||
|
let data = {};
|
||||||
|
try { data = JSON.parse(row.data || '{}'); } catch { /* corrupt blob — hand back empty */ }
|
||||||
|
return c.json({ ok: true, name, data, updatedAt: row.updatedAt });
|
||||||
|
} catch (err) {
|
||||||
|
return c.json({ ok: false, error: err.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/profile/save
|
||||||
|
// Body: { name, data } — updates an EXISTING profile only (404 otherwise).
|
||||||
|
app.post('/api/profile/save', async (c) => {
|
||||||
|
let body;
|
||||||
|
try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); }
|
||||||
|
|
||||||
|
const name = (body.name || '').trim().toLowerCase();
|
||||||
|
if (!name) return c.json({ ok: false, error: 'missing name' }, 400);
|
||||||
|
const dataJson = JSON.stringify(body.data && typeof body.data === 'object' ? body.data : {});
|
||||||
|
if (dataJson.length > PROFILE_MAX_BYTES) return c.json({ ok: false, error: 'profile data too large' }, 413);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!(await saveProfile(name, dataJson))) {
|
||||||
|
return c.json({ ok: false, error: 'profile not found' }, 404);
|
||||||
|
}
|
||||||
|
const row = await getProfile(name);
|
||||||
|
return c.json({ ok: true, updatedAt: row ? row.updatedAt : 0 });
|
||||||
|
} catch (err) {
|
||||||
|
return c.json({ ok: false, error: err.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/user/sync
|
||||||
|
// Body: { fingerprint, playlists?, recentVideo?, appVersion? }
|
||||||
|
app.post('/api/user/sync', async (c) => {
|
||||||
|
let body;
|
||||||
|
try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); }
|
||||||
|
|
||||||
|
const fp = (body.fingerprint || '').trim();
|
||||||
|
if (!fp) return c.json({ ok: false, error: 'missing fingerprint' }, 400);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await upsertUser({ fingerprint: fp, appVersion: body.appVersion, playlists: body.playlists });
|
||||||
|
if (body.recentVideo && body.recentVideo.id) {
|
||||||
|
await recordVideoAccess(fp, body.recentVideo);
|
||||||
|
}
|
||||||
|
return c.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
return c.json({ ok: false, error: err.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/user/data?fp=<fingerprint>
|
||||||
|
app.get('/api/user/data', async (c) => {
|
||||||
|
const fp = (c.req.query('fp') || '').trim();
|
||||||
|
if (!fp) return c.json({ ok: false, error: 'missing fingerprint' }, 400);
|
||||||
|
try {
|
||||||
|
const userData = await getUserData(fp);
|
||||||
|
return c.json({ ok: true, ...userData, version: APP_VERSION });
|
||||||
|
} catch (err) {
|
||||||
|
return c.json({ ok: false, error: err.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GET /sw.js — serve the service worker with BUILD_TAG injected
|
||||||
|
//
|
||||||
|
// The raw sw.js file contains the placeholder `__BUILD_TAG__` which is
|
||||||
|
// replaced here with the actual BUILD_TAG string so the SW's cache name
|
||||||
|
// tracks the deployment automatically — no manual version bump needed.
|
||||||
|
// Served with no-store cache headers so browsers always re-fetch it and
|
||||||
|
// pick up the substituted value rather than a browser-cached stale copy.
|
||||||
|
// ============================================================================
|
||||||
|
let _swSource = null;
|
||||||
|
app.get('/sw.js', (c) => {
|
||||||
|
if (!_swSource) {
|
||||||
|
try {
|
||||||
|
_swSource = readFileSync('./public/sw.js', 'utf8');
|
||||||
|
} catch {
|
||||||
|
return c.text('Service worker not found', 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Inject the build tag: replace the whole fallback expression with the
|
||||||
|
// real value. Matched by REGEX, not an exact string — an exact match broke
|
||||||
|
// the moment the fallback literal in sw.js was bumped ('v1.0.3' → 'v1.0.4'),
|
||||||
|
// after which the replacement silently did nothing, the SW version froze,
|
||||||
|
// and clients never saw another update no matter how many times we deployed.
|
||||||
|
const src = _swSource.replace(
|
||||||
|
/typeof __BUILD_TAG__ !== 'undefined' \? __BUILD_TAG__ : '[^']*'/,
|
||||||
|
JSON.stringify(BUILD_TAG)
|
||||||
|
);
|
||||||
|
if (src === _swSource) {
|
||||||
|
console.error('[sw] BUILD_TAG injection failed — placeholder not found in sw.js');
|
||||||
|
}
|
||||||
|
return c.text(src, 200, {
|
||||||
|
'Content-Type': 'application/javascript; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Static files — serve the frontend/public directory
|
||||||
|
// Must come AFTER all /api routes so API takes priority
|
||||||
|
// ============================================================================
|
||||||
|
app.use('/*', serveStatic({ root: './public' }));
|
||||||
|
// SPA fallback — return index.html for any unmatched path
|
||||||
|
app.get('/*', serveStatic({ path: './public/index.html' }));
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Boot
|
||||||
|
// ============================================================================
|
||||||
|
async function main() {
|
||||||
|
await initDb();
|
||||||
|
console.log(`[ytplayer] DB ready`);
|
||||||
|
console.log(`[ytplayer] Starting on port ${PORT}`);
|
||||||
|
|
||||||
|
// Bun.serve is the native Bun HTTP server
|
||||||
|
Bun.serve({
|
||||||
|
port: PORT,
|
||||||
|
fetch: app.fetch,
|
||||||
|
// Default is 10s, which killed /api/download proxy streams whenever the
|
||||||
|
// connection went idle mid-transfer. 240s covers slow saves; Bun caps
|
||||||
|
// this field at 255.
|
||||||
|
idleTimeout: 240,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[ytplayer] Listening → http://localhost:${PORT}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('[ytplayer] fatal:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
11
skills-lock.json
Normal file
11
skills-lock.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"skills": {
|
||||||
|
"mobile-app-ui-design": {
|
||||||
|
"source": "ceorkm/mobile-app-ui-design",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "SKILL.md",
|
||||||
|
"computedHash": "208e77416eeb4cf441d64f49dfb1b2423d87216b4e7bf767ee96ed6cf1c2e3fa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
// YT Player — Tauri (Windows / WebView2) backend.
|
|
||||||
//
|
|
||||||
// Mirrors the zero-native Zig bridge, but as Tauri commands. The same frontend
|
|
||||||
// in ../frontend calls these via window.__TAURI__.core.invoke(...). Returns the
|
|
||||||
// exact JSON shapes the UI expects:
|
|
||||||
// yt_search { query } -> { ok, results:[…] }
|
|
||||||
// yt_streams { videoId } -> { ok, data:{ meta, audioUrl, qualities[] } }
|
|
||||||
// store_load {} -> playlists / history / settings
|
|
||||||
// store_save { data } -> { ok }
|
|
||||||
//
|
|
||||||
// Unlike the size-limited zero-native bridge, Tauri's IPC has no fixed buffer,
|
|
||||||
// but we still return only the slim fields the UI needs.
|
|
||||||
|
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
|
||||||
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::process::Command;
|
|
||||||
use tauri::Manager;
|
|
||||||
|
|
||||||
const SEARCH_LIMIT: u32 = 25;
|
|
||||||
|
|
||||||
/// Locate yt-dlp: prefer the bundled resource copy, then a dev ./bin, then PATH.
|
|
||||||
fn ytdlp_path(app: &tauri::AppHandle) -> PathBuf {
|
|
||||||
let name = if cfg!(windows) { "yt-dlp.exe" } else { "yt-dlp" };
|
|
||||||
if let Ok(res) = app.path().resource_dir() {
|
|
||||||
let p = res.join("bin").join(name);
|
|
||||||
if p.exists() {
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let dev = PathBuf::from("bin").join(name);
|
|
||||||
if dev.exists() {
|
|
||||||
return dev;
|
|
||||||
}
|
|
||||||
PathBuf::from(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_ytdlp(app: &tauri::AppHandle, args: &[&str]) -> Result<String, String> {
|
|
||||||
let exe = ytdlp_path(app);
|
|
||||||
let mut cmd = Command::new(&exe);
|
|
||||||
cmd.args(args);
|
|
||||||
|
|
||||||
// Don't flash a console window when spawning yt-dlp on Windows.
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
use std::os::windows::process::CommandExt;
|
|
||||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
|
||||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
|
||||||
}
|
|
||||||
|
|
||||||
let output = cmd
|
|
||||||
.output()
|
|
||||||
.map_err(|e| format!("failed to launch yt-dlp ({}): {e}", exe.display()))?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
let err = String::from_utf8_lossy(&output.stderr);
|
|
||||||
return Err(if err.trim().is_empty() {
|
|
||||||
"yt-dlp failed".to_string()
|
|
||||||
} else {
|
|
||||||
err.trim().to_string()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn str_or<'a>(v: &'a Value, key: &str, fallback: &'a str) -> &'a str {
|
|
||||||
v.get(key).and_then(|x| x.as_str()).unwrap_or(fallback)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn yt_search(app: tauri::AppHandle, query: String) -> Value {
|
|
||||||
let q = query.trim();
|
|
||||||
if q.is_empty() {
|
|
||||||
return json!({ "ok": false, "error": "empty query" });
|
|
||||||
}
|
|
||||||
let search = format!("ytsearch{}:{}", SEARCH_LIMIT, q);
|
|
||||||
let out = match run_ytdlp(
|
|
||||||
&app,
|
|
||||||
&[
|
|
||||||
&search,
|
|
||||||
"--dump-json",
|
|
||||||
"--flat-playlist",
|
|
||||||
"--no-warnings",
|
|
||||||
"--ignore-errors",
|
|
||||||
],
|
|
||||||
) {
|
|
||||||
Ok(o) => o,
|
|
||||||
Err(e) => return json!({ "ok": false, "error": e }),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut results = Vec::new();
|
|
||||||
for line in out.lines() {
|
|
||||||
let line = line.trim();
|
|
||||||
if line.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let j: Value = match serde_json::from_str(line) {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
let id = str_or(&j, "id", "");
|
|
||||||
if id.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let channel = {
|
|
||||||
let c = str_or(&j, "channel", "");
|
|
||||||
if c.is_empty() {
|
|
||||||
str_or(&j, "uploader", "")
|
|
||||||
} else {
|
|
||||||
c
|
|
||||||
}
|
|
||||||
};
|
|
||||||
results.push(json!({
|
|
||||||
"id": id,
|
|
||||||
"title": str_or(&j, "title", "(untitled)"),
|
|
||||||
"channel": channel,
|
|
||||||
"duration": j.get("duration").and_then(|x| x.as_f64()).unwrap_or(0.0),
|
|
||||||
"thumbnail": format!("https://i.ytimg.com/vi/{}/mqdefault.jpg", id),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
json!({ "ok": true, "results": results })
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn yt_streams(app: tauri::AppHandle, video_id: String) -> Value {
|
|
||||||
if video_id.is_empty() {
|
|
||||||
return json!({ "ok": false, "error": "missing videoId" });
|
|
||||||
}
|
|
||||||
let url = format!("https://www.youtube.com/watch?v={}", video_id);
|
|
||||||
let out = match run_ytdlp(&app, &["-J", "--no-warnings", &url]) {
|
|
||||||
Ok(o) => o,
|
|
||||||
Err(e) => return json!({ "ok": false, "error": e }),
|
|
||||||
};
|
|
||||||
let info: Value = match serde_json::from_str(&out) {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => return json!({ "ok": false, "error": format!("parse error: {e}") }),
|
|
||||||
};
|
|
||||||
|
|
||||||
let empty: Vec<Value> = Vec::new();
|
|
||||||
let formats = info.get("formats").and_then(|f| f.as_array()).unwrap_or(&empty);
|
|
||||||
|
|
||||||
// Best audio-only stream (prefer m4a/mp4a, then by bitrate).
|
|
||||||
let mut best_audio_url = String::new();
|
|
||||||
let mut best_audio_score = -1.0_f64;
|
|
||||||
for f in formats {
|
|
||||||
let vcodec = str_or(f, "vcodec", "none");
|
|
||||||
let acodec = str_or(f, "acodec", "none");
|
|
||||||
let furl = str_or(f, "url", "");
|
|
||||||
if furl.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let has_video = vcodec != "none" && !vcodec.is_empty();
|
|
||||||
let has_audio = acodec != "none" && !acodec.is_empty();
|
|
||||||
if !has_video && has_audio {
|
|
||||||
let mut score = f.get("abr").and_then(|x| x.as_f64()).unwrap_or(0.0);
|
|
||||||
if acodec.contains("mp4a") {
|
|
||||||
score += 1000.0;
|
|
||||||
}
|
|
||||||
if score > best_audio_score {
|
|
||||||
best_audio_score = score;
|
|
||||||
best_audio_url = furl.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quality list: adaptive video-only first, then progressive; dedupe by height.
|
|
||||||
let mut qualities = Vec::new();
|
|
||||||
let mut seen: HashSet<i64> = HashSet::new();
|
|
||||||
for want_progressive in [false, true] {
|
|
||||||
for f in formats {
|
|
||||||
let vcodec = str_or(f, "vcodec", "none");
|
|
||||||
let acodec = str_or(f, "acodec", "none");
|
|
||||||
let furl = str_or(f, "url", "");
|
|
||||||
if furl.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let has_video = vcodec != "none" && !vcodec.is_empty();
|
|
||||||
let has_audio = acodec != "none" && !acodec.is_empty();
|
|
||||||
if !has_video {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let is_progressive = has_audio;
|
|
||||||
if is_progressive != want_progressive {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let height = f.get("height").and_then(|x| x.as_i64()).unwrap_or(0);
|
|
||||||
if height <= 0 || seen.contains(&height) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
seen.insert(height);
|
|
||||||
qualities.push(json!({
|
|
||||||
"label": format!("{}p", height),
|
|
||||||
"height": height,
|
|
||||||
"hasAudio": is_progressive,
|
|
||||||
"url": furl,
|
|
||||||
"ext": str_or(f, "ext", ""),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
qualities.sort_by(|a, b| {
|
|
||||||
b["height"]
|
|
||||||
.as_i64()
|
|
||||||
.unwrap_or(0)
|
|
||||||
.cmp(&a["height"].as_i64().unwrap_or(0))
|
|
||||||
});
|
|
||||||
|
|
||||||
let channel = {
|
|
||||||
let c = str_or(&info, "channel", "");
|
|
||||||
if c.is_empty() {
|
|
||||||
str_or(&info, "uploader", "")
|
|
||||||
} else {
|
|
||||||
c
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
json!({
|
|
||||||
"ok": true,
|
|
||||||
"data": {
|
|
||||||
"meta": {
|
|
||||||
"id": video_id,
|
|
||||||
"title": str_or(&info, "title", "(untitled)"),
|
|
||||||
"channel": channel,
|
|
||||||
"duration": info.get("duration").and_then(|x| x.as_f64()).unwrap_or(0.0),
|
|
||||||
"thumbnail": format!("https://i.ytimg.com/vi/{}/hqdefault.jpg", video_id),
|
|
||||||
},
|
|
||||||
"audioUrl": if best_audio_url.is_empty() { Value::Null } else { Value::String(best_audio_url) },
|
|
||||||
"qualities": qualities,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn data_file(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
|
||||||
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
|
||||||
std::fs::create_dir_all(&dir).ok();
|
|
||||||
Ok(dir.join("ytplayer-data.json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn store_load(app: tauri::AppHandle) -> Value {
|
|
||||||
let default = json!({
|
|
||||||
"playlists": [],
|
|
||||||
"history": [],
|
|
||||||
"settings": { "quality": "auto", "volume": 1, "audioOnly": false }
|
|
||||||
});
|
|
||||||
let path = match data_file(&app) {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(_) => return default,
|
|
||||||
};
|
|
||||||
match std::fs::read_to_string(&path) {
|
|
||||||
Ok(s) => serde_json::from_str::<Value>(&s).unwrap_or(default),
|
|
||||||
Err(_) => default,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn store_save(app: tauri::AppHandle, data: String) -> Value {
|
|
||||||
let path = match data_file(&app) {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(e) => return json!({ "ok": false, "error": e }),
|
|
||||||
};
|
|
||||||
match std::fs::write(&path, data) {
|
|
||||||
Ok(_) => json!({ "ok": true }),
|
|
||||||
Err(e) => json!({ "ok": false, "error": e.to_string() }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
tauri::Builder::default()
|
|
||||||
.invoke_handler(tauri::generate_handler![
|
|
||||||
yt_search,
|
|
||||||
yt_streams,
|
|
||||||
store_load,
|
|
||||||
store_save
|
|
||||||
])
|
|
||||||
.run(tauri::generate_context!())
|
|
||||||
.expect("error while running tauri application");
|
|
||||||
}
|
|
||||||
72
tests/audio-watchdog.smoke.spec.js
Normal file
72
tests/audio-watchdog.smoke.spec.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Smoke test for the background-audio-resume watchdog (Task #50).
|
||||||
|
*
|
||||||
|
* iOS Safari — most visibly in PWA/standalone mode on iPhone — can silently
|
||||||
|
* pause a background <audio> element (screen lock, app switch) without the
|
||||||
|
* app reacting, while a muted foreground <video> element keeps rolling. This
|
||||||
|
* doesn't try to simulate real OS audio suspension (Playwright/Chromium
|
||||||
|
* can't), but it does verify:
|
||||||
|
* 1. The new visibilitychange/pageshow/focus listeners don't throw and are
|
||||||
|
* wired up without breaking page load.
|
||||||
|
* 2. Player.resumeIfNeeded() actually resumes a sounding element that is
|
||||||
|
* paused while intent (_wantsPlaying) is true, and leaves it alone when
|
||||||
|
* intent is false (a real user pause).
|
||||||
|
*/
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
test.describe('Background audio resume watchdog', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForSelector('.app', { state: 'attached' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('page loads with no JS errors and the new listeners are wired', async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (e) => errors.push(e.message));
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.dispatchEvent(new Event('visibilitychange'));
|
||||||
|
window.dispatchEvent(new Event('pageshow'));
|
||||||
|
window.dispatchEvent(new Event('focus'));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resumeIfNeeded() resumes a silently-paused sounding element while intent is true', async ({ page }) => {
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const audio = document.getElementById('audio');
|
||||||
|
// Simulate a loaded audio-only track without going through the native
|
||||||
|
// bridge (not available in this headless smoke context).
|
||||||
|
audio.src = 'data:audio/mpeg;base64,//uQxAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAACAAAJmwAeHh4eHh4eHh4eLi4uLi4uLi4uLi5AQEBAQEBAQEBAQFJSUlJSUlJSUlJSZGRkZGRkZGRkZGR2dnZ2dnZ2dnZ2dg==';
|
||||||
|
Player.mode = 'audio';
|
||||||
|
Player.master = audio;
|
||||||
|
Player.secondary = null;
|
||||||
|
Player._wantsPlaying = true;
|
||||||
|
audio.pause(); // simulate iOS silently pausing the sounding element
|
||||||
|
const pausedBefore = audio.paused;
|
||||||
|
Player.resumeIfNeeded();
|
||||||
|
// play() is async; give the browser a tick to apply it.
|
||||||
|
await new Promise((r) => setTimeout(r, 150));
|
||||||
|
return { pausedBefore, pausedAfter: audio.paused };
|
||||||
|
});
|
||||||
|
expect(result.pausedBefore).toBe(true);
|
||||||
|
expect(result.pausedAfter).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resumeIfNeeded() leaves a real user pause alone', async ({ page }) => {
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const audio = document.getElementById('audio');
|
||||||
|
audio.src = 'data:audio/mpeg;base64,//uQxAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAACAAAJmwAeHh4eHh4eHh4eLi4uLi4uLi4uLi5AQEBAQEBAQEBAQFJSUlJSUlJSUlJSZGRkZGRkZGRkZGR2dnZ2dnZ2dnZ2dg==';
|
||||||
|
Player.mode = 'audio';
|
||||||
|
Player.master = audio;
|
||||||
|
Player.secondary = null;
|
||||||
|
Player._wantsPlaying = false; // user explicitly paused
|
||||||
|
audio.pause();
|
||||||
|
Player.resumeIfNeeded();
|
||||||
|
await new Promise((r) => setTimeout(r, 150));
|
||||||
|
return { paused: audio.paused };
|
||||||
|
});
|
||||||
|
expect(result.paused).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
98
tests/fullscreen-ios.smoke.spec.js
Normal file
98
tests/fullscreen-ios.smoke.spec.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Smoke test for the iOS/PWA fullscreen-button fix (Task #52).
|
||||||
|
*
|
||||||
|
* Bug: on an iPhone running the installed PWA in portrait (standalone
|
||||||
|
* display-mode), tapping the fullscreen button did nothing. Root cause: iOS
|
||||||
|
* Safari does not implement the standard Fullscreen API
|
||||||
|
* (Element.requestFullscreen) for arbitrary elements — even in a standalone
|
||||||
|
* PWA — so `stage.requestFullscreen?.()` silently no-ops. WebKit instead
|
||||||
|
* exposes a video-only, non-standard fallback,
|
||||||
|
* HTMLVideoElement.webkitEnterFullscreen/webkitExitFullscreen, which does
|
||||||
|
* work in standalone mode.
|
||||||
|
*
|
||||||
|
* Playwright/Chromium implements the standard Fullscreen API, so to exercise
|
||||||
|
* the WebKit fallback branch we delete `requestFullscreen` from the stage
|
||||||
|
* element (simulating iOS Safari) and stub `webkitEnterFullscreen` /
|
||||||
|
* `webkitExitFullscreen` on the <video> element, then confirm the fallback
|
||||||
|
* is invoked instead of the button doing nothing.
|
||||||
|
*/
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
test.describe('Fullscreen button — iOS WebKit fallback', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForSelector('.app', { state: 'attached' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to webkitEnterFullscreen when requestFullscreen is unavailable (iOS Safari/PWA)', async ({ page }) => {
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const video = document.getElementById('video');
|
||||||
|
const stage = video.parentElement;
|
||||||
|
|
||||||
|
// Simulate iOS Safari: no standard Fullscreen API on the stage element.
|
||||||
|
delete stage.requestFullscreen;
|
||||||
|
|
||||||
|
let entered = false;
|
||||||
|
video.webkitEnterFullscreen = () => { entered = true; };
|
||||||
|
|
||||||
|
// Make sure we're not in audio-only mode so the fallback proceeds.
|
||||||
|
Player.mode = 'progressive';
|
||||||
|
|
||||||
|
document.getElementById('fsBtn').click();
|
||||||
|
|
||||||
|
return { entered };
|
||||||
|
});
|
||||||
|
expect(result.entered).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exits via webkitExitFullscreen when webkitDisplayingFullscreen is true', async ({ page }) => {
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const video = document.getElementById('video');
|
||||||
|
const stage = video.parentElement;
|
||||||
|
|
||||||
|
delete stage.requestFullscreen;
|
||||||
|
// No document.exitFullscreen available either, mirroring iOS.
|
||||||
|
Object.defineProperty(document, 'exitFullscreen', { value: undefined, configurable: true });
|
||||||
|
Object.defineProperty(video, 'webkitDisplayingFullscreen', { value: true, configurable: true });
|
||||||
|
|
||||||
|
let exited = false;
|
||||||
|
video.webkitExitFullscreen = () => { exited = true; };
|
||||||
|
|
||||||
|
document.getElementById('fsBtn').click();
|
||||||
|
|
||||||
|
return { exited };
|
||||||
|
});
|
||||||
|
expect(result.exited).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows a toast instead of a silent no-op in audio-only mode', async ({ page }) => {
|
||||||
|
const toastText = await page.evaluate(() => {
|
||||||
|
const video = document.getElementById('video');
|
||||||
|
const stage = video.parentElement;
|
||||||
|
|
||||||
|
delete stage.requestFullscreen;
|
||||||
|
video.webkitEnterFullscreen = () => {};
|
||||||
|
Player.mode = 'audio';
|
||||||
|
|
||||||
|
document.getElementById('fsBtn').click();
|
||||||
|
|
||||||
|
const el = document.querySelector('.toast-container .toast, .toast');
|
||||||
|
return el ? el.textContent : null;
|
||||||
|
});
|
||||||
|
expect(toastText).toContain('audio-only');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no JS errors are thrown when the fullscreen button is clicked', async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (e) => errors.push(e.message));
|
||||||
|
|
||||||
|
// The fsBtn is only visible once a video is loaded (.player-pane loses
|
||||||
|
// its .empty state) — dispatch the click via JS as the other tests in
|
||||||
|
// this file do, rather than requiring a real video load in this smoke
|
||||||
|
// test.
|
||||||
|
await page.evaluate(() => document.getElementById('fsBtn').click());
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
239
tests/portrait-pwa.spec.js
Normal file
239
tests/portrait-pwa.spec.js
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
/**
|
||||||
|
* Portrait PWA layout tests.
|
||||||
|
*
|
||||||
|
* Verifies that the Settings panel (and other list-pane views) are fully
|
||||||
|
* accessible in portrait orientation when the app is running as a PWA
|
||||||
|
* (standalone display mode).
|
||||||
|
*
|
||||||
|
* Because Playwright cannot set display-mode:standalone natively, we inject
|
||||||
|
* a small CSS override that activates the portrait media query rule-set via
|
||||||
|
* a data attribute, and also add the .portrait-pwa class that the JS watcher
|
||||||
|
* would normally add. This exercises the same CSS paths that fire in the real
|
||||||
|
* installed PWA.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
// Portrait dimensions (iPhone 12 / similar)
|
||||||
|
const PORTRAIT_WIDTH = 390;
|
||||||
|
const PORTRAIT_HEIGHT = 844;
|
||||||
|
const LANDSCAPE_WIDTH = 844;
|
||||||
|
const LANDSCAPE_HEIGHT = 390;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject an override so the portrait media-query CSS fires without needing
|
||||||
|
* an actual installed PWA. We piggyback on a custom data attribute.
|
||||||
|
*/
|
||||||
|
async function enablePortraitPwaMode(page) {
|
||||||
|
await page.addStyleTag({
|
||||||
|
content: `
|
||||||
|
/* Mirror (display-mode:standalone) and (orientation:portrait) rules for testing */
|
||||||
|
[data-portrait-pwa-test] .app { grid-template-columns: 1fr; }
|
||||||
|
[data-portrait-pwa-test] .bottom-nav { display: flex; }
|
||||||
|
[data-portrait-pwa-test] .sidebar-toggle { display: inline-flex; }
|
||||||
|
[data-portrait-pwa-test] .sidebar {
|
||||||
|
position: fixed; top:0; left:0; bottom:0;
|
||||||
|
width: min(86vw, 300px); max-width:300px; z-index:100;
|
||||||
|
border-right:1px solid var(--line-soft); box-shadow:var(--shadow);
|
||||||
|
transform:translateX(-100%); transition:transform 0.28s var(--ease);
|
||||||
|
}
|
||||||
|
[data-portrait-pwa-test] .app.sidebar-open .sidebar { transform:translateX(0); }
|
||||||
|
[data-portrait-pwa-test] .app.sidebar-open .sidebar-backdrop { display:block; opacity:1; }
|
||||||
|
[data-portrait-pwa-test] .topbar { position:sticky; top:0; z-index:50; }
|
||||||
|
[data-portrait-pwa-test] .body {
|
||||||
|
flex-direction:column;
|
||||||
|
overflow-y:auto; overflow-x:hidden;
|
||||||
|
overscroll-behavior:contain;
|
||||||
|
}
|
||||||
|
[data-portrait-pwa-test] .player-pane { flex:none; width:100%; overflow-y:visible; }
|
||||||
|
[data-portrait-pwa-test] .player-stage { width:100%; aspect-ratio:16/9; }
|
||||||
|
[data-portrait-pwa-test] .btn-row { flex-wrap:wrap; gap:7px; }
|
||||||
|
[data-portrait-pwa-test] .now-meta { flex-direction:column; gap:12px; margin-top:14px; }
|
||||||
|
[data-portrait-pwa-test] .np-actions { flex-wrap:wrap; gap:6px; }
|
||||||
|
[data-portrait-pwa-test] .list-pane {
|
||||||
|
flex:none; min-height:100%; width:100%;
|
||||||
|
border-left:none; border-top:1px solid var(--line-soft);
|
||||||
|
overflow-y:visible; overflow-x:hidden;
|
||||||
|
padding-bottom: 60px;
|
||||||
|
}
|
||||||
|
[data-portrait-pwa-test] .cards { max-height:none; overflow-y:visible; padding-bottom:0; }
|
||||||
|
[data-portrait-pwa-test] .settings { padding-bottom:20px; }
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.documentElement.setAttribute('data-portrait-pwa-test', '1');
|
||||||
|
// Also add the JS class that setupPortraitPwaWatcher would add
|
||||||
|
const app = document.querySelector('.app');
|
||||||
|
if (app) app.classList.add('portrait-pwa');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright cannot set display-mode:standalone, so isPortraitPWA() in app.js
|
||||||
|
* would return false and the JS scroll behaviors (scrollListIntoViewPortrait,
|
||||||
|
* scrollPlayerIntoViewPortrait) would no-op. Stub matchMedia for that one
|
||||||
|
* query BEFORE app.js loads so the JS layer behaves as in the installed PWA.
|
||||||
|
*/
|
||||||
|
async function stubStandalonePortraitMediaQuery(page) {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const orig = window.matchMedia.bind(window);
|
||||||
|
window.matchMedia = (q) => {
|
||||||
|
if (q.includes('display-mode: standalone') && q.includes('portrait')) {
|
||||||
|
return {
|
||||||
|
matches: true, media: q, onchange: null,
|
||||||
|
addEventListener: () => {}, removeEventListener: () => {},
|
||||||
|
addListener: () => {}, removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return orig(q);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Portrait PWA layout', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: PORTRAIT_WIDTH, height: PORTRAIT_HEIGHT });
|
||||||
|
await stubStandalonePortraitMediaQuery(page);
|
||||||
|
await page.goto('/');
|
||||||
|
// Wait for app JS to initialise
|
||||||
|
await page.waitForSelector('.app', { state: 'attached' });
|
||||||
|
await enablePortraitPwaMode(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('app starts in portrait without horizontal scroll', async ({ page }) => {
|
||||||
|
const bodyScrollWidth = await page.evaluate(() => document.body.scrollWidth);
|
||||||
|
const viewportWidth = await page.evaluate(() => window.innerWidth);
|
||||||
|
expect(bodyScrollWidth).toBeLessThanOrEqual(viewportWidth + 2); // allow 2px rounding
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Settings panel is accessible in portrait', async ({ page }) => {
|
||||||
|
// Open the sidebar drawer (hamburger)
|
||||||
|
await page.click('#sidebarToggle');
|
||||||
|
await page.waitForTimeout(350); // drawer animation
|
||||||
|
|
||||||
|
// Navigate to Settings
|
||||||
|
await page.click('[data-view="settings"]');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Settings content must exist inside the list pane
|
||||||
|
const settingsEl = await page.locator('.settings');
|
||||||
|
await expect(settingsEl).toBeVisible();
|
||||||
|
|
||||||
|
// The settings element must not be clipped — its bounding box bottom
|
||||||
|
// should be reachable (list-pane scrolls to accommodate it)
|
||||||
|
const listPane = page.locator('.list-pane');
|
||||||
|
await expect(listPane).toBeVisible();
|
||||||
|
|
||||||
|
const listPaneBox = await listPane.boundingBox();
|
||||||
|
expect(listPaneBox).not.toBeNull();
|
||||||
|
expect(listPaneBox.height).toBeGreaterThan(50); // must have meaningful height
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Settings panel scrolls to bottom without overlap', async ({ page }) => {
|
||||||
|
await page.click('#sidebarToggle');
|
||||||
|
await page.waitForTimeout(350);
|
||||||
|
await page.click('[data-view="settings"]');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Scroll list-pane to the bottom
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const lp = document.querySelector('.list-pane');
|
||||||
|
if (lp) lp.scrollTop = lp.scrollHeight;
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
|
||||||
|
// After scrolling, the last setting group should be visible
|
||||||
|
const lastGroup = page.locator('.set-group').last();
|
||||||
|
await expect(lastGroup).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('navigation works while a track is playing (player pane taller than viewport)', async ({ page }) => {
|
||||||
|
// Simulate the DOM state afterLoad() creates for a playing track: player
|
||||||
|
// pane populated with controls, meta, up-next and related — together
|
||||||
|
// taller than the viewport. This used to leave the list-pane below the
|
||||||
|
// fold with .body overflow:hidden, so every nav tap looked dead.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.getElementById('playerPane').classList.remove('empty');
|
||||||
|
document.getElementById('playerPlaceholder').classList.add('hidden');
|
||||||
|
document.getElementById('controls').classList.remove('hidden');
|
||||||
|
document.getElementById('nowPlayingMeta').classList.remove('hidden');
|
||||||
|
document.getElementById('npTitle').textContent = 'Test track';
|
||||||
|
document.getElementById('upnext').classList.remove('hidden');
|
||||||
|
document.getElementById('upnextList').innerHTML =
|
||||||
|
'<div class="upnext-item">queued</div>'.repeat(4);
|
||||||
|
document.getElementById('relatedPanel').classList.remove('hidden');
|
||||||
|
document.getElementById('relatedList').innerHTML =
|
||||||
|
'<div class="related-item">related</div>'.repeat(6);
|
||||||
|
document.getElementById('miniBar').classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sanity: the populated player pane really is taller than the viewport.
|
||||||
|
const paneHeight = await page.evaluate(
|
||||||
|
() => document.getElementById('playerPane').getBoundingClientRect().height
|
||||||
|
);
|
||||||
|
expect(paneHeight).toBeGreaterThan(PORTRAIT_HEIGHT);
|
||||||
|
|
||||||
|
// Navigate via the bottom nav.
|
||||||
|
await page.click('.bottom-nav-btn[data-view="history"]');
|
||||||
|
await page.waitForTimeout(700); // smooth scroll settle
|
||||||
|
|
||||||
|
// The view switched AND its pane is actually on-screen.
|
||||||
|
await expect(page.locator('#listTitle')).toHaveText('History');
|
||||||
|
const box = await page.locator('.list-pane').boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
expect(box.y).toBeLessThan(PORTRAIT_HEIGHT / 2); // top of the page is visible
|
||||||
|
expect(box.y + box.height).toBeGreaterThan(200); // and it has visible extent
|
||||||
|
|
||||||
|
// Sidebar navigation must work the same way.
|
||||||
|
await page.click('#sidebarToggle');
|
||||||
|
await page.waitForTimeout(350);
|
||||||
|
await page.click('.nav-item[data-view="saved"]');
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
await expect(page.locator('#listTitle')).toHaveText('Saved videos');
|
||||||
|
const box2 = await page.locator('.list-pane').boundingBox();
|
||||||
|
expect(box2.y).toBeLessThan(PORTRAIT_HEIGHT / 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switching landscape to portrait restores layout', async ({ page }) => {
|
||||||
|
// Start in landscape
|
||||||
|
await page.setViewportSize({ width: LANDSCAPE_WIDTH, height: LANDSCAPE_HEIGHT });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
// Switch back to portrait
|
||||||
|
await page.setViewportSize({ width: PORTRAIT_WIDTH, height: PORTRAIT_HEIGHT });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// App should not have horizontal overflow
|
||||||
|
const bodyScrollWidth = await page.evaluate(() => document.body.scrollWidth);
|
||||||
|
const viewportWidth = await page.evaluate(() => window.innerWidth);
|
||||||
|
expect(bodyScrollWidth).toBeLessThanOrEqual(viewportWidth + 2);
|
||||||
|
|
||||||
|
// List pane should be visible and have height
|
||||||
|
const listPane = page.locator('.list-pane');
|
||||||
|
await expect(listPane).toBeVisible();
|
||||||
|
const box = await listPane.boundingBox();
|
||||||
|
expect(box.height).toBeGreaterThan(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('landscape mode is unaffected — sidebar column visible at wide viewport', async ({ page }) => {
|
||||||
|
// Remove the portrait-pwa test overrides
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.documentElement.removeAttribute('data-portrait-pwa-test');
|
||||||
|
const app = document.querySelector('.app');
|
||||||
|
if (app) app.classList.remove('portrait-pwa');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switch to a wide desktop viewport
|
||||||
|
await page.setViewportSize({ width: 1280, height: 800 });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
// In landscape desktop, the sidebar should be visible (grid column)
|
||||||
|
const sidebar = page.locator('.sidebar');
|
||||||
|
await expect(sidebar).toBeVisible();
|
||||||
|
|
||||||
|
// list-pane should have a fixed width (452px desktop)
|
||||||
|
const listPaneBox = await page.locator('.list-pane').boundingBox();
|
||||||
|
expect(listPaneBox.width).toBeGreaterThan(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
72
tests/video-editor.smoke.spec.js
Normal file
72
tests/video-editor.smoke.spec.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Smoke test for the "Edit & download" custom-video feature (Task #68).
|
||||||
|
*
|
||||||
|
* The editor lets a user cut parts out of a video and save the result as a
|
||||||
|
* custom offline video that can be added to playlists like any other. This
|
||||||
|
* spec runs against the static frontend (no backend), so it exercises the
|
||||||
|
* pieces that don't need the yt-dlp/ffmpeg server round-trip:
|
||||||
|
* • video-edit.js loads and exposes window.VideoEdit with correct maths.
|
||||||
|
* • The now-playing "Edit & download" button (#editBtn) is present.
|
||||||
|
* • The editor modal opens, accepts a cut, and reports the right final
|
||||||
|
* length via the shared keep-segment maths.
|
||||||
|
*/
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
test.describe('Video editor — Edit & download', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForSelector('.app', { state: 'attached' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exposes window.VideoEdit with correct cut/keep maths', async ({ page }) => {
|
||||||
|
const ok = await page.evaluate(() => typeof window.VideoEdit === 'object' && window.VideoEdit !== null);
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
|
||||||
|
const keep = await page.evaluate(() =>
|
||||||
|
window.VideoEdit.invertCuts([{ start: 10, end: 20 }], 60));
|
||||||
|
expect(keep).toEqual([{ start: 0, end: 10 }, { start: 20, end: 60 }]);
|
||||||
|
|
||||||
|
const param = await page.evaluate(() =>
|
||||||
|
window.VideoEdit.keepToParam(window.VideoEdit.invertCuts([{ start: 10, end: 20 }], 60)));
|
||||||
|
expect(param).toBe('0-10,20-60');
|
||||||
|
|
||||||
|
const finalLen = await page.evaluate(() =>
|
||||||
|
window.VideoEdit.keepDuration(window.VideoEdit.invertCuts([{ start: 10, end: 20 }], 60)));
|
||||||
|
expect(finalLen).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the now-playing Edit & download button', async ({ page }) => {
|
||||||
|
const btn = page.locator('#editBtn');
|
||||||
|
await expect(btn).toHaveCount(1);
|
||||||
|
await expect(btn).toContainText('Edit');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editor modal opens for a source video and computes the final length', async ({ page }) => {
|
||||||
|
// Drive openVideoEditor directly with a fake source that has a known
|
||||||
|
// duration, so the test doesn't depend on network playback. OPFS may be
|
||||||
|
// absent in the test browser; if so the editor toasts and returns — assert
|
||||||
|
// whichever path this browser takes so the test is deterministic.
|
||||||
|
const supported = await page.evaluate(() =>
|
||||||
|
!!(window.OPFS && window.OPFS.isSupported && window.OPFS.isSupported()));
|
||||||
|
|
||||||
|
const opened = await page.evaluate(() => {
|
||||||
|
window.openVideoEditor({ id: 'testsource1', title: 'Sample', duration: 100, thumbnail: '' });
|
||||||
|
const modal = document.getElementById('modal');
|
||||||
|
return modal && !modal.classList.contains('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!supported) {
|
||||||
|
// Without OPFS the editor declines to open — that's the correct guard.
|
||||||
|
expect(opened).toBe(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(opened).toBe(true);
|
||||||
|
// Add a cut 0:10–0:40 → final length should be 100 - 30 = 70s = "1:10".
|
||||||
|
await page.fill('.video-editor .ve-from', '0:10');
|
||||||
|
await page.fill('.video-editor .ve-to', '0:40');
|
||||||
|
await page.click('.video-editor .ve-add');
|
||||||
|
await expect(page.locator('.video-editor .ve-cut')).toHaveCount(1);
|
||||||
|
await expect(page.locator('.video-editor .ve-summary')).toContainText('1:10');
|
||||||
|
});
|
||||||
|
});
|
||||||
67
tests/viewport-anchor.smoke.spec.js
Normal file
67
tests/viewport-anchor.smoke.spec.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Smoke test for the layout-viewport anchor guard.
|
||||||
|
*
|
||||||
|
* Bug: on an iPhone running the installed PWA, playing a video sometimes left
|
||||||
|
* the bottom-nav buttons unresponsive. iOS WebKit can scroll the document's
|
||||||
|
* layout viewport behind the app's back (exiting native video fullscreen,
|
||||||
|
* keyboard dismissal, scrollIntoView walking up into <html>) even though the
|
||||||
|
* body is overflow:hidden. Fixed elements are then still drawn in place but
|
||||||
|
* their hit-testing regions are offset by the stray scroll amount, so taps on
|
||||||
|
* the nav do nothing — and no user gesture can scroll the document back.
|
||||||
|
*
|
||||||
|
* The guard (setupViewportAnchorGuard in app.js) snaps the document back to 0
|
||||||
|
* whenever it ends up scrolled, except while an input is focused (so it never
|
||||||
|
* fights the on-screen keyboard); it re-anchors on blur instead.
|
||||||
|
*
|
||||||
|
* The static test page doesn't overflow, so each test injects a tall spacer
|
||||||
|
* and relaxes the overflow clamp — simulating the scrollable-document state
|
||||||
|
* iOS leaves behind.
|
||||||
|
*/
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
test.describe('Viewport anchor guard — stray document scroll', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForSelector('.app', { state: 'attached' });
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const spacer = document.createElement('div');
|
||||||
|
spacer.style.height = '3000px';
|
||||||
|
document.body.appendChild(spacer);
|
||||||
|
document.documentElement.style.overflow = 'visible';
|
||||||
|
document.body.style.overflow = 'visible';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snaps the document back to 0 after a stray window scroll', async ({ page }) => {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
// boot() focuses the search input; release it so the guard is active.
|
||||||
|
if (document.activeElement) document.activeElement.blur();
|
||||||
|
window.scrollTo(0, 400);
|
||||||
|
});
|
||||||
|
await page.waitForFunction(() => window.scrollY === 0);
|
||||||
|
expect(await page.evaluate(() => window.scrollY)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves the scroll alone while an input is focused (keyboard), re-anchors on blur', async ({ page }) => {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.getElementById('searchInput').focus();
|
||||||
|
window.scrollTo(0, 300);
|
||||||
|
});
|
||||||
|
// Guard must not fight the keyboard-driven scroll while editing.
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await page.evaluate(() => document.getElementById('searchInput').blur());
|
||||||
|
await page.waitForFunction(() => window.scrollY === 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('re-anchors when native video fullscreen exits (webkitendfullscreen)', async ({ page }) => {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
if (document.activeElement) document.activeElement.blur();
|
||||||
|
window.scrollTo(0, 250);
|
||||||
|
document.getElementById('video').dispatchEvent(new Event('webkitendfullscreen'));
|
||||||
|
});
|
||||||
|
await page.waitForFunction(() => window.scrollY === 0);
|
||||||
|
expect(await page.evaluate(() => window.scrollY)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user