Compare commits
170 Commits
613f8fb902
...
codex/jame
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15f554430b | ||
|
|
7f49c317fd | ||
|
|
f0daeaedd2 | ||
|
|
7950e83c08 | ||
|
|
295ac6fb30 | ||
|
|
0c387a01cd | ||
|
|
e0802aa8a8 | ||
|
|
865c60cb85 | ||
|
|
ef12468a99 | ||
|
|
b3f0525a8e | ||
|
|
d02796b0eb | ||
|
|
7591c2d14c | ||
|
|
6bb3271431 | ||
|
|
27b32ff0c9 | ||
|
|
aca50c1690 | ||
|
|
76037613a7 | ||
|
|
d6a3570ff9 | ||
|
|
459a27b6eb | ||
|
|
933e920701 | ||
|
|
a1048f1c30 | ||
|
|
1e83951972 | ||
|
|
9a37715da4 | ||
|
|
0a5b113ebe | ||
|
|
6ae81d1024 | ||
|
|
52e51278d5 | ||
|
|
d027696d4e | ||
|
|
4ba5130c80 | ||
|
|
ea221a4e90 | ||
|
|
8a4144eb00 | ||
|
|
dd09f66578 | ||
|
|
cf27d4f15a | ||
|
|
9c988912de | ||
|
|
6f0235f506 | ||
|
|
a497180352 | ||
|
|
2001e77cee | ||
|
|
f161a5de74 | ||
|
|
0b8e398469 | ||
|
|
2fc6d5d4dc | ||
|
|
f359e5dd39 | ||
|
|
2c8d563d01 | ||
|
|
6b8d590638 | ||
|
|
009a155568 | ||
|
|
0251720fa2 | ||
|
|
97f4a7396e | ||
|
|
b59fc7264a | ||
|
|
14716e648b | ||
|
|
71608c2ce8 | ||
|
|
66bbda51c7 | ||
|
|
ff3f934717 | ||
|
|
3de4cba402 | ||
|
|
4b1f5204f8 | ||
|
|
9408ee07e5 | ||
|
|
97eb31e732 | ||
|
|
57edb410c3 | ||
|
|
c4a512bea6 | ||
|
|
3505dc3c0e | ||
|
|
639f0a2257 | ||
|
|
a86fa1cebe | ||
|
|
e7a7cdc6b9 | ||
|
|
799b1e7ed6 | ||
|
|
af6aabc3b7 | ||
|
|
21c12733d3 | ||
|
|
a08d363f2d | ||
|
|
ce5a159d4a | ||
|
|
f133e1bb9f | ||
|
|
a7473cbca2 | ||
|
|
2c5003575c | ||
|
|
b335f95f45 | ||
|
|
0475a1826c | ||
|
|
94ea211605 | ||
|
|
8e7fa1959a | ||
|
|
5fb9564468 | ||
|
|
3c9a24ae45 | ||
|
|
cdd26b6ae5 | ||
|
|
cd4a25ae23 | ||
|
|
b84050578b | ||
|
|
fab444b7b8 | ||
|
|
46ec9ab2ca | ||
|
|
c1a546a5c6 | ||
|
|
40b92ebc0d | ||
|
|
524a7b7f1d | ||
|
|
173eab8026 | ||
|
|
ccfb7d90ba | ||
|
|
5d6a1545df | ||
|
|
d98d444552 | ||
|
|
4853c2c977 | ||
|
|
e1cb450032 | ||
|
|
f804a23761 | ||
|
|
895f20a4c9 | ||
|
|
4950d3e417 | ||
|
|
92404563b3 | ||
|
|
5d1e5ec791 | ||
|
|
bb446c717f | ||
|
|
5b25ccaa86 | ||
|
|
54a07a0a2a | ||
|
|
04421a2145 | ||
|
|
a1f16f2e3b | ||
|
|
7434f2f486 | ||
|
|
7c45d098b1 | ||
|
|
84546eeb7d | ||
|
|
d18d17cf29 | ||
|
|
6cb00d5073 | ||
|
|
ad85cb6eb4 | ||
|
|
be1d939afd | ||
|
|
491ec407a1 | ||
|
|
dce186c187 | ||
|
|
93d3b05263 | ||
|
|
eb6aae7ae7 | ||
|
|
09125f3f5c | ||
|
|
7bc5b4f8b5 | ||
|
|
d65b9e032a | ||
|
|
fcd98b6a4d | ||
|
|
4bc15a55e3 | ||
|
|
64f8eae92b | ||
|
|
3f37c2a9ef | ||
|
|
233bf94e4c | ||
|
|
722df3125f | ||
|
|
2e58924971 | ||
|
|
b6dd2be40f | ||
|
|
b77938ab4a | ||
|
|
b4c776dcfe | ||
|
|
2b286ae146 | ||
|
|
128f8e95e3 | ||
|
|
63ff2173d8 | ||
|
|
a143c8331d | ||
|
|
5b259a1ac1 | ||
|
|
1eda52e4c5 | ||
|
|
3be7a7b194 | ||
|
|
bd80ad1ea0 | ||
|
|
c30fd9fe78 | ||
|
|
605d21a3e2 | ||
|
|
cc4fb0d5c9 | ||
|
|
af38affdbe | ||
|
|
034b3df7ae | ||
|
|
d4497b0c05 | ||
|
|
67fc6f584f | ||
|
|
a81f4ffa51 | ||
|
|
1cad18826b | ||
|
|
1ba3f8704b | ||
|
|
0bda242a98 | ||
|
|
1754933f7e | ||
|
|
4e5b1584c6 | ||
|
|
9589c38773 | ||
|
|
3baf67349f | ||
|
|
e100d47143 | ||
|
|
838798ca9b | ||
|
|
04b620e3c7 | ||
|
|
2042c9b6fc | ||
|
|
df4acfc9f1 | ||
|
|
536555e12d | ||
|
|
d43cce441b | ||
|
|
25ae810463 | ||
|
|
528e7c1ea1 | ||
|
|
c6074bdbc5 | ||
|
|
49ba470740 | ||
|
|
729e73e131 | ||
|
|
3c0adb2577 | ||
|
|
d3f5133572 | ||
|
|
90ef21297a | ||
|
|
d78b06ff4b | ||
|
|
5661eea6e6 | ||
|
|
795a5aaed4 | ||
|
|
08ff8398b5 | ||
|
|
88e2e67eeb | ||
|
|
ef70da1806 | ||
|
|
d93fc0d70f | ||
|
|
79a3c28c8e | ||
|
|
5e78374efb | ||
|
|
f341b0c76e | ||
|
|
5485a3c2f4 |
12
.gitignore
vendored
12
.gitignore
vendored
@@ -38,3 +38,15 @@ legacy/releases/
|
||||
|
||||
# python caches from scripts/lyrics
|
||||
__pycache__/
|
||||
plans/codex-shots*/
|
||||
plans/codex-report*.md
|
||||
|
||||
# performance harness artifacts and temp files
|
||||
perf/fixtures/
|
||||
perf/.tmp/
|
||||
|
||||
# UI regression screenshots and local measurements
|
||||
plans/ui-shots/
|
||||
|
||||
# Transport regression screenshots
|
||||
plans/transport-shots/
|
||||
|
||||
@@ -12,6 +12,15 @@
|
||||
],
|
||||
"createdAt": "2026-10-02T23:14:10.804Z",
|
||||
"reason": "Agent verified all three matching images are hidden dynamic artwork targets (artImg, serviceArt, rvThumb); app.js assigns their src when media metadata is available."
|
||||
},
|
||||
{
|
||||
"rule": "layout-transition",
|
||||
"value": "*",
|
||||
"files": [
|
||||
"frontend/theme-f7.css"
|
||||
],
|
||||
"createdAt": "2026-10-05T18:33:43.625Z",
|
||||
"reason": "Framework7 swipeout reveal retains its existing width transition for the action area"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
target_identity: "file:/home/josh/development/personal/ytplayer/frontend/index.html"
|
||||
target_fingerprint: "sha256:5f16cc1d66caa47d5d1ae35afe92b5206825e66772d974d72fd90effac161d27"
|
||||
target_path: /home/josh/development/personal/ytplayer/frontend/index.html
|
||||
timestamp: 2026-10-05T20-14-49Z
|
||||
slug: frontend-index-html
|
||||
---
|
||||
Method: dual-agent (A: /root/critique_design · B: /root/critique_evidence)
|
||||
|
||||
# Design Health Score
|
||||
|
||||
| # | Heuristic | Score | Key issue |
|
||||
|---|---|---:|---|
|
||||
| 1 | Visibility of System Status | 3/4 | Active tab and playback state are clear; row actions depend on an undisclosed swipe. |
|
||||
| 2 | Match System / Real World | 3/4 | Grouped lists, bottom tabs and sheet use familiar iOS patterns. |
|
||||
| 3 | User Control and Freedom | 3/4 | Player and sheets can be collapsed or closed; long swipe removal remains a high consequence gesture. |
|
||||
| 4 | Consistency and Standards | 3/4 | F7 components now share the same token-based visual treatment; some underlying CSS layers overlap. |
|
||||
| 5 | Error Prevention | 2/4 | Long-left removes a row without a confirmation step. |
|
||||
| 6 | Recognition Rather Than Recall | 2/4 | Swipe row actions are not apparent before discovery. |
|
||||
| 7 | Flexibility and Efficiency | 3/4 | Gestures and bottom tabs offer quick navigation for touch users. |
|
||||
| 8 | Aesthetic and Minimalist Design | 3/4 | Light, dark and contrast treatments stay restrained; row actions can obscure content while open. |
|
||||
| 9 | Error Recovery | 2/4 | No explicit undo is provided for a swipe removal. |
|
||||
| 10 | Help and Documentation | 1/4 | Only the mini-player has a visual gesture affordance; row and tab gestures lack a persistent hint. |
|
||||
| **Total** | | **25/40** | **Acceptable; interaction discovery is the main gap.** |
|
||||
|
||||
# Design Specificity Verdict
|
||||
|
||||
**LLM assessment:** The F7 layout now reads as a media player: native line icons, an inset media list, accent-led controls, glass navigation and a sheet for Up Next. The large title hierarchy and player/list relationship fit the product. It is no longer just the mini-player that changes. The remaining weakest point is that row actions and tab movement rely on gesture knowledge users may not have.
|
||||
|
||||
**Deterministic scan:** The scoped Impeccable detector scan across `frontend/index.html`, `frontend/app.js`, `frontend/f7-layout.js`, `frontend/fullscreen-orientation.css`, `frontend/fullscreen-orientation.js` and `frontend/theme-f7.css` reported 129 findings: 60 warnings and 69 advisories. Categories: low contrast 22, design-system-radius 35, undersized-ui-text 22, design-system-font-size 20, thin-border/wide-shadow 10, dark-glow 4, design-system-color 4, clipped overflow 3, gradient text 2, layout transition 2, tiny text 2, marquee 2, pulsing dot 1. It found no issues in the two gesture/fullscreen logic modules. Most radius/type findings refer to the intended F7 control scale or circular/icon controls; many remaining findings point to shared prototype content and pre-existing theme variants in the large single HTML document. The contrast findings around bright accent fills and dim secondary text merit future contextual review, especially against the detector's assumed body-text size.
|
||||
|
||||
**Browser visualization:** The detector overlay could not be injected into the selected live page. The available browser inspection loaded the default Classic layout and returned `Unexpected token '<'` from API/script requests served as HTML, so no reliable user-visible overlay is available. Separate Playwright captures were made on the static frontend with the F7 layout enabled and inspected directly.
|
||||
|
||||
# Cognitive Load
|
||||
|
||||
The tab bar reduces destination recall to six visible choices. The list header, filter and grouped items scan predictably. Hidden row actions and swipe-to-change-tab are not discoverable from the screen itself; the small grabber only hints at mini-player movement and does not label what the movement does. No screen presents more than four primary decision points at once.
|
||||
|
||||
# Emotional Journey
|
||||
|
||||
Search and queue selection are direct, and the full-player sheet preserves the sense of continuity while browsing. Filling the screen with a real video makes fullscreen feel intentional. A long swipe that removes a row can create a negative surprise because the outcome is not previewed or recoverable.
|
||||
|
||||
# What's Working
|
||||
|
||||
- The F7 treatment now spans search, sidebar, home, list, settings, player, mini-player, tabs, sheets and auxiliary panels.
|
||||
- Fill/Fit is explicit, persisted, and also toggles by center double-tap or multi-touch.
|
||||
- The classic regression pair is pixel-identical at 390×844.
|
||||
|
||||
# Priority Issues
|
||||
|
||||
1. **[P2] Row gestures are hard to discover.** Users see an ellipsis, but do not know that left/right reveals Playlist, Remove and Queue. Add a one-time, dismissible hint or a small visible interaction cue. Suggested command: `/impeccable clarify`.
|
||||
2. **[P2] Swipe removal has weak recovery.** A long left swipe removes immediately, with no Undo affordance. Give the toast a short Undo action or reduce the destructive gesture to a normal revealed button. Suggested command: `/impeccable harden`.
|
||||
|
||||
# Persona Red Flags
|
||||
|
||||
- **Jordan (First-Timer):** Can find Search and Queue from the bottom bar, but cannot infer that a song row can be swiped to add it to a playlist or remove it.
|
||||
- **Alex (Power User):** Can move quickly through tabs and the mini-player, but a long-left removal has no quick undo if the wrong row is removed.
|
||||
- **Morgan (Accessibility-focused listener):** Touch actions have generous targets, but gestures have no equivalent hint and depend on a screen reader user discovering the menu path.
|
||||
|
||||
# Minor Observations
|
||||
|
||||
- The critique found overlapping F7 declarations in `theme-f7.css`; current precedence is predictable but the file would be easier to maintain if these layers were consolidated.
|
||||
- The queue/settings duplicate heading and the redundant settings list filter were fixed during this pass; Queue's Play action is now the filled primary action.
|
||||
- The section rail remains usable as a compact floating control above the tab bar.
|
||||
- The detector's font-size advisories in compact badges and section labels are intentional density choices; check any future increase in text scale against them.
|
||||
|
||||
# Questions to Consider
|
||||
|
||||
- Would a single dismissible swipe hint be enough, or should Queue/Playlist/Remove stay visible behind each row menu?
|
||||
- Should long-left remove remain as a shortcut if the toast gains Undo, or should removal require tapping the revealed action?
|
||||
@@ -54,6 +54,12 @@ JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone
|
||||
refreshed every 1.5 s by the `glassStage` IIFE), four stacked icon tiles and secondary actions as a pill row,
|
||||
floating mini player. Elements that only this theme shows (`.stg-overlay`, `.skip10`, `.deck-chips`, `.vol-pod`
|
||||
extras, `.mode-name`) are in the shared markup and hidden by default.
|
||||
- **f7-swipe** (`frontend/f7-layout.js` + `theme-f7.css`): classic look + Framework7 gestures. Loads
|
||||
`vendor/framework7-swipe.min.js` (F7 9.2.0 core+Store+Swipeout+Modal+Sheet, rebuilt from
|
||||
`vendor/framework7-swipe.entry.js`) only when picked. Cards are wrapped in `li.swipeout` (Queue / Playlist /
|
||||
Remove, via `card._video`), swipe up on the mini bar = F7 "Up next" sheet, sideways = next/prev, left-edge
|
||||
swipe = sidebar. F7's touch module swallows programmatic `.click()`s, so `app.off('click')` +
|
||||
`app.off('touchend')` run after init — keep that, or app.js buttons driven by `.click()` silently stop working.
|
||||
- Now-playing tiles are `[svg.ti][span.tl]`; change state text with `setTile(btn, label)`, never `textContent`.
|
||||
|
||||
## Saved searches + caching notes
|
||||
@@ -554,3 +560,5 @@ field being typed in and reset the scroll position.
|
||||
## 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.
|
||||
|
||||
- Server code must not import `../frontend/...` directly: the Docker image copies frontend/ to `server/public/`. Use the existsSync fallback pattern (see piano.js / direct-relay.js).
|
||||
|
||||
34
docker-compose.beta.yml
Normal file
34
docker-compose.beta.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
# Beta stack (beta.worship.hesed.sbs). Separate from prod on purpose: own named
|
||||
# volumes (no shared DB or media drive), no hardcoded Traefik labels (Dokploy
|
||||
# injects the domain routing), no lyrics/piano workers.
|
||||
services:
|
||||
ytplayer:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ytplayer-beta-data:/app/data
|
||||
environment:
|
||||
PORT: "3000"
|
||||
DB_PATH: "/app/data/ytplayer.db"
|
||||
MEDIA_DIR: "/app/data/media"
|
||||
UPLOAD_DIR: "/app/data/uploads"
|
||||
MEDIA_CACHE_MAX_BYTES: "10737418240"
|
||||
APP_VERSION: "1.0.0-beta"
|
||||
ADMIN_PASSWORD: "${ADMIN_PASSWORD:-}"
|
||||
P2P_ENABLED: "${P2P_ENABLED:-0}"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:3000/api/version"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- dokploy-network
|
||||
|
||||
volumes:
|
||||
ytplayer-beta-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
dokploy-network:
|
||||
external: true
|
||||
904
docs/design/f7-swipe-contrast.json
Normal file
904
docs/design/f7-swipe-contrast.json
Normal file
@@ -0,0 +1,904 @@
|
||||
{
|
||||
"title": "YT Player \u2014 Framework7 Swipe (contrast)",
|
||||
"brief": "Ad-free YouTube/worship player PWA. Framework7 iOS-style layout with swipe gestures, built on the app's own tokens.",
|
||||
"frame": "phone",
|
||||
"platform": "web",
|
||||
"paletteKey": "coral",
|
||||
"theme": {
|
||||
"dark": true,
|
||||
"bothModes": false,
|
||||
"contrast": "high",
|
||||
"shape": "rounded",
|
||||
"font": "system",
|
||||
"emphasized": false,
|
||||
"motion": "expressive"
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"id": "search",
|
||||
"name": "Search",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"note": "Home + search results. Big title, rounded search field, results as an inset grouped list.",
|
||||
"swipe": {
|
||||
"left": "queue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "queue",
|
||||
"name": "Queue",
|
||||
"x": 492,
|
||||
"y": 0,
|
||||
"note": "Queued songs. Swipe a row left for Playlist/Remove (long swipe removes), right for Queue. Swipe sideways on empty space to change tab.",
|
||||
"swipe": {
|
||||
"right": "search"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "player",
|
||||
"name": "Now playing",
|
||||
"x": 984,
|
||||
"y": 0,
|
||||
"note": "Video on top, Apple Music\u2013style transport, icon action row. Swipe down to collapse to the mini player.",
|
||||
"swipe": {
|
||||
"down": "queue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "upnext",
|
||||
"name": "Up next",
|
||||
"x": 1476,
|
||||
"y": 0,
|
||||
"note": "Bottom sheet opened by swiping up on the mini player. Half height, drag up for full, down to close."
|
||||
},
|
||||
{
|
||||
"id": "fullscreen",
|
||||
"name": "Fullscreen",
|
||||
"x": 1968,
|
||||
"y": 0,
|
||||
"note": "Edge-to-edge video (Fill by default). Minimal icon-only controls over a soft bottom gradient; auto-hide.",
|
||||
"bg": "inverseSurface"
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": "g2",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i1",
|
||||
"kind": "topAppBar",
|
||||
"label": "Search",
|
||||
"icon": "menu",
|
||||
"variant": "filled",
|
||||
"icon2": "directions_car",
|
||||
"note": "Translucent navbar; menu opens the sidebar (also left-edge swipe)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g4",
|
||||
"x": 16,
|
||||
"y": 96,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i3",
|
||||
"kind": "searchBar",
|
||||
"label": "Search YouTube",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"icon2": "mic",
|
||||
"note": "Rounded F7 searchbar with inline mic; Enter searches."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g8",
|
||||
"x": 16,
|
||||
"y": 164,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i5",
|
||||
"kind": "chip",
|
||||
"label": "lofi beats",
|
||||
"icon": null,
|
||||
"variant": "filled"
|
||||
},
|
||||
{
|
||||
"id": "i6",
|
||||
"kind": "chip",
|
||||
"label": "live news",
|
||||
"icon": null,
|
||||
"variant": "filled"
|
||||
},
|
||||
{
|
||||
"id": "i7",
|
||||
"kind": "chip",
|
||||
"label": "worship",
|
||||
"icon": null,
|
||||
"variant": "filled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g10",
|
||||
"x": 16,
|
||||
"y": 212,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i9",
|
||||
"kind": "text",
|
||||
"label": "Results",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 28,
|
||||
"bold": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g15",
|
||||
"x": 16,
|
||||
"y": 260,
|
||||
"axis": "y",
|
||||
"items": [
|
||||
{
|
||||
"id": "i11",
|
||||
"kind": "listItem",
|
||||
"label": "Goodness of God",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Bethel Music \u00b7 5:12",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i12",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i13",
|
||||
"kind": "listItem",
|
||||
"label": "Build My Life",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Housefires \u00b7 6:04",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i14",
|
||||
"kind": "listItem",
|
||||
"label": "Graves Into Gardens",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Elevation \u00b7 6:41",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g17",
|
||||
"x": 16,
|
||||
"y": 700,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i16",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "music_note",
|
||||
"variant": "tonal",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "play_arrow",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slideUp"
|
||||
},
|
||||
"note": "Floating glass mini player. Tap opens the player, swipe left/right = next/previous, swipe up = Up next sheet."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g19",
|
||||
"x": 0,
|
||||
"y": 788,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i18",
|
||||
"kind": "bottomNav",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"tabs": [
|
||||
{
|
||||
"icon": "search",
|
||||
"label": "Search"
|
||||
},
|
||||
{
|
||||
"icon": "queue_music",
|
||||
"label": "Queue"
|
||||
},
|
||||
{
|
||||
"icon": "history",
|
||||
"label": "History"
|
||||
},
|
||||
{
|
||||
"icon": "download_done",
|
||||
"label": "Saved"
|
||||
},
|
||||
{
|
||||
"icon": "more_horiz",
|
||||
"label": "More"
|
||||
}
|
||||
],
|
||||
"selected": 0,
|
||||
"actions": {
|
||||
"tab:0": {
|
||||
"to": "search",
|
||||
"transition": "fade"
|
||||
},
|
||||
"tab:1": {
|
||||
"to": "queue",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
"note": "Translucent blurred tab bar; badge on Queue."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g21",
|
||||
"x": 492,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i20",
|
||||
"kind": "topAppBar",
|
||||
"label": "Queue",
|
||||
"icon": "menu",
|
||||
"variant": "filled",
|
||||
"icon2": "checklist"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g24",
|
||||
"x": 508,
|
||||
"y": 96,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i22",
|
||||
"kind": "button",
|
||||
"label": "Play queue",
|
||||
"icon": "play_arrow",
|
||||
"variant": "filled",
|
||||
"size": 182
|
||||
},
|
||||
{
|
||||
"id": "i23",
|
||||
"kind": "button",
|
||||
"label": "Clear",
|
||||
"icon": "clear_all",
|
||||
"variant": "tonal",
|
||||
"size": 182
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g29",
|
||||
"x": 508,
|
||||
"y": 168,
|
||||
"axis": "y",
|
||||
"items": [
|
||||
{
|
||||
"id": "i25",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "drag_handle",
|
||||
"note": "Swipe right \u2192 Queue (accent action)."
|
||||
},
|
||||
{
|
||||
"id": "i26",
|
||||
"kind": "listItem",
|
||||
"label": "Build My Life",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Housefires \u00b7 6:04",
|
||||
"icon2": "drag_handle",
|
||||
"note": "Swipe left \u2192 Playlist + Remove pills."
|
||||
},
|
||||
{
|
||||
"id": "i27",
|
||||
"kind": "listItem",
|
||||
"label": "Graves Into Gardens",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Elevation \u00b7 6:41",
|
||||
"icon2": "drag_handle",
|
||||
"note": "Long swipe left removes the row."
|
||||
},
|
||||
{
|
||||
"id": "i28",
|
||||
"kind": "listItem",
|
||||
"label": "Goodness of God",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Bethel Music \u00b7 5:12",
|
||||
"icon2": "drag_handle",
|
||||
"note": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g32",
|
||||
"x": 508,
|
||||
"y": 480,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i30",
|
||||
"kind": "button",
|
||||
"label": "Playlist",
|
||||
"icon": "playlist_add",
|
||||
"variant": "tonal"
|
||||
},
|
||||
{
|
||||
"id": "i31",
|
||||
"kind": "button",
|
||||
"label": "Remove",
|
||||
"icon": "delete",
|
||||
"variant": "filled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g34",
|
||||
"x": 508,
|
||||
"y": 548,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i33",
|
||||
"kind": "text",
|
||||
"label": "Swipe actions revealed (example row)",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g36",
|
||||
"x": 508,
|
||||
"y": 700,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i35",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "music_note",
|
||||
"variant": "tonal",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "play_arrow",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slideUp"
|
||||
},
|
||||
"note": "Floating glass mini player. Tap opens the player, swipe left/right = next/previous, swipe up = Up next sheet."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g38",
|
||||
"x": 492,
|
||||
"y": 788,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i37",
|
||||
"kind": "bottomNav",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"tabs": [
|
||||
{
|
||||
"icon": "search",
|
||||
"label": "Search"
|
||||
},
|
||||
{
|
||||
"icon": "queue_music",
|
||||
"label": "Queue"
|
||||
},
|
||||
{
|
||||
"icon": "history",
|
||||
"label": "History"
|
||||
},
|
||||
{
|
||||
"icon": "download_done",
|
||||
"label": "Saved"
|
||||
},
|
||||
{
|
||||
"icon": "more_horiz",
|
||||
"label": "More"
|
||||
}
|
||||
],
|
||||
"selected": 1,
|
||||
"actions": {
|
||||
"tab:0": {
|
||||
"to": "search",
|
||||
"transition": "fade"
|
||||
},
|
||||
"tab:1": {
|
||||
"to": "queue",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
"note": "Translucent blurred tab bar; badge on Queue."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g40",
|
||||
"x": 984,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i39",
|
||||
"kind": "topAppBar",
|
||||
"label": "Now playing",
|
||||
"icon": "keyboard_arrow_down",
|
||||
"variant": "filled",
|
||||
"icon2": "more_horiz",
|
||||
"actions": {
|
||||
"icon": {
|
||||
"to": "back",
|
||||
"transition": "slideDown"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g42",
|
||||
"x": 984,
|
||||
"y": 88,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i41",
|
||||
"kind": "image",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 412,
|
||||
"note": "16:9 video stage; the fullscreen button opens Fullscreen."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g44",
|
||||
"x": 1000,
|
||||
"y": 320,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i43",
|
||||
"kind": "text",
|
||||
"label": "Way Maker",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 24,
|
||||
"bold": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g46",
|
||||
"x": 1000,
|
||||
"y": 356,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i45",
|
||||
"kind": "text",
|
||||
"label": "Leeland",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 16
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g48",
|
||||
"x": 1000,
|
||||
"y": 396,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i47",
|
||||
"kind": "slider",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"value": 35,
|
||||
"note": "Thin seek track, accent fill."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g54",
|
||||
"x": 1048,
|
||||
"y": 456,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i49",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "skip_previous",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i50",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "replay_10",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i51",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "play_arrow",
|
||||
"variant": "filled"
|
||||
},
|
||||
{
|
||||
"id": "i52",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "forward_10",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i53",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "skip_next",
|
||||
"variant": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g61",
|
||||
"x": 1024,
|
||||
"y": 540,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i55",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "playlist_add",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i56",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "download",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i57",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "lyrics",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i58",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "share",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i59",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "fullscreen",
|
||||
"variant": "text",
|
||||
"action": {
|
||||
"to": "fullscreen",
|
||||
"transition": "expand"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i60",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "more_horiz",
|
||||
"variant": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g63",
|
||||
"x": 984,
|
||||
"y": 788,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i62",
|
||||
"kind": "bottomNav",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"tabs": [
|
||||
{
|
||||
"icon": "search",
|
||||
"label": "Search"
|
||||
},
|
||||
{
|
||||
"icon": "queue_music",
|
||||
"label": "Queue"
|
||||
},
|
||||
{
|
||||
"icon": "history",
|
||||
"label": "History"
|
||||
},
|
||||
{
|
||||
"icon": "download_done",
|
||||
"label": "Saved"
|
||||
},
|
||||
{
|
||||
"icon": "more_horiz",
|
||||
"label": "More"
|
||||
}
|
||||
],
|
||||
"selected": 1,
|
||||
"actions": {
|
||||
"tab:0": {
|
||||
"to": "search",
|
||||
"transition": "fade"
|
||||
},
|
||||
"tab:1": {
|
||||
"to": "queue",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
"note": "Translucent blurred tab bar; badge on Queue."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g65",
|
||||
"x": 1476,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i64",
|
||||
"kind": "topAppBar",
|
||||
"label": "Queue",
|
||||
"icon": "menu",
|
||||
"variant": "filled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g67",
|
||||
"x": 1476,
|
||||
"y": 380,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i66",
|
||||
"kind": "box",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 412,
|
||||
"size2": 512,
|
||||
"checked": true,
|
||||
"fill": "surfaceContainerHigh",
|
||||
"note": "F7 sheet with grab handle; backdrop tap closes."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g69",
|
||||
"x": 1492,
|
||||
"y": 404,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i68",
|
||||
"kind": "text",
|
||||
"label": "Up next",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 20,
|
||||
"bold": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g73",
|
||||
"x": 1492,
|
||||
"y": 448,
|
||||
"axis": "y",
|
||||
"items": [
|
||||
{
|
||||
"id": "i70",
|
||||
"kind": "listItem",
|
||||
"label": "Build My Life",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Housefires \u00b7 6:04",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i71",
|
||||
"kind": "listItem",
|
||||
"label": "Graves Into Gardens",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Elevation \u00b7 6:41",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i72",
|
||||
"kind": "listItem",
|
||||
"label": "Goodness of God",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Bethel Music \u00b7 5:12",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g75",
|
||||
"x": 1968,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i74",
|
||||
"kind": "box",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 412,
|
||||
"size2": 892,
|
||||
"fill": "inverseSurface",
|
||||
"note": "Video fills the screen (object-fit cover). Fit/Fill toggle in the controls."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g77",
|
||||
"x": 1984,
|
||||
"y": 24,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i76",
|
||||
"kind": "text",
|
||||
"label": "Way Maker \u2014 Leeland",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g79",
|
||||
"x": 2316,
|
||||
"y": 12,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i78",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "fullscreen_exit",
|
||||
"variant": "text",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g81",
|
||||
"x": 1984,
|
||||
"y": 790,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i80",
|
||||
"kind": "slider",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"value": 35
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g85",
|
||||
"x": 1984,
|
||||
"y": 828,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i82",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "pause",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i83",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "screen_rotation",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i84",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "fit_screen",
|
||||
"variant": "text",
|
||||
"note": "Fit \u2194 Fill."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
904
docs/design/f7-swipe-dark.json
Normal file
904
docs/design/f7-swipe-dark.json
Normal file
@@ -0,0 +1,904 @@
|
||||
{
|
||||
"title": "YT Player \u2014 Framework7 Swipe (dark)",
|
||||
"brief": "Ad-free YouTube/worship player PWA. Framework7 iOS-style layout with swipe gestures, built on the app's own tokens.",
|
||||
"frame": "phone",
|
||||
"platform": "web",
|
||||
"paletteKey": "coral",
|
||||
"theme": {
|
||||
"dark": true,
|
||||
"bothModes": true,
|
||||
"contrast": "standard",
|
||||
"shape": "rounded",
|
||||
"font": "system",
|
||||
"emphasized": false,
|
||||
"motion": "expressive"
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"id": "search",
|
||||
"name": "Search",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"note": "Home + search results. Big title, rounded search field, results as an inset grouped list.",
|
||||
"swipe": {
|
||||
"left": "queue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "queue",
|
||||
"name": "Queue",
|
||||
"x": 492,
|
||||
"y": 0,
|
||||
"note": "Queued songs. Swipe a row left for Playlist/Remove (long swipe removes), right for Queue. Swipe sideways on empty space to change tab.",
|
||||
"swipe": {
|
||||
"right": "search"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "player",
|
||||
"name": "Now playing",
|
||||
"x": 984,
|
||||
"y": 0,
|
||||
"note": "Video on top, Apple Music\u2013style transport, icon action row. Swipe down to collapse to the mini player.",
|
||||
"swipe": {
|
||||
"down": "queue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "upnext",
|
||||
"name": "Up next",
|
||||
"x": 1476,
|
||||
"y": 0,
|
||||
"note": "Bottom sheet opened by swiping up on the mini player. Half height, drag up for full, down to close."
|
||||
},
|
||||
{
|
||||
"id": "fullscreen",
|
||||
"name": "Fullscreen",
|
||||
"x": 1968,
|
||||
"y": 0,
|
||||
"note": "Edge-to-edge video (Fill by default). Minimal icon-only controls over a soft bottom gradient; auto-hide.",
|
||||
"bg": "inverseSurface"
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": "g2",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i1",
|
||||
"kind": "topAppBar",
|
||||
"label": "Search",
|
||||
"icon": "menu",
|
||||
"variant": "filled",
|
||||
"icon2": "directions_car",
|
||||
"note": "Translucent navbar; menu opens the sidebar (also left-edge swipe)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g4",
|
||||
"x": 16,
|
||||
"y": 96,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i3",
|
||||
"kind": "searchBar",
|
||||
"label": "Search YouTube",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"icon2": "mic",
|
||||
"note": "Rounded F7 searchbar with inline mic; Enter searches."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g8",
|
||||
"x": 16,
|
||||
"y": 164,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i5",
|
||||
"kind": "chip",
|
||||
"label": "lofi beats",
|
||||
"icon": null,
|
||||
"variant": "filled"
|
||||
},
|
||||
{
|
||||
"id": "i6",
|
||||
"kind": "chip",
|
||||
"label": "live news",
|
||||
"icon": null,
|
||||
"variant": "filled"
|
||||
},
|
||||
{
|
||||
"id": "i7",
|
||||
"kind": "chip",
|
||||
"label": "worship",
|
||||
"icon": null,
|
||||
"variant": "filled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g10",
|
||||
"x": 16,
|
||||
"y": 212,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i9",
|
||||
"kind": "text",
|
||||
"label": "Results",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 28,
|
||||
"bold": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g15",
|
||||
"x": 16,
|
||||
"y": 260,
|
||||
"axis": "y",
|
||||
"items": [
|
||||
{
|
||||
"id": "i11",
|
||||
"kind": "listItem",
|
||||
"label": "Goodness of God",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Bethel Music \u00b7 5:12",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i12",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i13",
|
||||
"kind": "listItem",
|
||||
"label": "Build My Life",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Housefires \u00b7 6:04",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i14",
|
||||
"kind": "listItem",
|
||||
"label": "Graves Into Gardens",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Elevation \u00b7 6:41",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g17",
|
||||
"x": 16,
|
||||
"y": 700,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i16",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "music_note",
|
||||
"variant": "tonal",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "play_arrow",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slideUp"
|
||||
},
|
||||
"note": "Floating glass mini player. Tap opens the player, swipe left/right = next/previous, swipe up = Up next sheet."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g19",
|
||||
"x": 0,
|
||||
"y": 788,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i18",
|
||||
"kind": "bottomNav",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"tabs": [
|
||||
{
|
||||
"icon": "search",
|
||||
"label": "Search"
|
||||
},
|
||||
{
|
||||
"icon": "queue_music",
|
||||
"label": "Queue"
|
||||
},
|
||||
{
|
||||
"icon": "history",
|
||||
"label": "History"
|
||||
},
|
||||
{
|
||||
"icon": "download_done",
|
||||
"label": "Saved"
|
||||
},
|
||||
{
|
||||
"icon": "more_horiz",
|
||||
"label": "More"
|
||||
}
|
||||
],
|
||||
"selected": 0,
|
||||
"actions": {
|
||||
"tab:0": {
|
||||
"to": "search",
|
||||
"transition": "fade"
|
||||
},
|
||||
"tab:1": {
|
||||
"to": "queue",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
"note": "Translucent blurred tab bar; badge on Queue."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g21",
|
||||
"x": 492,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i20",
|
||||
"kind": "topAppBar",
|
||||
"label": "Queue",
|
||||
"icon": "menu",
|
||||
"variant": "filled",
|
||||
"icon2": "checklist"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g24",
|
||||
"x": 508,
|
||||
"y": 96,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i22",
|
||||
"kind": "button",
|
||||
"label": "Play queue",
|
||||
"icon": "play_arrow",
|
||||
"variant": "filled",
|
||||
"size": 182
|
||||
},
|
||||
{
|
||||
"id": "i23",
|
||||
"kind": "button",
|
||||
"label": "Clear",
|
||||
"icon": "clear_all",
|
||||
"variant": "tonal",
|
||||
"size": 182
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g29",
|
||||
"x": 508,
|
||||
"y": 168,
|
||||
"axis": "y",
|
||||
"items": [
|
||||
{
|
||||
"id": "i25",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "drag_handle",
|
||||
"note": "Swipe right \u2192 Queue (accent action)."
|
||||
},
|
||||
{
|
||||
"id": "i26",
|
||||
"kind": "listItem",
|
||||
"label": "Build My Life",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Housefires \u00b7 6:04",
|
||||
"icon2": "drag_handle",
|
||||
"note": "Swipe left \u2192 Playlist + Remove pills."
|
||||
},
|
||||
{
|
||||
"id": "i27",
|
||||
"kind": "listItem",
|
||||
"label": "Graves Into Gardens",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Elevation \u00b7 6:41",
|
||||
"icon2": "drag_handle",
|
||||
"note": "Long swipe left removes the row."
|
||||
},
|
||||
{
|
||||
"id": "i28",
|
||||
"kind": "listItem",
|
||||
"label": "Goodness of God",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Bethel Music \u00b7 5:12",
|
||||
"icon2": "drag_handle",
|
||||
"note": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g32",
|
||||
"x": 508,
|
||||
"y": 480,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i30",
|
||||
"kind": "button",
|
||||
"label": "Playlist",
|
||||
"icon": "playlist_add",
|
||||
"variant": "tonal"
|
||||
},
|
||||
{
|
||||
"id": "i31",
|
||||
"kind": "button",
|
||||
"label": "Remove",
|
||||
"icon": "delete",
|
||||
"variant": "filled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g34",
|
||||
"x": 508,
|
||||
"y": 548,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i33",
|
||||
"kind": "text",
|
||||
"label": "Swipe actions revealed (example row)",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g36",
|
||||
"x": 508,
|
||||
"y": 700,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i35",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "music_note",
|
||||
"variant": "tonal",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "play_arrow",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slideUp"
|
||||
},
|
||||
"note": "Floating glass mini player. Tap opens the player, swipe left/right = next/previous, swipe up = Up next sheet."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g38",
|
||||
"x": 492,
|
||||
"y": 788,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i37",
|
||||
"kind": "bottomNav",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"tabs": [
|
||||
{
|
||||
"icon": "search",
|
||||
"label": "Search"
|
||||
},
|
||||
{
|
||||
"icon": "queue_music",
|
||||
"label": "Queue"
|
||||
},
|
||||
{
|
||||
"icon": "history",
|
||||
"label": "History"
|
||||
},
|
||||
{
|
||||
"icon": "download_done",
|
||||
"label": "Saved"
|
||||
},
|
||||
{
|
||||
"icon": "more_horiz",
|
||||
"label": "More"
|
||||
}
|
||||
],
|
||||
"selected": 1,
|
||||
"actions": {
|
||||
"tab:0": {
|
||||
"to": "search",
|
||||
"transition": "fade"
|
||||
},
|
||||
"tab:1": {
|
||||
"to": "queue",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
"note": "Translucent blurred tab bar; badge on Queue."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g40",
|
||||
"x": 984,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i39",
|
||||
"kind": "topAppBar",
|
||||
"label": "Now playing",
|
||||
"icon": "keyboard_arrow_down",
|
||||
"variant": "filled",
|
||||
"icon2": "more_horiz",
|
||||
"actions": {
|
||||
"icon": {
|
||||
"to": "back",
|
||||
"transition": "slideDown"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g42",
|
||||
"x": 984,
|
||||
"y": 88,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i41",
|
||||
"kind": "image",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 412,
|
||||
"note": "16:9 video stage; the fullscreen button opens Fullscreen."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g44",
|
||||
"x": 1000,
|
||||
"y": 320,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i43",
|
||||
"kind": "text",
|
||||
"label": "Way Maker",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 24,
|
||||
"bold": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g46",
|
||||
"x": 1000,
|
||||
"y": 356,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i45",
|
||||
"kind": "text",
|
||||
"label": "Leeland",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 16
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g48",
|
||||
"x": 1000,
|
||||
"y": 396,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i47",
|
||||
"kind": "slider",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"value": 35,
|
||||
"note": "Thin seek track, accent fill."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g54",
|
||||
"x": 1048,
|
||||
"y": 456,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i49",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "skip_previous",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i50",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "replay_10",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i51",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "play_arrow",
|
||||
"variant": "filled"
|
||||
},
|
||||
{
|
||||
"id": "i52",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "forward_10",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i53",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "skip_next",
|
||||
"variant": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g61",
|
||||
"x": 1024,
|
||||
"y": 540,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i55",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "playlist_add",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i56",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "download",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i57",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "lyrics",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i58",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "share",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i59",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "fullscreen",
|
||||
"variant": "text",
|
||||
"action": {
|
||||
"to": "fullscreen",
|
||||
"transition": "expand"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i60",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "more_horiz",
|
||||
"variant": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g63",
|
||||
"x": 984,
|
||||
"y": 788,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i62",
|
||||
"kind": "bottomNav",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"tabs": [
|
||||
{
|
||||
"icon": "search",
|
||||
"label": "Search"
|
||||
},
|
||||
{
|
||||
"icon": "queue_music",
|
||||
"label": "Queue"
|
||||
},
|
||||
{
|
||||
"icon": "history",
|
||||
"label": "History"
|
||||
},
|
||||
{
|
||||
"icon": "download_done",
|
||||
"label": "Saved"
|
||||
},
|
||||
{
|
||||
"icon": "more_horiz",
|
||||
"label": "More"
|
||||
}
|
||||
],
|
||||
"selected": 1,
|
||||
"actions": {
|
||||
"tab:0": {
|
||||
"to": "search",
|
||||
"transition": "fade"
|
||||
},
|
||||
"tab:1": {
|
||||
"to": "queue",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
"note": "Translucent blurred tab bar; badge on Queue."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g65",
|
||||
"x": 1476,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i64",
|
||||
"kind": "topAppBar",
|
||||
"label": "Queue",
|
||||
"icon": "menu",
|
||||
"variant": "filled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g67",
|
||||
"x": 1476,
|
||||
"y": 380,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i66",
|
||||
"kind": "box",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 412,
|
||||
"size2": 512,
|
||||
"checked": true,
|
||||
"fill": "surfaceContainerHigh",
|
||||
"note": "F7 sheet with grab handle; backdrop tap closes."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g69",
|
||||
"x": 1492,
|
||||
"y": 404,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i68",
|
||||
"kind": "text",
|
||||
"label": "Up next",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 20,
|
||||
"bold": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g73",
|
||||
"x": 1492,
|
||||
"y": 448,
|
||||
"axis": "y",
|
||||
"items": [
|
||||
{
|
||||
"id": "i70",
|
||||
"kind": "listItem",
|
||||
"label": "Build My Life",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Housefires \u00b7 6:04",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i71",
|
||||
"kind": "listItem",
|
||||
"label": "Graves Into Gardens",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Elevation \u00b7 6:41",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i72",
|
||||
"kind": "listItem",
|
||||
"label": "Goodness of God",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Bethel Music \u00b7 5:12",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g75",
|
||||
"x": 1968,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i74",
|
||||
"kind": "box",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 412,
|
||||
"size2": 892,
|
||||
"fill": "inverseSurface",
|
||||
"note": "Video fills the screen (object-fit cover). Fit/Fill toggle in the controls."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g77",
|
||||
"x": 1984,
|
||||
"y": 24,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i76",
|
||||
"kind": "text",
|
||||
"label": "Way Maker \u2014 Leeland",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g79",
|
||||
"x": 2316,
|
||||
"y": 12,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i78",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "fullscreen_exit",
|
||||
"variant": "text",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g81",
|
||||
"x": 1984,
|
||||
"y": 790,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i80",
|
||||
"kind": "slider",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"value": 35
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g85",
|
||||
"x": 1984,
|
||||
"y": 828,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i82",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "pause",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i83",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "screen_rotation",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i84",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "fit_screen",
|
||||
"variant": "text",
|
||||
"note": "Fit \u2194 Fill."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
904
docs/design/f7-swipe-light.json
Normal file
904
docs/design/f7-swipe-light.json
Normal file
@@ -0,0 +1,904 @@
|
||||
{
|
||||
"title": "YT Player \u2014 Framework7 Swipe (light)",
|
||||
"brief": "Ad-free YouTube/worship player PWA. Framework7 iOS-style layout with swipe gestures, built on the app's own tokens.",
|
||||
"frame": "phone",
|
||||
"platform": "web",
|
||||
"paletteKey": "coral",
|
||||
"theme": {
|
||||
"dark": false,
|
||||
"bothModes": true,
|
||||
"contrast": "standard",
|
||||
"shape": "rounded",
|
||||
"font": "system",
|
||||
"emphasized": false,
|
||||
"motion": "expressive"
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"id": "search",
|
||||
"name": "Search",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"note": "Home + search results. Big title, rounded search field, results as an inset grouped list.",
|
||||
"swipe": {
|
||||
"left": "queue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "queue",
|
||||
"name": "Queue",
|
||||
"x": 492,
|
||||
"y": 0,
|
||||
"note": "Queued songs. Swipe a row left for Playlist/Remove (long swipe removes), right for Queue. Swipe sideways on empty space to change tab.",
|
||||
"swipe": {
|
||||
"right": "search"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "player",
|
||||
"name": "Now playing",
|
||||
"x": 984,
|
||||
"y": 0,
|
||||
"note": "Video on top, Apple Music\u2013style transport, icon action row. Swipe down to collapse to the mini player.",
|
||||
"swipe": {
|
||||
"down": "queue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "upnext",
|
||||
"name": "Up next",
|
||||
"x": 1476,
|
||||
"y": 0,
|
||||
"note": "Bottom sheet opened by swiping up on the mini player. Half height, drag up for full, down to close."
|
||||
},
|
||||
{
|
||||
"id": "fullscreen",
|
||||
"name": "Fullscreen",
|
||||
"x": 1968,
|
||||
"y": 0,
|
||||
"note": "Edge-to-edge video (Fill by default). Minimal icon-only controls over a soft bottom gradient; auto-hide.",
|
||||
"bg": "inverseSurface"
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": "g2",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i1",
|
||||
"kind": "topAppBar",
|
||||
"label": "Search",
|
||||
"icon": "menu",
|
||||
"variant": "filled",
|
||||
"icon2": "directions_car",
|
||||
"note": "Translucent navbar; menu opens the sidebar (also left-edge swipe)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g4",
|
||||
"x": 16,
|
||||
"y": 96,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i3",
|
||||
"kind": "searchBar",
|
||||
"label": "Search YouTube",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"icon2": "mic",
|
||||
"note": "Rounded F7 searchbar with inline mic; Enter searches."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g8",
|
||||
"x": 16,
|
||||
"y": 164,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i5",
|
||||
"kind": "chip",
|
||||
"label": "lofi beats",
|
||||
"icon": null,
|
||||
"variant": "filled"
|
||||
},
|
||||
{
|
||||
"id": "i6",
|
||||
"kind": "chip",
|
||||
"label": "live news",
|
||||
"icon": null,
|
||||
"variant": "filled"
|
||||
},
|
||||
{
|
||||
"id": "i7",
|
||||
"kind": "chip",
|
||||
"label": "worship",
|
||||
"icon": null,
|
||||
"variant": "filled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g10",
|
||||
"x": 16,
|
||||
"y": 212,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i9",
|
||||
"kind": "text",
|
||||
"label": "Results",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 28,
|
||||
"bold": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g15",
|
||||
"x": 16,
|
||||
"y": 260,
|
||||
"axis": "y",
|
||||
"items": [
|
||||
{
|
||||
"id": "i11",
|
||||
"kind": "listItem",
|
||||
"label": "Goodness of God",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Bethel Music \u00b7 5:12",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i12",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i13",
|
||||
"kind": "listItem",
|
||||
"label": "Build My Life",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Housefires \u00b7 6:04",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i14",
|
||||
"kind": "listItem",
|
||||
"label": "Graves Into Gardens",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Elevation \u00b7 6:41",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g17",
|
||||
"x": 16,
|
||||
"y": 700,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i16",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "music_note",
|
||||
"variant": "tonal",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "play_arrow",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slideUp"
|
||||
},
|
||||
"note": "Floating glass mini player. Tap opens the player, swipe left/right = next/previous, swipe up = Up next sheet."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g19",
|
||||
"x": 0,
|
||||
"y": 788,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i18",
|
||||
"kind": "bottomNav",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"tabs": [
|
||||
{
|
||||
"icon": "search",
|
||||
"label": "Search"
|
||||
},
|
||||
{
|
||||
"icon": "queue_music",
|
||||
"label": "Queue"
|
||||
},
|
||||
{
|
||||
"icon": "history",
|
||||
"label": "History"
|
||||
},
|
||||
{
|
||||
"icon": "download_done",
|
||||
"label": "Saved"
|
||||
},
|
||||
{
|
||||
"icon": "more_horiz",
|
||||
"label": "More"
|
||||
}
|
||||
],
|
||||
"selected": 0,
|
||||
"actions": {
|
||||
"tab:0": {
|
||||
"to": "search",
|
||||
"transition": "fade"
|
||||
},
|
||||
"tab:1": {
|
||||
"to": "queue",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
"note": "Translucent blurred tab bar; badge on Queue."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g21",
|
||||
"x": 492,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i20",
|
||||
"kind": "topAppBar",
|
||||
"label": "Queue",
|
||||
"icon": "menu",
|
||||
"variant": "filled",
|
||||
"icon2": "checklist"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g24",
|
||||
"x": 508,
|
||||
"y": 96,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i22",
|
||||
"kind": "button",
|
||||
"label": "Play queue",
|
||||
"icon": "play_arrow",
|
||||
"variant": "filled",
|
||||
"size": 182
|
||||
},
|
||||
{
|
||||
"id": "i23",
|
||||
"kind": "button",
|
||||
"label": "Clear",
|
||||
"icon": "clear_all",
|
||||
"variant": "tonal",
|
||||
"size": 182
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g29",
|
||||
"x": 508,
|
||||
"y": 168,
|
||||
"axis": "y",
|
||||
"items": [
|
||||
{
|
||||
"id": "i25",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "drag_handle",
|
||||
"note": "Swipe right \u2192 Queue (accent action)."
|
||||
},
|
||||
{
|
||||
"id": "i26",
|
||||
"kind": "listItem",
|
||||
"label": "Build My Life",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Housefires \u00b7 6:04",
|
||||
"icon2": "drag_handle",
|
||||
"note": "Swipe left \u2192 Playlist + Remove pills."
|
||||
},
|
||||
{
|
||||
"id": "i27",
|
||||
"kind": "listItem",
|
||||
"label": "Graves Into Gardens",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Elevation \u00b7 6:41",
|
||||
"icon2": "drag_handle",
|
||||
"note": "Long swipe left removes the row."
|
||||
},
|
||||
{
|
||||
"id": "i28",
|
||||
"kind": "listItem",
|
||||
"label": "Goodness of God",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Bethel Music \u00b7 5:12",
|
||||
"icon2": "drag_handle",
|
||||
"note": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g32",
|
||||
"x": 508,
|
||||
"y": 480,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i30",
|
||||
"kind": "button",
|
||||
"label": "Playlist",
|
||||
"icon": "playlist_add",
|
||||
"variant": "tonal"
|
||||
},
|
||||
{
|
||||
"id": "i31",
|
||||
"kind": "button",
|
||||
"label": "Remove",
|
||||
"icon": "delete",
|
||||
"variant": "filled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g34",
|
||||
"x": 508,
|
||||
"y": 548,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i33",
|
||||
"kind": "text",
|
||||
"label": "Swipe actions revealed (example row)",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g36",
|
||||
"x": 508,
|
||||
"y": 700,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i35",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "music_note",
|
||||
"variant": "tonal",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "play_arrow",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slideUp"
|
||||
},
|
||||
"note": "Floating glass mini player. Tap opens the player, swipe left/right = next/previous, swipe up = Up next sheet."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g38",
|
||||
"x": 492,
|
||||
"y": 788,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i37",
|
||||
"kind": "bottomNav",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"tabs": [
|
||||
{
|
||||
"icon": "search",
|
||||
"label": "Search"
|
||||
},
|
||||
{
|
||||
"icon": "queue_music",
|
||||
"label": "Queue"
|
||||
},
|
||||
{
|
||||
"icon": "history",
|
||||
"label": "History"
|
||||
},
|
||||
{
|
||||
"icon": "download_done",
|
||||
"label": "Saved"
|
||||
},
|
||||
{
|
||||
"icon": "more_horiz",
|
||||
"label": "More"
|
||||
}
|
||||
],
|
||||
"selected": 1,
|
||||
"actions": {
|
||||
"tab:0": {
|
||||
"to": "search",
|
||||
"transition": "fade"
|
||||
},
|
||||
"tab:1": {
|
||||
"to": "queue",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
"note": "Translucent blurred tab bar; badge on Queue."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g40",
|
||||
"x": 984,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i39",
|
||||
"kind": "topAppBar",
|
||||
"label": "Now playing",
|
||||
"icon": "keyboard_arrow_down",
|
||||
"variant": "filled",
|
||||
"icon2": "more_horiz",
|
||||
"actions": {
|
||||
"icon": {
|
||||
"to": "back",
|
||||
"transition": "slideDown"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g42",
|
||||
"x": 984,
|
||||
"y": 88,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i41",
|
||||
"kind": "image",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 412,
|
||||
"note": "16:9 video stage; the fullscreen button opens Fullscreen."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g44",
|
||||
"x": 1000,
|
||||
"y": 320,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i43",
|
||||
"kind": "text",
|
||||
"label": "Way Maker",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 24,
|
||||
"bold": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g46",
|
||||
"x": 1000,
|
||||
"y": 356,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i45",
|
||||
"kind": "text",
|
||||
"label": "Leeland",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 16
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g48",
|
||||
"x": 1000,
|
||||
"y": 396,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i47",
|
||||
"kind": "slider",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"value": 35,
|
||||
"note": "Thin seek track, accent fill."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g54",
|
||||
"x": 1048,
|
||||
"y": 456,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i49",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "skip_previous",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i50",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "replay_10",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i51",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "play_arrow",
|
||||
"variant": "filled"
|
||||
},
|
||||
{
|
||||
"id": "i52",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "forward_10",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i53",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "skip_next",
|
||||
"variant": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g61",
|
||||
"x": 1024,
|
||||
"y": 540,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i55",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "playlist_add",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i56",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "download",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i57",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "lyrics",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i58",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "share",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i59",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "fullscreen",
|
||||
"variant": "text",
|
||||
"action": {
|
||||
"to": "fullscreen",
|
||||
"transition": "expand"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i60",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "more_horiz",
|
||||
"variant": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g63",
|
||||
"x": 984,
|
||||
"y": 788,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i62",
|
||||
"kind": "bottomNav",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"tabs": [
|
||||
{
|
||||
"icon": "search",
|
||||
"label": "Search"
|
||||
},
|
||||
{
|
||||
"icon": "queue_music",
|
||||
"label": "Queue"
|
||||
},
|
||||
{
|
||||
"icon": "history",
|
||||
"label": "History"
|
||||
},
|
||||
{
|
||||
"icon": "download_done",
|
||||
"label": "Saved"
|
||||
},
|
||||
{
|
||||
"icon": "more_horiz",
|
||||
"label": "More"
|
||||
}
|
||||
],
|
||||
"selected": 1,
|
||||
"actions": {
|
||||
"tab:0": {
|
||||
"to": "search",
|
||||
"transition": "fade"
|
||||
},
|
||||
"tab:1": {
|
||||
"to": "queue",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
"note": "Translucent blurred tab bar; badge on Queue."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g65",
|
||||
"x": 1476,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i64",
|
||||
"kind": "topAppBar",
|
||||
"label": "Queue",
|
||||
"icon": "menu",
|
||||
"variant": "filled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g67",
|
||||
"x": 1476,
|
||||
"y": 380,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i66",
|
||||
"kind": "box",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 412,
|
||||
"size2": 512,
|
||||
"checked": true,
|
||||
"fill": "surfaceContainerHigh",
|
||||
"note": "F7 sheet with grab handle; backdrop tap closes."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g69",
|
||||
"x": 1492,
|
||||
"y": 404,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i68",
|
||||
"kind": "text",
|
||||
"label": "Up next",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 20,
|
||||
"bold": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g73",
|
||||
"x": 1492,
|
||||
"y": 448,
|
||||
"axis": "y",
|
||||
"items": [
|
||||
{
|
||||
"id": "i70",
|
||||
"kind": "listItem",
|
||||
"label": "Build My Life",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Housefires \u00b7 6:04",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i71",
|
||||
"kind": "listItem",
|
||||
"label": "Graves Into Gardens",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Elevation \u00b7 6:41",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i72",
|
||||
"kind": "listItem",
|
||||
"label": "Goodness of God",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Bethel Music \u00b7 5:12",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g75",
|
||||
"x": 1968,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i74",
|
||||
"kind": "box",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 412,
|
||||
"size2": 892,
|
||||
"fill": "inverseSurface",
|
||||
"note": "Video fills the screen (object-fit cover). Fit/Fill toggle in the controls."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g77",
|
||||
"x": 1984,
|
||||
"y": 24,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i76",
|
||||
"kind": "text",
|
||||
"label": "Way Maker \u2014 Leeland",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g79",
|
||||
"x": 2316,
|
||||
"y": 12,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i78",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "fullscreen_exit",
|
||||
"variant": "text",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g81",
|
||||
"x": 1984,
|
||||
"y": 790,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i80",
|
||||
"kind": "slider",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"value": 35
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g85",
|
||||
"x": 1984,
|
||||
"y": 828,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i82",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "pause",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i83",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "screen_rotation",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i84",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "fit_screen",
|
||||
"variant": "text",
|
||||
"note": "Fit \u2194 Fill."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
904
docs/design/f7-swipe.json
Normal file
904
docs/design/f7-swipe.json
Normal file
@@ -0,0 +1,904 @@
|
||||
{
|
||||
"title": "YT Player \u2014 Framework7 Swipe",
|
||||
"brief": "Ad-free YouTube/worship player PWA. Framework7 iOS-style layout with swipe gestures, built on the app's own tokens.",
|
||||
"frame": "phone",
|
||||
"platform": "web",
|
||||
"paletteKey": "coral",
|
||||
"theme": {
|
||||
"dark": true,
|
||||
"bothModes": true,
|
||||
"contrast": "standard",
|
||||
"shape": "rounded",
|
||||
"font": "system",
|
||||
"emphasized": false,
|
||||
"motion": "expressive"
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"id": "search",
|
||||
"name": "Search",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"note": "Home + search results. Big title, rounded search field, results as an inset grouped list.",
|
||||
"swipe": {
|
||||
"left": "queue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "queue",
|
||||
"name": "Queue",
|
||||
"x": 492,
|
||||
"y": 0,
|
||||
"note": "Queued songs. Swipe a row left for Playlist/Remove (long swipe removes), right for Queue. Swipe sideways on empty space to change tab.",
|
||||
"swipe": {
|
||||
"right": "search"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "player",
|
||||
"name": "Now playing",
|
||||
"x": 984,
|
||||
"y": 0,
|
||||
"note": "Video on top, Apple Music\u2013style transport, icon action row. Swipe down to collapse to the mini player.",
|
||||
"swipe": {
|
||||
"down": "queue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "upnext",
|
||||
"name": "Up next",
|
||||
"x": 1476,
|
||||
"y": 0,
|
||||
"note": "Bottom sheet opened by swiping up on the mini player. Half height, drag up for full, down to close."
|
||||
},
|
||||
{
|
||||
"id": "fullscreen",
|
||||
"name": "Fullscreen",
|
||||
"x": 1968,
|
||||
"y": 0,
|
||||
"note": "Edge-to-edge video (Fill by default). Minimal icon-only controls over a soft bottom gradient; auto-hide.",
|
||||
"bg": "inverseSurface"
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": "g2",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i1",
|
||||
"kind": "topAppBar",
|
||||
"label": "Search",
|
||||
"icon": "menu",
|
||||
"variant": "filled",
|
||||
"icon2": "directions_car",
|
||||
"note": "Translucent navbar; menu opens the sidebar (also left-edge swipe)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g4",
|
||||
"x": 16,
|
||||
"y": 96,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i3",
|
||||
"kind": "searchBar",
|
||||
"label": "Search YouTube",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"icon2": "mic",
|
||||
"note": "Rounded F7 searchbar with inline mic; Enter searches."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g8",
|
||||
"x": 16,
|
||||
"y": 164,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i5",
|
||||
"kind": "chip",
|
||||
"label": "lofi beats",
|
||||
"icon": null,
|
||||
"variant": "filled"
|
||||
},
|
||||
{
|
||||
"id": "i6",
|
||||
"kind": "chip",
|
||||
"label": "live news",
|
||||
"icon": null,
|
||||
"variant": "filled"
|
||||
},
|
||||
{
|
||||
"id": "i7",
|
||||
"kind": "chip",
|
||||
"label": "worship",
|
||||
"icon": null,
|
||||
"variant": "filled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g10",
|
||||
"x": 16,
|
||||
"y": 212,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i9",
|
||||
"kind": "text",
|
||||
"label": "Results",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 28,
|
||||
"bold": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g15",
|
||||
"x": 16,
|
||||
"y": 260,
|
||||
"axis": "y",
|
||||
"items": [
|
||||
{
|
||||
"id": "i11",
|
||||
"kind": "listItem",
|
||||
"label": "Goodness of God",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Bethel Music \u00b7 5:12",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i12",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i13",
|
||||
"kind": "listItem",
|
||||
"label": "Build My Life",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Housefires \u00b7 6:04",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i14",
|
||||
"kind": "listItem",
|
||||
"label": "Graves Into Gardens",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Elevation \u00b7 6:41",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g17",
|
||||
"x": 16,
|
||||
"y": 700,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i16",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "music_note",
|
||||
"variant": "tonal",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "play_arrow",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slideUp"
|
||||
},
|
||||
"note": "Floating glass mini player. Tap opens the player, swipe left/right = next/previous, swipe up = Up next sheet."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g19",
|
||||
"x": 0,
|
||||
"y": 788,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i18",
|
||||
"kind": "bottomNav",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"tabs": [
|
||||
{
|
||||
"icon": "search",
|
||||
"label": "Search"
|
||||
},
|
||||
{
|
||||
"icon": "queue_music",
|
||||
"label": "Queue"
|
||||
},
|
||||
{
|
||||
"icon": "history",
|
||||
"label": "History"
|
||||
},
|
||||
{
|
||||
"icon": "download_done",
|
||||
"label": "Saved"
|
||||
},
|
||||
{
|
||||
"icon": "more_horiz",
|
||||
"label": "More"
|
||||
}
|
||||
],
|
||||
"selected": 0,
|
||||
"actions": {
|
||||
"tab:0": {
|
||||
"to": "search",
|
||||
"transition": "fade"
|
||||
},
|
||||
"tab:1": {
|
||||
"to": "queue",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
"note": "Translucent blurred tab bar; badge on Queue."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g21",
|
||||
"x": 492,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i20",
|
||||
"kind": "topAppBar",
|
||||
"label": "Queue",
|
||||
"icon": "menu",
|
||||
"variant": "filled",
|
||||
"icon2": "checklist"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g24",
|
||||
"x": 508,
|
||||
"y": 96,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i22",
|
||||
"kind": "button",
|
||||
"label": "Play queue",
|
||||
"icon": "play_arrow",
|
||||
"variant": "filled",
|
||||
"size": 182
|
||||
},
|
||||
{
|
||||
"id": "i23",
|
||||
"kind": "button",
|
||||
"label": "Clear",
|
||||
"icon": "clear_all",
|
||||
"variant": "tonal",
|
||||
"size": 182
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g29",
|
||||
"x": 508,
|
||||
"y": 168,
|
||||
"axis": "y",
|
||||
"items": [
|
||||
{
|
||||
"id": "i25",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "drag_handle",
|
||||
"note": "Swipe right \u2192 Queue (accent action)."
|
||||
},
|
||||
{
|
||||
"id": "i26",
|
||||
"kind": "listItem",
|
||||
"label": "Build My Life",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Housefires \u00b7 6:04",
|
||||
"icon2": "drag_handle",
|
||||
"note": "Swipe left \u2192 Playlist + Remove pills."
|
||||
},
|
||||
{
|
||||
"id": "i27",
|
||||
"kind": "listItem",
|
||||
"label": "Graves Into Gardens",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Elevation \u00b7 6:41",
|
||||
"icon2": "drag_handle",
|
||||
"note": "Long swipe left removes the row."
|
||||
},
|
||||
{
|
||||
"id": "i28",
|
||||
"kind": "listItem",
|
||||
"label": "Goodness of God",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Bethel Music \u00b7 5:12",
|
||||
"icon2": "drag_handle",
|
||||
"note": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g32",
|
||||
"x": 508,
|
||||
"y": 480,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i30",
|
||||
"kind": "button",
|
||||
"label": "Playlist",
|
||||
"icon": "playlist_add",
|
||||
"variant": "tonal"
|
||||
},
|
||||
{
|
||||
"id": "i31",
|
||||
"kind": "button",
|
||||
"label": "Remove",
|
||||
"icon": "delete",
|
||||
"variant": "filled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g34",
|
||||
"x": 508,
|
||||
"y": 548,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i33",
|
||||
"kind": "text",
|
||||
"label": "Swipe actions revealed (example row)",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g36",
|
||||
"x": 508,
|
||||
"y": 700,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i35",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "music_note",
|
||||
"variant": "tonal",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "play_arrow",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slideUp"
|
||||
},
|
||||
"note": "Floating glass mini player. Tap opens the player, swipe left/right = next/previous, swipe up = Up next sheet."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g38",
|
||||
"x": 492,
|
||||
"y": 788,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i37",
|
||||
"kind": "bottomNav",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"tabs": [
|
||||
{
|
||||
"icon": "search",
|
||||
"label": "Search"
|
||||
},
|
||||
{
|
||||
"icon": "queue_music",
|
||||
"label": "Queue"
|
||||
},
|
||||
{
|
||||
"icon": "history",
|
||||
"label": "History"
|
||||
},
|
||||
{
|
||||
"icon": "download_done",
|
||||
"label": "Saved"
|
||||
},
|
||||
{
|
||||
"icon": "more_horiz",
|
||||
"label": "More"
|
||||
}
|
||||
],
|
||||
"selected": 1,
|
||||
"actions": {
|
||||
"tab:0": {
|
||||
"to": "search",
|
||||
"transition": "fade"
|
||||
},
|
||||
"tab:1": {
|
||||
"to": "queue",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
"note": "Translucent blurred tab bar; badge on Queue."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g40",
|
||||
"x": 984,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i39",
|
||||
"kind": "topAppBar",
|
||||
"label": "Now playing",
|
||||
"icon": "keyboard_arrow_down",
|
||||
"variant": "filled",
|
||||
"icon2": "more_horiz",
|
||||
"actions": {
|
||||
"icon": {
|
||||
"to": "back",
|
||||
"transition": "slideDown"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g42",
|
||||
"x": 984,
|
||||
"y": 88,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i41",
|
||||
"kind": "image",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 412,
|
||||
"note": "16:9 video stage; the fullscreen button opens Fullscreen."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g44",
|
||||
"x": 1000,
|
||||
"y": 320,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i43",
|
||||
"kind": "text",
|
||||
"label": "Way Maker",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 24,
|
||||
"bold": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g46",
|
||||
"x": 1000,
|
||||
"y": 356,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i45",
|
||||
"kind": "text",
|
||||
"label": "Leeland",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 16
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g48",
|
||||
"x": 1000,
|
||||
"y": 396,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i47",
|
||||
"kind": "slider",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"value": 35,
|
||||
"note": "Thin seek track, accent fill."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g54",
|
||||
"x": 1048,
|
||||
"y": 456,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i49",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "skip_previous",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i50",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "replay_10",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i51",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "play_arrow",
|
||||
"variant": "filled"
|
||||
},
|
||||
{
|
||||
"id": "i52",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "forward_10",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i53",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "skip_next",
|
||||
"variant": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g61",
|
||||
"x": 1024,
|
||||
"y": 540,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i55",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "playlist_add",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i56",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "download",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i57",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "lyrics",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i58",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "share",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i59",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "fullscreen",
|
||||
"variant": "text",
|
||||
"action": {
|
||||
"to": "fullscreen",
|
||||
"transition": "expand"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i60",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "more_horiz",
|
||||
"variant": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g63",
|
||||
"x": 984,
|
||||
"y": 788,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i62",
|
||||
"kind": "bottomNav",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"tabs": [
|
||||
{
|
||||
"icon": "search",
|
||||
"label": "Search"
|
||||
},
|
||||
{
|
||||
"icon": "queue_music",
|
||||
"label": "Queue"
|
||||
},
|
||||
{
|
||||
"icon": "history",
|
||||
"label": "History"
|
||||
},
|
||||
{
|
||||
"icon": "download_done",
|
||||
"label": "Saved"
|
||||
},
|
||||
{
|
||||
"icon": "more_horiz",
|
||||
"label": "More"
|
||||
}
|
||||
],
|
||||
"selected": 1,
|
||||
"actions": {
|
||||
"tab:0": {
|
||||
"to": "search",
|
||||
"transition": "fade"
|
||||
},
|
||||
"tab:1": {
|
||||
"to": "queue",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
"note": "Translucent blurred tab bar; badge on Queue."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g65",
|
||||
"x": 1476,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i64",
|
||||
"kind": "topAppBar",
|
||||
"label": "Queue",
|
||||
"icon": "menu",
|
||||
"variant": "filled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g67",
|
||||
"x": 1476,
|
||||
"y": 380,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i66",
|
||||
"kind": "box",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 412,
|
||||
"size2": 512,
|
||||
"checked": true,
|
||||
"fill": "surfaceContainerHigh",
|
||||
"note": "F7 sheet with grab handle; backdrop tap closes."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g69",
|
||||
"x": 1492,
|
||||
"y": 404,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i68",
|
||||
"kind": "text",
|
||||
"label": "Up next",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 20,
|
||||
"bold": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g73",
|
||||
"x": 1492,
|
||||
"y": 448,
|
||||
"axis": "y",
|
||||
"items": [
|
||||
{
|
||||
"id": "i70",
|
||||
"kind": "listItem",
|
||||
"label": "Build My Life",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Housefires \u00b7 6:04",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i71",
|
||||
"kind": "listItem",
|
||||
"label": "Graves Into Gardens",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Elevation \u00b7 6:41",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i72",
|
||||
"kind": "listItem",
|
||||
"label": "Goodness of God",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Bethel Music \u00b7 5:12",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g75",
|
||||
"x": 1968,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i74",
|
||||
"kind": "box",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 412,
|
||||
"size2": 892,
|
||||
"fill": "inverseSurface",
|
||||
"note": "Video fills the screen (object-fit cover). Fit/Fill toggle in the controls."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g77",
|
||||
"x": 1984,
|
||||
"y": 24,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i76",
|
||||
"kind": "text",
|
||||
"label": "Way Maker \u2014 Leeland",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g79",
|
||||
"x": 2316,
|
||||
"y": 12,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i78",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "fullscreen_exit",
|
||||
"variant": "text",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g81",
|
||||
"x": 1984,
|
||||
"y": 790,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i80",
|
||||
"kind": "slider",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"value": 35
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g85",
|
||||
"x": 1984,
|
||||
"y": 828,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i82",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "pause",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i83",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "screen_rotation",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i84",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "fit_screen",
|
||||
"variant": "text",
|
||||
"note": "Fit \u2194 Fill."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
88
docs/fullscreen-background-audio.md
Normal file
88
docs/fullscreen-background-audio.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Fullscreen orientation and iPhone audio continuity
|
||||
|
||||
## Task A — done
|
||||
|
||||
Settings → Listening → Video & fullscreen offers Auto (follow device), Landscape,
|
||||
and Portrait. The choice persists on this device and can be cycled from the
|
||||
fullscreen controls. Supported browsers request fullscreen and then lock the
|
||||
selected orientation; Auto leaves orientation unlocked. Exit releases our lock.
|
||||
|
||||
When orientation locking or element fullscreen is unavailable, the player uses a
|
||||
viewport overlay. A portrait viewport with Landscape selected rotates the existing
|
||||
stage and its controls 90 degrees; Portrait in a landscape viewport rotates it
|
||||
in the opposite direction. Safe-area padding follows the rotated axes. Touch
|
||||
seeking, double-tap seeking, volume/brightness swipes and pinch movement use
|
||||
inverse coordinates. The video stays in its existing DOM position to avoid
|
||||
interrupting WebKit playback. Controls and browser fullscreen exit restore the
|
||||
inline surface, including when a native orientation lock was rejected.
|
||||
|
||||
Safari's custom overlay fills the available dynamic viewport; it cannot force
|
||||
Safari to hide browser chrome or change the phone's physical orientation. An
|
||||
installed PWA has no browser toolbar occupying that viewport.
|
||||
|
||||
Files: `frontend/fullscreen-orientation.js`, `.css`, `.test.js`, small hooks in
|
||||
`app.js`, `index.html`, `sw.js`, `tests/fullscreen-orientation.spec.js` and
|
||||
`playwright.fullscreen.config.js`.
|
||||
|
||||
## Task B — done; physical iPhone verification remains
|
||||
|
||||
The previous progressive handoff loaded the audio source and sought it only when
|
||||
the page was hidden, then paused the video before transferring master ownership.
|
||||
That required a cold audio decoder start and allowed pause-handler re-entry.
|
||||
Foreground recovery also sought the video, whose shared seeking handler then
|
||||
sought the still-playing audio. Repeated play/playing/focus/watchdog handlers
|
||||
could independently issue the same audio play/seek requests.
|
||||
|
||||
On iPhone, progressive playback now uses the existing dual playback path from the
|
||||
start: a muted video and an audible audio element. Saved files use the same local
|
||||
source; streams prefer their audio-only URL, falling back to the muxed source.
|
||||
EQ-render playback already used this path and remains unchanged. Locking promotes
|
||||
the already-playing audio to master without loading, seeking or restarting it.
|
||||
Unlocking aligns only the silent video; its internal seek does not propagate to
|
||||
the audible audio. Pending play requests are shared, small drift does not cause
|
||||
seeks, and the watchdog never seeks an element to itself. Hidden focus events do
|
||||
not restore the video prematurely. The audio clock still owns background progress
|
||||
and ended events, preserving auto-advance and lock-screen playback intent.
|
||||
|
||||
This uses an audio decoder alongside video while visible. Audio-only playback
|
||||
and tracks that start while already backgrounded retain their existing paths.
|
||||
Browsers other than iPhone retain their foreground progressive path. Their cold
|
||||
handoff fallback now sets master ownership before media events and waits for audio
|
||||
to play before pausing video; returning keeps that audio playing while video resumes.
|
||||
|
||||
Files: `frontend/audio-continuity.js`, `.test.js`, player/event hooks in `app.js`,
|
||||
`index.html`, `sw.js`, `tests/audio-continuity.spec.js`, and
|
||||
`playwright.audio-continuity.config.js`.
|
||||
|
||||
## Validation and hardware review
|
||||
|
||||
- 132 frontend unit tests pass, including orientation mapping, source selection,
|
||||
small-drift policy, concurrent play deduplication, autoplay rejection retry, and
|
||||
stale pending requests after pause/source reset.
|
||||
- `node --check frontend/app.js` and `bun build --no-bundle server/server.js` pass.
|
||||
- Eight fullscreen browser cases pass in Chromium/WebKit at 390 × 844, covering
|
||||
Classic and Glass Stage overlays, visible controls, quick toggle, exit/restore,
|
||||
lock/unlock calls and browser exit after lock rejection.
|
||||
- Four audio browser cases use real local PCM media in Chromium/WebKit, with an
|
||||
iPhone user agent, covering saved and streamed progressive attachments. Repeated
|
||||
visibility/pagehide/focus/pageshow events keep the same advancing audio element
|
||||
with zero audio loads, pauses, seeks or additional play calls.
|
||||
- All 155 server tests pass with `YTDLP_PATH=/tmp/ytplayer-test-yt-dlp` (the existing
|
||||
local test ZIPAPP). The initial default run failed because the system Python
|
||||
environment lacked `yt_dlp`; no server changes were necessary.
|
||||
|
||||
Review on an actual iPhone in Safari and installed PWA: saved MP4, streamed songs,
|
||||
EQ renders, multiple lock/unlock and app switches, auto-advance while locked,
|
||||
Media Session controls, seeking and PiP. Test Landscape/Portrait with safe areas,
|
||||
rotation and touch swipes in both themes. On Android/PWA, verify an actual native
|
||||
orientation lock and release; browser tests verify API calls with a controlled
|
||||
orientation implementation.
|
||||
|
||||
iOS may suspend JavaScript, pause media or interrupt the audio session during lock,
|
||||
app switching, route changes or incoming calls. A web app cannot guarantee a zero
|
||||
OS-level gap. These tests demonstrate removal of application-induced source
|
||||
reloads and audio seeks; desktop WebKit cannot simulate physical iOS suspension.
|
||||
Tracks first started in the background still reattach their picture on return;
|
||||
that existing transition also needs hardware review.
|
||||
|
||||
No server imports were added, and nothing was pushed or deployed.
|
||||
67
docs/glass-stage-iphone-followup.md
Normal file
67
docs/glass-stage-iphone-followup.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Glass Stage iPhone layout follow-up
|
||||
|
||||
The supplied production screenshots came from build `16c08b099a31`, before the
|
||||
Task A/B changes. This follow-up preserves the Glass Stage colours, glass tiles,
|
||||
stroke icons and all existing control ids/actions. Every CSS rule is scoped to
|
||||
`html[data-layout="glass-stage"]`; Classic retains its original rail and labels.
|
||||
|
||||
## Panel shortcuts and bottom chrome
|
||||
|
||||
The Glass section rail was a fixed horizontal overlay positioned above the mini
|
||||
player. Clearing the player did not clear a panel heading or filter underneath.
|
||||
`glass-panel-layout.js` moves that same rail, with its existing listeners, into
|
||||
`#listPane` before the heading. It restores the original location when leaving
|
||||
Glass Stage. The rail now occupies its own row instead of covering content.
|
||||
|
||||
The module measures the visible fixed bottom navigation and mini player. The
|
||||
navigation's measured height already contains the home-indicator safe area;
|
||||
the mini player sits eight pixels above that height. Scroll clearance includes
|
||||
both bars and a 16 px gap, without counting the safe area twice. Portrait uses
|
||||
one shared scroller and reserves that clearance once at its tail; desktop panes
|
||||
reserve it individually. The mini player's outer card clips the progress strip
|
||||
inside its rounded border, rather than leaving a separate line below it.
|
||||
|
||||
## Bottom navigation
|
||||
|
||||
The uploaded rightward icon shift is reproduced by the pre-Task-A CSS from
|
||||
`f2c9459`: installed-PWA padding of `4px 16px` remained on a fixed-width icon box.
|
||||
Task A (`79a3c28`) already resets that padding to zero. The new regression test
|
||||
runs an iPhone 13 browser profile at 390 px, including 34 px bottom safe-area
|
||||
allowance, Queue/Downloads badges and an active pill. In both Chromium and
|
||||
WebKit, SVG and icon-box centres match label centres within 0.6 CSS px. The old
|
||||
CSS shifts every icon more than eight CSS px. No further nav CSS change was
|
||||
needed; this is committed verification of the existing fix.
|
||||
|
||||
## Notes toolbar
|
||||
|
||||
The toolbar's Glass override explicitly enabled `flex-wrap:wrap`. It now keeps
|
||||
one row. Container queries respond to the player card's available width, even
|
||||
when a desktop player column is narrow. Spacing tightens first, the longer
|
||||
Chapters & bookmarks label becomes icon/count at narrow card widths, and all
|
||||
labels become icon-only on the smallest cards. Full accessible names and title
|
||||
hints remain. Every action retains a minimum 44 px target, and the fold chevron
|
||||
stays at the row's right end. Status text truncates within the remaining space.
|
||||
Glass label wrappers and added names restore exactly when switching to Classic.
|
||||
|
||||
## Verification
|
||||
|
||||
- 125 frontend unit tests pass, including measured chrome-clearance logic.
|
||||
- `node --check frontend/app.js` and `bun build --no-bundle server/server.js` pass.
|
||||
- Chromium and WebKit: all five panel shortcuts at 390 and 1440 px; final list
|
||||
row clears playback chrome; progress strip stays inside its card.
|
||||
- Classic geometry and before/after screenshot comparisons pass in both browsers
|
||||
at 390 and 1440 px, within the 32-pixel isolated rasterization tolerance.
|
||||
- iPhone-profile navigation alignment/badges pass in both engines at 390 px.
|
||||
- Toolbar stays one row at 320, 360, 390, 430 and 1440 px in both engines, including
|
||||
long status text and chapter counts; theme round-trip restoration passes.
|
||||
- Batched visual review of six WebKit captures found no overlap, alignment,
|
||||
clipping or wrapping defects in the requested areas.
|
||||
|
||||
Run `npx playwright test -c playwright.glass-panel.config.js` for the 22 browser
|
||||
cases. WebKit was installed locally for these checks; these are browser/iPhone
|
||||
profile tests, not tests on physical iPhone hardware. Review the installed PWA
|
||||
on an actual iPhone after the reviewer deploys, particularly when the mini
|
||||
player appears/disappears and after rotating the phone.
|
||||
|
||||
Before/after captures are saved under
|
||||
`/home/josh/deliverables/ytplayer-done9/`. No push or deployment was performed.
|
||||
153
docs/p2p-transfer.md
Normal file
153
docs/p2p-transfer.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# Direct device media transfers
|
||||
|
||||
## Existing paths (audit before implementation)
|
||||
|
||||
| Path | Existing media-byte route | Relevant code |
|
||||
| --- | --- | --- |
|
||||
| Paired remote | Controls only; the host plays its own source. No existing file-send action. | `frontend/app.js` Remote; `server/remote.js` |
|
||||
| Presenter and OBS | Lyrics, timing and metadata only; neither currently plays host media. | Presenter and overlay modules; remote relay |
|
||||
| Watch Party playback | Each guest loads independently through normal server/YouTube playback, unless already saved locally. | Party `applyState`, `Player.loadVideo`; `server/party.js` |
|
||||
| Watch Party voice | Direct WebRTC audio; server forwards SDP/ICE only. | Party voice mesh, party `rtc` relay |
|
||||
| On other devices / failed playback | Existing verified-file DataChannel download, 64 KiB frames, SHA-256 validation, worker writes OPFS. Only offered after server failure in playback. | `p2p-transfer.js`, `p2p-recv-worker.js`, `getFromPeers` |
|
||||
| Save / preload | Server download first, then OPFS. | `preload`, `API.cacheDownload`, download endpoints |
|
||||
| Export to Files / Photos | Local OPFS first, server URL next, peer fallback last. Native share is an OS export, not paired-device transfer. | `exportToDevice`, `export.js` |
|
||||
| Profile / playlist share and send | Metadata only. Sending a playlist does not transfer its media files. | Profile/playlist sync APIs |
|
||||
| Verify & share | Explicit device upload to server intake; hash and media validation admit it into the server cache. | `P2PClient.contribute`, `p2p-intake.js` |
|
||||
| Automatic rehydration | Server requests a holder to upload its full file to intake. This is an implicit media-byte server path. | `createRehydrator`, client `upload-request` handler |
|
||||
| Admin uploads / original YouTube downloads | Intentional server ingestion of original media; separate from direct device transfer. | Upload and media-cache endpoints |
|
||||
| VLC / external player URLs | HTTP server media URLs; these players cannot consume this application's DataChannel. | External player actions |
|
||||
|
||||
The current file protocol has no resume: its receiver truncates partial files and
|
||||
removes them on failure. Connection timeout is 20 seconds per holder. Device
|
||||
signalling authenticates a device, but does not constrain its destination to a
|
||||
verified profile or paired room. Profile names supplied at device registration
|
||||
are not proof of profile membership. Existing server-verified holdings also
|
||||
exclude files whose bytes the server has never admitted.
|
||||
|
||||
## Implementation design
|
||||
|
||||
Reuse ordered RTCDataChannel transfers with public STUN only. The WebSocket
|
||||
server carries bounded, validated signalling and metadata, never file chunks.
|
||||
Pair authorization must come from an authenticated remote/party room or a
|
||||
verified profile session, not an arbitrary client-supplied profile name.
|
||||
|
||||
A receiver explicitly accepts an invitation before saving. A cryptographically
|
||||
random, expiring, one-use invitation binds sender, receiver and file identity;
|
||||
subsequent signalling remains bound to that pair. Reject replay, mismatched
|
||||
identities, binary WebSocket messages and unrecognized protocol fields.
|
||||
|
||||
Send 64 KiB chunks with bufferedAmount backpressure. Persist a partial OPFS file
|
||||
and resume at its exact committed byte offset. Rehash the retained prefix before
|
||||
continuing and verify total size and SHA-256 before promoting the file into the
|
||||
saved store; only then record metadata. A changed hash/size cannot reuse a partial.
|
||||
|
||||
Use a 10-second connection deadline and an explicit, clearly labelled server
|
||||
fallback offer. Direct mode must not silently rehydrate server media from a
|
||||
device. Register “Direct device transfer (P2P)” in Settings, default on.
|
||||
|
||||
Party playback should request a host's saved file when announced, then play the
|
||||
receiver's OPFS copy. Remote controls and presenter lyrics retain their existing
|
||||
behaviour; add explicit receive/play actions rather than automatically starting
|
||||
media in a control-only screen. A manual send action targets authenticated peers.
|
||||
|
||||
Ordinary faststart MP4 cannot safely be appended as arbitrary MediaSource chunks.
|
||||
Progressive playback requires supported fragmented media and codec detection;
|
||||
otherwise use the completed, verified file (including iOS). Do not claim that
|
||||
raw MP4 chunks provide progressive playback.
|
||||
|
||||
## Verification required before release
|
||||
|
||||
Pure tests cover protocol parsing, chunk bounds, offsets, invitation replay and
|
||||
pair authorization. A two-page localhost browser test must transfer real bytes
|
||||
into OPFS through WebRTC and assert that server HTTP/WS logs contain only
|
||||
signalling and metadata. Exercise interruption/resume and explicit fallback.
|
||||
Review real iPhone foreground/background behaviour and restrictive NAT failure.
|
||||
|
||||
## Implemented behaviour
|
||||
|
||||
- `direct-protocol.js` strips unexpected fields and validates file claims and
|
||||
bounded signalling; `direct-relay.js` binds invitations to authenticated room
|
||||
members. Accepting an invitation is one-use, and only the bound pair can signal.
|
||||
- Remote, presenter and party relays carry the new protocol. Presenter and OBS
|
||||
still display lyrics/control metadata; they do not automatically play media.
|
||||
A presenter can explicitly accept a file sent by its paired host.
|
||||
- Settings → Direct device transfer provides the default-on toggle and send
|
||||
buttons for currently connected paired/party/profile peers. Send the currently
|
||||
playing **saved** song; the receiving device confirms every copy.
|
||||
- The paired remote has “Play here from paired screen” when its host has saved
|
||||
media. Party guests and ordinary playback resolve an advertised direct source
|
||||
before loading media. Saves and exports do the same; the existing OS export
|
||||
sheet is preserved.
|
||||
- `/ws/p2p` now has a profile-scoped, ephemeral file-id directory. Protected
|
||||
profiles require password/key proof, checked against the server profile hash.
|
||||
Unprotected profiles retain their existing name-based access policy. Claims
|
||||
are **not** admitted as server-verified media. Each device advertises at most
|
||||
1,000 local ids per directory update; use paired-room sending for larger libraries.
|
||||
- The legacy unconfirmed global signalling path is refused. Older clients need
|
||||
to reload the updated application to transfer files. This closes a bypass of
|
||||
receiver confirmation and room/profile membership.
|
||||
- `direct-media.js` uses public STUN, ordered 64 KiB DataChannel chunks and
|
||||
bufferedAmount backpressure. `direct-recv-worker.js` writes partials directly
|
||||
to OPFS, rehashes their retained prefix and verifies exact size plus SHA-256
|
||||
before promotion. File extensions are preserved. A disconnected partial can
|
||||
be resumed by sending/requesting the same file again, including non-aligned offsets.
|
||||
- The 10-second deadline covers connection establishment. Prefix rehashing and
|
||||
final verification do not consume it. Idle transfer timeout is 30 seconds.
|
||||
Failures retain partial data and offer an explicit **Use server copy** action.
|
||||
Automatic holder-to-server rehydration is blocked while direct mode is on;
|
||||
**Verify & share** remains an intentional, explicit server upload.
|
||||
- `direct-stream.js` supports progressive preview for compatible fragmented
|
||||
H.264 MP4 through MediaSource. Ordinary faststart MP4, unsupported codecs,
|
||||
unavailable MediaSource and preview buffer exhaustion use the complete file.
|
||||
iPhone playback should be reviewed using that completed-file path. A preview
|
||||
is interactive and independent of the main player's host clock; main party
|
||||
playback follows the host after the verified copy is ready.
|
||||
|
||||
## Paths that still use server media
|
||||
|
||||
Original YouTube acquisition, server uploads, intentional Verify & share intake,
|
||||
external VLC/M3U HTTP URLs and explicitly accepted server fallbacks still use the
|
||||
server. If no connected device advertises a local copy, normal original-source
|
||||
playback/download is preserved; this is not a transfer of another device's file.
|
||||
Profile/playlist shares, chapters, lyrics, presenter state and OBS state remain
|
||||
metadata/control only. Direct discovery requires an online paired room or an
|
||||
existing profile, and the browser must remain available to serve its OPFS file.
|
||||
No TURN service, deployment change or secret configuration is required.
|
||||
|
||||
## Checks and reviewer follow-up
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
node --test frontend/*.test.js
|
||||
node --check frontend/app.js
|
||||
bun build --no-bundle server/server.js
|
||||
bun test server/direct-relay.test.js server/p2p-hub.test.js server/remote.test.js server/party.test.js
|
||||
npx playwright test -c playwright.direct.config.js
|
||||
npx playwright test -c playwright.glass-navigation.config.js
|
||||
```
|
||||
|
||||
The two-page localhost test uses the production relay and transport, separate
|
||||
browser stores, a real RTCDataChannel and actual OPFS writes. It resumes a 2 MB
|
||||
file at byte 12,345, verifies the resulting SHA-256 and asserts that the server
|
||||
log contains only invitation/acceptance/signalling/completion. A second case
|
||||
adds 12 seconds of resume preparation to prove it is outside the 10-second
|
||||
connection deadline. This is a local transport proof, not proof that arbitrary
|
||||
NATs will connect.
|
||||
|
||||
Check real iPhone foreground/background suspension, large-file quota and final
|
||||
promotion, iOS completed-file playback, restrictive/symmetric NAT fallback,
|
||||
paired screen playback, party host changes during a copy, protected-profile
|
||||
credential changes/reconnect, OS export gestures, and an actual fragmented MP4
|
||||
MediaSource preview on desktop. Public STUN cannot overcome every NAT; declined
|
||||
invitations do not start media acquisition. Peer directories update when the
|
||||
presence socket connects and when local saves complete, rather than promising
|
||||
availability of offline/backgrounded devices.
|
||||
|
||||
Final verification on 2026-10-03: 123 frontend unit tests, 22 touched server
|
||||
tests (including remote and party), app syntax and server build, 8 Glass/Classic
|
||||
navigation browser tests at 390/1440 px, and both direct-transfer browser cases
|
||||
passed. The first navigation run lost its reused localhost server; a fresh-server
|
||||
rerun passed all eight cases without a code change. Classic screenshot comparisons
|
||||
remain within the test's rasterization tolerance. No real iPhone or restrictive
|
||||
NAT test was performed here, and no code was pushed or deployed.
|
||||
39
docs/related-video-recovery.md
Normal file
39
docs/related-video-recovery.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Related-video recovery
|
||||
|
||||
The old Now Playing related loader called `/api/search` with the complete title.
|
||||
That endpoint follows up to 14 InnerTube pages to collect 200 results, then uses
|
||||
resilient yt-dlp if necessary. Related displayed only eight cards. It had no
|
||||
alternate query or catalog fallback, silently caught failures, and hid its panel.
|
||||
It also read mutable `current` after awaiting the request, allowing a previous
|
||||
song's response to populate the next song's recommendations.
|
||||
|
||||
A read-only production probe (`/api/search?q=Rock Medley&refresh=1`) returned 200
|
||||
results successfully during this investigation. No persistent IP/account/region
|
||||
block was reproduced, and no privileged production logs or credentials were used.
|
||||
The exact intermittent upstream YouTube rejection cannot be established from
|
||||
that successful probe. The failure handling and request coupling above are
|
||||
verified directly in the previous code and reproduced in tests.
|
||||
|
||||
The new `/api/related` endpoint first reads one InnerTube watch-next response,
|
||||
including compact and modern lockup video cards. A local real request for
|
||||
`wZzRoXymOUU` parsed 20 recommendations. Failures/empty results fall back to bounded
|
||||
single-page title/channel searches, then the existing resilient yt-dlp search
|
||||
path, then videos already known to the server. It excludes the current song,
|
||||
deduplicates, returns eight cards, shares in-flight work and caches successful
|
||||
results. Failed responses are not cached; Retry bypasses the success cache.
|
||||
The YouTube work has an 18-second total budget with per-operation timeouts.
|
||||
The existing `SEARCH_INNERTUBE=0` switch still disables InnerTube.
|
||||
|
||||
Every new discovery goes through the existing metadata catalog/thumbnail collector.
|
||||
A metadata persistence failure cannot hide otherwise playable cards. Related's
|
||||
client snapshots the song and ignores superseded responses. When all sources are
|
||||
unavailable, its panel stays visible with a friendly message and Retry, rather
|
||||
than disappearing. Catalog fallback is explicitly labelled.
|
||||
|
||||
Validation: frontend loader tests cover filtering, errors, retry refresh and stale
|
||||
responses. Server tests cover renderer parsing, next/search/catalog fallbacks,
|
||||
metadata collection, cache/in-flight deduplication, deadline exhaustion, and route
|
||||
validation. Browser tests in both themes, Chromium and WebKit at 390 px, exercise
|
||||
503 → Retry → results and assert that the section rail retains its parent.
|
||||
|
||||
No changes to the Glass section rail; no push or deployment.
|
||||
80
docs/theme-bento-hub.md
Normal file
80
docs/theme-bento-hub.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# New Bento Hub theme
|
||||
|
||||
The old Bento rules in `frontend/styles.css` have been removed. The replacement
|
||||
is `frontend/theme-bento-hub.css`, scoped to `html[data-layout="bento-hub"]`.
|
||||
The stylesheet, small theme helper, and self-hosted Plus Jakarta Sans fonts are
|
||||
included in the service-worker shell cache. Classic remains the default.
|
||||
|
||||
## Match to the reference
|
||||
|
||||
The reference is `docs/mockups/bento-hub/index.html` and `SPEC.md` (local design
|
||||
sources). The implementation uses their obsidian surfaces, solar-gold primary
|
||||
controls, cyan listening/status cues, Plus Jakarta Sans and JetBrains Mono,
|
||||
16px outer / 10px inner corners, stroke SVG icons, search capsule, playlist
|
||||
artwork collages, bordered collection rows, and floating mini player.
|
||||
|
||||
Coverage includes the desktop sidebar and phone navigation, Home and search,
|
||||
Now Playing, all three notes tabs, stage lyrics, up next, playlist and channel
|
||||
views, Queue and batch selection, History, Saved, all download states and their
|
||||
Retry/Cancel controls, Settings overview and sections, Service mode, modals,
|
||||
control sheets, and empty states. Floating chrome has measured clearance;
|
||||
content scrolls inside the remaining viewport. `#sectionRail` stays fixed and
|
||||
attached directly to `body`. It is never moved into a panel or scroll container.
|
||||
|
||||
## Deliberate differences
|
||||
|
||||
- Content and navigation remain the real app's. Home shows actual playlists
|
||||
and recommendations. Counts, artwork, progress, lyrics and wake-lock state
|
||||
come from existing data; there are no invented verification badges, transfer
|
||||
speeds, subscriber counts, or guarantees that the screen will stay awake.
|
||||
- The desktop sidebar uses the spec's 260px width rather than the HTML mockup's
|
||||
240px. The drawer extends through 1023px. The existing app stacks its player
|
||||
at phone widths; its browsing and player controls remain accessible there.
|
||||
- Now Playing keeps the real control deck, playlist panel, volume, looping,
|
||||
fullscreen, notes and More controls. Notes remain with the player instead of
|
||||
reproducing the mockup's separate static lyrics column. Service keeps its
|
||||
view, autoscroll, floating lyrics, reporting and text-size controls; these
|
||||
wrap when necessary rather than overlapping.
|
||||
- Settings keeps its section registry, back/history behavior, live summaries,
|
||||
search and desktop two-pane navigation. Layout selection remains the existing
|
||||
select rather than duplicating it with mockup-only theme chips.
|
||||
- Functional text has an 11px floor and controls have 44px minimum targets.
|
||||
Muted dark text and the light-theme gold were adjusted for contrast. Existing
|
||||
font scaling, density, contrast, reduced-motion and performance preferences
|
||||
continue to apply. Service lyrics keep their adjustable sizing.
|
||||
- Mockup frames omit the shell on several screens and contain empty space or
|
||||
sample rows. Real pages retain the full shell and scrollable content. Video
|
||||
fixture art is synthetic; a real playing video fills the video stage.
|
||||
|
||||
## Verification and screenshots
|
||||
|
||||
Run `node scripts/test-bento-theme.js` for a reproducible comparison. It extracts
|
||||
pre-Bento commit `04b620e` into a temporary directory, captures Classic and Glass
|
||||
before shots, then checks the current app. It does not modify the checkout.
|
||||
`BENTO_BASE_COMMIT` can select another baseline; `BENTO_SCREENSHOT_DIR` can select
|
||||
an output directory. Default output is `~/deliverables/ytplayer-done13/`.
|
||||
|
||||
The Playwright matrix covers 23 screens at 390px and 1440px in Chromium and
|
||||
WebKit. It also checks centered navigation icons, download action appearance,
|
||||
fixed rail ownership, sidebar and mini-player navigation, floating clearance,
|
||||
theme switching restoration, and light/high-contrast appearance. The reference
|
||||
is opened at both widths and each reference frame is captured when the local
|
||||
mockup is present. No local mockup files are added to these implementation
|
||||
commits. Static fixtures freeze the Classic mini-player's initial observer and
|
||||
native indeterminate progress to avoid unrelated animation/scroll races.
|
||||
Before/after comparisons allow at most 40 subpixel rasterization differences
|
||||
in a whole screenshot; substantial geometry or styling differences fail. The
|
||||
additional theme-switch check tolerates one RGB level per channel to account
|
||||
for Chromium recompositing rounded corners, while retaining the 40-pixel limit.
|
||||
|
||||
Frontend unit tests include floating-chrome clearance with and without safe
|
||||
areas/hidden bars and Bento-only SVG mapping without changing Glass glyphs.
|
||||
The usual frontend tests, app syntax check and server build are run after each
|
||||
screen group. Server source and imports are unchanged.
|
||||
|
||||
On a real iPhone, review safe-area clearance in Safari and installed mode,
|
||||
keyboard and orientation changes, the floating rail, mini-player return,
|
||||
Service controls, and actual video playback. On desktop, review long titles,
|
||||
sidebar drawer at tablet widths, keyboard focus, Settings navigation and theme
|
||||
switching. WebKit automation checks layout; it does not emulate all iOS media
|
||||
or safe-area behavior. Nothing was pushed or deployed.
|
||||
109
docs/youtube-save-unavailable.md
Normal file
109
docs/youtube-save-unavailable.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Rock Medley save investigation
|
||||
|
||||
Video: `wZzRoXymOUU` — **Rock Medley**, Petra - Topic, 605 seconds.
|
||||
Investigation date: 2026-10-03. Main was pulled with `--ff-only` before changes.
|
||||
No production shell, credentials, cookies, vault, deployment or proxy was used.
|
||||
|
||||
## Evidence
|
||||
|
||||
YouTube's public oEmbed endpoint returns HTTP 200, the correct title/artist and
|
||||
thumbnail. This confirms the ID, but not playback permission. The watch page's
|
||||
initial player response on this development machine returns:
|
||||
|
||||
```json
|
||||
{"status":"UNPLAYABLE","reason":"Video unavailable"}
|
||||
```
|
||||
|
||||
The server's metadata invocation (`-J --no-warnings URL`) and the exact cache
|
||||
save invocation fail with `ERROR: [youtube] wZzRoXymOUU: Video unavailable`.
|
||||
Providing Bun as the JS runtime mirrors the Docker image's `/etc/yt-dlp.conf`.
|
||||
The error occurs during player extraction, before selecting/downloading a
|
||||
format. The server uses a ladder of selectors, not a hard-coded format ID.
|
||||
|
||||
| Local extractor | Default result |
|
||||
| --- | --- |
|
||||
| PATH yt-dlp 2026.07.04 | Video unavailable |
|
||||
| Repository binary 2026.08.19 | Video unavailable |
|
||||
| Downloaded nightly 2026.09.27.232945 | Video unavailable |
|
||||
|
||||
With 2026.08.19, explicit `web_embedded`, `web_safari`, `tv_simply`, `android_vr`,
|
||||
`mweb` and `web_music` clients all fail for this song too. A control video
|
||||
(`0gfX0dFLaBc`) succeeds from the same machine/runtime. This is video-specific
|
||||
upstream playability refusal, not a demonstrated stale-extractor, missing-format
|
||||
or broken-JS-runtime problem. It is **not exclusively reproducible on prod**.
|
||||
The generic response does not establish region, IP, account or licensing as the
|
||||
specific cause. Cookies or PO tokens were not requested or tested.
|
||||
|
||||
Upstream release notes: [2026.08.19](https://github.com/yt-dlp/yt-dlp/releases/tag/2026.08.19).
|
||||
[yt-dlp documentation](https://github.com/yt-dlp/yt-dlp#dependencies) describes
|
||||
runtime dependencies; no dependency update resolved this local reproduction.
|
||||
The Dockerfile is unchanged because an update alone is not a proven remedy.
|
||||
Its existing release-download layer is cached by Docker, so a future intentional
|
||||
extractor update must rebuild that layer, rather than assume new app code updates
|
||||
it automatically.
|
||||
|
||||
## Changes
|
||||
|
||||
1. Shared extraction now tries a bounded client ladder for generic unavailable
|
||||
and missing-format errors, as well as bot checks. This covers both metadata
|
||||
probes and actual saves. Explicit private/member/age/country/removal errors,
|
||||
local disk errors, cancellation and explicit client overrides do not trigger
|
||||
that ladder. The final diagnostic preserves the original failure and lists
|
||||
attempted clients; it does not mislabel the song as deleted.
|
||||
2. Explicit Retry POSTs `/api/download/:id/retry` before preparing a device save.
|
||||
It clears stale preparation/stream hints and calls the normal cache with
|
||||
`force:true` to bypass a **failed row's** cooldown. It does not delete ready
|
||||
media or duplicate running jobs. USB-volume, free-space/budget, duration and
|
||||
validation rules still belong to the existing cache. Repeated failed retries
|
||||
are limited to one per song per 15 seconds. Normal automatic resumes do not
|
||||
force a retry. Manual Retry follows the same new client ladder.
|
||||
3. Downloads, Settings → Downloads & storage, and save-failure toasts use plain
|
||||
language. Expandable “Technical details” retains the raw tool error as text,
|
||||
never HTML. Controls remain usable in both themes at 390/1440 px.
|
||||
|
||||
## Remaining reviewer check
|
||||
|
||||
**These changes do not demonstrate a successful download of Rock Medley.**
|
||||
The local default and all tested clients still receive YouTube's refusal.
|
||||
To establish the production-specific reason, the reviewer should compare:
|
||||
|
||||
- Actual yt-dlp version and the invocation below inside the deployed container.
|
||||
- Logged-out YouTube playback on the homelab's outbound network versus the
|
||||
working phone network; repeat with the phone on that same network if possible.
|
||||
- If only authenticated playback works, confirm whether the video needs account
|
||||
access before considering the existing optional read-only cookies mechanism.
|
||||
Do not assume a PO token is required without extractor diagnostics saying so.
|
||||
- If the failure tracks a network/region, use a permitted outbound network where
|
||||
the video is available, or a permitted alternate source/upload/device copy.
|
||||
Merely changing a region hint does not prove actual playback eligibility.
|
||||
|
||||
No network, cookies, token, Docker or deployment workaround was guessed or
|
||||
silently enabled. After any confirmed environment correction, Retry now starts
|
||||
a genuine fresh attempt instead of just returning the old backoff failure.
|
||||
|
||||
```sh
|
||||
# Metadata probe, same as the server (Docker config supplies the Bun runtime)
|
||||
yt-dlp -J --no-warnings 'https://www.youtube.com/watch?v=wZzRoXymOUU'
|
||||
|
||||
# Cache save, same selector/merge/rate args as the server's defaults
|
||||
yt-dlp 'https://www.youtube.com/watch?v=wZzRoXymOUU' --no-warnings --no-playlist \
|
||||
-f 'bv*[height<=720][vcodec^=avc1]+ba[ext=m4a]/bv*[height<=720][vcodec^=avc1]+ba/b[ext=mp4][vcodec^=avc1]/bv*[height<=720]+ba/b[ext=mp4]/b' \
|
||||
--merge-output-format mp4 --limit-rate 2M -o /tmp/rock-medley-check.mp4
|
||||
```
|
||||
|
||||
Tests: `node --test frontend/*.test.js`; app syntax check; unbundled server build;
|
||||
`bun test server/ytdlp-resilience.test.js server/download-retry.test.js
|
||||
server/media-cache.test.js`; and
|
||||
`npx playwright test --config playwright.download-retry.config.js`.
|
||||
The existing pool tests additionally need an importable yt-dlp zipapp:
|
||||
`YTDLP_PATH=/path/to/zipapp bun test server/ytdlp-pool.test.js`. The installed
|
||||
PATH executable here is a Python entry-point script, not an importable zipapp;
|
||||
providing the downloaded nightly zipapp makes all pool tests pass without any
|
||||
production code or environment changes.
|
||||
|
||||
Final validation: **118 frontend unit tests, 44 server tests and 13 browser cases
|
||||
pass**, plus the app syntax check and server build. Browser cases cover the real
|
||||
Retry controls in both Downloads and Settings, normal resumes, friendly failures,
|
||||
44 px details controls, safe raw text and overflow at phone/desktop widths.
|
||||
A real iPhone Safari/PWA save should still be checked after deployment, especially
|
||||
pause/resume of a partial copy and retry after a network change.
|
||||
@@ -6,6 +6,7 @@
|
||||
<meta name="robots" content="noindex" />
|
||||
<meta name="theme-color" content="#0a0c12" />
|
||||
<title>Lyrics Studio</title>
|
||||
<link rel="stylesheet" href="download-actions.css" />
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
@@ -193,7 +194,7 @@
|
||||
.transport { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.round { width: 42px; height: 42px; border-radius: 50%; border: 1px solid var(--line-2); background: rgba(255,255,255,.06); display: grid; place-items: center; font-size: 12px; font-weight: 800; }
|
||||
.round.big { width: 52px; height: 52px; border: 0; background: var(--grad); box-shadow: 0 10px 24px -8px var(--a1); }
|
||||
.round svg { width: 20px; height: 20px; fill: #fff; }
|
||||
.round svg { width: 20px; height: 20px; }
|
||||
.clock { font: 600 17px var(--mono); letter-spacing: -.02em; min-width: 108px; text-align: center; }
|
||||
.clock b { color: var(--a2); } .clock span { color: var(--text-dim); font-size: 13px; }
|
||||
.seg { display: inline-flex; padding: 3px; border-radius: 13px; background: rgba(0,0,0,.35); border: 1px solid var(--line); }
|
||||
@@ -312,7 +313,9 @@
|
||||
main { padding-top: 16px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
|
||||
</style>
|
||||
.media-shape { display:none; } .media-shape-classic { display:inline; }
|
||||
.media-icon { width:18px; height:18px; vertical-align:middle; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -382,7 +385,7 @@
|
||||
<canvas id="wave" height="64"></canvas>
|
||||
<div class="transport">
|
||||
<button class="round" id="bBack" type="button" title="Back 5 s" aria-label="Back 5 seconds">−5</button>
|
||||
<button class="round big" id="bPlay" type="button" aria-label="Play or pause"><svg viewBox="0 0 24 24"><path id="playIcon" d="M8 5v14l11-7z"/></svg></button>
|
||||
<button class="round big" id="bPlay" type="button" aria-label="Play or pause"><svg id="playIcon" class="transport-icon" data-transport-icon="play" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><use class="transport-shape transport-shape-classic" href="#transport-play"/><use class="transport-shape transport-shape-f7-swipe" href="#transport-play-f7-swipe"/><use class="transport-shape transport-shape-glass-stage" href="#transport-play-glass-stage"/><use class="transport-shape transport-shape-bento-hub" href="#transport-play-bento-hub"/></svg></button>
|
||||
<button class="round" id="bFwd" type="button" title="Forward 5 s" aria-label="Forward 5 seconds">+5</button>
|
||||
<div class="clock"><b id="cur">0:00.00</b> <span>/ <span id="dur">0:00</span></span></div>
|
||||
<select class="mini" id="rate" title="Playback speed" aria-label="Playback speed">
|
||||
@@ -407,7 +410,7 @@
|
||||
<label>Shift every timed line by <input class="field" id="shiftBy" type="text" value="0.0" style="flex:0 0 84px;height:36px;text-align:center" /> s</label>
|
||||
<button class="btn sm" id="shiftBtn" type="button">Apply</button>
|
||||
<span class="grow"></span>
|
||||
<button class="btn sm" id="webBtn" type="button" title="Fetch lyrics from LRCLIB">⤓ Pull from LRCLIB</button>
|
||||
<button class="btn sm" id="webBtn" type="button" title="Fetch lyrics from LRCLIB"><svg class="media-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-download"/></svg> Pull from LRCLIB</button>
|
||||
<button class="btn sm" id="rawBtn" type="button">✎ Edit as text</button>
|
||||
<label><input type="checkbox" id="follow" checked /> Follow playback</label>
|
||||
</div>
|
||||
@@ -508,7 +511,7 @@
|
||||
<div class="card">
|
||||
<div class="row" style="margin-bottom:8px"><h2 style="margin:0">Recent edits</h2><span class="grow"></span>
|
||||
<button class="btn sm" id="refreshBtn" type="button">↻ Refresh</button>
|
||||
<a class="btn sm" href="/api/admin/notes/export">⤓ Export all (JSON)</a></div>
|
||||
<a class="btn sm" href="/api/admin/notes/export"><svg class="media-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-download"/></svg> Export all (JSON)</a></div>
|
||||
<div class="scroll"><table class="tbl" id="revTable"></table></div>
|
||||
<pre id="revView" class="hidden" style="margin-top:12px"></pre>
|
||||
</div>
|
||||
@@ -569,6 +572,7 @@
|
||||
<nav class="nav" id="tabsBottom" role="tablist"></nav>
|
||||
</div>
|
||||
|
||||
<script src="download-actions.js"></script>
|
||||
<script src="lyrics-core.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
@@ -1169,8 +1173,8 @@
|
||||
const a = new Audio(url); a.preload = 'auto';
|
||||
a.addEventListener('timeupdate', tick); a.addEventListener('seeked', tick);
|
||||
a.addEventListener('loadedmetadata', () => { if (a.duration) { LE.dur = a.duration; $('dur').textContent = fmtT(a.duration).slice(0, -3); } draw(); });
|
||||
a.addEventListener('play', () => { $('playIcon').setAttribute('d', 'M7 5h4v14H7zM13 5h4v14h-4z'); });
|
||||
a.addEventListener('pause', () => { $('playIcon').setAttribute('d', 'M8 5v14l11-7z'); });
|
||||
a.addEventListener('play', () => { TransportIcons.set($('bPlay'),'pause'); });
|
||||
a.addEventListener('pause', () => { TransportIcons.set($('bPlay'),'play'); });
|
||||
a.addEventListener('error', () => say('No playable audio for this song on the server — you can still edit the text.', 'err'));
|
||||
a.playbackRate = Number($('rate').value);
|
||||
LE.audio = a;
|
||||
@@ -1196,7 +1200,7 @@
|
||||
<div class="ln-tools">
|
||||
<input class="tin" value="${esc(fmtT(line.t))}" placeholder="0:00.00" aria-label="Time" />
|
||||
<button class="btn" data-act="set" type="button">◎ Stamp now</button>
|
||||
<button class="btn" data-act="seek" type="button">▶ Play here</button>
|
||||
<button class="btn" data-act="seek" type="button">${TransportIcons.svg('play')} Play here</button>
|
||||
<button class="btn" data-act="nudge" data-d="-0.2" type="button">−0.2</button>
|
||||
<button class="btn" data-act="nudge" data-d="0.2" type="button">+0.2</button>
|
||||
<select class="mini" data-kind aria-label="Line type"><option value="line">Line</option><option value="section">Section</option><option value="cue">Cue</option></select>
|
||||
|
||||
84
frontend/app-bootstrap.test.js
Normal file
84
frontend/app-bootstrap.test.js
Normal file
@@ -0,0 +1,84 @@
|
||||
const {test}=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const {webcrypto,createHash}=require('node:crypto');
|
||||
const vm=require('node:vm');
|
||||
const {readFileSync}=require('node:fs');
|
||||
function fixture({cached=false,tampered=false,enabled=true,loading=false,quota=false,insecure=false,previous=false,contract=1,capture=true,controller=true,parser=false}={}){
|
||||
const source='window.appRuns=(window.appRuns||0)+1; window.boot=()=>window.bootRuns=(window.bootRuns||0)+1; document.addEventListener("DOMContentLoaded",boot);',hash=createHash('sha256').update(source).digest('hex'), key='/app.js?v='+hash.slice(0,10);
|
||||
const oldSource='window.oldApp=true;'+source, oldHash=createHash('sha256').update(oldSource).digest('hex').slice(0,10), oldKey='/app.js?v='+oldHash;
|
||||
const held=new Map(), appended=[],writes=[],listeners={};let calls=0;
|
||||
const response=()=>new Response(tampered?'bad':source,{headers:{'X-Asset-Hash':hash.slice(0,10)}});
|
||||
if(cached)held.set(key,response());
|
||||
if(previous){held.set(oldKey,new Response(oldSource,{headers:{'X-Asset-Hash':oldHash}}));held.set('/__ytp_asset_state',Response.json({previous:{files:{'/app.js':{h:oldHash}},groups:{core:{contract,files:['/app.js']}}}}));}
|
||||
const root={crypto:insecure?undefined:webcrypto,TextDecoder,Uint8Array,btoa,console,AssetSyncCore:require('./asset-sync-core'),navigator:{serviceWorker:{controller:controller?{}:null}},Lazy:{captureApp:capture,manifest:{groups:{core:{contract:1,files:['/app.js']}},appCache:enabled,files:{'/app.js':{h:hash.slice(0,10)}}},url:()=>key},fetch:async()=>{calls++;return previous?new Response(oldSource,{headers:{'X-Asset-Hash':oldHash}}):response();},caches:{open:async()=>({match:async k=>held.get(k)?.clone(),put:async(k,r)=>{if(quota)throw Error('quota');held.set(k,r.clone());}})}};
|
||||
const doc=root.document={readyState:loading?'loading':'complete',currentScript:parser?{}:null,write:value=>{writes.push(value);vm.runInContext(source,context);},querySelector:()=>({content:"script-src 'self' 'sha256-"+Buffer.from(hash,'hex').toString('base64')+"'"}),addEventListener:(name,fn)=>(listeners[name] ||= []).push(fn),createElement:()=>({remove(){this.removed=true;}}),head:{append(node){appended.push(node);if(node.textContent)vm.runInContext(node.textContent,context);else {queueMicrotask(async()=>{if(previous && node.src===key){const state=await held.get('/__ytp_asset_state').clone().json();if(!root.AssetSyncCore.fallback(root.Lazy.manifest,state.previous,'/app.js'))return node.onerror();vm.runInContext(oldSource,context);}else vm.runInContext(node.src===oldKey?oldSource:source,context);node.onload();});}}}};
|
||||
root.window=root;const context=vm.createContext(root);
|
||||
vm.runInContext(readFileSync(require.resolve('./section-rail.js'),'utf8'),context);
|
||||
return {root,held,appended,writes,listeners,key,calls:()=>calls,doc,response,oldKey};
|
||||
}
|
||||
test('cold boot caches verified app before execution so worker skips its download',async()=>{
|
||||
const f=fixture();await f.root.AppBootstrap.ready;assert.equal(f.calls(),1);assert.ok(f.held.has(f.key));assert.equal(f.root.appRuns,1);assert.equal(f.root.bootRuns,1);assert.equal(f.appended[0].src,undefined);assert.equal(f.appended[0].removed,true);
|
||||
});
|
||||
test('offline boot reads the exact cached app without network or re-evaluation',async()=>{
|
||||
const f=fixture({cached:true});await f.root.AppBootstrap.ready;assert.equal(f.calls(),0);assert.equal(f.root.appRuns,1);assert.equal(f.root.bootRuns,1);
|
||||
});
|
||||
test('tampered cached bytes are rejected before execution',async()=>{
|
||||
const f=fixture({cached:true,tampered:true});await assert.rejects(f.root.AppBootstrap.ready,/hash/);assert.equal(f.root.appRuns,undefined);
|
||||
});
|
||||
test('native and rollback boot use the original external classic script',async()=>{
|
||||
const f=fixture({enabled:false});await f.root.AppBootstrap.ready;assert.equal(f.calls(),0);assert.equal(f.appended[0].src,f.key);assert.equal(f.root.bootRuns,1);
|
||||
});
|
||||
test('boot waits for the normal DOM event when the parser has not finished',async()=>{
|
||||
const f=fixture({loading:true});await f.root.AppBootstrap.ready;assert.equal(f.root.bootRuns,undefined);for(const fn of f.listeners.DOMContentLoaded)fn();assert.equal(f.root.appRuns,1);assert.equal(f.root.bootRuns,1);
|
||||
});
|
||||
test('quota failure still executes the verified response without deleting saved data',async()=>{
|
||||
const f=fixture({quota:true});await f.root.AppBootstrap.ready;assert.equal(f.calls(),1);assert.equal(f.root.appRuns,1);assert.equal(f.root.bootRuns,1);
|
||||
});
|
||||
|
||||
|
||||
test('the early head response is consumed without a second page fetch',async()=>{
|
||||
const f=fixture();f.root.Lazy.appResponse=Promise.resolve(f.response());await f.root.AppBootstrap.ready;
|
||||
assert.equal(f.calls(),0);assert.ok(f.held.has(f.key));assert.equal(f.root.appRuns,1);
|
||||
});
|
||||
|
||||
|
||||
test('insecure local HTTP boot retains external execution without WebCrypto',async()=>{
|
||||
const f=fixture({insecure:true});await f.root.AppBootstrap.ready;assert.equal(f.calls(),0);assert.equal(f.appended[0].src,f.key);assert.equal(f.root.bootRuns,1);
|
||||
});
|
||||
|
||||
|
||||
test('missing current app uses verified same-contract N-1 offline through the controlling worker',async()=>{
|
||||
const f=fixture({previous:true});await f.root.AppBootstrap.ready;
|
||||
assert.equal(f.appended[0].src,f.oldKey);assert.equal(f.root.oldApp,true);assert.equal(f.root.bootRuns,1);assert.equal(f.held.has(f.key),false);
|
||||
});
|
||||
test('an incompatible previous core never executes against the new shell',async()=>{
|
||||
const f=fixture({previous:true,contract:2});await assert.rejects(f.root.AppBootstrap.ready,/hash/);assert.equal(f.root.appRuns,undefined);
|
||||
});
|
||||
|
||||
|
||||
test('a feature contract change also prevents stale core execution',async()=>{
|
||||
const f=fixture({previous:true});f.root.Lazy.manifest.groups['feature:test']={contract:2,files:[]};
|
||||
const state=await f.held.get('/__ytp_asset_state').json();state.previous.groups['feature:test']={contract:1,files:[]};f.held.set('/__ytp_asset_state',Response.json(state));
|
||||
await assert.rejects(f.root.AppBootstrap.ready,/hash/);assert.equal(f.root.appRuns,undefined);
|
||||
});
|
||||
|
||||
|
||||
test('native-cache engines use their external script and keep normal code-cache behavior',async()=>{
|
||||
const f=fixture({capture:false,controller:false});await f.root.AppBootstrap.ready;assert.equal(f.calls(),0);assert.equal(f.appended[0].src,f.key);assert.equal(f.root.bootRuns,1);
|
||||
});
|
||||
|
||||
|
||||
test('native-cache engines still reject an incompatible retained core',async()=>{
|
||||
const f=fixture({capture:false,previous:true,contract:2});await assert.rejects(f.root.AppBootstrap.ready,/Unable to load player/);assert.equal(f.root.appRuns,undefined);
|
||||
});
|
||||
test('native-cache engines use the validated current cache entry without decoding its body',async()=>{
|
||||
const f=fixture({capture:false,cached:true});await f.root.AppBootstrap.ready;assert.equal(f.calls(),0);assert.equal(f.appended[0].src,f.key);assert.equal(f.root.bootRuns,1);
|
||||
});
|
||||
|
||||
test('native parser boot preserves classic evaluation before the original DOM event',async()=>{
|
||||
const f=fixture({capture:false,loading:true,parser:true});
|
||||
assert.equal(f.root.appRuns,1);assert.equal(f.root.bootRuns,undefined);
|
||||
assert.deepEqual(f.writes,['<script src="'+f.key+'"></script>']);assert.equal(f.appended.length,0);
|
||||
for(const fn of f.listeners.DOMContentLoaded)fn();await f.root.AppBootstrap.ready;
|
||||
assert.equal(f.root.bootRuns,1);assert.equal(f.calls(),0);
|
||||
});
|
||||
83
frontend/app-seams.test.js
Normal file
83
frontend/app-seams.test.js
Normal file
@@ -0,0 +1,83 @@
|
||||
const {test}=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const {readFileSync}=require('node:fs');
|
||||
const {createHash}=require('node:crypto');
|
||||
const {join}=require('node:path');
|
||||
const app=readFileSync(join(__dirname,'app.js'),'utf8');
|
||||
test('Phase 4 preserves the exact playback, continuity, tracking and queue bodies',()=>{
|
||||
{const start=app.indexOf("const Player = {");assert.ok(start>=0,"Player");const body=app.slice(start,start+30343);assert.equal(createHash('sha256').update(body).digest('hex'),"373df46f4f994717472d26a6bac07449f6ca0a930cb666bfe3895d1dc65aa351","Player body unchanged");}
|
||||
{const start=app.indexOf("const Transition = (() => {");assert.ok(start>=0,"Transition");const body=app.slice(start,start+8588);assert.equal(createHash('sha256').update(body).digest('hex'),"71997a537078308a41f0cbf8e29fd1658fb3a199c4f5a25d8af0931f1c2a6d50","Transition body unchanged");}
|
||||
{const start=app.indexOf("const Wave = (() => {");assert.ok(start>=0,"Wave");const body=app.slice(start,start+5241);assert.equal(createHash('sha256').update(body).digest('hex'),"7252f4e856c59aef8aabfcd394ce4bcba656110cbd01a1a8af662eeb48166a3c","Wave body unchanged");}
|
||||
{const start=app.indexOf("const StatsTrack = (() => {");assert.ok(start>=0,"StatsTrack");const body=app.slice(start,start+1462);assert.equal(createHash('sha256').update(body).digest('hex'),"9aae2631ba3bfad918319579b7120814af06c84949394d04e52c65345332d514","StatsTrack body unchanged");}
|
||||
{const start=app.indexOf("function wirePlayerEvents() {");assert.ok(start>=0,"wirePlayerEvents");const body=app.slice(start,start+4913);assert.equal(createHash('sha256').update(body).digest('hex'),"1c5a2b5ce310f6aef6764325eda85a95f47e4f507f2049036ce1da206379908c","wirePlayerEvents body unchanged");}
|
||||
{const start=app.indexOf("function persist() {");assert.ok(start>=0,"persist");const body=app.slice(start,start+147);assert.equal(createHash('sha256').update(body).digest('hex'),"01e5c5dbc80616266db09b3829dc45b66476a3256b1c169d8e44f4983b24ac45","persist body unchanged");}
|
||||
{const start=app.indexOf("function playFromList(list, index, source = '', { playFull = false } = {}) {");assert.ok(start>=0,"playFromList");const body=app.slice(start,start+401);assert.equal(createHash('sha256').update(body).digest('hex'),"f8009c9674b3478959997dca881fb7b9e1ab3708c98e5abdf1958d752e47ee41","playFromList body unchanged");}
|
||||
{const start=app.indexOf("function advanceQueue() {");assert.ok(start>=0,"advanceQueue");const body=app.slice(start,start+596);assert.equal(createHash('sha256').update(body).digest('hex'),"be405b9c163e3d41a949ef11f6022ef13115b04083e3b6ad21d97beba1f45ae6","advanceQueue body unchanged");}
|
||||
{const start=app.indexOf("function playNext() {");assert.ok(start>=0,"playNext");const body=app.slice(start,start+143);assert.equal(createHash('sha256').update(body).digest('hex'),"859d2ffd2e8c89a8d9c0dd0cf429fd403675452cedba2e34aa3dc72683d4f8c6","playNext body unchanged");}
|
||||
{const start=app.indexOf("function playPrev() {");assert.ok(start>=0,"playPrev");const body=app.slice(start,start+268);assert.equal(createHash('sha256').update(body).digest('hex'),"ece8d33bd03c8146a6a702534c8fed50ff606170685b08d730b19d9d3c68cf48","playPrev body unchanged");}
|
||||
{const start=app.indexOf("function onTrackEnded() {");assert.ok(start>=0,"onTrackEnded");const body=app.slice(start,start+702);assert.equal(createHash('sha256').update(body).digest('hex'),"d6e147238ad9656fa1c2335973b37597749bcfd02df1a66390b306fe1194ae0d","onTrackEnded body unchanged");}
|
||||
{const start=app.indexOf("function sleepFadeSec() {");assert.ok(start>=0,"sleepFadeSec");const body=app.slice(start,start+138);assert.equal(createHash('sha256').update(body).digest('hex'),"3f986c8cc474d8313476e9c209b9264e3724011ffaee887f47178bb5155a4d77","sleepFadeSec body unchanged");}
|
||||
{const start=app.indexOf("function applySleepFade(remaining) {");assert.ok(start>=0,"applySleepFade");const body=app.slice(start,start+346);assert.equal(createHash('sha256').update(body).digest('hex'),"9bd0fa697b271c6b80c128ff1d0a7b00afcb3ccf08f98b5c7e7dc86fd4329681","applySleepFade body unchanged");}
|
||||
{const start=app.indexOf("function startSleepTimer(minutes, { endOfSong = false } = {}) {");assert.ok(start>=0,"startSleepTimer");const body=app.slice(start,start+487);assert.equal(createHash('sha256').update(body).digest('hex'),"f0a93401ab90196096074cb607b28d52c5903e11dbdfb8ddb95114e143af2409","startSleepTimer body unchanged");}
|
||||
{const start=app.indexOf("function cancelSleepTimer() {");assert.ok(start>=0,"cancelSleepTimer");const body=app.slice(start,start+253);assert.equal(createHash('sha256').update(body).digest('hex'),"cf396ab758a70eede6af7444539a82ca15af801a9a57024dd5542067e9fa771a","cancelSleepTimer body unchanged");}
|
||||
{const start=app.indexOf("function finishSleep() {");assert.ok(start>=0,"finishSleep");const body=app.slice(start,start+662);assert.equal(createHash('sha256').update(body).digest('hex'),"7ee93bf760bad5f39f4644339963918c5b369b2acc554b8e561c1a1f070ac6fb","finishSleep body unchanged");}
|
||||
{const start=app.indexOf("const EqRender = (() => {");assert.ok(start>=0,"EqRender");const body=app.slice(start,start+5960);assert.match(body,/EQ\.isIOS\(\) && WEB && !data\.settings\?\.jamesDsp\?\.enabled/);const standardBody=body.replace(' && !data.settings?.jamesDsp?.enabled','').slice(0,5912);assert.equal(createHash('sha256').update(standardBody).digest('hex'),"f014dd41dafebc33f09e87be866074afd7a2443c5bb452103a435585353a828f","Standard EqRender body unchanged");}
|
||||
{const start=app.indexOf("const Level = (() => {");assert.ok(start>=0,"Level");const body=app.slice(start,start+3892);assert.equal(createHash('sha256').update(body).digest('hex'),"e48ad7797384ac31bb8b864662fc2a546b04470b981042f016874ba750957226","Level body unchanged");}
|
||||
});
|
||||
|
||||
test('eager shell and Settings definitions load before app state and defer reading it',()=>{
|
||||
const html=readFileSync(join(__dirname,'index.html'),'utf8');
|
||||
for(const file of ['shell-core.js','views-core.js','section-rail.js']) {
|
||||
assert.ok(html.indexOf('src="'+file+'"')<=html.indexOf('src="section-rail.js"'));
|
||||
const vm=require('node:vm');const doc={readyState:'complete',addEventListener(){},createElement:()=>({}),head:{append(){}}};const globals={document:doc,Lazy:{manifest:{appCache:false},url:()=>'/app.js'},console};globals.window=globals;const context=vm.createContext(globals);
|
||||
// Evaluation before any data/$/els/Player declaration must be safe.
|
||||
vm.runInContext(readFileSync(join(__dirname,file),'utf8'),context);
|
||||
}
|
||||
const views=readFileSync(join(__dirname,'views-core.js'),'utf8');
|
||||
assert.ok(views.includes('async function renderSettings()'));
|
||||
});
|
||||
|
||||
test('remote facade preserves synchronous no-session and loaded changed hooks',()=>{
|
||||
const source=app.match(/\/\* Remote facade \*\/([\s\S]*?)\/\* End remote facade \*\//)?.[1];
|
||||
assert.ok(source,'remote facade is present');
|
||||
const context=require('node:vm').createContext({window:{},Lazy:{proxy:()=>({}),onLoad(){}}});
|
||||
require('node:vm').runInContext(source+';globalThis.api=Remote;',context);
|
||||
assert.equal(context.api.changed(),undefined);
|
||||
let calls=0;context.window.YT={Remote:{changed:()=>{calls++;return 'sync';}}};
|
||||
assert.equal(context.api.changed(),'sync');assert.equal(calls,1);
|
||||
});
|
||||
|
||||
test('watch-party facade keeps playback decisions synchronous before and after loading',()=>{
|
||||
const source=app.match(/\/\* Party facade \*\/([\s\S]*?)\/\* End party facade \*\//)?.[1];assert.ok(source);
|
||||
const context=require('node:vm').createContext({window:{},Lazy:{proxy:()=>({}),onLoad(){}}});
|
||||
require('node:vm').runInContext(source+';globalThis.api=Party;',context);
|
||||
for(const method of ['guestActive','intercept','nextDJ'])assert.equal(context.api[method]('next'),false);
|
||||
context.window.YT={Party:{guestActive:()=>true,intercept:action=>action==='next',nextDJ:()=>true}};
|
||||
for(const method of ['guestActive','intercept','nextDJ'])assert.equal(context.api[method]('next'),true);
|
||||
});
|
||||
|
||||
test('Share and External stay behind lazy entry proxies',()=>{
|
||||
assert.match(app,/const Share = Lazy\.proxy\('feature:share-external'/);
|
||||
assert.match(app,/const External = Lazy\.proxy\('feature:share-external'/);
|
||||
const chunk=readFileSync(join(__dirname,'share-external.js'),'utf8');
|
||||
const context=require('node:vm').createContext({window:{},navigator:{userAgent:''},location:{origin:'https://fixture.test'},isUploadId:()=>false});
|
||||
require('node:vm').runInContext(chunk,context);
|
||||
assert.equal(typeof context.window.YT.Share.open,'function');
|
||||
const text=context.window.YT.External.m3u([{id:'benchmedia01',title:'Fixture'}]);
|
||||
assert.match(text,/#EXTM3U/);assert.match(text,/benchmedia01/);
|
||||
});
|
||||
|
||||
test('listening tracking does not pull in the lazy Stats view',()=>{
|
||||
const {groups}=JSON.parse(readFileSync(join(__dirname,'assets.json'),'utf8'));
|
||||
assert.deepEqual(groups['feature:stats-view'].files,['/stats-view.js']);
|
||||
assert.deepEqual(groups['feature:stats-view'].dependencies,['feature:stats']);
|
||||
assert.deepEqual(groups['feature:stats'].files,['/stats-core.js']);
|
||||
assert.match(app,/Lazy\.proxy\('feature:stats-view', \['render'\], 'StatsView'\)/);
|
||||
});
|
||||
|
||||
test('an unavailable Stats chunk reports an error without rejecting the UI navigation',async()=>{
|
||||
const source=app.match(/function renderStats\(\) \{[\s\S]*?\n\}/)?.[0];assert.ok(source);
|
||||
const seen=[],context=require('node:vm').createContext({Lazy:{proxy:()=>({render:()=>Promise.reject(new Error('Unavailable fixture chunk'))})},toast:text=>seen.push(text)});
|
||||
require('node:vm').runInContext(source+';globalThis.render=renderStats;',context);
|
||||
await context.render();assert.deepEqual(seen,['Unavailable fixture chunk']);
|
||||
});
|
||||
4319
frontend/app.js
4319
frontend/app.js
File diff suppressed because it is too large
Load Diff
219
frontend/asset-sync-core.js
Normal file
219
frontend/asset-sync-core.js
Normal file
@@ -0,0 +1,219 @@
|
||||
/* Exact, resumable asset synchronization shared by pages and workers. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
const CACHE = 'ytplayer-assets';
|
||||
const STATE = '/__ytp_asset_state';
|
||||
const OPT_INS = '/__ytp_offline_opt_ins';
|
||||
const url = (path, file) => path + '?v=' + file.h;
|
||||
|
||||
function paths(manifest) {
|
||||
return Object.keys(manifest.files).sort();
|
||||
}
|
||||
|
||||
function offlinePaths(manifest, optIns = []) {
|
||||
const allowed = new Set(optIns || []), optional = new Map();
|
||||
for (const group of Object.values(manifest.groups || {})) {
|
||||
if (!group.offlineOptIn || allowed.has(group.offlineOptIn)) continue;
|
||||
for (const path of group.files || []) optional.set(path, true);
|
||||
}
|
||||
return paths(manifest).filter(path => !optional.has(path));
|
||||
}
|
||||
|
||||
function blocking(manifest, previous, activeLayout) {
|
||||
const names = new Set(Object.entries(manifest.groups)
|
||||
.filter(([name, group]) => name === 'core' || !group.background ||
|
||||
name === 'layout:' + activeLayout ||
|
||||
(previous?.groups[name] && previous.groups[name].contract !== group.contract))
|
||||
.map(([name]) => name));
|
||||
for (const name of names) for (const dependency of manifest.groups[name].dependencies || []) names.add(dependency);
|
||||
return [...new Set([...names].flatMap(name => manifest.groups[name].files))].sort();
|
||||
}
|
||||
|
||||
function plan(manifest, keys) {
|
||||
const held = new Set(keys);
|
||||
return {
|
||||
missing: paths(manifest).map(path => url(path, manifest.files[path])).filter(key => !held.has(key)),
|
||||
blocking: blocking(manifest).map(path => url(path, manifest.files[path])),
|
||||
};
|
||||
}
|
||||
|
||||
function retained(current, previous) {
|
||||
const currentPaths = new Set(paths(current));
|
||||
const keep = new Set([...currentPaths].map(path => url(path, current.files[path])));
|
||||
if (previous) for (const path of paths(previous)) {
|
||||
if (!currentPaths.has(path) || current.files[path]?.h !== previous.files[path].h) keep.add(url(path, previous.files[path]));
|
||||
}
|
||||
return keep;
|
||||
}
|
||||
|
||||
function fallback(manifest, previous, path) {
|
||||
if (!manifest.files[path] || !previous?.files[path]) return null;
|
||||
const group = Object.keys(manifest.groups).find(name => manifest.groups[name].files.includes(path));
|
||||
if (!group || !previous.groups[group]?.files.includes(path) ||
|
||||
manifest.groups[group].contract !== previous.groups[group].contract) return null;
|
||||
// A stale core must understand every group contract in the new shell.
|
||||
if (path === "/app.js" && Object.entries(manifest.groups).some(([name, entry]) =>
|
||||
previous.groups[name]?.contract !== entry.contract)) return null;
|
||||
return url(path, previous.files[path]);
|
||||
}
|
||||
|
||||
async function state(cache) {
|
||||
const response = await cache.match(STATE);
|
||||
return response ? response.json() : null;
|
||||
}
|
||||
|
||||
function verifiedProbe(response, hash) {
|
||||
if (!response) return false;
|
||||
const valid = response.headers.get('X-Asset-Hash') === hash;
|
||||
// Metadata checks must release Blob/data-pipe readers. They never consume
|
||||
// asset bytes, and cancelling this response does not remove its cache entry.
|
||||
response.body?.cancel().catch(() => {});
|
||||
return valid;
|
||||
}
|
||||
|
||||
async function readOptIns(cache) {
|
||||
try { return (await (await cache.match(OPT_INS))?.json()) || []; } catch { return []; }
|
||||
}
|
||||
|
||||
async function status(manifest, cache, { previous, activeLayout, offline = true, optIns } = {}) {
|
||||
const found = await Promise.all(blocking(manifest, previous, activeLayout).map(async path => {
|
||||
const response = await cache.match(url(path, manifest.files[path]));
|
||||
return verifiedProbe(response, manifest.files[path].h);
|
||||
}));
|
||||
const missing = found.filter(value => !value).length;
|
||||
return { ready: missing === 0, missing, version: manifest.buildTag, ...(offline ? await completeness(manifest, cache, { optIns }) : {}) };
|
||||
}
|
||||
|
||||
// All concurrent messages/layout jobs in this worker share six download slots.
|
||||
let downloads = 0;
|
||||
const waiters = [];
|
||||
async function downloadSlot(task) {
|
||||
if (downloads >= 6) await new Promise(resolve => waiters.push(resolve));
|
||||
else downloads++;
|
||||
try { return await task(); }
|
||||
finally { const next = waiters.shift(); if (next) next(); else downloads--; }
|
||||
}
|
||||
|
||||
const pendingDownloads = new Map();
|
||||
async function sharedDownload(key, task) {
|
||||
while (pendingDownloads.has(key)) await pendingDownloads.get(key).catch(() => {});
|
||||
const promise = downloadSlot(task);
|
||||
pendingDownloads.set(key, promise);
|
||||
try { return await promise; }
|
||||
finally { pendingDownloads.delete(key); }
|
||||
}
|
||||
|
||||
async function syncAssets(manifest, { cache, fetchFn, concurrency = 6, attempts = 3, activeLayout, groups, all = false, optIns } = {}) {
|
||||
const previous = (await state(cache))?.current;
|
||||
const missing = [];
|
||||
const selected = all ? offlinePaths(manifest, optIns || await readOptIns(cache)) : groups ? [...new Set(groups.flatMap(name => manifest.groups[name]?.files || []))].sort()
|
||||
: blocking(manifest, previous, activeLayout);
|
||||
for (const path of selected) {
|
||||
const key = url(path, manifest.files[path]);
|
||||
const response = await cache.match(key);
|
||||
if (!verifiedProbe(response, manifest.files[path].h)) missing.push(key);
|
||||
}
|
||||
const count = missing.length;
|
||||
let failure;
|
||||
await Promise.all(Array.from({ length: Math.max(1, Math.min(6, concurrency, count)) }, async () => {
|
||||
while (missing.length) {
|
||||
const key = missing.shift();
|
||||
try { await sharedDownload(key, async () => {
|
||||
const held = await cache.match(key);
|
||||
if (verifiedProbe(held, key.split('v=')[1])) return;
|
||||
let error;
|
||||
for (let attempt = 0; attempt < Math.min(3, attempts); attempt++) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 30000);
|
||||
try {
|
||||
const response = await fetchFn(key, { credentials: 'same-origin', signal: controller.signal });
|
||||
if (!response.ok || response.headers.get('X-Asset-Hash') !== key.split('v=')[1]) {
|
||||
throw new Error('Asset hash mismatch: ' + key);
|
||||
}
|
||||
// Keep the timeout through body consumption, not just response headers.
|
||||
await cache.put(key, response);
|
||||
error = null;
|
||||
break;
|
||||
} catch (err) { error = err; }
|
||||
finally { clearTimeout(timer); }
|
||||
}
|
||||
if (error) throw error;
|
||||
}); } catch (error) { failure = error; }
|
||||
}
|
||||
}));
|
||||
if (failure) throw failure;
|
||||
if (!groups && !all && !(await status(manifest, cache, { previous, activeLayout, offline: false })).ready) throw new Error('Incomplete blocking assets');
|
||||
return { refreshed: count, caches: 1 };
|
||||
}
|
||||
|
||||
|
||||
// Verified cache entries are the durable progress journal. Counters are rebuilt
|
||||
// after worker termination or browser eviction; never trust a stored ready bit.
|
||||
async function completeness(manifest, cache, { optIns } = {}) {
|
||||
const files = offlinePaths(manifest, optIns || await readOptIns(cache));
|
||||
const missingFiles = (await Promise.all(files.map(async path => {
|
||||
const response = await cache.match(url(path, manifest.files[path]));
|
||||
return verifiedProbe(response, manifest.files[path].h) ? null : path;
|
||||
}))).filter(Boolean);
|
||||
const total = files.length;
|
||||
return { offlineReady: missingFiles.length === 0, cached: total - missingFiles.length, total, missingFiles };
|
||||
}
|
||||
|
||||
const completionKey = manifest => '/__ytp_completion/' + manifest.buildTag;
|
||||
async function complete(manifest, { cache, fetchFn, cycles = 4, now = Date.now, optIns,
|
||||
sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), baseDelay = 1000,
|
||||
maxDelay = 30000, notify = () => {}, force = false, concurrency = 1 } = {}) {
|
||||
const key = completionKey(manifest), saved = await cache.match(key);
|
||||
let job = saved ? await saved.json() : { failures: 0, nextRetryAt: 0 };
|
||||
const selectedOptIns = optIns || await readOptIns(cache);
|
||||
let snapshot = await completeness(manifest, cache, { optIns: selectedOptIns });
|
||||
const publish = async running => {
|
||||
job = { ...job, ...snapshot, running, version: manifest.buildTag };
|
||||
await cache.put(key, Response.json(job));
|
||||
await notify(job);
|
||||
};
|
||||
if (snapshot.offlineReady) { job.failures = 0; job.nextRetryAt = 0; await publish(false); return job; }
|
||||
if (!force && job.nextRetryAt > now()) await sleep(Math.min(maxDelay, job.nextRetryAt - now()));
|
||||
for (let cycle = 0; cycle < cycles; cycle++) {
|
||||
await publish(true);
|
||||
let error;
|
||||
try { await syncAssets(manifest, { cache, fetchFn, all: true, optIns: selectedOptIns, concurrency }); }
|
||||
catch (failure) { error = failure; }
|
||||
snapshot = await completeness(manifest, cache, { optIns: selectedOptIns });
|
||||
if (snapshot.offlineReady) { job.failures = 0; job.nextRetryAt = 0; job.error = null; await publish(false); return job; }
|
||||
job.failures++;
|
||||
const delay = Math.min(maxDelay, baseDelay * 2 ** Math.min(job.failures - 1, 10));
|
||||
job.nextRetryAt = now() + delay;
|
||||
job.error = String(error?.message || 'Incomplete offline cache');
|
||||
await publish(cycle + 1 < cycles);
|
||||
if (cycle + 1 < cycles) await sleep(delay);
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
async function commit(manifest, cache, { previousClients, activeLayout } = {}) {
|
||||
const old = await state(cache);
|
||||
if (!(await status(manifest, cache, { previous: old?.current, activeLayout, offline: false })).ready) {
|
||||
throw new Error('Incomplete blocking assets');
|
||||
}
|
||||
const sameBuild = old?.current?.buildTag === manifest.buildTag;
|
||||
const previous = sameBuild ? old.previous : old?.current;
|
||||
const next = {
|
||||
current: manifest, previous,
|
||||
previousClients: previousClients || (sameBuild ? old.previousClients : []),
|
||||
};
|
||||
// One state write publishes the complete build and its old-tab affinity.
|
||||
await cache.put(STATE, new Response(JSON.stringify(next), { headers: { 'Content-Type': 'application/json' } }));
|
||||
const keep = retained(manifest, previous);
|
||||
for (const request of await cache.keys()) {
|
||||
const parsed = new URL(request.url);
|
||||
const key = parsed.pathname + parsed.search;
|
||||
if (parsed.searchParams.has('v') && !keep.has(key)) await cache.delete(key);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
const api = { CACHE, STATE, OPT_INS, url, paths, offlinePaths, blocking, plan, retained, fallback, status, completeness, completionKey, complete, syncAssets, readOptIns, state, commit };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else root.AssetSyncCore = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||
125
frontend/asset-sync-core.test.js
Normal file
125
frontend/asset-sync-core.test.js
Normal file
@@ -0,0 +1,125 @@
|
||||
const {test}=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const core=require('./asset-sync-core');
|
||||
const manifest=(a='a',b='b')=>({buildTag:a+b,files:{'/a.js':{h:a},'/index.html':{h:b}},groups:{core:{files:['/a.js','/index.html']}}});
|
||||
function cache(){const entries=new Map();return {entries,async match(k){return entries.get(k)?.clone()},async put(k,r){entries.set(k,r.clone())},async keys(){return [...entries.keys()].map(url=>({url:'http://local'+url}))},async delete(k){return entries.delete(k)}}}
|
||||
const response=h=>new Response(h,{headers:{'X-Asset-Hash':h}});
|
||||
test('exact diff shares unchanged URLs; retains N-1 and prunes N-2',()=>{assert.deepEqual(core.plan(manifest(),['/a.js?v=a']).missing,['/index.html?v=b']);assert.deepEqual([...core.retained(manifest('c'),manifest())].sort(),['/a.js?v=a','/a.js?v=c','/index.html?v=b']);});
|
||||
test('interrupted sync resumes verified files and refuses a mismatched hash',async()=>{const c=cache();let fail=true;let calls=[];const fetchFn=async u=>{calls.push(u);if(u.includes('index')&&fail)return response('wrong');return response(u.split('=')[1])};await assert.rejects(core.syncAssets(manifest(),{cache:c,fetchFn}),/hash/);assert.ok(await c.match('/a.js?v=a'));fail=false;calls=[];await core.syncAssets(manifest(),{cache:c,fetchFn});assert.deepEqual(calls,['/index.html?v=b']);await c.delete('/a.js?v=a');assert.equal((await core.status(manifest(),c)).missing,1);});
|
||||
test('commit is atomic and pruning keeps only current and previous changed versions',async()=>{const c=cache();await c.put('/a.js?v=z',response('z'));await core.syncAssets(manifest(),{cache:c,fetchFn:async u=>response(u.split('=')[1])});await core.commit(manifest(),c);await core.syncAssets(manifest('c'),{cache:c,fetchFn:async u=>response(u.split('=')[1])});await core.commit(manifest('c'),c);assert.ok(await c.match('/a.js?v=a'));assert.equal(await c.match('/a.js?v=z'),undefined);await core.syncAssets(manifest('d'),{cache:c,fetchFn:async u=>response(u.split('=')[1])});await core.commit(manifest('d'),c);assert.equal(await c.match('/a.js?v=a'),undefined);});
|
||||
test('downloads are capped at six and each failure gets three attempts',async()=>{const c=cache(),files={},list=[];for(let i=0;i<13;i++){const p='/'+i+'.js';files[p]={h:String(i)};list.push(p)}const m={buildTag:'pool',files,groups:{core:{files:list}}};let active=0,max=0;const tries={};await core.syncAssets(m,{cache:c,concurrency:20,fetchFn:async u=>{active++;max=Math.max(max,active);await new Promise(r=>setTimeout(r,5));active--;tries[u]=(tries[u]||0)+1;if(tries[u]<3)throw Error('drop');return response(u.split('=')[1])}});assert.equal(max,6);assert.ok(Object.values(tries).every(n=>n===3));});
|
||||
test('background files do not block, but active layout and contract changes do',()=>{const m=manifest();m.groups.extra={background:true,contract:2,files:['/extra.js']};m.files['/extra.js']={h:'e'};assert.deepEqual(core.blocking(m),['/a.js','/index.html']);assert.ok(core.blocking(m,{groups:{extra:{contract:1}}}).includes('/extra.js'));m.groups['layout:classic']={background:true,files:['/classic.css']};m.files['/classic.css']={h:'c'};assert.ok(core.blocking(m,null,'classic').includes('/classic.css'));});
|
||||
test('removed files survive one previous build for open tabs',()=>{const previous=manifest(),current=manifest('c');delete current.files['/a.js'];current.groups.core.files=['/index.html'];assert.ok(core.retained(current,previous).has('/a.js?v=a'));});
|
||||
|
||||
test('staged fallback permits only the previous same-contract version of a shipped group file', () => {
|
||||
const old = manifest(), next = manifest('c');
|
||||
old.groups.core.contract = next.groups.core.contract = 1;
|
||||
assert.equal(core.fallback(next, old, '/a.js'), '/a.js?v=a');
|
||||
next.groups.core.contract = 2;
|
||||
assert.equal(core.fallback(next, old, '/a.js'), null);
|
||||
assert.equal(core.fallback(next, old, '/missing.js'), null);
|
||||
});
|
||||
|
||||
test('background synchronization fills all groups without publishing a new current manifest', async () => {
|
||||
const c = cache(), m = manifest();
|
||||
m.groups.optional = { contract: 1, background: true, files: ['/extra.js'] };
|
||||
m.files['/extra.js'] = { h: 'e' };
|
||||
const fetchFn = async key => response(key.split('=')[1]);
|
||||
await core.syncAssets(m, { cache: c, fetchFn });
|
||||
assert.equal(await c.match('/extra.js?v=e'), undefined);
|
||||
await core.commit(m, c);
|
||||
const before = await core.state(c);
|
||||
const result = await core.syncAssets(m, { cache: c, fetchFn, groups: ['optional'] });
|
||||
assert.equal(result.refreshed, 1);
|
||||
assert.ok(await c.match('/extra.js?v=e'));
|
||||
assert.deepEqual(await core.state(c), before);
|
||||
});
|
||||
|
||||
test('active layouts also block on shared dependencies, while other layouts stay background', () => {
|
||||
const m=manifest(); m.groups['layout:glass-stage']={contract:1,background:true,files:['/glass.css'],dependencies:['feature:controls']}; m.groups['feature:controls']={contract:1,background:true,files:['/controls.js']}; m.groups['layout:bento-hub']={contract:1,background:true,files:['/bento.css']};
|
||||
for(const path of ['/glass.css','/controls.js','/bento.css'])m.files[path]={h:'h'};
|
||||
assert.deepEqual(core.blocking(m,null,'glass-stage'),['/a.js','/controls.js','/glass.css','/index.html']);
|
||||
});
|
||||
|
||||
test('removing group membership retains the old tab URL even if the physical file is still shipped unchanged',()=>{
|
||||
const previous=manifest(),current=manifest();current.groups.core.files=['/index.html'];
|
||||
assert.ok(core.retained(current,previous).has('/a.js?v=a'));
|
||||
});
|
||||
|
||||
test('completion includes ungrouped runtime files and exposes verified missing paths', async () => {
|
||||
const c=cache(), m=manifest();m.files['/worker.js']={h:'w'};
|
||||
await core.syncAssets(m,{cache:c,fetchFn:async u=>response(u.split('=')[1])});
|
||||
const status=await core.status(m,c);
|
||||
assert.equal(status.ready,true);assert.equal(status.offlineReady,false);
|
||||
assert.deepEqual(status.missingFiles,['/worker.js']);assert.equal(status.cached,2);assert.equal(status.total,3);
|
||||
await core.complete(m,{cache:c,fetchFn:async u=>response(u.split('=')[1])});
|
||||
assert.equal((await core.status(m,c)).offlineReady,true);
|
||||
});
|
||||
|
||||
test('offline completion skips opt-in DSP assets until the user enables offline audio effects', async () => {
|
||||
const c=cache(),m=manifest();m.files['/vendor/jamesdsp/dsp/jamesdsp.wasm']={h:'wasm'};
|
||||
m.groups.jamesdsp={contract:1,eager:false,background:true,offlineOptIn:'jamesDsp',files:['/vendor/jamesdsp/dsp/jamesdsp.wasm']};
|
||||
const calls=[];
|
||||
const defaultJob=await core.complete(m,{cache:c,fetchFn:async u=>{calls.push(u);return response(u.split('=')[1])}});
|
||||
assert.deepEqual(calls.sort(),['/a.js?v=a','/index.html?v=b']);
|
||||
assert.equal(defaultJob.total,2);assert.equal(defaultJob.offlineReady,true);
|
||||
calls.length=0;
|
||||
await c.put(core.OPT_INS,Response.json(['jamesDsp']));
|
||||
const optedJob=await core.complete(m,{cache:c,fetchFn:async u=>{calls.push(u);return response(u.split('=')[1])}});
|
||||
assert.deepEqual(calls,['/vendor/jamesdsp/dsp/jamesdsp.wasm?v=wasm']);
|
||||
assert.equal(optedJob.total,3);assert.equal(optedJob.offlineReady,true);
|
||||
});
|
||||
|
||||
test('completion persists backoff and resumes only missing files after a worker restart',async()=>{
|
||||
const c=cache(),m=manifest();let clock=1000;const calls=[];
|
||||
await core.complete(m,{cache:c,cycles:1,now:()=>clock,baseDelay:10,fetchFn:async u=>{if(u.includes('index'))throw Error('offline');return response('a');}});
|
||||
const checkpoint=await(await c.match(core.completionKey(m))).json();
|
||||
assert.equal(checkpoint.running,false);assert.equal(checkpoint.failures,1);assert.equal(checkpoint.nextRetryAt,1010);
|
||||
await core.complete(m,{cache:c,now:()=>clock,sleep:async ms=>{assert.equal(ms,10);clock+=ms;},fetchFn:async u=>{calls.push(u);return response(u.split('=')[1]);}});
|
||||
assert.deepEqual(calls,['/index.html?v=b']);
|
||||
const status=await(await c.match(core.completionKey(m))).json();assert.equal(status.offlineReady,true);assert.equal(status.failures,0);
|
||||
await c.delete('/a.js?v=a');assert.equal((await core.status(m,c)).offlineReady,false);
|
||||
});
|
||||
|
||||
test('completion retries with exponential backoff and keeps making progress past a failed asset',async()=>{
|
||||
const c=cache(),m=manifest(),delays=[];let tries=0;
|
||||
const job=await core.complete(m,{cache:c,baseDelay:5,sleep:async ms=>delays.push(ms),fetchFn:async u=>{if(u.includes('a.js')&&++tries<=6)throw Error('drop');return response(u.split('=')[1]);}});
|
||||
assert.deepEqual(delays,[5,10]);assert.equal(job.offlineReady,true);assert.ok(await c.match('/index.html?v=b'));
|
||||
});
|
||||
|
||||
test('overlapping layout and completion jobs share a six-download ceiling',async()=>{
|
||||
const c=cache(),m=manifest();m.groups.extra={background:true,files:[]};
|
||||
for(let i=0;i<15;i++){const p='/extra'+i+'.js';m.files[p]={h:String(i)};m.groups.extra.files.push(p);}
|
||||
let active=0,peak=0;const calls=new Map();
|
||||
const fetchFn=async u=>{active++;peak=Math.max(active,peak);calls.set(u,(calls.get(u)||0)+1);await new Promise(r=>setTimeout(r,3));active--;return response(u.split('=')[1]);};
|
||||
await Promise.all([core.complete(m,{cache:c,fetchFn}),core.syncAssets(m,{cache:c,fetchFn,groups:['extra']})]);
|
||||
assert.ok(peak<=6);assert.ok([...calls.values()].every(count=>count===1));
|
||||
});
|
||||
|
||||
test('offline completion keeps its own network concurrency to one',async()=>{
|
||||
const c=cache(),m=manifest();let active=0,peak=0;
|
||||
for(let i=0;i<12;i++){const p='/complete'+i+'.js';m.files[p]={h:String(i)};}
|
||||
const fetchFn=async u=>{active++;peak=Math.max(peak,active);await new Promise(r=>setTimeout(r,2));active--;return response(u.split('=')[1]);};
|
||||
await core.complete(m,{cache:c,fetchFn});assert.equal(peak,1);
|
||||
});
|
||||
|
||||
test('blocking commit never waits on an optional file readiness probe',{timeout:1000},async()=>{
|
||||
const c=cache(),m=manifest();m.files['/extra.js']={h:'extra'};m.groups.extra={files:['/extra.js'],background:true,contract:1};
|
||||
await c.put('/a.js?v=a',response('a'));await c.put('/index.html?v=b',response('b'));
|
||||
const match=c.match;let extraProbes=0;c.match=async key=>{if(key==='/extra.js?v=extra'){extraProbes++;return new Promise(()=>{});}return match(key);};
|
||||
await core.commit(m,c);assert.equal(extraProbes,0);assert.equal((await core.state(c)).current.buildTag,m.buildTag);
|
||||
c.match=match;assert.equal((await core.status(m,c)).offlineReady,false);
|
||||
});
|
||||
|
||||
test('metadata probes release response streams while stored asset bodies remain readable',async()=>{
|
||||
const c=cache(),m=manifest();await c.put('/a.js?v=a',response('a'));await c.put('/index.html?v=b',response('b'));
|
||||
const match=c.match,probes=[];c.match=async key=>{const r=await match(key);if(r)probes.push(r);return r;};
|
||||
assert.equal((await core.status(m,c)).offlineReady,true);assert.ok(probes.length>0);assert.ok(probes.every(r=>r.bodyUsed));
|
||||
assert.equal(await(await match('/a.js?v=a')).text(),'a');assert.equal(await(await match('/index.html?v=b')).text(),'b');
|
||||
});
|
||||
|
||||
test('stale app core requires every feature contract to match the new shell',()=>{
|
||||
const old={files:{'/app.js':{h:'old'}},groups:{core:{contract:1,files:['/app.js']},extra:{contract:1,files:[]}}};
|
||||
const next=structuredClone(old);next.files['/app.js'].h='new';next.groups.extra.contract=2;
|
||||
assert.equal(core.fallback(next,old,'/app.js'),null);next.groups.extra.contract=1;assert.equal(core.fallback(next,old,'/app.js'),'/app.js?v=old');
|
||||
});
|
||||
102
frontend/asset-worker.test.js
Normal file
102
frontend/asset-worker.test.js
Normal file
@@ -0,0 +1,102 @@
|
||||
const {test}=require('node:test');const assert=require('node:assert/strict');const vm=require('node:vm');const fs=require('node:fs');const core=require('./asset-sync-core');
|
||||
function environment(){
|
||||
const stores=new Map(),listeners={},messages=[],skips=[],fetches=[],selfMessages=[];const m={buildTag:'next',files:{'/index.html':{h:'index'},'/app.js':{h:'app'}},groups:{core:{files:['/index.html','/app.js']}}};
|
||||
const storage={async keys(){return [...stores.keys()]},async delete(n){return stores.delete(n)},async open(n){if(!stores.has(n)){const map=new Map();stores.set(n,{async match(k){return map.get(typeof k==='string'?k:new URL(k.url).pathname+new URL(k.url).search)?.clone()},async put(k,r){map.set(k,r.clone())},async keys(){return [...map.keys()].map(k=>({url:'https://local'+k}))},async delete(k){return map.delete(typeof k==='string'?k:new URL(k.url).pathname+new URL(k.url).search)}})}return stores.get(n)}};
|
||||
const sandbox={__BUILD_TAG__:'next',__ASSET_SYNC__:true,AssetSyncCore:core,importScripts:()=>{},caches:storage,URL,Response,Request,Headers,setTimeout,clearTimeout,crypto:require('node:crypto').webcrypto,console,fetch:async u=>{fetches.push(u);return u==='/api/manifest'?Response.json(m):new Response(u,{headers:{'X-Asset-Hash':m.files[u.split('?')[0]]?.h || ''}})},self:{registration:{active:{postMessage:data=>selfMessages.push(data)}},location:{origin:'https://local'},addEventListener:(t,f)=>listeners[t]=f,skipWaiting:()=>skips.push(1),clients:{claim:async()=>{},matchAll:async()=>[{id:'old',postMessage:x=>messages.push(x)}]}}};
|
||||
vm.runInNewContext(fs.readFileSync(__dirname+'/sw.js','utf8'),sandbox);
|
||||
async function dispatch(t,e={}){const tasks=[];listeners[t]({...e,waitUntil:v=>tasks.push(v)});await Promise.all(tasks)}
|
||||
async function request(url,mode='cors',clientId='new'){let p;const tasks=[];listeners.fetch({request:{url:'https://local'+url,method:'GET',mode},clientId,waitUntil:v=>tasks.push(v),respondWith:v=>p=v});const response=await p;await Promise.all(tasks);return response;}
|
||||
return {storage,m,skips,fetches,dispatch,request,sandbox,selfMessages,async complete(){for(const data of selfMessages.splice(0))await dispatch('message',{data});}};
|
||||
}
|
||||
test('install does not activate or publish; CACHE_STATUS is honest; activation commits and exact requests self-heal',async()=>{const e=environment();await e.dispatch('install');assert.equal(e.skips.length,0);const cache=await e.storage.open(core.CACHE);assert.equal(await core.state(cache),null);let reply;await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal(reply.ready,true);await e.dispatch('activate');await e.complete();assert.equal((await core.state(cache)).current.buildTag,'next');assert.equal((await e.request('/playlist/x','navigate')).headers.get('X-Asset-Hash'),'index');await cache.delete('/app.js?v=app');assert.equal((await core.status(e.m,cache)).ready,false);await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal((await core.status(e.m,cache)).offlineReady,true);assert.equal(reply.version,'next');assert.equal((await e.request('/app.js')).headers.get('X-Asset-Hash'),'app');assert.ok(await cache.match('/app.js?v=app'));assert.equal(await cache.match('/app.js?v=stale'),undefined);assert.equal((await e.request('/app.js?v=stale')).status,409);});
|
||||
test('reported playback defers explicit activation until pause; install never calls skipWaiting',async()=>{const e=environment(),source={id:'p'};await e.dispatch('message',{data:{type:'SKIP_WAITING'}});assert.equal(e.skips.length,0);await e.dispatch('message',{source,data:{type:'PLAYING',value:true}});await e.dispatch('message',{source,data:{type:'SKIP_WAITING'}});assert.equal(e.skips.length,0);await e.dispatch('message',{source,data:{type:'PLAYING',value:false}});assert.equal(e.skips.length,1);});
|
||||
|
||||
test('staged same-contract responses use verified N-1 without poisoning the new URL',async()=>{
|
||||
const e=environment(); const cache=await e.storage.open(core.CACHE);
|
||||
const old={buildTag:'old',files:{'/extra.js':{h:'old'}},groups:{core:{files:[],contract:1},'feature:extra':{files:['/extra.js'],contract:1,background:true}}};
|
||||
e.m.groups.core.contract=1; e.m.groups['feature:extra']={files:['/extra.js'],contract:1,background:true}; e.m.files['/extra.js']={h:'new'};
|
||||
await cache.put(core.STATE,Response.json({current:old})); await cache.put('/extra.js?v=old',new Response('old body',{headers:{'X-Asset-Hash':'old','Cache-Control':'public, max-age=31536000, immutable','Content-Encoding':'gzip','Content-Length':'9'}}));
|
||||
await e.dispatch('install'); await e.dispatch('activate');await e.complete();
|
||||
assert.ok(await cache.match('/extra.js?v=new'));await cache.delete('/extra.js?v=new');
|
||||
assert.equal(await cache.match('/extra.js?v=new'),undefined);
|
||||
const stale=await e.request('/extra.js?v=new'); assert.equal(await stale.text(),'old body');assert.equal(stale.headers.get('X-Asset-Hash'),'old');assert.equal(stale.headers.get('Cache-Control'),'no-store');assert.equal(stale.headers.get('Content-Encoding'),null);assert.equal(stale.headers.get('Content-Length'),null);assert.equal(await cache.match('/extra.js?v=new'),undefined);
|
||||
await e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:true}}); assert.ok(await cache.match('/extra.js?v=new'));
|
||||
await e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}}); assert.equal((await cache.match('/extra.js?v=new')).headers.get('X-Asset-Hash'),'new');
|
||||
assert.equal((await e.request('/extra.js?v=new')).headers.get('X-Asset-Hash'),'new');
|
||||
});
|
||||
|
||||
test('a changed background contract is downloaded before ready and cannot fall back after commit',async()=>{
|
||||
const e=environment(),cache=await e.storage.open(core.CACHE);
|
||||
const old={buildTag:'old',files:{'/extra.js':{h:'old'}},groups:{core:{files:[],contract:1},'feature:extra':{files:['/extra.js'],contract:1,background:true}}};
|
||||
e.m.groups.core.contract=1;e.m.groups['feature:extra']={files:['/extra.js'],contract:2,background:true};e.m.files['/extra.js']={h:'new'};
|
||||
await cache.put(core.STATE,Response.json({current:old}));await cache.put('/extra.js?v=old',new Response('old body',{headers:{'X-Asset-Hash':'old'}}));
|
||||
await e.dispatch('install');assert.equal((await core.state(cache)).current.buildTag,'old');
|
||||
assert.equal((await cache.match('/extra.js?v=new')).headers.get('X-Asset-Hash'),'new');
|
||||
let reply;await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal(reply.ready,true);
|
||||
await e.dispatch('activate');await e.complete();await cache.delete('/extra.js?v=new');
|
||||
const response=await e.request('/extra.js?v=new');assert.equal(response.headers.get('X-Asset-Hash'),'new');assert.notEqual(await response.text(),'old body');
|
||||
});
|
||||
|
||||
test('overlapping idle warm messages share one download pool',async()=>{
|
||||
const e=environment();e.m.files['/extra.js']={h:'extra'};e.m.groups['feature:extra']={files:['/extra.js'],contract:1,background:true};
|
||||
await e.dispatch('install');await e.dispatch('activate');await e.complete();
|
||||
await Promise.all([e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}}),e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}})]);
|
||||
assert.equal(e.fetches.filter(url=>url==='/extra.js?v=extra').length,1);
|
||||
});
|
||||
|
||||
test('waiting status does not start completion and activation delegates it to its own event',async()=>{
|
||||
const e=environment();e.m.files['/extra.js']={h:'extra'};e.m.groups['feature:extra']={files:['/extra.js'],contract:1,background:true};
|
||||
await e.dispatch('install');let reply;await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});
|
||||
assert.equal(reply.ready,true);assert.equal(reply.offlineReady,false);assert.ok(!e.fetches.includes('/extra.js?v=extra'));
|
||||
let release,started;const gate=new Promise(r=>release=r),begin=new Promise(r=>started=r),fetch=e.sandbox.fetch;
|
||||
e.sandbox.fetch=async url=>{if(url==='/extra.js?v=extra'){started();await gate;}return fetch(url);};
|
||||
await e.dispatch('activate');const cache=await e.storage.open(core.CACHE);
|
||||
assert.equal((await core.state(cache)).current.buildTag,'next');assert.equal((await core.status(e.m,cache)).ready,true);
|
||||
assert.equal((await core.status(e.m,cache)).offlineReady,false);assert.equal(e.selfMessages[0].type,'COMPLETE_ASSETS');
|
||||
const job=e.complete();await begin;assert.equal(await cache.match('/extra.js?v=extra'),undefined);release();await job;
|
||||
assert.equal((await core.status(e.m,cache)).offlineReady,true);
|
||||
});
|
||||
|
||||
test('legacy shell verification is staged during install so Refresh UI activation does no hashing',async()=>{
|
||||
const e=environment(),legacy=await e.storage.open('ytplayer-production-old');let digests=0;
|
||||
await legacy.put('/app.js',new Response('legacy app bytes'));
|
||||
const subtle=e.sandbox.crypto.subtle;
|
||||
e.sandbox.crypto={subtle:{digest:async(...args)=>{digests++;return subtle.digest(...args);}}};
|
||||
await e.dispatch('install');
|
||||
const stagedDigests=digests,cache=await e.storage.open(core.CACHE),candidate=await(await cache.match('/__ytp_candidate/next')).json();
|
||||
assert.ok(stagedDigests>0,'install verified old cached bytes before offering Refresh UI');
|
||||
assert.deepEqual(candidate.legacyCacheNames,['ytplayer-production-old']);
|
||||
assert.ok((await core.state(cache)).current.legacyURLs['/app.js']);
|
||||
await e.dispatch('activate');
|
||||
assert.equal(digests,stagedDigests,'the user-triggered activation does not hash the old shell again');
|
||||
assert.equal((await e.storage.keys()).includes('ytplayer-production-old'),false,'old shell is deleted only after commit');
|
||||
});
|
||||
|
||||
test('offline opt-in is persisted and adds only declared optional groups to completion',async()=>{
|
||||
const e=environment();e.m.files['/large.wasm']={h:'wasm'};e.m.groups.dsp={files:['/large.wasm'],contract:1,background:true,offlineOptIn:'jamesDsp'};
|
||||
await e.dispatch('install');await e.dispatch('activate');await e.complete();
|
||||
const cache=await e.storage.open(core.CACHE);assert.equal(await cache.match('/large.wasm?v=wasm'),undefined);
|
||||
await e.dispatch('message',{data:{type:'OFFLINE_OPT_INS',values:['jamesDsp','not-a-valid-opt-in']}});
|
||||
assert.deepEqual(await core.readOptIns(cache),['jamesDsp']);
|
||||
assert.equal((await cache.match('/large.wasm?v=wasm')).headers.get('X-Asset-Hash'),'wasm');
|
||||
const status=await core.status(e.m,cache);assert.equal(status.offlineReady,true);assert.equal(status.total,3);
|
||||
});
|
||||
|
||||
test('page-captured app is reused on first install without a worker download',async()=>{
|
||||
const e=environment(),cache=await e.storage.open(core.CACHE);
|
||||
await cache.put('/app.js?v=app',new Response('page captured app',{headers:{'X-Asset-Hash':'app'}}));
|
||||
await e.dispatch('install');assert.equal(e.fetches.includes('/app.js?v=app'),false);
|
||||
assert.equal(await(await cache.match('/app.js?v=app')).text(),'page captured app');
|
||||
assert.equal(e.skips.length,0);assert.equal(await core.state(cache),null);
|
||||
});
|
||||
|
||||
|
||||
test('worker never supplies stale app to a native script when a feature contract changed',async()=>{
|
||||
const e=environment(),cache=await e.storage.open(core.CACHE);
|
||||
const old={buildTag:'old',files:{'/app.js':{h:'old'}},groups:{core:{contract:1,files:['/app.js']},extra:{contract:1,files:[]}}};
|
||||
e.m.groups.core.contract=1;e.m.groups.extra={contract:2,background:true,files:[]};
|
||||
await cache.put(core.STATE,Response.json({current:old}));await cache.put('/app.js?v=old',new Response('old app',{headers:{'X-Asset-Hash':'old'}}));
|
||||
await e.dispatch('install');await e.dispatch('activate');await cache.delete('/app.js?v=app');
|
||||
e.sandbox.fetch=async()=>{throw Error('offline')};
|
||||
const response=await e.request('/app.js?v=app');assert.equal(response.status,503);assert.equal(await response.text(),'Offline');
|
||||
});
|
||||
386
frontend/assets.json
Normal file
386
frontend/assets.json
Normal file
@@ -0,0 +1,386 @@
|
||||
{
|
||||
"groups": {
|
||||
"core": {
|
||||
"contract": 2,
|
||||
"eager": true,
|
||||
"files": [
|
||||
"/index.html",
|
||||
"/styles.css",
|
||||
"/service-ui.css",
|
||||
"/refresh.css",
|
||||
"/layout-base.css",
|
||||
"/recommendations.css",
|
||||
"/flag-ui.js",
|
||||
"/async-guard.js",
|
||||
"/visible-timer.js",
|
||||
"/asset-sync-core.js",
|
||||
"/sw-update.js",
|
||||
"/fingerprint.js",
|
||||
"/opfs.js",
|
||||
"/opfs-worker.js",
|
||||
"/lyrics-core.js",
|
||||
"/lyrics-display.js",
|
||||
"/sha256.js",
|
||||
"/resume-core.js",
|
||||
"/loudness-wasm.js",
|
||||
"/loudness.js",
|
||||
"/sha256-wasm.js",
|
||||
"/settings-sections.js",
|
||||
"/related-videos.js",
|
||||
"/related-videos.css",
|
||||
"/audio-continuity.js",
|
||||
"/audio-graph.js",
|
||||
"/fullscreen-orientation.js",
|
||||
"/fullscreen-orientation.css",
|
||||
"/download-actions.js",
|
||||
"/download-actions.css",
|
||||
"/download-errors.js",
|
||||
"/downloads.js",
|
||||
"/downloads-page.js",
|
||||
"/offline-pages.css",
|
||||
"/saved-page.js",
|
||||
"/server-backfill.js",
|
||||
"/saved-page.css",
|
||||
"/prewarm-next.js",
|
||||
"/settings-sections.css",
|
||||
"/app.js",
|
||||
"/manifest.webmanifest",
|
||||
"/icons/icon-192.png",
|
||||
"/icons/icon-512.png",
|
||||
"/fonts/fonts.css",
|
||||
"/fonts/BricolageGrotesque-latin-ext.woff2",
|
||||
"/fonts/BricolageGrotesque-latin.woff2",
|
||||
"/fonts/HankenGrotesk-latin-ext.woff2",
|
||||
"/fonts/HankenGrotesk-latin.woff2",
|
||||
"/fonts/JetBrainsMono-latin-ext.woff2",
|
||||
"/fonts/JetBrainsMono-latin.woff2",
|
||||
"/lazy.js",
|
||||
"/feature-bootstrap.js",
|
||||
"/eq-parameters.js",
|
||||
"/assets.json",
|
||||
"/device-db.js",
|
||||
"/shell-core.js",
|
||||
"/views-core.js",
|
||||
"/section-rail.js"
|
||||
],
|
||||
"background": false
|
||||
},
|
||||
"layout:glass-stage": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/theme-glass.css",
|
||||
"/theme-glass-panel.css",
|
||||
"/glass-panel-layout.js"
|
||||
],
|
||||
"background": true,
|
||||
"dependencies": [
|
||||
"feature:layout-controls"
|
||||
]
|
||||
},
|
||||
"layout:classic": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/theme-classic.css"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"layout:bento-hub": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/theme-bento-hub.css",
|
||||
"/bento-hub.js",
|
||||
"/fonts/PlusJakartaSans-latin.woff2",
|
||||
"/fonts/PlusJakartaSans-latin-ext.woff2"
|
||||
],
|
||||
"background": true,
|
||||
"dependencies": [
|
||||
"feature:layout-controls"
|
||||
]
|
||||
},
|
||||
"layout:f7-swipe": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/theme-f7.css",
|
||||
"/vendor/framework7-swipe.min.js",
|
||||
"/f7-layout.js"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:piano": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/piano-core.js",
|
||||
"/piano.js",
|
||||
"/piano-engine.mjs"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:midi": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/midi.js"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:party-dj": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/party-dj.js"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:lyrics-window": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/lyrics-window.js"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:lower-third": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/lower-third.js",
|
||||
"/lower-third.css"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:vocal-reducer": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/vocal-reducer.js"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:eq-core": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/eq-core.js"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:setlist-import": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/setlist-import.js"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:car-mode": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/car-mode.js",
|
||||
"/car-mode.css"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:p2p": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/p2p-core.js",
|
||||
"/p2p-client.js",
|
||||
"/p2p-transfer.js",
|
||||
"/p2p-recv-worker.js",
|
||||
"/hash-worker.js"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:direct": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/direct-protocol.js",
|
||||
"/direct-stream.js",
|
||||
"/direct-media.js",
|
||||
"/direct-media.css",
|
||||
"/direct-recv-worker.js"
|
||||
],
|
||||
"background": true,
|
||||
"dependencies": [
|
||||
"feature:p2p"
|
||||
]
|
||||
},
|
||||
"feature:video-edit": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/video-edit.js"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:stats": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/stats-core.js"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:export": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/export.js"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:playlist": {
|
||||
"contract": 1,
|
||||
"eager": true,
|
||||
"files": [],
|
||||
"embeddedIn": [
|
||||
"/app.js"
|
||||
],
|
||||
"background": false
|
||||
},
|
||||
"feature:settings": {
|
||||
"contract": 1,
|
||||
"eager": true,
|
||||
"files": [],
|
||||
"embeddedIn": [
|
||||
"/app.js",
|
||||
"/settings-sections.js",
|
||||
"/settings-sections.css",
|
||||
"/views-core.js"
|
||||
],
|
||||
"background": false
|
||||
},
|
||||
"feature:service-mode": {
|
||||
"contract": 1,
|
||||
"eager": true,
|
||||
"files": [],
|
||||
"embeddedIn": [
|
||||
"/app.js",
|
||||
"/service-ui.css"
|
||||
],
|
||||
"background": false
|
||||
},
|
||||
"feature:lyrics-editor": {
|
||||
"contract": 1,
|
||||
"eager": true,
|
||||
"files": [],
|
||||
"embeddedIn": [
|
||||
"/app.js",
|
||||
"/lyrics-core.js",
|
||||
"/lyrics-display.js"
|
||||
],
|
||||
"background": false
|
||||
},
|
||||
"feature:layout-controls": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": [
|
||||
"/theme-glass-controls.css",
|
||||
"/glass-controls.js"
|
||||
]
|
||||
},
|
||||
"layout:sanctuary-stage": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": []
|
||||
},
|
||||
"layout:liturgical": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": []
|
||||
},
|
||||
"layout:living-hymnal": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": []
|
||||
},
|
||||
"layout:broadcaster": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": []
|
||||
},
|
||||
"layout:contemplative": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": []
|
||||
},
|
||||
"feature:presenter": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": [
|
||||
"/presenter.js"
|
||||
]
|
||||
},
|
||||
"feature:remote-client": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": [
|
||||
"/remote-client.js"
|
||||
]
|
||||
},
|
||||
"feature:watch-party": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": [
|
||||
"/watch-party-client.js"
|
||||
]
|
||||
},
|
||||
"feature:share-external": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": [
|
||||
"/share-external.js"
|
||||
]
|
||||
},
|
||||
"feature:stats-view": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": [
|
||||
"/stats-view.js"
|
||||
],
|
||||
"dependencies": [
|
||||
"feature:stats"
|
||||
]
|
||||
},
|
||||
"feature:jamesdsp": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"offlineOptIn": "jamesDsp",
|
||||
"files": [
|
||||
"/vendor/jamesdsp/jamesdsp-feature.js",
|
||||
"/vendor/jamesdsp/jamesdsp-node.mjs",
|
||||
"/vendor/jamesdsp/params.mjs",
|
||||
"/vendor/jamesdsp/ir-utils.mjs",
|
||||
"/vendor/jamesdsp/ir-builtins.mjs",
|
||||
"/vendor/jamesdsp/ir-store.mjs",
|
||||
"/vendor/jamesdsp/worklet.mjs",
|
||||
"/vendor/jamesdsp/dsp/jamesdsp-a982b53.mjs",
|
||||
"/vendor/jamesdsp/dsp/jamesdsp.wasm"
|
||||
],
|
||||
"dependencies": [
|
||||
"feature:eq-core",
|
||||
"feature:vocal-reducer"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,68 @@
|
||||
}
|
||||
}
|
||||
|
||||
const AsyncGuard = { runExclusive };
|
||||
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
async function retry(task, { attempts = 3, delay = attempt => attempt === 1 ? 350 : 1000,
|
||||
shouldRetry = () => true, sleep = wait } = {}) {
|
||||
let last;
|
||||
for (let attempt = 1; attempt <= Math.max(1, attempts); attempt++) {
|
||||
try { return await task(attempt); }
|
||||
catch (error) {
|
||||
last = error;
|
||||
if (attempt >= attempts || !shouldRetry(error, attempt)) throw error;
|
||||
await sleep(delay(attempt));
|
||||
}
|
||||
}
|
||||
throw last;
|
||||
}
|
||||
|
||||
// Retry only idempotent GETs. Keep the timeout active through JSON parsing so
|
||||
// a stalled response body is treated like a stalled connection.
|
||||
async function getJson(url, options = {}, config = {}) {
|
||||
const fetchFn = config.fetch || root.fetch?.bind(root);
|
||||
if (!fetchFn) throw new Error('fetch is unavailable');
|
||||
if ((options.method || 'GET').toUpperCase() !== 'GET') throw new Error('Retry helper only accepts GET');
|
||||
const attempts = config.attempts ?? 3, timeoutMs = config.timeoutMs ?? 20000;
|
||||
return retry(async () => {
|
||||
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
||||
let timedOut = false, timer, externalAbort;
|
||||
const external = options.signal;
|
||||
if (controller && external) {
|
||||
if (external.aborted) throw external.reason || new DOMException('Aborted', 'AbortError');
|
||||
externalAbort = () => controller.abort(external.reason);
|
||||
external.addEventListener('abort', externalAbort, { once: true });
|
||||
}
|
||||
if (controller && timeoutMs > 0) timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
|
||||
try {
|
||||
const response = await fetchFn(url, { ...options, ...(controller ? { signal: controller.signal } : {}) });
|
||||
if ([408, 425, 429].includes(response.status) || response.status >= 500) {
|
||||
const error = new Error(`HTTP ${response.status}`); error.retryable = true; throw error;
|
||||
}
|
||||
if (!response.ok) {
|
||||
let message = `HTTP ${response.status}`;
|
||||
try { message = (await response.json()).error || message; } catch {}
|
||||
const error = new Error(message); error.retryable = false; throw error;
|
||||
}
|
||||
const value = await response.json();
|
||||
if (config.retryResult?.(value)) {
|
||||
const error = new Error(value.error || value.message || 'Temporary API failure');
|
||||
error.retryable = true;
|
||||
throw error;
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
if (external?.aborted) throw error;
|
||||
if (timedOut) { const timeout = new Error('Request timed out'); timeout.retryable = true; throw timeout; }
|
||||
if (error.retryable === undefined) error.retryable = true;
|
||||
throw error;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (external && externalAbort) external.removeEventListener('abort', externalAbort);
|
||||
}
|
||||
}, { attempts, shouldRetry: error => error.retryable !== false, sleep: config.sleep || wait });
|
||||
}
|
||||
|
||||
const AsyncGuard = { runExclusive, retry, getJson };
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = AsyncGuard;
|
||||
|
||||
@@ -60,6 +60,55 @@ test('frees the key after failure so a retry is possible', async () => {
|
||||
assert.strictEqual(out, 'ok');
|
||||
});
|
||||
|
||||
test('bounded GET retries recover transient network and server failures', async () => {
|
||||
const { getJson } = require('./async-guard');
|
||||
let calls = 0;
|
||||
const result = await getJson('/api/search', {}, { sleep: async () => {}, fetch: async () => {
|
||||
calls++;
|
||||
if (calls === 1) throw new TypeError('connection reset');
|
||||
if (calls === 2) return new Response('{}', { status: 503 });
|
||||
return Response.json({ ok: true, results: [] });
|
||||
} });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
|
||||
test('GET retry stops at the bound and does not retry permanent HTTP errors', async () => {
|
||||
const { getJson } = require('./async-guard');
|
||||
let calls = 0;
|
||||
await assert.rejects(getJson('/api/search', {}, { sleep: async () => {}, fetch: async () => {
|
||||
calls++; return new Response('{}', { status: 404 });
|
||||
} }), /HTTP 404/);
|
||||
assert.equal(calls, 1);
|
||||
await assert.rejects(getJson('/api/search', {}, { attempts: 2, sleep: async () => {}, fetch: async () => {
|
||||
calls++; throw new TypeError('offline');
|
||||
} }), /offline/);
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
|
||||
test('retryable API error payloads recover but permanent application errors do not retry', async () => {
|
||||
const { getJson } = require('./async-guard'); let calls = 0;
|
||||
const retryResult = value => value?.ok === false && /temporarily unavailable/i.test(value.error || '');
|
||||
const result = await getJson('/api/streams', {}, { retryResult, sleep: async () => {}, fetch: async () => {
|
||||
calls++;
|
||||
return Response.json(calls === 1 ? { ok: false, error: 'temporarily unavailable' } : { ok: true, data: [] });
|
||||
} });
|
||||
assert.equal(result.ok, true); assert.equal(calls, 2);
|
||||
calls = 0;
|
||||
const absent = await getJson('/api/streams', {}, { retryResult, sleep: async () => {}, fetch: async () => { calls++; return Response.json({ ok: false, error: 'video not found' }); } });
|
||||
assert.equal(absent.ok, false); assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('timed out GET bodies retry within the configured bound', async () => {
|
||||
const { getJson } = require('./async-guard'); let calls = 0;
|
||||
const result = await getJson('/api/related', {}, { attempts: 2, timeoutMs: 5, sleep: async () => {}, fetch: async (_url, { signal }) => {
|
||||
calls++;
|
||||
if (calls === 1) return new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')), { once: true }));
|
||||
return Response.json({ ok: true });
|
||||
} });
|
||||
assert.equal(result.ok, true); assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('supports a synchronous fn', async () => {
|
||||
const set = new Set();
|
||||
const out = await runExclusive(set, 's', () => 7);
|
||||
|
||||
31
frontend/audio-continuity.js
Normal file
31
frontend/audio-continuity.js
Normal file
@@ -0,0 +1,31 @@
|
||||
(function (root) {
|
||||
'use strict';
|
||||
function progressiveSource(current, videoSource) {
|
||||
return current?.eqAudioUrl || current?.localUrl || current?.audioUrl || videoSource || '';
|
||||
}
|
||||
function needsAlignment(time, clock, threshold = 0.3) {
|
||||
return Number.isFinite(time) && Number.isFinite(clock) && Math.abs(time - clock) >= threshold;
|
||||
}
|
||||
function create() {
|
||||
const pending = new WeakMap();
|
||||
function play(element) {
|
||||
if (pending.has(element)) return pending.get(element);
|
||||
if (!element.paused) return Promise.resolve();
|
||||
let request;
|
||||
try { request = Promise.resolve(element.play()); } catch (error) { request = Promise.reject(error); }
|
||||
// Preserve the intent to play after an OS interruption; the watchdog retries.
|
||||
request = request.catch(() => {}).finally(() => { if (pending.get(element) === request) pending.delete(element); });
|
||||
pending.set(element, request); return request;
|
||||
}
|
||||
function resume(element, clock) {
|
||||
// An ended element is finished, not interrupted: play() would restart it from 0.
|
||||
if (!element || !element.paused || element.ended || pending.has(element)) return;
|
||||
if (clock && element !== clock && needsAlignment(element.currentTime, clock.currentTime)) element.currentTime = clock.currentTime;
|
||||
return play(element);
|
||||
}
|
||||
return { play, resume, reset: element => pending.delete(element) };
|
||||
}
|
||||
const api = { progressiveSource, needsAlignment, create };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else { root.AudioContinuity = api; root.audioContinuity = create(); }
|
||||
})(globalThis);
|
||||
49
frontend/audio-continuity.test.js
Normal file
49
frontend/audio-continuity.test.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { progressiveSource, needsAlignment, create } = require('./audio-continuity');
|
||||
test('iPhone progressive audio uses saved/EQ sources before remote fallbacks', () => {
|
||||
assert.equal(progressiveSource({ localUrl: 'blob:saved', audioUrl: 'remote' }, 'mux'), 'blob:saved');
|
||||
assert.equal(progressiveSource({ eqAudioUrl: 'blob:eq', localUrl: 'blob:saved' }), 'blob:eq');
|
||||
assert.equal(progressiveSource({ audioUrl: 'audio' }, 'mux'), 'audio');
|
||||
assert.equal(progressiveSource({}, 'mux'), 'mux');
|
||||
});
|
||||
test('small drift and invalid clocks do not trigger an audible seek', () => {
|
||||
assert.equal(needsAlignment(50.05, 50), false);
|
||||
assert.equal(needsAlignment(49, 50), true);
|
||||
assert.equal(needsAlignment(NaN, 50), false);
|
||||
});
|
||||
test('simultaneous play, visibility and watchdog resumes share one pending play', async () => {
|
||||
let finish, calls = 0, seeks = 0;
|
||||
const audio = { paused: true, get currentTime() { return 10; }, set currentTime(value) { seeks++; }, play() { calls++; return new Promise(resolve => { finish = resolve; }); } };
|
||||
const controller = create();
|
||||
controller.resume(audio, { currentTime: 10.05 });
|
||||
controller.resume(audio, { currentTime: 12 });
|
||||
const pending = controller.play(audio);
|
||||
assert.equal(calls, 1); assert.equal(seeks, 0);
|
||||
finish(); await pending;
|
||||
controller.resume(audio, audio); // never seek an element to itself
|
||||
assert.equal(seeks, 0); assert.equal(calls, 2); finish();
|
||||
});
|
||||
test('rejected autoplay can retry and only paused, misaligned secondaries seek', async () => {
|
||||
const controller = create(); let calls = 0;
|
||||
const audio = { paused: true, currentTime: 0, play() { calls++; return Promise.reject(new Error('OS suspension')); } };
|
||||
await controller.play(audio); await controller.resume(audio, { currentTime: 4 });
|
||||
assert.equal(calls, 2); assert.equal(audio.currentTime, 4);
|
||||
audio.paused = false; controller.resume(audio, { currentTime: 10 });
|
||||
assert.equal(audio.currentTime, 4); assert.equal(calls, 2);
|
||||
});
|
||||
test('a new play after pause/source reset is not blocked by a stale promise', async () => {
|
||||
const controller = create(), finishes = []; let calls = 0;
|
||||
const audio = { paused: true, play() { calls++; return new Promise(resolve => finishes.push(resolve)); } };
|
||||
const old = controller.play(audio); controller.reset(audio);
|
||||
const fresh = controller.play(audio); assert.equal(calls, 2);
|
||||
finishes[0](); await old;
|
||||
assert.equal(controller.play(audio), fresh); // old completion cannot discard the new request
|
||||
finishes[1](); await fresh;
|
||||
});
|
||||
test('an ended element is never resumed (last song must not restart from 0)', () => {
|
||||
let calls = 0;
|
||||
const audio = { paused: true, ended: true, currentTime: 200, play() { calls++; return Promise.resolve(); } };
|
||||
create().resume(audio, { currentTime: 200 });
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
69
frontend/audio-graph.js
Normal file
69
frontend/audio-graph.js
Normal file
@@ -0,0 +1,69 @@
|
||||
/* One lifetime owner for the player's realtime AudioContext and media sources. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
function create(options = {}) {
|
||||
const getConstructor = options.getConstructor || (() => root.AudioContext || root.webkitAudioContext);
|
||||
let context = null;
|
||||
const sources = new Map();
|
||||
const routes = new Map();
|
||||
const failed = new Map();
|
||||
|
||||
function getContext(contextOptions) {
|
||||
if (context) return context;
|
||||
const Constructor = getConstructor();
|
||||
if (typeof Constructor !== 'function') throw new Error('Web Audio is unavailable');
|
||||
context = contextOptions === undefined ? new Constructor() : new Constructor(contextOptions);
|
||||
return context;
|
||||
}
|
||||
|
||||
function source(element) {
|
||||
if (!element) throw new TypeError('A media element is required');
|
||||
if (sources.has(element)) return sources.get(element);
|
||||
if (failed.has(element)) throw failed.get(element);
|
||||
try {
|
||||
const created = getContext().createMediaElementSource(element);
|
||||
sources.set(element, created);
|
||||
return created;
|
||||
} catch (error) {
|
||||
// MediaElementSource cannot be undone. Remember failure so a later
|
||||
// route change can never attempt a second source for this element.
|
||||
failed.set(element, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function connect(element, destination) {
|
||||
const mediaSource = source(element);
|
||||
if (!destination) throw new TypeError('An audio destination is required');
|
||||
if (routes.get(element) === destination) return mediaSource;
|
||||
if (routes.has(element)) mediaSource.disconnect();
|
||||
mediaSource.connect(destination);
|
||||
routes.set(element, destination);
|
||||
return mediaSource;
|
||||
}
|
||||
|
||||
function routeAll(elements, destination) {
|
||||
for (const element of elements || []) connect(element, destination);
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return {
|
||||
contextCreated: !!context,
|
||||
sampleRate: context?.sampleRate || null,
|
||||
sourceCount: sources.size,
|
||||
failedCount: failed.size,
|
||||
routedCount: routes.size,
|
||||
};
|
||||
}
|
||||
|
||||
return { context: getContext, source, connect, routeAll, snapshot };
|
||||
}
|
||||
|
||||
const singleton = create();
|
||||
const api = { create, context: (...args) => singleton.context(...args), source: (...args) => singleton.source(...args),
|
||||
connect: (...args) => singleton.connect(...args), routeAll: (...args) => singleton.routeAll(...args),
|
||||
snapshot: () => singleton.snapshot() };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else root.AudioGraph = api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
75
frontend/audio-graph.test.js
Normal file
75
frontend/audio-graph.test.js
Normal file
@@ -0,0 +1,75 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
const AudioGraph = require('./audio-graph');
|
||||
|
||||
test('AudioGraph creates its context only on demand and retains the first context options', () => {
|
||||
let contexts = 0, optionsSeen;
|
||||
class Context {
|
||||
constructor(options) { contexts++; optionsSeen = options; this.sampleRate = options?.sampleRate || 44100; }
|
||||
}
|
||||
const graph = AudioGraph.create({ getConstructor: () => Context });
|
||||
assert.equal(graph.snapshot().contextCreated, false);
|
||||
const first = graph.context({ sampleRate: 48000 });
|
||||
assert.equal(graph.context({ sampleRate: 44100 }), first);
|
||||
assert.equal(contexts, 1);
|
||||
assert.deepEqual(optionsSeen, { sampleRate: 48000 });
|
||||
assert.equal(first.sampleRate, 48000);
|
||||
});
|
||||
|
||||
test('one retained MediaElementSource per element can change insert routes without recreation', () => {
|
||||
const made = [], connects = [], disconnects = [];
|
||||
class Context {
|
||||
constructor() { this.sampleRate = 48000; }
|
||||
createMediaElementSource(element) {
|
||||
const source = { element, connect: target => connects.push([element.id, target.id]), disconnect: () => disconnects.push(element.id) };
|
||||
made.push(source); return source;
|
||||
}
|
||||
}
|
||||
const graph = AudioGraph.create({ getConstructor: () => Context });
|
||||
const a = { id: 'video' }, b = { id: 'audio' }, standard = { id: 'standard' }, dsp = { id: 'dsp' };
|
||||
const original = graph.connect(a, standard);
|
||||
graph.connect(a, standard);
|
||||
graph.routeAll([a, b], dsp);
|
||||
graph.routeAll([a, b], standard);
|
||||
assert.equal(made.length, 2);
|
||||
assert.equal(graph.source(a), original);
|
||||
assert.deepEqual(connects, [['video', 'standard'], ['video', 'dsp'], ['audio', 'dsp'], ['video', 'standard'], ['audio', 'standard']]);
|
||||
assert.deepEqual(disconnects, ['video', 'video', 'audio']);
|
||||
assert.deepEqual(graph.snapshot(), { contextCreated: true, sampleRate: 48000, sourceCount: 2, failedCount: 0, routedCount: 2 });
|
||||
});
|
||||
|
||||
test('a failed source creation is remembered and is never attempted twice', () => {
|
||||
let attempts = 0;
|
||||
class Context {
|
||||
constructor() { this.sampleRate = 48000; }
|
||||
createMediaElementSource() { attempts++; throw new Error('already sourced'); }
|
||||
}
|
||||
const graph = AudioGraph.create({ getConstructor: () => Context }), element = {};
|
||||
assert.throws(() => graph.source(element), /already sourced/);
|
||||
assert.throws(() => graph.source(element), /already sourced/);
|
||||
assert.equal(attempts, 1);
|
||||
assert.deepEqual(graph.snapshot(), { contextCreated: true, sampleRate: 48000, sourceCount: 0, failedCount: 1, routedCount: 0 });
|
||||
});
|
||||
|
||||
test('the extracted Standard EQ build block is identical to the original except for context ownership', () => {
|
||||
const source = readFileSync(join(__dirname, 'app.js'), 'utf8');
|
||||
const original = readFileSync(join(__dirname, '..', 'tests', 'fixtures', 'standard-eq-build.txt'), 'utf8').trimEnd();
|
||||
const start = source.indexOf(' function build() {', source.indexOf('const EQ = (() => {'));
|
||||
const end = source.indexOf('\n function attach(el)', start);
|
||||
assert.ok(start >= 0 && end > start, 'EQ build block exists');
|
||||
const current = source.slice(start, end).trimEnd();
|
||||
assert.equal(current, original.replace('st.ctx = new AC();', 'st.ctx = AudioGraph.context();'));
|
||||
assert.match(source, /AudioGraph\.connect\(el, st\.filters\[0\]\)/);
|
||||
assert.doesNotMatch(source, /\.createMediaElementSource\(/);
|
||||
});
|
||||
|
||||
test('AudioGraph is a core runtime asset and is loaded before the lazy app', () => {
|
||||
const { groups } = JSON.parse(readFileSync(join(__dirname, 'assets.json'), 'utf8'));
|
||||
assert.ok(groups.core.files.includes('/audio-graph.js'));
|
||||
const html = readFileSync(join(__dirname, 'index.html'), 'utf8');
|
||||
assert.ok(html.indexOf('src="audio-graph.js"') < html.indexOf('src="section-rail.js"'));
|
||||
const embedded = JSON.parse(html.match(/id="ytp-assets">([^<]+)<\/script>/)[1]);
|
||||
assert.deepEqual(embedded.groups, groups);
|
||||
});
|
||||
65
frontend/autosave.test.js
Normal file
65
frontend/autosave.test.js
Normal file
@@ -0,0 +1,65 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
|
||||
// Exercise the actual app functions without DOM/player/network dependencies.
|
||||
const source = fs.readFileSync(require('node:path').join(__dirname, 'app.js'), 'utf8');
|
||||
function harness({ autoPreload = true, cached = [], result = { ok: true, cached: true }, error } = {}) {
|
||||
const calls = [], jobs = new Map();
|
||||
const video = { id: 'benchmedia01', title: 'Local clip' };
|
||||
const context = vm.createContext({
|
||||
data: { settings: { autoPreload }, playlists: [{ id: 'pl', videos: [video] }], pinnedPlaylists: [] },
|
||||
cachedIds: new Set(cached), downloading: new Set(), downloadMeta: new Map(), saveControllers: new Map(),
|
||||
view: { type: 'home' }, current: null, window: {}, AbortController,
|
||||
slim: v => v, markCardCacheState() {}, renderSidebar() {}, updateDownloadBadge() {}, toast() {},
|
||||
SaveSlots: { acquire: async () => {}, release() {} },
|
||||
SaveQueue: { add: v => jobs.set(v.id, { status: 'queued' }), remove: id => jobs.delete(id), progress() {}, state: (id, status, message) => jobs.set(id, { status, message }) },
|
||||
API: { cacheDownload: async id => { calls.push(id); if (error) throw error; return result; } },
|
||||
recordDeviceFile() {}, warmThumb() {}, thumbUrlFor: () => '', cacheMutations: 0,
|
||||
});
|
||||
const preload = source.slice(source.indexOf('async function preload(video,'), source.indexOf('// A device copy that failed to play.'));
|
||||
const playlists = source.slice(source.indexOf('function preloadPlaylist(pl)'), source.indexOf('// Update a single card\'s saved badge'));
|
||||
vm.runInContext(preload + playlists, context);
|
||||
return { context, calls, jobs, video };
|
||||
}
|
||||
|
||||
test('auto preload tops up playlists, while pinned playlists save with the setting off', async () => {
|
||||
const h = harness({ autoPreload: false });
|
||||
h.context.preloadPlaylist(h.context.data.playlists[0]);
|
||||
await new Promise(r => setImmediate(r));
|
||||
assert.deepEqual(h.calls, []);
|
||||
h.context.data.pinnedPlaylists = ['pl'];
|
||||
h.context.preloadPinnedPlaylists();
|
||||
await new Promise(r => setImmediate(r));
|
||||
assert.deepEqual(h.calls, [h.video.id]);
|
||||
assert.ok(h.context.cachedIds.has(h.video.id));
|
||||
assert.equal(h.jobs.size, 0);
|
||||
assert.equal(h.context.downloading.size, 0);
|
||||
const enabled = harness();
|
||||
enabled.context.preloadPlaylist(enabled.context.data.playlists[0]);
|
||||
await new Promise(r => setImmediate(r));
|
||||
assert.deepEqual(enabled.calls, [enabled.video.id]);
|
||||
});
|
||||
|
||||
test('a repeated save shares the in-flight operation and does not download an existing copy', async () => {
|
||||
const h = harness();
|
||||
await Promise.all([h.context.preload(h.video), h.context.preload(h.video)]);
|
||||
await h.context.preload(h.video);
|
||||
assert.deepEqual(h.calls, [h.video.id]);
|
||||
assert.ok(h.context.cachedIds.has(h.video.id));
|
||||
assert.equal(h.context.saveControllers.size, 0);
|
||||
});
|
||||
|
||||
test('failed and interrupted saves do not acquire a Saved badge and can be attempted again', async () => {
|
||||
for (const options of [{ result: { ok: false, error: 'server failed' } }, { error: new Error('storage quota') }, { result: { ok: false, paused: true } }]) {
|
||||
const h = harness(options);
|
||||
await h.context.preload(h.video, { quiet: true });
|
||||
assert.equal(h.context.cachedIds.has(h.video.id), false);
|
||||
assert.equal(h.context.downloading.size, 0);
|
||||
assert.equal(h.context.saveControllers.size, 0);
|
||||
assert.ok(h.jobs.has(h.video.id));
|
||||
await h.context.preload(h.video, { quiet: true });
|
||||
assert.equal(h.calls.length, 2);
|
||||
}
|
||||
});
|
||||
40
frontend/bento-hub.js
Normal file
40
frontend/bento-hub.js
Normal file
@@ -0,0 +1,40 @@
|
||||
/* Bento-only artwork and measured clearance. Navigation and player nodes stay put. */
|
||||
(function(root){
|
||||
'use strict';
|
||||
function chromeClearance(height,boxes){
|
||||
const occupied=boxes.filter(b=>b.visible&&b.fixed&&b.height>0&&b.top>=height/2).map(b=>height-b.top);
|
||||
return Math.max(0,...occupied)+16;
|
||||
}
|
||||
function mount(doc){
|
||||
let frame=0,active=false;const html=doc.documentElement;
|
||||
const resize=new root.ResizeObserver(schedule),observer=new root.MutationObserver(schedule);
|
||||
function paint(){
|
||||
frame=0;const on=html.dataset.layout==='bento-hub';
|
||||
if(!on){if(active){resize.disconnect();observer.disconnect();html.style.removeProperty('--bento-bottom-clearance');doc.querySelectorAll('.bento-collage,.bento-search-icon').forEach(n=>n.remove());doc.querySelectorAll('.bento-search-label').forEach(n=>n.replaceWith(...n.childNodes));}active=false;return;}
|
||||
if(!active){active=true;observer.observe(doc.body,{childList:true,subtree:true,attributes:true,attributeFilter:['class','hidden']});for(const id of ['bottomNav','miniBar','sectionRail']){const el=doc.getElementById(id);if(el)resize.observe(el);}}
|
||||
const search=doc.querySelector('.search-btn');
|
||||
if(search&&!search.querySelector('.bento-search-icon')){
|
||||
const label=doc.createElement('span');label.className='bento-search-label';label.append(...search.childNodes);
|
||||
const icon=doc.createElementNS('http://www.w3.org/2000/svg','svg');icon.classList.add('bento-search-icon');icon.setAttribute('aria-hidden','true');icon.setAttribute('viewBox','0 0 24 24');icon.innerHTML='<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>';search.append(label,icon);
|
||||
}
|
||||
const boxes=['bottomNav','miniBar','sectionRail'].map(id=>doc.getElementById(id)).filter(Boolean).map(el=>{const r=el.getBoundingClientRect(),s=root.getComputedStyle(el);return {top:r.top,height:r.height,fixed:s.position==='fixed',visible:s.display!=='none'&&s.visibility!=='hidden'};});
|
||||
const clearance=chromeClearance(root.innerHeight,boxes)+'px';if(html.style.getPropertyValue('--bento-bottom-clearance')!==clearance)html.style.setProperty('--bento-bottom-clearance',clearance);
|
||||
for(const card of doc.querySelectorAll('.home-pl-card')){
|
||||
if(card.querySelector('.bento-collage'))continue;
|
||||
// Use actual playlist artwork; never invent an image or a verification badge.
|
||||
const name=card.querySelector('.home-pl-name')?.textContent;
|
||||
const playlist=root.BentoHub.playlists?.().find(p=>p.name===name);
|
||||
const images=(playlist?.videos||[]).filter(v=>v.thumbnail).slice(0,4);
|
||||
if(images.length<4)continue;
|
||||
const collage=doc.createElement('span');collage.className='bento-collage';collage.setAttribute('aria-hidden','true');
|
||||
for(const video of images){const img=doc.createElement('img');img.src=video.thumbnail;img.alt='';img.loading='lazy';collage.append(img);}
|
||||
card.querySelector('.home-pl-thumb')?.prepend(collage);
|
||||
}
|
||||
}
|
||||
function schedule(){if(!frame)frame=root.requestAnimationFrame(paint);}
|
||||
new root.MutationObserver(schedule).observe(html,{attributes:true,attributeFilter:['data-layout']});root.addEventListener('resize',schedule);paint();
|
||||
}
|
||||
const api={chromeClearance,playlists:null};
|
||||
if(typeof module!=='undefined')module.exports=api;
|
||||
else {root.BentoHub=api; if(root.document.readyState==='loading')root.document.addEventListener('DOMContentLoaded',()=>mount(root.document),{once:true});else mount(root.document);}
|
||||
})(typeof window==='undefined'?globalThis:window);
|
||||
12
frontend/bento-hub.test.js
Normal file
12
frontend/bento-hub.test.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const test=require('node:test'),assert=require('node:assert/strict');
|
||||
const {chromeClearance}=require('./bento-hub');
|
||||
test('clearance follows the highest visible floating control, including safe area',()=>{
|
||||
assert.equal(chromeClearance(844,[{visible:true,fixed:true,top:746,height:98},{visible:true,fixed:true,top:666,height:72},{visible:true,fixed:true,top:602,height:56}]),258);
|
||||
});
|
||||
test('hidden and scrolling content cannot reserve fullscreen bottom space',()=>{
|
||||
assert.equal(chromeClearance(844,[{visible:false,fixed:true,top:602,height:56},{visible:true,fixed:false,top:500,height:100},{visible:true,fixed:true,top:0,height:64}]),16);
|
||||
});
|
||||
test('desktop mini player reserves its real height and margin; no chrome is 16px',()=>{
|
||||
assert.equal(chromeClearance(844,[{visible:true,fixed:true,top:760,height:72}]),100);
|
||||
assert.equal(chromeClearance(844,[]),16);
|
||||
});
|
||||
@@ -4,4 +4,3 @@
|
||||
.car-controls button { min-height: 110px; font: 650 clamp(22px, 3vw, 36px)/1.2 system-ui; background: #fff; color: #050607; border: 3px solid #fff; border-radius: 16px; }
|
||||
.car-exit { align-self: flex-start; min-height: 52px; padding: 12px 20px; background: #070809; color: #fff; border: 2px solid #fff; border-radius: 8px; }
|
||||
.car-mode button:focus-visible { outline: 4px solid #61caff; outline-offset: 4px; }
|
||||
.voice-search { flex-shrink: 0; min-width: 44px; min-height: 44px; }
|
||||
|
||||
@@ -10,19 +10,19 @@
|
||||
overlay = doc.createElement('section'); overlay.className = 'car-mode'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-label', 'Car mode');
|
||||
const exit = doc.createElement('button'); exit.type = 'button'; exit.className = 'car-exit'; exit.textContent = 'Exit Car mode'; exit.onclick = close;
|
||||
const title = doc.createElement('h1'); const controls = doc.createElement('div'); controls.className = 'car-controls'; let toggle;
|
||||
for (const [label, action] of [['Previous', 'prev'], ['Play / Pause', 'toggle'], ['15 s back', 'back'], ['Next', 'next']]) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = label; b.setAttribute('aria-label', label); b.onclick = () => adapter.action(action); if (action === 'toggle') toggle = b; controls.append(b); }
|
||||
const update = () => { const state = adapter.state(); title.textContent = state.title || 'Choose a song before entering Car mode'; toggle.textContent = state.paused ? 'Play' : 'Pause'; }; update();
|
||||
for (const [label, action] of [['Previous', 'prev'], ['Play / Pause', 'toggle'], ['15 s back', 'back'], ['Next', 'next']]) { const b = doc.createElement('button'); b.type = 'button'; b.innerHTML = root.TransportIcons.svg({prev:'previous',toggle:'play',back:'back15',next:'next'}[action]) + '<span>' + label + '</span>'; b.setAttribute('aria-label', label); b.onclick = () => adapter.action(action); if (action === 'toggle') toggle = b; controls.append(b); }
|
||||
const update = () => { const state = adapter.state(); title.textContent = state.title || 'Choose a song before entering Car mode'; root.TransportIcons.set(toggle, state.paused ? 'play' : 'pause'); toggle.insertAdjacentHTML('beforeend','<span>' + (state.paused ? 'Play' : 'Pause') + '</span>'); }; update();
|
||||
overlay.append(exit, title, controls); doc.body.append(overlay); timer = setInterval(update, 500); exit.focus();
|
||||
overlay.onkeydown = e => { if (e.key === 'Escape') { e.preventDefault(); close(); } else if (e.key === 'Tab') { const buttons = [...overlay.querySelectorAll('button')], first = buttons[0], last = buttons.at(-1); if (e.shiftKey && doc.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && doc.activeElement === last) { e.preventDefault(); first.focus(); } } e.stopPropagation(); };
|
||||
}
|
||||
function configure(options) {
|
||||
adapter = options; const doc = root.document, Recognition = root.SpeechRecognition || root.webkitSpeechRecognition;
|
||||
if (Recognition) {
|
||||
const b = doc.createElement('button'); b.type = 'button'; b.className = 'icon-btn voice-search'; b.title = 'Voice search'; b.setAttribute('aria-label', 'Voice search'); b.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3m-4 0h8"/></svg>'; adapter.input.after(b);
|
||||
const b = doc.getElementById('voiceSearchBtn') || doc.createElement('button'); b.id='voiceSearchBtn'; b.type = 'button'; b.className = 'icon-btn voice-search'; b.title = 'Voice search'; b.setAttribute('aria-label', 'Voice search'); b.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3m-4 0h8"/></svg>'; if(!b.isConnected)adapter.input.after(b);
|
||||
let recognition, listening = false;
|
||||
b.onclick = () => { if (listening) { recognition.abort(); return; } recognition = new Recognition(); recognition.lang = doc.documentElement.lang || root.navigator.language || 'en-US'; recognition.continuous = false; recognition.interimResults = false; recognition.onstart = () => { listening = true; b.setAttribute('aria-pressed', 'true'); b.title = 'Listening… tap to cancel'; }; recognition.onend = () => { listening = false; b.setAttribute('aria-pressed', 'false'); b.title = 'Voice search'; }; recognition.onerror = e => adapter.toast(`Voice search: ${e.error === 'not-allowed' ? 'microphone permission was denied' : e.error}`); recognition.onresult = e => { const query = transcript(e); if (query) { adapter.input.value = query; adapter.search(); } }; try { recognition.start(); } catch (error) { adapter.toast(error.message); } };
|
||||
}
|
||||
const car = doc.createElement('button'); car.type = 'button'; car.className = 'icon-btn'; car.title = 'Car mode'; car.setAttribute('aria-label', 'Car mode'); car.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="m5 8 2-5h10l2 5M3 8h18v10H3zM5 18v3m14-3v3M6 12h2m8 0h2"/></svg>'; doc.getElementById('searchForm').after(car); car.onclick = open;
|
||||
const car = doc.getElementById('carModeBtn') || doc.createElement('button'); car.id='carModeBtn'; car.type = 'button'; car.className = 'icon-btn'; car.title = 'Car mode'; car.setAttribute('aria-label', 'Car mode'); car.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="m5 8 2-5h10l2 5M3 8h18v10H3zM5 18v3m14-3v3M6 12h2m8 0h2"/></svg>'; if(!car.isConnected)doc.getElementById('searchForm').after(car); car.onclick = open;
|
||||
root.SettingsSections.register({ id: 'voice-car', title: 'Voice & Car mode', cluster: 'Listening', summary: () => Recognition ? 'Voice search available · Car mode' : 'Car mode · voice unavailable', render(container) { const p = doc.createElement('p'); p.textContent = Recognition ? 'Use the microphone beside Search to speak a query. Recognition availability and processing depend on your browser.' : 'Voice search is unavailable in this browser.'; const b = doc.createElement('button'); b.type = 'button'; b.className = 'settings-back'; b.textContent = 'Enter Car mode'; b.onclick = open; container.append(p, b); } });
|
||||
}
|
||||
const api = { transcript, configure, open, close }; if (typeof module !== 'undefined') module.exports = api; else root.CarMode = api;
|
||||
|
||||
@@ -1,2 +1,27 @@
|
||||
const { test } = require('node:test'), assert = require('node:assert/strict'); const { transcript } = require('./car-mode');
|
||||
test('voice search submits only final results from the result index', () => { const interim = [{transcript:'wrong'}]; interim.isFinal = false; const final = [{transcript:'Amazing Grace'}]; final.isFinal = true; assert.equal(transcript({results:[interim, final]}), 'Amazing Grace'); assert.equal(transcript({resultIndex:2,results:[final,final]}), ''); assert.equal(transcript({}), ''); });
|
||||
|
||||
test('lazy Car mode keeps four independent transport buttons and the voice entry in place', () => {
|
||||
const vm=require('node:vm'),fs=require('node:fs');
|
||||
class Element {
|
||||
constructor(tag){this.tag=tag;this.children=[];this.inert=false;}
|
||||
append(...nodes){for(const node of nodes){node.remove();node.parent=this;this.children.push(node);}}
|
||||
remove(){if(this.parent){this.parent.children=this.parent.children.filter(n=>n!==this);this.parent=null;}}
|
||||
after(node){this.parent.append(node);}
|
||||
setAttribute(name,value){this[name]=value;}
|
||||
focus(){}
|
||||
insertAdjacentHTML(position,html){this.innerHTML+=html;}
|
||||
get isConnected(){return !!this.parent;}
|
||||
}
|
||||
const body=new Element('body'),input=new Element('input'),form=new Element('form'),voice=new Element('button');form.id='searchForm';voice.id='voiceSearchBtn';body.append(form,input,voice);
|
||||
const all=node=>[node,...node.children.flatMap(all)],doc={body,documentElement:{lang:'en'},createElement:tag=>new Element(tag),getElementById:id=>all(body).find(n=>n.id===id)};
|
||||
let section;const actions=[],root={document:doc,TransportIcons:require('./download-actions').transport,navigator:{},SpeechRecognition:function(){},SettingsSections:{register:s=>section=s}};
|
||||
vm.runInNewContext(fs.readFileSync(__dirname+'/car-mode.js','utf8'),{window:root,setInterval:()=>1,clearInterval:()=>{}});
|
||||
root.CarMode.configure({input,action:action=>actions.push(action),state:()=>({paused:true,title:'Song'})});
|
||||
root.CarMode.open();const overlay=body.children.find(n=>n.className==='car-mode'),controls=overlay.children.find(n=>n.className==='car-controls');
|
||||
assert.equal(controls.children.length,4);assert.equal(new Set(controls.children).size,4);assert.equal(voice.parent,body);
|
||||
controls.children.forEach(b=>b.onclick());assert.deepEqual(actions,['prev','toggle','back','next']);
|
||||
root.CarMode.close();assert.equal(voice.parent,body);assert.equal(body.inert,false);
|
||||
const panel=new Element('section');body.append(panel);section.render(panel);
|
||||
assert.equal(voice.parent,body,'settings must not steal the voice entry');assert.equal(panel.children.at(-1).textContent,'Enter Car mode');
|
||||
});
|
||||
|
||||
6
frontend/direct-media.css
Normal file
6
frontend/direct-media.css
Normal file
@@ -0,0 +1,6 @@
|
||||
.direct-transfer-dialog{max-width:min(420px,calc(100vw - 32px));padding:24px;border:1px solid var(--border);border-radius:var(--radius,16px);background:var(--surface,#202026);color:var(--text,#fff)}
|
||||
.direct-transfer-dialog::backdrop{background:rgba(0,0,0,.65)}
|
||||
.direct-transfer-dialog p{line-height:1.6;color:var(--text-2);overflow-wrap:anywhere}
|
||||
.direct-transfer-dialog button,#settings-section-direct-transfer button{min-height:44px;padding:10px 16px;margin:4px;border:1px solid var(--border);border-radius:10px;background:var(--surface-2,#303039);color:var(--text,#fff)}
|
||||
.direct-transfer-dialog button:focus-visible,#settings-section-direct-transfer button:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
|
||||
#settings-section-direct-transfer label{display:flex;align-items:center;gap:12px;min-height:44px}
|
||||
168
frontend/direct-media.js
Normal file
168
frontend/direct-media.js
Normal file
@@ -0,0 +1,168 @@
|
||||
/* Paired-room transport; signalling only goes to the server. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
const waiting = new Map();
|
||||
let profilePeers = [];
|
||||
const rooms = new Map(), transfers = new Map(), pending = new Map();
|
||||
let hooks = { settings: () => ({}), persist() {}, current: () => null, saved() {}, notify() {}, fallback() {} };
|
||||
const enabled = () => hooks.settings().directTransfer !== false && !!root.RTCPeerConnection;
|
||||
function attach(key, room) { rooms.set(key, room); }
|
||||
function send(key, m) { const r = rooms.get(key); if (!r) throw Error('Paired device disconnected'); r.send(m); }
|
||||
function signal(key, token, data) { send(key, { type: 'direct', action: 'signal', token, data }); }
|
||||
async function offer(key, to, meta) {
|
||||
if (!enabled()) throw Error('Direct device transfer is unavailable or turned off');
|
||||
if (!meta?.id) throw Error('Choose a saved song first');
|
||||
if (pending.has(`${key}:${to}:${meta.id}`) || [...transfers.values()].some(s => s.key === key && s.peer === to && s.claim.id === meta.id)) return;
|
||||
const file = await root.OPFS.getFileObject(meta.id);
|
||||
if (!file) throw Error('Save this song on this device before sending it');
|
||||
const hash = root.Sha256.create();
|
||||
for (let at = 0; at < file.size; at += 1024 * 1024) hash.update(new Uint8Array(await file.slice(at, at + 1024 * 1024).arrayBuffer()));
|
||||
const claim = root.DirectProtocol.file({ ...meta, cid: hash.hex(), size: file.size, extension: file.name.split('.').pop().toLowerCase(), streamMime: await root.DirectStream?.inspect(file), title: meta.title || meta.id });
|
||||
if (!claim) throw Error('Unsupported file metadata');
|
||||
const pendingKey = `${key}:${to}:${claim.id}`;
|
||||
const item = { file, claim }; pending.set(pendingKey, item);
|
||||
setTimeout(() => { if (pending.get(pendingKey) === item) pending.delete(pendingKey); }, 60000);
|
||||
send(key, { type: 'direct', action: 'invite', to, file: claim });
|
||||
hooks.notify('Waiting for the receiving device to accept');
|
||||
}
|
||||
function finish(s, error) {
|
||||
if (s.done) return; s.done = true; clearTimeout(s.timer); clearTimeout(s.idle);
|
||||
s.pc?.close(); transfers.delete(s.token);
|
||||
if (error) s.preview?.close(); else s.preview?.end();
|
||||
if (s.worker) { s.worker.postMessage({ op: 'abort' }); setTimeout(() => s.worker.terminate(), 1000); }
|
||||
if (error) { waiting.get(s.claim.id)?.resolve(false); waiting.delete(s.claim.id); hooks.notify(`${error}. Partial copy kept for resume.`); hooks.fallback(s.claim, () => offer(s.key, s.peer, s.claim).catch(e => hooks.notify(e.message))); }
|
||||
}
|
||||
function connection(s, receiver) {
|
||||
clearTimeout(s.timer);
|
||||
const pc = s.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
|
||||
s.ice = s.ice || [];
|
||||
pc.onicecandidate = e => { if (e.candidate) signal(s.key, s.token, { kind: 'ice', candidate: e.candidate.toJSON() }); };
|
||||
s.timer = setTimeout(() => finish(s, 'Direct connection failed after 10 seconds'), 10000);
|
||||
pc.onconnectionstatechange = () => { if (['failed', 'closed'].includes(pc.connectionState)) finish(s, 'Direct connection closed'); };
|
||||
const bump = () => { clearTimeout(s.idle); s.idle = setTimeout(() => finish(s, 'Direct transfer stalled'), 30000); };
|
||||
const channel = dc => {
|
||||
s.dc = dc; dc.binaryType = 'arraybuffer'; dc.bufferedAmountLowThreshold = 1024 * 1024;
|
||||
dc.onopen = () => { clearTimeout(s.timer); bump(); if (receiver && s.offset != null) dc.send(JSON.stringify({ t: 'get', offset: s.offset })); };
|
||||
dc.onclose = () => { if (!s.done && !s.verifying) setTimeout(() => { if (!s.done && !s.verifying) finish(s, 'Direct channel closed'); }, 3000); };
|
||||
dc.onmessage = async e => {
|
||||
bump();
|
||||
try {
|
||||
if (receiver) {
|
||||
if (typeof e.data === 'string') { const m = JSON.parse(e.data); if (m.t === 'end') { s.verifying = true; clearTimeout(s.idle); s.worker.postMessage({ op: 'finish' }); } else throw Error('Unexpected file control'); }
|
||||
else { s.preview?.push(e.data); s.worker.postMessage({ op: 'chunk', buf: e.data }, [e.data]); }
|
||||
} else {
|
||||
if (typeof e.data !== 'string' || s.started) throw Error('Unexpected file request');
|
||||
const m = JSON.parse(e.data); if (m.t !== 'get') throw Error('Unexpected file request');
|
||||
let pos = root.DirectProtocol.offset(m.offset, s.file.size); s.started = true;
|
||||
while (pos < s.file.size && !s.done) {
|
||||
if (dc.bufferedAmount > 4 * 1024 * 1024) await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => { cleanup(); reject(Error('Peer stopped receiving')); }, 10000);
|
||||
const cleanup = () => { clearTimeout(timeout); dc.removeEventListener('bufferedamountlow', low); dc.removeEventListener('close', closed); };
|
||||
const low = () => { cleanup(); resolve(); }, closed = () => { cleanup(); reject(Error('Peer disconnected')); };
|
||||
dc.addEventListener('bufferedamountlow', low); dc.addEventListener('close', closed);
|
||||
});
|
||||
const range = root.DirectProtocol.nextChunk(pos, s.file.size);
|
||||
dc.send(await s.file.slice(range.start, range.end).arrayBuffer()); pos = range.end; bump();
|
||||
}
|
||||
if (!s.done) { dc.send(JSON.stringify({ t: 'end' })); clearTimeout(s.idle); s.idle = setTimeout(() => finish(s, 'Receiver did not finish verification'), 30 * 60000); }
|
||||
}
|
||||
} catch (err) { finish(s, err.message); }
|
||||
};
|
||||
};
|
||||
if (receiver) channel(pc.createDataChannel('file', { ordered: true })); else pc.ondatachannel = e => channel(e.channel);
|
||||
return pc;
|
||||
}
|
||||
async function accept(key, m) {
|
||||
const s = { key, token: m.token, claim: m.file, peer: m.from, receiver: true }; transfers.set(m.token, s);
|
||||
s.preview = m.file.streamMime ? root.DirectStream?.create(m.file.streamMime, m.file.title) : null;
|
||||
const wait = waiting.get(m.file.id); if (wait) clearTimeout(wait.timer);
|
||||
send(key, { type: 'direct', action: 'accept', token: m.token });
|
||||
s.worker = window.Lazy ? window.Lazy.worker('/direct-recv-worker.js') : new Worker('/direct-recv-worker.js');
|
||||
s.worker.onmessage = async e => {
|
||||
const result = e.data;
|
||||
if (result.op === 'opened') {
|
||||
s.offset = result.offset;
|
||||
connection(s, true);
|
||||
const offer = await s.pc.createOffer(); await s.pc.setLocalDescription(offer); signal(key, m.token, { kind: 'offer', sdp: offer.sdp });
|
||||
} else if (result.op === 'done') {
|
||||
send(key, { type: 'direct', action: 'complete', token: m.token });
|
||||
await hooks.saved(result.file); waiting.get(result.file.id)?.resolve(true); waiting.delete(result.file.id); publish(); hooks.notify(`Saved ${result.file.title} directly from the other device`); finish(s);
|
||||
} else if (result.op === 'error') finish(s, result.error);
|
||||
};
|
||||
s.worker.postMessage({ op: 'open', file: m.file });
|
||||
}
|
||||
async function handle(key, m) {
|
||||
if (m.type !== 'direct') return;
|
||||
if (m.action === 'request') { if (enabled() && hooks.settings().p2pShare !== false) offer(key, m.from, hooks.meta?.(m.id) || { id: m.id, title: m.id }).catch(e => hooks.notify(e.message)); return; }
|
||||
try {
|
||||
if (m.action === 'invite') {
|
||||
if (!enabled() || !root.DirectProtocol.file(m.file)) return send(key, { type: 'direct', action: 'decline', token: m.token });
|
||||
confirmReceive(key, m); return;
|
||||
}
|
||||
if (m.action === 'invited') {
|
||||
const p = pending.get(`${key}:${m.to}:${m.file.id}`); if (!p) return;
|
||||
pending.delete(`${key}:${m.to}:${m.file.id}`);
|
||||
const s = { key, token: m.token, peer: m.to, claim: p.claim, file: p.file }; transfers.set(m.token, s); s.timer = setTimeout(() => finish(s), 60000); return;
|
||||
}
|
||||
const s = transfers.get(m.token); if (!s || s.key !== key || m.from !== s.peer) return;
|
||||
if (m.action === 'accept') { s.accepted = true; clearTimeout(s.timer); s.timer = setTimeout(() => finish(s, 'Receiver stopped preparing the file'), 30 * 60000); }
|
||||
else if (m.action === 'decline' || m.action === 'complete') finish(s);
|
||||
else if (m.action === 'signal') {
|
||||
const d = m.data;
|
||||
if (!s.pc && !s.receiver && s.accepted) { if (d.kind === 'ice') { (s.ice ||= []).push(d.candidate); return; } if (d.kind === 'offer') connection(s, false); }
|
||||
if (!s.pc) return;
|
||||
if (d.kind === 'ice') { if (s.pc.remoteDescription) await s.pc.addIceCandidate(d.candidate); else s.ice.push(d.candidate); }
|
||||
else {
|
||||
await s.pc.setRemoteDescription({ type: d.kind, sdp: d.sdp });
|
||||
for (const c of s.ice.splice(0)) await s.pc.addIceCandidate(c);
|
||||
if (d.kind === 'offer') { const a = await s.pc.createAnswer(); await s.pc.setLocalDescription(a); signal(key, m.token, { kind: 'answer', sdp: a.sdp }); }
|
||||
}
|
||||
}
|
||||
} catch (e) { const s = transfers.get(m.token); if (s) finish(s, e.message); else hooks.notify(e.message); }
|
||||
}
|
||||
function confirmReceive(key, m) {
|
||||
const dialog = document.createElement('dialog'); dialog.className = 'direct-transfer-dialog';
|
||||
const heading = document.createElement('h2'); heading.textContent = 'Save from your paired device?';
|
||||
const text = document.createElement('p'); text.textContent = `${m.file.title} · ${(m.file.size / 1024 ** 2).toFixed(1)} MB. Media travels directly between devices. Keep both screens open. Playback starts after the full copy is verified.`;
|
||||
const yes = document.createElement('button'); yes.textContent = 'Accept & save';
|
||||
const no = document.createElement('button'); no.textContent = 'Decline';
|
||||
const decline = () => { waiting.get(m.file.id)?.resolve(false); waiting.delete(m.file.id); send(key, { type: 'direct', action: 'decline', token: m.token }); dialog.remove(); };
|
||||
yes.onclick = () => { dialog.remove(); accept(key, m).catch(e => hooks.notify(e.message)); }; no.onclick = decline; dialog.oncancel = decline;
|
||||
dialog.append(heading, text, yes, no); document.body.append(dialog); dialog.showModal();
|
||||
}
|
||||
async function publish() {
|
||||
if (!root.P2PClient?.isConnected()) return;
|
||||
const files = await root.OPFS.listVideos({ strict: true });
|
||||
root.P2PClient.send({ type: 'direct-inventory', ids: enabled() && hooks.settings().p2pShare !== false ? files.map(f => f.id) : [] });
|
||||
}
|
||||
function source(id) { if (!enabled()) return null; for (const [key, room] of rooms) { const peer = room.peers().find(p => p.files?.includes(id)); if (peer) return { key, peer }; } return null; }
|
||||
function hasSource(id) { return !!source(id); }
|
||||
function obtain(id) {
|
||||
if (!hasSource(id)) return Promise.resolve(null);
|
||||
if (waiting.has(id)) return waiting.get(id).promise;
|
||||
const { key, peer } = source(id);
|
||||
let resolve; const promise = new Promise(r => { resolve = r; });
|
||||
const wait = { promise, resolve }; waiting.set(id, wait);
|
||||
send(key, { type: 'direct', action: 'request', to: peer.id, id });
|
||||
wait.timer = setTimeout(() => { if (waiting.get(id)?.promise === promise) { waiting.delete(id); resolve(false); hooks.notify('The other device did not accept or is unavailable'); hooks.fallback(hooks.meta?.(id) || { id, title: id }); } }, 60000);
|
||||
return promise;
|
||||
}
|
||||
function configure(h) {
|
||||
hooks = { ...hooks, ...h };
|
||||
if (root.P2PClient) {
|
||||
root.P2PClient.onMessage('hello', () => { attach('profile', { send: root.P2PClient.send, peers: () => profilePeers }); publish(); });
|
||||
root.P2PClient.onMessage('direct-peers', m => { profilePeers = m.peers || []; });
|
||||
root.P2PClient.onMessage('direct', m => handle('profile', m));
|
||||
}
|
||||
root.SettingsSections.register({ id: 'direct-transfer', title: 'Direct device transfer', cluster: 'Library & storage', summary: () => enabled() ? 'On · paired devices only' : 'Off or unsupported', icon: '<path d="M4 6h6v12H4zM14 6h6v12h-6M10 10h4m-4 4h4"/>', render(container) {
|
||||
const label = document.createElement('label'); const toggle = document.createElement('input'); toggle.type = 'checkbox'; toggle.checked = hooks.settings().directTransfer !== false;
|
||||
toggle.onchange = () => { hooks.settings().directTransfer = toggle.checked; hooks.persist(); publish(); }; label.append(toggle, ' Direct device transfer (P2P)');
|
||||
const help = document.createElement('p'); help.textContent = 'Pair through Remote or join a Watch Party, then send your currently playing saved song. The receiver confirms before saving. Keep both devices open. No TURN relay is used.';
|
||||
container.append(label, help);
|
||||
for (const [key, r] of rooms) for (const peer of r.peers()) {
|
||||
const b = document.createElement('button'); b.textContent = `Send saved song to ${peer.name}`; b.style.minHeight = '44px'; b.onclick = () => offer(key, peer.id, hooks.current()).catch(e => hooks.notify(e.message)); container.append(b);
|
||||
}
|
||||
} });
|
||||
}
|
||||
root.DirectMedia = { configure, attach, handle, offer, enabled, obtain, hasSource, publish };
|
||||
}(globalThis));
|
||||
41
frontend/direct-protocol.js
Normal file
41
frontend/direct-protocol.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/* Shared bounded control protocol: media bytes belong exclusively on DataChannel. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
const CHUNK = 65536;
|
||||
const text = (v, n) => typeof v === 'string' && v.length <= n && !/[\u0000-\u001f]/.test(v);
|
||||
const id = (v) => text(v, 128) && /^[A-Za-z0-9_-]+$/.test(v);
|
||||
function file(v) {
|
||||
if (!v || !id(v.id) || !/^[a-f0-9]{64}$/.test(v.cid) || !Number.isSafeInteger(v.size) || v.size <= 0 || v.size > 4 * 1024 ** 3) return null;
|
||||
if (!text(v.title, 300) || !text(v.channel || '', 200)) return null;
|
||||
const streamMime = typeof v.streamMime === 'string' && /^video\/mp4; codecs="avc1\.[a-f0-9]{6}(, mp4a\.40\.2)?"$/.test(v.streamMime) ? v.streamMime : '';
|
||||
return { streamMime, extension: /^(mp4|m4a|webm|ogg|opus|mkv|mp3|wav|aac)$/.test(v.extension) ? v.extension : 'mp4', id: v.id, cid: v.cid, size: v.size, title: v.title, channel: v.channel || '', duration: Number.isFinite(v.duration) && v.duration >= 0 ? v.duration : 0 };
|
||||
}
|
||||
function parse(value) {
|
||||
if (typeof value === 'string') { if (value.length > 65536) return null; try { value = JSON.parse(value); } catch { return null; } }
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) || value.type !== 'direct') return null;
|
||||
const m = { type: 'direct', action: value.action };
|
||||
if (value.action === 'request') return id(value.to) && id(value.id) ? { ...m, to: value.to, id: value.id } : null;
|
||||
if (value.action === 'invite') {
|
||||
const f = file(value.file); if (!id(value.to) || !f) return null;
|
||||
return { ...m, to: value.to, file: f };
|
||||
}
|
||||
if (!['accept', 'decline', 'signal', 'complete'].includes(value.action) || !id(value.token)) return null;
|
||||
m.token = value.token;
|
||||
if (value.action === 'signal') {
|
||||
const d = value.data;
|
||||
if (!d || typeof d !== 'object') return null;
|
||||
if (['offer', 'answer'].includes(d.kind) && typeof d.sdp === 'string' && d.sdp.length <= 60000) m.data = { kind: d.kind, sdp: d.sdp };
|
||||
else if (d.kind === 'ice' && d.candidate && text(d.candidate.candidate, 2048)) {
|
||||
const c = d.candidate;
|
||||
if (!(c.sdpMid == null || text(c.sdpMid, 64)) || !(c.sdpMLineIndex == null || Number.isInteger(c.sdpMLineIndex) && c.sdpMLineIndex >= 0 && c.sdpMLineIndex < 100)) return null;
|
||||
m.data = { kind: 'ice', candidate: { candidate: c.candidate, sdpMid: c.sdpMid ?? null, sdpMLineIndex: c.sdpMLineIndex ?? null } };
|
||||
} else return null;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
function offset(v, size) { if (!Number.isSafeInteger(v) || v < 0 || v > size) throw new Error('Invalid resume offset'); return v; }
|
||||
function nextChunk(pos, size) { offset(pos, size); return { start: pos, end: Math.min(size, pos + CHUNK) }; }
|
||||
const api = { CHUNK, file, parse, offset, nextChunk };
|
||||
if (typeof module !== 'undefined') module.exports = api;
|
||||
root.DirectProtocol = api;
|
||||
}(typeof globalThis !== 'undefined' ? globalThis : this));
|
||||
18
frontend/direct-protocol.test.js
Normal file
18
frontend/direct-protocol.test.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const p = require('./direct-protocol.js');
|
||||
test('direct protocol strips unexpected payload and validates file claims', () => {
|
||||
const m = p.parse({ type: 'direct', action: 'invite', to: 'peer', file: { id: 'video', title: 'Song', cid: 'a'.repeat(64), size: 10, bytes: 'secret' }, bytes: 'not relayed' });
|
||||
assert.equal(m.file.size, 10); assert.equal(m.bytes, undefined); assert.equal(m.file.bytes, undefined);
|
||||
assert.equal(p.parse({ ...m, file: { ...m.file, size: Infinity } }), null);
|
||||
assert.equal(p.parse(new Uint8Array(4)), null);
|
||||
});
|
||||
test('chunks retain exact resume offset and never exceed file end', () => {
|
||||
assert.deepEqual(p.nextChunk(17, 100000), { start: 17, end: 65553 });
|
||||
assert.deepEqual(p.nextChunk(99999, 100000), { start: 99999, end: 100000 });
|
||||
for (const n of [-1, 100001, 0.5, NaN]) assert.throws(() => p.offset(n, 100000));
|
||||
});
|
||||
test('signalling only admits bounded SDP or ICE, never byte frames', () => {
|
||||
assert.equal(p.parse({ type: 'direct', action: 'signal', token: 'abc', data: { kind: 'bytes', data: [1, 2] } }), null);
|
||||
assert.equal(p.parse({ type: 'direct', action: 'signal', token: 'abc', data: { kind: 'offer', sdp: 'x'.repeat(60001) } }), null);
|
||||
});
|
||||
40
frontend/direct-recv-worker.js
Normal file
40
frontend/direct-recv-worker.js
Normal file
@@ -0,0 +1,40 @@
|
||||
'use strict';
|
||||
let dir, handle, access, hash, pos = 0, claim, name;
|
||||
let chain = Promise.resolve();
|
||||
self.onmessage = e => { chain = chain.then(() => run(e.data)); };
|
||||
async function close() { if (access) { access.flush(); access.close(); access = null; } }
|
||||
async function run(m) {
|
||||
try {
|
||||
if (!self.Sha256) { self.__ASSET_URLS__=m.assetUrls || {}; importScripts(self.__ASSET_URLS__.sha256 || '/sha256.js'); }
|
||||
if (m.op === 'open') {
|
||||
claim = m.file;
|
||||
if (!/^[A-Za-z0-9_-]{1,128}$/.test(claim.id) || !/^[a-f0-9]{64}$/.test(claim.cid)) throw Error('Invalid file identity');
|
||||
dir = await (await navigator.storage.getDirectory()).getDirectoryHandle('videos', { create: true });
|
||||
name = `${claim.id}.${claim.cid}.direct.part`;
|
||||
handle = await dir.getFileHandle(name, { create: true });
|
||||
access = await handle.createSyncAccessHandle();
|
||||
pos = access.getSize(); if (pos > claim.size) { access.truncate(0); pos = 0; }
|
||||
hash = self.Sha256.create();
|
||||
for (let at = 0; at < pos; at += 1024 * 1024) { const b = new Uint8Array(Math.min(1024 * 1024, pos - at)); access.read(b, { at }); hash.update(b); }
|
||||
self.postMessage({ op: 'opened', offset: pos });
|
||||
} else if (m.op === 'chunk') {
|
||||
const b = new Uint8Array(m.buf);
|
||||
if (!access || b.length > 65536 || pos + b.length > claim.size) throw Error('Invalid file chunk');
|
||||
if (access.write(b, { at: pos }) !== b.length) throw Error('Incomplete OPFS write');
|
||||
hash.update(b); pos += b.length; access.flush();
|
||||
self.postMessage({ op: 'progress', received: pos });
|
||||
} else if (m.op === 'abort') { await close(); self.postMessage({ op: 'closed' }); }
|
||||
else if (m.op === 'finish') {
|
||||
await close();
|
||||
if (pos !== claim.size || hash.hex() !== claim.cid) { await dir.removeEntry(name); throw Error('File verification failed; retry starts from zero'); }
|
||||
const extension = /^(mp4|m4a|webm|ogg|opus|mkv|mp3|wav|aac)$/.test(claim.extension) ? claim.extension : 'mp4';
|
||||
const final = `${claim.id}.${extension}`;
|
||||
// Copy only after verification; existing saved playback remains intact until then.
|
||||
const out = await (await dir.getFileHandle(final, { create: true })).createSyncAccessHandle();
|
||||
try { const f = await handle.getFile(); for (let at = 0; at < f.size; at += 1024 * 1024) out.write(new Uint8Array(await f.slice(at, at + 1024 * 1024).arrayBuffer()), { at }); out.truncate(f.size); out.flush(); } finally { out.close(); }
|
||||
await dir.removeEntry(name);
|
||||
for await (const [old, entry] of dir.entries()) { if (entry.kind === 'file' && old !== final && old.startsWith(claim.id + '.') && /\.(mp4|m4a|webm|ogg|opus|mkv|mp3|wav|aac)$/.test(old)) await dir.removeEntry(old); }
|
||||
self.postMessage({ op: 'done', file: claim });
|
||||
}
|
||||
} catch (e) { await close(); self.postMessage({ op: 'error', error: e.message }); }
|
||||
}
|
||||
44
frontend/direct-stream.js
Normal file
44
frontend/direct-stream.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* Progressive preview is only safe for fragmented MP4, never ordinary MP4 chunks. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
function inspectBytes(bytes) {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const str = (at, n) => String.fromCharCode(...bytes.subarray(at, at + n));
|
||||
let at = 0, moov = null;
|
||||
while (at + 8 <= bytes.length) {
|
||||
const size = view.getUint32(at); if (size < 8 || at + size > bytes.length) break;
|
||||
if (str(at + 4, 4) === 'moov') { moov = bytes.subarray(at + 8, at + size); break; }
|
||||
at += size;
|
||||
}
|
||||
if (!moov) return null;
|
||||
const tagAt = (tag) => { for (let i = 4; i + 4 <= moov.length; i++) if (String.fromCharCode(...moov.subarray(i, i + 4)) === tag) return i; return -1; };
|
||||
if (tagAt('mvex') < 0) return null;
|
||||
const avc = tagAt('avcC'), audio = tagAt('mp4a');
|
||||
if (avc < 0 || avc + 8 > moov.length || moov[avc + 4] !== 1) return null;
|
||||
const codec = Array.from(moov.subarray(avc + 5, avc + 8), b => b.toString(16).padStart(2, '0')).join('');
|
||||
return `video/mp4; codecs="avc1.${codec}${audio >= 0 ? ', mp4a.40.2' : ''}"`;
|
||||
}
|
||||
async function inspect(file) { return inspectBytes(new Uint8Array(await file.slice(0, 1024 * 1024).arrayBuffer())); }
|
||||
function create(mime, title) {
|
||||
if (!root.MediaSource?.isTypeSupported(mime)) return null;
|
||||
const media = new MediaSource(), url = URL.createObjectURL(media), queue = [];
|
||||
let buffer, queued = 0, ended = false, stopped = false;
|
||||
const dialog = document.createElement('dialog'); dialog.className = 'direct-transfer-dialog';
|
||||
const heading = document.createElement('h2'); heading.textContent = title;
|
||||
const note = document.createElement('p'); note.textContent = 'Direct preview · the full copy is still being saved and verified. Tap Play when ready.';
|
||||
const video = document.createElement('video'); video.controls = true; video.playsInline = true; video.src = url; video.style.width = '100%';
|
||||
const close = document.createElement('button'); close.textContent = 'Close preview';
|
||||
const dispose = () => { stopped = true; queue.length = 0; video.pause(); video.removeAttribute('src'); video.load(); URL.revokeObjectURL(url); dialog.remove(); };
|
||||
close.onclick = dispose; dialog.oncancel = e => { e.preventDefault(); dispose(); }; dialog.append(heading, note, video, close); document.body.append(dialog); dialog.showModal();
|
||||
function flush() {
|
||||
if (stopped || !buffer || buffer.updating) return;
|
||||
if (queue.length) { const b = queue.shift(); queued -= b.byteLength; try { buffer.appendBuffer(b); } catch { dispose(); } }
|
||||
else if (ended && media.readyState === 'open') { try { media.endOfStream(); } catch {} }
|
||||
}
|
||||
media.addEventListener('sourceopen', () => { try { buffer = media.addSourceBuffer(mime); buffer.addEventListener('updateend', flush); buffer.addEventListener('error', dispose); flush(); } catch { dispose(); } }, { once: true });
|
||||
return { push(bytes) { if (stopped) return; queued += bytes.byteLength; if (queued > 8 * 1024 * 1024) { dispose(); return; } queue.push(bytes.slice(0)); flush(); }, end() { ended = true; flush(); }, close: dispose };
|
||||
}
|
||||
const api = { inspectBytes, inspect, create };
|
||||
if (typeof module !== 'undefined') module.exports = api;
|
||||
root.DirectStream = api;
|
||||
}(globalThis));
|
||||
12
frontend/direct-stream.test.js
Normal file
12
frontend/direct-stream.test.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { inspectBytes } = require('./direct-stream.js');
|
||||
function box(type, payload = Buffer.alloc(0)) { const b = Buffer.alloc(8 + payload.length); b.writeUInt32BE(b.length); b.write(type, 4); payload.copy(b, 8); return b; }
|
||||
test('progressive playback only accepts a complete fragmented MP4 initialization', () => {
|
||||
const codec = box('avcC', Buffer.from([1, 0x64, 0, 0x1f]));
|
||||
const fragmented = Buffer.concat([box('ftyp'), box('moov', Buffer.concat([box('mvex'), codec, box('mp4a')]))]);
|
||||
assert.equal(inspectBytes(fragmented), 'video/mp4; codecs="avc1.64001f, mp4a.40.2"');
|
||||
assert.equal(inspectBytes(Buffer.concat([box('ftyp'), box('moov', codec)])), null);
|
||||
assert.equal(inspectBytes(fragmented.subarray(0, fragmented.length - 1)), null);
|
||||
assert.equal(inspectBytes(Buffer.alloc(0)), null);
|
||||
});
|
||||
38
frontend/download-actions.css
Normal file
38
frontend/download-actions.css
Normal file
@@ -0,0 +1,38 @@
|
||||
/* Shared quiet controls; use each layout's existing surface and focus tokens. */
|
||||
:is(.dl-row,.download-job) .download-action { display:inline-flex; align-items:center; justify-content:center; flex:none; gap:8px; height:auto; min-height:44px; min-width:44px; padding:8px 14px; border:1px solid var(--line); border-radius:var(--radius-sm); background:var(--bg-2); color:var(--text); font:600 13px var(--ui); line-height:1.4; cursor:pointer; white-space:nowrap; }
|
||||
:is(.dl-row,.download-job) .download-action:hover { background:var(--bg-3); border-color:var(--accent); }
|
||||
:is(.dl-row,.download-job) .download-action.danger { color:var(--accent); }
|
||||
:is(.dl-row,.download-job) .download-action:focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
|
||||
:is(.dl-row,.download-job) .download-action:disabled { opacity:.5; cursor:not-allowed; }
|
||||
.download-action-icon { width:20px; height:20px; flex:none; fill:none; stroke:currentColor; stroke-width:1.9; stroke-linecap:round; stroke-linejoin:round; pointer-events:none; }
|
||||
html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action { border-radius:18px; border-color:var(--glass-line); background:var(--tile); color:var(--text-2); box-shadow:none; }
|
||||
html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action:hover { border-color:var(--glass-line); background:var(--tile-hi); color:var(--text); }
|
||||
html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action.danger { color:var(--accent-bright); }
|
||||
html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action:focus-visible { outline-color:var(--accent-bright); }
|
||||
|
||||
/* Transport drawings and touch targets survive layout/state changes. */
|
||||
.transport-icon { display:inline-block; flex:none; width:24px; height:24px; vertical-align:middle; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; pointer-events:none; }
|
||||
.transport-shape { display:none; }
|
||||
.transport-shape-classic { display:inline; }
|
||||
html:is([data-layout="f7-swipe"],[data-layout="glass-stage"],[data-layout="bento-hub"]) .transport-shape-classic { display:none; }
|
||||
html[data-layout="f7-swipe"] .transport-shape-f7-swipe,
|
||||
html[data-layout="glass-stage"] .transport-shape-glass-stage,
|
||||
html[data-layout="bento-hub"] .transport-shape-bento-hub { display:inline; }
|
||||
html[data-layout="f7-swipe"] .transport-shape-f7-swipe { fill:currentColor; stroke-width:.7; }
|
||||
html[data-layout="f7-swipe"] .transport-icon:is([data-transport-icon="play"],[data-transport-icon="pause"],[data-transport-icon="previous"],[data-transport-icon="next"],[data-transport-icon="forward"]),
|
||||
html[data-layout="f7-swipe"] #controls .transport-icon:is([data-transport-icon="play"],[data-transport-icon="pause"],[data-transport-icon="previous"],[data-transport-icon="next"],[data-transport-icon="forward"]) { fill:currentColor; stroke-width:.7; }
|
||||
html[data-layout="f7-swipe"] :is([data-transport-icon="back10"],[data-transport-icon="forward10"],[data-transport-icon="back15"]) .transport-shape { fill:none; stroke-width:2; }
|
||||
html[data-layout="glass-stage"] .transport-icon,
|
||||
html[data-layout="glass-stage"] #controls .transport-icon { stroke-width:1.5; }
|
||||
html[data-layout="bento-hub"] .transport-icon { stroke-width:2; stroke-linejoin:round; }
|
||||
button:has(> .transport-icon), a.np-btn:has(> .transport-icon) { min-width:44px; min-height:44px; }
|
||||
button:has(> .transport-icon) { align-items:center; justify-content:center; }
|
||||
html[data-layout="f7-swipe"] :is(#playBtn,#stgPlay,.service-btn-play,.rv-play) { border-radius:50%; }
|
||||
html[data-layout="f7-swipe"] #playBtn { width:64px; height:64px; }
|
||||
html[data-layout="glass-stage"] :is(#playBtn,.mini-btn,.rv-tbtn):has(> .transport-icon) { background:var(--tile,rgba(255,255,255,.08)); border:1px solid var(--line); backdrop-filter:blur(12px); }
|
||||
html[data-layout="bento-hub"] :is(#playBtn,.mini-btn,.service-btn,.rv-tbtn):has(> .transport-icon) { border-radius:16px; }
|
||||
.car-controls .transport-icon { width:36px; height:36px; margin-right:12px; }
|
||||
.gh-seek .transport-icon, .empty-icon .transport-icon { width:48px; height:48px; }
|
||||
.ph-logo .transport-icon { width:60%; height:60%; }
|
||||
|
||||
.home-pl-play:has(> .transport-icon) { min-width:44px; min-height:44px; }
|
||||
101
frontend/download-actions.js
Normal file
101
frontend/download-actions.js
Normal file
@@ -0,0 +1,101 @@
|
||||
/* One action control for the Downloads page and Settings download manager. */
|
||||
(function(root) {
|
||||
'use strict';
|
||||
// One SVG definition per media concept; every instance inherits its control colour.
|
||||
const mediaPaths = {
|
||||
download: '<path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4"/>',
|
||||
offline: '<circle cx="12" cy="12" r="9"/><path d="m7.5 12 3 3 6-6"/>',
|
||||
progress: '<path d="M12 3a9 9 0 1 1-9 9"/>'
|
||||
};
|
||||
const variants = {
|
||||
'classic': mediaPaths,
|
||||
'f7-swipe': {
|
||||
download: '<path d="M12 3v12m-5-5 5 5 5-5M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/>',
|
||||
offline: '<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>',
|
||||
progress: '<path d="M12 3a9 9 0 1 1-9 9"/>'
|
||||
},
|
||||
'glass-stage': {
|
||||
download: '<path d="M12 4v11m-4-4 4 4 4-4M5 20h14"/>',
|
||||
offline: '<circle cx="12" cy="12" r="8"/><path d="m8.5 12 2.5 2.5 4.5-5"/>',
|
||||
progress: '<path d="M12 4a8 8 0 1 1-8 8"/>'
|
||||
},
|
||||
'bento-hub': {
|
||||
download: '<path d="M12 4v11m-4-4 4 4 4-4M5 17v3h14v-3"/>',
|
||||
offline: '<rect x="4" y="4" width="16" height="16" rx="4"/><path d="m8 12 3 3 5-6"/>',
|
||||
progress: '<path d="M12 4a8 8 0 1 1-8 8"/>'
|
||||
}
|
||||
};
|
||||
const use = kind => `<use href="#media-${kind}"/>`;
|
||||
const svg = (kind, extra = '') => `<svg class="media-icon ${extra}" data-media-icon="${kind}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${use(kind)}</svg>`;
|
||||
const media = { paths: mediaPaths, use, svg };
|
||||
if (root.document) {
|
||||
const defs = root.document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
defs.setAttribute('aria-hidden', 'true'); defs.setAttribute('width', '0'); defs.setAttribute('height', '0');
|
||||
defs.style.position = 'absolute'; defs.style.pointerEvents = 'none';
|
||||
defs.innerHTML = '<defs>' + Object.keys(mediaPaths).map(kind => `<g id="media-${kind}">${Object.entries(variants).map(([layout, paths]) => `<g class="media-shape media-shape-${layout}">${paths[kind]}</g>`).join('')}</g>`).join('') + '</defs>';
|
||||
root.document.body.appendChild(defs);
|
||||
root.MediaIcons = media;
|
||||
}
|
||||
// Select variants on visible use elements: ancestor CSS in referenced SVG groups
|
||||
// can report changed styles without repainting the cloned geometry in Chromium.
|
||||
const transportPaths = {
|
||||
play: '<path d="m8 5 11 7-11 7z"/>',
|
||||
pause: '<rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/>',
|
||||
previous: '<path d="m19 5-10 7 10 7zM5 5v14"/>',
|
||||
next: '<path d="m5 5 10 7-10 7zM19 5v14"/>',
|
||||
forward: '<path d="m3 5 9 7-9 7zM12 5l9 7-9 7z"/>'
|
||||
};
|
||||
for (const seconds of [10,15]) for (const direction of ['back','forward']) {
|
||||
transportPaths[direction + seconds] = (direction === 'back'
|
||||
? '<path d="M4 12a8 8 0 1 0 3-6.2M4 4v4.5h4.5"/>'
|
||||
: '<path d="M20 12a8 8 0 1 1-3-6.2M20 4v4.5h-4.5"/>') +
|
||||
`<text x="12" y="15.5" text-anchor="middle" font-size="7.5" font-weight="700" fill="currentColor" stroke="none">${seconds}</text>`;
|
||||
}
|
||||
const transportVariants = Object.fromEntries(['classic','f7-swipe','glass-stage','bento-hub'].map(layout => [layout,{...transportPaths}]));
|
||||
Object.assign(transportVariants['f7-swipe'], {
|
||||
play: '<path d="M8 5.8a1 1 0 0 1 1.5-.86l10 6.2a1 1 0 0 1 0 1.72l-10 6.2A1 1 0 0 1 8 18.2z"/>',
|
||||
previous: '<rect x="4" y="5" width="3" height="14" rx="1.5"/><path d="M19 6a1 1 0 0 0-1.5-.86l-8.5 6a1 1 0 0 0 0 1.72l8.5 6A1 1 0 0 0 19 18z"/>',
|
||||
next: '<rect x="17" y="5" width="3" height="14" rx="1.5"/><path d="M5 6a1 1 0 0 1 1.5-.86l8.5 6a1 1 0 0 1 0 1.72l-8.5 6A1 1 0 0 1 5 18z"/>'
|
||||
});
|
||||
const transport = {
|
||||
paths: transportPaths, variants: transportVariants,
|
||||
svg(kind) {
|
||||
if (!transportPaths[kind]) throw new Error('Unknown transport icon: ' + kind);
|
||||
return `<svg class="transport-icon" data-transport-icon="${kind}" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">${Object.keys(transportVariants).map(layout=>`<use class="transport-shape transport-shape-${layout}" href="#transport-${kind}${layout==='classic'?'':'-'+layout}"/>`).join('')}</svg>`;
|
||||
},
|
||||
set(button, kind, label) {
|
||||
if (!button) return;
|
||||
const iconId=button.querySelector?.('.transport-icon')?.id;
|
||||
button.innerHTML = this.svg(kind);
|
||||
if(iconId)button.querySelector('.transport-icon').id=iconId;
|
||||
button.setAttribute('aria-label', label || (kind === 'pause' ? 'Pause' : 'Play'));
|
||||
}
|
||||
};
|
||||
if (root.document) {
|
||||
const defs = root.document.createElementNS('http://www.w3.org/2000/svg','svg');
|
||||
defs.setAttribute('aria-hidden','true'); defs.setAttribute('width','0'); defs.setAttribute('height','0');
|
||||
defs.style.position='absolute'; defs.style.pointerEvents='none';
|
||||
defs.innerHTML='<defs>'+Object.keys(transportPaths).flatMap(kind=>Object.entries(transportVariants).map(([layout,paths])=>`<g id="transport-${kind}${layout==='classic'?'':'-'+layout}">${paths[kind]}</g>`)).join('')+'</defs>';
|
||||
root.document.body.appendChild(defs); root.TransportIcons=transport;
|
||||
}
|
||||
const paths = {
|
||||
retry: '<path d="M3 11a9 9 0 1 1 2.6 7M3 3v8h8"/>',
|
||||
cancel: '<path d="m6 6 12 12M6 18 18 6"/>',
|
||||
pause: '<path d="M8 5v14M16 5v14"/>',
|
||||
resume: '<path d="m9 5 10 7-10 7z"/>',
|
||||
};
|
||||
function create(doc, action, title) {
|
||||
if (!paths[action]) throw new Error('Unknown download action');
|
||||
const label = action[0].toUpperCase() + action.slice(1);
|
||||
const button = doc.createElement('button'); button.type = 'button';
|
||||
button.className = `dl-act download-action${action === 'cancel' ? ' danger' : ''}`;
|
||||
button.setAttribute('aria-label', `${label} ${title}`);
|
||||
const icon = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
icon.setAttribute('viewBox', '0 0 24 24'); icon.setAttribute('aria-hidden', 'true'); icon.setAttribute('focusable', 'false');
|
||||
icon.classList.add('download-action-icon'); icon.innerHTML = paths[action];
|
||||
const text = doc.createElement('span'); text.textContent = label;
|
||||
button.append(icon, text); return button;
|
||||
}
|
||||
const api = { create, media, transport };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.DownloadActions = api;
|
||||
})(globalThis);
|
||||
28
frontend/download-errors.js
Normal file
28
frontend/download-errors.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/* Plain-language save failures, with the original diagnostic kept on demand. */
|
||||
(function(root) {
|
||||
'use strict';
|
||||
function message(error) {
|
||||
const raw = String(error?.message || error || '').trim();
|
||||
if (!raw) return 'Could not save this video. Try Retry.';
|
||||
if (/not available in your country|geo.?restricted/i.test(raw)) return 'This video is unavailable in the server’s region. It may still play on your device.';
|
||||
if (/private video|members.only|join this channel|confirm your age|age.restricted/i.test(raw)) return 'YouTube requires access this server does not have to save this video.';
|
||||
if (/removed by|video has been removed|copyright claim/i.test(raw)) return 'YouTube is no longer making this video available to the server.';
|
||||
if (/sign in to confirm|not a bot/i.test(raw)) return 'YouTube temporarily blocked the server’s download request. Try Retry later.';
|
||||
if (/video unavailable/i.test(raw)) return 'YouTube isn’t letting the server save this video. Try Retry; it may still play on YouTube.';
|
||||
if (/requested format (?:is )?not available|no video formats found/i.test(raw)) return 'No downloadable version was available. Try Retry to check other download methods.';
|
||||
if (/^ERROR:|^WARNING:|Traceback|yt-dlp exited|yt-dlp not found/i.test(raw)) return 'The server could not save this video. Try Retry.';
|
||||
// Existing plain-language connection/quota/validation messages remain useful.
|
||||
return raw.length <= 180 && !raw.includes('\n') ? raw : 'Could not save this video. Try Retry.';
|
||||
}
|
||||
function appendDetails(container, error) {
|
||||
if (!error) return;
|
||||
const doc = container.ownerDocument;
|
||||
const details = doc.createElement('details'); details.className = 'download-error-details';
|
||||
const summary = doc.createElement('summary'); summary.textContent = 'Technical details';
|
||||
const pre = doc.createElement('pre'); pre.textContent = String(error?.message || error);
|
||||
details.append(summary, pre); container.append(details);
|
||||
return details;
|
||||
}
|
||||
const api = { message, appendDetails };
|
||||
if (typeof module !== 'undefined') module.exports = api; else root.DownloadErrors = api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
24
frontend/download-errors.test.js
Normal file
24
frontend/download-errors.test.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { message } = require('./download-errors.js');
|
||||
const { formatJobStatus } = require('./downloads-page.js');
|
||||
test('unavailable songs get useful copy without claiming that they were removed', () => {
|
||||
const raw = 'ERROR: [youtube] wZzRoXymOUU: Video unavailable\nYouTube clients tried: default, web_embedded, web_safari';
|
||||
assert.equal(message(raw), 'YouTube isn’t letting the server save this video. Try Retry; it may still play on YouTube.');
|
||||
assert.equal(formatJobStatus({ status: 'failed', error: raw }), 'Failed: ' + message(raw));
|
||||
});
|
||||
test('known restrictions and bot failures have distinct explanations', () => {
|
||||
assert.match(message('Video unavailable. not available in your country'), /server’s region/);
|
||||
assert.match(message('Video unavailable. Private video'), /requires access/);
|
||||
assert.match(message('Video unavailable. Confirm your age'), /requires access/);
|
||||
assert.match(message('Video has been removed'), /no longer/);
|
||||
assert.match(message('Sign in to confirm you are not a bot'), /temporarily blocked/);
|
||||
assert.match(message('Requested format is not available'), /other download methods/);
|
||||
});
|
||||
test('missing and raw tool failures get a short fallback; existing useful messages survive', () => {
|
||||
assert.equal(message(null), 'Could not save this video. Try Retry.');
|
||||
assert.equal(message('ERROR: mysterious extractor failure'), 'The server could not save this video. Try Retry.');
|
||||
assert.equal(message('Connection lost'), 'Connection lost');
|
||||
assert.equal(message('Please wait a few seconds before trying again.'), 'Please wait a few seconds before trying again.');
|
||||
assert.equal(message('a'.repeat(200)), 'Could not save this video. Try Retry.');
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
/* Downloads page UI module. Grouped jobs, storage info, confirmed cancellation. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
const actionButtons = typeof module !== 'undefined' ? require('./download-actions.js') : root.DownloadActions;
|
||||
const errors = typeof module !== 'undefined' ? require('./download-errors.js') : root.DownloadErrors;
|
||||
|
||||
const bytes = n => {
|
||||
if (typeof n !== 'number' || isNaN(n) || n <= 0) return '0 MB';
|
||||
@@ -18,7 +20,7 @@
|
||||
|
||||
function formatJobStatus(job) {
|
||||
if (job.status === 'failed') {
|
||||
return job.error ? `Failed: ${job.error}` : 'Save failed';
|
||||
return job.error ? `Failed: ${errors.message(job.error)}` : 'Save failed';
|
||||
}
|
||||
if (job.status === 'paused') {
|
||||
return 'Paused — resume when you’re ready';
|
||||
@@ -117,11 +119,7 @@
|
||||
empty.className = 'empty-state dl-empty-state';
|
||||
empty.innerHTML = `
|
||||
<div class="empty-icon dl-empty-icon-wrapper" aria-hidden="true">
|
||||
<svg class="dl-empty-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
<svg class="dl-empty-svg media-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-download"/></svg>
|
||||
</div>
|
||||
<h3 class="empty-title">No active downloads</h3>
|
||||
<p class="empty-desc">Saves in progress appear here with live status. Videos you save land in <strong>Saved</strong>.</p>
|
||||
@@ -215,14 +213,13 @@
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (isFailed && job.error) errors.appendDetails(row.querySelector('.card-info'), job.error);
|
||||
|
||||
const acts = doc.createElement('div');
|
||||
acts.className = 'dl-actions';
|
||||
|
||||
if (isActive) {
|
||||
const pauseBtn = doc.createElement('button');
|
||||
pauseBtn.type = 'button';
|
||||
pauseBtn.className = 'dl-act';
|
||||
pauseBtn.textContent = 'Pause';
|
||||
const pauseBtn = actionButtons.create(doc, 'pause', job.title || job.id);
|
||||
pauseBtn.setAttribute('aria-label', `Pause ${job.title || job.id}`);
|
||||
pauseBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -231,10 +228,7 @@
|
||||
};
|
||||
acts.appendChild(pauseBtn);
|
||||
} else if (isPaused) {
|
||||
const resumeBtn = doc.createElement('button');
|
||||
resumeBtn.type = 'button';
|
||||
resumeBtn.className = 'dl-act';
|
||||
resumeBtn.textContent = 'Resume';
|
||||
const resumeBtn = actionButtons.create(doc, 'resume', job.title || job.id);
|
||||
resumeBtn.setAttribute('aria-label', `Resume ${job.title || job.id}`);
|
||||
resumeBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -243,10 +237,7 @@
|
||||
};
|
||||
acts.appendChild(resumeBtn);
|
||||
} else if (isFailed) {
|
||||
const retryBtn = doc.createElement('button');
|
||||
retryBtn.type = 'button';
|
||||
retryBtn.className = 'dl-act';
|
||||
retryBtn.textContent = 'Retry';
|
||||
const retryBtn = actionButtons.create(doc, 'retry', job.title || job.id);
|
||||
retryBtn.setAttribute('aria-label', `Retry ${job.title || job.id}`);
|
||||
retryBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -256,10 +247,7 @@
|
||||
acts.appendChild(retryBtn);
|
||||
}
|
||||
|
||||
const cancelBtn = doc.createElement('button');
|
||||
cancelBtn.type = 'button';
|
||||
cancelBtn.className = 'dl-act danger';
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
const cancelBtn = actionButtons.create(doc, 'cancel', job.title || job.id);
|
||||
cancelBtn.setAttribute('aria-label', `Cancel ${job.title || job.id}`);
|
||||
cancelBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/* Download controls and device storage accounting. */
|
||||
(function(root) {
|
||||
'use strict';
|
||||
const actionButtons = typeof module !== 'undefined' ? require('./download-actions.js') : root.DownloadActions;
|
||||
const errors = typeof module !== 'undefined' ? require('./download-errors.js') : root.DownloadErrors;
|
||||
const bytes = n => n >= 1073741824 ? `${(n / 1073741824).toFixed(1)} GB` : `${(n / 1048576).toFixed(1)} MB`;
|
||||
function breakdown(files, playlists) {
|
||||
const kinds = { video: 0, audio: 0, eq: 0 }, byId = new Map();
|
||||
@@ -10,7 +12,7 @@
|
||||
let adapter, total = 0;
|
||||
function configure(options) {
|
||||
adapter = options;
|
||||
root.SettingsSections.register({ id: 'downloads-storage', title: 'Downloads & storage', cluster: 'Library & storage', description: 'Active queued paused failed saves, disk usage and playlist sizes', icon: '<path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4"/>', summary: () => `${adapter.slots()} at once · ${bytes(total)}`, render(container) {
|
||||
root.SettingsSections.register({ id: 'downloads-storage', title: 'Downloads & storage', cluster: 'Library & storage', description: 'Active queued paused failed saves, disk usage and playlist sizes', icon: '<use href="#media-download"/>', summary: () => `${adapter.slots()} at once · ${bytes(total)}`, render(container) {
|
||||
const doc = container.ownerDocument;
|
||||
const heading = doc.createElement('h3'); heading.textContent = 'Downloads & storage';
|
||||
const jobs = doc.createElement('div'); jobs.className = 'download-manager'; jobs.setAttribute('aria-label', 'Download manager');
|
||||
@@ -24,13 +26,15 @@
|
||||
for (const job of items) {
|
||||
const row = doc.createElement('div'); row.className = 'download-job';
|
||||
const title = doc.createElement('b'); title.textContent = job.title || job.id;
|
||||
const status = doc.createElement('small'); status.textContent = job.error || job.status;
|
||||
const status = doc.createElement('small'); status.textContent = job.error ? errors.message(job.error) : job.status;
|
||||
const progress = doc.createElement('progress'); progress.max = job.progress?.total || 1; if (job.progress?.total) progress.value = job.progress.received || 0; progress.setAttribute('aria-label', `Saving ${job.title || job.id}`);
|
||||
const actions = doc.createElement('div');
|
||||
for (const action of [...(['active', 'queued'].includes(job.status) ? ['pause'] : [job.status === 'failed' ? 'retry' : 'resume']), 'cancel']) {
|
||||
const button = doc.createElement('button'); button.type = 'button'; button.textContent = action[0].toUpperCase() + action.slice(1); button.setAttribute('aria-label', `${button.textContent} ${job.title || job.id}`); button.onclick = () => { adapter.action(job.id, action); signature = ''; paint(); }; actions.append(button);
|
||||
const button = actionButtons.create(doc, action, job.title || job.id); button.onclick = () => { adapter.action(job.id, action); signature = ''; paint(); }; actions.append(button);
|
||||
}
|
||||
row.append(title, status, progress, actions); jobs.append(row);
|
||||
row.append(title, status, progress, actions);
|
||||
if (job.error) errors.appendDetails(row, job.error);
|
||||
jobs.append(row);
|
||||
}
|
||||
}
|
||||
async function disk() {
|
||||
|
||||
@@ -1,24 +1,7 @@
|
||||
/* Shared live/offline EQ definitions and legacy curve migration. */
|
||||
(function(root) {
|
||||
'use strict';
|
||||
const frequencies = [31,62,125,250,500,1000,2000,4000,8000,16000];
|
||||
const bands = frequencies.map((f,i) => ({ f, label: f >= 1000 ? `${f/1000}k` : String(f), type: i === 0 ? 'lowshelf' : i === 9 ? 'highshelf' : 'peaking' }));
|
||||
const clamp = n => Math.max(-12, Math.min(12, Number(n) || 0));
|
||||
function migrate(values) {
|
||||
if (!Array.isArray(values)) return frequencies.map(() => 0);
|
||||
if (values.length !== 5) return frequencies.map((_,i) => clamp(values[i]));
|
||||
const old = [60,250,1000,4000,12000], curve = values.map(clamp);
|
||||
return frequencies.map(f => { if (f <= old[0]) return curve[0]; if (f >= old[4]) return curve[4]; const i = old.findIndex(x => x >= f), weight = Math.log(f / old[i-1]) / Math.log(old[i] / old[i-1]); return Math.round((curve[i-1] * (1-weight) + curve[i] * weight) * 10) / 10; });
|
||||
}
|
||||
const presets = {
|
||||
flat: { name: 'Flat', g: frequencies.map(() => 0) },
|
||||
bass: { name: 'Bass boost', g: [5,7,5,3,1,0,0,0,0,0] },
|
||||
vocal: { name: 'Vocal clarity', g: [-3,-4,-2,0,1,2,3,4,1,0] },
|
||||
acoustic: { name: 'Acoustic live', g: [-2,-1,0,-2,-1,1,2,2,1,0] },
|
||||
classical: { name: 'Classical', g: migrate([4,2,-1,1,3]) },
|
||||
treble: { name: 'Treble boost', g: [0,0,0,0,0,0,1,3,5,6] },
|
||||
};
|
||||
const width = value => Math.max(0, Math.min(100, Number(value) || 0));
|
||||
const { bands, presets, migrate, width } = typeof module !== 'undefined' ? require('./eq-parameters') : root.EqParameters;
|
||||
function createWidener(context, source, value = 0) {
|
||||
const splitter = context.createChannelSplitter(2), output = context.createChannelMerger(2), delay = context.createDelay(.05);
|
||||
source.connect(splitter); splitter.connect(output, 0, 0); splitter.connect(delay, 1); delay.connect(output, 0, 1);
|
||||
|
||||
25
frontend/eq-parameters.js
Normal file
25
frontend/eq-parameters.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/* Eager settings values; audio graphs remain in lazy feature groups. */
|
||||
(function(root) {
|
||||
'use strict';
|
||||
const frequencies = [31,62,125,250,500,1000,2000,4000,8000,16000];
|
||||
const bands = frequencies.map((f,i) => ({ f, label: f >= 1000 ? `${f/1000}k` : String(f), type: i === 0 ? 'lowshelf' : i === 9 ? 'highshelf' : 'peaking' }));
|
||||
const clamp = n => Math.max(-12, Math.min(12, Number(n) || 0));
|
||||
function migrate(values) {
|
||||
if (!Array.isArray(values)) return frequencies.map(() => 0);
|
||||
if (values.length !== 5) return frequencies.map((_,i) => clamp(values[i]));
|
||||
const old = [60,250,1000,4000,12000], curve = values.map(clamp);
|
||||
return frequencies.map(f => { if (f <= old[0]) return curve[0]; if (f >= old[4]) return curve[4]; const i = old.findIndex(x => x >= f), weight = Math.log(f / old[i-1]) / Math.log(old[i] / old[i-1]); return Math.round((curve[i-1] * (1-weight) + curve[i] * weight) * 10) / 10; });
|
||||
}
|
||||
const presets = {
|
||||
flat: { name: 'Flat', g: frequencies.map(() => 0) },
|
||||
bass: { name: 'Bass boost', g: [5,7,5,3,1,0,0,0,0,0] },
|
||||
vocal: { name: 'Vocal clarity', g: [-3,-4,-2,0,1,2,3,4,1,0] },
|
||||
acoustic: { name: 'Acoustic live', g: [-2,-1,0,-2,-1,1,2,2,1,0] },
|
||||
classical: { name: 'Classical', g: migrate([4,2,-1,1,3]) },
|
||||
treble: { name: 'Treble boost', g: [0,0,0,0,0,0,1,3,5,6] },
|
||||
};
|
||||
const width = value => Math.max(0, Math.min(100, Number(value) || 0));
|
||||
const amount = value => Math.max(0, Math.min(100, Number.isFinite(Number(value)) ? Number(value) : 100));
|
||||
const api = { bands, presets, migrate, width, amount };
|
||||
if (typeof module !== 'undefined') module.exports = api; else root.EqParameters = api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
244
frontend/f7-layout.js
Normal file
244
frontend/f7-layout.js
Normal file
@@ -0,0 +1,244 @@
|
||||
/* Framework7 Swipe layout: the classic design with Framework7 swipe gestures.
|
||||
* Cards become F7 swipeouts, swiping up on the mini bar opens an F7 "Up next"
|
||||
* sheet, and the mini bar / screen edge take horizontal swipes. Framework7 is
|
||||
* loaded only when this layout is first selected. */
|
||||
(function(root){
|
||||
'use strict';
|
||||
const LAYOUT='f7-swipe',F7_SRC='vendor/framework7-swipe.min.js';
|
||||
|
||||
// Which swipe actions a card gets in a given view. Pure, so it is testable.
|
||||
function cardActions(viewType,canDelete){
|
||||
const left=[{act:'queue',label:'Queue'}];
|
||||
const right=[{act:'playlist',label:'Playlist'}];
|
||||
if(canDelete)right.push({act:'remove',label:'Remove',danger:true});
|
||||
return {left,right};
|
||||
}
|
||||
// Classify a finished drag. Returns 'left'|'right'|'up'|'down'|null.
|
||||
function swipeDirection(dx,dy,min){
|
||||
min=min||48;const ax=Math.abs(dx),ay=Math.abs(dy);
|
||||
if(Math.max(ax,ay)<min)return null;
|
||||
if(ax>ay*1.4)return dx<0?'left':'right';
|
||||
if(ay>ax*1.4)return dy<0?'up':'down';
|
||||
return null;
|
||||
}
|
||||
function tabSwipeIndex(active,count,direction){
|
||||
if(direction!=='left'&&direction!=='right'||count<1)return -1;
|
||||
return Math.max(0,Math.min(count-1,active+(direction==='left'?1:-1)));
|
||||
}
|
||||
|
||||
function mount(doc){
|
||||
const html=doc.documentElement;let app=null,loading=null,active=false,sheet=null,cardsObserver=null;
|
||||
const on=()=>html.dataset.layout===LAYOUT;
|
||||
// app.js top-level `let` bindings are shared across classic scripts but are not on window.
|
||||
/* global view, queue, queueIndex, queueSource */
|
||||
const g=(name)=>{try{switch(name){case 'view':return view;case 'queue':return queue;case 'queueIndex':return queueIndex;case 'queueSource':return queueSource;}}catch(_){}return undefined;};
|
||||
|
||||
function loadF7(){
|
||||
if(root.Framework7)return Promise.resolve(root.Framework7);
|
||||
if(!loading)loading=new Promise((res,rej)=>{const s=doc.createElement('script');s.src=root.Lazy?.url('/vendor/framework7-swipe.min.js')||F7_SRC;s.onload=()=>res(root.Framework7);s.onerror=()=>{loading=null;rej(new Error('Framework7 failed to load'));};doc.head.append(s);});
|
||||
return loading;
|
||||
}
|
||||
function ensureApp(){
|
||||
return loadF7().then((F7)=>{
|
||||
if(!app){
|
||||
app=new F7({el:'body',theme:'ios',touch:{activeState:false,touchRipple:false,touchHighlight:false,tapHold:false},swipeout:{noFollow:false,removeElements:false},sheet:{backdrop:true,closeByBackdropClick:true}});
|
||||
// F7's touch module swallows any click whose target isn't the touched element and
|
||||
// cancels the click after a moved touch. app.js relies on programmatic .click()
|
||||
// calls, and F7 can't be torn down, so drop those two app-wide handlers.
|
||||
app.off('click');app.off('touchend');
|
||||
}
|
||||
return app;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Swipeout cards ----
|
||||
// Framework7 only animates <a> children of the actions box, so these are links acting as buttons.
|
||||
function actionBtn(a){const b=doc.createElement('a');b.setAttribute('role','button');b.tabIndex=0;b.className='swipeout-action f7s-act'+(a.danger?' f7s-danger':'');b.dataset.act=a.act;b.textContent=a.label;return b;}
|
||||
function closeRow(li){
|
||||
if(app){try{app.swipeout.close(li);return;}catch{}}
|
||||
li.classList.remove('f7s-touch-open','swipeout-opened');li.querySelector('.swipeout-content')?.style.removeProperty('transform');
|
||||
li.querySelectorAll('.swipeout-actions-opened').forEach(el=>el.classList.remove('swipeout-actions-opened'));
|
||||
li.querySelectorAll('.f7s-act').forEach(el=>el.style.removeProperty('transform'));
|
||||
}
|
||||
function openRow(li,side){
|
||||
const box=li.querySelector(`.swipeout-actions-${side}`);if(!box)return;
|
||||
doc.querySelectorAll('li.f7s-row.f7s-touch-open').forEach(other=>{if(other!==li)closeRow(other);});
|
||||
if(app){try{app.swipeout.open(li,side);if(li.classList.contains('swipeout-opened'))return;}catch{}}
|
||||
const width=[...box.children].reduce((sum,el)=>sum+el.offsetWidth,0)+16;
|
||||
li.classList.add('f7s-touch-open','swipeout-opened');box.classList.add('swipeout-actions-opened');
|
||||
li.querySelector('.swipeout-content').style.transform=`translate3d(${side==='right'?-width:width}px,0,0)`;
|
||||
box.querySelectorAll('.f7s-act').forEach(el=>el.style.transform='scale(1)');
|
||||
}
|
||||
function wrapCard(card){
|
||||
if(!card.classList||!card.classList.contains('card')||card.closest('li.swipeout')||card.classList.contains('skeleton-card')||!card._video)return;
|
||||
const li=doc.createElement('li');li.className='swipeout f7s-row';
|
||||
card.replaceWith(li);card.classList.add('swipeout-content');li.append(card);
|
||||
const acts=cardActions(g('view')?.type,!!card.querySelector('.card-del'));
|
||||
for(const side of ['left','right']){
|
||||
if(!acts[side].length)continue;
|
||||
const box=doc.createElement('div');box.className='swipeout-actions-'+side;acts[side].forEach(a=>box.append(actionBtn(a)));li.append(box);
|
||||
}
|
||||
}
|
||||
function unwrapAll(){
|
||||
doc.querySelectorAll('li.f7s-row').forEach(li=>{const c=li.querySelector('.swipeout-content');if(c){c.classList.remove('swipeout-content');c.style.transform='';li.replaceWith(c);}else li.remove();});
|
||||
}
|
||||
function wrapAll(){doc.querySelectorAll('#cards .card').forEach(wrapCard);}
|
||||
function onAction(e){
|
||||
const btn=e.target.closest('.f7s-act');if(!btn)return;
|
||||
const li=btn.closest('li.swipeout'),card=li&&li.querySelector('.card'),v=card&&card._video;
|
||||
e.stopPropagation();closeRow(li);if(!v)return;
|
||||
if(btn.dataset.act==='queue')root.addToQueue&&root.addToQueue(v);
|
||||
else if(btn.dataset.act==='playlist')root.openCardMenu&&root.openCardMenu(v);
|
||||
else if(btn.dataset.act==='remove'){const del=card.querySelector('.card-del');if(del)del.click();}
|
||||
}
|
||||
|
||||
// ---- Up next sheet ----
|
||||
function upcoming(){const q=g('queue')||[],i=g('queueIndex');return q.slice((typeof i==='number'?i:-1)+1);}
|
||||
function renderSheet(){
|
||||
const list=sheet.el.querySelector('.f7s-sheet-list'),items=upcoming();
|
||||
list.innerHTML='';
|
||||
if(!items.length){const p=doc.createElement('p');p.className='f7s-empty';p.textContent='Nothing queued after this song.';list.append(p);return;}
|
||||
items.forEach((v,k)=>{
|
||||
const li=doc.createElement('li');li.className='f7s-up';
|
||||
li.innerHTML='<div class="f7s-up-info"><div class="f7s-up-title"></div><div class="f7s-up-ch"></div></div>';
|
||||
if(v.thumbnail){const img=doc.createElement('img');img.alt='';img.loading='lazy';img.decoding='async';img.src=v.thumbnail;li.prepend(img);}
|
||||
li.querySelector('.f7s-up-title').textContent=v.title||'';li.querySelector('.f7s-up-ch').textContent=v.channel||'';
|
||||
li.addEventListener('click',()=>{const q=g('queue')||[],i=g('queueIndex');sheet.close();root.playFromList&&root.playFromList(q,(typeof i==='number'?i:-1)+1+k,g('queueSource'));});
|
||||
list.append(li);
|
||||
});
|
||||
}
|
||||
function openSheet(){
|
||||
ensureApp().then((a)=>{
|
||||
if(!sheet){
|
||||
const el=doc.createElement('div');el.className='sheet-modal f7s-sheet';
|
||||
el.innerHTML='<div class="sheet-modal-inner"><div class="f7s-grab" aria-hidden="true"></div><div class="f7s-sheet-head"><span>Up next</span></div><ul class="f7s-sheet-list"></ul></div>';
|
||||
doc.body.append(el);
|
||||
sheet=a.sheet.create({el,swipeToClose:true,breakpoints:[0.5],backdropBreakpoint:0.5,backdrop:true,closeByBackdropClick:true,swipeHandler:'.f7s-grab, .f7s-sheet-head'});
|
||||
}
|
||||
renderSheet();sheet.open();
|
||||
}).catch(()=>{});
|
||||
}
|
||||
function closeSheet(){
|
||||
const modal=sheet&&sheet.el&&sheet.el.f7Modal;if(!modal)return;
|
||||
sheet.el.classList.remove('f7s-full');modal.setBreakpoint(0);
|
||||
// F7 emits its initial `opened` transition after the sheet animation. If a
|
||||
// very fast drag reaches the handle during that transition, repeat close
|
||||
// after that event so its initial breakpoint cannot reopen the sheet.
|
||||
setTimeout(()=>{if(modal.opened)modal.setBreakpoint(0);},360);
|
||||
}
|
||||
|
||||
// ---- Mini bar + edge swipes (F7 has no component for these) ----
|
||||
let drag=null,swallowClick=false;
|
||||
function down(e){
|
||||
if(!on()||e.pointerType==='touch'||e.pointerType==='mouse'&&e.button!==0)return;
|
||||
const mini=e.target.closest&&e.target.closest('#miniBar');
|
||||
const sidebarOpen=!!doc.querySelector('.app.sidebar-open');
|
||||
const edge=!mini&&e.clientX<22&&!e.target.closest('.player-stage');
|
||||
const inSidebar=sidebarOpen&&e.target.closest&&e.target.closest('#sidebar, #sidebarBackdrop');
|
||||
const inRow=!!(e.target.closest&&e.target.closest('.f7s-row, .card, input, textarea, select, button, a, [role="slider"], #seek, #volume'));
|
||||
const inPlayer=e.target.closest&&e.target.closest('#playerPane:not(.empty)');
|
||||
const inBody=e.target.closest&&e.target.closest('.body');
|
||||
if(!mini&&!edge&&!inSidebar&&(!inBody||inRow)&&!(inPlayer&&!inRow))return;
|
||||
const kind=mini?'mini':edge?'edge':inSidebar?'sidebar':inPlayer?'player':'tabs';
|
||||
drag={x:e.clientX,y:e.clientY,kind};
|
||||
}
|
||||
function up(e){
|
||||
if(!drag)return;const d=drag;drag=null;
|
||||
const dir=swipeDirection(e.clientX-d.x,e.clientY-d.y,d.kind==='mini'?36:56);if(!dir)return;
|
||||
if(d.kind==='mini'){
|
||||
swallowClick=true;setTimeout(()=>{swallowClick=false;},400);
|
||||
if(dir==='left')root.playNext&&root.playNext();
|
||||
else if(dir==='right')root.playPrev&&root.playPrev();
|
||||
else if(dir==='up')openSheet();
|
||||
}else if(d.kind==='sheet'){
|
||||
if(dir==='up'&&sheet){sheet.el.f7Modal.setBreakpoint(1);sheet.el.classList.add('f7s-full');}
|
||||
else if(dir==='down')closeSheet();
|
||||
}else if(d.kind==='edge'&&dir==='right')root.openSidebar&&root.openSidebar();
|
||||
else if(d.kind==='sidebar'&&dir==='left')root.closeSidebar&&root.closeSidebar();
|
||||
else if(d.kind==='tabs'&&(dir==='left'||dir==='right')){
|
||||
const tabs=[...doc.querySelectorAll('#bottomNav .bottom-nav-btn')].filter(b=>!b.disabled&&!b.hidden);
|
||||
const active=Math.max(0,tabs.findIndex(b=>b.classList.contains('active'))),next=tabSwipeIndex(active,tabs.length,dir);
|
||||
if(tabs[next]&&next!==active){swallowClick=true;setTimeout(()=>{swallowClick=false;},400);tabs[next].click();}
|
||||
}else if(d.kind==='player'&&dir==='down'&&innerWidth<=860&&!doc.fullscreenElement&&!doc.querySelector('.orientation-fullscreen')){
|
||||
const search=doc.querySelector('#bottomNav .bottom-nav-btn[data-view="search"]');if(search)search.click();
|
||||
}
|
||||
}
|
||||
function click(e){if(swallowClick&&e.target.closest&&e.target.closest('#miniBar')){e.stopPropagation();e.preventDefault();swallowClick=false;}}
|
||||
let touchDrag=null,rowDrag=null;
|
||||
function touchDown(e){
|
||||
if(!on()||!e.changedTouches.length)return;
|
||||
const t=e.changedTouches[0],target=e.target,mini=target.closest&&target.closest('#miniBar');
|
||||
if(target.closest&&target.closest('.f7s-grab, .f7s-sheet-head')){touchDrag={x:t.clientX,y:t.clientY,kind:'sheet'};rowDrag=null;return;}
|
||||
const row=target.closest&&target.closest('li.f7s-row');
|
||||
if(row){rowDrag={row,x:t.clientX,y:t.clientY};touchDrag=null;return;}
|
||||
const sidebarOpen=!!doc.querySelector('.app.sidebar-open'),edge=!mini&&t.clientX<22&&!target.closest('.player-stage');
|
||||
const inSidebar=sidebarOpen&&target.closest&&target.closest('#sidebar, #sidebarBackdrop');
|
||||
const inRow=!!(target.closest&&target.closest('.f7s-row, .card, input, textarea, select, button, a, [role="slider"], #seek, #volume'));
|
||||
const inPlayer=target.closest&&target.closest('#playerPane:not(.empty)'),inBody=target.closest&&target.closest('.body');
|
||||
if(!mini&&!edge&&!inSidebar&&(!inBody||inRow)&&!(inPlayer&&!inRow))return;
|
||||
touchDrag={x:t.clientX,y:t.clientY,kind:mini?'mini':edge?'edge':inSidebar?'sidebar':inPlayer?'player':'tabs'};
|
||||
}
|
||||
function finish(d,x,y){
|
||||
if(!d)return;const dir=swipeDirection(x-d.x,y-d.y,d.kind==='mini'?36:56);if(!dir)return;
|
||||
if(d.kind==='mini'){
|
||||
swallowClick=true;setTimeout(()=>{swallowClick=false;},400);
|
||||
if(dir==='left')root.playNext&&root.playNext();else if(dir==='right')root.playPrev&&root.playPrev();else if(dir==='up')openSheet();
|
||||
}else if(d.kind==='sheet'){
|
||||
if(dir==='up'&&sheet){sheet.el.f7Modal.setBreakpoint(1);sheet.el.classList.add('f7s-full');}
|
||||
else if(dir==='down')closeSheet();
|
||||
}else if(d.kind==='edge'&&dir==='right')root.openSidebar&&root.openSidebar();
|
||||
else if(d.kind==='sidebar'&&dir==='left')root.closeSidebar&&root.closeSidebar();
|
||||
else if(d.kind==='tabs'&&(dir==='left'||dir==='right')){
|
||||
const tabs=[...doc.querySelectorAll('#bottomNav .bottom-nav-btn')].filter(b=>!b.disabled&&!b.hidden);
|
||||
const active=Math.max(0,tabs.findIndex(b=>b.classList.contains('active'))),next=tabSwipeIndex(active,tabs.length,dir);
|
||||
if(tabs[next]&&next!==active){swallowClick=true;setTimeout(()=>{swallowClick=false;},400);tabs[next].click();}
|
||||
}else if(d.kind==='player'&&dir==='down'&&innerWidth<=860&&!doc.fullscreenElement&&!doc.querySelector('.orientation-fullscreen')){
|
||||
const search=doc.querySelector('#bottomNav .bottom-nav-btn[data-view="search"]');if(search)search.click();
|
||||
}
|
||||
}
|
||||
function touchUp(e){
|
||||
if(!e.changedTouches.length)return;const t=e.changedTouches[0];
|
||||
if(rowDrag){const d=rowDrag;rowDrag=null;const dx=t.clientX-d.x,dy=t.clientY-d.y;
|
||||
if(Math.abs(dx)>Math.max(56,Math.abs(dy)*1.4)){
|
||||
const card=d.row.querySelector('.card');
|
||||
// A long swipe left removes once; F7's own overswipe is not relied on, so this is the only path.
|
||||
if(dx< -170){const remove=card&&card.querySelector('.card-del');if(remove)remove.click();}
|
||||
else if(dx<0)openRow(d.row,'right');
|
||||
else if(d.row.querySelector('.swipeout-actions-left'))openRow(d.row,'left');
|
||||
else {const v=card&&card._video;v&&root.addToQueue&&root.addToQueue(v);}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(!touchDrag)return;const d=touchDrag;touchDrag=null;finish(d,t.clientX,t.clientY);
|
||||
}
|
||||
|
||||
function activate(){
|
||||
active=true;
|
||||
ensureApp().then(()=>{if(!active)return;wrapAll();}).catch(()=>{});
|
||||
const cards=doc.getElementById('cards');
|
||||
if(cards){cardsObserver=new root.MutationObserver(()=>{if(app)wrapAll();});cardsObserver.observe(cards,{childList:true});}
|
||||
doc.addEventListener('click',onAction,true);
|
||||
}
|
||||
function deactivate(){
|
||||
active=false;if(cardsObserver){cardsObserver.disconnect();cardsObserver=null;}
|
||||
doc.removeEventListener('click',onAction,true);
|
||||
if(sheet&&sheet.opened)sheet.close(false);
|
||||
unwrapAll();
|
||||
}
|
||||
function sync(){if(on()&&!active)activate();else if(!on()&&active)deactivate();}
|
||||
doc.addEventListener('pointerdown',down,{passive:true});
|
||||
doc.addEventListener('pointerup',up,{passive:true});
|
||||
doc.addEventListener('pointercancel',()=>{drag=null;},{passive:true});
|
||||
doc.addEventListener('touchstart',touchDown,{passive:true,capture:true});
|
||||
doc.addEventListener('touchend',touchUp,{passive:true,capture:true});
|
||||
doc.addEventListener('touchcancel',()=>{touchDrag=null;rowDrag=null;},{passive:true,capture:true});
|
||||
doc.addEventListener('click',click,true);
|
||||
doc.addEventListener('click',e=>{if(e.target.classList?.contains('sheet-backdrop'))closeSheet();},true);
|
||||
new root.MutationObserver(sync).observe(html,{attributes:true,attributeFilter:['data-layout']});
|
||||
sync();
|
||||
}
|
||||
|
||||
const api={cardActions,swipeDirection,tabSwipeIndex};
|
||||
if(typeof module!=='undefined')module.exports=api;
|
||||
else {root.F7Swipe=api; if(root.document.readyState==='loading')root.document.addEventListener('DOMContentLoaded',()=>mount(root.document),{once:true});else mount(root.document);}
|
||||
})(typeof window==='undefined'?globalThis:window);
|
||||
27
frontend/f7-layout.test.js
Normal file
27
frontend/f7-layout.test.js
Normal file
@@ -0,0 +1,27 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { cardActions, swipeDirection, tabSwipeIndex } = require('./f7-layout');
|
||||
|
||||
test('cardActions: every view gets Queue, removable rows get Playlist and Remove', () => {
|
||||
assert.deepStrictEqual(cardActions('queue', true).left.map((a) => a.act), ['queue']);
|
||||
assert.deepStrictEqual(cardActions('queue', true).right.map((a) => a.act), ['playlist', 'remove']);
|
||||
assert.deepStrictEqual(cardActions('search', false).left.map((a) => a.act), ['queue']);
|
||||
assert.deepStrictEqual(cardActions('search', false).right.map((a) => a.act), ['playlist']);
|
||||
});
|
||||
|
||||
test('swipeDirection classifies clear swipes and ignores short or diagonal drags', () => {
|
||||
assert.strictEqual(swipeDirection(-80, 5), 'left');
|
||||
assert.strictEqual(swipeDirection(80, -10), 'right');
|
||||
assert.strictEqual(swipeDirection(4, -70), 'up');
|
||||
assert.strictEqual(swipeDirection(10, 20), null);
|
||||
assert.strictEqual(swipeDirection(60, 60), null);
|
||||
assert.strictEqual(swipeDirection(40, 0, 36), 'right');
|
||||
});
|
||||
|
||||
test('tabSwipeIndex moves between bottom tabs and clamps at either end', () => {
|
||||
assert.strictEqual(tabSwipeIndex(0, 5, 'left'), 1);
|
||||
assert.strictEqual(tabSwipeIndex(3, 5, 'right'), 2);
|
||||
assert.strictEqual(tabSwipeIndex(4, 5, 'left'), 4);
|
||||
assert.strictEqual(tabSwipeIndex(0, 5, 'right'), 0);
|
||||
assert.strictEqual(tabSwipeIndex(2, 5, 'up'), -1);
|
||||
});
|
||||
77
frontend/feature-bootstrap.js
Normal file
77
frontend/feature-bootstrap.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/* Thin eager entries and configuration for optional, stateful feature modules. */
|
||||
(function(root) {
|
||||
'use strict';
|
||||
const Lazy = root.Lazy, doc = root.document;
|
||||
if (!Lazy) return;
|
||||
const specs = [
|
||||
['Piano','piano','configure','piano','Piano transcription','Live & worship','Off · load model on request'],
|
||||
['MidiPedals','midi','configure','midi','MIDI pedalboard','Live & worship','Off'],
|
||||
['PartyDJ','party-dj','register','party-dj','Watch party','Live & worship','Shared suggestions · host approval'],
|
||||
['LyricsWindow','lyrics-window','configure','floating-lyrics','Floating lyrics','Live & worship','Floating lyrics window'],
|
||||
['LowerThird','lower-third','register','broadcast','Presenter & broadcast','Live & worship','Projector · OBS lower third'],
|
||||
['EqCore','eq-core','register','equalizer','Equalizer & stereo','Listening','Flat · Original stereo'],
|
||||
['VocalReducer','vocal-reducer','register','vocal-reducer','Vocal reducer','Listening','100% · centre-panned vocals'],
|
||||
['SetlistImport','setlist-import','configure','setlist-import','Setlist import','Library & storage','Planning Center · SongSelect · ChordPro'],
|
||||
['CarMode','car-mode','configure','voice-car','Voice & Car mode','Listening','Voice search · Car mode'],
|
||||
['DirectMedia','direct','configure','direct-transfer','Direct device transfer','Library & storage','Paired devices only'],
|
||||
];
|
||||
function button(id, text, anchor, name, action, className='np-btn') {
|
||||
if (!anchor || doc.getElementById(id)) return;
|
||||
const b=doc.createElement('button'); b.id=id; b.type='button'; b.className=className; b.textContent=text; b.setAttribute('aria-label',text);
|
||||
if(id==='carModeBtn')b.innerHTML='<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="m5 8 2-5h10l2 5M3 8h18v10H3zM5 18v3m14-3v3M6 12h2m8 0h2"/></svg>';
|
||||
if(id==='voiceSearchBtn')b.innerHTML='<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3m-4 0h8"/></svg>';
|
||||
if(id.startsWith('floating-')){b.title='Open floating lyrics';b.setAttribute('aria-label','Open floating lyrics window');}
|
||||
b.title ||= text;
|
||||
b.onclick=()=>Lazy.load(name).then(action).catch(error=>root.dispatchEvent(new CustomEvent('ytp-lazy-error',{detail:{error}})));
|
||||
anchor.after(b); Lazy.intent(b,name);
|
||||
}
|
||||
for (const [globalName, suffix, setup, id, title, cluster, summary] of specs) {
|
||||
const group='feature:'+suffix;
|
||||
const facade=Lazy.proxy(group,['open','close','obtain','publish','attach','handle','offer','paint','fade'],globalName);
|
||||
let configured=false;
|
||||
facade[setup]=options=>{
|
||||
if (configured) return; configured=true;
|
||||
Lazy.onLoad(group,()=>root[globalName][setup](options));
|
||||
root.SettingsSections.register({id,title,cluster,lazyGroup:group,summary:()=>{
|
||||
const s=options?.settings?.() || {};
|
||||
if(globalName==='Piano')return s.pianoEnabled?'On · browser or optional worker':'Off · load model on request';
|
||||
if(globalName==='MidiPedals')return s.midiEnabled?'On · pedal mappings':'Off';
|
||||
if(globalName==='EqCore')return `${root.EqParameters.presets[s.eq?.preset]?.name || 'Custom'} · ${root.EqParameters.width(s.stereoWidth)?'Widened':'Original stereo'}`;
|
||||
if(globalName==='VocalReducer')return `${root.EqParameters.amount(s.vocalLevel ?? 100)}% · centre-panned vocals`;
|
||||
if(globalName==='DirectMedia')return facade.enabled()?'On · paired devices only':'Off or unsupported';
|
||||
if(globalName==='CarMode')return root.SpeechRecognition||root.webkitSpeechRecognition?'Voice search available · Car mode':'Car mode · voice unavailable';
|
||||
if(globalName==='LyricsWindow')return root.documentPictureInPicture?.requestWindow?'Interactive window available':root.HTMLCanvasElement?.prototype.captureStream&&doc.pictureInPictureEnabled?'Lyric video PiP available':'Not supported on this browser';
|
||||
return summary;
|
||||
},
|
||||
render(container,context) {
|
||||
let disposed=false, cleanup;
|
||||
container.textContent='Loading…';
|
||||
Lazy.load(group).then(()=>{
|
||||
if (disposed || !container.isConnected) return;
|
||||
container.replaceChildren(); cleanup=root.SettingsSections.get(id).render(container,context);
|
||||
}).catch(error=>{if(!disposed)container.textContent=error.message;});
|
||||
return ()=>{disposed=true; if(typeof cleanup==='function')cleanup();};
|
||||
},
|
||||
});
|
||||
if (globalName==='CarMode') {
|
||||
button('carModeBtn','Car mode',doc.getElementById('searchForm'),group,()=>root.CarMode.open(),'icon-btn');
|
||||
if(root.SpeechRecognition||root.webkitSpeechRecognition)button('voiceSearchBtn','Voice search',options.input,group,()=>doc.getElementById('voiceSearchBtn').click(),'icon-btn voice-search');
|
||||
}
|
||||
if (globalName==='LyricsWindow' && (root.documentPictureInPicture?.requestWindow || (root.HTMLCanvasElement?.prototype.captureStream && doc.pictureInPictureEnabled))) {
|
||||
for(const anchor of ['stageLyricsAuto','serviceAutoscrollBtn','pipBtn'])button('floating-'+anchor,'⧉ Lyrics',doc.getElementById(anchor),group,()=>doc.getElementById('floating-'+anchor).click());
|
||||
}
|
||||
};
|
||||
if(globalName==='LowerThird') {
|
||||
facade.active=()=>new URLSearchParams(root.location.search).get('overlay')==='lowerthird';
|
||||
facade.url=(origin,code)=>{const u=new URL('/',origin);u.searchParams.set('overlay','lowerthird');u.searchParams.set('code',String(code));return u.href;};
|
||||
}
|
||||
if(globalName==='DirectMedia') {
|
||||
let settings=()=>({}); const configure=facade.configure;
|
||||
facade.configure=options=>{settings=options.settings;configure(options);};
|
||||
facade.enabled=()=>settings().directTransfer!==false && !!root.RTCPeerConnection;
|
||||
facade.hasSource=()=>false;
|
||||
}
|
||||
if(globalName==='LyricsWindow')facade.element=()=>null;
|
||||
root[globalName]=facade;
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
93
frontend/fonts/PlusJakartaSans-OFL.txt
Normal file
93
frontend/fonts/PlusJakartaSans-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Plus Jakarta Sans Project Authors (https://github.com/tokotype/PlusJakartaSans)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
frontend/fonts/PlusJakartaSans-latin-ext.woff2
Normal file
BIN
frontend/fonts/PlusJakartaSans-latin-ext.woff2
Normal file
Binary file not shown.
BIN
frontend/fonts/PlusJakartaSans-latin.woff2
Normal file
BIN
frontend/fonts/PlusJakartaSans-latin.woff2
Normal file
Binary file not shown.
75
frontend/fullscreen-orientation.css
Normal file
75
frontend/fullscreen-orientation.css
Normal file
@@ -0,0 +1,75 @@
|
||||
/* Shared fullscreen surface; inline player styling is unchanged. */
|
||||
.orientation-fullscreen .fullscreen-hud[hidden] { display: none; }
|
||||
.orientation-fullscreen .fullscreen-hud {
|
||||
--hud-ink: var(--text, #fff);
|
||||
position: absolute; z-index: 20; right: max(12px, env(safe-area-inset-right));
|
||||
bottom: max(8px, env(safe-area-inset-bottom)); left: max(12px, env(safe-area-inset-left));
|
||||
display: flex; flex-direction: column; align-items: stretch; gap: 2px; padding: 4px 2px 2px;
|
||||
color: #fff; background: linear-gradient(to bottom, transparent, rgb(0 0 0 / .45));
|
||||
opacity: 1; transition: opacity .22s ease;
|
||||
}
|
||||
.fullscreen-top { display: flex; align-items: center; justify-content: space-between; min-height: 38px; }
|
||||
.fullscreen-top .fullscreen-title { min-width: 0; max-width: calc(100% - 52px); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 13px; font-weight: 500; color: #fff; text-shadow: 0 1px 5px #000; }
|
||||
.fullscreen-controls { display: flex; align-items: center; gap: 6px; }
|
||||
.fullscreen-top button { width: 38px; min-width: 38px; height: 38px; min-height: 38px; }
|
||||
.orientation-fullscreen .fullscreen-hud.hud-hidden { opacity: 0; pointer-events: none; }
|
||||
.fullscreen-hud button {
|
||||
display: grid; place-items: center; flex: none;
|
||||
width: 44px; min-width: 44px; height: 44px; min-height: 44px; padding: 0;
|
||||
border: 0; border-radius: 50%; background: transparent;
|
||||
color: var(--hud-ink, #fff); font: inherit; cursor: pointer;
|
||||
filter: drop-shadow(0 1px 3px rgb(0 0 0 / .8));
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.fullscreen-hud button:hover { background: rgb(255 255 255 / .12); }
|
||||
.fullscreen-hud button:active { transform: scale(.94); }
|
||||
.fullscreen-hud button svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.fullscreen-hud button .fill { fill: currentColor; stroke: none; }
|
||||
.fullscreen-hud button:focus-visible { outline: 2px solid var(--accent, #fff); outline-offset: 2px; }
|
||||
.fullscreen-hud input { min-width: 40px; min-height: 18px; height: 18px; flex: 1; margin: 0 4px; accent-color: var(--accent, #fff); touch-action: none; appearance: none; background: transparent; }
|
||||
.fullscreen-hud input::-webkit-slider-runnable-track { height: 3px; border-radius: 2px; background: linear-gradient(to right, var(--accent, #fff) 0 var(--seek-progress, 0%), rgb(255 255 255 / .55) var(--seek-progress, 0%) 100%); }
|
||||
.fullscreen-hud input::-webkit-slider-thumb { appearance: none; width: 9px; height: 9px; margin-top: -3px; border-radius: 50%; background: #fff; box-shadow: 0 1px 4px #000; }
|
||||
.fullscreen-hud input:active::-webkit-slider-runnable-track { height: 5px; }
|
||||
.fullscreen-hud input::-moz-range-track { height: 3px; border-radius: 2px; background: rgb(255 255 255 / .55); }
|
||||
.fullscreen-hud input::-moz-range-progress { height: 3px; background: var(--accent, #fff); }
|
||||
.fullscreen-hud input::-moz-range-thumb { width: 9px; height: 9px; border: 0; border-radius: 50%; background: #fff; }
|
||||
.player-stage.orientation-fullscreen { background: #000; }
|
||||
.player-stage.orientation-overlay {
|
||||
position: fixed !important; z-index: 10000 !important; inset: 0 !important;
|
||||
width: 100dvw !important; height: 100dvh !important;
|
||||
min-width: 0 !important; min-height: 0 !important;
|
||||
max-width: none !important; max-height: none !important;
|
||||
aspect-ratio: auto !important; margin: 0 !important; padding: 0 !important;
|
||||
border: 0 !important; border-radius: 0 !important; box-shadow: none !important;
|
||||
}
|
||||
.player-stage.orientation-overlay[data-fullscreen-rotation="90"],
|
||||
.player-stage.orientation-overlay[data-fullscreen-rotation="-90"] {
|
||||
inset: auto !important; left: 50% !important; top: 50% !important;
|
||||
width: 100dvh !important; height: 100dvw !important;
|
||||
transform: translate(-50%, -50%) rotate(90deg) !important;
|
||||
}
|
||||
.player-stage.orientation-overlay[data-fullscreen-rotation="-90"] { transform: translate(-50%, -50%) rotate(-90deg) !important; }
|
||||
.orientation-fullscreen video {
|
||||
flex: none !important; width: 100% !important; height: 100% !important;
|
||||
max-width: none !important; max-height: none !important; object-fit: cover;
|
||||
}
|
||||
.orientation-fullscreen[data-fullscreen-fit="fit"] video { object-fit: contain; }
|
||||
.orientation-fullscreen[data-fullscreen-fit="fill"] video { object-fit: cover; }
|
||||
.orientation-fullscreen #stgOverlay { display: none !important; }
|
||||
.orientation-overlay[data-fullscreen-rotation="90"] .fullscreen-hud {
|
||||
bottom: max(12px, env(safe-area-inset-left)); left: max(12px, env(safe-area-inset-top)); right: max(12px, env(safe-area-inset-bottom));
|
||||
}
|
||||
.orientation-overlay[data-fullscreen-rotation="-90"] .fullscreen-hud {
|
||||
bottom: max(12px, env(safe-area-inset-right)); left: max(12px, env(safe-area-inset-bottom)); right: max(12px, env(safe-area-inset-top));
|
||||
}
|
||||
@media (max-width: 460px) {
|
||||
.orientation-fullscreen .fullscreen-hud { gap: 2px; padding: 4px 2px 2px; }
|
||||
.fullscreen-hud button { width: 44px; min-width: 44px; height: 44px; min-height: 44px; }
|
||||
}
|
||||
/* Raise existing ancestry instead of moving a playing video in WebKit. */
|
||||
body.landscape-fs .orientation-ancestor { z-index: 10001 !important; transform: none !important; filter: none !important; overflow: visible !important; }
|
||||
body.landscape-fs { overflow: hidden; }
|
||||
/* While the app's own fullscreen is up, nothing of the app may show around or over it. */
|
||||
body.landscape-fs .orientation-ancestor { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; contain: none !important; will-change: auto !important; perspective: none !important; clip-path: none !important; isolation: auto !important; }
|
||||
body.landscape-fs :is(.topbar, #bottomNav, #miniBar, #sectionRail, #toastContainer, .sheet-backdrop, .f7s-sheet) { visibility: hidden !important; }
|
||||
body.landscape-fs > .player-stage.orientation-overlay { z-index: 2147483000 !important; }
|
||||
122
frontend/fullscreen-orientation.js
Normal file
122
frontend/fullscreen-orientation.js
Normal file
@@ -0,0 +1,122 @@
|
||||
(function (root) {
|
||||
'use strict';
|
||||
const choices = ['auto', 'landscape', 'portrait'];
|
||||
const normalize = value => choices.includes(value) ? value : 'auto';
|
||||
function rotation(value, width, height) {
|
||||
value = normalize(value);
|
||||
return value === 'landscape' && width < height ? 90 : value === 'portrait' && width > height ? -90 : 0;
|
||||
}
|
||||
function coordinates(x, y, rect, angle = 0) {
|
||||
if (angle === 90) return { x: y - rect.top, y: rect.right - x, width: rect.height, height: rect.width };
|
||||
if (angle === -90) return { x: rect.bottom - y, y: x - rect.left, width: rect.height, height: rect.width };
|
||||
return { x: x - rect.left, y: y - rect.top, width: rect.width, height: rect.height };
|
||||
}
|
||||
function normalizeFit(value) { return value === 'fit' ? 'fit' : 'fill'; }
|
||||
function create(options) {
|
||||
const { stage, settings, persist, toggle, seek, state } = options;
|
||||
let active = false, custom = false, native = false, locked = false, angle = 0, timer, hideTimer;
|
||||
const ancestors = [];
|
||||
for (let el = stage.parentElement; el && el !== document.body; el = el.parentElement) ancestors.push(el);
|
||||
// iOS (no element fullscreen) draws its own fixed overlay. Any ancestor with a filter,
|
||||
// backdrop-filter, containment or clipping traps a fixed child, which showed the app
|
||||
// around the video on iPhone. Lift the stage to <body> for the overlay and put it back after.
|
||||
let home = null;
|
||||
function lift() { if (home || stage.parentElement === document.body) return; home = { parent: stage.parentElement, next: stage.nextSibling }; document.body.append(stage); }
|
||||
function unlift() { if (!home) return; const { parent, next } = home; home = null; if (next && next.parentNode === parent) parent.insertBefore(stage, next); else parent.append(stage); }
|
||||
const hud = document.createElement('div'); hud.className = 'fullscreen-hud'; hud.hidden = true;
|
||||
hud.innerHTML = '<div class="fullscreen-top"><div class="fullscreen-title" aria-live="polite"></div><button type="button" aria-label="Exit fullscreen" title="Exit fullscreen"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 4H4v5m0-5 6 6m5 9h5v-5m0 5-6-6"/></svg></button></div><div class="fullscreen-controls"><button type="button" aria-label="Play or pause" title="Play or pause">' + root.TransportIcons.svg('play') + '</button><input type="range" min="0" max="1000" value="0" aria-label="Seek video"><button type="button" class="fullscreen-orientation" aria-label="Change fullscreen orientation" title="Change fullscreen orientation"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8V4h4M20 16v4h-4M4 4l6 6m4 4 6 6M20 8V4h-4M4 16v4h4M20 4l-6 6m-4 4-6 6"/></svg></button><button type="button" class="fullscreen-fit" aria-label="Change video fit" title="Change video fit"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7V4h3M17 4h3v3M20 17v3h-3M7 20H4v-3M7 9h10v6H7z"/></svg></button></div>';
|
||||
stage.append(hud);
|
||||
const play = hud.querySelector('button[aria-label="Play or pause"]'), orientation = hud.querySelector('.fullscreen-orientation'), fitButton = hud.querySelector('.fullscreen-fit'), exit = hud.querySelector('button[aria-label="Exit fullscreen"]'), range = hud.querySelector('input');
|
||||
const title = hud.querySelector('.fullscreen-title');
|
||||
function setFit(value) {
|
||||
const fit = normalizeFit(value); settings().fullscreenFit = fit; persist();
|
||||
stage.dataset.fullscreenFit = fit;
|
||||
fitButton.setAttribute('aria-label', `Video fill mode: ${fit === 'fill' ? 'Fill' : 'Fit'}`);
|
||||
fitButton.title = `${fit === 'fill' ? 'Fill' : 'Fit'} video frame`;
|
||||
}
|
||||
function revealControls() {
|
||||
if (!active) return;
|
||||
hud.classList.remove('hud-hidden'); clearTimeout(hideTimer);
|
||||
hideTimer = setTimeout(() => {
|
||||
if (!hud.contains(document.activeElement)) hud.classList.add('hud-hidden');
|
||||
}, 3200);
|
||||
}
|
||||
stage.addEventListener('pointerdown', revealControls, { passive: true });
|
||||
stage.addEventListener('pointermove', event => { if (event.pointerType === 'mouse') revealControls(); }, { passive: true });
|
||||
hud.addEventListener('focusin', () => { clearTimeout(hideTimer); hud.classList.remove('hud-hidden'); });
|
||||
hud.addEventListener('focusout', () => setTimeout(revealControls, 0));
|
||||
hud.addEventListener('click', revealControls);
|
||||
play.onclick = toggle; exit.onclick = leave;
|
||||
fitButton.onclick = () => setFit(settings().fullscreenFit === 'fill' ? 'fit' : 'fill');
|
||||
// Fit/Fill is only the HUD button: a centre double-tap is play/pause and two fingers pinch-zoom (app.js Gestures).
|
||||
function layout() {
|
||||
if (!active) return;
|
||||
angle = custom ? rotation(settings().fullscreenOrientation, innerWidth, innerHeight) : 0;
|
||||
stage.dataset.fullscreenRotation = String(angle);
|
||||
title.textContent = state().title || '';
|
||||
orientation.setAttribute('aria-label', `Change fullscreen orientation (currently ${normalize(settings().fullscreenOrientation)})`);
|
||||
orientation.title = `Orientation: ${normalize(settings().fullscreenOrientation)}`;
|
||||
}
|
||||
async function orient() {
|
||||
if (locked) { screen.orientation?.unlock?.(); locked = false; }
|
||||
const value = normalize(settings().fullscreenOrientation);
|
||||
if (!custom && value !== 'auto') {
|
||||
try { await screen.orientation.lock(value); locked = true; if (!active) { screen.orientation.unlock(); locked = false; return; } }
|
||||
catch { if (!active) return; custom = true; lift(); stage.classList.add('orientation-overlay'); }
|
||||
}
|
||||
layout();
|
||||
}
|
||||
orientation.onclick = () => { settings().fullscreenOrientation = choices[(choices.indexOf(normalize(settings().fullscreenOrientation)) + 1) % choices.length]; persist(); orient(); };
|
||||
range.oninput = () => seek(Number(range.value) / 1000 * (state().duration || 0));
|
||||
// Explicit inverse coordinates also work for rotated range controls on WebKit.
|
||||
const rangeSeek = event => {
|
||||
const point = coordinates(event.clientX, event.clientY, range.getBoundingClientRect(), angle);
|
||||
seek(Math.max(0, Math.min(1, point.x / point.width)) * (state().duration || 0));
|
||||
};
|
||||
range.onpointerdown = event => { event.preventDefault(); range.setPointerCapture(event.pointerId); rangeSeek(event); };
|
||||
range.onpointermove = event => { if (range.hasPointerCapture(event.pointerId)) rangeSeek(event); };
|
||||
hud.addEventListener('pointerdown', event => event.stopPropagation());
|
||||
hud.addEventListener('pointerup', event => event.stopPropagation());
|
||||
function cleanup() {
|
||||
active = false; custom = false; native = false; angle = 0; clearInterval(timer); clearTimeout(hideTimer);
|
||||
ancestors.forEach(el => el.classList.remove('orientation-ancestor'));
|
||||
hud.hidden = true; hud.classList.remove('hud-hidden'); stage.classList.remove('orientation-overlay', 'orientation-fullscreen');
|
||||
delete stage.dataset.fullscreenRotation; document.body.classList.remove('landscape-fs');
|
||||
delete stage.dataset.fullscreenFit;
|
||||
unlift();
|
||||
if (locked) { screen.orientation?.unlock?.(); locked = false; }
|
||||
}
|
||||
function leave() {
|
||||
if (document.fullscreenElement === stage) document.exitFullscreen().catch(() => {});
|
||||
cleanup();
|
||||
}
|
||||
async function enter() {
|
||||
if (active) { leave(); return; }
|
||||
active = true; ancestors.forEach(el => el.classList.add('orientation-ancestor')); stage.classList.add('orientation-fullscreen'); hud.hidden = false; revealControls();
|
||||
setFit(normalizeFit(settings().fullscreenFit));
|
||||
document.body.classList.add('landscape-fs');
|
||||
try {
|
||||
if (!stage.requestFullscreen) throw new Error('Custom fullscreen');
|
||||
await stage.requestFullscreen();
|
||||
native = true;
|
||||
} catch { custom = true; lift(); stage.classList.add('orientation-overlay'); }
|
||||
if (!active) return;
|
||||
await orient();
|
||||
if (!active) return;
|
||||
timer = setInterval(() => {
|
||||
const s = state();
|
||||
range.value = String(s.duration ? s.time / s.duration * 1000 : 0);
|
||||
range.style.setProperty('--seek-progress', `${s.duration ? Math.min(100, s.time / s.duration * 100) : 0}%`);
|
||||
play.setAttribute('aria-label', s.paused ? 'Play video' : 'Pause video');
|
||||
play.title = s.paused ? 'Play' : 'Pause';
|
||||
root.TransportIcons.set(play, s.paused ? 'play' : 'pause', s.paused ? 'Play video' : 'Pause video');
|
||||
}, 250);
|
||||
}
|
||||
document.addEventListener('fullscreenchange', () => { if (active && !document.fullscreenElement && native) cleanup(); });
|
||||
document.addEventListener('keydown', event => { if (active && event.key === 'Escape') leave(); });
|
||||
window.addEventListener('resize', layout);
|
||||
return { enter, leave, point: event => coordinates(event.clientX, event.clientY, stage.getBoundingClientRect(), angle) };
|
||||
}
|
||||
const api = { normalize, normalizeFit, rotation, coordinates, create };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.FullscreenOrientation = api;
|
||||
})(globalThis);
|
||||
22
frontend/fullscreen-orientation.test.js
Normal file
22
frontend/fullscreen-orientation.test.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { normalize, normalizeFit, rotation, coordinates } = require('./fullscreen-orientation');
|
||||
test('fullscreen fit defaults safely to fill and accepts fit', () => {
|
||||
assert.equal(normalizeFit(undefined), 'fill');
|
||||
assert.equal(normalizeFit('fit'), 'fit');
|
||||
assert.equal(normalizeFit('unexpected'), 'fill');
|
||||
});
|
||||
test('orientation defaults and only rotates mismatched viewports', () => {
|
||||
assert.equal(normalize('invalid'), 'auto');
|
||||
assert.equal(rotation('auto', 390, 844), 0);
|
||||
assert.equal(rotation('landscape', 390, 844), 90);
|
||||
assert.equal(rotation('landscape', 844, 390), 0);
|
||||
assert.equal(rotation('portrait', 844, 390), -90);
|
||||
});
|
||||
test('rotated touch coordinates preserve local seek and swipe axes', () => {
|
||||
const rect = { left: 10, top: 20, right: 400, bottom: 864, width: 390, height: 844 };
|
||||
assert.deepEqual(coordinates(400, 20, rect, 90), { x: 0, y: 0, width: 844, height: 390 });
|
||||
assert.deepEqual(coordinates(10, 864, rect, 90), { x: 844, y: 390, width: 844, height: 390 });
|
||||
assert.deepEqual(coordinates(10, 864, rect, -90), { x: 0, y: 0, width: 844, height: 390 });
|
||||
assert.deepEqual(coordinates(205, 442, rect), { x: 195, y: 422, width: 390, height: 844 });
|
||||
});
|
||||
@@ -1,12 +1,14 @@
|
||||
/* Draw legacy control glyphs only in Glass; restore them on theme changes. */
|
||||
/* Draw legacy control glyphs in Glass and Bento; restore them on theme changes. */
|
||||
(function(root) {
|
||||
'use strict';
|
||||
const paths = {
|
||||
trash:'<path d="M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7M14 10v7"/>',
|
||||
music:'<path d="M9 18V5l11-2v13M9 8l11-2"/><ellipse cx="6" cy="18" rx="3" ry="2"/><ellipse cx="17" cy="16" rx="3" ry="2"/>',
|
||||
add:'<path d="M12 5v14M5 12h14"/>', close:'<path d="m6 6 12 12M18 6 6 18"/>',
|
||||
search:'<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
|
||||
save:'<path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4"/>',
|
||||
play:'<path d="m8 5 11 7-11 7z"/>', pause:'<path d="M8 5v14M16 5v14"/>',
|
||||
next:'<path d="m5 5 10 7-10 7zM19 5v14"/>', previous:'<path d="m19 5-10 7 10 7zM5 5v14"/>',
|
||||
save:'<use href="#media-download"/>',
|
||||
play:'<use class="transport-shape transport-shape-classic" href="#transport-play"/><use class="transport-shape transport-shape-f7-swipe" href="#transport-play-f7-swipe"/><use class="transport-shape transport-shape-glass-stage" href="#transport-play-glass-stage"/><use class="transport-shape transport-shape-bento-hub" href="#transport-play-bento-hub"/>', pause:'<use class="transport-shape transport-shape-classic" href="#transport-pause"/><use class="transport-shape transport-shape-f7-swipe" href="#transport-pause-f7-swipe"/><use class="transport-shape transport-shape-glass-stage" href="#transport-pause-glass-stage"/><use class="transport-shape transport-shape-bento-hub" href="#transport-pause-bento-hub"/>',
|
||||
next:'<use class="transport-shape transport-shape-classic" href="#transport-next"/><use class="transport-shape transport-shape-f7-swipe" href="#transport-next-f7-swipe"/><use class="transport-shape transport-shape-glass-stage" href="#transport-next-glass-stage"/><use class="transport-shape transport-shape-bento-hub" href="#transport-next-bento-hub"/>', previous:'<use class="transport-shape transport-shape-classic" href="#transport-previous"/><use class="transport-shape transport-shape-f7-swipe" href="#transport-previous-f7-swipe"/><use class="transport-shape transport-shape-glass-stage" href="#transport-previous-glass-stage"/><use class="transport-shape transport-shape-bento-hub" href="#transport-previous-bento-hub"/>',
|
||||
repeat:'<path d="m17 2 4 4-4 4M3 11V8a2 2 0 0 1 2-2h16M7 22l-4-4 4-4m14-1v3a2 2 0 0 1-2 2H3"/>',
|
||||
edit:'<path d="m14 5 5 5M4 20l5-1L21 7l-4-4L5 15z"/>',
|
||||
lyrics:'<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3M8 22h8"/>',
|
||||
@@ -18,50 +20,75 @@
|
||||
clock:'<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
||||
shuffle:'<path d="m18 3 3 3-3 3M3 6h3c5 0 7 12 12 12h3m-3-3 3 3-3 3M3 18h3c2 0 4-3 6-6s4-6 6-6h3"/>',
|
||||
down:'<path d="m6 9 6 6 6-6"/>', right:'<path d="m9 6 6 6-6 6"/>', back:'<path d="M20 12H4m6-6-6 6 6 6"/>', more:'<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
|
||||
television:'<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4"/>', pin:'<path d="M9 3h6l-1 6 4 4H6l4-4zM12 13v8"/>',
|
||||
devices:'<rect x="3" y="4" width="14" height="12" rx="2"/><path d="M7 20h6M10 16v4"/><rect x="17" y="10" width="5" height="11" rx="1"/>'
|
||||
};
|
||||
const glyphs = {'+':'add','✕':'close','×':'close','✖':'close','🔍':'search','🔎':'search','⬇':'save','⤓':'save','💾':'save','▶':'play','⏸':'pause','⏭':'next','⏮':'previous','↻':'repeat','↺':'repeat','✎':'edit','✏':'edit','📝':'edit','🎤':'lyrics','↗':'share','⧉':'copy','✓':'check','📁':'folder','⚙':'settings','📋':'chapters','🕘':'clock','🔀':'shuffle','📡':'devices','▾':'down','▸':'right','←':'back','⋯':'more'};
|
||||
function leadingGlyph(text) {
|
||||
const glyphs = {'+':'add','✕':'close','×':'close','✖':'close','🔍':'search','🔎':'search','▶':'play','⏸':'pause','⏭':'next','⏮':'previous','↻':'repeat','↺':'repeat','✎':'edit','✏':'edit','📝':'edit','🎤':'lyrics','↗':'share','⧉':'copy','✓':'check','📁':'folder','⚙':'settings','📋':'chapters','🕘':'clock','🔀':'shuffle','📡':'devices','▾':'down','▸':'right','←':'back','⋯':'more','⋮':'more','🗑':'trash','🎵':'music'};
|
||||
function leadingGlyph(text,bento=false) {
|
||||
const mapping=bento?{...glyphs,'📺':'television','⏱':'clock','⇅':'settings','📌':'pin','🎧':'music','📚':'folder'}:glyphs;
|
||||
const prefix = text.match(/^\s*/)[0], rest = text.slice(prefix.length);
|
||||
const glyph = Object.keys(glyphs).find(key => rest.startsWith(key));
|
||||
const glyph = Object.keys(mapping).find(key => rest.startsWith(key));
|
||||
if (!glyph) return null;
|
||||
const length = glyph.length + (rest[glyph.length] === '\uFE0F' ? 1 : 0);
|
||||
return { kind:glyphs[glyph], original:prefix+rest.slice(0,length), rest:rest.slice(length) };
|
||||
return { kind:mapping[glyph], original:prefix+rest.slice(0,length), rest:rest.slice(length) };
|
||||
}
|
||||
function mount(doc) {
|
||||
const decorations = new Set(); let scheduled=false;
|
||||
const selector = '#listPane button, .sidebar button, .smart-item .pl-name, .notes-tab, .notes-fold, .mini-btn';
|
||||
const decorations = new Set(), tabLabels = new Map(); let scheduled=false;
|
||||
const selector = '#listPane button, .sidebar button, .smart-item .pl-name, .notes-tab, .notes-fold, .mini-btn, .sidebar .playlist-item:not(.smart-item), .pl-offline-badge.all, .home-pl-offline.all, .home-pl-play, .home-pl-thumb-fallback';
|
||||
const svg = kind => { const icon=doc.createElementNS('http://www.w3.org/2000/svg','svg'); icon.setAttribute('viewBox','0 0 24 24');icon.setAttribute('aria-hidden','true');icon.classList.add('glass-control-icon');icon.innerHTML=paths[kind];return icon; };
|
||||
const observer=new root.MutationObserver(schedule), themeObserver=new root.MutationObserver(schedule);
|
||||
function restore(record) {
|
||||
record.svg.remove(); if(record.original.isConnected)record.original.replaceWith(doc.createTextNode(record.original.textContent));
|
||||
if(record.addedLabel && record.parent.isConnected)record.parent.removeAttribute('aria-label');
|
||||
}
|
||||
function restoreTab(parent, record) {
|
||||
for(const label of record.labels)if(label.isConnected)label.replaceWith(doc.createTextNode(label.textContent));
|
||||
for(const [name,value] of Object.entries(record.attributes)) { if(value===null)parent.removeAttribute(name);else parent.setAttribute(name,value); }
|
||||
}
|
||||
function paint() {
|
||||
scheduled=false;observer.disconnect();
|
||||
const active=doc.documentElement.dataset.layout==='glass-stage';
|
||||
const bento=doc.documentElement.dataset.layout==='bento-hub';
|
||||
const active=bento || doc.documentElement.dataset.layout==='glass-stage';
|
||||
for(const [parent,record] of tabLabels)if(!active || !parent.isConnected){restoreTab(parent,record);tabLabels.delete(parent);}
|
||||
for(const record of decorations)if(!active || !record.svg.isConnected){restore(record);decorations.delete(record);}
|
||||
if(active) for(const parent of doc.querySelectorAll(selector)) {
|
||||
if(active) for(const parent of doc.querySelectorAll(selector+(bento?', .service-btn, .np-btn, .ctrl, .empty-icon, .dl-saved-cta, .brand-mark, .upnext-head button, .ph-logo, .saved-badge':''))) {
|
||||
// Existing SVGs already express the action; only legacy glyphs change.
|
||||
if(parent.querySelector('svg:not(.glass-control-icon),.legacy-nav-icon'))continue;
|
||||
for(const node of [...parent.childNodes]) {
|
||||
if(node.nodeType!==3)continue;const match=leadingGlyph(node.textContent);if(!match)continue;
|
||||
const icon=svg(match.kind), original=doc.createElement('span'); original.className='glass-original-glyph';original.textContent=match.original;
|
||||
if(node.nodeType!==3)continue;const match=leadingGlyph(node.textContent,bento);if(!match)continue;
|
||||
const icon=svg(match.kind);
|
||||
if(parent.matches('.pl-offline-badge,.home-pl-offline')) { icon.removeAttribute('aria-hidden'); icon.setAttribute('role','img'); icon.setAttribute('aria-label',parent.title || 'Available offline'); }
|
||||
const original=doc.createElement('span'); original.className='glass-original-glyph';original.textContent=match.original;
|
||||
node.textContent=match.rest;parent.insertBefore(icon,node);parent.insertBefore(original,node);
|
||||
const addedLabel=parent.tagName==='BUTTON'&&!parent.hasAttribute('aria-label')&&!parent.textContent.replace(match.original,'').trim();
|
||||
if(addedLabel)parent.setAttribute('aria-label',parent.title || ({play:'Play',pause:'Pause',close:'Close',add:'Add',save:'Save'}[match.kind] || match.kind));
|
||||
decorations.add({parent,svg:icon,original,addedLabel});break;
|
||||
}
|
||||
if(parent.matches('.sidebar .playlist-item:not(.smart-item)')&&!parent.querySelector('svg')) {
|
||||
const icon=svg('folder'); parent.prepend(icon);
|
||||
decorations.add({parent,svg:icon,original:doc.createElement('span'),addedLabel:false});
|
||||
}
|
||||
if(parent.matches('.notes-tab')&&!parent.querySelector('svg')&&paths[parent.dataset.tab==='markers'?'chapters':parent.dataset.tab]) {
|
||||
const kind=parent.dataset.tab==='markers'?'chapters':parent.dataset.tab,icon=svg(kind);parent.prepend(icon);
|
||||
const original=doc.createElement('span');decorations.add({parent,svg:icon,original,addedLabel:false});
|
||||
}
|
||||
}
|
||||
if(active)for(const parent of doc.querySelectorAll('.notes-tab')) {
|
||||
if(tabLabels.has(parent))continue;
|
||||
const record={labels:[],attributes:{'aria-label':parent.getAttribute('aria-label'),title:parent.getAttribute('title')}};
|
||||
const name=parent.dataset.tab==='markers'?'Chapters & bookmarks':parent.dataset.tab==='lyrics'?'Lyrics':'Search';
|
||||
if(!parent.hasAttribute('aria-label'))parent.setAttribute('aria-label',name);
|
||||
if(!parent.hasAttribute('title'))parent.setAttribute('title',name);
|
||||
for(const node of [...parent.childNodes])if(node.nodeType===3 && node.textContent.trim()) {
|
||||
const label=doc.createElement('span');label.className='glass-tab-label';node.replaceWith(label);label.append(node);record.labels.push(label);
|
||||
}
|
||||
tabLabels.set(parent,record);
|
||||
}
|
||||
if(active)observer.observe(doc.body,{childList:true,subtree:true,characterData:true});
|
||||
}
|
||||
function schedule(){if(!scheduled){scheduled=true;root.queueMicrotask(paint);}}
|
||||
themeObserver.observe(doc.documentElement,{attributes:true,attributeFilter:['data-layout']});paint();
|
||||
return ()=>{observer.disconnect();themeObserver.disconnect();for(const record of decorations)restore(record);};
|
||||
return ()=>{observer.disconnect();themeObserver.disconnect();for(const record of decorations)restore(record);for(const [parent,record] of tabLabels)restoreTab(parent,record);};
|
||||
}
|
||||
if(typeof module!=='undefined')module.exports={leadingGlyph,paths};
|
||||
else if(root.document.readyState==='loading')root.document.addEventListener('DOMContentLoaded',()=>mount(root.document),{once:true});
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
const test=require('node:test'),assert=require('node:assert/strict');
|
||||
const {leadingGlyph}=require('./glass-controls.js');
|
||||
test('glass icon substitution preserves leading whitespace and remaining labels',()=>{
|
||||
assert.deepEqual(leadingGlyph(' ⬇ Save'),{kind:'save',original:' ⬇',rest:' Save'});
|
||||
assert.equal(leadingGlyph('Save'),null);
|
||||
assert.deepEqual(leadingGlyph('⚙️ Settings'),{kind:'settings',original:'⚙️',rest:' Settings'});
|
||||
assert.equal(leadingGlyph('A song with 🔍 in its title'),null);
|
||||
assert.equal(leadingGlyph('Clear all'),null);
|
||||
assert.equal(leadingGlyph('⏸').kind,'pause');
|
||||
});
|
||||
|
||||
test('playlist trash, menu and music glyphs use drawn glass icons',()=>{
|
||||
assert.equal(leadingGlyph('🗑️ Delete').kind,'trash');
|
||||
assert.equal(leadingGlyph('⋮').kind,'more');
|
||||
assert.equal(leadingGlyph('🎵').kind,'music');
|
||||
});
|
||||
|
||||
test('Bento-only legacy icons leave the Glass glyph set unchanged',()=>{
|
||||
assert.equal(leadingGlyph('📺 Show video'),null);
|
||||
assert.deepEqual(leadingGlyph('📺 Show video',true),{kind:'television',original:'📺',rest:' Show video'});
|
||||
assert.equal(leadingGlyph('⏱ Tap-sync',true).kind,'clock');
|
||||
assert.equal(leadingGlyph('📌 Bookmark',true).kind,'pin');
|
||||
});
|
||||
|
||||
41
frontend/glass-panel-layout.js
Normal file
41
frontend/glass-panel-layout.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/* Glass Stage: measure the bottom chrome so panels keep clear of it. The
|
||||
section rail stays a fixed floating bar (body-level); never move it into a
|
||||
scrolling container or it scrolls with the content. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
function bottomClearance(viewport, boxes, gap = 16) {
|
||||
const occupied = boxes.reduce((height, b) => b.fixed && b.height > 0 && b.top < viewport && b.bottom > 0 ? Math.max(height, viewport - b.top) : height, 0);
|
||||
return Math.ceil(occupied + (occupied ? gap : 0));
|
||||
}
|
||||
function mount(doc) {
|
||||
const win = doc.defaultView, html = doc.documentElement;
|
||||
let frame = 0;
|
||||
function active() { return html.dataset.layout === 'glass-stage'; }
|
||||
function schedule() { if (!frame) frame = win.requestAnimationFrame(measure); }
|
||||
function set(name, value) { if (html.style.getPropertyValue(name) !== value) html.style.setProperty(name, value); }
|
||||
function box(el) { const r = el.getBoundingClientRect(); return { top: r.top, bottom: r.bottom, height: el.classList.contains('hidden') ? 0 : r.height, fixed: win.getComputedStyle(el).position === 'fixed' }; }
|
||||
function measure() {
|
||||
frame = 0; if (!active()) return;
|
||||
const nav = doc.getElementById('bottomNav'), mini = doc.getElementById('miniBar');
|
||||
const navBox = box(nav);
|
||||
set('--glass-nav-height', `${Math.ceil(navBox.height)}px`);
|
||||
// Read again after nav-height changes: the mini player's bottom depends on it.
|
||||
set('--glass-bottom-clearance', `${bottomClearance(win.innerHeight, [box(nav), box(mini)])}px`);
|
||||
}
|
||||
function sync() {
|
||||
if (active()) schedule();
|
||||
else {
|
||||
html.style.removeProperty('--glass-nav-height'); html.style.removeProperty('--glass-bottom-clearance');
|
||||
}
|
||||
}
|
||||
const observer = new MutationObserver(sync); observer.observe(html, { attributes: true, attributeFilter: ['data-layout'] });
|
||||
const chrome = [doc.getElementById('bottomNav'), doc.getElementById('miniBar')].filter(Boolean);
|
||||
const changes = new MutationObserver(schedule); chrome.forEach(el => changes.observe(el, { attributes: true, attributeFilter: ['class', 'style'] }));
|
||||
const resize = typeof win.ResizeObserver === 'function' ? new win.ResizeObserver(schedule) : null; chrome.forEach(el => resize?.observe(el));
|
||||
win.addEventListener('resize', schedule); win.addEventListener('orientationchange', schedule); win.visualViewport?.addEventListener('resize', schedule);
|
||||
sync();
|
||||
}
|
||||
if (typeof module !== 'undefined') module.exports = { bottomClearance };
|
||||
else if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', () => mount(root.document), { once: true });
|
||||
else mount(root.document);
|
||||
}(typeof window !== 'undefined' ? window : globalThis));
|
||||
10
frontend/glass-panel-layout.test.js
Normal file
10
frontend/glass-panel-layout.test.js
Normal file
@@ -0,0 +1,10 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { bottomClearance } = require('./glass-panel-layout.js');
|
||||
test('clearance covers stacked floating chrome and includes its viewport inset once', () => {
|
||||
assert.equal(bottomClearance(844, [{ fixed: true, top: 740, bottom: 844, height: 104 }, { fixed: true, top: 668, bottom: 732, height: 64 }]), 192);
|
||||
assert.equal(bottomClearance(844, [{ fixed: true, top: 740, bottom: 844, height: 104 }]), 120);
|
||||
});
|
||||
test('hidden, nonfixed and offscreen elements do not reserve chrome space', () => {
|
||||
assert.equal(bottomClearance(844, [{ fixed: true, top: 740, bottom: 844, height: 0 }, { fixed: false, top: 668, bottom: 732, height: 64 }, { fixed: true, top: 900, bottom: 964, height: 64 }]), 0);
|
||||
});
|
||||
@@ -6,11 +6,11 @@
|
||||
^ * Out: { ok: true, sha256, size, engine } | { ok: false, error }
|
||||
* ========================================================================== */
|
||||
'use strict';
|
||||
importScripts('/sha256.js');
|
||||
|
||||
self.onmessage = async (e) => {
|
||||
const { name } = e.data || {};
|
||||
try {
|
||||
if (!self.Sha256) { self.__ASSET_URLS__=e.data.assetUrls || {}; importScripts(self.__ASSET_URLS__.sha256 || '/sha256.js'); }
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const dir = await root.getDirectoryHandle('videos');
|
||||
const file = await (await dir.getFileHandle(String(name))).getFile();
|
||||
|
||||
File diff suppressed because one or more lines are too long
66
frontend/jamesdsp-app.test.js
Normal file
66
frontend/jamesdsp-app.test.js
Normal file
@@ -0,0 +1,66 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const app = readFileSync(join(__dirname, 'app.js'), 'utf8');
|
||||
const views = readFileSync(join(__dirname, 'views-core.js'), 'utf8');
|
||||
|
||||
function parsePreset(text) {
|
||||
const declaration = views.match(/function parseJamesDspPreset\(text\) \{[\s\S]*?\n\}/)?.[0];
|
||||
assert.ok(declaration, 'preset parser is present');
|
||||
const context = vm.createContext({});
|
||||
vm.runInContext(`${declaration}; globalThis.parse = parseJamesDspPreset;`, context);
|
||||
return context.parse(text);
|
||||
}
|
||||
|
||||
test('the persisted engine is Standard by default and upgrades missing JamesDSP settings safely', () => {
|
||||
assert.match(app, /jamesDsp: \{ enabled: false, downloadForOffline: false, preset: 'flat', params: \{\}, irRef: null \}/);
|
||||
assert.match(app, /downloadForOffline: saved\.downloadForOffline === true/);
|
||||
assert.match(app, /enabled: saved\.enabled === true/);
|
||||
assert.match(app, /preset: typeof saved\.preset === 'string' \? saved\.preset : 'flat'/);
|
||||
assert.match(app, /settings: normalizeJamesDspSettings\(\{ \.\.\.DEFAULT_SETTINGS, \.\.\.\(loaded\.settings \|\| \{\}\) \}\)/);
|
||||
assert.match(app, /data\.settings = normalizeJamesDspSettings\(\{ \.\.\.DEFAULT_SETTINGS, \.\.\.payload\.settings \}\)/);
|
||||
});
|
||||
|
||||
test('live JamesDSP shares the existing iOS gate and never stacks over EqRender copies', () => {
|
||||
assert.match(app, /const available = \(\) => !!AC && \(!isIOS \|\| data\.settings\.liveAudioIOS === true\)/);
|
||||
assert.match(app, /if \(data\.settings\?\.jamesDsp\?\.enabled\) return null/);
|
||||
assert.match(app, /const active = \(\) => EQ\.isIOS\(\) && WEB && !data\.settings\?\.jamesDsp\?\.enabled/);
|
||||
assert.match(app, /if \(rendered\(\)\) throw new Error\('This song is already playing a Standard processed copy\./);
|
||||
});
|
||||
|
||||
test('EQ creates no second context or element source and passes Standard fallback routing to the controller', () => {
|
||||
assert.equal((app.match(/\.createMediaElementSource\(/g) || []).length, 0);
|
||||
assert.match(app, /st\.ctx = AudioGraph\.context\(\)/);
|
||||
assert.match(app, /AudioGraph\.connect\(el, st\.filters\[0\]\)/);
|
||||
assert.match(app, /standardInput: st\.filters\[0\]/);
|
||||
assert.match(app, /dspController\.setLevelDb\(st\.levelDb\)/);
|
||||
assert.match(app, /if \(data\.settings\.jamesDsp\?\.enabled\) \{[\s\S]{0,450}startDsp\(\)/);
|
||||
assert.match(app, /request !== dspRequest \|\| !data\.settings\.jamesDsp\?\.enabled/);
|
||||
});
|
||||
|
||||
test('Playback settings expose persisted presets, bands, safe import/export, IR, and A/B bypass', () => {
|
||||
assert.match(views, /sel\('setSoundEngine'/);
|
||||
assert.match(views, /sel\('setDspPreset'/);
|
||||
for (const id of ['setDspCompressor', 'dspPresetImportBtn', 'dspPresetExportBtn', 'dspResetBtn', 'dspIrFile']) assert.ok(views.includes(`id="${id}"`), id);
|
||||
assert.match(views, /\[25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000\]/);
|
||||
assert.match(views, /imported\?\.texts/);
|
||||
assert.doesNotMatch(views, /liveprog|ddc|arbitrary/);
|
||||
assert.match(views, /navigator\.hardwareConcurrency/);
|
||||
assert.match(views, /Long impulse responses are disabled/);
|
||||
assert.doesNotMatch(views, /44\.1 or 48 kHz/);
|
||||
assert.match(views, /setDspSpatialMode/);
|
||||
assert.match(views, /data-dsp-param="convolver\.predelayMs"/);
|
||||
assert.match(views, /data-dsp-param="convolver\.mix"/);
|
||||
assert.match(app, /Bypass JamesDSP/);
|
||||
assert.match(app, /toggleDspBypass/);
|
||||
});
|
||||
|
||||
test('preset import validates ranges and excludes text-effect parameters', () => {
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(parsePreset(JSON.stringify({ version: 1, preset: 'warm', params: { 'eq.band0': 3, 'bass.enabled': 1 } })))), { preset: 'warm', params: { 'eq.band0': 3, 'bass.enabled': 1 } });
|
||||
assert.throws(() => parsePreset({ version: 1, preset: 'flat', params: { 'liveprog.enabled': 1 } }), /text effects are disabled|Unsupported preset setting/);
|
||||
assert.throws(() => parsePreset({ version: 1, params: { 'eq.band0': 50 } }), /Unsupported preset setting/);
|
||||
assert.throws(() => parsePreset({ version: 1, texts: { effect: 'unsafe' }, params: {} }), /text effects are disabled/);
|
||||
});
|
||||
68
frontend/jamesdsp-assets.test.js
Normal file
68
frontend/jamesdsp-assets.test.js
Normal file
@@ -0,0 +1,68 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
|
||||
const root = __dirname;
|
||||
const assets = JSON.parse(readFileSync(join(root, 'assets.json'), 'utf8'));
|
||||
const group = assets.groups['feature:jamesdsp'];
|
||||
|
||||
test('JamesDSP runtime files live only in a lazy background group', () => {
|
||||
assert.ok(group, 'feature group exists');
|
||||
assert.equal(group.contract, 1);
|
||||
assert.equal(group.eager, false);
|
||||
assert.equal(group.background, true);
|
||||
assert.equal(group.offlineOptIn, 'jamesDsp');
|
||||
assert.deepEqual(group.dependencies, ['feature:eq-core', 'feature:vocal-reducer']);
|
||||
assert.deepEqual(group.files, [
|
||||
'/vendor/jamesdsp/jamesdsp-feature.js',
|
||||
'/vendor/jamesdsp/jamesdsp-node.mjs',
|
||||
'/vendor/jamesdsp/params.mjs',
|
||||
'/vendor/jamesdsp/ir-utils.mjs',
|
||||
'/vendor/jamesdsp/ir-builtins.mjs',
|
||||
'/vendor/jamesdsp/ir-store.mjs',
|
||||
'/vendor/jamesdsp/worklet.mjs',
|
||||
'/vendor/jamesdsp/dsp/jamesdsp-a982b53.mjs',
|
||||
'/vendor/jamesdsp/dsp/jamesdsp.wasm',
|
||||
]);
|
||||
for (const path of group.files) assert.equal(assets.groups.core.files.includes(path), false, `${path} is not core`);
|
||||
const sw = readFileSync(join(root, 'sw.js'), 'utf8');
|
||||
for (const path of group.files) assert.equal(sw.includes(`'${path}'`), false, `${path} is not in the legacy shell`);
|
||||
});
|
||||
|
||||
test('JamesDSP resolves every module dependency through the page build manifest', () => {
|
||||
const node = readFileSync(join(root, 'vendor/jamesdsp/jamesdsp-node.mjs'), 'utf8');
|
||||
const worklet = readFileSync(join(root, 'vendor/jamesdsp/worklet.mjs'), 'utf8');
|
||||
assert.match(node, /globalThis\.Lazy\?\.url\?\.\(path\)/);
|
||||
assert.match(node, /assetURL\('\/vendor\/jamesdsp\/dsp\/jamesdsp\.wasm'/);
|
||||
assert.match(node, /assetURL\('\/vendor\/jamesdsp\/params\.mjs'/);
|
||||
assert.match(node, /processorOptions:\{wasmBytes\}/);
|
||||
assert.match(worklet, /import createJamesDSP from '\.\/dsp\/jamesdsp-a982b53\.mjs'/);
|
||||
assert.doesNotMatch(worklet, /import\(/);
|
||||
assert.match(node, /context\.sampleRate<8000\|\|context\.sampleRate>96000/);
|
||||
assert.match(node, /!\[1,2,4\]\.includes\(buffer\.numberOfChannels\) \|\| buffer\.length>65536/);
|
||||
const feature = readFileSync(join(root, 'vendor/jamesdsp/jamesdsp-feature.js'), 'utf8');
|
||||
assert.match(feature, /Lazy\.url\('\/vendor\/jamesdsp\/jamesdsp-node\.mjs'\)/);
|
||||
assert.match(feature, /Lazy\.url\('\/vendor\/jamesdsp\/dsp\/jamesdsp\.wasm'\)/);
|
||||
});
|
||||
|
||||
test('player wrapper rejects text effects while portable presets retain IR references', () => {
|
||||
const node = readFileSync(join(root, 'vendor/jamesdsp/jamesdsp-node.mjs'), 'utf8');
|
||||
const worklet = readFileSync(join(root, 'vendor/jamesdsp/worklet.mjs'), 'utf8');
|
||||
assert.match(node, /Text effects are disabled in this player version/);
|
||||
assert.match(node, /toJSON\(\) \{return \{version:2,params:/);
|
||||
assert.match(node, /irCapFrames:view\.getUint32\(32,true\)/);
|
||||
assert.match(node, /getIR\(preset\.irRef\.name\)/);
|
||||
const feature = readFileSync(join(root, 'vendor/jamesdsp/jamesdsp-feature.js'), 'utf8');
|
||||
assert.match(feature, /async function prepareIR\(context, buffer, maxFrames = 65536\)/);
|
||||
assert.match(feature, /prepared\.length>Math\.min\(65536,maxFrames\)/);
|
||||
assert.doesNotMatch(worklet, /data\.type==='text'/);
|
||||
});
|
||||
|
||||
test('vendor provenance pins the source and retains both supplied license texts', () => {
|
||||
const version = readFileSync(join(root, 'vendor/jamesdsp/VERSION.txt'), 'utf8');
|
||||
assert.match(version, /a982b53c4ab347d1d9c50f4ce7ee3c2735eaa817/);
|
||||
assert.match(version, /GPL-2\.0 text/);
|
||||
assert.ok(readFileSync(join(root, 'vendor/jamesdsp/LICENSE.txt'), 'utf8').includes('Version 3'));
|
||||
assert.ok(readFileSync(join(root, 'vendor/jamesdsp/UPSTREAM-MAIN-LICENSE.txt'), 'utf8').includes('Version 2'));
|
||||
});
|
||||
114
frontend/jamesdsp-controller.test.js
Normal file
114
frontend/jamesdsp-controller.test.js
Normal file
@@ -0,0 +1,114 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createController } = require('./vendor/jamesdsp/jamesdsp-feature.js');
|
||||
|
||||
function fixture({ loadNode } = {}) {
|
||||
const routed = [], errors = [], created = [], configured = [], bypass = [];
|
||||
const graph = { routeAll: (elements, destination) => routed.push([elements.map(x => x.id), destination]) };
|
||||
const gainParam = { value: 1, cancelScheduledValues() {}, setValueAtTime(v) { this.value = v; }, linearRampToValueAtTime(v) { this.value = v; } };
|
||||
const context = { sampleRate: 48000, state: 'running', currentTime: 0, destination: 'destination', createGain: () => ({ gain: gainParam, connect() {}, disconnect() {} }), resume: async () => {} };
|
||||
const standardInput = 'standard-input', elements = [{ id: 'video' }, { id: 'audio' }];
|
||||
const prepareGraph = async () => ({ context, standardInput, elements });
|
||||
const defaultLoad = async () => {
|
||||
const listeners = new Map();
|
||||
const instance = {
|
||||
node: { connect() {}, disconnect() {} }, destroyed: false,
|
||||
addEventListener(type, fn) { listeners.set(type, fn); },
|
||||
removeEventListener(type, fn) { if (listeners.get(type) === fn) listeners.delete(type); },
|
||||
async setBypass(v) { bypass.push(v); },
|
||||
async applyPreset(params) { configured.push({ ...params }); },
|
||||
async setParam(name, value) { configured.push({ [name]: value }); },
|
||||
destroy() { this.destroyed = true; },
|
||||
fail(error) { listeners.get('error')?.({ detail: error }); },
|
||||
};
|
||||
created.push(instance); return instance;
|
||||
};
|
||||
const controller = createController({ graph, prepareGraph, loadNode: loadNode || defaultLoad, onError: error => errors.push(error) });
|
||||
return { controller, routed, errors, created, configured, bypass, context, elements, standardInput };
|
||||
}
|
||||
|
||||
test('enable routes one initialized JamesDSP node and disable restores Standard before destroying it', async () => {
|
||||
const f = fixture();
|
||||
assert.equal(await f.controller.enable({ preset: 'bass', params: { gain: -2 } }, 3), true);
|
||||
assert.equal(f.controller.active, true);
|
||||
assert.deepEqual(f.routed, [[['video', 'audio'], f.created[0].node]]);
|
||||
assert.equal(f.created.length, 1);
|
||||
assert.equal(f.controller.state().liveHeaps, 1);
|
||||
assert.equal(f.controller.state().maxLiveHeaps, 1);
|
||||
const standard = f.controller.disable();
|
||||
assert.deepEqual(f.routed.at(-1), [['video', 'audio'], f.standardInput]);
|
||||
await standard;
|
||||
assert.equal(f.created[0].destroyed, true);
|
||||
assert.equal(f.controller.state().liveHeaps, 0);
|
||||
assert.equal(f.controller.state().nodesDestroyed, 1);
|
||||
assert.equal(f.errors.length, 0);
|
||||
});
|
||||
|
||||
test('a rejected or unsupported engine leaves the Standard route active', async () => {
|
||||
const f = fixture({ loadNode: async () => { throw new Error('corrupt wasm'); } });
|
||||
await assert.rejects(f.controller.enable({ preset: 'flat' }), /corrupt wasm/);
|
||||
assert.deepEqual(f.routed, [[['video', 'audio'], f.standardInput]]);
|
||||
assert.equal(f.controller.active, false);
|
||||
assert.equal(f.controller.state().liveHeaps, 0);
|
||||
assert.equal(f.errors.length, 1);
|
||||
|
||||
const supportedRate = fixture();
|
||||
supportedRate.context.sampleRate = 32000;
|
||||
await supportedRate.controller.enable({ preset: 'flat' });
|
||||
assert.equal(supportedRate.controller.state().active, true);
|
||||
assert.equal(supportedRate.controller.state().sampleRate, 32000);
|
||||
});
|
||||
|
||||
test('a runtime worklet failure switches sources to Standard before destroying the heap', async () => {
|
||||
const f = fixture();
|
||||
await f.controller.enable({ preset: 'flat' });
|
||||
const instance = f.created[0];
|
||||
instance.fail(new Error('processor fault'));
|
||||
assert.deepEqual(f.routed.at(-1), [['video', 'audio'], f.standardInput]);
|
||||
assert.equal(instance.destroyed, true);
|
||||
assert.equal(f.controller.active, false);
|
||||
assert.equal(f.controller.state().liveHeaps, 0);
|
||||
assert.match(f.errors[0].message, /processor fault/);
|
||||
});
|
||||
|
||||
test('disable during async node loading prevents a late DSP route', async () => {
|
||||
let finish;
|
||||
const f = fixture({ loadNode: () => new Promise(resolve => { finish = resolve; }) });
|
||||
const enabling = f.controller.enable({ preset: 'flat' });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const disabling = f.controller.disable();
|
||||
const neverRouted = {
|
||||
node: { connect() {}, disconnect() {} },
|
||||
addEventListener() {}, removeEventListener() {}, setBypass: async () => {}, applyPreset: async () => {}, setParam: async () => {}, destroy() { this.destroyed = true; },
|
||||
};
|
||||
finish(neverRouted);
|
||||
await enabling; await disabling;
|
||||
assert.equal(f.routed.some(([, destination]) => destination === neverRouted.node), false);
|
||||
assert.equal(neverRouted.destroyed, true);
|
||||
assert.equal(f.controller.state().maxLiveHeaps, 0);
|
||||
});
|
||||
|
||||
test('preset gain and Level gain combine within engine bounds without pausing playback', async () => {
|
||||
const f = fixture();
|
||||
await f.controller.enable({ preset: 'loudness', params: { gain: 8 } }, 9);
|
||||
assert.ok(f.configured.some(entry => entry.gain === 12));
|
||||
assert.equal(f.created.length, 1);
|
||||
assert.equal(f.controller.state().maxLiveHeaps, 1);
|
||||
assert.equal(f.controller.active, true);
|
||||
});
|
||||
|
||||
test('A/B bypass is retained when settings are reapplied', async () => {
|
||||
const f = fixture();
|
||||
await f.controller.enable({ preset: 'warm' }, 0);
|
||||
assert.equal(await f.controller.toggleBypass(), true);
|
||||
assert.equal(f.controller.state().route, 'standard');
|
||||
assert.deepEqual(f.routed.at(-1), [['video', 'audio'], f.standardInput], 'bypass follows the exact Standard route');
|
||||
await f.controller.enable({ preset: 'bass' }, 0);
|
||||
assert.equal(f.controller.state().bypassed, true);
|
||||
assert.equal(f.controller.state().route, 'standard');
|
||||
assert.deepEqual(f.routed.at(-1), [['video', 'audio'], f.standardInput], 'settings changes cannot replace the Standard route while bypassed');
|
||||
assert.ok(f.bypass.at(-1));
|
||||
assert.equal(await f.controller.toggleBypass(), false);
|
||||
assert.equal(f.controller.state().route, 'jamesdsp');
|
||||
assert.equal(f.bypass.at(-1), false);
|
||||
});
|
||||
23
frontend/jamesdsp-ir.test.js
Normal file
23
frontend/jamesdsp-ir.test.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
test('generated room responses support four true-stereo paths and respect the low-memory cap', async () => {
|
||||
const { generateIR, BUILTIN_IRS } = await import('./vendor/jamesdsp/ir-builtins.mjs');
|
||||
const { resampleIR } = await import('./vendor/jamesdsp/ir-utils.mjs');
|
||||
const ir = generateIR('Hall', 48000, 4, { maxFrames: 24000 });
|
||||
assert.equal(ir.numberOfChannels, 4);
|
||||
assert.equal(ir.length, 24000);
|
||||
assert.equal(ir.truncated, true);
|
||||
assert.ok(ir.getChannelData(0).some((sample, i) => sample !== ir.getChannelData(1)[i]), 'each matrix path has distinct taps');
|
||||
const converted = resampleIR(ir, 32000);
|
||||
assert.equal(converted.sampleRate, 32000);
|
||||
assert.equal(converted.numberOfChannels, 4);
|
||||
assert.equal(converted.length, 16000);
|
||||
assert.deepEqual(Object.keys(BUILTIN_IRS), ['Small room', 'Hall', 'Plate', 'Cathedral']);
|
||||
});
|
||||
|
||||
test('the IndexedDB store rejects impulse responses beyond its measured internal-frame cap', async () => {
|
||||
const { saveIR } = await import('./vendor/jamesdsp/ir-store.mjs');
|
||||
const oversized = { sampleRate: 48000, numberOfChannels: 4, length: 65537, getChannelData: () => new Float32Array(65537) };
|
||||
await assert.rejects(saveIR('oversized', oversized), /65,536-frame internal limit/);
|
||||
});
|
||||
11
frontend/layout-base.css
Normal file
11
frontend/layout-base.css
Normal file
@@ -0,0 +1,11 @@
|
||||
/* Shared markup defaults stay eager when individual layouts load on demand. */
|
||||
.stg-overlay, .skip10, .deck-chips, .vol-pct, .mode-name { display: none; }
|
||||
.np-btn .ti, .np-btn .tl { pointer-events: none; }
|
||||
.np-btn .ti { width: 20px; height: 20px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.np-btn .i-done { display: none; }
|
||||
.np-btn.done .i-save { display: none; }
|
||||
.np-btn.done .i-done { display: inline; }
|
||||
.stg-bot, .ch-badge, .upnext-clear, .list-sub, .p2p-strip { display: none; }
|
||||
.sleep-status .sl-ic, .sleep-status .sl-sub { display: none; }
|
||||
.ab-group { display: contents; }
|
||||
.mc-tune { display: none; }
|
||||
176
frontend/lazy.js
Normal file
176
frontend/lazy.js
Normal file
@@ -0,0 +1,176 @@
|
||||
/* Build-local classic-script loader. The inert manifest precedes this head script. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
const doc = root.document;
|
||||
let manifest;
|
||||
try { manifest = JSON.parse(doc.getElementById('ytp-assets').textContent); }
|
||||
catch { return; } // The index must supply this map; never fetch one during boot.
|
||||
const assets = new Map(), groups = new Map(), hooks = new Map();
|
||||
let warmScheduled = false;
|
||||
const css = new Set([...doc.querySelectorAll('link[data-lazy-css]')].map(link => link.dataset.lazyCss));
|
||||
const selected = new Map();
|
||||
const url = path => selected.get(path) || (manifest.files[path]?.h ? path + '?v=' + manifest.files[path].h : './' + path.replace(/^\//,''));
|
||||
function reloadRequired(name) {
|
||||
api.reloadRequired = true;
|
||||
if (root.CustomEvent) root.dispatchEvent?.(new root.CustomEvent('ytp-reload-required', { detail: { group: name } }));
|
||||
}
|
||||
function compatible(name, current, seen = new Set()) {
|
||||
if(seen.has(name))return true;
|
||||
seen.add(name);
|
||||
const built=manifest.groups[name], newer=current.groups[name];
|
||||
if(!built || !newer || built.contract!==newer.contract)return false;
|
||||
return (newer.dependencies || []).every(dependency=>(built.dependencies || []).includes(dependency)&&compatible(dependency,current,seen));
|
||||
}
|
||||
async function select(name) {
|
||||
if (!root.caches) return;
|
||||
try {
|
||||
const cache = await root.caches.open('ytplayer-assets');
|
||||
const response = await cache.match('/__ytp_asset_state');
|
||||
const current = response && (await response.json()).current;
|
||||
const built = manifest.groups[name], newer = current?.groups[name];
|
||||
// The active layout's CSS already executed in the parser before app boot.
|
||||
if(name === 'layout:'+api.layout) return;
|
||||
if (!newer || current.buildTag === manifest.buildTag) return;
|
||||
if (!compatible(name,current)) { reloadRequired(name); return; }
|
||||
const changedList = newer.files.join('\n') !== built.files.join('\n');
|
||||
const available = new Map();
|
||||
for (const path of changedList ? newer.files : built.files) {
|
||||
// Membership and contract must both match the running core's expectation.
|
||||
if (!newer.files.includes(path) || !current.files[path]) continue;
|
||||
const key = path + '?v=' + current.files[path].h;
|
||||
const held = await cache.match(key);
|
||||
if (held?.headers.get('X-Asset-Hash') === current.files[path].h) available.set(path, key);
|
||||
}
|
||||
if(changedList && available.size !== newer.files.length)return;
|
||||
for(const [path,key] of available)selected.set(path,key);
|
||||
if(changedList)return newer;
|
||||
} catch { /* Private browsing/cache eviction: keep the embedded build-local map. */ }
|
||||
}
|
||||
function inject(path) {
|
||||
if (assets.has(path)) return assets.get(path);
|
||||
const style = /\.css$/.test(path);
|
||||
if (style && css.has(path)) return Promise.resolve();
|
||||
if (!style && !/\.js$/.test(path)) return Promise.resolve(); // workers/imports/fonts are not page scripts
|
||||
const loadOnce = () => new Promise((resolve, reject) => {
|
||||
const node = doc.createElement(style ? 'link' : 'script');
|
||||
const timer = setTimeout(() => { node.remove?.(); reject(new Error('Timed out loading ' + path)); }, 20000);
|
||||
if (style) { node.rel = 'stylesheet'; node.href = url(path); }
|
||||
else { node.async = false; node.src = url(path); }
|
||||
node.onload = () => { clearTimeout(timer); resolve(); };
|
||||
node.onerror = () => { clearTimeout(timer); node.remove?.(); reject(new Error('Unable to load ' + path)); };
|
||||
doc.head.append(node);
|
||||
});
|
||||
const task = root.AsyncGuard?.retry
|
||||
? root.AsyncGuard.retry(loadOnce, { attempts: 3 })
|
||||
: loadOnce();
|
||||
assets.set(path, task);
|
||||
task.catch(() => assets.delete(path));
|
||||
return task;
|
||||
}
|
||||
function load(name) {
|
||||
if (groups.has(name)) return groups.get(name);
|
||||
const group = manifest.groups[name];
|
||||
if (!group) return Promise.reject(new Error('Unknown asset group: ' + name));
|
||||
const task = (async () => {
|
||||
for (const dependency of group.dependencies || []) await load(dependency);
|
||||
const selectedGroup = await select(name) || group;
|
||||
for (const path of selectedGroup.files) if (!/-worker\.js$/.test(path)) await inject(path);
|
||||
for (const fn of hooks.get(name) || []) await fn();
|
||||
group.executed = true;
|
||||
})();
|
||||
groups.set(name, task);
|
||||
task.catch(error => {
|
||||
groups.delete(name);
|
||||
if (root.CustomEvent) root.dispatchEvent?.(new root.CustomEvent('ytp-lazy-error', { detail: { group: name, error } }));
|
||||
});
|
||||
return task;
|
||||
}
|
||||
const api = {
|
||||
load, prefetch: load, loaded: name => !!manifest.groups[name]?.executed, url,
|
||||
manifest, layout: 'classic', reloadRequired: false,
|
||||
worker(path) {
|
||||
const worker=new root.Worker(url(path)), post=worker.postMessage.bind(worker); let first=true;
|
||||
worker.postMessage=(message,transfer)=>{
|
||||
if(first) { first=false; message={...message,assetUrls:{sha256:url('/sha256.js'),wasm:url('/sha256-wasm.js'),resume:url('/resume-core.js')}}; }
|
||||
post(message,transfer);
|
||||
};
|
||||
return worker;
|
||||
},
|
||||
reportLayout(registration) {
|
||||
const send = reg => {
|
||||
const workers = new Set([root.navigator.serviceWorker.controller, reg?.installing, reg?.waiting, reg?.active]);
|
||||
for(const worker of workers) worker?.postMessage({type:'LAYOUT',value:api.layout});
|
||||
};
|
||||
if(registration)send(registration);
|
||||
else root.navigator.serviceWorker?.getRegistration().then(send).catch(()=>{});
|
||||
},
|
||||
warm() {
|
||||
if (warmScheduled || !root.navigator.serviceWorker || root.navigator.onLine === false) return;
|
||||
warmScheduled = true;
|
||||
const start = () => {
|
||||
warmScheduled = false;
|
||||
if(root.navigator.onLine === false)return;
|
||||
root.navigator.serviceWorker.ready.then(reg => {
|
||||
for (const worker of new Set([reg.active, reg.waiting])) worker?.postMessage({type:'COMPLETE_ASSETS'});
|
||||
}).catch(()=>{});
|
||||
};
|
||||
if (root.requestIdleCallback) root.requestIdleCallback(start, { timeout: 8000 });
|
||||
else setTimeout(start, 5000);
|
||||
},
|
||||
onLoad(name, fn) {
|
||||
if(api.loaded(name)) { Promise.resolve().then(fn).catch(error=>{if(root.CustomEvent)root.dispatchEvent?.(new root.CustomEvent('ytp-lazy-error',{detail:{group:name,error}}));}); return; }
|
||||
const list = hooks.get(name) || []; list.push(fn); hooks.set(name, list);
|
||||
},
|
||||
proxy(name, methods, globalName) {
|
||||
return Object.fromEntries(methods.map(method => [method, async (...args) => {
|
||||
await load(name);
|
||||
const key = globalName || name.split(':')[1];
|
||||
const target = root.YT?.[key] || root[key];
|
||||
if (typeof target?.[method] !== 'function') throw new Error('Missing lazy method: ' + method);
|
||||
return target[method](...args);
|
||||
}]));
|
||||
},
|
||||
intent(element, name) {
|
||||
if (!element) return;
|
||||
for (const type of ['pointerenter', 'focus', 'touchstart']) element.addEventListener(type, () => load(name).catch(() => {}), { once: true, passive: true });
|
||||
},
|
||||
};
|
||||
// Own this response once. Classic-script and fetch preloads do not share
|
||||
// their cached bodies in WebKit; the later bootstrap consumes this promise.
|
||||
const agent=root.navigator.userAgent || '';
|
||||
api.captureApp=!!(manifest.appCache && !root.navigator.serviceWorker?.controller && root.crypto?.subtle && /AppleWebKit\//.test(agent) && !/(?:Chrome|Chromium|Edg|OPR)\//.test(agent));
|
||||
if(api.captureApp) {
|
||||
api.appResponse=(async()=>{
|
||||
const key=url('/app.js');
|
||||
try {
|
||||
const cache=await root.caches?.open('ytplayer-assets'), held=await cache?.match(key);
|
||||
if(held?.headers.get('X-Asset-Hash')===manifest.files['/app.js']?.h && held)return held;
|
||||
} catch {}
|
||||
return root.fetch(key,{credentials:'same-origin'});
|
||||
})();
|
||||
api.appResponse.catch(()=>{});
|
||||
} else if(manifest.appCache) {
|
||||
// Preserve native script/code-cache reuse in engines without the WebKit gap.
|
||||
const preload=doc.createElement('link');preload.rel='preload';preload.as='script';preload.fetchPriority='low';preload.href=url('/app.js');doc.head.append(preload);
|
||||
}
|
||||
root.Lazy = api;
|
||||
root.navigator.serviceWorker?.addEventListener('message',event=>{
|
||||
if(event.data?.type==='ASSET_LAYOUT_REQUEST') event.source?.postMessage({type:'LAYOUT',value:api.layout});
|
||||
if(event.data?.type==='SW_UPDATE_AVAILABLE') api.warm();
|
||||
});
|
||||
root.navigator.serviceWorker?.addEventListener('controllerchange',()=>{api.reportLayout();api.warm();});
|
||||
root.addEventListener?.('online',()=>api.warm());
|
||||
api.warm();
|
||||
let settings={};
|
||||
try { settings=JSON.parse(root.localStorage.getItem('_ytpdata') || '{}').settings || {}; api.layout=settings.layout || 'classic'; } catch {}
|
||||
Object.assign(doc.documentElement.dataset,{layout:api.layout,theme:settings.theme || 'dark',font:settings.fontScale || 'normal',density:settings.density || 'comfortable',perf:settings.perfMode?'on':'off',motion:settings.reduceMotion?'reduced':'full'});
|
||||
const layoutName = manifest.groups['layout:' + api.layout] ? 'layout:'+api.layout : 'layout:classic';
|
||||
const layoutGroups = new Set([layoutName]);
|
||||
for(const name of layoutGroups) for(const dependency of manifest.groups[name]?.dependencies || []) layoutGroups.add(dependency);
|
||||
if (doc.readyState === 'loading') for (const path of [...layoutGroups].flatMap(name=>manifest.groups[name]?.files || [])) {
|
||||
if (!/\.css$/.test(path) || css.has(path)) continue;
|
||||
// Parser-inserted CSS blocks rendering; dynamic head links do not on all WebKit versions.
|
||||
css.add(path);
|
||||
doc.write('<link rel="stylesheet" href="' + url(path) + '" data-lazy-css="' + path + '">');
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
129
frontend/lazy.test.js
Normal file
129
frontend/lazy.test.js
Normal file
@@ -0,0 +1,129 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
function fixture(current, held = [], appCache = false, userAgent = 'AppleWebKit/605.1 Safari/605.1', controlled = false) {
|
||||
const inserted = [], listeners = {}, writes = [], fetched = [];
|
||||
const manifest = { buildTag: 'own', appCache, groups: { core: { files: [] }, 'layout:classic': { files: ['/classic.css'] }, 'layout:glass-stage': { files: ['/glass.css', '/one.js', '/two.js'] }, 'feature:test': { contract:1, files: ['/one.js', '/two.js'] } }, files: { '/app.js':{h:'a'}, '/classic.css': {h:'c'}, '/glass.css':{h:'g'}, '/one.js':{h:'1'}, '/two.js':{h:'2'} } };
|
||||
const doc = { readyState:'loading', documentElement: { dataset:{} }, getElementById: () => ({ textContent:JSON.stringify(manifest) }), querySelectorAll: () => [], createElement: tag => ({ tagName:tag.toUpperCase() }), write: value => writes.push(value), addEventListener: (t,f) => listeners[t]=f };
|
||||
doc.head = { append: node => { inserted.push(node); queueMicrotask(() => node.onload?.()); } };
|
||||
const root = { document:doc, localStorage:{ getItem:() => '{"settings":{"layout":"glass-stage"}}' }, console, Promise, URL, crypto:{subtle:{}}, AsyncGuard:require('./async-guard'), requestIdleCallback:fn=>{root.__idle=fn;}, fetch:async key=>{fetched.push(key);return new Response('app');}, setTimeout, clearTimeout, navigator:{userAgent,serviceWorker:{ready:Promise.resolve({}),controller:controlled?{}:null,addEventListener(){}}}, addEventListener(){} };
|
||||
if (current) root.caches = { open: async () => ({ match: async key => key === '/__ytp_asset_state' ? new Response(JSON.stringify({current})) : held.includes(key) ? new Response('', {headers:{'X-Asset-Hash':key.split('=')[1]}}) : undefined }) };
|
||||
root.window=root; root.globalThis=root;
|
||||
vm.runInNewContext(readFileSync(require.resolve('./lazy.js'),'utf8'), root);
|
||||
return { root, inserted, writes, listeners, manifest, fetched };
|
||||
}
|
||||
test('external head bootstrap selects remembered CSS before paint without fetching a manifest', () => {
|
||||
const {root,writes}=fixture();
|
||||
assert.equal(root.document.documentElement.dataset.layout,'glass-stage');
|
||||
assert.deepEqual(writes,['<link rel="stylesheet" href="/glass.css?v=g" data-lazy-css="/glass.css">']);
|
||||
});
|
||||
test('lazy groups execute ordered classic scripts once, sharing concurrent calls and dependencies', async () => {
|
||||
const {root,inserted}=fixture();
|
||||
const one=root.Lazy.load('feature:test'), two=root.Lazy.load('feature:test');
|
||||
await Promise.all([one,two]);
|
||||
assert.deepEqual(inserted.map(n=>n.src),['/one.js?v=1','/two.js?v=2']);
|
||||
assert.equal(root.Lazy.loaded('feature:test'),true);
|
||||
await root.Lazy.load('layout:glass-stage');
|
||||
assert.equal(inserted.filter(n=>n.tagName==='SCRIPT').length,2);
|
||||
});
|
||||
test('transient script injection failure retries before proxy dispatch', async () => {
|
||||
const {root,inserted}=fixture();
|
||||
const append=root.document.head.append;
|
||||
let first=true;
|
||||
root.document.head.append=n=>{inserted.push(n);queueMicrotask(()=>{if(first){first=false;n.onerror?.();}else n.onload?.();});};
|
||||
await root.Lazy.load('feature:test');
|
||||
assert.equal(inserted.length,3,'first file is retried, then the second file loads');
|
||||
root.document.head.append=append;
|
||||
root.TestApi={open:value=>value+1};
|
||||
const proxy=root.Lazy.proxy('feature:test',['open'],'TestApi');
|
||||
assert.equal(await proxy.open(4),5);
|
||||
assert.equal(root.Lazy.url('/two.js'),'/two.js?v=2');
|
||||
await assert.rejects(root.Lazy.load('feature:missing'),/Unknown/);
|
||||
});
|
||||
|
||||
test('executing groups are pinned even after a newer compatible manifest arrives', async () => {
|
||||
const f=fixture(); await f.root.Lazy.load('feature:test');
|
||||
const newer=JSON.parse(JSON.stringify(f.manifest)); newer.files['/one.js'].h='new';
|
||||
f.root.caches={open:async()=>({match:async key=>key==='/__ytp_asset_state'?new Response(JSON.stringify({current:newer})):new Response('',{headers:{'X-Asset-Hash':'new'}})})};
|
||||
await f.root.Lazy.load('feature:test');
|
||||
assert.deepEqual(f.inserted.map(n=>n.src),['/one.js?v=1','/two.js?v=2']);
|
||||
});
|
||||
test('unexecuted compatible group immediately uses newly cached file URLs', async () => {
|
||||
const next=fixture().manifest; next.buildTag='new'; next.files['/one.js'].h='new';
|
||||
const f=fixture(next,['/one.js?v=new','/two.js?v=2']);
|
||||
await f.root.Lazy.load('feature:test');
|
||||
assert.deepEqual(f.inserted.map(n=>n.src),['/one.js?v=new','/two.js?v=2']);
|
||||
assert.equal(f.root.Lazy.reloadRequired,false);
|
||||
});
|
||||
test('unexecuted incompatible group keeps its running-build N-1 URLs and requires reload', async () => {
|
||||
const next=fixture().manifest; next.buildTag='new'; next.files['/one.js'].h='new'; next.groups['feature:test'].contract=2;
|
||||
const f=fixture(next,['/one.js?v=new','/two.js?v=2']);
|
||||
const events=[]; f.root.CustomEvent=class {constructor(type,opts){this.type=type;this.detail=opts.detail}}; f.root.dispatchEvent=e=>events.push(e.type);
|
||||
await f.root.Lazy.load('feature:test');
|
||||
assert.deepEqual(f.inserted.map(n=>n.src),['/one.js?v=1','/two.js?v=2']);
|
||||
assert.equal(f.root.Lazy.reloadRequired,true);
|
||||
assert.ok(events.includes('ytp-reload-required'));
|
||||
});
|
||||
|
||||
test('workers receive the pinned import URL map in their first message', () => {
|
||||
const f=fixture(), sent=[]; f.root.Worker=class { constructor(url){this.url=url;} postMessage(message,transfer){sent.push({message,transfer});} };
|
||||
f.manifest.files['/worker.js']={h:'w'}; f.root.Lazy.manifest.files['/worker.js']={h:'w'};
|
||||
const worker=f.root.Lazy.worker('/worker.js');worker.postMessage({op:'open'},['transfer']);worker.postMessage({op:'finish'});
|
||||
assert.equal(worker.url,'/worker.js?v=w');assert.equal(sent[0].message.assetUrls.sha256,f.root.Lazy.url('/sha256.js'));assert.equal(sent[0].message.assetUrls.wasm,f.root.Lazy.url('/sha256-wasm.js'));assert.deepEqual(sent[0].transfer,['transfer']);assert.equal(sent[1].message.assetUrls,undefined);
|
||||
});
|
||||
|
||||
test('new group files with a changed dependency contract cannot run against the old dependency',async()=>{
|
||||
const next=fixture().manifest;next.buildTag='next';next.groups['feature:test'].dependencies=['feature:dep'];next.groups['feature:dep']={contract:2,files:[]};next.files['/one.js'].h='new';
|
||||
const f=fixture(next,['/one.js?v=new','/two.js?v=2']);f.root.Lazy.manifest.groups['feature:test'].dependencies=['feature:dep'];f.root.Lazy.manifest.groups['feature:dep']={contract:1,files:[]};
|
||||
await f.root.Lazy.load('feature:test');assert.deepEqual(f.inserted.map(n=>n.src),['/one.js?v=1','/two.js?v=2']);assert.equal(f.root.Lazy.reloadRequired,true);
|
||||
});
|
||||
|
||||
test('classic chunk registry proxies use window.YT and keep the executed singleton pinned',async()=>{
|
||||
const {root,inserted}=fixture();root.YT={TestApi:{open:n=>n+2}};
|
||||
const proxy=root.Lazy.proxy('feature:test',['open'],'TestApi');
|
||||
assert.equal(await proxy.open(3),5);
|
||||
const singleton=root.YT.TestApi;await root.Lazy.load('feature:test');
|
||||
assert.equal(root.YT.TestApi,singleton);assert.equal(inserted.length,2);
|
||||
});
|
||||
|
||||
|
||||
test('online launches request worker completion even with Save-Data and skip offline messages',async()=>{
|
||||
const f=fixture(),sent=[];
|
||||
const active={postMessage:m=>sent.push(m)},waiting={postMessage:m=>sent.push(m)};
|
||||
f.root.navigator.connection={saveData:true};f.root.navigator.onLine=true;
|
||||
f.root.navigator.serviceWorker={ready:Promise.resolve({active,waiting})};
|
||||
f.root.Lazy.warm();await Promise.resolve();
|
||||
assert.equal(sent.length,0,'warming waits until the page is idle');
|
||||
f.root.__idle();await Promise.resolve();
|
||||
assert.deepEqual(sent.map(m=>m.type),['COMPLETE_ASSETS','COMPLETE_ASSETS']);
|
||||
f.root.navigator.onLine=false;f.root.Lazy.warm();await Promise.resolve();assert.equal(sent.length,2);
|
||||
});
|
||||
test('server-enabled head capture owns exactly one app response; native and rollback do not fetch',async()=>{
|
||||
const modern=fixture(null,[],true);await modern.root.Lazy.appResponse;
|
||||
assert.deepEqual(modern.fetched,['/app.js?v=a']);
|
||||
const legacy=fixture();assert.deepEqual(legacy.fetched,[]);assert.equal(legacy.root.Lazy.appResponse,undefined);
|
||||
});
|
||||
|
||||
|
||||
test('head capture is cache-first even before a worker controls the page',async()=>{
|
||||
const f=fixture(fixture().manifest,['/app.js?v=a'],true);const response=await f.root.Lazy.appResponse;
|
||||
assert.equal(response.headers.get('X-Asset-Hash'),'a');assert.deepEqual(f.fetched,[]);
|
||||
});
|
||||
|
||||
|
||||
test('Chromium retains native script loading and only preloads the current app URL',async()=>{
|
||||
const f=fixture(null,[],true,'AppleWebKit/537.36 Chrome/140.0 Safari/537.36');
|
||||
assert.equal(f.root.Lazy.captureApp,false);assert.equal(f.root.Lazy.appResponse,undefined);assert.deepEqual(f.fetched,[]);
|
||||
assert.equal(f.inserted.length,1);assert.equal(f.inserted[0].rel,'preload');assert.equal(f.inserted[0].as,'script');assert.equal(f.inserted[0].href,'/app.js?v=a');assert.equal(f.inserted[0].fetchPriority,'low');
|
||||
});
|
||||
test('iOS Chromium-branded browsers still use their WebKit response capture',async()=>{
|
||||
const f=fixture(null,[],true,'AppleWebKit/605.1 CriOS/140.0 Mobile Safari/605.1');await f.root.Lazy.appResponse;
|
||||
assert.equal(f.root.Lazy.captureApp,true);assert.deepEqual(f.fetched,['/app.js?v=a']);
|
||||
});
|
||||
|
||||
test('controlled WebKit visits use worker-cached native app scripts without a response handoff',async()=>{
|
||||
const f=fixture(null,[],true,'AppleWebKit/605.1 Safari/605.1',true);
|
||||
assert.equal(f.root.Lazy.captureApp,false);assert.equal(f.root.Lazy.appResponse,undefined);assert.deepEqual(f.fetched,[]);
|
||||
assert.equal(f.inserted[0].as,'script');assert.equal(f.inserted[0].href,'/app.js?v=a');assert.equal(f.inserted[0].fetchPriority,'low');
|
||||
});
|
||||
@@ -16,10 +16,19 @@
|
||||
for (const link of root.document.querySelectorAll('link[rel="stylesheet"]')) { const copy = doc.createElement('link'); copy.rel = 'stylesheet'; copy.href = link.href; doc.head.append(copy); }
|
||||
const style = doc.createElement('style'); style.textContent = 'body{margin:0;background:var(--bg,#151719);color:var(--text,#fff);display:flex;flex-direction:column;height:100vh;overflow:hidden}.lyrics-floating-view{position:relative!important;inset:auto!important;flex:1;min-height:0;display:flex!important;flex-direction:column!important;width:100%!important}.lyrics-floating-view .sl-list{flex:1;min-height:0;height:auto!important}.lyrics-floating-controls{display:flex;gap:8px;padding:12px}.lyrics-floating-controls button{flex:1;min-height:48px;background:var(--bg-2,#25292d);color:inherit;border:1px solid var(--line,#555);border-radius:8px}'; doc.head.append(style);
|
||||
const view = adapter.view(), marker = root.document.createComment('floating lyrics'); view.before(marker); view.classList.add('lyrics-floating-view'); doc.body.append(view);
|
||||
doc.body.append(root.document.querySelector('#transport-play').ownerSVGElement.cloneNode(true));
|
||||
const controls = doc.createElement('div'); controls.className = 'lyrics-floating-controls';
|
||||
for (const [label, action] of [['Play / Pause', adapter.toggle], ['Next', adapter.next]]) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = label; b.onclick = action; controls.append(b); } doc.body.append(controls);
|
||||
for (const [kind, label, action] of [['play', 'Play', adapter.toggle], ['next', 'Next', adapter.next]]) { const b = doc.createElement('button'); b.type = 'button'; root.TransportIcons.set(b,kind,label); b.onclick = action; controls.append(b); } doc.body.append(controls);
|
||||
const repaint = () => {
|
||||
doc.documentElement.dataset.layout=root.document.documentElement.dataset.layout; doc.documentElement.dataset.theme=root.document.documentElement.dataset.theme;
|
||||
const kind=root.document.querySelector('#playBtn .transport-icon')?.dataset.transportIcon || 'play';
|
||||
root.TransportIcons.set(controls.firstElementChild,kind);
|
||||
};
|
||||
const transportObserver=new root.MutationObserver(repaint);
|
||||
transportObserver.observe(root.document.getElementById('playBtn'),{childList:true});
|
||||
transportObserver.observe(root.document.documentElement,{attributes:true,attributeFilter:['data-layout','data-theme']}); repaint();
|
||||
const win = pipWindow; let restored = false;
|
||||
cleanup = () => { if (restored) return; restored = true; marker.replaceWith(view); view.classList.remove('lyrics-floating-view'); pipWindow = null; adapter.refresh(); if (!win.closed) win.close(); };
|
||||
cleanup = () => { if (restored) return; restored = true; transportObserver.disconnect(); marker.replaceWith(view); view.classList.remove('lyrics-floating-view'); pipWindow = null; adapter.refresh(); if (!win.closed) win.close(); };
|
||||
win.addEventListener('pagehide', cleanup, { once: true }); win.addEventListener('resize', adapter.refresh); adapter.refresh();
|
||||
} else {
|
||||
const canvas = root.document.createElement('canvas'); canvas.width = 840; canvas.height = 500; const ctx = canvas.getContext('2d');
|
||||
@@ -33,7 +42,7 @@
|
||||
}
|
||||
function configure(options) {
|
||||
adapter = options; const supported = !!mode();
|
||||
for (const id of ['stageLyricsAuto', 'serviceAutoscrollBtn', 'pipBtn']) { const anchor = root.document.getElementById(id); if (!anchor || !supported) continue; const button = root.document.createElement('button'); button.type = 'button'; button.className = 'np-btn'; button.textContent = '⧉ Lyrics'; button.title = 'Open floating lyrics'; button.setAttribute('aria-label', 'Open floating lyrics window'); button.onclick = open; anchor.after(button); }
|
||||
for (const id of ['stageLyricsAuto', 'serviceAutoscrollBtn', 'pipBtn']) { const anchor = root.document.getElementById(id); if (!anchor || !supported) continue; const button = root.document.getElementById('floating-'+id) || root.document.createElement('button'); button.id='floating-'+id; button.type = 'button'; button.className = 'np-btn'; button.textContent = '⧉ Lyrics'; button.title = 'Open floating lyrics'; button.setAttribute('aria-label', 'Open floating lyrics window'); button.onclick = open; if(!button.isConnected)anchor.after(button); }
|
||||
root.SettingsSections.register({ id: 'floating-lyrics', title: 'Floating lyrics', cluster: 'Live & worship', summary: () => mode() === 'document' ? 'Interactive window available' : mode() ? 'Lyric video PiP available' : 'Not supported on this browser', render(container) { const p = container.ownerDocument.createElement('p'); p.textContent = mode() === 'document' ? 'Open synced lyrics with seeking, Play/Pause and Next in an always-on-top window.' : mode() ? 'This browser supports a lyric-video window. Playback controls remain in the player.' : 'Floating lyrics are not supported on this browser.'; container.append(p); if (supported) { const b = container.ownerDocument.createElement('button'); b.type = 'button'; b.className = 'settings-back'; b.textContent = 'Open floating lyrics'; b.onclick = open; container.append(b); } } });
|
||||
}
|
||||
const api = { configure, support, wrap, element: id => pipWindow?.document.getElementById(id) || null }; if (typeof module !== 'undefined') module.exports = api; else root.LyricsWindow = api;
|
||||
|
||||
19
frontend/media-icons.test.js
Normal file
19
frontend/media-icons.test.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { readdirSync, readFileSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
|
||||
test('download and offline icons never use emoji or text glyphs', () => {
|
||||
// Arrow keys are prose, not action icons. Exact lines prevent new exceptions.
|
||||
const allow = new Map([
|
||||
['index.html', new Set(['<div class="shortcut-row"><kbd>↑</kbd><kbd>↓</kbd><span>Volume up / down</span></div>'])],
|
||||
['admin.html', new Set(['<span class="muted" style="font-size:12.5px">Shortcuts: <code>Space</code> stamp (Tap-sync) · <code>S</code> stamp selected · <code>K</code> play · <code>↑↓</code> select · <code>⌘/Ctrl S</code> save</span>'])],
|
||||
]);
|
||||
const failures=[];
|
||||
for (const file of readdirSync(__dirname).filter(f=>/\.(js|html|css)$/.test(f) && !f.endsWith('.test.js'))) {
|
||||
readFileSync(join(__dirname,file),'utf8').split('\n').forEach((line,i)=>{
|
||||
if (/[⬇⤓⇩💾📥↓]/u.test(line) && !allow.get(file)?.has(line.trim())) failures.push(`${file}:${i+1}`);
|
||||
});
|
||||
}
|
||||
assert.deepEqual(failures,[]);
|
||||
});
|
||||
@@ -21,3 +21,13 @@ html[data-layout] .dl-row .card-info { grid-column:2; grid-row:1; min-width:0; }
|
||||
.dl-row .dl-act.danger { color:var(--accent); background:transparent; }
|
||||
.dl-row .dl-act:disabled { opacity:.5; cursor:wait; }
|
||||
@media(min-width:900px) { html[data-layout] .card.dl-row { grid-template-columns:88px minmax(0,1fr); } html[data-layout] .dl-row .thumb { width:88px; } }
|
||||
|
||||
/* Save failures remain readable; raw extractor output is opt-in, never HTML. */
|
||||
.dl-failed .card-channel { white-space:normal; line-height:1.5; }
|
||||
.download-error-details { min-width:0; color:var(--text-2); font:12px/1.5 var(--ui); }
|
||||
.download-error-details summary { min-height:44px; padding:12px 0; cursor:pointer; }
|
||||
.download-error-details summary:focus-visible { outline:2px solid var(--accent); outline-offset:2px; border-radius:4px; }
|
||||
.download-error-details pre { white-space:pre-wrap; overflow-wrap:anywhere; max-height:240px; overflow:auto; margin:0 0 8px; font:12px/1.5 var(--mono); }
|
||||
.download-job { grid-template-columns:minmax(0,1fr); }
|
||||
.download-job > div { flex-wrap:wrap; }
|
||||
.download-job progress { min-width:0; }
|
||||
|
||||
@@ -34,8 +34,7 @@
|
||||
|
||||
// Incremental SHA-256 (frontend/sha256.js): the file is hashed while it is
|
||||
// written, so the device knows its content id without re-reading the file.
|
||||
try { importScripts('/sha256.js'); } catch { /* hashing unavailable — save still works */ }
|
||||
importScripts('/resume-core.js');
|
||||
|
||||
|
||||
async function getVideosDir() {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
@@ -203,6 +202,10 @@ async function writeFile(dirName, name, buffer) {
|
||||
}
|
||||
|
||||
self.onmessage = async (e) => {
|
||||
self.__ASSET_URLS__=e.data?.assetUrls || {};
|
||||
try { if(!self.Sha256)importScripts(self.__ASSET_URLS__.sha256 || '/sha256.js'); } catch { /* hashing unavailable — legacy save still works */ }
|
||||
try { if(!self.ResumeCore)importScripts(self.__ASSET_URLS__.resume || '/resume-core.js'); }
|
||||
catch(error) { self.postMessage({type:'error',error:error.message}); return; }
|
||||
if (e.data && e.data.op === 'write') {
|
||||
try { await writeFile(e.data.dir, e.data.name, e.data.buffer); self.postMessage({ type: 'written' }); }
|
||||
catch (err) { self.postMessage({ type: 'error', error: err && err.message ? err.message : String(err) }); }
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
return new Promise((resolve) => {
|
||||
let worker;
|
||||
try {
|
||||
worker = new Worker('/opfs-worker.js');
|
||||
worker = window.Lazy ? window.Lazy.worker('/opfs-worker.js') : new Worker('/opfs-worker.js');
|
||||
} catch {
|
||||
resolve({ ok: false, fallback: true });
|
||||
return;
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
function hashInWorker(name) {
|
||||
return new Promise((resolve) => {
|
||||
let w;
|
||||
try { w = new Worker('/hash-worker.js'); } catch { resolve(null); return; }
|
||||
try { w = window.Lazy ? window.Lazy.worker('/hash-worker.js') : new Worker('/hash-worker.js'); } catch { resolve(null); return; }
|
||||
w.onmessage = (e) => { w.terminate(); resolve(e.data && e.data.ok ? e.data : null); };
|
||||
w.onerror = () => { w.terminate(); resolve(null); };
|
||||
w.postMessage({ name });
|
||||
@@ -216,7 +216,7 @@
|
||||
ws = sock;
|
||||
let ping = null;
|
||||
sock.onopen = () => {
|
||||
sock.send(JSON.stringify({ type: 'auth', device: d.deviceId, secret: d.secret }));
|
||||
sock.send(JSON.stringify({ type: 'auth', device: d.deviceId, secret: d.secret, profile: hooks.getProfile(), profileSecret: hooks.getProfileSecret?.() || '' }));
|
||||
// The server drops sockets idle for 120 s (server.js websocketHandler).
|
||||
ping = setInterval(() => { try { sock.send('{"type":"ping"}'); } catch { /* closing */ } }, 50_000);
|
||||
};
|
||||
@@ -285,7 +285,7 @@
|
||||
// and only for the exact cid this device holds.
|
||||
let restoring = false;
|
||||
onMessage('upload-request', async (m) => {
|
||||
if (restoring || hooks.getSettings().p2pShare === false) return;
|
||||
if (restoring || hooks.getSettings().p2pShare === false || hooks.getSettings().directTransfer !== false) return;
|
||||
const rec = window.DeviceDB ? await window.DeviceDB.getFile(m.videoId) : null;
|
||||
if (!rec || rec.cid !== m.cid) return;
|
||||
restoring = true;
|
||||
@@ -296,7 +296,7 @@
|
||||
function start(h) {
|
||||
hooks = { ...hooks, ...(h || {}) };
|
||||
const idle = window.requestIdleCallback || ((fn) => setTimeout(fn, 1));
|
||||
setTimeout(() => idle(() => sync()), 8000);
|
||||
setTimeout(() => idle(() => sync()), h?.initialDelay ?? 8000);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible' && Date.now() - lastSync > RESYNC_MS) sync();
|
||||
});
|
||||
@@ -304,6 +304,7 @@
|
||||
|
||||
window.P2PClient = {
|
||||
start, changed, sync, device, authHeaders, config, contribute, unknownVideos,
|
||||
send: m => { if (!ws || ws.readyState !== 1) return false; ws.send(JSON.stringify(m)); return true; },
|
||||
onMessage, signal, peer: () => myPeer, isConnected: () => !!(ws && ws.readyState === 1 && myPeer),
|
||||
};
|
||||
}());
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
* The ".part" suffix keeps OPFS.listVideos() from ever listing a partial file.
|
||||
* ========================================================================== */
|
||||
'use strict';
|
||||
importScripts('/sha256.js');
|
||||
|
||||
let dir = null;
|
||||
let handle = null;
|
||||
@@ -36,6 +35,7 @@ self.onmessage = (e) => { chain = chain.then(() => onOp(e.data || {})); };
|
||||
|
||||
async function onOp(m) {
|
||||
try {
|
||||
if (!self.Sha256) { self.__ASSET_URLS__=m.assetUrls || {}; importScripts(self.__ASSET_URLS__.sha256 || '/sha256.js'); }
|
||||
if (m.op === 'open') {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
dir = await root.getDirectoryHandle('videos', { create: true });
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
return new Promise((resolve) => {
|
||||
const xid = newXid();
|
||||
const pc = new RTCPeerConnection({ iceServers });
|
||||
const worker = new Worker('/p2p-recv-worker.js');
|
||||
const worker = window.Lazy ? window.Lazy.worker('/p2p-recv-worker.js') : new Worker('/p2p-recv-worker.js');
|
||||
let settled = false;
|
||||
let idle = null;
|
||||
let expected = size;
|
||||
|
||||
@@ -200,7 +200,7 @@
|
||||
button('Transcribe in browser', () =>
|
||||
run(async (id) => {
|
||||
const buffer = await audio(id, file.files[0]);
|
||||
const engine = await import('./piano-engine.mjs');
|
||||
const engine = await import(root.Lazy?.url('/piano-engine.mjs') || './piano-engine.mjs');
|
||||
const result = await engine.transcribe(buffer, (text) => {
|
||||
if (alive) status.textContent = text;
|
||||
});
|
||||
|
||||
210
frontend/presenter.js
Normal file
210
frontend/presenter.js
Normal file
@@ -0,0 +1,210 @@
|
||||
/* Lazy classic chunk; its executing singleton stays pinned until reload. */
|
||||
(window.YT ||= {}).Presenter = (() => {
|
||||
const noop = { boot() {}, active: () => false };
|
||||
const view = $('presenterView');
|
||||
if (!WEB || !view || typeof WebSocket === 'undefined') return noop;
|
||||
const overlay = LowerThird.active();
|
||||
const KEY = overlay ? `ytpOverlayPair:${new URLSearchParams(location.search).get('code') || ''}` : 'ytpPresenterPair';
|
||||
const load = () => { try { return JSON.parse(localStorage.getItem(KEY) || 'null'); } catch { return null; } };
|
||||
const save = (v) => { try { if (v) localStorage.setItem(KEY, JSON.stringify(v)); else localStorage.removeItem(KEY); } catch { /* blocked */ } };
|
||||
const wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/remote?${new URLSearchParams(q)}`;
|
||||
const list = $('pvList');
|
||||
const st = { ws: null, state: null, stateAt: 0, hostName: '', vid: null, all: [], docOff: 0, idx: -2, retry: null, notes: new Map(), wake: null, idleTimer: null, seq: 0 };
|
||||
|
||||
function setStatus(t) { $('pvStatus').textContent = t; }
|
||||
|
||||
function show() {
|
||||
view.classList.remove('hidden');
|
||||
document.body.classList.add('presenter-open');
|
||||
// The app underneath may have focused its search box; keys belong here now.
|
||||
try { if (document.activeElement && !view.contains(document.activeElement)) document.activeElement.blur(); } catch { /* fine */ }
|
||||
try { if (Player.master && !Player.master.paused) Player.pause(); } catch { /* nothing loaded */ }
|
||||
keepAwake();
|
||||
bumpIdle();
|
||||
}
|
||||
|
||||
async function keepAwake() {
|
||||
try { if ('wakeLock' in navigator && !st.wake) { st.wake = await navigator.wakeLock.request('screen'); st.wake.addEventListener('release', () => { st.wake = null; }); } } catch { /* not allowed */ }
|
||||
}
|
||||
|
||||
function showPairForm(msg = '') {
|
||||
$('pvPair').classList.remove('hidden');
|
||||
$('pvMsg').textContent = msg;
|
||||
setStatus('Not paired');
|
||||
}
|
||||
|
||||
async function pair(code) {
|
||||
code = String(code || '').replace(/\D/g, '');
|
||||
if (code.length !== 6) { showPairForm('Enter the 6-digit code from the playing screen.'); return; }
|
||||
$('pvMsg').textContent = 'Connecting…';
|
||||
try {
|
||||
const res = await fetch('/api/remote/pair', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, name: '🖥 Presenter' }) });
|
||||
const j = await res.json().catch(() => null);
|
||||
if (!j || !j.ok) { showPairForm('⚠ ' + ((j && j.error) || `HTTP ${res.status}`)); return; }
|
||||
save({ roomId: j.roomId, remoteId: j.remoteId, token: j.token, hostName: j.hostName });
|
||||
$('pvPair').classList.add('hidden');
|
||||
connect();
|
||||
} catch {
|
||||
showPairForm('⚠ Network error — try again');
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const p = load();
|
||||
if (!p) { showPairForm(); return; }
|
||||
clearTimeout(st.retry);
|
||||
try { if (st.ws) st.ws.close(); } catch { /* gone */ }
|
||||
setStatus('Connecting…');
|
||||
const ws = new WebSocket(wsUrl({ role: 'remote', room: p.roomId, rid: p.remoteId, token: p.token, name: '🖥 Presenter' }));
|
||||
st.ws = ws;
|
||||
ws.onmessage = (e) => {
|
||||
let m; try { m = JSON.parse(e.data); } catch { return; }
|
||||
if (!overlay) { DirectMedia.attach('presenter', { send: m => ws.send(JSON.stringify(m)), peers: () => [{ id: 'host', name: st.hostName || 'Screen' }] }); }
|
||||
if (m.type === 'direct') { if (!overlay) DirectMedia.handle('presenter', m); return; }
|
||||
if (m.type === 'hello') { st.hostName = m.hostName || 'screen'; if (m.state) onState(m.state); else setStatus(`Following ${st.hostName}`); }
|
||||
else if (m.type === 'state') onState(m.state);
|
||||
else if (m.type === 'host-offline') { if (overlay) { st.all = []; LowerThird.paint([], -1); } setStatus(`${st.hostName || 'The screen'} is offline — waiting…`); }
|
||||
};
|
||||
ws.onclose = (e) => {
|
||||
if (st.ws !== ws) return;
|
||||
st.ws = null;
|
||||
if (e.code === 4001) { save(null); showPairForm('This presenter was unpaired — enter a new code.'); return; }
|
||||
setStatus(e.code === 4004 ? 'Screen offline — retrying…' : 'Reconnecting…');
|
||||
st.retry = setTimeout(connect, 3000);
|
||||
};
|
||||
}
|
||||
|
||||
function onState(s) {
|
||||
st.state = s;
|
||||
st.stateAt = Date.now();
|
||||
const v = s && s.v;
|
||||
setStatus(v ? `● Following ${st.hostName} · ${v.title}` : `● Following ${st.hostName} · nothing playing`);
|
||||
const id = v && v.id;
|
||||
if (id !== st.vid) loadSong(id, v);
|
||||
}
|
||||
|
||||
async function loadSong(id, v) {
|
||||
const seq = ++st.seq;
|
||||
st.vid = id;
|
||||
st.all = [];
|
||||
st.docOff = 0;
|
||||
render(v);
|
||||
if (!id || !isMediaId(id)) return;
|
||||
let notes = st.notes.get(id);
|
||||
if (!notes) {
|
||||
try {
|
||||
const j = await (await fetch(`/api/notes/${encodeURIComponent(id)}`)).json();
|
||||
notes = j && j.ok ? j : null;
|
||||
if (notes) { st.notes.set(id, notes); if (st.notes.size > 50) st.notes.delete(st.notes.keys().next().value); }
|
||||
} catch { notes = null; }
|
||||
}
|
||||
if (seq !== st.seq) return;
|
||||
const doc = notes && notes.lyrics && notes.lyrics.data;
|
||||
st.all = (doc && doc.lines) || [];
|
||||
st.docOff = (doc && Number(doc.offset)) || 0;
|
||||
render(v);
|
||||
}
|
||||
|
||||
function render(v) {
|
||||
if (overlay) { LowerThird.paint([], -1); st.idx = -2; tick(true); return; }
|
||||
const sung = st.all.map((l, i) => ({ ...l, i })).filter((l) => l.kind === 'line');
|
||||
const title = $('pvTitle');
|
||||
if (!sung.length) {
|
||||
list.innerHTML = '';
|
||||
title.innerHTML = v ? `${escapeHtml(v.title)}${v.channel ? `<small>${escapeHtml(v.channel)}</small>` : ''}` : '';
|
||||
title.classList.toggle('hidden', !v);
|
||||
return;
|
||||
}
|
||||
title.classList.add('hidden');
|
||||
const pad = Math.round(list.clientHeight / 2);
|
||||
list.style.paddingTop = pad + 'px';
|
||||
list.style.paddingBottom = pad + 'px';
|
||||
list.innerHTML = sung.map((l) => `<div class="sl-line" data-i="${l.i}">${LyricsDisplay.html(l)}</div>`).join('');
|
||||
refit();
|
||||
st.idx = -2;
|
||||
tick(true);
|
||||
setTimeout(refit, 900); // once more after fonts/layout settle
|
||||
}
|
||||
|
||||
function refit() {
|
||||
fitLyricLines(list, { maxShare: 0.2, maxPx: 240 });
|
||||
}
|
||||
|
||||
function liveTime() {
|
||||
const s = st.state;
|
||||
if (!s) return 0;
|
||||
return s.paused ? s.cur : s.cur + ((Date.now() - st.stateAt) / 1000) * (s.rate || 1);
|
||||
}
|
||||
|
||||
function tick(force = false) {
|
||||
if (view.classList.contains('hidden') || !st.all.length || !st.state) return;
|
||||
if (overlay) { LowerThird.paint(st.all, LyricsCore.activeIndex(st.all, liveTime(), st.docOff + (Number(st.state.off) || 0))); return; }
|
||||
const idx = LyricsCore.activeIndex(st.all, liveTime(), st.docOff + (Number(st.state.off) || 0));
|
||||
if (idx === st.idx && !force) return;
|
||||
st.idx = idx;
|
||||
const rows = [...list.querySelectorAll('.sl-line')];
|
||||
const pos = rows.findIndex((el) => Number(el.dataset.i) === idx);
|
||||
rows.forEach((el, k) => {
|
||||
const d = pos < 0 ? 99 : Math.abs(k - pos);
|
||||
el.classList.toggle('active', d === 0);
|
||||
el.classList.toggle('near', d === 1);
|
||||
el.classList.toggle('past', pos >= 0 && k < pos);
|
||||
});
|
||||
const target = pos >= 0 ? rows[pos] : rows[0];
|
||||
if (target) {
|
||||
const go = () => list.scrollTo({ top: Math.max(0, target.offsetTop - list.clientHeight / 2 + target.offsetHeight / 2), behavior: 'smooth' });
|
||||
go();
|
||||
setTimeout(go, 380);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
|
||||
else view.requestFullscreen?.().catch(() => {});
|
||||
}
|
||||
|
||||
// The status bar and cursor hide after 3 s without mouse movement.
|
||||
function bumpIdle() {
|
||||
view.classList.remove('idle');
|
||||
clearTimeout(st.idleTimer);
|
||||
st.idleTimer = setTimeout(() => view.classList.add('idle'), 3000);
|
||||
}
|
||||
|
||||
function boot() {
|
||||
let param = null;
|
||||
try { param = new URLSearchParams(location.search).get(overlay ? 'code' : 'present'); } catch { /* no URL */ }
|
||||
if (param === null) return;
|
||||
try {
|
||||
const u = new URL(location.href);
|
||||
u.searchParams.delete('present');
|
||||
history.replaceState(null, '', u.pathname + (u.search || '') + u.hash);
|
||||
} catch { /* keep */ }
|
||||
show();
|
||||
const code = String(param).replace(/\D/g, '');
|
||||
if (overlay && load()) connect();
|
||||
else if (code.length === 6) pair(code);
|
||||
else if (load()) connect();
|
||||
else showPairForm();
|
||||
setInterval(() => tick(), 200);
|
||||
window.addEventListener('resize', () => { refit(); tick(true); });
|
||||
if (document.fonts) {
|
||||
document.fonts.ready.then(refit);
|
||||
document.fonts.addEventListener('loadingdone', refit);
|
||||
}
|
||||
view.addEventListener('mousemove', bumpIdle);
|
||||
view.addEventListener('dblclick', toggleFullscreen);
|
||||
$('pvFull').addEventListener('click', toggleFullscreen);
|
||||
$('pvPairBtn').addEventListener('click', () => pair($('pvCode').value));
|
||||
$('pvCode').addEventListener('keydown', (e) => { e.stopPropagation(); if (e.key === 'Enter') pair($('pvCode').value); });
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (view.classList.contains('hidden') || (e.target.closest && e.target.closest('#pvPair'))) return;
|
||||
if (e.key === 'f' || e.key === 'F') { e.preventDefault(); e.stopImmediatePropagation(); toggleFullscreen(); }
|
||||
else if (e.key === 'b' || e.key === 'B') { e.preventDefault(); e.stopImmediatePropagation(); $('pvBlank').classList.toggle('hidden'); }
|
||||
}, true);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') { keepAwake(); if (!st.ws && load()) connect(); }
|
||||
});
|
||||
}
|
||||
|
||||
return { boot, active: () => !view.classList.contains('hidden') };
|
||||
})();
|
||||
37
frontend/prewarm-next.js
Normal file
37
frontend/prewarm-next.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/* Pure queue candidate selection for stream prewarming. */
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory();
|
||||
else root.PrewarmNext = factory();
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
function candidates(queue, index, state = {}, n = 2, known = {}) {
|
||||
if (!Array.isArray(queue) || !queue.length || !Number.isInteger(index) || index < 0 || index >= queue.length) return [];
|
||||
if (state.loopOne) return [];
|
||||
const count = Math.max(0, Math.floor(Number(n) || 0));
|
||||
if (!count) return [];
|
||||
const repeatAll = state.repeatMode === 'all' || state.repeatAll === true;
|
||||
const offline = new Set(known.offline || []);
|
||||
const serverCached = new Set(known.serverCached || []);
|
||||
const current = queue[index];
|
||||
const currentId = typeof current === 'string' ? current : current && current.id;
|
||||
const seen = new Set(currentId ? [currentId] : []);
|
||||
const ids = [];
|
||||
for (let step = 1; step < queue.length + (repeatAll ? 1 : 0) && ids.length < count; step++) {
|
||||
let at = index + step;
|
||||
if (at >= queue.length) {
|
||||
if (!repeatAll) break;
|
||||
at %= queue.length;
|
||||
}
|
||||
const item = queue[at];
|
||||
const id = typeof item === 'string' ? item : item && item.id;
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
if (offline.has(id) || serverCached.has(id) || (item && item.serverCached)) continue;
|
||||
ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
return { candidates };
|
||||
});
|
||||
26
frontend/prewarm-next.test.js
Normal file
26
frontend/prewarm-next.test.js
Normal file
@@ -0,0 +1,26 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { candidates } = require('./prewarm-next.js');
|
||||
|
||||
const q = ['a', 'b', 'c', 'd'].map(id => ({ id }));
|
||||
|
||||
test('returns the next n queue ids in existing order', () => {
|
||||
assert.deepEqual(candidates(q, 0, {}, 2), ['b', 'c']);
|
||||
});
|
||||
test('repeat all wraps and does not duplicate ids', () => {
|
||||
assert.deepEqual(candidates(q, 3, { repeatMode: 'all' }, 2), ['a', 'b']);
|
||||
assert.deepEqual(candidates([{ id: 'x' }, { id: 'x' }], 0, { repeatMode: 'all' }, 3), []);
|
||||
});
|
||||
test('repeat off stops at the end and loop one has no candidates', () => {
|
||||
assert.deepEqual(candidates(q, 3, {}, 2), []);
|
||||
assert.deepEqual(candidates(q, 0, { loopOne: true, repeatMode: 'all' }, 2), []);
|
||||
});
|
||||
test('skips offline and known server cached ids and deduplicates', () => {
|
||||
assert.deepEqual(candidates(q, 0, {}, 3, { offline: ['b'], serverCached: ['c'] }), ['d']);
|
||||
assert.deepEqual(candidates([{ id: 'a' }, { id: 'b', serverCached: true }, { id: 'c' }], 0, {}, 2), ['c']);
|
||||
});
|
||||
test('shuffle follows the queue order selected before advance', () => {
|
||||
const shuffled = ['a', 'd', 'b', 'c'].map(id => ({ id }));
|
||||
assert.deepEqual(candidates(shuffled, 0, { shuffle: true }, 2), ['d', 'b']);
|
||||
});
|
||||
11
frontend/related-videos.css
Normal file
11
frontend/related-videos.css
Normal file
@@ -0,0 +1,11 @@
|
||||
.related-notice { display:flex; align-items:center; flex-wrap:wrap; gap:12px; padding:12px 0; color:var(--text-2); font-size:14px; line-height:1.5; }
|
||||
.related-notice p { margin:0; flex:1 1 180px; }
|
||||
.related-retry { display:inline-flex; align-items:center; justify-content:center; gap:8px; min-width:44px; min-height:44px; padding:8px 14px; border:1px solid var(--line); border-radius:var(--radius-sm,8px); color:var(--text); background:var(--bg-2); font:inherit; cursor:pointer; }
|
||||
.related-retry svg { width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:1.9; stroke-linecap:round; stroke-linejoin:round; }
|
||||
.related-retry:hover { background:var(--bg-3); }
|
||||
.related-retry:focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
|
||||
html[data-layout="glass-stage"] .related-retry { border-radius:18px; border-color:var(--glass-line); background:var(--tile); color:var(--text-2); }
|
||||
html[data-layout="glass-stage"] .related-retry:hover { background:var(--tile-hi); color:var(--text); }
|
||||
/* The scrollable player must not squeeze this card to its border height. */
|
||||
.related-panel { flex-shrink:0; }
|
||||
.related-notice { flex-shrink:0; }
|
||||
28
frontend/related-videos.js
Normal file
28
frontend/related-videos.js
Normal file
@@ -0,0 +1,28 @@
|
||||
(function(root) {
|
||||
'use strict';
|
||||
function cardsFor(cards, id) {
|
||||
const seen = new Set([id]);
|
||||
return (Array.isArray(cards) ? cards : []).filter(card => {
|
||||
if (!card?.id || !card.title || seen.has(card.id)) return false;
|
||||
seen.add(card.id); return true;
|
||||
}).slice(0, 8);
|
||||
}
|
||||
function createLoader({ fetchRelated, update }) {
|
||||
let serial = 0;
|
||||
return async function load(meta, { refresh = false } = {}) {
|
||||
const request = ++serial, snapshot = { ...meta };
|
||||
update({ id: snapshot.id, status: 'loading', cards: [], message: 'Finding related videos…' });
|
||||
let state;
|
||||
try {
|
||||
const result = await fetchRelated(snapshot, { refresh });
|
||||
const cards = cardsFor(result?.results, snapshot.id);
|
||||
state = result?.ok && cards.length ? { id: snapshot.id, status: 'ready', cards, message: result.message || '' }
|
||||
: { id: snapshot.id, status: 'error', cards: [], message: 'Related videos aren’t available right now. Please try again.', detail: result?.detail || result?.error || '' };
|
||||
} catch (error) { state = { id: snapshot.id, status: 'error', cards: [], message: 'Related videos aren’t available right now. Please try again.', detail: error.message || '' }; }
|
||||
if (request === serial) update(state);
|
||||
return state;
|
||||
};
|
||||
}
|
||||
const api = { cardsFor, createLoader };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.RelatedVideos = api;
|
||||
})(globalThis);
|
||||
18
frontend/related-videos.test.js
Normal file
18
frontend/related-videos.test.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createLoader, cardsFor } = require('./related-videos');
|
||||
test('related cards exclude the current video, duplicates and invalid cards', () => {
|
||||
assert.deepEqual(cardsFor([{id:'a',title:'A'},{id:'b',title:'B'},{id:'b',title:'B'},null,{id:'c'}],'a'),[{id:'b',title:'B'}]);
|
||||
});
|
||||
test('failed related discovery stays actionable and Retry refreshes the source', async () => {
|
||||
const states = [], calls = [];
|
||||
const load = createLoader({ fetchRelated: async (_, options) => { calls.push(options); return calls.length === 1 ? {ok:false,error:'YouTube HTTP 429'} : {ok:true,results:[{id:'b',title:'B'}]}; }, update: state => states.push(state) });
|
||||
await load({id:'a',title:'A'}); assert.equal(states.at(-1).status,'error'); assert.equal(states.at(-1).detail,'YouTube HTTP 429');
|
||||
await load({id:'a',title:'A'},{refresh:true}); assert.equal(states.at(-1).status,'ready'); assert.equal(calls[1].refresh,true);
|
||||
});
|
||||
test('a slow previous song cannot overwrite the current song recommendations', async () => {
|
||||
let finish; const states=[];
|
||||
const load=createLoader({fetchRelated: meta=>meta.id==='a'?new Promise(resolve=>{finish=resolve;}):Promise.resolve({ok:true,results:[{id:'d',title:'D'}]}),update:state=>states.push(state)});
|
||||
const old=load({id:'a'}); await load({id:'b'}); finish({ok:true,results:[{id:'c',title:'C'}]}); await old;
|
||||
assert.equal(states.at(-1).id,'b'); assert.equal(states.at(-1).cards[0].id,'d');
|
||||
});
|
||||
673
frontend/remote-client.js
Normal file
673
frontend/remote-client.js
Normal file
@@ -0,0 +1,673 @@
|
||||
/* Lazy classic chunk; its executing singleton stays pinned until reload. */
|
||||
(window.YT ||= {}).Remote = (() => {
|
||||
const noop = { boot() {}, changed() {} };
|
||||
if (!WEB || typeof WebSocket === 'undefined') return noop;
|
||||
|
||||
const HOST_KEY = 'ytpRemoteHost';
|
||||
const PAIR_KEY = 'ytpRemotePair';
|
||||
const load = (k) => { try { return JSON.parse(localStorage.getItem(k) || 'null'); } catch { return null; } };
|
||||
const save = (k, v) => { try { if (v) localStorage.setItem(k, JSON.stringify(v)); else localStorage.removeItem(k); } catch { /* storage blocked */ } };
|
||||
const wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/remote?${new URLSearchParams(q)}`;
|
||||
const newSecret = () => {
|
||||
const b = new Uint8Array(32);
|
||||
crypto.getRandomValues(b);
|
||||
return btoa(String.fromCharCode(...b)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
};
|
||||
const guessName = (fallback) => {
|
||||
const ua = navigator.userAgent || '';
|
||||
if (/iPhone/.test(ua)) return 'iPhone';
|
||||
if (/iPad/.test(ua)) return 'iPad';
|
||||
if (/Android/.test(ua)) return 'Android phone';
|
||||
if (/Windows/.test(ua)) return 'Windows PC';
|
||||
if (/Mac/.test(ua)) return 'Mac';
|
||||
return fallback;
|
||||
};
|
||||
const clock = (t) => (window.LyricsCore ? LyricsCore.fmtClock(t) : fmtTime(t));
|
||||
|
||||
// ==========================================================================
|
||||
// Host (the screen being controlled)
|
||||
// ==========================================================================
|
||||
const host = { ws: null, remotes: [], code: '', codeExpires: 0, retryMs: 2000, timer: null, lastState: '', lastQueue: '', replaced: false, pending: false };
|
||||
|
||||
function hostCfg() {
|
||||
let cfg = load(HOST_KEY);
|
||||
if (!cfg || !cfg.secret) cfg = { secret: newSecret(), name: guessName('Desktop'), enabled: false };
|
||||
return cfg;
|
||||
}
|
||||
|
||||
function hostConnect() {
|
||||
const cfg = hostCfg();
|
||||
if (!cfg.enabled) return;
|
||||
try { if (host.ws) host.ws.close(); } catch { /* already closed */ }
|
||||
const ws = new WebSocket(wsUrl({ role: 'host', secret: cfg.secret, name: cfg.name }));
|
||||
host.ws = ws;
|
||||
host.replaced = false;
|
||||
ws.onopen = () => { host.retryMs = 2000; };
|
||||
ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onHostMessage(m); };
|
||||
ws.onclose = (e) => {
|
||||
if (host.ws !== ws) return;
|
||||
host.ws = null;
|
||||
host.remotes = [];
|
||||
renderChip();
|
||||
renderHostModal();
|
||||
if (e.code === 4000) { host.replaced = true; renderHostModal(); return; } // another tab hosts now
|
||||
if (hostCfg().enabled) {
|
||||
clearTimeout(host.timer);
|
||||
host.timer = setTimeout(hostConnect, host.retryMs);
|
||||
host.retryMs = Math.min(host.retryMs * 2, 30000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const hostSend = (m) => { if (host.ws && host.ws.readyState === 1) host.ws.send(JSON.stringify(m)); };
|
||||
|
||||
function onHostMessage(m) {
|
||||
DirectMedia.attach('remote-host', { send: hostSend, peers: () => host.remotes.map(r => ({ id: r.rid, name: r.name })) });
|
||||
if (m.type === 'direct') { DirectMedia.handle('remote-host', m); return; }
|
||||
if (m.type === 'hello') {
|
||||
host.code = m.code; host.codeExpires = m.codeExpires; host.remotes = m.remotes || [];
|
||||
host.lastState = ''; host.lastQueue = '';
|
||||
pushState(); pushQueue();
|
||||
} else if (m.type === 'code') {
|
||||
host.code = m.code; host.codeExpires = m.codeExpires;
|
||||
} else if (m.type === 'remotes') {
|
||||
host.remotes = m.remotes || [];
|
||||
if (m.joined) { toast(`📱 ${m.joined} connected as a remote`); host.lastState = ''; host.lastQueue = ''; pushState(); pushQueue(); }
|
||||
if (m.left) toast(`📱 ${m.left} disconnected`);
|
||||
} else if (m.type === 'ping') {
|
||||
hostSend({ type: 'pong', rid: m.rid, t: m.t });
|
||||
return;
|
||||
} else if (m.type === 'cmd') {
|
||||
runCommand(m);
|
||||
return;
|
||||
}
|
||||
renderChip();
|
||||
renderHostModal();
|
||||
}
|
||||
|
||||
function stateNow() {
|
||||
const meta = current && current.meta;
|
||||
const M = Player.master;
|
||||
return {
|
||||
directAvailable: !!meta && cachedIds.has(meta.id) && DirectMedia.enabled() && data.settings.p2pShare !== false,
|
||||
v: meta ? { id: meta.id, title: meta.title || '', channel: meta.channel || '', thumbnail: meta.thumbnail || '' } : null,
|
||||
cur: M ? Math.floor((M.currentTime || 0) * 10) / 10 : 0,
|
||||
dur: (M && isFinite(M.duration) && M.duration) || (meta && meta.duration) || 0,
|
||||
paused: !M || M.paused,
|
||||
volume: data.settings.volume ?? 1,
|
||||
service: isServiceModeOpen(),
|
||||
sleep: sleepTimerRemaining || 0,
|
||||
hostName: hostCfg().name,
|
||||
// For the presenter screen: this screen's personal lyric offset for the
|
||||
// song and the playback speed (to extrapolate between updates).
|
||||
off: meta && data.lyricOffsets ? Number(data.lyricOffsets[meta.id]) || 0 : 0,
|
||||
rate: (M && M.playbackRate) || 1,
|
||||
};
|
||||
}
|
||||
|
||||
function pushState(force = false) {
|
||||
if (!host.ws || !host.remotes.length) return;
|
||||
const st = stateNow();
|
||||
const key = JSON.stringify({ ...st, cur: Math.floor(st.cur) });
|
||||
if (!force && key === host.lastState) return;
|
||||
host.lastState = key;
|
||||
hostSend({ type: 'state', state: { ...st, at: Date.now() } });
|
||||
}
|
||||
|
||||
function pushQueue() {
|
||||
if (!host.ws || !host.remotes.length) return;
|
||||
const items = queue.slice(0, 200).map((v) => ({
|
||||
id: v.id, title: v.title || '', channel: v.channel || '', duration: v.duration || 0, thumbnail: v.thumbnail || '',
|
||||
}));
|
||||
const key = queueIndex + '|' + items.map((v) => v.id).join(',');
|
||||
if (key === host.lastQueue) return;
|
||||
host.lastQueue = key;
|
||||
hostSend({ type: 'queue', queue: { items, idx: queueIndex } });
|
||||
}
|
||||
|
||||
// Coalesce bursts (play event + renderUpNext + afterLoad) into one push.
|
||||
function changed() {
|
||||
if (!host.ws || host.pending) return;
|
||||
host.pending = true;
|
||||
setTimeout(() => { host.pending = false; pushState(); pushQueue(); }, 50);
|
||||
}
|
||||
|
||||
const validIdx = (i) => Number.isInteger(i) && i > queueIndex && i < queue.length;
|
||||
|
||||
function runCommand(m) {
|
||||
const who = m.from || 'Phone';
|
||||
switch (m.cmd) {
|
||||
case 'toggle': Player.toggle(); break;
|
||||
case 'play': Player.play(); break;
|
||||
case 'pause': Player.pause(); break;
|
||||
case 'next': playNext(); break;
|
||||
case 'prev': playPrev(); break;
|
||||
case 'seek': {
|
||||
const t = Number(m.t);
|
||||
if (isFinite(t) && t >= 0) Player.seek(t);
|
||||
break;
|
||||
}
|
||||
case 'volume': {
|
||||
const v = Math.max(0, Math.min(1, Number(m.v)));
|
||||
if (!isFinite(v)) break;
|
||||
data.settings.volume = v;
|
||||
els.volume.value = String(v);
|
||||
Player.applyVolume();
|
||||
syncMuteIcon();
|
||||
persist();
|
||||
break;
|
||||
}
|
||||
case 'jump': {
|
||||
const i = Number(m.i);
|
||||
if (Number.isInteger(i) && i >= 0 && i < queue.length) { queueIndex = i - 1; playNext(); }
|
||||
break;
|
||||
}
|
||||
case 'remove': {
|
||||
const i = Number(m.i);
|
||||
if (validIdx(i)) { queue.splice(i, 1); renderUpNext(); }
|
||||
break;
|
||||
}
|
||||
case 'move': {
|
||||
const from = Number(m.from), to = Number(m.to);
|
||||
if (validIdx(from) && validIdx(to) && from !== to) {
|
||||
const [v] = queue.splice(from, 1);
|
||||
queue.splice(to, 0, v);
|
||||
renderUpNext();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'add': {
|
||||
const v = m.video;
|
||||
if (!v || typeof v.id !== 'string' || !isMediaId(v.id)) break;
|
||||
const item = slim({ ...v, title: String(v.title || v.id).slice(0, 300), channel: String(v.channel || '').slice(0, 200) });
|
||||
if (!current || !queue.length) { playFromList([item], 0, 'remote'); }
|
||||
else if (!queue.slice(queueIndex + 1).some((x) => x.id === item.id)) { queue.push(item); renderUpNext(); }
|
||||
toast(`📱 ${who} added “${item.title}”`);
|
||||
break;
|
||||
}
|
||||
case 'sleep': {
|
||||
const min = Number(m.min) || 0;
|
||||
if (min > 0) startSleepTimer(Math.min(min, 240)); else cancelSleepTimer();
|
||||
break;
|
||||
}
|
||||
case 'service':
|
||||
if (m.on) enterServiceMode(); else exitServiceMode();
|
||||
break;
|
||||
default: return;
|
||||
}
|
||||
changed();
|
||||
}
|
||||
|
||||
function startHosting() {
|
||||
const cfg = hostCfg();
|
||||
cfg.enabled = true;
|
||||
save(HOST_KEY, cfg);
|
||||
hostConnect();
|
||||
renderHostModal();
|
||||
}
|
||||
|
||||
function stopHosting() {
|
||||
const cfg = hostCfg();
|
||||
cfg.enabled = false;
|
||||
save(HOST_KEY, cfg);
|
||||
clearTimeout(host.timer);
|
||||
const ws = host.ws;
|
||||
host.ws = null;
|
||||
host.remotes = [];
|
||||
try { if (ws) ws.close(); } catch { /* gone */ }
|
||||
renderChip();
|
||||
renderHostModal();
|
||||
}
|
||||
|
||||
function unpairAll() {
|
||||
hostSend({ type: 'unpair-all' });
|
||||
const cfg = hostCfg();
|
||||
cfg.secret = newSecret(); // every phone's token was derived from the old one
|
||||
save(HOST_KEY, cfg);
|
||||
setTimeout(hostConnect, 300);
|
||||
toast('All phones unpaired');
|
||||
}
|
||||
|
||||
function renderChip() {
|
||||
const chip = $('remoteChip');
|
||||
if (!chip) return;
|
||||
const n = host.remotes.length;
|
||||
chip.classList.toggle('hidden', !host.ws || !n);
|
||||
if (n) {
|
||||
$('remoteChipText').textContent = n === 1
|
||||
? `Remote connected — ${host.remotes[0].name}`
|
||||
: `${n} remotes connected`;
|
||||
}
|
||||
}
|
||||
|
||||
let hostModalOpen = false;
|
||||
function renderHostModal() {
|
||||
if (!hostModalOpen) return;
|
||||
const body = $('modalBody') && $('modalBody').querySelector('.remote-host');
|
||||
if (!body) { hostModalOpen = false; return; }
|
||||
const cfg = hostCfg();
|
||||
const live = !!host.ws;
|
||||
const mins = host.codeExpires ? Math.max(0, Math.round((host.codeExpires - Date.now()) / 60000)) : 0;
|
||||
body.querySelector('.rh-body').innerHTML = !cfg.enabled ? `
|
||||
<p class="modal-text">Let a phone drive this screen — play/pause, seek, volume, the queue and service mode —
|
||||
while this computer plays to the TV or speakers. Pairing uses a one-time 6-digit code; nothing to sign in to.</p>`
|
||||
: host.replaced ? `<p class="modal-text">Another tab on this computer took over as the remote-controlled screen.</p>`
|
||||
: !live ? `<p class="modal-text">Connecting to the server…</p>`
|
||||
: `
|
||||
<div class="rh-pair">
|
||||
<img class="rh-qr" alt="QR code to pair a phone" src="/api/remote/qr/${host.code}" />
|
||||
<div>
|
||||
<div class="rh-label">Pairing code</div>
|
||||
<div class="rh-code">${host.code.slice(0, 3)} ${host.code.slice(3)}</div>
|
||||
<div class="rh-sub">Valid ${mins} more min · one use</div>
|
||||
<div class="rh-sub">On the phone: open this app → 📱 Remote → <b>Use this phone as a remote</b>, or scan the QR code.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rh-label">Presenter / projector screen</div>
|
||||
<div class="rh-present">
|
||||
<img class="rh-qr rh-qr-sm" alt="QR code for the presenter screen" src="/api/remote/qr/${host.code}?kind=present" />
|
||||
<div>
|
||||
<div class="rh-sub">Lyrics-only display that follows this screen. On the projector's computer or TV browser open
|
||||
<code>${escapeHtml(location.origin)}/?present=${host.code}</code> (same code), or scan the QR.</div>
|
||||
<button class="np-btn" type="button" data-act="present">🖥 Open presenter window here</button>
|
||||
<button class="np-btn" type="button" data-act="obs">Copy OBS browser-source URL</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rh-label">Connected phones & screens</div>
|
||||
${host.remotes.length ? host.remotes.map((r) => `
|
||||
<div class="rh-remote"><span>📱 ${escapeHtml(r.name)}</span><button class="mk-btn" type="button" data-kick="${escapeHtml(r.rid)}" title="Disconnect">✕</button></div>`).join('')
|
||||
: '<div class="rh-sub">None yet.</div>'}`;
|
||||
const nameIn = body.querySelector('.rh-name');
|
||||
if (document.activeElement !== nameIn) nameIn.value = cfg.name;
|
||||
body.querySelector('[data-act="start"]').classList.toggle('hidden', cfg.enabled && !host.replaced);
|
||||
body.querySelector('[data-act="stop"]').classList.toggle('hidden', !cfg.enabled);
|
||||
body.querySelector('[data-act="code"]').classList.toggle('hidden', !live);
|
||||
body.querySelector('[data-act="unpair"]').classList.toggle('hidden', !live);
|
||||
}
|
||||
|
||||
async function openHostModal() {
|
||||
try { await Lazy.load('feature:direct'); } catch(error) { toast(error.message); return; }
|
||||
const body = document.createElement('div');
|
||||
body.className = 'remote-host';
|
||||
body.innerHTML = `
|
||||
<div class="rh-body"></div>
|
||||
<label class="rh-name-row">This screen's name <input class="rh-name" maxlength="40" /></label>
|
||||
<div class="rh-buttons">
|
||||
<button class="np-btn" type="button" data-act="start">${TransportIcons.svg('play')} Start — control from a phone</button>
|
||||
<button class="np-btn" type="button" data-act="code">↻ New code</button>
|
||||
<button class="np-btn" type="button" data-act="unpair">Unpair all phones</button>
|
||||
<button class="np-btn" type="button" data-act="stop">Stop</button>
|
||||
</div>`;
|
||||
body.addEventListener('click', (e) => {
|
||||
const kick = e.target.closest('[data-kick]');
|
||||
if (kick) { hostSend({ type: 'kick', rid: kick.dataset.kick }); return; }
|
||||
const act = e.target.closest('[data-act]');
|
||||
if (!act) return;
|
||||
if (act.dataset.act === 'start') startHosting();
|
||||
else if (act.dataset.act === 'stop') stopHosting();
|
||||
else if (act.dataset.act === 'code') hostSend({ type: 'new-code' });
|
||||
else if (act.dataset.act === 'unpair') unpairAll();
|
||||
else if (act.dataset.act === 'obs') { navigator.clipboard.writeText(LowerThird.url(location.origin, host.code)).then(() => toast('OBS URL copied'), () => toast('Copy blocked')); }
|
||||
else if (act.dataset.act === 'present') {
|
||||
// A second window on this computer (drag it to the projector display).
|
||||
window.open(`/?present=${host.code}`, 'ytp-presenter', 'popup,width=1280,height=720');
|
||||
}
|
||||
});
|
||||
body.querySelector('.rh-name').addEventListener('change', (e) => {
|
||||
const cfg = hostCfg();
|
||||
cfg.name = e.target.value.trim().slice(0, 40) || guessName('Desktop');
|
||||
save(HOST_KEY, cfg);
|
||||
if (cfg.enabled) hostConnect(); // the server learns names on connect
|
||||
});
|
||||
showModal('📱 Control this screen from a phone', body, [
|
||||
{ label: 'Done', primary: true, onClick: () => { hostModalOpen = false; closeModal(); } },
|
||||
]);
|
||||
hostModalOpen = true;
|
||||
renderHostModal();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Remote (this phone drives a host)
|
||||
// ==========================================================================
|
||||
const rc = { ws: null, open: false, state: null, stateAt: 0, queue: { items: [], idx: -1 }, retry: null, ping: null, seekTimer: null, latency: null, online: false, seeking: false, volTimer: null, drag: null };
|
||||
|
||||
const rcSend = (m) => { if (rc.ws && rc.ws.readyState === 1) rc.ws.send(JSON.stringify(m)); };
|
||||
const cmd = (name, args = {}) => rcSend({ type: 'cmd', cmd: name, ...args });
|
||||
|
||||
function setStatus(text, ok = false) {
|
||||
$('rvStatus').textContent = text;
|
||||
$('rvStatus').classList.toggle('ok', ok);
|
||||
}
|
||||
|
||||
function showPairForm(msg = '') {
|
||||
$('rvPair').classList.remove('hidden');
|
||||
$('rvControl').classList.add('hidden');
|
||||
$('rvHostName').textContent = 'Remote';
|
||||
setStatus('Not paired');
|
||||
$('rvPairMsg').textContent = msg;
|
||||
if (!$('rvName').value) $('rvName').value = (load(PAIR_KEY) || {}).name || guessName('Phone');
|
||||
}
|
||||
|
||||
function rcConnect() {
|
||||
const p = load(PAIR_KEY);
|
||||
if (!p || !rc.open) return;
|
||||
clearTimeout(rc.retry);
|
||||
try { if (rc.ws) rc.ws.close(); } catch { /* gone */ }
|
||||
$('rvPair').classList.add('hidden');
|
||||
$('rvControl').classList.remove('hidden');
|
||||
$('rvHostName').textContent = p.hostName || 'Screen';
|
||||
setStatus('Connecting…');
|
||||
const ws = new WebSocket(wsUrl({ role: 'remote', room: p.roomId, rid: p.remoteId, token: p.token, name: p.name || guessName('Phone') }));
|
||||
rc.ws = ws;
|
||||
ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onRemoteMessage(m); };
|
||||
ws.onclose = (e) => {
|
||||
if (rc.ws !== ws) return;
|
||||
rc.ws = null;
|
||||
rc.online = false;
|
||||
clearInterval(rc.ping);
|
||||
if (e.code === 4001) { save(PAIR_KEY, null); showPairForm('This phone was unpaired by the screen — pair again.'); return; }
|
||||
if (!rc.open) return;
|
||||
setStatus(e.code === 4004 ? 'Screen offline — retrying…' : 'Reconnecting…');
|
||||
rc.retry = setTimeout(rcConnect, 4000);
|
||||
};
|
||||
}
|
||||
|
||||
function onRemoteMessage(m) {
|
||||
DirectMedia.attach('remote-client', { send: rcSend, peers: () => [{ id: 'host', name: 'Paired screen', files: rc.state?.directAvailable && rc.state?.v ? [rc.state.v.id] : [] }] });
|
||||
if (m.type === 'direct') { DirectMedia.handle('remote-client', m); return; }
|
||||
if (m.type === 'hello') {
|
||||
rc.online = true;
|
||||
$('rvHostName').textContent = m.hostName || 'Screen';
|
||||
if (m.state) { rc.state = m.state; rc.stateAt = Date.now(); }
|
||||
if (m.queue) rc.queue = m.queue;
|
||||
setStatus('Synced', true);
|
||||
clearInterval(rc.ping);
|
||||
const ping = () => rcSend({ type: 'ping', t: Date.now() });
|
||||
ping();
|
||||
rc.ping = setInterval(ping, 5000);
|
||||
renderRemote(); renderQueue();
|
||||
} else if (m.type === 'state') {
|
||||
rc.state = m.state; rc.stateAt = Date.now();
|
||||
renderRemote();
|
||||
} else if (m.type === 'queue') {
|
||||
rc.queue = m.queue || { items: [], idx: -1 };
|
||||
renderQueue();
|
||||
} else if (m.type === 'pong') {
|
||||
rc.latency = Date.now() - m.t;
|
||||
if (rc.online) setStatus(`Synced · ${rc.latency} ms`, true);
|
||||
} else if (m.type === 'host-offline') {
|
||||
rc.online = false;
|
||||
setStatus('Screen offline — waiting…');
|
||||
}
|
||||
}
|
||||
|
||||
function liveCur() {
|
||||
const s = rc.state;
|
||||
if (!s) return 0;
|
||||
const cur = s.paused ? s.cur : s.cur + (Date.now() - rc.stateAt) / 1000;
|
||||
return Math.min(cur, s.dur || cur);
|
||||
}
|
||||
|
||||
function renderRemote() {
|
||||
const s = rc.state;
|
||||
const v = s && s.v;
|
||||
$('rvTitle').textContent = v ? v.title : 'Nothing playing';
|
||||
$('rvChannel').textContent = v ? v.channel : '';
|
||||
let directPlay = $('rvDirectPlay');
|
||||
if (!directPlay) { directPlay = document.createElement('button'); directPlay.id = 'rvDirectPlay'; directPlay.className = 'np-btn'; directPlay.style.minHeight = '44px'; directPlay.innerHTML = TransportIcons.svg('play') + ' Play here from paired screen'; $('rvChannel').after(directPlay); }
|
||||
directPlay.hidden = !v || !s.directAvailable || !DirectMedia.enabled();
|
||||
directPlay.onclick = () => v && playVideoAt({ ...v, duration: s.dur || 0 }, liveCur());
|
||||
|
||||
const thumb = $('rvThumb');
|
||||
if (v && v.thumbnail) { if (thumb.getAttribute('src') !== v.thumbnail) thumb.src = v.thumbnail; thumb.classList.remove('hidden'); }
|
||||
else thumb.classList.add('hidden');
|
||||
TransportIcons.set($('rvToggle'), s && !s.paused ? 'pause' : 'play');
|
||||
$('rvDur').textContent = clock((s && s.dur) || 0);
|
||||
if (!rc.volTimer && document.activeElement !== $('rvVolume')) {
|
||||
const pct = Math.round(((s && s.volume) ?? 1) * 100);
|
||||
$('rvVolume').value = String(pct);
|
||||
$('rvVolVal').textContent = pct + '%';
|
||||
}
|
||||
const sleepMin = s && s.sleep ? Math.ceil(s.sleep / 60) : 0;
|
||||
$('rvSleep').textContent = sleepMin ? `⏱ ${sleepMin} min` : '⏱ Sleep';
|
||||
$('rvSleep').classList.toggle('on', !!sleepMin);
|
||||
$('rvService').classList.toggle('on', !!(s && s.service));
|
||||
renderSeek();
|
||||
}
|
||||
|
||||
function renderSeek() {
|
||||
if (rc.seeking || !rc.open) return;
|
||||
const s = rc.state;
|
||||
const cur = liveCur();
|
||||
$('rvCur').textContent = clock(cur);
|
||||
$('rvSeek').value = s && s.dur ? String(Math.round((cur / s.dur) * 1000)) : '0';
|
||||
}
|
||||
|
||||
function renderQueue() {
|
||||
const q = rc.queue || { items: [], idx: -1 };
|
||||
const up = q.items.map((v, i) => ({ ...v, i })).filter((v) => v.i > q.idx);
|
||||
$('rvQueueCount').textContent = up.length ? `${up.length} song${up.length === 1 ? '' : 's'}` : '';
|
||||
const list = $('rvQueue');
|
||||
list.innerHTML = up.length ? up.map((v) => `
|
||||
<div class="rv-row" data-i="${v.i}">
|
||||
<span class="rv-handle" aria-label="Drag to reorder">⠿</span>
|
||||
<div class="rv-row-text">
|
||||
<div class="rv-row-title">${escapeHtml(v.title)}</div>
|
||||
<div class="rv-row-sub">${escapeHtml(v.channel)}</div>
|
||||
</div>
|
||||
<span class="rv-row-dur">${v.duration ? clock(v.duration) : ''}</span>
|
||||
<button class="rv-row-x" type="button" data-rm="${v.i}" aria-label="Remove">✕</button>
|
||||
</div>`).join('') : '<div class="rv-empty">Nothing queued after this song.</div>';
|
||||
}
|
||||
|
||||
// ---- drag to reorder (pointer events, works for touch + mouse) ----
|
||||
function onDragStart(e) {
|
||||
const handle = e.target.closest('.rv-handle');
|
||||
if (!handle) return;
|
||||
const row = handle.closest('.rv-row');
|
||||
e.preventDefault();
|
||||
const rows = [...$('rvQueue').querySelectorAll('.rv-row')];
|
||||
rc.drag = { row, rows, startY: e.clientY, from: Number(row.dataset.i), to: Number(row.dataset.i), h: row.offsetHeight };
|
||||
row.classList.add('dragging');
|
||||
row.setPointerCapture(e.pointerId);
|
||||
row.addEventListener('pointermove', onDragMove);
|
||||
row.addEventListener('pointerup', onDragEnd, { once: true });
|
||||
row.addEventListener('pointercancel', onDragEnd, { once: true });
|
||||
}
|
||||
function onDragMove(e) {
|
||||
const d = rc.drag;
|
||||
if (!d) return;
|
||||
const dy = e.clientY - d.startY;
|
||||
d.row.style.transform = `translateY(${dy}px)`;
|
||||
const pos = d.rows.indexOf(d.row);
|
||||
const target = Math.max(0, Math.min(d.rows.length - 1, pos + Math.round(dy / d.h)));
|
||||
d.to = Number(d.rows[target].dataset.i);
|
||||
}
|
||||
function onDragEnd() {
|
||||
const d = rc.drag;
|
||||
rc.drag = null;
|
||||
if (!d) return;
|
||||
d.row.removeEventListener('pointermove', onDragMove);
|
||||
d.row.classList.remove('dragging');
|
||||
d.row.style.transform = '';
|
||||
if (d.to !== d.from) {
|
||||
// Optimistic: reorder locally now, the host's queue push confirms it.
|
||||
const items = rc.queue.items.slice();
|
||||
const [v] = items.splice(d.from, 1);
|
||||
items.splice(d.to, 0, v);
|
||||
rc.queue = { ...rc.queue, items };
|
||||
renderQueue();
|
||||
cmd('move', { from: d.from, to: d.to });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- add to queue: search sheet ----
|
||||
function openAddSheet() {
|
||||
const body = document.createElement('div');
|
||||
body.className = 'rv-add';
|
||||
body.innerHTML = `
|
||||
<form class="rv-add-form"><input class="rv-add-q" type="search" placeholder="Search YouTube…" /><button class="np-btn" type="submit">Search</button></form>
|
||||
<div class="rv-add-results"></div>`;
|
||||
const results = body.querySelector('.rv-add-results');
|
||||
let found = [];
|
||||
body.querySelector('form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const q = body.querySelector('.rv-add-q').value.trim();
|
||||
if (!q) return;
|
||||
results.innerHTML = '<div class="rv-empty">Searching…</div>';
|
||||
try {
|
||||
const res = await API.search(q);
|
||||
found = (res && res.ok && res.results) || [];
|
||||
} catch { found = []; }
|
||||
results.innerHTML = found.length ? found.slice(0, 20).map((v, i) => `
|
||||
<button class="rv-add-item" type="button" data-k="${i}">
|
||||
<img src="${escapeHtml(v.thumbnail || '')}" alt="" loading="lazy" decoding="async" />
|
||||
<span><b>${escapeHtml(v.title || '')}</b><small>${escapeHtml(v.channel || '')}${v.duration ? ' · ' + clock(v.duration) : ''}</small></span>
|
||||
</button>`).join('') : '<div class="rv-empty">No results.</div>';
|
||||
});
|
||||
results.addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-k]');
|
||||
if (!b) return;
|
||||
const v = found[Number(b.dataset.k)];
|
||||
if (!v) return;
|
||||
cmd('add', { video: { id: v.id, title: v.title, channel: v.channel, duration: v.duration, thumbnail: v.thumbnail } });
|
||||
b.classList.add('added');
|
||||
toast(`Added “${v.title}”`);
|
||||
});
|
||||
showModal('+ Add to the screen’s queue', body, [{ label: 'Done', primary: true, onClick: closeModal }]);
|
||||
setTimeout(() => body.querySelector('.rv-add-q').focus(), 50);
|
||||
}
|
||||
|
||||
async function doPair(codeRaw) {
|
||||
const code = String(codeRaw || '').replace(/\D/g, '');
|
||||
const name = $('rvName').value.trim() || guessName('Phone');
|
||||
if (code.length !== 6) { $('rvPairMsg').textContent = 'Enter the 6-digit code from the screen.'; return; }
|
||||
$('rvPairMsg').textContent = 'Pairing…';
|
||||
try {
|
||||
const res = await fetch('/api/remote/pair', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, name }),
|
||||
});
|
||||
const j = await res.json().catch(() => null);
|
||||
if (!j || !j.ok) { $('rvPairMsg').textContent = '⚠ ' + ((j && j.error) || `HTTP ${res.status}`); return; }
|
||||
save(PAIR_KEY, { roomId: j.roomId, remoteId: j.remoteId, token: j.token, hostName: j.hostName, name: j.name });
|
||||
$('rvCode').value = '';
|
||||
rcConnect();
|
||||
} catch {
|
||||
$('rvPairMsg').textContent = '⚠ Network error — try again';
|
||||
}
|
||||
}
|
||||
|
||||
async function openRemoteView({ code = '' } = {}) {
|
||||
try { await Lazy.load('feature:direct'); } catch(error) { toast(error.message); return; }
|
||||
rc.open = true;
|
||||
rc.seekTimer = VisibleTimer.setIntervalWhenVisible(renderSeek, 500);
|
||||
// This phone is the remote now — don't also play here.
|
||||
try { if (Player.master && !Player.master.paused) Player.pause(); } catch { /* nothing loaded */ }
|
||||
$('remoteView').classList.remove('hidden');
|
||||
document.body.classList.add('remote-open');
|
||||
if (code) { save(PAIR_KEY, null); showPairForm(); $('rvCode').value = code; doPair(code); return; }
|
||||
if (load(PAIR_KEY)) rcConnect(); else showPairForm();
|
||||
}
|
||||
|
||||
function closeRemoteView() {
|
||||
rc.open = false;
|
||||
rc.seekTimer?.clear();
|
||||
rc.seekTimer = null;
|
||||
clearTimeout(rc.retry);
|
||||
clearInterval(rc.ping);
|
||||
const ws = rc.ws;
|
||||
rc.ws = null;
|
||||
try { if (ws) ws.close(); } catch { /* gone */ }
|
||||
$('remoteView').classList.add('hidden');
|
||||
document.body.classList.remove('remote-open');
|
||||
}
|
||||
|
||||
function openChooser() {
|
||||
const hosting = hostCfg().enabled;
|
||||
const paired = load(PAIR_KEY);
|
||||
const body = document.createElement('div');
|
||||
body.innerHTML = `<p class="modal-text">Play on a computer hooked to the TV or speakers, and drive it from your phone.</p>`;
|
||||
showModal('📱 Remote', body, [
|
||||
{ label: paired ? `Control “${paired.hostName}”` : 'Use this phone as a remote', onClick: () => { closeModal(); openRemoteView(); } },
|
||||
{ label: hosting ? 'This screen: remote on' : 'Control this screen from a phone', primary: true, onClick: () => { closeModal(); openHostModal(); } },
|
||||
]);
|
||||
}
|
||||
|
||||
function bindUi() {
|
||||
$('rvClose').addEventListener('click', closeRemoteView);
|
||||
$('rvMenu').addEventListener('click', () => {
|
||||
const p = load(PAIR_KEY);
|
||||
showModal('Remote options', document.createTextNode(p ? `Paired with “${p.hostName}”.` : 'Not paired.'), [
|
||||
{ label: 'Close', onClick: closeModal },
|
||||
...(p ? [{ label: 'Unpair this phone', danger: true, onClick: () => { closeModal(); save(PAIR_KEY, null); try { rc.ws && rc.ws.close(); } catch { /* gone */ } showPairForm('Unpaired.'); } }] : []),
|
||||
]);
|
||||
});
|
||||
$('rvPairBtn').addEventListener('click', () => doPair($('rvCode').value));
|
||||
$('rvCode').addEventListener('keydown', (e) => { if (e.key === 'Enter') doPair($('rvCode').value); });
|
||||
$('rvToggle').addEventListener('click', () => {
|
||||
cmd('toggle');
|
||||
if (rc.state) { rc.state.cur = liveCur(); rc.stateAt = Date.now(); rc.state.paused = !rc.state.paused; renderRemote(); }
|
||||
});
|
||||
$('rvPrev').addEventListener('click', () => cmd('prev'));
|
||||
$('rvNext').addEventListener('click', () => cmd('next'));
|
||||
const seek = $('rvSeek');
|
||||
seek.addEventListener('input', () => {
|
||||
rc.seeking = true;
|
||||
const dur = (rc.state && rc.state.dur) || 0;
|
||||
$('rvCur').textContent = clock((Number(seek.value) / 1000) * dur);
|
||||
});
|
||||
seek.addEventListener('change', () => {
|
||||
const dur = (rc.state && rc.state.dur) || 0;
|
||||
const t = (Number(seek.value) / 1000) * dur;
|
||||
cmd('seek', { t });
|
||||
if (rc.state) { rc.state.cur = t; rc.stateAt = Date.now(); }
|
||||
rc.seeking = false;
|
||||
});
|
||||
$('rvVolume').addEventListener('input', (e) => {
|
||||
const pct = Number(e.target.value);
|
||||
$('rvVolVal').textContent = pct + '%';
|
||||
clearTimeout(rc.volTimer);
|
||||
rc.volTimer = setTimeout(() => { rc.volTimer = null; cmd('volume', { v: pct / 100 }); }, 120);
|
||||
});
|
||||
$('rvQueue').addEventListener('pointerdown', onDragStart);
|
||||
$('rvQueue').addEventListener('click', (e) => {
|
||||
const rm = e.target.closest('[data-rm]');
|
||||
if (rm) { cmd('remove', { i: Number(rm.dataset.rm) }); return; }
|
||||
if (e.target.closest('.rv-handle')) return;
|
||||
const row = e.target.closest('.rv-row');
|
||||
if (row) cmd('jump', { i: Number(row.dataset.i) });
|
||||
});
|
||||
$('rvAdd').addEventListener('click', openAddSheet);
|
||||
$('rvSleep').addEventListener('click', () => {
|
||||
const options = [0, 15, 30, 45, 60, 90];
|
||||
const curMin = rc.state && rc.state.sleep ? Math.ceil(rc.state.sleep / 60) : 0;
|
||||
const next = options.find((o) => o > curMin) ?? 0;
|
||||
cmd('sleep', { min: next });
|
||||
toast(next ? `Screen pauses in ${next} min` : 'Sleep timer off');
|
||||
});
|
||||
$('rvService').addEventListener('click', () => cmd('service', { on: !(rc.state && rc.state.service) }));
|
||||
// Hosts keep phones current once a second while anything plays.
|
||||
setInterval(() => { if (host.ws && host.remotes.length) { pushState(); pushQueue(); } }, 1000);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible' && rc.open && !rc.ws) rcConnect();
|
||||
});
|
||||
}
|
||||
|
||||
function boot() {
|
||||
bindUi();
|
||||
if (hostCfg().enabled) hostConnect();
|
||||
// A scanned QR / shared link: /?pair=482913
|
||||
let code = '';
|
||||
try { code = (new URLSearchParams(location.search).get('pair') || '').replace(/\D/g, ''); } catch { /* no URL */ }
|
||||
if (code) {
|
||||
try {
|
||||
const u = new URL(location.href);
|
||||
u.searchParams.delete('pair');
|
||||
history.replaceState(null, '', u.pathname + u.search + u.hash);
|
||||
} catch { /* leave the URL */ }
|
||||
openRemoteView({ code });
|
||||
}
|
||||
}
|
||||
|
||||
return { boot, changed, openChooser, openHostModal };
|
||||
})();
|
||||
@@ -2,9 +2,10 @@
|
||||
.saved-storage-text { min-width:0; color:var(--text-2); font-size:14px; font-variant-numeric:tabular-nums; }
|
||||
.saved-clear { flex:none; min-height:44px; padding:0 10px; border:0; background:transparent; color:var(--accent); font:inherit; cursor:pointer; }
|
||||
html[data-layout] .card.saved-card { display:grid; grid-template-columns:72px minmax(0,1fr) 96px; align-items:center; gap:12px; min-height:88px; }
|
||||
html[data-layout] .saved-card .thumb { grid-column:1; grid-row:1; width:72px; }
|
||||
html[data-layout] #cards .saved-card .thumb { grid-column:1; grid-row:1; width:100%; min-width:0; max-width:100%; flex:none; overflow:hidden; }
|
||||
html[data-layout] #cards .saved-card .thumb img { width:100%; max-width:100%; height:100%; object-fit:cover; }
|
||||
html[data-layout] .saved-card .card-info { grid-column:2; grid-row:1; min-width:0; }
|
||||
html[data-layout] .saved-card .card-title { display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2; overflow:hidden; overflow-wrap:anywhere; }
|
||||
html[data-layout] .saved-card .card-title { display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2; overflow:hidden; overflow-wrap:anywhere; text-overflow:ellipsis; }
|
||||
html[data-layout] .saved-file-actions { grid-column:3; grid-row:1; display:flex; align-items:center; gap:8px; }
|
||||
.saved-file-action { width:44px; height:44px; min-width:44px; padding:10px; border:1px solid var(--line); border-radius:var(--radius-sm,8px); background:var(--bg-2); color:var(--text-2); cursor:pointer; }
|
||||
.saved-file-action svg { display:block; width:22px; height:22px; }
|
||||
@@ -13,4 +14,4 @@ html[data-layout] .saved-file-actions { grid-column:3; grid-row:1; display:flex;
|
||||
.saved-file-action.danger:hover, .saved-clear:hover { color:var(--accent); }
|
||||
.saved-file-action:focus-visible, .saved-clear:focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
|
||||
.saved-file-action:disabled, .saved-clear:disabled { opacity:.5; cursor:wait; }
|
||||
@media(min-width:900px) { html[data-layout] .card.saved-card { grid-template-columns:88px minmax(0,1fr) 96px; } html[data-layout] .saved-card .thumb { width:88px; } }
|
||||
@media(min-width:900px) { html[data-layout] .card.saved-card { grid-template-columns:88px minmax(0,1fr) 96px; } }
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
'use strict';
|
||||
const matches = (video, query) => !query || `${video.title || video.id} ${video.channel || ''}`.toLowerCase().includes(query.toLowerCase());
|
||||
const paths = {
|
||||
export: '<path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4"/>',
|
||||
export: '<use href="#media-download"/>',
|
||||
delete: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6"/>'
|
||||
};
|
||||
function button(doc, kind, label, action) {
|
||||
const b = doc.createElement('button'); b.type = 'button';
|
||||
b.className = `saved-file-action${kind === 'delete' ? ' danger' : ''}`;
|
||||
b.setAttribute('aria-label', label); b.title = label;
|
||||
b.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[kind]}</svg>`;
|
||||
b.innerHTML = `<svg class="media-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[kind]}</svg>`;
|
||||
b.onclick = e => { e.stopPropagation(); action(); }; return b;
|
||||
}
|
||||
function actions(row, video, exportFile, deleteFile) {
|
||||
|
||||
321
frontend/section-rail.js
Normal file
321
frontend/section-rail.js
Normal file
@@ -0,0 +1,321 @@
|
||||
/* Eager UI definitions. Read app lexical state only when called after initialization. */
|
||||
const SectionRail = (() => {
|
||||
const CLASSIC_ICONS = {
|
||||
player: '<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4"/>',
|
||||
meta: '<path d="M9 18V5l11-2v13M9 8l11-2"/><ellipse cx="6" cy="18" rx="3" ry="2"/><ellipse cx="17" cy="16" rx="3" ry="2"/>',
|
||||
notes: '<path d="M14 3H4v18h16V11M9 15l2-5 7-7 3 3-7 7z"/>',
|
||||
related: '<path d="m10 14 4-4m-5 6-1 1a4 4 0 0 1-6-6l4-4a4 4 0 0 1 6 0m0 10a4 4 0 0 0 6 0l4-4a4 4 0 0 0-6-6l-1 1"/>',
|
||||
list: '<path d="M9 6h12M9 12h12M9 18h12M3 6h1M3 12h1M3 18h1"/>',
|
||||
};
|
||||
const SECTIONS = [
|
||||
{ id: 'player', sel: '.player-stage', icon: '📺', label: 'Player' },
|
||||
{ id: 'meta', sel: '#nowPlayingMeta', icon: '🎵', label: 'Now playing', fold: true },
|
||||
{ id: 'notes', sel: '#notesPanel', icon: '📝', label: 'Lyrics', fold: true },
|
||||
{ id: 'related', sel: '#relatedPanel', icon: '🔗', label: 'Related', fold: true },
|
||||
{ id: 'list', sel: '#listPane', icon: '📋', label: 'List' },
|
||||
];
|
||||
const st = { active: '', btns: new Map(), labelTimer: null, raf: 0, bottom: -1 };
|
||||
const rail = () => document.getElementById('sectionRail');
|
||||
const folds = () => (data.settings.foldedCards ||= {});
|
||||
const isFolded = (id) => !!folds()[id];
|
||||
const elOf = (s) => document.querySelector(s.sel);
|
||||
// Only cards that are actually on screen belong in the rail.
|
||||
const visible = () => SECTIONS.filter((s) => {
|
||||
const el = elOf(s);
|
||||
return el && !el.classList.contains('hidden') && el.offsetParent !== null;
|
||||
});
|
||||
|
||||
function applyFold(sec) {
|
||||
const el = elOf(sec);
|
||||
if (!el || !sec.fold) return;
|
||||
el.classList.toggle('folded', isFolded(sec.id));
|
||||
if (sec.id === 'notes') {
|
||||
const b = document.getElementById('notesFoldBtn');
|
||||
if (b) b.setAttribute('aria-expanded', String(!isFolded(sec.id)));
|
||||
}
|
||||
// The Related card already had its own +/− button; keep the two in step.
|
||||
if (sec.id === 'related') {
|
||||
relatedCollapsed = isFolded(sec.id);
|
||||
const rl = document.getElementById('relatedList');
|
||||
const rb = document.getElementById('relatedToggleBtn');
|
||||
if (rl) rl.classList.toggle('hidden', relatedCollapsed);
|
||||
if (rb) rb.textContent = relatedCollapsed ? '+' : '−';
|
||||
}
|
||||
}
|
||||
|
||||
function setFolded(id, on) {
|
||||
const sec = SECTIONS.find((x) => x.id === id);
|
||||
if (!sec || !sec.fold) return;
|
||||
folds()[id] = !!on;
|
||||
persist();
|
||||
applyFold(sec);
|
||||
paint(); // NOT render() — folding a card must not rebuild the list
|
||||
}
|
||||
|
||||
function flashLabel(btn) {
|
||||
clearTimeout(st.labelTimer);
|
||||
[...st.btns.values()].forEach((b) => b.classList.remove('show-label'));
|
||||
btn.classList.add('show-label');
|
||||
st.labelTimer = setTimeout(() => btn.classList.remove('show-label'), 1400);
|
||||
}
|
||||
|
||||
function onTap(sec, btn) {
|
||||
const el = elOf(sec);
|
||||
if (!el) return;
|
||||
flashLabel(btn);
|
||||
if (sec.id === 'player' || sec.id === 'meta') restoreNowPlayingList();
|
||||
if (st.active === sec.id && sec.fold) {
|
||||
// Already here: this is the second tap — fold or unfold the card.
|
||||
setFolded(sec.id, !isFolded(sec.id));
|
||||
if (isFolded(sec.id)) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
return;
|
||||
}
|
||||
st.active = sec.id; // claim it now so the next tap folds
|
||||
paint();
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
function build() {
|
||||
const r = rail();
|
||||
if (!r) return;
|
||||
const items = visible();
|
||||
// A rail with one entry is noise; so is one on the empty home screen.
|
||||
if (items.length < 2) { r.classList.add('hidden'); r.innerHTML = ''; st.btns.clear(); return; }
|
||||
const key = items.map((s) => s.id).join(',');
|
||||
if (r.dataset.key !== key) {
|
||||
r.dataset.key = key;
|
||||
r.innerHTML = '';
|
||||
st.btns.clear();
|
||||
for (const sec of items) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'sec-rail-btn';
|
||||
btn.dataset.sec = sec.id;
|
||||
btn.setAttribute('aria-label', sec.label);
|
||||
btn.innerHTML = `<span class="legacy-nav-icon" aria-hidden="true">${sec.icon}</span><svg class="classic-nav-icon" hidden viewBox="0 0 24 24" aria-hidden="true">${CLASSIC_ICONS[sec.id]}</svg><span class="sec-rail-label">${escapeHtml(sec.label)}</span>`;
|
||||
btn.addEventListener('click', () => onTap(sec, btn));
|
||||
r.appendChild(btn);
|
||||
st.btns.set(sec.id, btn);
|
||||
}
|
||||
}
|
||||
r.classList.remove('hidden');
|
||||
placeRail();
|
||||
paint();
|
||||
}
|
||||
|
||||
// The page does NOT scroll the document: `.player-pane` is the scroller in
|
||||
// the browser, but the installed PWA in portrait scrolls `.body` instead
|
||||
// (`.player-pane` goes overflow:visible there). So find the real scroller
|
||||
// rather than assuming one.
|
||||
function scrollerOf(el) {
|
||||
for (let e = el && el.parentElement; e; e = e.parentElement) {
|
||||
const cs = getComputedStyle(e);
|
||||
if (/(auto|scroll)/.test(cs.overflowY) && e.scrollHeight > e.clientHeight + 4) return e;
|
||||
}
|
||||
return document.scrollingElement || document.documentElement;
|
||||
}
|
||||
|
||||
// The visible box to measure against — the scroller's own rect, or the
|
||||
// window when the document itself scrolls.
|
||||
function viewBox() {
|
||||
const first = elOf(visible()[0] || SECTIONS[0]);
|
||||
const sc = scrollerOf(first);
|
||||
if (!sc || sc === document.scrollingElement || sc === document.documentElement) {
|
||||
return { top: 0, height: window.innerHeight };
|
||||
}
|
||||
const r = sc.getBoundingClientRect();
|
||||
return { top: r.top, height: r.height };
|
||||
}
|
||||
|
||||
// Which card is the reader looking at? The one whose top is closest to a
|
||||
// line a third of the way down the visible box — steadier than "first
|
||||
// visible", which flickers between two cards at a boundary.
|
||||
function currentId() {
|
||||
const items = visible();
|
||||
if (!items.length) return '';
|
||||
const box = viewBox();
|
||||
const line = box.top + box.height / 3;
|
||||
let best = '', bestD = Infinity;
|
||||
let near = items[0].id, nearD = Infinity; // fallback: closest card overall
|
||||
for (const sec of items) {
|
||||
const el = elOf(sec);
|
||||
if (!el) continue;
|
||||
const r = el.getBoundingClientRect();
|
||||
const d = Math.abs(r.top - line);
|
||||
// Distance to the visible band, for the fallback below.
|
||||
const outside = Math.max(box.top - r.bottom, r.top - (box.top + box.height), 0);
|
||||
if (outside < nearD || (outside === nearD && d < bestD)) { nearD = outside; near = sec.id; }
|
||||
if (r.bottom < box.top || r.top > box.top + box.height) continue;
|
||||
if (d < bestD) { bestD = d; best = sec.id; }
|
||||
}
|
||||
// Scrolled past the last card (or a card is taller than the viewport and
|
||||
// its top has gone by): never leave the rail with nothing highlighted.
|
||||
return best || near;
|
||||
}
|
||||
|
||||
// The mini player and the bottom nav are both position:fixed bottom:0, and
|
||||
// either can be absent (nothing playing / landscape). Measure whatever is
|
||||
// actually pinned down there and park the rail above it, rather than
|
||||
// guessing an offset that is wrong on the next phone.
|
||||
function placeRail() {
|
||||
const r = rail();
|
||||
let occupied = 0;
|
||||
for (const id of ['bottomNav', 'miniBar']) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el || el.classList.contains('hidden')) continue;
|
||||
const box = el.getBoundingClientRect();
|
||||
if (!box.height) continue;
|
||||
if (getComputedStyle(el).position !== 'fixed') continue;
|
||||
// Count any fixed bar sitting in the lower half. It must NOT be required
|
||||
// to touch the viewport bottom: on an iPhone XR the bottom nav ends
|
||||
// above the home-indicator gap, so a "bottom >= innerHeight" test missed
|
||||
// it entirely and the rail was parked underneath it.
|
||||
if (box.top > window.innerHeight * 0.5) occupied = Math.max(occupied, window.innerHeight - box.top);
|
||||
}
|
||||
// 14px was too tight: the mini player is a floating CARD with a shadow and
|
||||
// rounded corners, so its visual edge reads higher than its box.
|
||||
const want = Math.round(occupied + 24);
|
||||
if (st.bottom !== want) {
|
||||
st.bottom = want;
|
||||
if (r) r.style.setProperty('--rail-bottom', want + 'px');
|
||||
// Published for anything else that must sit above the bottom bars —
|
||||
// the watch-party panel's leave button was hidden behind them.
|
||||
document.documentElement.style.setProperty('--bottom-chrome', Math.round(occupied) + 'px');
|
||||
}
|
||||
}
|
||||
|
||||
function paint() {
|
||||
for (const [id, btn] of st.btns) {
|
||||
btn.classList.toggle('active', id === st.active);
|
||||
btn.classList.toggle('folded', isFolded(id));
|
||||
}
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
if (st.raf) return;
|
||||
st.raf = requestAnimationFrame(() => {
|
||||
st.raf = 0;
|
||||
// Panels appear and disappear outside render() too (the Related card
|
||||
// shows once a video's related list arrives), and a card with no icon
|
||||
// would leave the rail with nothing highlighted. build() is a no-op
|
||||
// unless the set of visible cards actually changed.
|
||||
build();
|
||||
placeRail();
|
||||
const id = currentId();
|
||||
if (id && id !== st.active) { st.active = id; paint(); }
|
||||
});
|
||||
}
|
||||
|
||||
function boot() {
|
||||
const fb = document.getElementById('notesFoldBtn');
|
||||
if (fb) fb.addEventListener('click', () => setFolded('notes', !isFolded('notes')));
|
||||
SECTIONS.forEach(applyFold);
|
||||
// Scroll events from an element do NOT bubble, so a window listener never
|
||||
// fires here. A capture-phase listener on document catches them all, and
|
||||
// keeps working when the scroller changes between browser and installed
|
||||
// PWA (different elements scroll in each).
|
||||
document.addEventListener('scroll', onScroll, { passive: true, capture: true });
|
||||
window.addEventListener('resize', () => { build(); placeRail(); onScroll(); });
|
||||
// The mini player appears AFTER the scroll that reveals it, so a scroll
|
||||
// handler alone measures the layout as it was a moment ago and parks the
|
||||
// rail underneath it. Watch the two bottom bars for real.
|
||||
const watch = ['bottomNav', 'miniBar'].map((id) => document.getElementById(id)).filter(Boolean);
|
||||
if (watch.length && typeof MutationObserver !== 'undefined') {
|
||||
// The mini bar slides in over 0.25s, so a measurement taken the instant
|
||||
// it appears catches it mid-animation and lands the rail a few pixels
|
||||
// too low. Measure now and again once it has settled.
|
||||
const mo = new MutationObserver(() => { placeRail(); setTimeout(placeRail, 320); });
|
||||
watch.forEach((el) => mo.observe(el, { attributes: true, attributeFilter: ['class', 'style'] }));
|
||||
}
|
||||
if (watch.length && typeof ResizeObserver !== 'undefined') {
|
||||
const ro = new ResizeObserver(() => placeRail());
|
||||
watch.forEach((el) => ro.observe(el));
|
||||
}
|
||||
build();
|
||||
onScroll();
|
||||
}
|
||||
|
||||
// Called from render(): cards appear and disappear as videos load.
|
||||
function refresh() {
|
||||
SECTIONS.forEach(applyFold);
|
||||
build();
|
||||
placeRail();
|
||||
onScroll();
|
||||
}
|
||||
|
||||
return { boot, refresh, setFolded, isFolded };
|
||||
})();
|
||||
|
||||
/* Cache the single page-owned app response before it registers the worker. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
const doc=root.document, manifest=root.Lazy.manifest, key=root.Lazy.url('/app.js');
|
||||
let domReady=doc.readyState==='complete', appSawDom=false;
|
||||
doc.addEventListener('DOMContentLoaded',()=>{domReady=true;appSawDom=typeof root.boot==='function';},{once:true});
|
||||
function execute(node) {
|
||||
doc.head.append(node);
|
||||
node.remove();
|
||||
delete root.Lazy.appResponse;
|
||||
// app.js keeps its original DOMContentLoaded listener. Async response
|
||||
// consumption can finish after that event; boot exactly once in that case.
|
||||
if(domReady)root.boot();
|
||||
}
|
||||
async function external(url) {
|
||||
await new Promise((resolve,reject)=>{
|
||||
const node=doc.createElement('script');node.src=url;
|
||||
node.onload=()=>{if(domReady&&!appSawDom)root.boot();resolve();};node.onerror=()=>reject(Error('Unable to load player'));
|
||||
doc.head.append(node);
|
||||
});
|
||||
}
|
||||
function native() {
|
||||
if(doc.readyState!=='loading' || !doc.currentScript)return external(key);
|
||||
return new Promise((resolve,reject)=>{
|
||||
doc.addEventListener('DOMContentLoaded',()=>typeof root.boot==='function'?resolve():reject(Error('Unable to load player')),{once:true});
|
||||
const escaped=key.replaceAll('&','&').replaceAll('"','"').replaceAll('<','<');
|
||||
doc.write('<script src="'+escaped+'"></script>');
|
||||
});
|
||||
}
|
||||
async function verified(response, expected) {
|
||||
const bytes=await response.clone().arrayBuffer();
|
||||
const digest=new Uint8Array(await root.crypto.subtle.digest('SHA-256',bytes));
|
||||
const hex=Array.from(digest,b=>b.toString(16).padStart(2,'0')).join('');
|
||||
if(hex.slice(0,10)!==expected)throw Error('Player body hash mismatch');
|
||||
return {bytes,digest};
|
||||
}
|
||||
async function start() {
|
||||
if(!manifest.appCache || !root.crypto?.subtle || !root.Lazy.captureApp)return native();
|
||||
const expected=manifest.files['/app.js'].h;
|
||||
let cache;
|
||||
try { cache=await root.caches?.open('ytplayer-assets'); } catch {}
|
||||
let response=await cache?.match(key);
|
||||
if(!response || response.headers.get('X-Asset-Hash')!==expected) {
|
||||
try { response=await (root.Lazy.appResponse || root.fetch(key,{credentials:'same-origin'})); } catch {}
|
||||
}
|
||||
if(!response?.ok || response.headers.get('X-Asset-Hash')!==expected) {
|
||||
// Preserve staged N-1 boot after an individual cache eviction. Only the
|
||||
// controller can serve this old URL safely; never fetch stale URLs bare.
|
||||
const state=cache && await root.AssetSyncCore.state(cache);
|
||||
const previousKey=state && root.AssetSyncCore.fallback(manifest,state.previous,'/app.js');
|
||||
const previous=previousKey && await cache.match(previousKey);
|
||||
const previousHash=state?.previous?.files['/app.js']?.h;
|
||||
const contractsMatch=state?.previous && Object.entries(manifest.groups).every(([name,group])=>state.previous.groups[name]?.contract===group.contract);
|
||||
if(contractsMatch && root.navigator?.serviceWorker?.controller && previous?.ok && previous.headers.get('X-Asset-Hash')===previousHash) {
|
||||
await verified(previous,previousHash);
|
||||
delete root.Lazy.appResponse;
|
||||
return external(previousKey);
|
||||
}
|
||||
throw Error('Player asset hash mismatch');
|
||||
}
|
||||
const {bytes,digest}=await verified(response,expected);
|
||||
const permission="'sha256-"+root.btoa(String.fromCharCode(...digest))+"'";
|
||||
if(!doc.querySelector('meta[http-equiv="Content-Security-Policy"]').content.includes(permission))throw Error('Player body hash mismatch');
|
||||
// CacheStorage may share a full quota with saved music. Never remove data
|
||||
// to make room; boot from these verified bytes even if caching is refused.
|
||||
try { await cache?.put(key,response); } catch(error) { root.console.warn('[app-cache]',error.message); }
|
||||
const node=doc.createElement('script');node.textContent=new root.TextDecoder().decode(bytes);
|
||||
execute(node);
|
||||
}
|
||||
const ready=start();
|
||||
root.AppBootstrap={ready};
|
||||
ready.catch(error=>root.console.error('[app-cache]',error.message));
|
||||
})(typeof window!=='undefined'?window:globalThis);
|
||||
37
frontend/settings-persist.test.js
Normal file
37
frontend/settings-persist.test.js
Normal file
@@ -0,0 +1,37 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const source = fs.readFileSync(__dirname + '/app.js', 'utf8');
|
||||
// Exercise the production persistence/profile functions without starting a DOM player.
|
||||
const declaration = name => source.match(new RegExp('(?:async )?function ' + name + '\\([^]*?\\n}'))[0];
|
||||
function fixture(updatedAt) {
|
||||
const settings = { saveBeforePlay: true, autoPreload: true, layout: 'f7-swipe', eq: { preset: 'custom', g: [1,2,3,4,5] }, jamesDsp: { enabled: true, downloadForOffline: true, preset: 'warm', params: { 'eq.band0': 3 } } };
|
||||
const defaults = { saveBeforePlay: false, autoPreload: true, layout: 'classic', jamesDsp: { enabled: false, downloadForOffline: false, preset: 'flat', params: {} } };
|
||||
const context = vm.createContext({ WEB: true, data: { settings, profile: { name: 'test', syncedAt: 200 } }, DEFAULT_SETTINGS: defaults, API: { saveData: async () => {} }, profileHeaders: () => ({}), scheduleProfilePush: () => context.pushes++, pushes: 0, fetch: async () => ({ status: 200, json: async () => ({ ok: true, name: 'test', updatedAt, data: { settings: { saveBeforePlay: false } } }) }), localStorage: { getItem: () => JSON.stringify({ settings }) } });
|
||||
vm.runInContext([declaration('loadDataFromStorage'), declaration('normalizeJamesDspSettings'), declaration('applyProfileData'), declaration('pullProfileIfNewer')].join('\n'), context);
|
||||
return context;
|
||||
}
|
||||
test('stored settings load verbatim instead of reverting to defaults', () => {
|
||||
const c = fixture(100);
|
||||
const loaded = vm.runInContext('loadDataFromStorage()', c);
|
||||
assert.equal(loaded.settings.saveBeforePlay, true);
|
||||
assert.equal(loaded.settings.layout, 'f7-swipe');
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(loaded.settings.eq.g)), [1,2,3,4,5]);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(loaded.settings.jamesDsp)), { enabled: true, downloadForOffline: true, preset: 'warm', params: { 'eq.band0': 3 } });
|
||||
});
|
||||
for (const updatedAt of [100,200]) test(`profile timestamp ${updatedAt} cannot overwrite local settings synced at 200`, async () => {
|
||||
const c = fixture(updatedAt), previous = c.data.settings;
|
||||
await vm.runInContext('pullProfileIfNewer()', c);
|
||||
assert.equal(c.data.settings, previous);
|
||||
assert.equal(c.pushes, 1);
|
||||
});
|
||||
test('newer profile intentionally replaces settings and restores missing defaults', async () => {
|
||||
const c = fixture(300);
|
||||
await vm.runInContext('pullProfileIfNewer()', c);
|
||||
assert.equal(c.data.settings.saveBeforePlay, false);
|
||||
assert.equal(c.data.settings.autoPreload, true);
|
||||
assert.equal(c.data.settings.layout, 'classic');
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(c.data.settings.jamesDsp)), { enabled: false, downloadForOffline: false, preset: 'flat', params: {}, irRef: null });
|
||||
assert.equal(c.data.profile.syncedAt, 300);
|
||||
});
|
||||
@@ -1,4 +1,27 @@
|
||||
.settings-browser { display: block; width: 100%; min-width: 0; }
|
||||
.settings-browser, .settings-grid, .settings-overview, .settings-detail, .settings-section,
|
||||
.settings-browser .settings, .settings-browser .set-group, .settings-browser .dsp-settings,
|
||||
.settings-browser .dsp-subsection, .settings-browser .dsp-param-list {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
/* Settings is a workspace, not a sidebar: give the section browser the full
|
||||
content area on desktop instead of leaving it inside the fixed 452px list. */
|
||||
.body:has(#listPane .cards[data-view="settings"]) .player-pane { display: none; }
|
||||
.body:has(#listPane .cards[data-view="settings"]) #listPane {
|
||||
flex: 1 1 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
border-left: 0;
|
||||
}
|
||||
.body:has(#listPane .cards[data-view="settings"]) #listPane .cards {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.settings-browser [hidden] { display: none !important; }
|
||||
.settings-search { margin-bottom: 12px; }
|
||||
.settings-search input { width: 100%; min-height: 44px; background: var(--bg-2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; font: inherit; }
|
||||
@@ -11,10 +34,81 @@
|
||||
.settings-section-row:hover, .settings-section-row[aria-current="true"] { background: var(--bg-3); border-radius: 8px; }
|
||||
.settings-browser :is(button,input,select,textarea):focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||
.settings-back { min-height: 44px; padding: 8px 12px; margin-bottom: 12px; background: var(--bg-2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; font: inherit; cursor: pointer; }
|
||||
.settings-browser .set-row { min-height: 44px; }
|
||||
.settings-browser .set-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, min(40%, 260px));
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 44px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.settings-browser .set-row > :is(span, input, select, button, output) { min-width: 0; max-width: 100%; }
|
||||
.settings-browser .set-row > :is(input, select, button, output) { box-sizing: border-box; }
|
||||
.settings-browser .set-control-wrap { display: block; width: 100%; min-width: 0; max-width: 100%; overflow: hidden; }
|
||||
#listPane #cards .settings-browser .set-row select,
|
||||
#listPane #cards .settings-browser .set-control-wrap select,
|
||||
.settings-browser .set-row select,
|
||||
.settings-browser .set-control-wrap select {
|
||||
box-sizing: border-box;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding-right: 28px;
|
||||
background-color: var(--bg-3);
|
||||
background-image: linear-gradient(45deg, transparent 50%, var(--text-dim) 50%), linear-gradient(135deg, var(--text-dim) 50%, transparent 50%);
|
||||
background-position: calc(100% - 14px) 50%, calc(100% - 9px) 50%;
|
||||
background-size: 5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
color: var(--text);
|
||||
border-color: var(--line);
|
||||
}
|
||||
.settings-browser .set-row > span:first-child { overflow-wrap: anywhere; }
|
||||
.settings-browser .set-row:has(> span:only-child) { grid-template-columns: minmax(0, 1fr); }
|
||||
.settings-browser .set-row small { font-size: 12px; max-width: 100%; }
|
||||
.settings-browser .set-row > span { min-width: 0; }
|
||||
.settings-browser .set-row input[type="range"] { max-width: 42%; }
|
||||
.settings-browser .set-row input[type="range"] { width: calc(100% - 16px); max-width: calc(100% - 16px); min-width: 0; }
|
||||
.settings-browser .set-row input[type="checkbox"] {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
justify-self: end;
|
||||
width: 46px;
|
||||
height: 28px;
|
||||
min-width: 46px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(circle at 14px 50%, var(--text) 0 9px, transparent 10px), var(--bg-3);
|
||||
cursor: pointer;
|
||||
transition: background .16s ease, border-color .16s ease;
|
||||
}
|
||||
.settings-browser .set-row input[type="checkbox"]:checked {
|
||||
border-color: var(--accent);
|
||||
background: radial-gradient(circle at 32px 50%, var(--on-accent, #fff) 0 9px, transparent 10px), var(--accent);
|
||||
}
|
||||
.settings-browser .set-row input[type="checkbox"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||
.dsp-file-control {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.dsp-file-control input[type="file"] { position: absolute; width: 1px; height: 1px; min-width: 0; max-width: 1px; padding: 0; border: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
.dsp-file-control:focus-within { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 8px; }
|
||||
.dsp-file-button { display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding: 7px 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--bg-3); color: var(--text); font: 600 12px var(--ui); white-space: nowrap; }
|
||||
.dsp-file-name { display: block; min-width: 0; overflow: hidden; color: var(--text-2); text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||
.dsp-subsection .sp-note { min-width: 0; max-width: 100%; overflow-wrap: anywhere; }
|
||||
.dsp-subsection > .btn { min-width: 0; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.dsp-actions { min-width: 0; max-width: 100%; }
|
||||
.dsp-param-list { grid-template-columns: repeat(auto-fit, minmax(min(190px, 100%), 1fr)); }
|
||||
.dsp-param-row input[type="range"] { width: calc(100% - 16px); min-width: 0; max-width: calc(100% - 16px); }
|
||||
.settings-browser .settings-detail { display: none; min-width: 0; }
|
||||
.settings-browser[data-detail="1"] .settings-overview { display: none; }
|
||||
.settings-browser[data-detail="1"] .settings-detail { display: block; }
|
||||
@@ -22,7 +116,7 @@
|
||||
.settings-grid { display: grid; grid-template-columns: minmax(160px, .8fr) minmax(0, 1.4fr); gap: 16px; }
|
||||
.settings-browser .settings-overview, .settings-browser[data-detail="1"] .settings-overview, .settings-browser .settings-detail { display: block; }
|
||||
.settings-back { display: none; }
|
||||
.settings-browser .set-row { flex-wrap: wrap; gap: 12px; }
|
||||
.settings-browser .set-row { flex-wrap: nowrap; gap: 12px; }
|
||||
}
|
||||
.download-job { padding: 14px 0; border-bottom: 1px solid var(--line); display: grid; gap: 8px; }
|
||||
.download-job b { overflow-wrap: anywhere; }
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
const icon = paths => `<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${paths || '<circle cx="12" cy="12" r="7"/>'}</svg>`;
|
||||
function mount(host, context = {}) {
|
||||
dispose();
|
||||
const document = host.ownerDocument, win = document.defaultView, sections = registry.list(), panels = new Map(), rows = new Map(), cleanups = [];
|
||||
const document = host.ownerDocument, win = document.defaultView, sections = registry.list(), panels = new Map(), rows = new Map(), cleanups = [], searchLoads = new Set();
|
||||
let mounted = true;
|
||||
let remembered = ''; try { remembered = win.sessionStorage.getItem('ytpSettingsSection') || ''; } catch {}
|
||||
let active = registry.get(remembered) ? remembered : sections[0]?.id;
|
||||
host.classList.add('settings-browser');
|
||||
@@ -45,14 +46,23 @@
|
||||
const text = document.createElement('span'), title = document.createElement('b'), summary = document.createElement('small'); title.textContent = section.title; text.append(title, summary); button.append(text);
|
||||
button.setAttribute('aria-controls', 'settings-section-' + section.id); navigation.append(button); rows.set(section.id, { button, summary });
|
||||
const panel = document.createElement('section'); panel.id = 'settings-section-' + section.id; panel.className = 'settings-section'; panel.setAttribute('aria-label', section.title); panels.set(section.id, panel); detail.append(panel);
|
||||
const cleanup = section.render(panel, context); if (typeof cleanup === 'function') cleanups.push(cleanup);
|
||||
if (!section.lazyGroup) {
|
||||
const cleanup = section.render(panel, context); if (typeof cleanup === 'function') cleanups.push(cleanup);
|
||||
}
|
||||
button.addEventListener('click', () => open(section.id));
|
||||
if(section.lazyGroup)root.Lazy.intent(button,section.lazyGroup);
|
||||
}
|
||||
}
|
||||
grid.append(navigation, detail); host.replaceChildren(toolbar, results, empty, grid);
|
||||
function summaries() { for (const section of sections) { try { rows.get(section.id).summary.textContent = section.summary(context) || ''; } catch {} } }
|
||||
function show(id, inDetail, focusId) {
|
||||
if (registry.get(id)) active = id;
|
||||
const section = sections.find(section => section.id === active);
|
||||
const panel = panels.get(active);
|
||||
if ((inDetail || win.matchMedia?.('(min-width: 861px)').matches) && section?.lazyGroup && !panel.dataset.rendered) {
|
||||
panel.dataset.rendered = '1';
|
||||
const cleanup = section.render(panel, context); if (typeof cleanup === 'function') cleanups.push(cleanup);
|
||||
}
|
||||
host.dataset.detail = inDetail ? '1' : '0';
|
||||
for (const [key, panel] of panels) panel.hidden = key !== active;
|
||||
for (const [key, row] of rows) row.button.setAttribute('aria-current', key === active ? 'true' : 'false');
|
||||
@@ -71,6 +81,19 @@
|
||||
function filter() {
|
||||
const query = input.value.trim(); results.replaceChildren(); results.hidden = !query; grid.hidden = !!query;
|
||||
if (!query) { empty.hidden = true; return; }
|
||||
// Searching is explicit feature intent. Mount optional controls so their
|
||||
// existing labels/help remain searchable without parsing them on opening Settings.
|
||||
for (const section of sections) {
|
||||
const panel=panels.get(section.id);
|
||||
if(!section.lazyGroup || panel.dataset.rendered || searchLoads.has(section.id))continue;
|
||||
searchLoads.add(section.id);
|
||||
root.Lazy.load(section.lazyGroup).then(()=>{
|
||||
if(!mounted || !host.isConnected || panel.dataset.rendered)return;
|
||||
panel.dataset.rendered='1';
|
||||
const cleanup=registry.get(section.id).render(panel,context);if(typeof cleanup==='function')cleanups.push(cleanup);
|
||||
filter();
|
||||
}).catch(()=>searchLoads.delete(section.id));
|
||||
}
|
||||
const entries = [];
|
||||
for (const section of sections) {
|
||||
entries.push({ title: section.title, description: section.description || '', section: section.id });
|
||||
@@ -92,11 +115,11 @@
|
||||
const pop = event => { if (!host.isConnected) return; show(event.state?.ytpSettingsSection || active, !!event.state?.ytpSettingsSection); if (!event.state?.ytpSettingsSection) rows.get(active)?.button.focus({ preventScroll: true }); };
|
||||
win.addEventListener('popstate', pop); input.addEventListener('input', filter);
|
||||
host.addEventListener('change', summaries); host.addEventListener('input', summaries);
|
||||
const observer = new win.MutationObserver(summaries); observer.observe(detail, { childList: true, subtree: true, characterData: true });
|
||||
const observer = new win.MutationObserver(()=>{summaries();if(input.value.trim())filter();}); observer.observe(detail, { childList: true, subtree: true, characterData: true });
|
||||
const state = win.history.state?.ytpSettingsSection;
|
||||
if (!state) win.history.replaceState({ ...win.history.state, ytpSettingsSection: null }, '', win.location.href);
|
||||
show(state || active, !!state); summaries();
|
||||
dispose = () => { observer.disconnect(); win.removeEventListener('popstate', pop); cleanups.forEach(fn => fn()); };
|
||||
dispose = () => { mounted=false;observer.disconnect(); win.removeEventListener('popstate', pop); cleanups.forEach(fn => fn()); };
|
||||
return { open, refresh: summaries, dispose };
|
||||
}
|
||||
const api = { ...registry, mount, search, normalize, createRegistry, clusters };
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
wasm = null;
|
||||
try {
|
||||
if (typeof WebAssembly === 'undefined') return wasm;
|
||||
if (!root.SHA256_WASM_B64 && typeof importScripts === 'function') importScripts('/sha256-wasm.js');
|
||||
if (!root.SHA256_WASM_B64 && typeof importScripts === 'function') importScripts(root.__ASSET_URLS__?.wasm || '/sha256-wasm.js');
|
||||
let b64 = root.SHA256_WASM_B64;
|
||||
if (!b64 && typeof require === 'function') b64 = require('./sha256-wasm.js');
|
||||
if (!b64) return wasm;
|
||||
|
||||
221
frontend/share-external.js
Normal file
221
frontend/share-external.js
Normal file
@@ -0,0 +1,221 @@
|
||||
/* Lazy classic chunk; its executing singleton stays pinned until reload. */
|
||||
(window.YT ||= {}).Share = (() => {
|
||||
const appLink = (id, t) => `${location.origin}/?v=${encodeURIComponent(id)}${t >= 1 ? `&t=${Math.floor(t)}` : ''}`;
|
||||
const ytLink = (id, t) => `https://youtu.be/${encodeURIComponent(id)}${t >= 1 ? `?t=${Math.floor(t)}` : ''}`;
|
||||
|
||||
async function copy(text, label) {
|
||||
try { await navigator.clipboard.writeText(text); toast(`${label} copied ✓`); }
|
||||
catch { toast('Copy blocked — long-press the link to copy it'); }
|
||||
}
|
||||
|
||||
function open() {
|
||||
const meta = current && current.meta;
|
||||
if (!meta || !meta.id || meta.custom) { toast('Play a YouTube video first'); return; }
|
||||
const t0 = Math.floor(Player.master.currentTime || 0);
|
||||
const body = document.createElement('div');
|
||||
body.className = 'share-moment';
|
||||
body.innerHTML = `
|
||||
<label class="sm-row">Moment <input class="sm-time" inputmode="decimal" value="${LyricsCore.fmtClock(t0)}" /></label>
|
||||
<div class="sm-links">
|
||||
<div class="sm-link"><span>This app</span><code class="sm-app"></code><button class="np-btn" type="button" data-act="app">Copy</button></div>
|
||||
${isUploadId(meta.id) ? '' : '<div class="sm-link"><span>YouTube</span><code class="sm-yt"></code><button class="np-btn" type="button" data-act="yt">Copy</button></div>'}
|
||||
</div>
|
||||
<div class="sm-gif">
|
||||
<div class="sm-gif-controls">
|
||||
<label>GIF length <select class="sm-len"><option value="2">2 s</option><option value="3" selected>3 s</option><option value="5">5 s</option></select></label>
|
||||
<button class="np-btn" type="button" data-act="gif">🎞 Make GIF</button>
|
||||
</div>
|
||||
<div class="sm-gif-out"></div>
|
||||
</div>
|
||||
<div class="sm-gif sm-bite">
|
||||
<div class="sm-gif-controls">
|
||||
<label>🔔 Soundbite <input class="sm-b-start" inputmode="decimal" /></label>
|
||||
<label>to <input class="sm-b-end" inputmode="decimal" /></label>
|
||||
<select class="sm-b-fmt"><option value="mp3">MP3</option><option value="m4r">iPhone ringtone (.m4r)</option></select>
|
||||
<button class="np-btn" type="button" data-act="bite">Make</button>
|
||||
</div>
|
||||
<div class="sm-bite-out"></div>
|
||||
</div>`;
|
||||
const timeIn = body.querySelector('.sm-time');
|
||||
const cur = () => { const v = LyricsCore.parseStamp(timeIn.value); return v === null ? t0 : v; };
|
||||
const refresh = () => {
|
||||
body.querySelector('.sm-app').textContent = appLink(meta.id, cur());
|
||||
const yt = body.querySelector('.sm-yt');
|
||||
if (yt) yt.textContent = ytLink(meta.id, cur());
|
||||
};
|
||||
timeIn.addEventListener('input', refresh);
|
||||
timeIn.addEventListener('keydown', (e) => e.stopPropagation());
|
||||
refresh();
|
||||
// Soundbite range: the A-B loop when one is set, else the next 20 s.
|
||||
const bStart = abA !== null ? abA : t0;
|
||||
const bEnd = abB !== null && abB > bStart ? abB : bStart + 20;
|
||||
body.querySelector('.sm-b-start').value = LyricsCore.fmtClock(bStart);
|
||||
body.querySelector('.sm-b-end').value = LyricsCore.fmtClock(bEnd);
|
||||
body.querySelectorAll('.sm-bite input').forEach((i) => i.addEventListener('keydown', (e) => e.stopPropagation()));
|
||||
let biteBlob = null;
|
||||
let gifBlob = null;
|
||||
body.addEventListener('click', async (e) => {
|
||||
const act = e.target.closest('[data-act]');
|
||||
if (!act) return;
|
||||
if (act.dataset.act === 'app') copy(appLink(meta.id, cur()), 'App link');
|
||||
else if (act.dataset.act === 'yt') copy(ytLink(meta.id, cur()), 'YouTube link');
|
||||
else if (act.dataset.act === 'gif') {
|
||||
const out = body.querySelector('.sm-gif-out');
|
||||
out.innerHTML = '<div class="sm-busy">Making the GIF…</div>';
|
||||
act.disabled = true;
|
||||
try {
|
||||
const d = body.querySelector('.sm-len').value;
|
||||
const res = await fetch(`/api/media/${encodeURIComponent(meta.id)}/gif?t=${cur()}&d=${d}&w=480`);
|
||||
if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || `HTTP ${res.status}`); }
|
||||
gifBlob = await res.blob();
|
||||
const url = URL.createObjectURL(gifBlob);
|
||||
const name = `${(meta.title || meta.id).replace(/[^\w\- ]+/g, '').trim().slice(0, 40) || meta.id}-${Math.floor(cur())}s.gif`;
|
||||
out.innerHTML = `<img class="sm-preview" alt="GIF preview" src="${url}" />
|
||||
<div class="sm-gif-actions"><a class="np-btn" href="${url}" download="${escapeHtml(name)}">${MediaIcons.svg('download')} Download</a>
|
||||
${navigator.canShare && navigator.canShare({ files: [new File([gifBlob], name, { type: 'image/gif' })] }) ? '<button class="np-btn" type="button" data-act="sharegif">↗ Share GIF</button>' : ''}</div>`;
|
||||
out.dataset.name = name;
|
||||
} catch (err) {
|
||||
out.innerHTML = `<div class="sm-err">⚠ ${escapeHtml(err.message)}</div>`;
|
||||
} finally {
|
||||
act.disabled = false;
|
||||
}
|
||||
} else if (act.dataset.act === 'bite') {
|
||||
const out = body.querySelector('.sm-bite-out');
|
||||
const a = LyricsCore.parseStamp(body.querySelector('.sm-b-start').value);
|
||||
const b = LyricsCore.parseStamp(body.querySelector('.sm-b-end').value);
|
||||
const fmt = body.querySelector('.sm-b-fmt').value;
|
||||
const max = fmt === 'm4r' ? 40 : 60;
|
||||
if (a === null || b === null || b <= a) { out.innerHTML = '<div class="sm-err">⚠ Times must look like 1:23, end after start</div>'; return; }
|
||||
if (b - a > max) { out.innerHTML = `<div class="sm-err">⚠ Max ${max} s for ${fmt === 'm4r' ? 'an iPhone ringtone' : 'a soundbite'}</div>`; return; }
|
||||
out.innerHTML = '<div class="sm-busy">Cutting the soundbite…</div>';
|
||||
act.disabled = true;
|
||||
try {
|
||||
const res = await fetch(`/api/media/${encodeURIComponent(meta.id)}/clip?start=${a}&end=${b}&fmt=${fmt}`);
|
||||
if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || `HTTP ${res.status}`); }
|
||||
biteBlob = await res.blob();
|
||||
const url = URL.createObjectURL(biteBlob);
|
||||
const name = `${(meta.title || meta.id).replace(/[^\w\- ]+/g, '').trim().slice(0, 40) || meta.id}-${Math.floor(a)}s.${fmt}`;
|
||||
out.dataset.name = name;
|
||||
out.dataset.type = fmt === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
|
||||
out.innerHTML = `<audio controls src="${url}" class="sm-audio"></audio>
|
||||
<div class="sm-gif-actions"><a class="np-btn" href="${url}" download="${escapeHtml(name)}">${MediaIcons.svg('download')} Download</a>
|
||||
${navigator.canShare && navigator.canShare({ files: [new File([biteBlob], name, { type: out.dataset.type })] }) ? '<button class="np-btn" type="button" data-act="sharebite">↗ Share</button>' : ''}</div>
|
||||
${fmt === 'm4r' ? '<p class="sp-note">iPhone: share it to GarageBand (or drag into Finder with the phone connected) to set it as a ringtone.</p>' : ''}`;
|
||||
} catch (err) {
|
||||
out.innerHTML = `<div class="sm-err">⚠ ${escapeHtml(err.message)}</div>`;
|
||||
} finally {
|
||||
act.disabled = false;
|
||||
}
|
||||
} else if (act.dataset.act === 'sharebite' && biteBlob) {
|
||||
const o = body.querySelector('.sm-bite-out');
|
||||
try { await navigator.share({ files: [new File([biteBlob], o.dataset.name, { type: o.dataset.type })], title: meta.title }); } catch { /* dismissed */ }
|
||||
} else if (act.dataset.act === 'sharegif' && gifBlob) {
|
||||
const name = body.querySelector('.sm-gif-out').dataset.name;
|
||||
try { await navigator.share({ files: [new File([gifBlob], name, { type: 'image/gif' })], title: meta.title }); } catch { /* dismissed */ }
|
||||
}
|
||||
});
|
||||
const actions = [{ label: 'Done', onClick: closeModal }];
|
||||
if (navigator.share) {
|
||||
actions.push({ label: '↗ Share link', primary: true, onClick: async () => {
|
||||
try { await navigator.share({ title: meta.title, text: `${meta.title} @ ${LyricsCore.fmtClock(cur())}`, url: appLink(meta.id, cur()) }); } catch { /* dismissed */ }
|
||||
} });
|
||||
} else {
|
||||
actions.push({ label: 'Copy app link', primary: true, onClick: () => copy(appLink(meta.id, cur()), 'App link') });
|
||||
}
|
||||
showModal('🔗 Share this moment', body, actions);
|
||||
}
|
||||
|
||||
// /?v=<id>&t=<sec> — open that video at that moment.
|
||||
async function bootFromUrl() {
|
||||
let id = '', t = 0;
|
||||
try {
|
||||
const q = new URLSearchParams(location.search);
|
||||
id = (q.get('v') || '').trim();
|
||||
t = Math.max(0, parseFloat(q.get('t')) || 0);
|
||||
} catch { return; }
|
||||
if (!isMediaId(id)) return;
|
||||
try {
|
||||
const u = new URL(location.href);
|
||||
u.searchParams.delete('v');
|
||||
u.searchParams.delete('t');
|
||||
history.replaceState(null, '', u.pathname + u.search + u.hash);
|
||||
} catch { /* keep */ }
|
||||
await playVideoAt(data.history.find((x) => x.id === id) || { id, title: '' }, t);
|
||||
}
|
||||
|
||||
return { open, bootFromUrl };
|
||||
})();
|
||||
|
||||
/* Lazy classic chunk; its executing singleton stays pinned until reload. */
|
||||
(window.YT ||= {}).External = (() => {
|
||||
const isAndroid = /Android/i.test(navigator.userAgent);
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const streamUrl = (id) => `${location.origin}${isUploadId(id) ? '/api/uploads/' : '/api/media/'}${encodeURIComponent(id)}`;
|
||||
async function cached(id) {
|
||||
if (isUploadId(id)) return true; // uploads are always a single file on the server
|
||||
try { const j = await (await fetch(`/api/media/${encodeURIComponent(id)}/status`)).json(); return j && j.status === 'ready'; } catch { return false; }
|
||||
}
|
||||
const intent = (url, title, pkg) => `intent:${url}#Intent;action=android.intent.action.VIEW;type=video/*;${pkg ? `package=${pkg};` : ''}S.title=${encodeURIComponent(title || '')};end`;
|
||||
function m3u(items) {
|
||||
return ['#EXTM3U', ...items.flatMap((v) => [`#EXTINF:${Math.round(v.duration || -1)},${(v.title || v.id).replace(/[\r\n]/g, ' ')}`, streamUrl(v.id)])].join('\n') + '\n';
|
||||
}
|
||||
function download(name, text) {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(new Blob([text], { type: 'audio/x-mpegurl' }));
|
||||
a.download = name;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
|
||||
}
|
||||
|
||||
async function open() {
|
||||
const meta = current && current.meta;
|
||||
if (!meta || !meta.id || meta.custom) { toast('Play a YouTube video first'); return; }
|
||||
const body = document.createElement('div');
|
||||
body.className = 'ext-panel';
|
||||
body.innerHTML = '<div class="sm-busy">Checking the server copy…</div>';
|
||||
showModal('📺 Open in another player', body, [{ label: 'Close', primary: true, onClick: closeModal }]);
|
||||
const ok = await cached(meta.id);
|
||||
if (!ok) {
|
||||
body.innerHTML = `<p class="modal-text">The server hasn't finished caching this video yet, and other players need that single-file copy. Keep it playing for a minute (or tap ${MediaIcons.svg('download')} Save), then try again.</p>`;
|
||||
return;
|
||||
}
|
||||
const url = streamUrl(meta.id);
|
||||
const title = meta.title || meta.id;
|
||||
const inList = queue.length > 1 && queueIndex >= 0;
|
||||
body.innerHTML = `
|
||||
<div class="sm-link"><span>Stream</span><code>${escapeHtml(url)}</code><button class="np-btn" type="button" data-act="copy">Copy</button></div>
|
||||
<div class="ext-buttons">
|
||||
${isAndroid ? `
|
||||
<a class="np-btn" href="${escapeHtml(intent(url, title, 'org.videolan.vlc'))}">${TransportIcons.svg('play')} VLC</a>
|
||||
<a class="np-btn" href="${escapeHtml(intent(url, title, 'com.mxtech.videoplayer.ad'))}">${TransportIcons.svg('play')} MX Player</a>
|
||||
<a class="np-btn" href="${escapeHtml(intent(url, title))}">${TransportIcons.svg('play')} Open with…</a>`
|
||||
: isIOS ? `
|
||||
<a class="np-btn" href="vlc-x-callback://x-callback-url/stream?url=${encodeURIComponent(url)}">${TransportIcons.svg('play')} VLC</a>
|
||||
<a class="np-btn" href="infuse://x-callback-url/play?url=${encodeURIComponent(url)}">${TransportIcons.svg('play')} Infuse</a>`
|
||||
: `<a class="np-btn" href="vlc://${escapeHtml(url)}">${TransportIcons.svg('play')} VLC</a>`}
|
||||
<button class="np-btn" type="button" data-act="m3u">${MediaIcons.svg('download')} .m3u (this video)</button>
|
||||
${inList ? `<button class="np-btn" type="button" data-act="m3u-list">${MediaIcons.svg('download')} .m3u (whole list)</button>` : ''}
|
||||
</div>
|
||||
<p class="sp-note">${isAndroid ? 'VLC / MX Player must be installed; “Open with…” shows every video app.'
|
||||
: isIOS ? 'Needs the VLC (or Infuse) app. The link streams from your server; nothing is downloaded first.'
|
||||
: 'Desktop VLC may not register vlc:// links — the .m3u file opens in VLC, mpv, IINA or any media player.'}</p>`;
|
||||
body.addEventListener('click', async (e) => {
|
||||
const act = e.target.closest('[data-act]');
|
||||
if (!act) return;
|
||||
if (act.dataset.act === 'copy') {
|
||||
try { await navigator.clipboard.writeText(url); toast('Stream URL copied ✓'); } catch { toast('Copy blocked'); }
|
||||
} else if (act.dataset.act === 'm3u') {
|
||||
download(`${title.replace(/[^\w\- ]+/g, '').trim().slice(0, 50) || meta.id}.m3u`, m3u([meta]));
|
||||
} else if (act.dataset.act === 'm3u-list') {
|
||||
act.disabled = true;
|
||||
const items = queue.slice(Math.max(0, queueIndex));
|
||||
const flags = await Promise.all(items.map((v) => (v.custom ? false : cached(v.id))));
|
||||
const ready = items.filter((_, i) => flags[i]);
|
||||
download('ytplayer-queue.m3u', m3u(ready));
|
||||
toast(`Playlist with ${ready.length} of ${items.length} songs${ready.length < items.length ? ' (the rest aren’t cached on the server yet)' : ''}`, { duration: 5000 });
|
||||
act.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
return { open, m3u, intent };
|
||||
})();
|
||||
124
frontend/shell-consistency.test.js
Normal file
124
frontend/shell-consistency.test.js
Normal file
@@ -0,0 +1,124 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync, existsSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
|
||||
const root = join(__dirname, '..');
|
||||
const html = readFileSync(join(__dirname, 'index.html'), 'utf8');
|
||||
const sw = readFileSync(join(__dirname, 'sw.js'), 'utf8');
|
||||
const shellMatch = sw.match(/const SHELL = \[([\s\S]*?)\n\];/);
|
||||
assert.ok(shellMatch, 'service worker SHELL list is present');
|
||||
const shell = [...shellMatch[1].matchAll(/['"](\/[^'"]+)['"]/g)].map((match) => match[1]);
|
||||
const listed = new Set(shell);
|
||||
|
||||
test('every local script and stylesheet in index.html is precached by the service worker', () => {
|
||||
const localAssets = [];
|
||||
for (const match of html.matchAll(/<(?:script\b[^>]*\bsrc|link\b[^>]*\bhref)="([^"]+)"/gi)) {
|
||||
const url = match[1];
|
||||
if (/^(?:[a-z]+:|\/|#)/i.test(url)) continue;
|
||||
if (!/\.(?:js|css)(?:[?#]|$)/i.test(url)) continue;
|
||||
localAssets.push(`/${url.split(/[?#]/, 1)[0]}`);
|
||||
}
|
||||
assert.ok(localAssets.length > 0, 'found local script/style assets');
|
||||
for (const asset of localAssets) assert.ok(listed.has(asset), `${asset} appears in SHELL`);
|
||||
});
|
||||
|
||||
test('every SHELL asset exists on disk and there are no duplicate entries', () => {
|
||||
assert.equal(new Set(shell).size, shell.length, 'SHELL has no duplicate entries');
|
||||
for (const asset of shell) assert.ok(existsSync(join(__dirname, asset.slice(1))), `${asset} exists`);
|
||||
});
|
||||
|
||||
test('assets.json describes the complete existing shell exactly once with eager core features', () => {
|
||||
const { groups } = JSON.parse(readFileSync(join(__dirname, 'assets.json'), 'utf8'));
|
||||
const described = new Set();
|
||||
for (const [name, group] of Object.entries(groups)) {
|
||||
assert.equal(group.contract, name === 'core' ? 2 : 1, `${name} contract (core boot packing changed)`);
|
||||
assert.equal(typeof group.eager, 'boolean');
|
||||
for (const asset of group.files) {
|
||||
assert.ok(!described.has(asset), `${asset} has one group`);
|
||||
described.add(asset);
|
||||
assert.ok(existsSync(join(__dirname, asset.slice(1))), `${asset} exists`);
|
||||
}
|
||||
}
|
||||
for (const asset of shell) if (asset !== '/') assert.ok(described.has(asset), `${asset} is described`);
|
||||
// piano-engine is dynamically imported and historically absent from SHELL.
|
||||
// Piano and JamesDSP load only through build-local dynamic module URLs.
|
||||
const dynamicOnly = new Set(['/piano-engine.mjs', ...groups['feature:jamesdsp'].files]);
|
||||
for (const asset of described) assert.ok(listed.has(asset) || dynamicOnly.has(asset), `${asset} shell or documented dynamic import`);
|
||||
assert.equal(groups.core.eager, true);
|
||||
for (const name of ['playlist', 'settings', 'service-mode', 'lyrics-editor']) {
|
||||
assert.equal(groups[`feature:${name}`].eager, true);
|
||||
for (const asset of groups[`feature:${name}`].embeddedIn) assert.ok(groups.core.files.includes(asset), `${name} remains in core`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the selected glass-stage layout includes its controls and panel dependencies', () => {
|
||||
const { groups } = JSON.parse(readFileSync(join(__dirname, 'assets.json'), 'utf8'));
|
||||
assert.deepEqual(groups['layout:glass-stage'].files, [
|
||||
'/theme-glass.css', '/theme-glass-panel.css', '/glass-panel-layout.js',
|
||||
]);
|
||||
assert.deepEqual(groups['layout:glass-stage'].dependencies,['feature:layout-controls']);
|
||||
assert.ok(groups['feature:layout-controls'].files.includes('/glass-controls.js'));
|
||||
});
|
||||
|
||||
test('the shared sync core is eager and loads before the update helper',()=>{
|
||||
const {groups}=JSON.parse(readFileSync(join(__dirname,'assets.json'),'utf8'));
|
||||
assert.ok(groups.core.files.includes('/asset-sync-core.js'));
|
||||
assert.ok(html.indexOf('src="asset-sync-core.js"')<html.indexOf('src="sw-update.js"'));
|
||||
assert.ok(listed.has('/asset-sync-core.js'));
|
||||
});
|
||||
|
||||
test('only core scripts and styles remain eager; native fallback and active layout bootstrap agree', () => {
|
||||
const {groups}=JSON.parse(readFileSync(join(__dirname,'assets.json'),'utf8'));
|
||||
const background=new Set(Object.values(groups).filter(g=>g.background).flatMap(g=>g.files));
|
||||
for(const match of html.matchAll(/<(?:script\b[^>]*\bsrc|link\b[^>]*\bhref)="([^"]+)"/gi)) {
|
||||
// A style preload discovers bytes without applying or blocking another layout.
|
||||
if(match[0].includes('rel="preload"'))continue;
|
||||
const path='/'+match[1].replace(/^\//,'').split(/[?#]/)[0];
|
||||
assert.ok(!background.has(path),`${path} is not eager`);
|
||||
}
|
||||
assert.ok(html.indexOf('id="ytp-assets"')<html.indexOf('src="lazy.js"'));
|
||||
assert.ok(html.indexOf('src="lazy.js"')<html.indexOf('</head>'));
|
||||
assert.ok(html.indexOf('src="lazy.js"')<html.indexOf('rel="stylesheet"'),'head bootstrap must not wait for common CSS before requesting the selected layout');
|
||||
assert.ok(html.indexOf('rel="preload"')<html.indexOf('src="lazy.js"'),'font preloads must start before the render-blocking bootstrap');
|
||||
assert.deepEqual(JSON.parse(html.match(/id="ytp-assets">([^<]+)<\/script>/)[1]).groups,groups);
|
||||
for(const name of ['playlist','settings','service-mode','lyrics-editor'])assert.ok(groups['feature:'+name].eager);
|
||||
});
|
||||
|
||||
test('layout-independent visibility and transport defaults stay in eager CSS',()=>{
|
||||
const {groups}=JSON.parse(readFileSync(join(__dirname,'assets.json'),'utf8'));
|
||||
assert.ok(groups.core.files.includes('/layout-base.css'));
|
||||
const shared=readFileSync(join(__dirname,'layout-base.css'),'utf8');
|
||||
for(const selector of ['.stg-overlay','.deck-chips','.np-btn.done .i-save','.sleep-status .sl-ic','.ab-group','.mc-tune'])assert.ok(shared.includes(selector));
|
||||
assert.ok(html.includes('href="layout-base.css"'));
|
||||
});
|
||||
|
||||
|
||||
test('classic first-visit style preload is discovered before the head loader without applying another layout',()=>{
|
||||
const preload=html.match(/<link\b[^>]*rel="preload"[^>]*href="theme-classic.css"[^>]*as="style"[^>]*>/);
|
||||
assert.ok(preload,'classic stylesheet has an early preload');
|
||||
assert.ok(html.indexOf(preload[0])<html.indexOf('src="lazy.js"'));
|
||||
assert.ok(!/<link\b[^>]*rel="stylesheet"[^>]*href="theme-classic.css"/.test(html),'the selected layout remains parser-applied by the loader');
|
||||
});
|
||||
|
||||
|
||||
test('the last core script owns boot without an extra request or unsafe stale packing',()=>{
|
||||
const {groups}=JSON.parse(readFileSync(join(__dirname,'assets.json'),'utf8'));
|
||||
assert.equal(groups.core.contract,2,'old core packing cannot fall back without the loader');
|
||||
assert.ok(groups.core.files.includes('/app.js'));
|
||||
assert.ok(groups.core.files.includes('/section-rail.js'));
|
||||
assert.equal(groups.core.files.includes('/app-bootstrap.js'),false);
|
||||
assert.equal(listed.has('/app-bootstrap.js'),false);
|
||||
const scripts=[...html.matchAll(/<script src="([^"]+)"/g)].map(m=>m[1]);
|
||||
assert.equal(scripts.at(-1),'section-rail.js');
|
||||
assert.ok(html.includes("'sha256-__APP_SCRIPT_HASH__'"));
|
||||
assert.ok(readFileSync(join(__dirname,'section-rail.js'),'utf8').includes('root.AppBootstrap={ready}'));
|
||||
});
|
||||
test('runtime exclusion rules never remove shell or declared dynamic assets', async () => {
|
||||
const { isRuntimeAsset } = await import('../server/asset-manifest.js');
|
||||
const definition = JSON.parse(readFileSync(join(__dirname, 'assets.json'), 'utf8'));
|
||||
for (const asset of new Set([...shell.filter(p => p !== '/'), ...Object.values(definition.groups).flatMap(g => g.files)])) {
|
||||
assert.ok(isRuntimeAsset(asset, definition), `${asset} remains a runtime asset`);
|
||||
}
|
||||
for (const asset of ['/app.test.js', '/nested/probe.test.mjs', '/README.md', '/fonts/font-OFL.txt', '/admin.html', '/perf/fixture.js']) assert.equal(isRuntimeAsset(asset, definition), false, asset);
|
||||
});
|
||||
305
frontend/shell-core.js
Normal file
305
frontend/shell-core.js
Normal file
@@ -0,0 +1,305 @@
|
||||
/* Eager UI definitions. Read app lexical state only when called after initialization. */
|
||||
const Recommendations = (() => {
|
||||
let cards = [], fetchedAt = 0, loading = false, error = false, actor = '', queuedRefresh = false;
|
||||
let listHost = null;
|
||||
const deviceFingerprint = () => window.getFingerprint ? window.getFingerprint() : 'unknown';
|
||||
const fingerprint = () => data.profile?.name ? 'profile:' + data.profile.name.toLowerCase() : 'device:' + deviceFingerprint();
|
||||
function load() {
|
||||
const fp = fingerprint();
|
||||
if (fp === actor) return;
|
||||
actor = fp; cards = []; fetchedAt = 0;
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('ytpRecommendations:' + fp) || 'null');
|
||||
if (saved && Array.isArray(saved.cards)) cards = saved.cards;
|
||||
} catch { /* no saved recommendations */ }
|
||||
}
|
||||
function paint(host) {
|
||||
if (!host) return;
|
||||
host.replaceChildren();
|
||||
const heading = document.createElement('div'); heading.className = 'rec-heading';
|
||||
const copy = document.createElement('div');
|
||||
const title = document.createElement('h2'); title.textContent = 'Recommended for you';
|
||||
const sub = document.createElement('p'); sub.textContent = error && cards.length ? 'Saved picks · reconnect to refresh' : 'Familiar favorites and something new, inspired by your listening.';
|
||||
copy.append(title, sub);
|
||||
const refresh = document.createElement('button'); refresh.type = 'button'; refresh.className = 'rec-refresh';
|
||||
refresh.textContent = loading ? 'Updating…' : 'Refresh'; refresh.disabled = loading;
|
||||
refresh.onclick = () => update(true);
|
||||
heading.append(copy, refresh); host.appendChild(heading);
|
||||
if (!cards.length) {
|
||||
const empty = document.createElement('p'); empty.className = 'rec-empty'; empty.setAttribute('role', 'status');
|
||||
empty.textContent = loading ? 'Finding your next listen…' : error ? 'Recommendations are unavailable. You can still search or play your playlists.' : 'Search or play a few videos to start discovering recommendations here.';
|
||||
host.appendChild(empty); return;
|
||||
}
|
||||
const grid = document.createElement('div'); grid.className = 'rec-grid';
|
||||
cards.forEach((v, i) => {
|
||||
const item = document.createElement('article'); item.className = 'rec-item';
|
||||
const play = document.createElement('button'); play.type = 'button'; play.className = 'rec-play';
|
||||
play.setAttribute('aria-label', 'Play ' + v.title);
|
||||
const cover = document.createElement('span'); cover.className = 'rec-cover';
|
||||
const img = document.createElement('img'); img.src = v.thumbnail || ''; img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; cover.appendChild(img);
|
||||
const title = document.createElement('span'); title.className = 'rec-title'; title.textContent = v.title;
|
||||
const channel = document.createElement('span'); channel.className = 'rec-channel'; channel.textContent = v.channel || '';
|
||||
play.append(cover, title, channel); play.onclick = () => { setHomeOverPlayer(false); playFromList(cards.slice(), i, 'recommendations'); };
|
||||
const reason = document.createElement('div'); reason.className = 'rec-reason'; reason.textContent = v.reason || 'Recommended for you';
|
||||
const actions = document.createElement('div'); actions.className = 'rec-actions';
|
||||
const queue = document.createElement('button'); queue.type = 'button'; queue.textContent = '+ Queue'; queue.setAttribute('aria-label', 'Queue ' + v.title); queue.onclick = () => addToQueue(v);
|
||||
const more = document.createElement('button'); more.type = 'button'; more.textContent = '•••'; more.setAttribute('aria-label', 'More actions for ' + v.title); more.onclick = () => openCardMenu(v);
|
||||
actions.append(queue, more); item.append(play, reason, actions); grid.appendChild(item);
|
||||
});
|
||||
host.appendChild(grid);
|
||||
}
|
||||
function repaint() {
|
||||
if (!WEB) return;
|
||||
const home = $('homeRecommendations');
|
||||
if (home) { home.classList.remove('hidden'); paint(home); }
|
||||
els.playerPane?.classList.add('has-recommendations');
|
||||
if (listHost?.isConnected) paint(listHost);
|
||||
requestAnimationFrame(updateBottomChrome);
|
||||
}
|
||||
async function update(force = false) {
|
||||
load();
|
||||
if (!WEB) return;
|
||||
if (loading) { if (force) queuedRefresh = true; return; }
|
||||
if (!force && Date.now() - fetchedAt < 300000) return;
|
||||
const fp = actor; loading = true; error = false; repaint();
|
||||
try {
|
||||
const name = data.profile?.name || '';
|
||||
const result = await webFetch('/api/recommendations?fp=' + encodeURIComponent(deviceFingerprint()) + (name ? '&profile=' + encodeURIComponent(name) : ''), { headers: name ? profileHeaders(name) : {} });
|
||||
if (fp !== fingerprint()) return;
|
||||
if (!result.ok || !Array.isArray(result.results)) throw new Error('invalid recommendations');
|
||||
cards = result.results;
|
||||
try { localStorage.setItem('ytpRecommendations:' + fp, JSON.stringify({ cards })); } catch { /* full */ }
|
||||
} catch { error = true; }
|
||||
finally {
|
||||
loading = false; fetchedAt = fp === fingerprint() ? Date.now() : 0; repaint();
|
||||
if (queuedRefresh || fp !== fingerprint()) { queuedRefresh = false; update(true); }
|
||||
}
|
||||
}
|
||||
function home() { if (WEB) { load(); repaint(); update(); } }
|
||||
function inList() {
|
||||
if (!WEB) return;
|
||||
listHost = document.createElement('section'); listHost.className = 'recommendations'; listHost.setAttribute('aria-label', 'Recommended videos');
|
||||
els.cards.appendChild(listHost); load(); paint(listHost); update();
|
||||
}
|
||||
return { home, inList, update };
|
||||
})();
|
||||
|
||||
function renderSidebar() {
|
||||
els.playlistList.innerHTML = '';
|
||||
for (const pl of data.playlists) {
|
||||
const stats = playlistOfflineStats(pl);
|
||||
let offlineBadge = '';
|
||||
if (stats.total > 0) {
|
||||
if (stats.cached === stats.total) {
|
||||
offlineBadge = `<span class="pl-offline-badge all" title="Available offline">${MediaIcons.svg('offline')}</span>`;
|
||||
} else if (stats.cached > 0) {
|
||||
offlineBadge = `<span class="pl-offline-badge partial" title="${stats.cached} of ${stats.total} videos available offline">${stats.cached}/${stats.total}</span>`;
|
||||
}
|
||||
}
|
||||
const item = document.createElement('div');
|
||||
item.className = 'playlist-item' + (view.type === 'playlist' && view.id === pl.id ? ' active' : '');
|
||||
item.innerHTML = `<span class="pl-name"></span>${offlineBadge}<span class="pl-count">${pl.videos.length}</span>`;
|
||||
item.querySelector('.pl-name').textContent = pl.name;
|
||||
item.addEventListener('click', () => { view = { type: 'playlist', id: pl.id }; render(); });
|
||||
els.playlistList.appendChild(item);
|
||||
}
|
||||
document.querySelectorAll('.nav-item').forEach((b) => {
|
||||
b.classList.toggle('active', b.dataset.view === view.type);
|
||||
});
|
||||
// Keep bottom-nav in sync with sidebar nav active state
|
||||
document.querySelectorAll('.bottom-nav-btn').forEach((b) => {
|
||||
b.classList.toggle('active', b.dataset.view === view.type);
|
||||
});
|
||||
updateQueueBadge();
|
||||
updateDownloadBadge();
|
||||
renderHomePlaylists();
|
||||
}
|
||||
|
||||
function renderHomePlaylists() {
|
||||
if (window.BentoHub) BentoHub.playlists = () => data.playlists || [];
|
||||
const wrap = document.getElementById('homePlaylists');
|
||||
const hero = document.querySelector('#playerPlaceholder .hero');
|
||||
const grid = document.getElementById('homePlGrid');
|
||||
if (!wrap || !hero || !grid) return;
|
||||
Recommendations.home();
|
||||
|
||||
const lists = data.playlists || [];
|
||||
wrap.classList.toggle('hidden', !lists.length);
|
||||
hero.classList.toggle('hidden', !!lists.length);
|
||||
// The hero is centered art; the grid wants the full box, so the placeholder
|
||||
// switches from centering to stretching (and tightens its padding), and the
|
||||
// stage drops its 16:9 lock so the grid gets the pane's real height.
|
||||
if (els.placeholder) els.placeholder.classList.toggle('showing-playlists', !!lists.length);
|
||||
if (els.playerPane) els.playerPane.classList.toggle('home-playlists', !!lists.length);
|
||||
if (!lists.length) { grid.innerHTML = ''; return; }
|
||||
|
||||
grid.innerHTML = '';
|
||||
for (const pl of lists) {
|
||||
const count = pl.videos.length;
|
||||
const thumb = (pl.videos.find((v) => v && v.thumbnail) || {}).thumbnail || '';
|
||||
const stats = playlistOfflineStats(pl);
|
||||
let offlineHtml = '';
|
||||
if (stats.total > 0) {
|
||||
if (stats.cached === stats.total) {
|
||||
offlineHtml = `<span class="home-pl-offline all" title="Available offline">${MediaIcons.svg('offline')}</span>`;
|
||||
} else if (stats.cached > 0) {
|
||||
offlineHtml = `<span class="home-pl-offline partial" title="${stats.cached} of ${stats.total} videos available offline">${stats.cached}/${stats.total}</span>`;
|
||||
}
|
||||
}
|
||||
const card = document.createElement('button');
|
||||
card.className = 'home-pl-card';
|
||||
card.type = 'button';
|
||||
card.innerHTML = `
|
||||
<span class="home-pl-thumb">
|
||||
${thumb ? `<img src="${escapeHtml(thumb)}" alt="" loading="lazy" decoding="async" />`
|
||||
: '<span class="home-pl-thumb-fallback">🎵</span>'}
|
||||
${offlineHtml}
|
||||
<span class="home-pl-play" title="Play all">${TransportIcons.svg('play')}</span>
|
||||
</span>
|
||||
<span class="home-pl-name"></span>
|
||||
<span class="home-pl-count">${count} video${count === 1 ? '' : 's'}</span>`;
|
||||
card.querySelector('.home-pl-name').textContent = pl.name;
|
||||
card.title = pl.name;
|
||||
card.addEventListener('click', () => { view = { type: 'playlist', id: pl.id }; render(); });
|
||||
card.querySelector('.home-pl-play').addEventListener('click', (e) => {
|
||||
e.stopPropagation(); // the play badge plays; the rest of the card opens the list
|
||||
if (count) playFromList(pl.videos, 0, 'playlist:' + pl.id);
|
||||
else toast('That playlist is empty');
|
||||
});
|
||||
grid.appendChild(card);
|
||||
}
|
||||
// Measure after layout — scrollHeight is meaningless until the cards lay out.
|
||||
requestAnimationFrame(updateHomeGridScrollState);
|
||||
}
|
||||
|
||||
function updateHomeGridScrollState() {
|
||||
const grid = document.getElementById('homePlGrid');
|
||||
if (!grid) return;
|
||||
const overflows = grid.scrollHeight > grid.clientHeight + 1;
|
||||
grid.classList.toggle('is-scrollable', overflows);
|
||||
grid.classList.toggle('at-end', !overflows ||
|
||||
grid.scrollTop + grid.clientHeight >= grid.scrollHeight - 2);
|
||||
}
|
||||
|
||||
function updateBottomChrome() {
|
||||
let occupied = 0;
|
||||
for (const id of ['bottomNav', 'miniBar']) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el || el.classList.contains('hidden')) continue;
|
||||
const box = el.getBoundingClientRect();
|
||||
if (!box.height || getComputedStyle(el).position !== 'fixed') continue;
|
||||
if (box.top > window.innerHeight * 0.5) occupied = Math.max(occupied, window.innerHeight - box.top);
|
||||
}
|
||||
document.documentElement.style.setProperty('--bottom-chrome', Math.round(occupied) + 'px');
|
||||
}
|
||||
|
||||
function setHomeOverPlayer(on) {
|
||||
const pane = els.playerPane;
|
||||
if (!pane) return;
|
||||
on = !!on && !!(current && current.meta);
|
||||
// Service mode hides the bottom bar and mini bar (and, in its Video view,
|
||||
// lives inside this pane): leave it first or the home screen is a dead end.
|
||||
if (on && isServiceModeOpen()) exitServiceMode();
|
||||
if (pane.classList.contains('home-over') === on) return;
|
||||
pane.classList.toggle('home-over', on);
|
||||
pane.classList.toggle('empty', on);
|
||||
if (els.placeholder) els.placeholder.classList.toggle('hidden', !on);
|
||||
if (on) {
|
||||
renderHomePlaylists();
|
||||
showMiniBar();
|
||||
const body = document.querySelector('.body');
|
||||
if (body) body.scrollTo({ top: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
function showMiniBar() {
|
||||
if (!current || !current.meta) return;
|
||||
$('miniTitle').textContent = current.meta.title;
|
||||
const miniCh = $('miniChannel');
|
||||
miniCh.textContent = current.meta.channel || '';
|
||||
miniCh.classList.toggle('link', !!channelKeyOf(current.meta));
|
||||
$('miniBar').classList.remove('hidden');
|
||||
updateMiniBar();
|
||||
requestAnimationFrame(updateBottomChrome);
|
||||
}
|
||||
|
||||
function updateMiniBar() {
|
||||
if (!current || !current.meta) return;
|
||||
const cur = Player.master.currentTime || 0;
|
||||
const dur = Player.master.duration || 0;
|
||||
$('miniTime').textContent = fmtTime(cur);
|
||||
if (dur > 0) {
|
||||
$('miniProgressFill').style.width = Math.min(100, (cur / dur) * 100) + '%';
|
||||
}
|
||||
}
|
||||
|
||||
function hideMiniBar() {
|
||||
$('miniBar').classList.add('hidden');
|
||||
requestAnimationFrame(updateBottomChrome);
|
||||
}
|
||||
|
||||
function setTile(btn, label) {
|
||||
const tl = btn.querySelector('.tl');
|
||||
if (tl) tl.textContent = label; else btn.textContent = label;
|
||||
}
|
||||
|
||||
function updateNowPlayingActions() {
|
||||
if (!current || !current.meta) return;
|
||||
const id = current.meta.id;
|
||||
renderAvailability(id);
|
||||
// Custom (edited) videos are already a saved-offline cut of a source video;
|
||||
// re-saving or re-editing them makes no sense, so hide those actions.
|
||||
const isCustom = !!current.meta.custom;
|
||||
if (els.editBtn) els.editBtn.hidden = isCustom;
|
||||
if (els.saveBtn) els.saveBtn.hidden = isCustom;
|
||||
// The server cache exists only behind the web PWA backend.
|
||||
if (els.brokenBtn) {
|
||||
els.brokenBtn.hidden = isCustom || !WEB;
|
||||
els.brokenBtn.disabled = reportingBroken.has(id);
|
||||
els.brokenBtn.textContent = reportingBroken.has(id) ? '⏳ Reporting…' : '⚠ Broken';
|
||||
}
|
||||
{ const ch = $('npChannel'); if (ch) { const ok = isUploadId(id) || cachedIds.has(id); ch.classList.toggle('verified', ok); ch.title = ok ? 'Available on this device or the server' : ''; } }
|
||||
const btn = els.saveBtn;
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('saving', downloading.has(id));
|
||||
if (removing.has(id)) {
|
||||
setTile(btn, 'Removing…');
|
||||
btn.classList.remove('done');
|
||||
btn.disabled = true;
|
||||
} else if (downloading.has(id)) {
|
||||
setTile(btn, 'Saving…');
|
||||
btn.classList.remove('done');
|
||||
btn.disabled = true;
|
||||
} else if (cachedIds.has(id)) {
|
||||
setTile(btn, 'Saved');
|
||||
btn.classList.add('done');
|
||||
btn.disabled = false;
|
||||
btn.title = 'Saved for offline — click to remove from cache';
|
||||
} else {
|
||||
setTile(btn, 'Save');
|
||||
btn.classList.remove('done');
|
||||
btn.disabled = false;
|
||||
btn.title = 'Save this video for offline playback';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSmartSidebar() {
|
||||
const list = $('smartPlaylistList');
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
SMART_PLAYLISTS.forEach((sp) => {
|
||||
const item = document.createElement('div');
|
||||
const isActive = view.type === 'smart' && view.smartType === sp.id;
|
||||
item.className = 'playlist-item smart-item' + (isActive ? ' active' : '');
|
||||
item.innerHTML = `<span class="pl-name">${sp.label}</span>`;
|
||||
item.addEventListener('click', () => { view = { type: 'smart', smartType: sp.id }; render(); });
|
||||
list.appendChild(item);
|
||||
});
|
||||
if (WEB && data.settings.p2pReceive !== false) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'playlist-item smart-item' + (view.type === 'p2p' ? ' active' : '');
|
||||
item.innerHTML = '<span class="pl-name">📡 On other devices</span>';
|
||||
item.addEventListener('click', () => { view = { type: 'p2p' }; render(); });
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
124
frontend/stats-view.js
Normal file
124
frontend/stats-view.js
Normal file
@@ -0,0 +1,124 @@
|
||||
/* Lazy classic chunk; its executing singleton stays pinned until reload. */
|
||||
(window.YT ||= {}).StatsView = { render: async function renderStats() {
|
||||
try { await Lazy.load('feature:stats'); } catch (error) { toast(error.message); return; }
|
||||
if (view.type !== 'stats') return;
|
||||
StatsTrack.flush();
|
||||
els.listTitle.textContent = 'Stats';
|
||||
resetListHeaderExtras();
|
||||
els.listActions.innerHTML = '';
|
||||
els.status.classList.add('hidden');
|
||||
const c = els.cards;
|
||||
const S = StatsCore.summarize(data.stats);
|
||||
if (!S.totalPlays && S.totalSec < 60) {
|
||||
c.innerHTML = `
|
||||
<div class="empty-state stats-empty">
|
||||
<div class="empty-icon">📊</div>
|
||||
<h3 class="empty-title">No listening stats yet</h3>
|
||||
<p class="empty-desc">Stats start counting now — a song counts as played after 30 seconds. They sync with your online profile.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
const hrs = (sec) => StatsCore.fmtHours(sec);
|
||||
const delta = S.thisMonthSec - S.lastMonthSec;
|
||||
const deltaTxt = S.lastMonthSec || S.thisMonthSec
|
||||
? `${delta >= 0 ? '▲ +' : '▼ −'}${(Math.abs(delta) / 3600).toFixed(1)}h vs last month`
|
||||
: '';
|
||||
const maxArtist = Math.max(1, ...S.topArtists.map((a) => a.plays));
|
||||
const spark = (vals) => {
|
||||
const w = 96, h = 26, max = Math.max(1, ...vals);
|
||||
const pts = vals.map((v, i) => `${(i / (vals.length - 1)) * w},${h - 3 - (v / max) * (h - 6)}`).join(' ');
|
||||
return `<svg class="st-spark" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" aria-hidden="true"><polyline points="${pts}" /></svg>`;
|
||||
};
|
||||
const heatMax = Math.max(1, ...S.heat.flat().map((x) => x.minutes));
|
||||
const level = (m) => (m <= 0 ? 0 : Math.min(4, 1 + Math.floor((m / heatMax) * 3.999)));
|
||||
const fmtDay = (k) => new Date(`${k}T12:00:00`).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
// Month label over the first week column of each month.
|
||||
const months = S.heat.map((col, w) => {
|
||||
const d = new Date(`${col[0].date}T12:00:00`);
|
||||
const prev = w ? new Date(`${S.heat[w - 1][0].date}T12:00:00`) : null;
|
||||
return !prev || prev.getMonth() !== d.getMonth() ? d.toLocaleDateString(undefined, { month: 'short' }) : '';
|
||||
});
|
||||
const playlistsWith = data.playlists.length;
|
||||
|
||||
c.innerHTML = `
|
||||
<div class="stats">
|
||||
<div class="st-tiles">
|
||||
<div class="st-tile">
|
||||
<div class="st-label">⏱ Listening this month</div>
|
||||
<div class="st-big">${hrs(S.thisMonthSec)}</div>
|
||||
<div class="st-sub">${escapeHtml(deltaTxt)}${deltaTxt ? ' · ' : ''}${hrs(S.totalSec)} all time</div>
|
||||
</div>
|
||||
<div class="st-tile">
|
||||
<div class="st-label">${TransportIcons.svg('play')} Track plays</div>
|
||||
<div class="st-big">${S.totalPlays}</div>
|
||||
<div class="st-sub">${S.songsPlayed} song${S.songsPlayed === 1 ? '' : 's'} · ${playlistsWith} playlist${playlistsWith === 1 ? '' : 's'}</div>
|
||||
</div>
|
||||
<div class="st-tile">
|
||||
<div class="st-label">🔥 Practice streak</div>
|
||||
<div class="st-big">${S.streak.current}-day</div>
|
||||
<div class="st-sub">${S.streak.current ? 'Streak active' : 'Listen 5 min today to start one'} · best ${S.streak.best} day${S.streak.best === 1 ? '' : 's'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="st-grid">
|
||||
<section class="st-panel">
|
||||
<div class="st-panel-head"><span>🎤 Top artists</span><small>plays this month</small></div>
|
||||
${S.topArtists.length ? S.topArtists.map((a) => `
|
||||
<div class="st-bar-row" data-tip="${escapeHtml(a.name)} — ${a.plays} play${a.plays === 1 ? '' : 's'} this month">
|
||||
<span class="st-bar-name">${escapeHtml(a.name)}</span>
|
||||
<span class="st-bar-track"><span class="st-bar" style="width:${Math.max(3, (a.plays / maxArtist) * 100)}%"></span></span>
|
||||
<span class="st-bar-val">${a.plays}</span>
|
||||
</div>`).join('') : '<div class="st-none">No plays this month yet.</div>'}
|
||||
</section>
|
||||
<section class="st-panel">
|
||||
<div class="st-panel-head"><span>🎵 Top songs</span><small>last 30 days</small></div>
|
||||
${S.topSongs.length ? S.topSongs.map((t, i) => `
|
||||
<div class="st-song" data-tip="${escapeHtml(t.title)} — ${t.plays} play${t.plays === 1 ? '' : 's'} in 30 days" data-id="${escapeHtml(t.id)}">
|
||||
<span class="st-rank">${String(i + 1).padStart(2, '0')}</span>
|
||||
<span class="st-song-text"><b>${escapeHtml(t.title)}</b><small>${escapeHtml(t.channel)}</small></span>
|
||||
${spark(t.spark)}
|
||||
<span class="st-bar-val">${t.plays}</span>
|
||||
</div>`).join('') : '<div class="st-none">No plays in the last 30 days.</div>'}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="st-panel">
|
||||
<div class="st-panel-head"><span>🗓 Listening habits</span><small>last 16 weeks${S.peak ? ` · peak day: ${S.peak.day}s (avg ${S.peak.avgHours.toFixed(1)} h)` : ''}</small></div>
|
||||
<div class="st-heat-wrap">
|
||||
<div class="st-heat-days"><span></span><span>Mon</span><span></span><span>Wed</span><span></span><span>Fri</span><span></span><span>Sun</span></div>
|
||||
<div class="st-heat-main">
|
||||
<div class="st-heat-months">${months.map((m) => `<span>${m}</span>`).join('')}</div>
|
||||
<div class="st-heat">${S.heat.map((col) => col.map((x) => x.future
|
||||
? '<span class="st-cell future"></span>'
|
||||
: `<span class="st-cell l${level(x.minutes)}" data-tip="${fmtDay(x.date)} · ${x.minutes} min"></span>`).join('')).join('')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="st-legend"><span>Less</span>${[0, 1, 2, 3, 4].map((l) => `<span class="st-cell l${l}"></span>`).join('')}<span>More</span></div>
|
||||
<details class="st-table"><summary>Show as table</summary>
|
||||
<table><thead><tr><th>Day</th><th>Minutes</th></tr></thead><tbody>
|
||||
${S.heat.flat().filter((x) => !x.future && x.minutes > 0).reverse().map((x) => `<tr><td>${fmtDay(x.date)}</td><td>${x.minutes}</td></tr>`).join('') || '<tr><td colspan="2">No listening in this window.</td></tr>'}
|
||||
</tbody></table>
|
||||
</details>
|
||||
</section>
|
||||
<div id="statsTip" class="st-tip hidden"></div>
|
||||
</div>`;
|
||||
|
||||
// One tooltip for every mark (bars, songs, heat cells).
|
||||
const root = c.querySelector('.stats');
|
||||
const tip = $('statsTip');
|
||||
root.addEventListener('pointermove', (e) => {
|
||||
const el = e.target.closest('[data-tip]');
|
||||
if (!el) { tip.classList.add('hidden'); return; }
|
||||
tip.textContent = el.dataset.tip;
|
||||
tip.classList.remove('hidden');
|
||||
const r = root.getBoundingClientRect();
|
||||
const half = tip.offsetWidth / 2 + 4; // keep the whole tip inside the panel
|
||||
tip.style.left = `${Math.min(r.width - half, Math.max(half, e.clientX - r.left))}px`;
|
||||
tip.style.top = `${e.clientY - r.top - 14}px`;
|
||||
});
|
||||
root.addEventListener('pointerleave', () => tip.classList.add('hidden'));
|
||||
root.querySelectorAll('.st-song').forEach((row) => row.addEventListener('click', () => {
|
||||
const v = data.history.find((x) => x.id === row.dataset.id) || { id: row.dataset.id, title: row.querySelector('b').textContent };
|
||||
Player.loadVideo(v);
|
||||
}));
|
||||
} };
|
||||
@@ -273,7 +273,7 @@ body::after {
|
||||
background: linear-gradient(180deg, rgba(16,16,19,0.7), transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.search-form { flex: 1; display: flex; gap: 10px; max-width: 760px; position: relative; }
|
||||
.search-form { flex: 1; display: flex; align-items: center; gap: 10px; max-width: 760px; position: relative; }
|
||||
|
||||
/* Recent-searches dropdown — device-local, kept in localStorage (RecentSearches in app.js) */
|
||||
.search-recent {
|
||||
@@ -752,7 +752,7 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
||||
}
|
||||
.np-btn:hover { color: var(--text); border-color: var(--accent); background: var(--bg-2); transform: translateY(-1px); }
|
||||
.np-btn:disabled { opacity: 0.6; cursor: default; transform: none; }
|
||||
.np-btn.done { color: #7ee0a8; border-color: rgba(126,224,168,0.35); }
|
||||
.np-btn.done { color: var(--accent-bright); border-color: var(--accent); }
|
||||
.np-title {
|
||||
font-family: var(--display);
|
||||
font-weight: 700;
|
||||
@@ -1019,7 +1019,7 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
||||
box-shadow: 0 0 26px -12px var(--accent-glow);
|
||||
}
|
||||
.card.playing::after {
|
||||
content: "▶ NOW PLAYING";
|
||||
content: "NOW PLAYING";
|
||||
position: absolute; top: 9px; right: 44px;
|
||||
font-family: var(--mono); font-size: 8px; font-weight: 700;
|
||||
letter-spacing: 0.1em; color: var(--accent);
|
||||
@@ -1097,19 +1097,20 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
||||
display: none;
|
||||
align-items: center; justify-content: center;
|
||||
width: 18px; height: 18px;
|
||||
color: #7ee0a8;
|
||||
color: var(--accent-bright);
|
||||
font-size: 12px; font-weight: 700;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.85);
|
||||
}
|
||||
.card.cached .saved-badge { display: flex; }
|
||||
.card.downloading .saved-badge {
|
||||
display: flex;
|
||||
color: #ffc850;
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
color: var(--accent-bright);
|
||||
}
|
||||
.card.downloading .saved-badge::before { content: "⏳"; }
|
||||
.card.downloading .saved-badge { font-size: 0; }
|
||||
.card.downloading .saved-badge::before { font-size: 12px; }
|
||||
/* State changes reveal SVGs without rewriting badges or their handlers. */
|
||||
.saved-badge .badge-progress { display:none; }
|
||||
.card.downloading .saved-badge .badge-offline { display:none; }
|
||||
.card.downloading .saved-badge .badge-progress { display:block; }
|
||||
.saved-badge .media-icon { width:16px; height:16px; filter:drop-shadow(0 1px 2px #000); }
|
||||
|
||||
/* Download progress bar on card thumb */
|
||||
.dl-progress {
|
||||
@@ -1158,6 +1159,15 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
||||
.set-row > span { display: flex; flex-direction: column; gap: 3px; color: var(--text); font-size: 13.5px; font-weight: 500; }
|
||||
.set-row small { color: var(--text-dim); font-size: 11.5px; font-weight: 400; line-height: 1.4; max-width: 280px; }
|
||||
.set-row input[type="range"] { width: 150px; }
|
||||
.dsp-settings { margin-top: 8px; padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--bg-2); }
|
||||
.dsp-settings .set-group-title { padding: 0 0 8px; }
|
||||
.dsp-param-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 14px; }
|
||||
.dsp-param-row input[type="range"] { width: min(46vw, 190px); }
|
||||
.dsp-actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 0; }
|
||||
.dsp-actions .btn { color: var(--text); }
|
||||
.dsp-subsection { margin: 14px 0; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg-1); }
|
||||
.dsp-subsection h4 { margin: 0 0 8px; color: var(--text); }
|
||||
.dsp-risk { padding: 9px 11px; border-radius: 8px; color: var(--text); background: color-mix(in srgb, var(--accent) 13%, var(--bg-2)); font-size: .9em; }
|
||||
.set-row input[type="checkbox"] { width: 17px; height: 17px; accent-color: var(--accent); cursor: pointer; }
|
||||
.about-version { color: var(--text-dim); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; user-select: text; }
|
||||
.set-select {
|
||||
@@ -3345,84 +3355,6 @@ html[data-layout="contemplative"] .brand-mark {
|
||||
box-shadow: 0 6px 18px -4px rgba(45, 212, 191, 0.4), inset 0 1px 0 rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
/* --- Bento Hub -------------------------------------------------------------
|
||||
* GitHub-dark-ish with honey-gold accent. Tighter borders give a
|
||||
* "dashboard card" feel throughout.
|
||||
* ------------------------------------------------------------------------- */
|
||||
html[data-layout="bento-hub"] {
|
||||
--bg: #0d1117;
|
||||
--bg-1: #13181f;
|
||||
--bg-2: #1c2333;
|
||||
--bg-3: #252d3d;
|
||||
--line: #30363d;
|
||||
--line-soft: #21262d;
|
||||
--text: #e6edf3;
|
||||
--text-2: #8b949e;
|
||||
--text-dim: #484f58;
|
||||
--accent: #eab308;
|
||||
--accent-bright: #fbbf24;
|
||||
--accent-deep: #ca8a04;
|
||||
--accent-glow: rgba(234, 179, 8, 0.4);
|
||||
--radius: 10px;
|
||||
--radius-sm: 6px;
|
||||
}
|
||||
html[data-layout="bento-hub"] body::before {
|
||||
background:
|
||||
radial-gradient(900px 600px at 18% -10%, rgba(234, 179, 8, 0.06), transparent 60%),
|
||||
radial-gradient(700px 500px at 100% 110%, rgba(234, 179, 8, 0.03), transparent 55%);
|
||||
}
|
||||
html[data-layout="bento-hub"] .brand-mark {
|
||||
background: linear-gradient(145deg, #fbbf24, #ca8a04);
|
||||
box-shadow: 0 6px 18px -4px rgba(234, 179, 8, 0.45), inset 0 1px 0 rgba(255,255,255,0.25);
|
||||
}
|
||||
html[data-layout="bento-hub"] .sidebar {
|
||||
background: #13181f;
|
||||
border-right: 1px solid #30363d;
|
||||
}
|
||||
html[data-layout="bento-hub"] .brand {
|
||||
border-bottom: 1px solid #21262d;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
html[data-layout="bento-hub"] .nav-item {
|
||||
border-radius: 6px;
|
||||
}
|
||||
html[data-layout="bento-hub"] .nav-item.active {
|
||||
background: rgba(234, 179, 8, 0.08);
|
||||
color: #fbbf24;
|
||||
}
|
||||
html[data-layout="bento-hub"] .nav-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 60%;
|
||||
background: #eab308;
|
||||
border-radius: 0 3px 3px 0;
|
||||
}
|
||||
html[data-layout="bento-hub"] .playlist-item {
|
||||
background: #0d1117;
|
||||
border: 1px solid #21262d;
|
||||
border-radius: 8px;
|
||||
margin: 3px 0;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
html[data-layout="bento-hub"] .playlist-item.active {
|
||||
border-color: #eab308;
|
||||
background: rgba(234, 179, 8, 0.05);
|
||||
}
|
||||
html[data-layout="bento-hub"] .topbar {
|
||||
background: #13181f;
|
||||
border-bottom: 2px solid #21262d;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
html[data-layout="bento-hub"] .mini-bar-inner {
|
||||
background: linear-gradient(180deg, #1c2333, #13181f);
|
||||
border-top: 1px solid #30363d;
|
||||
}
|
||||
|
||||
/* Grid-column overrides only apply on screens wide enough for an inline sidebar
|
||||
(≥861px). Below that the sidebar is position:fixed (out of grid flow) so
|
||||
a two-column grid would leave .main stranded in the narrow first column. */
|
||||
@@ -3983,6 +3915,35 @@ body.presenter-open { overflow: hidden; }
|
||||
.player-stage { touch-action: pan-y; } /* page still scrolls; we get double-tap + pinch */
|
||||
:fullscreen .player-stage, .player-stage:fullscreen,
|
||||
body.landscape-fs .player-stage { touch-action: none; } /* fullscreen (real or the iOS landscape one): swipes too */
|
||||
/* Native fullscreen keeps the ordinary stage's aspect ratio and pane width unless
|
||||
all sizing constraints are explicitly reset. This also covers every layout's
|
||||
player because fullscreen is a cross-layout surface. */
|
||||
:is(.player-stage:fullscreen, .player-stage.orientation-fullscreen.orientation-overlay) {
|
||||
position: fixed !important;
|
||||
inset: 0 !important;
|
||||
width: 100dvw !important;
|
||||
height: 100dvh !important;
|
||||
min-width: 0 !important;
|
||||
min-height: 0 !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
aspect-ratio: auto !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
overflow: hidden !important;
|
||||
background: #000 !important;
|
||||
}
|
||||
.player-stage.orientation-fullscreen > video {
|
||||
flex: none !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
object-fit: contain;
|
||||
}
|
||||
#video { transform-origin: center center; transition: transform 0.05s linear; }
|
||||
.gesture-hud {
|
||||
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 6;
|
||||
@@ -4306,6 +4267,30 @@ body.landscape-fs .player-stage { touch-action: none; } /* fullscreen (real or t
|
||||
.search-form { min-width: 0; }
|
||||
.search-form #searchInput { flex: 1 1 auto; min-width: 0; }
|
||||
|
||||
/* Eager toolbar geometry: these buttons exist before Car mode is loaded. */
|
||||
.topbar :is(#voiceSearchBtn, #carModeBtn, #updateIconBtn) {
|
||||
flex: none;
|
||||
width: 44px; height: 44px; min-width: 44px; min-height: 44px;
|
||||
padding: 0;
|
||||
align-items: center; justify-content: center;
|
||||
display: inline-flex;
|
||||
}
|
||||
.topbar #updateIconBtn.hidden { display: none; }
|
||||
.topbar :is(#voiceSearchBtn, #carModeBtn) svg { width: 22px; height: 22px; flex: none; }
|
||||
.topbar :is(#voiceSearchBtn, #carModeBtn):hover { transform: none; }
|
||||
@media (max-width: 430px) {
|
||||
html[data-layout] .topbar, html[data-layout] #searchForm { gap: 4px; }
|
||||
html[data-layout] #searchForm .search-btn { flex: none; padding-inline: 8px; }
|
||||
html[data-layout="f7-swipe"] #searchInput { padding-right: 6px; }
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
html[data-layout="bento-hub"] .topbar {
|
||||
padding-top: calc(12px + env(safe-area-inset-top, 0px));
|
||||
padding-left: max(12px, env(safe-area-inset-left, 0px));
|
||||
padding-right: max(12px, env(safe-area-inset-right, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
/* iPhone/iPad: no double-tap page zoom (scrolling unaffected) */
|
||||
html.ios-nozoom, html.ios-nozoom body { touch-action: manipulation; }
|
||||
|
||||
@@ -4356,3 +4341,27 @@ html.ios-nozoom, html.ios-nozoom body { touch-action: manipulation; }
|
||||
|
||||
.lyric-primary { white-space: pre-line; }
|
||||
.lyric-secondary { display: block; white-space: pre-line; font-size: .62em; font-weight: 450; line-height: 1.2; opacity: .68; margin-top: .15em; }
|
||||
|
||||
/* Media icons share definitions and follow the active layout's stroke family. */
|
||||
.media-icon { display:inline-block; width:20px; height:20px; flex:none; vertical-align:middle; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; pointer-events:none; }
|
||||
.media-nav-icon { width:20px; height:20px; }
|
||||
.empty-icon .media-icon { width:48px; height:48px; }
|
||||
:is(.pl-offline-badge,.home-pl-offline) .media-icon { width:14px; height:14px; }
|
||||
html[data-layout="classic"] .media-icon { stroke-width:1.8; }
|
||||
html[data-layout="f7-swipe"] .media-icon { stroke-width:2; }
|
||||
html[data-layout="glass-stage"] .media-icon { stroke-width:1.9; }
|
||||
html[data-layout="bento-hub"] .media-icon { stroke-width:1.8; }
|
||||
.np-btn .i-progress { display:none; }
|
||||
.np-btn.saving :is(.i-save,.i-done) { display:none; }
|
||||
.np-btn.saving .i-progress { display:inline; }
|
||||
:is([data-media-icon="progress"], .np-btn.saving .media-icon) { animation:mediaSpin 1s linear infinite; transform-origin:center; }
|
||||
@keyframes mediaSpin { to { transform:rotate(360deg); } }
|
||||
@media(prefers-reduced-motion:reduce) { :is([data-media-icon="progress"], .np-btn.saving .media-icon) { animation:none; } }
|
||||
.media-shape { display:none; }
|
||||
.media-shape-classic { display:inline; }
|
||||
html[data-layout="f7-swipe"] .media-shape-classic,
|
||||
html[data-layout="glass-stage"] .media-shape-classic,
|
||||
html[data-layout="bento-hub"] .media-shape-classic { display:none; }
|
||||
html[data-layout="f7-swipe"] .media-shape-f7-swipe,
|
||||
html[data-layout="glass-stage"] .media-shape-glass-stage,
|
||||
html[data-layout="bento-hub"] .media-shape-bento-hub { display:inline; }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user