Compare commits

..

24 Commits

Author SHA1 Message Date
Claude Worker
70460232f0 feat: Add scrubber with A/B markers for video editing/downloading 2026-07-18 17:59:20 +00:00
Claude Worker
046aa67108 feat: Add visual scrubber to video editor
Task #69 completed by ClaudeQueue

ClaudeQueue
2026-07-18 17:50:27 +00:00
Claude Worker
8c0776f97d feat: Add support for editing video
Add an Edit
2026-07-18 16:43:15 +00:00
Jonathan Sykes
94dba16ff6 Add plan-queue and run-queue skills 2026-07-16 20:51:51 +08:00
Jonathan Sykes
6fff68884b add per-song remember-position flag and playlist play-in-full mode 2026-07-14 15:15:11 +08:00
Jonathan Sykes
3afb22b305 document repo map and local dev, add deploy-prod skill
- CLAUDE.md gains a repo map, endpoint list, and local dev setup
  (server serves only ./public — symlink ../frontend once)
- new deploy-prod skill captures the Dokploy deploy + buildTag
  verification flow; track .agents/.claude skills and skills-lock.json
- ignore server/data, test-results, .claude/worktrees
2026-07-11 13:44:57 +08:00
Jonathan Sykes
70b5214ce8 fix intermittent 403 on video save by letting yt-dlp fetch the stream itself 2026-07-11 00:45:37 +08:00
Jonathan Sykes
88c89d66af fix portrait PWA nav dead while playing by making body the scroll container 2026-07-10 13:55:31 +08:00
Jonathan Sykes
b72c079fb4 respect repeat settings when a-b loop reaches point b 2026-07-04 18:11:21 +08:00
Jonathan Sykes
327a67004d Show build time in GMT+8 with 'built N ago' in Settings About 2026-07-04 12:49:41 +08:00
Jonathan Sykes
b8c7ad4ad1 fix: let list pane shrink in stacked layout so settings can scroll in mid-width windows 2026-07-03 06:05:16 +08:00
Jonathan Sykes
94140a5433 feat: add passkey-style online profiles for cross-device sync and show build time in About 2026-07-03 05:16:05 +08:00
Jonathan Sykes
048817bcf4 fix: fall back to copy when OPFS move() throws on WebKit and retry failed worker downloads on the main thread 2026-07-03 05:16:05 +08:00
Jonathan Sykes
76f8ec525d feat: add opt-in save-before-playing setting backed by server-compiled single-file downloads 2026-07-03 00:07:50 +08:00
Jonathan Sykes
ba05f0dafe fix: make audio the playback clock in dual mode so video rebuffering never pauses or seeks live audio; harden viewport anchor guard and add modal backdrop dismiss 2026-07-02 23:58:42 +08:00
Jonathan Sykes
ed9f93b0b7 fix: stop playback stutter and save failures via async yt-dlp, per-download OPFS workers, lazy playback blobs, GPU composite trims, and landscape pane scrolling 2026-07-02 23:40:49 +08:00
Jonathan Sykes
2af2eebacc feat: redesign portrait PWA as mobile app UI with thumb-zone transport, bottom-sheet modals, and floating mini-player 2026-07-02 22:38:14 +08:00
Jonathan Sykes
722457312c fix: re-anchor stray iOS layout-viewport scroll that broke nav taps 2026-07-02 21:48:31 +08:00
Jonathan Sykes
a73781a048 docs: record deploy procedure, update-flow architecture, and data-model quirks 2026-07-02 21:15:35 +08:00
Jonathan Sykes
4a18990058 fix: inject BUILD_TAG into sw.js via regex so a fallback bump cannot freeze the SW version 2026-07-02 21:07:22 +08:00
Jonathan Sykes
e6f032a6f2 feat: add shuffle toggle for upcoming tracks with S shortcut 2026-07-02 21:01:28 +08:00
Jonathan Sykes
6039dc80c1 fix: hash every served frontend file into the build tag so any shell change busts caches 2026-07-02 13:40:36 +08:00
Jonathan Sykes
dd71f6b2ab fix: fetch and activate the new service worker on Refresh UI when none is waiting yet 2026-07-02 13:40:36 +08:00
Jonathan Sykes
f36f6aeee1 fix: only treat a waiting service worker as an update when a controller exists 2026-07-02 13:40:36 +08:00
29 changed files with 3121 additions and 178 deletions

View 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 ≈ 56 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.

View 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
View File

@@ -0,0 +1 @@
../../.agents/skills/deploy-prod

View File

@@ -0,0 +1 @@
../../.agents/skills/mobile-app-ui-design

View 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).

View 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.

View 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

View 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.

View 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
View File

@@ -24,3 +24,9 @@ node_modules/
# local data
*.tmp
.DS_Store
# runtime/local artifacts
server/data/
test-results/
.claude/worktrees/
server/public

105
CLAUDE.md Normal file
View 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 ≈ 56 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.

View File

@@ -14,10 +14,13 @@ 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/*
@@ -39,6 +42,11 @@ 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

File diff suppressed because it is too large Load Diff

View File

@@ -135,6 +135,7 @@
<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">
<button id="muteBtn" class="ctrl" title="Mute">🔊</button>
<input id="volume" type="range" min="0" max="1" step="0.01" value="1" />
@@ -160,6 +161,7 @@
</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>
@@ -179,6 +181,7 @@
<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 &amp; download</button>
<button id="addPlaylistBtn" class="np-btn" title="Add to a playlist"> Playlist</button>
</div>
</div>
@@ -293,6 +296,7 @@
<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>
@@ -306,6 +310,7 @@
<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>

114
frontend/opfs-worker.js Normal file
View 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) });
}
};

View File

@@ -71,7 +71,11 @@
const [handle, name] = found;
const file = await handle.getFile();
const ext = name.slice(name.lastIndexOf('.') + 1);
const blob = new Blob([await file.arrayBuffer()], { type: extToMime(ext) });
// 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;
@@ -88,9 +92,42 @@
}
},
// 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');
@@ -117,19 +154,32 @@
throw err;
}
} else {
// Fallback: buffer entirely (older browsers)
const buf = await response.arrayBuffer();
const writable = await tmpHandle.createWritable();
await writable.write(buf);
await writable.close();
// 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. OPFS doesn't have rename, so: read + write + delete.
// 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 */ }

View File

@@ -1305,6 +1305,14 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
@media (max-width: 1080px) {
.body { flex-direction: column; }
.list-pane { width: auto; border-left: none; border-top: 1px solid var(--line-soft); }
/* In the stacked layout both panes must be allowed to shrink below their
content height (flex min-height defaults to auto), or they overflow the
hidden .body instead of engaging their own scrollbars — on a landscape
phone that made "Up next" unreachable. The base .list-pane rule sets
flex-shrink: 0 (right for the fixed-width side column); here the pane
must shrink or long views (Settings) get clipped with no scrollbar. */
.player-pane { min-height: 0; }
.list-pane { min-height: 0; flex-shrink: 1; }
}
/* ============================================================================
@@ -1757,14 +1765,17 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
z-index: 50;
}
/* --- Body: vertical stack (player above list) -------------------------- */
/* --- Body: vertical stack (player above list), THE scroll container ---- */
.body {
flex-direction: column;
overflow: hidden;
/* Body itself does NOT scroll — player-pane is fixed height,
list-pane takes remaining space and scrolls independently.
This keeps the player always visible and gives the list/settings
the full remaining viewport to scroll in. */
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
/* The body is the single scroll container in portrait. A playing player
pane (stage + control deck + meta + up-next + related) is taller than
the whole viewport, so it must be able to scroll out of the way — with
overflow:hidden here the list-pane sat unreachable below the fold and
every sidebar/bottom-nav tap looked dead while a track was playing. */
}
/* --- Player pane: full width, compact vertical padding ----------------- */
@@ -1800,14 +1811,16 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
gap: 6px;
}
/* --- List pane: full width below player, fills remaining height, scrolls independently --- */
/* --- List pane: full width below player, flows in the body scroll ------ */
.list-pane {
flex: 1;
min-height: 0; /* required for flex children to shrink below content size */
flex: none;
/* At least one full body-height so navigating always yields a full-screen
page; longer content just grows the body scroll. */
min-height: 100%;
width: 100%;
border-left: none;
border-top: 1px solid var(--line-soft);
overflow-y: auto;
overflow-y: visible;
overflow-x: hidden;
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
@@ -1878,6 +1891,278 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
.hero-tagline { font-size: 13px; }
}
/* ============================================================================
* INSTALLED PWA ON TOUCH DEVICES — drop expensive full-screen composites
*
* The film-grain overlay (mix-blend-mode over the whole viewport), the fixed
* gradient wash, and the chrome backdrop-blurs all re-composite over the
* playing <video> every frame. On iPhones that GPU contention audibly
* stutters audio — backgrounding the PWA (which stops rendering) made
* playback smooth, which is how this was diagnosed. Same cuts as
* data-perf="on", applied automatically where they hurt most; desktop
* browsers and fine-pointer devices are untouched.
* ========================================================================== */
@media (display-mode: standalone) and (pointer: coarse) {
body::before,
body::after { display: none; }
.topbar,
.bottom-nav,
.mini-bar-inner {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.player-pane:not(.empty) .player-stage {
box-shadow: 0 0 0 1px var(--line) inset;
}
}
/* ============================================================================
* PORTRAIT PWA — mobile app design refresh
*
* Same media query as the structural block above; being later in the cascade,
* these rules win ties. Scope is intentionally identical so desktop, browser
* tabs, and landscape are never affected. Design goals (mobile-app UI):
* • 44px minimum tap targets, thumb-zone transport controls
* • music-app control deck: centered shuffle/prev/play/next/repeat row,
* secondary tools on a second row; volume slider hidden (hardware keys)
* • bottom-sheet modals, floating mini-player card, nav active pill
* • 16px input font to stop iOS focus-zoom
* ========================================================================== */
@media (display-mode: standalone) and (orientation: portrait) {
/* --- Global touch polish ---------------------------------------------- */
::-webkit-scrollbar { width: 0; height: 0; }
button, .card, .nav-item, .playlist-item { -webkit-tap-highlight-color: transparent; }
/* --- Topbar: app-bar with pill search --------------------------------- */
.sidebar-toggle {
width: 44px;
height: 44px;
border-radius: 14px;
}
.search-form { min-width: 0; } /* flex item: allow shrinking to the viewport */
#searchInput {
height: 44px;
min-width: 0; /* let the pill shrink below intrinsic input width */
border-radius: 22px;
padding: 0 18px;
font-size: 16px; /* ≥16px prevents iOS focus-zoom */
}
.search-btn {
height: 44px;
border-radius: 22px;
padding: 0 18px;
}
/* --- Player pane ------------------------------------------------------- */
.player-pane {
padding: 12px calc(12px + env(safe-area-inset-right)) 12px
calc(12px + env(safe-area-inset-left));
}
.player-stage { border-radius: 16px; }
/* --- Control deck: two-row music-app transport ------------------------- */
.controls {
margin-top: 12px;
border-radius: 18px;
padding: 14px 14px 16px;
}
.seek-row { gap: 10px; }
/* Fatter seek track + thumb for touch */
.seek { height: 6px; }
.seek::-webkit-slider-thumb { width: 18px; height: 18px; }
.btn-row {
justify-content: center;
gap: 10px 8px;
margin-top: 14px;
}
/* Forced line break between transport row (order ≤ 5) and tool row (≥ 7) */
.btn-row::before { content: ""; width: 100%; order: 6; height: 0; }
/* Row A — transport, centered in the thumb zone */
#shuffleBtn { order: 1; }
#prevBtn { order: 2; }
#playBtn { order: 3; }
#nextBtn { order: 4; }
#repeatBtn { order: 5; }
#prevBtn, #nextBtn, #shuffleBtn, #repeatBtn {
background: transparent;
border-color: transparent;
width: 48px;
height: 48px;
border-radius: 50%;
}
#prevBtn, #nextBtn { font-size: 20px; }
#shuffleBtn, #repeatBtn { font-size: 17px; color: var(--text-2); }
#shuffleBtn.active, #repeatBtn.active {
color: var(--accent-bright);
background: rgba(255, 75, 50, 0.16);
border-color: transparent;
box-shadow: none;
}
#playBtn {
width: 64px;
height: 64px;
border-radius: 50%;
font-size: 24px;
margin: 0 6px;
}
/* Row B — secondary tools, uniform 44px targets (sizes only: colors and
.active/.sleep-active states keep their class-based styling) */
#abABtn, #abBBtn, #abClearBtn, #muteBtn, #loopBtn, #sleepTimerBtn, #fsBtn {
order: 7;
min-width: 44px;
height: 44px;
border-radius: 13px;
}
.vol { order: 7; }
.vol input { display: none; } /* hardware volume keys on mobile */
.btn-row .spacer { display: none; }
.btn-row .sel { order: 8; }
.sel select {
height: 44px;
border-radius: 13px;
padding: 0 10px;
}
/* --- Now playing meta: full-width action grid --------------------------- */
.now-meta { margin-top: 16px; }
.np-title { font-size: 18px; }
.np-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
width: 100%;
}
.np-btn {
padding: 12px 8px;
text-align: center;
border-radius: 13px;
}
/* --- Up next / related: comfier touch rows ------------------------------ */
.upnext { border-radius: 18px; }
.upnext-item { padding: 8px; border-radius: 12px; }
.upnext-item img { width: 96px; border-radius: 8px; }
.related-panel { border-radius: 18px; }
.related-item { padding: 8px; border-radius: 12px; }
.related-item img { width: 96px; border-radius: 8px; }
/* --- List pane: large title, touch-sized rows --------------------------- */
.list-header { padding: 18px 16px 10px; }
.list-header h2 { font-size: 24px; letter-spacing: -0.02em; }
.list-actions button { padding: 9px 13px; border-radius: 10px; }
.list-filter-bar input {
padding: 11px 14px;
border-radius: 13px;
font-size: 16px;
}
.batch-btn { padding: 10px 14px; border-radius: 10px; }
.cards { gap: 8px; padding-left: 12px; padding-right: 12px; }
.card { padding: 10px; gap: 12px; border-radius: 16px; }
.thumb { border-radius: 12px; }
.card-title { font-size: 14px; }
.card-channel { margin-top: 6px; }
/* No hover on touch — keep row actions always visible and finger-sized */
.card-menu { width: 38px; height: 38px; border-radius: 12px; opacity: 1; }
.card-del { opacity: 1; width: 36px; height: 36px; border-radius: 11px; }
/* --- Settings: roomier rows --------------------------------------------- */
.set-group { border-radius: 18px; }
.set-row { padding: 14px 0; }
.set-row input[type="checkbox"] { width: 22px; height: 22px; }
.set-select { padding: 10px 12px; border-radius: 11px; }
/* --- Sidebar drawer: rounded sheet edge --------------------------------- */
.sidebar {
border-right: none;
border-radius: 0 22px 22px 0;
}
.nav-item { padding: 13px 14px; font-size: 15px; }
.playlist-item { padding: 12px 14px; }
.switch { padding: 12px 10px; }
/* --- Bottom nav: active pill indicator ---------------------------------- */
.bottom-nav-btn { padding: 8px 4px 10px; gap: 4px; }
.bottom-nav-icon {
font-size: 21px;
padding: 4px 16px;
border-radius: 16px;
transition: background 0.2s var(--ease), transform 0.18s var(--ease);
}
.bottom-nav-btn.active .bottom-nav-icon {
background: rgba(255, 75, 50, 0.16);
transform: none;
}
.bottom-nav-badge { top: 4px; margin-left: 10px; }
/* --- Mini player: floating card above the nav --------------------------- */
.mini-bar {
left: 10px;
right: 10px;
bottom: calc(72px + env(safe-area-inset-bottom));
border-radius: 16px;
overflow: hidden;
border: 1px solid var(--line);
box-shadow: 0 12px 34px -10px rgba(0, 0, 0, 0.65);
}
.mini-bar-inner {
border-top: none;
padding: 10px 6px 10px 14px;
}
.mini-btn { width: 40px; height: 40px; border-radius: 12px; }
.mini-close { width: 36px; height: 36px; }
/* List pane clearance for taller nav + floating mini-bar */
.list-pane { padding-bottom: calc(152px + env(safe-area-inset-bottom)); }
/* --- Toasts: clear the floating mini-bar -------------------------------- */
.toast-container { bottom: calc(148px + env(safe-area-inset-bottom)); }
.toast { white-space: normal; max-width: calc(100vw - 32px); text-align: center; }
/* --- Modals: bottom sheet ----------------------------------------------- */
.modal-backdrop {
align-items: flex-end;
padding: 0;
z-index: 400; /* above mini-bar (300) and bottom-nav (310) */
}
.modal {
width: 100%;
max-width: 100%;
border-radius: 22px 22px 0 0;
border-left: none;
border-right: none;
border-bottom: none;
padding: 12px 20px calc(24px + env(safe-area-inset-bottom));
animation: sheetUp 0.3s var(--ease);
}
.modal::before {
content: "";
display: block;
width: 40px;
height: 4px;
border-radius: 2px;
background: var(--line);
margin: 0 auto 16px;
}
.modal input[type="text"] { font-size: 16px; padding: 13px 15px; border-radius: 13px; }
.modal-list button { padding: 14px 15px; border-radius: 13px; }
.modal-actions { margin-top: 22px; }
.modal-actions .btn { padding: 13px 20px; border-radius: 13px; }
/* --- Shortcut overlay: fit narrow screens ------------------------------- */
.shortcut-panel { min-width: 0; width: 100%; max-width: 480px; }
}
@keyframes sheetUp {
from { opacity: 0; transform: translateY(40%); }
to { opacity: 1; transform: none; }
}
/* ============================================================================
* PORTRAIT PWA — JS-class mirror (.portrait-pwa on .app)
*
@@ -1922,3 +2207,88 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
padding-right: calc(14px + env(safe-area-inset-right, 0px));
}
}
/* ============================================================================
* Video editor modal (Edit & download) + edited-video badges
* ========================================================================== */
.video-editor { display: flex; flex-direction: column; gap: 12px; }
.video-editor .ve-intro { margin: 0; color: var(--text-2); font-size: 13px; line-height: 1.5; }
.video-editor .ve-add-row {
display: flex; align-items: flex-end; gap: 8px; flex-wrap: wrap;
}
.video-editor .ve-add-row label {
display: flex; flex-direction: column; gap: 4px;
font-size: 12px; color: var(--text-2); flex: 1 1 90px;
}
.video-editor .ve-add-row input {
background: var(--bg-2); border: 1px solid var(--line); color: var(--text);
border-radius: var(--radius-sm); padding: 8px 10px; font-size: 14px; width: 100%;
}
.video-editor .ve-add-row input:focus { outline: none; border-color: var(--accent); }
.video-editor .ve-add { flex: 0 0 auto; }
.video-editor .ve-error {
color: var(--accent-bright); font-size: 12.5px; margin: -4px 0 0;
}
.video-editor .ve-cuts { display: flex; flex-direction: column; gap: 6px; }
.video-editor .ve-cut {
display: flex; align-items: center; justify-content: space-between;
background: var(--bg-2); border: 1px solid var(--line);
border-radius: var(--radius-sm); padding: 7px 10px; font-size: 13.5px;
}
.video-editor .ve-cut-del {
background: transparent; border: none; color: var(--text-2);
cursor: pointer; font-size: 14px; padding: 2px 6px; border-radius: 6px;
}
.video-editor .ve-cut-del:hover { color: #fff; background: var(--accent); }
.video-editor .ve-title-row {
display: flex; flex-direction: column; gap: 4px;
font-size: 12px; color: var(--text-2);
}
.video-editor .ve-title {
background: var(--bg-2); border: 1px solid var(--line); color: var(--text);
border-radius: var(--radius-sm); padding: 8px 10px; font-size: 14px;
}
.video-editor .ve-title:focus { outline: none; border-color: var(--accent); }
.video-editor .ve-summary { color: var(--text); font-size: 13.5px; }
/* Badge marking an edited (custom) copy in the Saved list and on cards. */
.edit-badge {
position: absolute; top: 4px; left: 4px;
background: var(--accent); color: #fff; font-size: 11px;
line-height: 1; padding: 3px 5px; border-radius: 6px;
box-shadow: 0 2px 6px -2px var(--accent-glow);
}
.saved-card.custom .thumb { position: relative; }
/* Video Editor Scrubber */
.ve-scrubber-track {
height: 30px;
background: var(--bg-3);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
position: relative;
cursor: pointer;
margin: 10px 0;
}
.ve-scrubber-bar {
height: 100%;
background: var(--bg-2);
border-radius: var(--radius-sm);
}
.ve-marker {
position: absolute;
top: 0;
width: 20px;
height: 100%;
background: var(--accent);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 10px;
font-weight: bold;
cursor: pointer;
pointer-events: auto;
}
.ve-marker-a { left: 0; }
.ve-marker-b { right: 0; }

View File

@@ -36,8 +36,20 @@
* @returns {Promise<void>}
*/
async function applyUpdate({ reg, container, reload, setTimeout: setTimeoutFn }) {
const waiting = reg && reg.waiting;
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
@@ -60,6 +72,25 @@
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) {

View File

@@ -93,6 +93,76 @@ test('falls back to a plain reload when there is no waiting worker', async () =>
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;

View File

@@ -47,6 +47,7 @@ const SHELL = [
'/sw-update.js',
'/fingerprint.js',
'/opfs.js',
'/opfs-worker.js',
'/app.js',
'/manifest.webmanifest',
'/icons/icon-192.png',

161
frontend/video-edit.js Normal file
View 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);

View 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);
});

View File

@@ -53,9 +53,55 @@ export async function initDb() {
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.

View File

@@ -22,15 +22,18 @@
import { Hono } from 'hono';
import { serveStatic } from 'hono/bun';
import { logger } from 'hono/logger';
import { spawnSync } from 'node:child_process';
import { spawn } from 'node:child_process';
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
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 } from './db.js';
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.
@@ -51,10 +54,19 @@ const YTDLP = process.env.YTDLP_PATH || 'yt-dlp';
// ----------------------------------------------------------------------------
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');
for (const file of ['app.js', 'sw.js', 'index.html', 'styles.css']) {
hash.update(readFileSync(`./public/${file}`));
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) —
@@ -66,6 +78,14 @@ function computeBuildTag() {
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;
@@ -73,19 +93,89 @@ const CHANNEL_LIMIT = 60;
// yt-dlp helpers
// ============================================================================
// Run yt-dlp synchronously and return stdout as a string.
// Throws on non-zero exit.
// 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) {
const result = spawnSync(YTDLP, args, {
encoding: 'utf8',
maxBuffer: 32 * 1024 * 1024, // 32 MB — large channel dumps can be big
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);
});
if (result.error) throw new Error('yt-dlp not found: ' + result.error.message);
if (result.status !== 0) {
const err = (result.stderr || '').trim();
throw new Error(err || 'yt-dlp exited with code ' + result.status);
});
}
// 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 result.stdout || '';
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
@@ -159,7 +249,7 @@ app.use('*', logger());
// 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 },
{ version: APP_VERSION, buildTag: BUILD_TAG, buildTime: BUILD_TIME },
200,
{ 'Cache-Control': 'no-store, no-cache, must-revalidate' }
)
@@ -171,7 +261,7 @@ app.get('/api/search', async (c) => {
if (!q) return c.json({ ok: false, error: 'empty query' }, 400);
try {
const out = runYtdlp([
const out = await runYtdlp([
`ytsearch${SEARCH_LIMIT}:${q}`,
'--dump-json', '--flat-playlist',
'--no-warnings', '--ignore-errors',
@@ -189,7 +279,7 @@ app.get('/api/channel', async (c) => {
try {
const url = channelToUrl(chan);
const out = runYtdlp([
const out = await runYtdlp([
url,
'--dump-json', '--flat-playlist',
'--no-warnings', '--ignore-errors',
@@ -222,7 +312,7 @@ app.get('/api/streams', async (c) => {
try {
const url = `https://www.youtube.com/watch?v=${videoId}`;
const out = runYtdlp(['-J', '--no-warnings', url]);
const out = await runYtdlp(['-J', '--no-warnings', url]);
const info = JSON.parse(out);
const formats = Array.isArray(info.formats) ? info.formats : [];
@@ -278,56 +368,244 @@ app.get('/api/streams', async (c) => {
}
});
// GET /api/download/:videoId
// Resolves the best progressive (audio+video single-file) stream URL via
// yt-dlp and proxies the binary 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);
// 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 {
const url = `https://www.youtube.com/watch?v=${videoId}`;
// --get-url with bestvideo+bestaudio/best format is not what we want
// here — we need a SINGLE FILE so no ffmpeg muxing is required in the
// browser. Use -f "bestvideo[ext=mp4][acodec!=none]/best[ext=mp4]/best"
const out = runYtdlp([
url,
'--no-warnings',
'-f', 'bestvideo[ext=mp4][acodec!=none]/bestvideo[acodec!=none]/best[ext=mp4]/best',
'--get-url',
await runYtdlp([
`https://www.youtube.com/watch?v=${videoId}`,
'--no-warnings', '--no-playlist',
...formatArgs,
'-N', '4',
'-o', tmp,
]);
const streamUrl = out.trim();
if (!streamUrl) throw new Error('No stream URL returned');
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 });
// Fetch from YouTube and pipe to the client
const upstream = await fetch(streamUrl, {
if (fp) recordVideoAccess(fp, { id: videoId }).catch(() => {});
return new Response(Readable.toWeb(stream), {
status: 200,
headers: {
// Mimic a browser to avoid 403s from YouTube CDN
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
'Referer': 'https://www.youtube.com/',
},
});
if (!upstream.ok) throw new Error(`Upstream error ${upstream.status}`);
const contentType = upstream.headers.get('content-type') || 'video/mp4';
const contentLength = upstream.headers.get('content-length');
const headers = new Headers({
'Content-Type': contentType,
'Content-Type': 'video/mp4',
'Content-Length': String(size),
'Content-Disposition': `attachment; filename="${videoId}.mp4"`,
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': '*',
},
});
if (contentLength) headers.set('Content-Length', contentLength);
}
// Log the access if a fingerprint was supplied (fire-and-forget)
// "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');
if (fp) {
recordVideoAccess(fp, { id: videoId }).catch(() => {});
// ?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);
}
}
return new Response(upstream.body, { status: 200, headers });
// ?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);
}
@@ -383,11 +661,18 @@ app.get('/sw.js', (c) => {
return c.text('Service worker not found', 404);
}
}
// Inject the build tag: replace the placeholder with the real value.
// 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__ : 'v1.0.3'",
/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',
@@ -414,6 +699,10 @@ async function main() {
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}`);

11
skills-lock.json Normal file
View 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"
}
}
}

View File

@@ -1,4 +0,0 @@
{
"status": "passed",
"failedTests": []
}

View File

@@ -29,6 +29,7 @@ async function enablePortraitPwaMode(page) {
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;
@@ -39,16 +40,20 @@ async function enablePortraitPwaMode(page) {
[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:hidden; }
[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:1; min-height:0; width:100%;
flex:none; min-height:100%; width:100%;
border-left:none; border-top:1px solid var(--line-soft);
overflow-y:auto; overflow-x:hidden;
overflow-y:visible; overflow-x:hidden;
padding-bottom: 60px;
}
[data-portrait-pwa-test] .cards { max-height:none; overflow-y:visible; padding-bottom:0; }
@@ -63,9 +68,33 @@ async function enablePortraitPwaMode(page) {
});
}
/**
* 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' });
@@ -119,6 +148,53 @@ test.describe('Portrait PWA layout', () => {
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 });

View 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:100: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');
});
});

View 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);
});
});