Compare commits
131 Commits
df4acfc9f1
...
codex/jame
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
12
.gitignore
vendored
12
.gitignore
vendored
@@ -38,3 +38,15 @@ legacy/releases/
|
|||||||
|
|
||||||
# python caches from scripts/lyrics
|
# python caches from scripts/lyrics
|
||||||
__pycache__/
|
__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",
|
"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."
|
"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,
|
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`
|
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.
|
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`.
|
- Now-playing tiles are `[svg.ti][span.tl]`; change state text with `setTile(btn, label)`, never `textContent`.
|
||||||
|
|
||||||
## Saved searches + caching notes
|
## Saved searches + caching notes
|
||||||
|
|||||||
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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
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.
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
<meta name="robots" content="noindex" />
|
<meta name="robots" content="noindex" />
|
||||||
<meta name="theme-color" content="#0a0c12" />
|
<meta name="theme-color" content="#0a0c12" />
|
||||||
<title>Lyrics Studio</title>
|
<title>Lyrics Studio</title>
|
||||||
|
<link rel="stylesheet" href="download-actions.css" />
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
@@ -193,7 +194,7 @@
|
|||||||
.transport { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
.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 { 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.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 { 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; }
|
.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); }
|
.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; }
|
main { padding-top: 16px; }
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
|
@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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -382,7 +385,7 @@
|
|||||||
<canvas id="wave" height="64"></canvas>
|
<canvas id="wave" height="64"></canvas>
|
||||||
<div class="transport">
|
<div class="transport">
|
||||||
<button class="round" id="bBack" type="button" title="Back 5 s" aria-label="Back 5 seconds">−5</button>
|
<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>
|
<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>
|
<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">
|
<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>
|
<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>
|
<button class="btn sm" id="shiftBtn" type="button">Apply</button>
|
||||||
<span class="grow"></span>
|
<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>
|
<button class="btn sm" id="rawBtn" type="button">✎ Edit as text</button>
|
||||||
<label><input type="checkbox" id="follow" checked /> Follow playback</label>
|
<label><input type="checkbox" id="follow" checked /> Follow playback</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -508,7 +511,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="row" style="margin-bottom:8px"><h2 style="margin:0">Recent edits</h2><span class="grow"></span>
|
<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>
|
<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>
|
<div class="scroll"><table class="tbl" id="revTable"></table></div>
|
||||||
<pre id="revView" class="hidden" style="margin-top:12px"></pre>
|
<pre id="revView" class="hidden" style="margin-top:12px"></pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -569,6 +572,7 @@
|
|||||||
<nav class="nav" id="tabsBottom" role="tablist"></nav>
|
<nav class="nav" id="tabsBottom" role="tablist"></nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="download-actions.js"></script>
|
||||||
<script src="lyrics-core.js"></script>
|
<script src="lyrics-core.js"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
@@ -1169,8 +1173,8 @@
|
|||||||
const a = new Audio(url); a.preload = 'auto';
|
const a = new Audio(url); a.preload = 'auto';
|
||||||
a.addEventListener('timeupdate', tick); a.addEventListener('seeked', tick);
|
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('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('play', () => { TransportIcons.set($('bPlay'),'pause'); });
|
||||||
a.addEventListener('pause', () => { $('playIcon').setAttribute('d', 'M8 5v14l11-7z'); });
|
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.addEventListener('error', () => say('No playable audio for this song on the server — you can still edit the text.', 'err'));
|
||||||
a.playbackRate = Number($('rate').value);
|
a.playbackRate = Number($('rate').value);
|
||||||
LE.audio = a;
|
LE.audio = a;
|
||||||
@@ -1196,7 +1200,7 @@
|
|||||||
<div class="ln-tools">
|
<div class="ln-tools">
|
||||||
<input class="tin" value="${esc(fmtT(line.t))}" placeholder="0:00.00" aria-label="Time" />
|
<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="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>
|
||||||
<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>
|
<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);
|
||||||
|
});
|
||||||
84
frontend/app-seams.test.js
Normal file
84
frontend/app-seams.test.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
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 EQ = (() => {");assert.ok(start>=0,"EQ");const body=app.slice(start,start+7917);assert.equal(createHash('sha256').update(body).digest('hex'),"f0cba011c94f33e6fd0ab778021f4c97238262dfa0a8dd40bf3f17149c227528","EQ body unchanged");}
|
||||||
|
{const start=app.indexOf("const EqRender = (() => {");assert.ok(start>=0,"EqRender");const body=app.slice(start,start+5912);assert.equal(createHash('sha256').update(body).digest('hex'),"f014dd41dafebc33f09e87be866074afd7a2443c5bb452103a435585353a828f","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']);
|
||||||
|
});
|
||||||
3997
frontend/app.js
3997
frontend/app.js
File diff suppressed because it is too large
Load Diff
204
frontend/asset-sync-core.js
Normal file
204
frontend/asset-sync-core.js
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
/* Exact, resumable asset synchronization shared by pages and workers. */
|
||||||
|
(function (root) {
|
||||||
|
'use strict';
|
||||||
|
const CACHE = 'ytplayer-assets';
|
||||||
|
const STATE = '/__ytp_asset_state';
|
||||||
|
const url = (path, file) => path + '?v=' + file.h;
|
||||||
|
|
||||||
|
function paths(manifest) {
|
||||||
|
return Object.keys(manifest.files).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 status(manifest, cache, { previous, activeLayout, offline = true } = {}) {
|
||||||
|
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) : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }) {
|
||||||
|
const previous = (await state(cache))?.current;
|
||||||
|
const missing = [];
|
||||||
|
const selected = all ? paths(manifest) : 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) {
|
||||||
|
const files = paths(manifest);
|
||||||
|
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,
|
||||||
|
sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), baseDelay = 1000,
|
||||||
|
maxDelay = 30000, notify = () => {}, force = false } = {}) {
|
||||||
|
const key = completionKey(manifest), saved = await cache.match(key);
|
||||||
|
let job = saved ? await saved.json() : { failures: 0, nextRetryAt: 0 };
|
||||||
|
let snapshot = await completeness(manifest, cache);
|
||||||
|
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 }); }
|
||||||
|
catch (failure) { error = failure; }
|
||||||
|
snapshot = await completeness(manifest, cache);
|
||||||
|
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, url, paths, blocking, plan, retained, fallback, status, completeness, completionKey, complete, syncAssets, state, commit };
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||||
|
else root.AssetSyncCore = api;
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||||
104
frontend/asset-sync-core.test.js
Normal file
104
frontend/asset-sync-core.test.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
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('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('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');
|
||||||
|
});
|
||||||
77
frontend/asset-worker.test.js
Normal file
77
frontend/asset-worker.test.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
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('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');
|
||||||
|
});
|
||||||
377
frontend/assets.json
Normal file
377
frontend/assets.json
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"/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,
|
||||||
|
"files": [
|
||||||
|
"/vendor/jamesdsp/controller.js",
|
||||||
|
"/vendor/jamesdsp/jamesdsp-node.js",
|
||||||
|
"/vendor/jamesdsp/worklet.js",
|
||||||
|
"/vendor/jamesdsp/params.js",
|
||||||
|
"/vendor/jamesdsp/dsp/jamesdsp.js",
|
||||||
|
"/vendor/jamesdsp/dsp/jamesdsp.wasm"
|
||||||
|
],
|
||||||
|
"background": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,8 @@
|
|||||||
pending.set(element, request); return request;
|
pending.set(element, request); return request;
|
||||||
}
|
}
|
||||||
function resume(element, clock) {
|
function resume(element, clock) {
|
||||||
if (!element || !element.paused || pending.has(element)) return;
|
// 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;
|
if (clock && element !== clock && needsAlignment(element.currentTime, clock.currentTime)) element.currentTime = clock.currentTime;
|
||||||
return play(element);
|
return play(element);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,3 +41,9 @@ test('a new play after pause/source reset is not blocked by a stale promise', as
|
|||||||
assert.equal(controller.play(audio), fresh); // old completion cannot discard the new request
|
assert.equal(controller.play(audio), fresh); // old completion cannot discard the new request
|
||||||
finishes[1](); await fresh;
|
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);
|
||||||
|
});
|
||||||
|
|||||||
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-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-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; }
|
.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');
|
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 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;
|
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); }
|
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'; toggle.textContent = state.paused ? 'Play' : 'Pause'; }; update();
|
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.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(); };
|
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) {
|
function configure(options) {
|
||||||
adapter = options; const doc = root.document, Recognition = root.SpeechRecognition || root.webkitSpeechRecognition;
|
adapter = options; const doc = root.document, Recognition = root.SpeechRecognition || root.webkitSpeechRecognition;
|
||||||
if (Recognition) {
|
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;
|
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); } };
|
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); } });
|
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;
|
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');
|
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('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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
s.preview = m.file.streamMime ? root.DirectStream?.create(m.file.streamMime, m.file.title) : null;
|
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);
|
const wait = waiting.get(m.file.id); if (wait) clearTimeout(wait.timer);
|
||||||
send(key, { type: 'direct', action: 'accept', token: m.token });
|
send(key, { type: 'direct', action: 'accept', token: m.token });
|
||||||
s.worker = new Worker('/direct-recv-worker.js');
|
s.worker = window.Lazy ? window.Lazy.worker('/direct-recv-worker.js') : new Worker('/direct-recv-worker.js');
|
||||||
s.worker.onmessage = async e => {
|
s.worker.onmessage = async e => {
|
||||||
const result = e.data;
|
const result = e.data;
|
||||||
if (result.op === 'opened') {
|
if (result.op === 'opened') {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
importScripts('/sha256.js');
|
|
||||||
let dir, handle, access, hash, pos = 0, claim, name;
|
let dir, handle, access, hash, pos = 0, claim, name;
|
||||||
let chain = Promise.resolve();
|
let chain = Promise.resolve();
|
||||||
self.onmessage = e => { chain = chain.then(() => run(e.data)); };
|
self.onmessage = e => { chain = chain.then(() => run(e.data)); };
|
||||||
async function close() { if (access) { access.flush(); access.close(); access = null; } }
|
async function close() { if (access) { access.flush(); access.close(); access = null; } }
|
||||||
async function run(m) {
|
async function run(m) {
|
||||||
try {
|
try {
|
||||||
|
if (!self.Sha256) { self.__ASSET_URLS__=m.assetUrls || {}; importScripts(self.__ASSET_URLS__.sha256 || '/sha256.js'); }
|
||||||
if (m.op === 'open') {
|
if (m.op === 'open') {
|
||||||
claim = m.file;
|
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');
|
if (!/^[A-Za-z0-9_-]{1,128}$/.test(claim.id) || !/^[a-f0-9]{64}$/.test(claim.cid)) throw Error('Invalid file identity');
|
||||||
|
|||||||
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);
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
/* Downloads page UI module. Grouped jobs, storage info, confirmed cancellation. */
|
/* Downloads page UI module. Grouped jobs, storage info, confirmed cancellation. */
|
||||||
(function (root) {
|
(function (root) {
|
||||||
'use strict';
|
'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 errors = typeof module !== 'undefined' ? require('./download-errors.js') : root.DownloadErrors;
|
||||||
|
|
||||||
const bytes = n => {
|
const bytes = n => {
|
||||||
@@ -118,11 +119,7 @@
|
|||||||
empty.className = 'empty-state dl-empty-state';
|
empty.className = 'empty-state dl-empty-state';
|
||||||
empty.innerHTML = `
|
empty.innerHTML = `
|
||||||
<div class="empty-icon dl-empty-icon-wrapper" aria-hidden="true">
|
<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">
|
<svg class="dl-empty-svg media-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-download"/></svg>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<h3 class="empty-title">No active downloads</h3>
|
<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>
|
<p class="empty-desc">Saves in progress appear here with live status. Videos you save land in <strong>Saved</strong>.</p>
|
||||||
@@ -222,10 +219,7 @@
|
|||||||
acts.className = 'dl-actions';
|
acts.className = 'dl-actions';
|
||||||
|
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
const pauseBtn = doc.createElement('button');
|
const pauseBtn = actionButtons.create(doc, 'pause', job.title || job.id);
|
||||||
pauseBtn.type = 'button';
|
|
||||||
pauseBtn.className = 'dl-act';
|
|
||||||
pauseBtn.textContent = 'Pause';
|
|
||||||
pauseBtn.setAttribute('aria-label', `Pause ${job.title || job.id}`);
|
pauseBtn.setAttribute('aria-label', `Pause ${job.title || job.id}`);
|
||||||
pauseBtn.onclick = (e) => {
|
pauseBtn.onclick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -234,10 +228,7 @@
|
|||||||
};
|
};
|
||||||
acts.appendChild(pauseBtn);
|
acts.appendChild(pauseBtn);
|
||||||
} else if (isPaused) {
|
} else if (isPaused) {
|
||||||
const resumeBtn = doc.createElement('button');
|
const resumeBtn = actionButtons.create(doc, 'resume', job.title || job.id);
|
||||||
resumeBtn.type = 'button';
|
|
||||||
resumeBtn.className = 'dl-act';
|
|
||||||
resumeBtn.textContent = 'Resume';
|
|
||||||
resumeBtn.setAttribute('aria-label', `Resume ${job.title || job.id}`);
|
resumeBtn.setAttribute('aria-label', `Resume ${job.title || job.id}`);
|
||||||
resumeBtn.onclick = (e) => {
|
resumeBtn.onclick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -246,10 +237,7 @@
|
|||||||
};
|
};
|
||||||
acts.appendChild(resumeBtn);
|
acts.appendChild(resumeBtn);
|
||||||
} else if (isFailed) {
|
} else if (isFailed) {
|
||||||
const retryBtn = doc.createElement('button');
|
const retryBtn = actionButtons.create(doc, 'retry', job.title || job.id);
|
||||||
retryBtn.type = 'button';
|
|
||||||
retryBtn.className = 'dl-act';
|
|
||||||
retryBtn.textContent = 'Retry';
|
|
||||||
retryBtn.setAttribute('aria-label', `Retry ${job.title || job.id}`);
|
retryBtn.setAttribute('aria-label', `Retry ${job.title || job.id}`);
|
||||||
retryBtn.onclick = (e) => {
|
retryBtn.onclick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -259,10 +247,7 @@
|
|||||||
acts.appendChild(retryBtn);
|
acts.appendChild(retryBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cancelBtn = doc.createElement('button');
|
const cancelBtn = actionButtons.create(doc, 'cancel', job.title || job.id);
|
||||||
cancelBtn.type = 'button';
|
|
||||||
cancelBtn.className = 'dl-act danger';
|
|
||||||
cancelBtn.textContent = 'Cancel';
|
|
||||||
cancelBtn.setAttribute('aria-label', `Cancel ${job.title || job.id}`);
|
cancelBtn.setAttribute('aria-label', `Cancel ${job.title || job.id}`);
|
||||||
cancelBtn.onclick = (e) => {
|
cancelBtn.onclick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* Download controls and device storage accounting. */
|
/* Download controls and device storage accounting. */
|
||||||
(function(root) {
|
(function(root) {
|
||||||
'use strict';
|
'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 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`;
|
const bytes = n => n >= 1073741824 ? `${(n / 1073741824).toFixed(1)} GB` : `${(n / 1048576).toFixed(1)} MB`;
|
||||||
function breakdown(files, playlists) {
|
function breakdown(files, playlists) {
|
||||||
@@ -11,7 +12,7 @@
|
|||||||
let adapter, total = 0;
|
let adapter, total = 0;
|
||||||
function configure(options) {
|
function configure(options) {
|
||||||
adapter = 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 doc = container.ownerDocument;
|
||||||
const heading = doc.createElement('h3'); heading.textContent = 'Downloads & storage';
|
const heading = doc.createElement('h3'); heading.textContent = 'Downloads & storage';
|
||||||
const jobs = doc.createElement('div'); jobs.className = 'download-manager'; jobs.setAttribute('aria-label', 'Download manager');
|
const jobs = doc.createElement('div'); jobs.className = 'download-manager'; jobs.setAttribute('aria-label', 'Download manager');
|
||||||
@@ -29,7 +30,7 @@
|
|||||||
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 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');
|
const actions = doc.createElement('div');
|
||||||
for (const action of [...(['active', 'queued'].includes(job.status) ? ['pause'] : [job.status === 'failed' ? 'retry' : 'resume']), 'cancel']) {
|
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);
|
row.append(title, status, progress, actions);
|
||||||
if (job.error) errors.appendDetails(row, job.error);
|
if (job.error) errors.appendDetails(row, job.error);
|
||||||
|
|||||||
@@ -1,24 +1,7 @@
|
|||||||
/* Shared live/offline EQ definitions and legacy curve migration. */
|
/* Shared live/offline EQ definitions and legacy curve migration. */
|
||||||
(function(root) {
|
(function(root) {
|
||||||
'use strict';
|
'use strict';
|
||||||
const frequencies = [31,62,125,250,500,1000,2000,4000,8000,16000];
|
const { bands, presets, migrate, width } = typeof module !== 'undefined' ? require('./eq-parameters') : root.EqParameters;
|
||||||
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));
|
|
||||||
function createWidener(context, source, value = 0) {
|
function createWidener(context, source, value = 0) {
|
||||||
const splitter = context.createChannelSplitter(2), output = context.createChannelMerger(2), delay = context.createDelay(.05);
|
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);
|
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.
@@ -1,18 +1,75 @@
|
|||||||
/* Shared fullscreen surface; inline player styling is unchanged. */
|
/* Shared fullscreen surface; inline player styling is unchanged. */
|
||||||
.orientation-fullscreen .fullscreen-hud[hidden] { display: none; }
|
.orientation-fullscreen .fullscreen-hud[hidden] { display: none; }
|
||||||
.orientation-fullscreen .fullscreen-hud { position: absolute; z-index: 20; bottom: max(12px, env(safe-area-inset-bottom)); left: max(12px, env(safe-area-inset-left)); right: max(12px, env(safe-area-inset-right)); display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: 12px; background: rgb(0 0 0 / .8); color: white; }
|
.orientation-fullscreen .fullscreen-hud {
|
||||||
.fullscreen-hud button { min-height: 44px; padding: 8px 12px; border: 1px solid rgb(255 255 255 / .25); border-radius: 8px; background: rgb(255 255 255 / .12); color: white; font: inherit; }
|
--hud-ink: var(--text, #fff);
|
||||||
.fullscreen-hud button:focus-visible { outline: 2px solid white; outline-offset: 2px; }
|
position: absolute; z-index: 20; right: max(12px, env(safe-area-inset-right));
|
||||||
.fullscreen-hud input { min-width: 40px; flex: 1; height: 44px; }
|
bottom: max(8px, env(safe-area-inset-bottom)); left: max(12px, env(safe-area-inset-left));
|
||||||
.player-stage.orientation-fullscreen { background: black; }
|
display: flex; flex-direction: column; align-items: stretch; gap: 2px; padding: 4px 2px 2px;
|
||||||
.player-stage.orientation-overlay { position: fixed !important; z-index: 10000 !important; inset: 0 !important; width: 100dvw !important; height: 100dvh !important; max-width: none !important; max-height: none !important; aspect-ratio: auto !important; margin: 0 !important; border-radius: 0 !important; }
|
color: #fff; background: linear-gradient(to bottom, transparent, rgb(0 0 0 / .45));
|
||||||
.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; }
|
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; }
|
.player-stage.orientation-overlay[data-fullscreen-rotation="-90"] { transform: translate(-50%, -50%) rotate(-90deg) !important; }
|
||||||
.orientation-fullscreen video { width: 100% !important; height: 100% !important; object-fit: contain; }
|
.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-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 {
|
||||||
.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)); }
|
bottom: max(12px, env(safe-area-inset-left)); left: max(12px, env(safe-area-inset-top)); right: max(12px, env(safe-area-inset-bottom));
|
||||||
@media(max-width: 460px) { .fullscreen-hud { flex-wrap: wrap; } .fullscreen-hud input { flex-basis: 100%; order: -1; } .fullscreen-hud button { flex: 1; padding-inline: 6px; font-size: 12px; } }
|
}
|
||||||
/* Raise the existing ancestry instead of moving a playing video in WebKit. */
|
.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 .orientation-ancestor { z-index: 10001 !important; transform: none !important; filter: none !important; overflow: visible !important; }
|
||||||
body.landscape-fs { overflow: hidden; }
|
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; }
|
||||||
|
|||||||
@@ -11,28 +11,58 @@
|
|||||||
if (angle === -90) return { x: rect.bottom - y, y: x - rect.left, 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 };
|
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) {
|
function create(options) {
|
||||||
const { stage, settings, persist, toggle, seek, state } = options;
|
const { stage, settings, persist, toggle, seek, state } = options;
|
||||||
let active = false, custom = false, native = false, locked = false, angle = 0, timer;
|
let active = false, custom = false, native = false, locked = false, angle = 0, timer, hideTimer;
|
||||||
const ancestors = [];
|
const ancestors = [];
|
||||||
for (let el = stage.parentElement; el && el !== document.body; el = el.parentElement) ancestors.push(el);
|
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;
|
const hud = document.createElement('div'); hud.className = 'fullscreen-hud'; hud.hidden = true;
|
||||||
hud.innerHTML = '<button type="button" aria-label="Play or pause">Play / pause</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"></button><button type="button" aria-label="Exit fullscreen">Exit</button>';
|
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);
|
stage.append(hud);
|
||||||
const [play, orientation, exit] = hud.querySelectorAll('button'), range = hud.querySelector('input');
|
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;
|
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() {
|
function layout() {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
angle = custom ? rotation(settings().fullscreenOrientation, innerWidth, innerHeight) : 0;
|
angle = custom ? rotation(settings().fullscreenOrientation, innerWidth, innerHeight) : 0;
|
||||||
stage.dataset.fullscreenRotation = String(angle);
|
stage.dataset.fullscreenRotation = String(angle);
|
||||||
orientation.textContent = `Orientation: ${normalize(settings().fullscreenOrientation)}`;
|
title.textContent = state().title || '';
|
||||||
|
orientation.setAttribute('aria-label', `Change fullscreen orientation (currently ${normalize(settings().fullscreenOrientation)})`);
|
||||||
|
orientation.title = `Orientation: ${normalize(settings().fullscreenOrientation)}`;
|
||||||
}
|
}
|
||||||
async function orient() {
|
async function orient() {
|
||||||
if (locked) { screen.orientation?.unlock?.(); locked = false; }
|
if (locked) { screen.orientation?.unlock?.(); locked = false; }
|
||||||
const value = normalize(settings().fullscreenOrientation);
|
const value = normalize(settings().fullscreenOrientation);
|
||||||
if (!custom && value !== 'auto') {
|
if (!custom && value !== 'auto') {
|
||||||
try { await screen.orientation.lock(value); locked = true; if (!active) { screen.orientation.unlock(); locked = false; return; } }
|
try { await screen.orientation.lock(value); locked = true; if (!active) { screen.orientation.unlock(); locked = false; return; } }
|
||||||
catch { if (!active) return; custom = true; stage.classList.add('orientation-overlay'); }
|
catch { if (!active) return; custom = true; lift(); stage.classList.add('orientation-overlay'); }
|
||||||
}
|
}
|
||||||
layout();
|
layout();
|
||||||
}
|
}
|
||||||
@@ -48,10 +78,12 @@
|
|||||||
hud.addEventListener('pointerdown', event => event.stopPropagation());
|
hud.addEventListener('pointerdown', event => event.stopPropagation());
|
||||||
hud.addEventListener('pointerup', event => event.stopPropagation());
|
hud.addEventListener('pointerup', event => event.stopPropagation());
|
||||||
function cleanup() {
|
function cleanup() {
|
||||||
active = false; custom = false; native = false; angle = 0; clearInterval(timer);
|
active = false; custom = false; native = false; angle = 0; clearInterval(timer); clearTimeout(hideTimer);
|
||||||
ancestors.forEach(el => el.classList.remove('orientation-ancestor'));
|
ancestors.forEach(el => el.classList.remove('orientation-ancestor'));
|
||||||
hud.hidden = true; stage.classList.remove('orientation-overlay', 'orientation-fullscreen');
|
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.fullscreenRotation; document.body.classList.remove('landscape-fs');
|
||||||
|
delete stage.dataset.fullscreenFit;
|
||||||
|
unlift();
|
||||||
if (locked) { screen.orientation?.unlock?.(); locked = false; }
|
if (locked) { screen.orientation?.unlock?.(); locked = false; }
|
||||||
}
|
}
|
||||||
function leave() {
|
function leave() {
|
||||||
@@ -60,23 +92,31 @@
|
|||||||
}
|
}
|
||||||
async function enter() {
|
async function enter() {
|
||||||
if (active) { leave(); return; }
|
if (active) { leave(); return; }
|
||||||
active = true; ancestors.forEach(el => el.classList.add('orientation-ancestor')); stage.classList.add('orientation-fullscreen'); hud.hidden = false;
|
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');
|
document.body.classList.add('landscape-fs');
|
||||||
try {
|
try {
|
||||||
if (!stage.requestFullscreen) throw new Error('Custom fullscreen');
|
if (!stage.requestFullscreen) throw new Error('Custom fullscreen');
|
||||||
await stage.requestFullscreen();
|
await stage.requestFullscreen();
|
||||||
native = true;
|
native = true;
|
||||||
} catch { custom = true; stage.classList.add('orientation-overlay'); }
|
} catch { custom = true; lift(); stage.classList.add('orientation-overlay'); }
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
await orient();
|
await orient();
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
timer = setInterval(() => { const s = state(); range.value = String(s.duration ? s.time / s.duration * 1000 : 0); play.textContent = s.paused ? 'Play' : 'Pause'; }, 250);
|
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('fullscreenchange', () => { if (active && !document.fullscreenElement && native) cleanup(); });
|
||||||
document.addEventListener('keydown', event => { if (active && event.key === 'Escape') leave(); });
|
document.addEventListener('keydown', event => { if (active && event.key === 'Escape') leave(); });
|
||||||
window.addEventListener('resize', layout);
|
window.addEventListener('resize', layout);
|
||||||
return { enter, leave, point: event => coordinates(event.clientX, event.clientY, stage.getBoundingClientRect(), angle) };
|
return { enter, leave, point: event => coordinates(event.clientX, event.clientY, stage.getBoundingClientRect(), angle) };
|
||||||
}
|
}
|
||||||
const api = { normalize, rotation, coordinates, create };
|
const api = { normalize, normalizeFit, rotation, coordinates, create };
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.FullscreenOrientation = api;
|
if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.FullscreenOrientation = api;
|
||||||
})(globalThis);
|
})(globalThis);
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
const { test } = require('node:test');
|
const { test } = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const { normalize, rotation, coordinates } = require('./fullscreen-orientation');
|
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', () => {
|
test('orientation defaults and only rotates mismatched viewports', () => {
|
||||||
assert.equal(normalize('invalid'), 'auto');
|
assert.equal(normalize('invalid'), 'auto');
|
||||||
assert.equal(rotation('auto', 390, 844), 0);
|
assert.equal(rotation('auto', 390, 844), 0);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* 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) {
|
(function(root) {
|
||||||
'use strict';
|
'use strict';
|
||||||
const paths = {
|
const paths = {
|
||||||
@@ -6,9 +6,9 @@
|
|||||||
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"/>',
|
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"/>',
|
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"/>',
|
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"/>',
|
save:'<use href="#media-download"/>',
|
||||||
play:'<path d="m8 5 11 7-11 7z"/>', pause:'<path d="M8 5v14M16 5v14"/>',
|
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:'<path d="m5 5 10 7-10 7zM19 5v14"/>', previous:'<path d="m19 5-10 7 10 7zM5 5v14"/>',
|
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"/>',
|
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"/>',
|
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"/>',
|
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"/>',
|
||||||
@@ -20,15 +20,17 @@
|
|||||||
clock:'<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
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"/>',
|
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"/>',
|
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"/>'
|
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','⋮':'more','🗑':'trash','🎵':'music'};
|
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) {
|
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 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;
|
if (!glyph) return null;
|
||||||
const length = glyph.length + (rest[glyph.length] === '\uFE0F' ? 1 : 0);
|
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) {
|
function mount(doc) {
|
||||||
const decorations = new Set(), tabLabels = new Map(); let scheduled=false;
|
const decorations = new Set(), tabLabels = new Map(); let scheduled=false;
|
||||||
@@ -45,14 +47,15 @@
|
|||||||
}
|
}
|
||||||
function paint() {
|
function paint() {
|
||||||
scheduled=false;observer.disconnect();
|
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 [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);}
|
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.
|
// Existing SVGs already express the action; only legacy glyphs change.
|
||||||
if(parent.querySelector('svg:not(.glass-control-icon),.legacy-nav-icon'))continue;
|
if(parent.querySelector('svg:not(.glass-control-icon),.legacy-nav-icon'))continue;
|
||||||
for(const node of [...parent.childNodes]) {
|
for(const node of [...parent.childNodes]) {
|
||||||
if(node.nodeType!==3)continue;const match=leadingGlyph(node.textContent);if(!match)continue;
|
if(node.nodeType!==3)continue;const match=leadingGlyph(node.textContent,bento);if(!match)continue;
|
||||||
const icon=svg(match.kind);
|
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'); }
|
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;
|
const original=doc.createElement('span'); original.className='glass-original-glyph';original.textContent=match.original;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const test=require('node:test'),assert=require('node:assert/strict');
|
const test=require('node:test'),assert=require('node:assert/strict');
|
||||||
const {leadingGlyph}=require('./glass-controls.js');
|
const {leadingGlyph}=require('./glass-controls.js');
|
||||||
test('glass icon substitution preserves leading whitespace and remaining labels',()=>{
|
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.deepEqual(leadingGlyph('⚙️ Settings'),{kind:'settings',original:'⚙️',rest:' Settings'});
|
||||||
assert.equal(leadingGlyph('A song with 🔍 in its title'),null);
|
assert.equal(leadingGlyph('A song with 🔍 in its title'),null);
|
||||||
assert.equal(leadingGlyph('Clear all'),null);
|
assert.equal(leadingGlyph('Clear all'),null);
|
||||||
@@ -13,3 +13,10 @@ test('playlist trash, menu and music glyphs use drawn glass icons',()=>{
|
|||||||
assert.equal(leadingGlyph('⋮').kind,'more');
|
assert.equal(leadingGlyph('⋮').kind,'more');
|
||||||
assert.equal(leadingGlyph('🎵').kind,'music');
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
^ * Out: { ok: true, sha256, size, engine } | { ok: false, error }
|
^ * Out: { ok: true, sha256, size, engine } | { ok: false, error }
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
'use strict';
|
'use strict';
|
||||||
importScripts('/sha256.js');
|
|
||||||
|
|
||||||
self.onmessage = async (e) => {
|
self.onmessage = async (e) => {
|
||||||
const { name } = e.data || {};
|
const { name } = e.data || {};
|
||||||
try {
|
try {
|
||||||
|
if (!self.Sha256) { self.__ASSET_URLS__=e.data.assetUrls || {}; importScripts(self.__ASSET_URLS__.sha256 || '/sha256.js'); }
|
||||||
const root = await navigator.storage.getDirectory();
|
const root = await navigator.storage.getDirectory();
|
||||||
const dir = await root.getDirectoryHandle('videos');
|
const dir = await root.getDirectoryHandle('videos');
|
||||||
const file = await (await dir.getFileHandle(String(name))).getFile();
|
const file = await (await dir.getFileHandle(String(name))).getFile();
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
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; }
|
||||||
164
frontend/lazy.js
Normal file
164
frontend/lazy.js
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
/* 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();
|
||||||
|
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 task = new Promise((resolve, reject) => {
|
||||||
|
const node = doc.createElement(style ? 'link' : 'script');
|
||||||
|
if (style) { node.rel = 'stylesheet'; node.href = url(path); }
|
||||||
|
else { node.async = false; node.src = url(path); }
|
||||||
|
node.onload = resolve;
|
||||||
|
node.onerror = () => reject(new Error('Unable to load ' + path));
|
||||||
|
doc.head.append(node);
|
||||||
|
});
|
||||||
|
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 (!root.navigator.serviceWorker || 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(()=>{});
|
||||||
|
},
|
||||||
|
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);
|
||||||
125
frontend/lazy.test.js
Normal file
125
frontend/lazy.test.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
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:{}}, 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('failed injection can retry and proxy dispatches only after execution', async () => {
|
||||||
|
const {root,inserted}=fixture();
|
||||||
|
const append=root.document.head.append;
|
||||||
|
root.document.head.append=n=>{inserted.push(n);queueMicrotask(()=>n.onerror?.());};
|
||||||
|
await assert.rejects(root.Lazy.load('feature:test'),/one.js/);
|
||||||
|
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.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); }
|
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 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);
|
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';
|
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;
|
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();
|
win.addEventListener('pagehide', cleanup, { once: true }); win.addEventListener('resize', adapter.refresh); adapter.refresh();
|
||||||
} else {
|
} else {
|
||||||
const canvas = root.document.createElement('canvas'); canvas.width = 840; canvas.height = 500; const ctx = canvas.getContext('2d');
|
const canvas = root.document.createElement('canvas'); canvas.width = 840; canvas.height = 500; const ctx = canvas.getContext('2d');
|
||||||
@@ -33,7 +42,7 @@
|
|||||||
}
|
}
|
||||||
function configure(options) {
|
function configure(options) {
|
||||||
adapter = options; const supported = !!mode();
|
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); } } });
|
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;
|
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,[]);
|
||||||
|
});
|
||||||
@@ -34,8 +34,7 @@
|
|||||||
|
|
||||||
// Incremental SHA-256 (frontend/sha256.js): the file is hashed while it is
|
// 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.
|
// 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() {
|
async function getVideosDir() {
|
||||||
const root = await navigator.storage.getDirectory();
|
const root = await navigator.storage.getDirectory();
|
||||||
@@ -203,6 +202,10 @@ async function writeFile(dirName, name, buffer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.onmessage = async (e) => {
|
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') {
|
if (e.data && e.data.op === 'write') {
|
||||||
try { await writeFile(e.data.dir, e.data.name, e.data.buffer); self.postMessage({ type: 'written' }); }
|
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) }); }
|
catch (err) { self.postMessage({ type: 'error', error: err && err.message ? err.message : String(err) }); }
|
||||||
|
|||||||
@@ -151,7 +151,7 @@
|
|||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let worker;
|
let worker;
|
||||||
try {
|
try {
|
||||||
worker = new Worker('/opfs-worker.js');
|
worker = window.Lazy ? window.Lazy.worker('/opfs-worker.js') : new Worker('/opfs-worker.js');
|
||||||
} catch {
|
} catch {
|
||||||
resolve({ ok: false, fallback: true });
|
resolve({ ok: false, fallback: true });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
function hashInWorker(name) {
|
function hashInWorker(name) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let w;
|
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.onmessage = (e) => { w.terminate(); resolve(e.data && e.data.ok ? e.data : null); };
|
||||||
w.onerror = () => { w.terminate(); resolve(null); };
|
w.onerror = () => { w.terminate(); resolve(null); };
|
||||||
w.postMessage({ name });
|
w.postMessage({ name });
|
||||||
@@ -296,7 +296,7 @@
|
|||||||
function start(h) {
|
function start(h) {
|
||||||
hooks = { ...hooks, ...(h || {}) };
|
hooks = { ...hooks, ...(h || {}) };
|
||||||
const idle = window.requestIdleCallback || ((fn) => setTimeout(fn, 1));
|
const idle = window.requestIdleCallback || ((fn) => setTimeout(fn, 1));
|
||||||
setTimeout(() => idle(() => sync()), 8000);
|
setTimeout(() => idle(() => sync()), h?.initialDelay ?? 8000);
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.visibilityState === 'visible' && Date.now() - lastSync > RESYNC_MS) sync();
|
if (document.visibilityState === 'visible' && Date.now() - lastSync > RESYNC_MS) sync();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
* The ".part" suffix keeps OPFS.listVideos() from ever listing a partial file.
|
* The ".part" suffix keeps OPFS.listVideos() from ever listing a partial file.
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
'use strict';
|
'use strict';
|
||||||
importScripts('/sha256.js');
|
|
||||||
|
|
||||||
let dir = null;
|
let dir = null;
|
||||||
let handle = null;
|
let handle = null;
|
||||||
@@ -36,6 +35,7 @@ self.onmessage = (e) => { chain = chain.then(() => onOp(e.data || {})); };
|
|||||||
|
|
||||||
async function onOp(m) {
|
async function onOp(m) {
|
||||||
try {
|
try {
|
||||||
|
if (!self.Sha256) { self.__ASSET_URLS__=m.assetUrls || {}; importScripts(self.__ASSET_URLS__.sha256 || '/sha256.js'); }
|
||||||
if (m.op === 'open') {
|
if (m.op === 'open') {
|
||||||
const root = await navigator.storage.getDirectory();
|
const root = await navigator.storage.getDirectory();
|
||||||
dir = await root.getDirectoryHandle('videos', { create: true });
|
dir = await root.getDirectoryHandle('videos', { create: true });
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const xid = newXid();
|
const xid = newXid();
|
||||||
const pc = new RTCPeerConnection({ iceServers });
|
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 settled = false;
|
||||||
let idle = null;
|
let idle = null;
|
||||||
let expected = size;
|
let expected = size;
|
||||||
|
|||||||
@@ -200,7 +200,7 @@
|
|||||||
button('Transcribe in browser', () =>
|
button('Transcribe in browser', () =>
|
||||||
run(async (id) => {
|
run(async (id) => {
|
||||||
const buffer = await audio(id, file.files[0]);
|
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) => {
|
const result = await engine.transcribe(buffer, (text) => {
|
||||||
if (alive) status.textContent = 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-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; }
|
.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] .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-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; }
|
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 { 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; }
|
.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.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: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; }
|
.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';
|
'use strict';
|
||||||
const matches = (video, query) => !query || `${video.title || video.id} ${video.channel || ''}`.toLowerCase().includes(query.toLowerCase());
|
const matches = (video, query) => !query || `${video.title || video.id} ${video.channel || ''}`.toLowerCase().includes(query.toLowerCase());
|
||||||
const paths = {
|
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"/>'
|
delete: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6"/>'
|
||||||
};
|
};
|
||||||
function button(doc, kind, label, action) {
|
function button(doc, kind, label, action) {
|
||||||
const b = doc.createElement('button'); b.type = 'button';
|
const b = doc.createElement('button'); b.type = 'button';
|
||||||
b.className = `saved-file-action${kind === 'delete' ? ' danger' : ''}`;
|
b.className = `saved-file-action${kind === 'delete' ? ' danger' : ''}`;
|
||||||
b.setAttribute('aria-label', label); b.title = label;
|
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;
|
b.onclick = e => { e.stopPropagation(); action(); }; return b;
|
||||||
}
|
}
|
||||||
function actions(row, video, exportFile, deleteFile) {
|
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);
|
||||||
34
frontend/settings-persist.test.js
Normal file
34
frontend/settings-persist.test.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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] } };
|
||||||
|
const context = vm.createContext({ WEB: true, data: { settings, profile: { name: 'test', syncedAt: 200 } }, DEFAULT_SETTINGS: { saveBeforePlay: false, autoPreload: true, layout: 'classic' }, 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('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]);
|
||||||
|
});
|
||||||
|
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.equal(c.data.profile.syncedAt, 300);
|
||||||
|
});
|
||||||
@@ -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>`;
|
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 = {}) {
|
function mount(host, context = {}) {
|
||||||
dispose();
|
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 remembered = ''; try { remembered = win.sessionStorage.getItem('ytpSettingsSection') || ''; } catch {}
|
||||||
let active = registry.get(remembered) ? remembered : sections[0]?.id;
|
let active = registry.get(remembered) ? remembered : sections[0]?.id;
|
||||||
host.classList.add('settings-browser');
|
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);
|
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 });
|
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 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));
|
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);
|
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 summaries() { for (const section of sections) { try { rows.get(section.id).summary.textContent = section.summary(context) || ''; } catch {} } }
|
||||||
function show(id, inDetail, focusId) {
|
function show(id, inDetail, focusId) {
|
||||||
if (registry.get(id)) active = id;
|
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';
|
host.dataset.detail = inDetail ? '1' : '0';
|
||||||
for (const [key, panel] of panels) panel.hidden = key !== active;
|
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');
|
for (const [key, row] of rows) row.button.setAttribute('aria-current', key === active ? 'true' : 'false');
|
||||||
@@ -71,6 +81,19 @@
|
|||||||
function filter() {
|
function filter() {
|
||||||
const query = input.value.trim(); results.replaceChildren(); results.hidden = !query; grid.hidden = !!query;
|
const query = input.value.trim(); results.replaceChildren(); results.hidden = !query; grid.hidden = !!query;
|
||||||
if (!query) { empty.hidden = true; return; }
|
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 = [];
|
const entries = [];
|
||||||
for (const section of sections) {
|
for (const section of sections) {
|
||||||
entries.push({ title: section.title, description: section.description || '', section: section.id });
|
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 }); };
|
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);
|
win.addEventListener('popstate', pop); input.addEventListener('input', filter);
|
||||||
host.addEventListener('change', summaries); host.addEventListener('input', summaries);
|
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;
|
const state = win.history.state?.ytpSettingsSection;
|
||||||
if (!state) win.history.replaceState({ ...win.history.state, ytpSettingsSection: null }, '', win.location.href);
|
if (!state) win.history.replaceState({ ...win.history.state, ytpSettingsSection: null }, '', win.location.href);
|
||||||
show(state || active, !!state); summaries();
|
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 };
|
return { open, refresh: summaries, dispose };
|
||||||
}
|
}
|
||||||
const api = { ...registry, mount, search, normalize, createRegistry, clusters };
|
const api = { ...registry, mount, search, normalize, createRegistry, clusters };
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
wasm = null;
|
wasm = null;
|
||||||
try {
|
try {
|
||||||
if (typeof WebAssembly === 'undefined') return wasm;
|
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;
|
let b64 = root.SHA256_WASM_B64;
|
||||||
if (!b64 && typeof require === 'function') b64 = require('./sha256-wasm.js');
|
if (!b64 && typeof require === 'function') b64 = require('./sha256-wasm.js');
|
||||||
if (!b64) return wasm;
|
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 };
|
||||||
|
})();
|
||||||
122
frontend/shell-consistency.test.js
Normal file
122
frontend/shell-consistency.test.js
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
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.
|
||||||
|
for (const asset of described) assert.ok(listed.has(asset) || asset === '/piano-engine.mjs', `${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);
|
background: linear-gradient(180deg, rgba(16,16,19,0.7), transparent);
|
||||||
backdrop-filter: blur(8px);
|
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) */
|
/* Recent-searches dropdown — device-local, kept in localStorage (RecentSearches in app.js) */
|
||||||
.search-recent {
|
.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: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: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 {
|
.np-title {
|
||||||
font-family: var(--display);
|
font-family: var(--display);
|
||||||
font-weight: 700;
|
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);
|
box-shadow: 0 0 26px -12px var(--accent-glow);
|
||||||
}
|
}
|
||||||
.card.playing::after {
|
.card.playing::after {
|
||||||
content: "▶ NOW PLAYING";
|
content: "NOW PLAYING";
|
||||||
position: absolute; top: 9px; right: 44px;
|
position: absolute; top: 9px; right: 44px;
|
||||||
font-family: var(--mono); font-size: 8px; font-weight: 700;
|
font-family: var(--mono); font-size: 8px; font-weight: 700;
|
||||||
letter-spacing: 0.1em; color: var(--accent);
|
letter-spacing: 0.1em; color: var(--accent);
|
||||||
@@ -1097,19 +1097,20 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
|||||||
display: none;
|
display: none;
|
||||||
align-items: center; justify-content: center;
|
align-items: center; justify-content: center;
|
||||||
width: 18px; height: 18px;
|
width: 18px; height: 18px;
|
||||||
color: #7ee0a8;
|
color: var(--accent-bright);
|
||||||
font-size: 12px; font-weight: 700;
|
font-size: 12px; font-weight: 700;
|
||||||
text-shadow: 0 1px 3px rgba(0,0,0,0.85);
|
text-shadow: 0 1px 3px rgba(0,0,0,0.85);
|
||||||
}
|
}
|
||||||
.card.cached .saved-badge { display: flex; }
|
.card.cached .saved-badge { display: flex; }
|
||||||
.card.downloading .saved-badge {
|
.card.downloading .saved-badge {
|
||||||
display: flex;
|
display: flex;
|
||||||
color: #ffc850;
|
color: var(--accent-bright);
|
||||||
animation: pulse 1s ease-in-out infinite;
|
|
||||||
}
|
}
|
||||||
.card.downloading .saved-badge::before { content: "⏳"; }
|
/* State changes reveal SVGs without rewriting badges or their handlers. */
|
||||||
.card.downloading .saved-badge { font-size: 0; }
|
.saved-badge .badge-progress { display:none; }
|
||||||
.card.downloading .saved-badge::before { font-size: 12px; }
|
.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 */
|
/* Download progress bar on card thumb */
|
||||||
.dl-progress {
|
.dl-progress {
|
||||||
@@ -3345,84 +3346,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);
|
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
|
/* 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
|
(≥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. */
|
a two-column grid would leave .main stranded in the narrow first column. */
|
||||||
@@ -3983,6 +3906,35 @@ body.presenter-open { overflow: hidden; }
|
|||||||
.player-stage { touch-action: pan-y; } /* page still scrolls; we get double-tap + pinch */
|
.player-stage { touch-action: pan-y; } /* page still scrolls; we get double-tap + pinch */
|
||||||
:fullscreen .player-stage, .player-stage:fullscreen,
|
:fullscreen .player-stage, .player-stage:fullscreen,
|
||||||
body.landscape-fs .player-stage { touch-action: none; } /* fullscreen (real or the iOS landscape one): swipes too */
|
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; }
|
#video { transform-origin: center center; transition: transform 0.05s linear; }
|
||||||
.gesture-hud {
|
.gesture-hud {
|
||||||
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 6;
|
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 6;
|
||||||
@@ -4306,6 +4258,30 @@ body.landscape-fs .player-stage { touch-action: none; } /* fullscreen (real or t
|
|||||||
.search-form { min-width: 0; }
|
.search-form { min-width: 0; }
|
||||||
.search-form #searchInput { flex: 1 1 auto; 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) */
|
/* iPhone/iPad: no double-tap page zoom (scrolling unaffected) */
|
||||||
html.ios-nozoom, html.ios-nozoom body { touch-action: manipulation; }
|
html.ios-nozoom, html.ios-nozoom body { touch-action: manipulation; }
|
||||||
|
|
||||||
@@ -4356,3 +4332,27 @@ html.ios-nozoom, html.ios-nozoom body { touch-action: manipulation; }
|
|||||||
|
|
||||||
.lyric-primary { white-space: pre-line; }
|
.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; }
|
.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; }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
* sw-update — makes "Refresh UI" actually land the new build.
|
* sw-update — makes "Refresh UI" actually land the new build.
|
||||||
*
|
*
|
||||||
* Framework-free and dependency-free on purpose (same pattern as
|
* Framework-free, with a shared sync core and a legacy fallback (same pattern as
|
||||||
* async-guard.js):
|
* async-guard.js):
|
||||||
* • Loads as a plain <script> under CSP `script-src 'self'` (browser
|
* • Loads as a plain <script> under CSP `script-src 'self'` (browser
|
||||||
* global `window.SwUpdate`).
|
* global `window.SwUpdate`).
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
* already current (a failed install leaves a partial cache that the
|
* already current (a failed install leaves a partial cache that the
|
||||||
* activate handler then mistook for a previous deploy).
|
* activate handler then mistook for a previous deploy).
|
||||||
*
|
*
|
||||||
* The fix no longer depends on the service-worker install lifecycle:
|
* Legacy fallback (retained for ASSET_SYNC=0):
|
||||||
* 1. refreshShellInPlace() downloads a fresh copy of every file the shell
|
* 1. refreshShellInPlace() downloads a fresh copy of every file the shell
|
||||||
* caches hold (cache-busted, so even an old worker's cache-first
|
* caches hold (cache-busted, so even an old worker's cache-first
|
||||||
* handler can't answer with the stale copy) — ALL of them or nothing —
|
* handler can't answer with the stale copy) — ALL of them or nothing —
|
||||||
@@ -25,6 +25,10 @@
|
|||||||
* serves the next load, it serves the new build.
|
* serves the next load, it serves the new build.
|
||||||
* 2. Only then is a waiting worker (if any) activated, and the page
|
* 2. Only then is a waiting worker (if any) activated, and the page
|
||||||
* reloaded once.
|
* reloaded once.
|
||||||
|
* Incremental mode uses AssetSyncCore to fetch only missing verified hash URLs
|
||||||
|
* and publishes a complete blocking set. Playback guards cover activation and
|
||||||
|
* reload callbacks; PLAYING reports provide an additional worker guard.
|
||||||
|
*
|
||||||
* The banner itself only opens when the build the page is running differs
|
* The banner itself only opens when the build the page is running differs
|
||||||
* from the server's (see app.js maybeShowUpdateBanner), so no lifecycle
|
* from the server's (see app.js maybeShowUpdateBanner), so no lifecycle
|
||||||
* event can re-open it once the page is current.
|
* event can re-open it once the page is current.
|
||||||
@@ -64,7 +68,7 @@
|
|||||||
* @param {Function} [opts.setTimeout]
|
* @param {Function} [opts.setTimeout]
|
||||||
* @returns {Promise<{refreshed: number, caches: number, resumed: number}>}
|
* @returns {Promise<{refreshed: number, caches: number, resumed: number}>}
|
||||||
*/
|
*/
|
||||||
async function refreshShellInPlace({ cachesApi, fetchFn, isShellCache, bust, timeoutMs, attempts, attemptTimeoutMs, concurrency, stagingKey, setTimeout: setTimeoutFn }) {
|
async function refreshLegacyShell({ cachesApi, fetchFn, isShellCache, bust, timeoutMs, attempts, attemptTimeoutMs, concurrency, stagingKey, setTimeout: setTimeoutFn }) {
|
||||||
const names = (await cachesApi.keys()).filter(isShellCache);
|
const names = (await cachesApi.keys()).filter(isShellCache);
|
||||||
if (!names.length) return { refreshed: 0, caches: 0, resumed: 0 }; // uncontrolled page: a reload already hits the network
|
if (!names.length) return { refreshed: 0, caches: 0, resumed: 0 }; // uncontrolled page: a reload already hits the network
|
||||||
|
|
||||||
@@ -145,6 +149,46 @@
|
|||||||
return { refreshed: urls.size, caches: names.length, resumed };
|
return { refreshed: urls.size, caches: names.length, resumed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshShellInPlace(opts) {
|
||||||
|
const core = root.AssetSyncCore;
|
||||||
|
if (core) {
|
||||||
|
const response = await opts.fetchFn('/api/manifest', { cache: 'no-store' });
|
||||||
|
if (!response.ok) throw new Error('Manifest unavailable');
|
||||||
|
const m = await response.json();
|
||||||
|
const cache = await opts.cachesApi.open(core.CACHE);
|
||||||
|
// Only upgraded workers can route the persistent scheme. A rollback
|
||||||
|
// worker explicitly advertises the legacy path through CACHE_STATUS.
|
||||||
|
const reg = root.navigator && await root.navigator.serviceWorker.getRegistration();
|
||||||
|
const worker = reg && (reg.waiting || reg.active);
|
||||||
|
const status = await askCacheStatus(worker);
|
||||||
|
// Unknown protocol is a retryable failure, never an empty successful
|
||||||
|
// legacy refresh followed by a reload into the old persistent manifest.
|
||||||
|
if (worker && !status) throw new Error('Worker update status unavailable; try again');
|
||||||
|
if (status && status.assetSync) {
|
||||||
|
const activeLayout = root.Lazy?.layout || 'classic';
|
||||||
|
const result = await core.syncAssets(m,{cache,fetchFn:opts.fetchFn,activeLayout});
|
||||||
|
if (!reg.waiting) await core.commit(m,cache,{activeLayout});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return refreshLegacyShell({ ...opts, isShellCache: n => n !== 'ytplayer-assets' && opts.isShellCache(n) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function playing() { return typeof Player !== 'undefined' && !!Player._wantsPlaying; }
|
||||||
|
function reportPlaying(value = playing()) {
|
||||||
|
const sw = root.navigator && root.navigator.serviceWorker;
|
||||||
|
if (!sw) return;
|
||||||
|
sw.getRegistration().then(reg => {
|
||||||
|
for (const worker of [sw.controller, reg && reg.waiting]) if(worker) worker.postMessage({type:'PLAYING',value});
|
||||||
|
}).catch(()=>{});
|
||||||
|
}
|
||||||
|
if (root.document && root.navigator && root.navigator.serviceWorker) {
|
||||||
|
if (root.VisibleTimer) root.VisibleTimer.setIntervalWhenVisible(()=>reportPlaying(),1000);
|
||||||
|
for (const event of ['play','pause','ended']) root.document.addEventListener(event,()=>reportPlaying(),true);
|
||||||
|
root.document.addEventListener('visibilitychange',()=>reportPlaying());
|
||||||
|
root.navigator.serviceWorker.addEventListener('controllerchange',()=>reportPlaying());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asks a worker whether it has already precached its whole shell.
|
* Asks a worker whether it has already precached its whole shell.
|
||||||
* Resolves to null on any problem (no reply, no MessageChannel, timeout) —
|
* Resolves to null on any problem (no reply, no MessageChannel, timeout) —
|
||||||
@@ -167,7 +211,7 @@
|
|||||||
try {
|
try {
|
||||||
const ch = new Ctor();
|
const ch = new Ctor();
|
||||||
ch.port1.onmessage = (ev) => finish(ev && ev.data ? ev.data : null);
|
ch.port1.onmessage = (ev) => finish(ev && ev.data ? ev.data : null);
|
||||||
worker.postMessage({ type: 'CACHE_STATUS' }, [ch.port2]);
|
worker.postMessage({ type: 'CACHE_STATUS', activeLayout: root.Lazy?.layout }, [ch.port2]);
|
||||||
} catch { finish(null); return; }
|
} catch { finish(null); return; }
|
||||||
if (schedule) schedule(() => finish(null), timeoutMs);
|
if (schedule) schedule(() => finish(null), timeoutMs);
|
||||||
});
|
});
|
||||||
@@ -191,7 +235,14 @@
|
|||||||
* @param {Function} [opts.setTimeout] injectable for tests
|
* @param {Function} [opts.setTimeout] injectable for tests
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async function applyUpdate({ reg, container, reload, refreshShell, askStatus, onPhase, setTimeout: setTimeoutFn }) {
|
async function applyUpdate({ reg, container, reload, refreshShell, askStatus, onPhase, isPlaying = playing, confirmOverride = root.confirm && root.confirm.bind(root), setTimeout: setTimeoutFn }) {
|
||||||
|
let overridden = false;
|
||||||
|
const guard = () => {
|
||||||
|
if (!isPlaying() || overridden) return true;
|
||||||
|
if (confirmOverride && confirmOverride('Playing - update after this song / when paused. Update now anyway? Playback may stop.')) { overridden = true; return true; }
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if (!guard()) throw new Error('Playing - update after this song / when paused');
|
||||||
const scheduleTimeout = setTimeoutFn || (typeof setTimeout !== 'undefined' ? setTimeout : null);
|
const scheduleTimeout = setTimeoutFn || (typeof setTimeout !== 'undefined' ? setTimeout : null);
|
||||||
const waiting = reg && reg.waiting;
|
const waiting = reg && reg.waiting;
|
||||||
|
|
||||||
@@ -199,16 +250,18 @@
|
|||||||
// Opt-in: without an askStatus the download path runs exactly as before.
|
// Opt-in: without an askStatus the download path runs exactly as before.
|
||||||
// (It must never be implicit — a probe that waits on a reply would hang
|
// (It must never be implicit — a probe that waits on a reply would hang
|
||||||
// forever if the caller injected a setTimeout that never fires.)
|
// forever if the caller injected a setTimeout that never fires.)
|
||||||
let precached = false;
|
let precached = false, offlineStatus;
|
||||||
if (waiting && askStatus) {
|
if (waiting && askStatus) {
|
||||||
try {
|
try {
|
||||||
const status = await askStatus(waiting);
|
const status = await askStatus(waiting);
|
||||||
precached = !!(status && status.ready);
|
precached = !!(status && status.ready);
|
||||||
|
offlineStatus = status;
|
||||||
} catch { precached = false; }
|
} catch { precached = false; }
|
||||||
}
|
}
|
||||||
if (onPhase) onPhase(precached ? 'ready' : 'downloading');
|
if (onPhase) onPhase(precached ? 'ready' : 'downloading', offlineStatus);
|
||||||
if (!precached && refreshShell) await refreshShell();
|
if (!precached && refreshShell) await refreshShell();
|
||||||
|
|
||||||
|
if (!guard()) throw new Error('Playing - update after this song / when paused');
|
||||||
if (!waiting) {
|
if (!waiting) {
|
||||||
reload();
|
reload();
|
||||||
return;
|
return;
|
||||||
@@ -216,7 +269,7 @@
|
|||||||
|
|
||||||
let reloaded = false;
|
let reloaded = false;
|
||||||
const reloadOnce = () => {
|
const reloadOnce = () => {
|
||||||
if (reloaded) return;
|
if (reloaded || (isPlaying() && !overridden)) return;
|
||||||
reloaded = true;
|
reloaded = true;
|
||||||
reload();
|
reload();
|
||||||
};
|
};
|
||||||
@@ -224,10 +277,29 @@
|
|||||||
// either way; reload on controllerchange, or after a short safety net.
|
// either way; reload on controllerchange, or after a short safety net.
|
||||||
container.addEventListener('controllerchange', reloadOnce, { once: true });
|
container.addEventListener('controllerchange', reloadOnce, { once: true });
|
||||||
if (scheduleTimeout) scheduleTimeout(reloadOnce, 4000);
|
if (scheduleTimeout) scheduleTimeout(reloadOnce, 4000);
|
||||||
|
if (overridden) waiting.postMessage({type:'PLAYING',value:false});
|
||||||
waiting.postMessage({ type: 'SKIP_WAITING' });
|
waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const SwUpdate = { applyUpdate, refreshShellInPlace, askCacheStatus, BUST_PARAM };
|
function showOfflineStatus(status) {
|
||||||
|
const el = root.document?.getElementById('offlineAssetStatus');
|
||||||
|
if (el && typeof status?.total === 'number') {
|
||||||
|
el.textContent = `${status.offlineReady ? 'Offline ready' : 'Preparing offline'} ${status.cached}/${status.total}`;
|
||||||
|
} else if (el && typeof status?.ready === 'boolean') {
|
||||||
|
el.textContent = status.ready ? 'Offline ready (legacy cache)' : 'Offline cache incomplete (legacy mode)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function offlineStatus() {
|
||||||
|
const registration = await root.navigator?.serviceWorker?.getRegistration();
|
||||||
|
const status = await askCacheStatus(registration?.active);
|
||||||
|
showOfflineStatus(status);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
root.navigator?.serviceWorker?.addEventListener?.('message', event => {
|
||||||
|
if (event.data?.type === 'OFFLINE_STATUS') showOfflineStatus(event.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
const SwUpdate = { offlineStatus, showOfflineStatus, applyUpdate, refreshShellInPlace, askCacheStatus, BUST_PARAM };
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
module.exports = SwUpdate;
|
module.exports = SwUpdate;
|
||||||
|
|||||||
@@ -349,3 +349,41 @@ test('askCacheStatus returns the worker reply', async () => {
|
|||||||
const got = await askCacheStatus(worker, { setTimeout: () => {}, Channel: FakeChannel });
|
const got = await askCacheStatus(worker, { setTimeout: () => {}, Channel: FakeChannel });
|
||||||
assert.deepStrictEqual(got, { ready: true, version: 'abc' });
|
assert.deepStrictEqual(got, { ready: true, version: 'abc' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('playback guard blocks activation and reload unless explicit override is accepted',async()=>{
|
||||||
|
let messages=[],reloads=0;const reg={waiting:{postMessage:m=>messages.push(m)}};
|
||||||
|
await assert.rejects(applyUpdate({reg,container:fakeContainer(),reload:()=>reloads++,isPlaying:()=>true,confirmOverride:()=>false}),/Playing/);
|
||||||
|
assert.equal(messages.length,0);assert.equal(reloads,0);
|
||||||
|
let playing=false;const container=fakeContainer();await applyUpdate({reg,container,reload:()=>reloads++,isPlaying:()=>playing,askStatus:async()=>({ready:true}),setTimeout:()=>{}});playing=true;container.fireControllerChange();assert.equal(reloads,0);
|
||||||
|
});
|
||||||
|
test('explicit playback override clears the worker guard and reloads once',async()=>{
|
||||||
|
const messages=[];let reloads=0;const container=fakeContainer();
|
||||||
|
await applyUpdate({reg:{waiting:{postMessage:m=>messages.push(m)}},container,reload:()=>reloads++,isPlaying:()=>true,confirmOverride:()=>true,askStatus:async()=>({ready:true}),setTimeout:()=>{}});
|
||||||
|
assert.deepStrictEqual(messages,[{type:'PLAYING',value:false},{type:'SKIP_WAITING'}]);
|
||||||
|
container.fireControllerChange();container.fireControllerChange();assert.equal(reloads,1);
|
||||||
|
});
|
||||||
|
test('an unanswered worker status cannot fall through to an empty legacy refresh',async()=>{
|
||||||
|
const vm=require('node:vm'),fs=require('node:fs');
|
||||||
|
const sandbox={AssetSyncCore:{CACHE:'ytplayer-assets'},navigator:{serviceWorker:{getRegistration:async()=>({active:{postMessage(){}}})}}};
|
||||||
|
vm.runInNewContext(fs.readFileSync(__dirname+'/sw-update.js','utf8'),sandbox);
|
||||||
|
await assert.rejects(sandbox.SwUpdate.refreshShellInPlace({cachesApi:{open:async()=>({}),keys:async()=>[]},fetchFn:async()=>({ok:true,json:async()=>({buildTag:'new'})}),isShellCache:()=>true}),/Worker update status unavailable/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('update readiness retains completion progress without blocking the ready fast path',async()=>{
|
||||||
|
const phases=[],container=fakeContainer();let downloaded=false;
|
||||||
|
const status={ready:true,offlineReady:false,cached:90,total:151,missingFiles:['/optional.js']};
|
||||||
|
await applyUpdate({reg:{waiting:{postMessage(){}}},container,askStatus:async()=>status,
|
||||||
|
onPhase:(phase,progress)=>phases.push({phase,progress}),refreshShell:async()=>{downloaded=true;},setTimeout:()=>{}});
|
||||||
|
assert.equal(downloaded,false);assert.equal(phases[0].phase,'ready');assert.deepStrictEqual(phases[0].progress,status);
|
||||||
|
});
|
||||||
|
test('offline cache status reports preparing and complete counts in Settings',()=>{
|
||||||
|
const vm=require('node:vm'),fs=require('node:fs'),element={};
|
||||||
|
const sandbox={document:{getElementById:()=>element}};
|
||||||
|
vm.runInNewContext(fs.readFileSync(__dirname+'/sw-update.js','utf8'),sandbox);
|
||||||
|
sandbox.SwUpdate.showOfflineStatus({offlineReady:false,cached:90,total:151});
|
||||||
|
assert.equal(element.textContent,'Preparing offline 90/151');
|
||||||
|
sandbox.SwUpdate.showOfflineStatus({offlineReady:true,cached:151,total:151});
|
||||||
|
assert.equal(element.textContent,'Offline ready 151/151');
|
||||||
|
sandbox.SwUpdate.showOfflineStatus({ready:true});assert.equal(element.textContent,'Offline ready (legacy cache)');
|
||||||
|
});
|
||||||
|
|||||||
234
frontend/sw.js
234
frontend/sw.js
@@ -2,7 +2,7 @@
|
|||||||
* sw.js — YT Player Service Worker
|
* sw.js — YT Player Service Worker
|
||||||
*
|
*
|
||||||
* Strategy:
|
* Strategy:
|
||||||
* App shell (HTML/CSS/JS) → cache-first, versioned cache
|
* App shell (HTML/CSS/JS) → cache-first, exact per-file hash keys
|
||||||
* /api/* requests → network-only (never cache yt-dlp results)
|
* /api/* requests → network-only (never cache yt-dlp results)
|
||||||
* /api/download/* → network-only (streamed binary, never cache)
|
* /api/download/* → network-only (streamed binary, never cache)
|
||||||
* YouTube thumbnails (i.ytimg.com) → stale-while-revalidate
|
* YouTube thumbnails (i.ytimg.com) → stale-while-revalidate
|
||||||
@@ -14,9 +14,9 @@
|
|||||||
* 2. The client (app.js) shows the "Update available" banner only when the
|
* 2. The client (app.js) shows the "Update available" banner only when the
|
||||||
* build it is running differs from the server's /api/version buildTag —
|
* build it is running differs from the server's /api/version buildTag —
|
||||||
* a waiting worker or a broadcast is merely a prompt to re-check.
|
* a waiting worker or a broadcast is merely a prompt to re-check.
|
||||||
* 3. "Refresh UI" (sw-update.js) re-downloads every shell file into every
|
* 3. Install syncs missing assets into ytplayer-assets. "Refresh UI" activates
|
||||||
* versioned shell cache (all-or-nothing), activates a waiting worker if
|
* the verified waiting worker (SKIP_WAITING) and reloads once. The legacy
|
||||||
* there is one (SKIP_WAITING), and reloads once.
|
* full-shell path remains available through ASSET_SYNC=0.
|
||||||
* 4. activate: if an older *versioned shell cache* is found (i.e. this
|
* 4. activate: if an older *versioned shell cache* is found (i.e. this
|
||||||
* activation is genuinely replacing a previous deploy, not just the
|
* activation is genuinely replacing a previous deploy, not just the
|
||||||
* first-ever install of a freshly (re)registered worker), broadcast
|
* first-ever install of a freshly (re)registered worker), broadcast
|
||||||
@@ -27,13 +27,15 @@
|
|||||||
// It changes on every deploy/restart so the cache is busted automatically
|
// It changes on every deploy/restart so the cache is busted automatically
|
||||||
// without any manual version bump.
|
// without any manual version bump.
|
||||||
const VERSION = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v1.0.4';
|
const VERSION = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v1.0.4';
|
||||||
|
const ASSET_SYNC = typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true;
|
||||||
|
if (ASSET_SYNC) importScripts('/asset-sync-core.js');
|
||||||
const CACHE = 'ytplayer-' + VERSION;
|
const CACHE = 'ytplayer-' + VERSION;
|
||||||
|
|
||||||
// Prefix shared by every versioned app-shell cache (ytplayer-<VERSION>).
|
// Prefix shared by every versioned app-shell cache (ytplayer-<VERSION>).
|
||||||
// Utility caches (ytplayer-thumbs, ytplayer-fonts) intentionally do NOT
|
// Utility caches (ytplayer-thumbs, ytplayer-fonts) intentionally do NOT
|
||||||
// match this — they aren't versioned and must survive every activate.
|
// match this — they aren't versioned and must survive every activate.
|
||||||
const SHELL_CACHE_PREFIX = 'ytplayer-';
|
const SHELL_CACHE_PREFIX = 'ytplayer-';
|
||||||
const UTILITY_CACHES = new Set(['ytplayer-thumbs', 'ytplayer-fonts']);
|
const UTILITY_CACHES = new Set(['ytplayer-thumbs', 'ytplayer-fonts', 'ytplayer-assets']);
|
||||||
const THUMB_CACHE = 'ytplayer-thumbs';
|
const THUMB_CACHE = 'ytplayer-thumbs';
|
||||||
|
|
||||||
// Hosts serving video thumbnails and channel avatars. yt-dlp hands back
|
// Hosts serving video thumbnails and channel avatars. yt-dlp hands back
|
||||||
@@ -58,7 +60,12 @@ const SHELL = [
|
|||||||
'/styles.css',
|
'/styles.css',
|
||||||
'/service-ui.css',
|
'/service-ui.css',
|
||||||
'/refresh.css',
|
'/refresh.css',
|
||||||
|
'/layout-base.css',
|
||||||
'/theme-glass.css',
|
'/theme-glass.css',
|
||||||
|
'/theme-bento-hub.css',
|
||||||
|
'/theme-f7.css',
|
||||||
|
'/fonts/PlusJakartaSans-latin.woff2',
|
||||||
|
'/fonts/PlusJakartaSans-latin-ext.woff2',
|
||||||
'/theme-glass-panel.css',
|
'/theme-glass-panel.css',
|
||||||
'/glass-panel-layout.js',
|
'/glass-panel-layout.js',
|
||||||
'/theme-classic.css',
|
'/theme-classic.css',
|
||||||
@@ -66,6 +73,12 @@ const SHELL = [
|
|||||||
'/flag-ui.js',
|
'/flag-ui.js',
|
||||||
'/export.js',
|
'/export.js',
|
||||||
'/async-guard.js',
|
'/async-guard.js',
|
||||||
|
'/visible-timer.js',
|
||||||
|
'/asset-sync-core.js',
|
||||||
|
'/lazy.js',
|
||||||
|
'/feature-bootstrap.js',
|
||||||
|
'/eq-parameters.js',
|
||||||
|
'/assets.json',
|
||||||
'/sw-update.js',
|
'/sw-update.js',
|
||||||
'/fingerprint.js',
|
'/fingerprint.js',
|
||||||
'/opfs.js',
|
'/opfs.js',
|
||||||
@@ -91,9 +104,19 @@ const SHELL = [
|
|||||||
'/p2p-transfer.js',
|
'/p2p-transfer.js',
|
||||||
'/p2p-recv-worker.js',
|
'/p2p-recv-worker.js',
|
||||||
'/settings-sections.js',
|
'/settings-sections.js',
|
||||||
|
'/vendor/jamesdsp/controller.js',
|
||||||
|
'/vendor/jamesdsp/jamesdsp-node.js',
|
||||||
|
'/vendor/jamesdsp/worklet.js',
|
||||||
|
'/vendor/jamesdsp/params.js',
|
||||||
|
'/vendor/jamesdsp/dsp/jamesdsp.js',
|
||||||
|
'/vendor/jamesdsp/dsp/jamesdsp.wasm',
|
||||||
|
'/related-videos.js',
|
||||||
|
'/related-videos.css',
|
||||||
'/audio-continuity.js',
|
'/audio-continuity.js',
|
||||||
'/fullscreen-orientation.js',
|
'/fullscreen-orientation.js',
|
||||||
'/fullscreen-orientation.css',
|
'/fullscreen-orientation.css',
|
||||||
|
'/download-actions.js',
|
||||||
|
'/download-actions.css',
|
||||||
'/download-errors.js',
|
'/download-errors.js',
|
||||||
'/downloads.js',
|
'/downloads.js',
|
||||||
'/downloads-page.js',
|
'/downloads-page.js',
|
||||||
@@ -101,6 +124,9 @@ const SHELL = [
|
|||||||
'/saved-page.js',
|
'/saved-page.js',
|
||||||
'/server-backfill.js',
|
'/server-backfill.js',
|
||||||
'/glass-controls.js',
|
'/glass-controls.js',
|
||||||
|
'/bento-hub.js',
|
||||||
|
'/f7-layout.js',
|
||||||
|
'/vendor/framework7-swipe.min.js',
|
||||||
'/theme-glass-controls.css',
|
'/theme-glass-controls.css',
|
||||||
'/saved-page.css',
|
'/saved-page.css',
|
||||||
'/party-dj.js',
|
'/party-dj.js',
|
||||||
@@ -113,9 +139,18 @@ const SHELL = [
|
|||||||
'/piano.js',
|
'/piano.js',
|
||||||
'/setlist-import.js',
|
'/setlist-import.js',
|
||||||
'/car-mode.js',
|
'/car-mode.js',
|
||||||
|
'/prewarm-next.js',
|
||||||
'/car-mode.css',
|
'/car-mode.css',
|
||||||
'/lower-third.css',
|
'/lower-third.css',
|
||||||
'/settings-sections.css',
|
'/settings-sections.css',
|
||||||
|
'/shell-core.js',
|
||||||
|
'/views-core.js',
|
||||||
|
'/section-rail.js',
|
||||||
|
'/presenter.js',
|
||||||
|
'/remote-client.js',
|
||||||
|
'/watch-party-client.js',
|
||||||
|
'/share-external.js',
|
||||||
|
'/stats-view.js',
|
||||||
'/app.js',
|
'/app.js',
|
||||||
'/manifest.webmanifest',
|
'/manifest.webmanifest',
|
||||||
'/icons/icon-192.png',
|
'/icons/icon-192.png',
|
||||||
@@ -133,7 +168,7 @@ const SHELL = [
|
|||||||
// skipWaiting() is NOT called here — the page activates a waiting worker
|
// skipWaiting() is NOT called here — the page activates a waiting worker
|
||||||
// from "Refresh UI" (see sw-update.js).
|
// from "Refresh UI" (see sw-update.js).
|
||||||
self.addEventListener('install', (e) => {
|
self.addEventListener('install', (e) => {
|
||||||
e.waitUntil(precacheShell());
|
e.waitUntil(ASSET_SYNC ? installAssets() : precacheShell());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fresh requests (`cache: 'reload'` skips the browser HTTP cache, which could
|
// Fresh requests (`cache: 'reload'` skips the browser HTTP cache, which could
|
||||||
@@ -167,6 +202,7 @@ async function precacheShell() {
|
|||||||
// ---- Activate: evict old caches, claim clients, notify about update ----
|
// ---- Activate: evict old caches, claim clients, notify about update ----
|
||||||
self.addEventListener('activate', (e) => {
|
self.addEventListener('activate', (e) => {
|
||||||
e.waitUntil((async () => {
|
e.waitUntil((async () => {
|
||||||
|
if (ASSET_SYNC) { await activateAssets(); return; }
|
||||||
const keys = await caches.keys();
|
const keys = await caches.keys();
|
||||||
|
|
||||||
// Was there a *previous deploy's* app-shell cache lying around? If so,
|
// Was there a *previous deploy's* app-shell cache lying around? If so,
|
||||||
@@ -234,6 +270,8 @@ self.addEventListener('fetch', (e) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ASSET_SYNC && request.mode === 'navigate') e.waitUntil(completeAssets());
|
||||||
|
|
||||||
// Only intercept GET/HEAD — let POST (sync endpoint) go through unmodified
|
// Only intercept GET/HEAD — let POST (sync endpoint) go through unmodified
|
||||||
if (request.method !== 'GET' && request.method !== 'HEAD') return;
|
if (request.method !== 'GET' && request.method !== 'HEAD') return;
|
||||||
|
|
||||||
@@ -265,13 +303,62 @@ self.addEventListener('fetch', (e) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// App shell → cache-first, then network, then generic offline fallback
|
// App shell → cache-first, then network, then generic offline fallback
|
||||||
e.respondWith(cacheFirst(request));
|
e.respondWith(cacheFirst(request, e.clientId));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Message: handle SKIP_WAITING from the client ----
|
// ---- Message: handle SKIP_WAITING from the client ----
|
||||||
|
const playingClients = new Map(), deferredClients = new Set();
|
||||||
|
let activeLayout = 'classic', layoutReply;
|
||||||
|
let completionTask;
|
||||||
|
function completeAssets(force = false) {
|
||||||
|
if (!ASSET_SYNC) return Promise.resolve();
|
||||||
|
completionTask ||= (async () => {
|
||||||
|
const cache = await caches.open(AssetSyncCore.CACHE);
|
||||||
|
const m = (await AssetSyncCore.state(cache))?.current;
|
||||||
|
if (!m || m.buildTag !== VERSION) return;
|
||||||
|
const job = await AssetSyncCore.complete(m, { cache, fetchFn: fetch, force,
|
||||||
|
notify: async status => {
|
||||||
|
for (const client of await self.clients.matchAll({ type: 'window' }))
|
||||||
|
client.postMessage({ type: 'OFFLINE_STATUS', ...status });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!job.offlineReady) {
|
||||||
|
// Chromium can wake this job without a page; Safari resumes on the next
|
||||||
|
// navigation/online message. No timers outside an event lifetime.
|
||||||
|
try { await self.registration.sync?.register('ytp-offline-complete'); } catch {}
|
||||||
|
}
|
||||||
|
})().catch(error => console.warn('[asset-sync] completion paused', error.message))
|
||||||
|
.finally(() => { completionTask = null; });
|
||||||
|
return completionTask;
|
||||||
|
}
|
||||||
|
self.addEventListener('sync', e => {
|
||||||
|
if (e.tag === 'ytp-offline-complete') e.waitUntil(completeAssets());
|
||||||
|
});
|
||||||
self.addEventListener('message', (e) => {
|
self.addEventListener('message', (e) => {
|
||||||
if (e.data && e.data.type === 'SKIP_WAITING') {
|
if (ASSET_SYNC && e.data?.type === 'LAYOUT' && e.source && typeof e.data.value === 'string') {
|
||||||
self.skipWaiting();
|
activeLayout = e.data.value;
|
||||||
|
layoutReply?.();
|
||||||
|
e.waitUntil((async()=>{
|
||||||
|
const cache=await caches.open(AssetSyncCore.CACHE);
|
||||||
|
await cache.put('/__ytp_layout',Response.json({layout:activeLayout}));
|
||||||
|
const m=await candidate(cache);
|
||||||
|
if(m && m.activeLayout!==activeLayout) {
|
||||||
|
await AssetSyncCore.syncAssets(m,{cache,fetchFn:fetch,activeLayout});
|
||||||
|
m.activeLayout=activeLayout; await cache.put(candidateKey,Response.json(m));
|
||||||
|
}
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
if (ASSET_SYNC && ['WARM_ASSETS', 'COMPLETE_ASSETS'].includes(e.data?.type)) {
|
||||||
|
e.waitUntil(completeAssets(e.data.type === 'COMPLETE_ASSETS'));
|
||||||
|
}
|
||||||
|
if (e.data && e.data.type === 'PLAYING' && e.source) {
|
||||||
|
playingClients.set(e.source.id, !!e.data.value);
|
||||||
|
if (!e.data.value && deferredClients.delete(e.source.id)) self.skipWaiting();
|
||||||
|
}
|
||||||
|
if (e.data && e.data.type === 'SKIP_WAITING' && e.source && e.source.id) {
|
||||||
|
const id = e.source && e.source.id;
|
||||||
|
if (playingClients.get(id)) deferredClients.add(id);
|
||||||
|
else self.skipWaiting();
|
||||||
}
|
}
|
||||||
// "Is your shell already downloaded?" — a waiting worker has precached the
|
// "Is your shell already downloaded?" — a waiting worker has precached the
|
||||||
// whole new build during install, so "Refresh UI" can swap to it instantly
|
// whole new build during install, so "Refresh UI" can swap to it instantly
|
||||||
@@ -279,7 +366,19 @@ self.addEventListener('message', (e) => {
|
|||||||
// deletes its cache on failure, but a cache can also be evicted under
|
// deletes its cache on failure, but a cache can also be evicted under
|
||||||
// storage pressure, so the files are actually checked.
|
// storage pressure, so the files are actually checked.
|
||||||
if (e.data && e.data.type === 'CACHE_STATUS') {
|
if (e.data && e.data.type === 'CACHE_STATUS') {
|
||||||
|
if (ASSET_SYNC) e.waitUntil(completeAssets());
|
||||||
e.waitUntil((async () => {
|
e.waitUntil((async () => {
|
||||||
|
if (ASSET_SYNC) {
|
||||||
|
let reply = { ready: false, missing: -1, version: VERSION };
|
||||||
|
try {
|
||||||
|
const cache = await caches.open(AssetSyncCore.CACHE), state = await AssetSyncCore.state(cache);
|
||||||
|
const pending = await candidate(cache), m = pending || state?.current;
|
||||||
|
if(m) reply = await AssetSyncCore.status(m,cache,{previous:pending ? state?.current : state?.previous,activeLayout:e.data.activeLayout || (pending ? m.activeLayout : activeLayout) || 'classic'});
|
||||||
|
} catch {}
|
||||||
|
reply.type = 'CACHE_STATUS'; reply.assetSync = true; reply.completing = !!completionTask && !reply.offlineReady;
|
||||||
|
if(e.ports && e.ports[0]) e.ports[0].postMessage(reply); else if(e.source) e.source.postMessage(reply);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let missing = SHELL.length;
|
let missing = SHELL.length;
|
||||||
try {
|
try {
|
||||||
const cache = await caches.open(CACHE);
|
const cache = await caches.open(CACHE);
|
||||||
@@ -298,7 +397,8 @@ self.addEventListener('message', (e) => {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// Cache-first: serve from cache; if missing, fetch, cache, return.
|
// Cache-first: serve from cache; if missing, fetch, cache, return.
|
||||||
async function cacheFirst(request) {
|
async function cacheFirst(request, clientId) {
|
||||||
|
if (ASSET_SYNC) return assetFetch(request, clientId);
|
||||||
const cache = await caches.open(CACHE);
|
const cache = await caches.open(CACHE);
|
||||||
// Shell files are precached under their plain URL; index.html asks for them as
|
// Shell files are precached under their plain URL; index.html asks for them as
|
||||||
// `file?v=<build>` (so the HTTP cache can keep them forever) — match ignoring it.
|
// `file?v=<build>` (so the HTTP cache can keep them forever) — match ignoring it.
|
||||||
@@ -404,3 +504,117 @@ async function staleWhileRevalidate(request, cacheName) {
|
|||||||
// surface a real network error instead.
|
// surface a real network error instead.
|
||||||
return cached || (await networkFetch) || Response.error();
|
return cached || (await networkFetch) || Response.error();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Installation stores a candidate only. Activation publishes one atomic state record.
|
||||||
|
const candidateKey = '/__ytp_candidate/' + VERSION;
|
||||||
|
async function candidate(cache) { const r = await cache.match(candidateKey); return r ? r.json() : null; }
|
||||||
|
async function installAssets() {
|
||||||
|
const r = await fetch('/api/manifest', { cache: 'no-store' });
|
||||||
|
if (!r.ok) throw Error('Manifest unavailable');
|
||||||
|
const m = await r.json();
|
||||||
|
if (m.buildTag !== VERSION) throw Error('Deployment changed during install');
|
||||||
|
const cache = await caches.open(AssetSyncCore.CACHE);
|
||||||
|
const remembered = await cache.match('/__ytp_layout');
|
||||||
|
if(remembered)activeLayout=(await remembered.json()).layout || 'classic';
|
||||||
|
let layoutKnown = !!remembered;
|
||||||
|
const clients = await self.clients.matchAll({type:'window',includeUncontrolled:true});
|
||||||
|
await new Promise(resolve=>{
|
||||||
|
const timer=setTimeout(resolve,500); layoutReply=()=>{layoutKnown=true;clearTimeout(timer);resolve();};
|
||||||
|
for(const client of clients)client.postMessage({type:'ASSET_LAYOUT_REQUEST'});
|
||||||
|
});
|
||||||
|
layoutReply=null; m.activeLayout=activeLayout;
|
||||||
|
const freshInstall = !(await AssetSyncCore.state(cache));
|
||||||
|
const fetchAsset = async (url,init) => {
|
||||||
|
// Fonts/icons still use plain URLs in CSS/manifest. On first install reuse
|
||||||
|
// their HTTP-cache responses, but only if the hash proves current bytes.
|
||||||
|
if(freshInstall && /^(?:\/fonts\/|\/icons\/|\/manifest.webmanifest)/.test(url)) {
|
||||||
|
const r = await fetch(url.split('?')[0],{...init,cache:'force-cache'});
|
||||||
|
if(r.ok && r.headers.get('X-Asset-Hash') === url.split('v=')[1]) return r;
|
||||||
|
}
|
||||||
|
return fetch(url,init);
|
||||||
|
};
|
||||||
|
await AssetSyncCore.syncAssets(m, { cache, fetchFn: fetchAsset, activeLayout });
|
||||||
|
// Older pages cannot report their saved choice. Keep every layout ready for
|
||||||
|
// that one migration so an immediate offline reload cannot lose its theme.
|
||||||
|
if(!layoutKnown && ((await AssetSyncCore.state(cache)) || (await caches.keys()).some(isVersionedShellCache))) {
|
||||||
|
const layouts=new Set(Object.keys(m.groups).filter(name=>name.startsWith('layout:')));
|
||||||
|
for(const name of layouts)for(const dependency of m.groups[name].dependencies || [])layouts.add(dependency);
|
||||||
|
await AssetSyncCore.syncAssets(m,{cache,fetchFn:fetchAsset,groups:[...layouts]});
|
||||||
|
}
|
||||||
|
await cache.put(candidateKey, new Response(JSON.stringify(m)));
|
||||||
|
}
|
||||||
|
async function migrateLegacy(cache, m) {
|
||||||
|
// Legacy responses lack hash headers. Verify their actual bytes locally before
|
||||||
|
// retaining them; this also preserves old whole-build URLs in already-open tabs.
|
||||||
|
const names = (await caches.keys()).filter(isVersionedShellCache);
|
||||||
|
let previous = null;
|
||||||
|
for (const name of names) {
|
||||||
|
const old = await caches.open(name);
|
||||||
|
const files = {}, legacyURLs = {};
|
||||||
|
for (const req of await old.keys()) {
|
||||||
|
const u = new URL(req.url), p = u.pathname === '/' ? '/index.html' : u.pathname;
|
||||||
|
if (!m.files[p]) continue;
|
||||||
|
const r = await old.match(req);
|
||||||
|
if (!r || !r.ok) continue;
|
||||||
|
const bytes = await r.clone().arrayBuffer();
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||||
|
const h = [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2,'0')).join('').slice(0,10);
|
||||||
|
const headers = new Headers(r.headers); headers.set('X-Asset-Hash', h);
|
||||||
|
files[p] = { h }; legacyURLs[p] = p + '?v=' + h;
|
||||||
|
await cache.put(legacyURLs[p], new Response(bytes, { headers }));
|
||||||
|
}
|
||||||
|
if (Object.keys(files).length) previous = { buildTag:name.slice(SHELL_CACHE_PREFIX.length),files,groups:{core:{files:Object.keys(files)}},legacyURLs };
|
||||||
|
}
|
||||||
|
if (previous && !(await AssetSyncCore.state(cache))) await cache.put(AssetSyncCore.STATE, new Response(JSON.stringify({current:previous})));
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
async function activateAssets() {
|
||||||
|
const cache = await caches.open(AssetSyncCore.CACHE), m = await candidate(cache);
|
||||||
|
if (!m) throw Error('Missing candidate manifest');
|
||||||
|
const names = await migrateLegacy(cache,m), old = await AssetSyncCore.state(cache);
|
||||||
|
const previousClients = (await self.clients.matchAll({type:'window'})).map(c=>c.id);
|
||||||
|
await AssetSyncCore.commit(m,cache,{previousClients,activeLayout:m.activeLayout || activeLayout});
|
||||||
|
// Legacy deletion occurs strictly after verified commit.
|
||||||
|
await Promise.all(names.map(n => caches.delete(n)));
|
||||||
|
for (const req of await cache.keys()) if(new URL(req.url).pathname.startsWith('/__ytp_candidate/')) await cache.delete(req);
|
||||||
|
await self.clients.claim();
|
||||||
|
if(old && old.current.buildTag !== m.buildTag) for(const c of await self.clients.matchAll({type:'window',includeUncontrolled:true})) c.postMessage({type:'SW_UPDATE_AVAILABLE',version:VERSION});
|
||||||
|
// Completion owns a message-event lifetime, not the activation barrier.
|
||||||
|
// In particular, a legacy client's four-second reload must not wait for it.
|
||||||
|
self.registration.active.postMessage({ type: 'COMPLETE_ASSETS' });
|
||||||
|
try { console.info('[asset-sync] storage', await self.navigator.storage.estimate()); } catch {}
|
||||||
|
}
|
||||||
|
async function assetFetch(request, clientId) {
|
||||||
|
const cache = await caches.open(AssetSyncCore.CACHE), state = await AssetSyncCore.state(cache);
|
||||||
|
if(!state) return fetch(request);
|
||||||
|
const u = new URL(request.url);
|
||||||
|
let m = state.current;
|
||||||
|
if(u.origin !== self.location.origin) return fetch(request);
|
||||||
|
if(u.searchParams.has('__ytpfresh')) return fetch(request);
|
||||||
|
const nav = request.mode === 'navigate' || u.pathname === '/' || u.pathname === '/index.html';
|
||||||
|
if(!nav && state.previousClients?.includes(clientId) && state.previous?.files[u.pathname]) m = state.previous;
|
||||||
|
let path = nav ? '/index.html' : u.pathname;
|
||||||
|
let key;
|
||||||
|
if(u.searchParams.has('v') && state.current.files[u.pathname]) {
|
||||||
|
key = u.pathname + u.search;
|
||||||
|
if(state.previous?.legacyURLs && u.searchParams.get('v') === state.previous.buildTag) key = state.previous.legacyURLs[path] || key;
|
||||||
|
} else if(m.files[path]) key = AssetSyncCore.url(path,m.files[path]);
|
||||||
|
if(key) {
|
||||||
|
const cached = await cache.match(key);
|
||||||
|
const h = new URL(key,self.location.origin).searchParams.get('v');
|
||||||
|
if(cached && cached.headers.get('X-Asset-Hash') === h) return cached;
|
||||||
|
if(state.current.files[path]?.h === h) {
|
||||||
|
const previousKey=AssetSyncCore.fallback(state.current,state.previous,path);
|
||||||
|
const previousResponse=previousKey && await cache.match(previousKey);
|
||||||
|
if(previousResponse && previousResponse.headers.get('X-Asset-Hash')===state.previous.files[path].h) {
|
||||||
|
// A stale body returned under a new URL must not poison the browser's
|
||||||
|
// resource cache either. Its decoded stream has no transport encoding.
|
||||||
|
const headers=new Headers(previousResponse.headers);headers.set('Cache-Control','no-store');
|
||||||
|
for(const name of ['Content-Encoding','Content-Length','Transfer-Encoding'])headers.delete(name);
|
||||||
|
return new Response(previousResponse.body,{status:previousResponse.status,headers});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try { const r = await fetch(key); if(r.ok && r.headers.get('X-Asset-Hash') !== h) return new Response('Asset version unavailable',{status:409}); if(r.ok && request.method !== 'HEAD') await cache.put(key,r.clone()); return r; } catch { return new Response('Offline',{status:503}); }
|
||||||
|
}
|
||||||
|
try { return await fetch(request); } catch { return new Response('Offline',{status:503}); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ function loadSw(initialCacheKeys, { fetchImpl } = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sandbox = {
|
const sandbox = {
|
||||||
|
__ASSET_SYNC__: false,
|
||||||
self: self_,
|
self: self_,
|
||||||
caches: caches_,
|
caches: caches_,
|
||||||
fetch: fetchImpl || (async () => { throw new Error('fetch not mocked'); }),
|
fetch: fetchImpl || (async () => { throw new Error('fetch not mocked'); }),
|
||||||
|
|||||||
363
frontend/theme-bento-hub.css
Normal file
363
frontend/theme-bento-hub.css
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
/* Bento Hub — obsidian tiles, solar controls and cyan listening cues.
|
||||||
|
Reference: docs/mockups/bento-hub. No shared-theme overrides live here. */
|
||||||
|
@font-face { font-family:'Plus Jakarta Sans'; font-style:normal; font-weight:400 800; font-display:swap; src:url('fonts/PlusJakartaSans-latin.woff2') format('woff2'); unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; }
|
||||||
|
@font-face { font-family:'Plus Jakarta Sans'; font-style:normal; font-weight:400 800; font-display:swap; src:url('fonts/PlusJakartaSans-latin-ext.woff2') format('woff2'); unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; }
|
||||||
|
html[data-layout="bento-hub"] {
|
||||||
|
--bg:#090b0e; --bg-1:#11151c; --bg-2:#19202d; --bg-3:#222c3e; --bg-glass:rgba(17,21,28,.85);
|
||||||
|
--line:rgba(255,255,255,.09); --line-soft:rgba(255,255,255,.04); --line-bright:rgba(255,255,255,.18);
|
||||||
|
--text:#f1f5f9; --text-2:#94a3b8; --text-dim:#8594aa; --on-accent:#090b0e;
|
||||||
|
--accent:#ffb703; --accent-bright:#ffc527; --accent-deep:#d97706; --accent-glow:rgba(255,183,3,.22);
|
||||||
|
--accent-sec:#00f5d4; --accent-sec-deep:#00bbf9; --danger:#ef4444; --danger-text:#ff9393; --danger-bg:rgba(239,68,68,.12); --success:#10b981;
|
||||||
|
--radius:16px; --radius-outer:16px; --radius-inner:10px; --radius-sm:6px; --tile-gap:16px;
|
||||||
|
--shadow-sm:0 2px 8px rgb(0 0 0/.3); --shadow-md:0 8px 24px rgb(0 0 0/.45); --shadow-lg:0 16px 40px rgb(0 0 0/.6); --shadow:var(--shadow-md);
|
||||||
|
--ui:'Plus Jakarta Sans',system-ui,sans-serif; --display:var(--ui); --mono:'JetBrains Mono',monospace; color-scheme:dark;
|
||||||
|
}
|
||||||
|
html[data-layout="bento-hub"][data-theme="light"] {
|
||||||
|
--bg:#f4f6f8; --bg-1:#fff; --bg-2:#e9ecef; --bg-3:#dee2e6; --bg-glass:rgba(255,255,255,.88);
|
||||||
|
--line:rgba(0,0,0,.08); --line-soft:rgba(0,0,0,.04); --line-bright:rgba(0,0,0,.16);
|
||||||
|
--text:#0f172a; --text-2:#475569; --text-dim:#64748b; --on-accent:#fff;
|
||||||
|
--accent:#b65f04; --accent-bright:#d97706; --accent-deep:#b45309; --accent-glow:rgba(217,119,6,.18);
|
||||||
|
--accent-sec:#0d9488; --accent-sec-deep:#0f766e; --danger-text:#b91c1c;
|
||||||
|
--shadow-sm:0 2px 6px rgb(0 0 0/.05); --shadow-md:0 8px 20px rgb(0 0 0/.08); --shadow-lg:0 16px 32px rgb(0 0 0/.12); color-scheme:light;
|
||||||
|
}
|
||||||
|
html[data-layout="bento-hub"][data-theme="contrast"] { --bg:#000; --bg-1:#080808; --bg-2:#161616; --line:#aaa; --line-soft:#666; --text:#fff; --text-2:#ddd; --text-dim:#bbb; }
|
||||||
|
html[data-layout="bento-hub"] body { background:var(--bg); color:var(--text); font-family:var(--ui); }
|
||||||
|
html[data-layout="bento-hub"] body::before { display:none; }
|
||||||
|
html[data-layout="bento-hub"] ::selection { background:var(--accent); color:var(--on-accent); }
|
||||||
|
html[data-layout="bento-hub"] :is(input,textarea) { caret-color:var(--accent-sec); }
|
||||||
|
html[data-layout="bento-hub"] * { scrollbar-color:var(--bg-3) var(--bg-1); scrollbar-width:thin; }
|
||||||
|
html[data-layout="bento-hub"] ::-webkit-scrollbar-thumb { background:var(--bg-3); border-radius:6px; }
|
||||||
|
html[data-layout="bento-hub"] :is(button,a,input,select,textarea,summary,[tabindex]):focus-visible { outline:2px solid var(--accent-sec); outline-offset:3px; }
|
||||||
|
html[data-layout="bento-hub"] :is(button,input,select,textarea) { font-family:var(--ui); }
|
||||||
|
html[data-layout="bento-hub"] :is(button,select,summary) { min-height:44px; }
|
||||||
|
html[data-layout="bento-hub"] button:disabled { opacity:.45; cursor:not-allowed; }
|
||||||
|
html[data-layout="bento-hub"] .glass-original-glyph,
|
||||||
|
html[data-layout="bento-hub"] .legacy-nav-icon { display:none; }
|
||||||
|
html[data-layout="bento-hub"] :is(.classic-nav-icon,.glass-control-icon) { display:inline-block; width:20px; height:20px; flex:none; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; vertical-align:middle; }
|
||||||
|
html[data-layout="bento-hub"] .sidebar { background:var(--bg-1); border-right:1px solid var(--line); padding:20px 16px; gap:16px; }
|
||||||
|
html[data-layout="bento-hub"] .brand { padding:0 0 16px; border-bottom:1px solid var(--line); gap:10px; font-size:18px; font-weight:800; letter-spacing:-.02em; }
|
||||||
|
html[data-layout="bento-hub"] .brand-mark { width:34px; height:34px; border-radius:10px; background:linear-gradient(135deg,var(--accent),var(--accent-deep)); color:var(--on-accent); box-shadow:var(--shadow-sm); }
|
||||||
|
html[data-layout="bento-hub"] .nav { gap:6px; }
|
||||||
|
html[data-layout="bento-hub"] .nav-item { display:flex; align-items:center; gap:12px; min-height:44px; padding:10px 12px; border:1px solid transparent; border-radius:10px; color:var(--text-2); font-size:13px; font-weight:600; }
|
||||||
|
html[data-layout="bento-hub"] .nav-item:hover { background:var(--bg-2); color:var(--text); }
|
||||||
|
html[data-layout="bento-hub"] .nav-item.active { background:var(--bg-2); border-color:var(--line-bright); color:var(--accent); box-shadow:inset 3px 0 var(--accent); }
|
||||||
|
html[data-layout="bento-hub"] .nav-item.active::before { display:none; }
|
||||||
|
html[data-layout="bento-hub"] :is(.nav-badge,.pl-count) { background:var(--bg-3); color:var(--text-2); font:600 11px var(--mono); }
|
||||||
|
html[data-layout="bento-hub"] .pl-header { font:700 12px var(--ui); color:var(--text-2); letter-spacing:0; }
|
||||||
|
html[data-layout="bento-hub"] .playlist-item { display:flex; align-items:center; gap:10px; min-height:44px; padding:10px; border:1px solid var(--line); border-radius:10px; background:var(--bg); margin:6px 0; }
|
||||||
|
html[data-layout="bento-hub"] .playlist-item.active { background:var(--bg-2); border-color:var(--accent); }
|
||||||
|
html[data-layout="bento-hub"] .pl-name { min-width:0; font-size:13px; }
|
||||||
|
html[data-layout="bento-hub"] .sidebar-footer { border-top:1px solid var(--line); color:var(--text-2); font-size:12px; }
|
||||||
|
html[data-layout="bento-hub"] .topbar { background:var(--bg-1); border-bottom:1px solid var(--line); padding:12px 20px; gap:12px; backdrop-filter:none; }
|
||||||
|
html[data-layout="bento-hub"] .search-form { max-width:640px; gap:8px; }
|
||||||
|
html[data-layout="bento-hub"] #searchInput { border:1px solid var(--line); border-radius:999px; background:var(--bg-2); padding:12px 18px; font-size:14px; min-height:44px; box-shadow:none; }
|
||||||
|
html[data-layout="bento-hub"] .search-btn { border:0; border-radius:999px; min-height:44px; background:var(--accent); color:var(--on-accent); font:700 13px var(--ui); box-shadow:none; }
|
||||||
|
html[data-layout="bento-hub"] :is(.sidebar-toggle,.icon-btn,.update-icon-btn) { min-width:44px; width:44px; min-height:44px; border-radius:10px; background:var(--bg-2); color:var(--text-2); }
|
||||||
|
html[data-layout="bento-hub"] .bottom-nav { height:calc(64px + env(safe-area-inset-bottom)); background:var(--bg-glass); border-top:1px solid var(--line); backdrop-filter:blur(16px); -webkit-backdrop-filter:blur(16px); padding:0 4px env(safe-area-inset-bottom); }
|
||||||
|
html[data-layout="bento-hub"] .bottom-nav-btn { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:4px; padding:8px 0; min-height:64px; color:var(--text-2); }
|
||||||
|
html[data-layout="bento-hub"] .bottom-nav-icon { display:grid; place-items:center; width:24px; height:24px; margin:0; transform:none; }
|
||||||
|
html[data-layout="bento-hub"] .bottom-nav-label { font-size:11px; font-weight:600; letter-spacing:0; }
|
||||||
|
html[data-layout="bento-hub"] .bottom-nav-btn.active { color:var(--accent); background:transparent; }
|
||||||
|
html[data-layout="bento-hub"] .bottom-nav-btn.active::before { content:''; position:absolute; top:0; width:24px; height:2px; border-radius:2px; background:var(--accent); }
|
||||||
|
html[data-layout="bento-hub"] .bottom-nav-badge { left:calc(50% + 6px); top:4px; margin:0; height:18px; min-width:18px; font:700 11px var(--mono); color:var(--on-accent); background:var(--accent); }
|
||||||
|
html[data-layout="bento-hub"] .player-pane { padding:20px; gap:16px; background:var(--bg); }
|
||||||
|
html[data-layout="bento-hub"] .list-pane { background:var(--bg); border-left:1px solid var(--line); width:440px; }
|
||||||
|
html[data-layout="bento-hub"] .player-placeholder { background:var(--bg); border:0; padding:0; }
|
||||||
|
html[data-layout="bento-hub"] .hero { padding:24px; background:var(--bg-1); border:1px solid var(--line); border-radius:16px; }
|
||||||
|
html[data-layout="bento-hub"] .hero-title { font:800 32px/1.2 var(--ui); color:var(--text); letter-spacing:-.03em; }
|
||||||
|
html[data-layout="bento-hub"] .hero-tagline { font-size:14px; color:var(--text-2); max-width:65ch; line-height:1.6; }
|
||||||
|
html[data-layout="bento-hub"] :is(.hero-pill,.chip) { background:var(--bg-2); color:var(--text-2); border:1px solid var(--line); border-radius:999px; font-size:12px; }
|
||||||
|
html[data-layout="bento-hub"] .hero-pill::before { background:var(--accent-sec); }
|
||||||
|
html[data-layout="bento-hub"] .home-pl { padding:0; }
|
||||||
|
html[data-layout="bento-hub"] .home-pl-title { font:800 22px var(--ui); color:var(--text); }
|
||||||
|
html[data-layout="bento-hub"] .home-pl-grid { grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:16px; padding:0 0 16px; }
|
||||||
|
html[data-layout="bento-hub"] .home-pl-card { padding:12px; border:1px solid var(--line); border-radius:16px; background:var(--bg-1); box-shadow:var(--shadow-sm); }
|
||||||
|
html[data-layout="bento-hub"] .home-pl-thumb { border-radius:10px; aspect-ratio:16/9; }
|
||||||
|
html[data-layout="bento-hub"] .home-pl-name { padding:10px 0 0; font:700 14px/1.4 var(--ui); }
|
||||||
|
html[data-layout="bento-hub"] .home-pl-count { padding:4px 0 0; font-size:12px; color:var(--text-2); }
|
||||||
|
html[data-layout="bento-hub"] .home-pl-play { width:44px; height:44px; color:var(--on-accent); background:var(--accent); border-radius:999px; }
|
||||||
|
html[data-layout="bento-hub"] :is(.home-pl-new,.list-actions button,.np-btn,.dl-saved-cta,.dl-saved-link) { display:inline-flex; align-items:center; justify-content:center; gap:8px; border:1px solid var(--line); border-radius:10px; padding:10px 14px; background:var(--bg-2); color:var(--text); font:600 13px var(--ui); }
|
||||||
|
html[data-layout="bento-hub"] :is(.home-pl-new,.list-actions .primary-btn,.primary-btn) { background:var(--accent); color:var(--on-accent); border-color:var(--accent); }
|
||||||
|
html[data-layout="bento-hub"] .recommendations { border:1px solid var(--line); border-radius:16px; background:var(--bg-1); padding:16px; }
|
||||||
|
html[data-layout="bento-hub"] .recommendations h2 { font:800 22px var(--ui); }
|
||||||
|
html[data-layout="bento-hub"] .rec-card { background:var(--bg-2); border:1px solid var(--line); border-radius:10px; }
|
||||||
|
html[data-layout="bento-hub"] .rec-title { font-size:13px; }
|
||||||
|
html[data-layout="bento-hub"] .rec-channel { font-size:12px; color:var(--text-2); }
|
||||||
|
html[data-layout="bento-hub"] .list-header { padding:20px 16px 12px; gap:12px; }
|
||||||
|
html[data-layout="bento-hub"] .list-header h2 { font:800 22px/1.3 var(--ui); color:var(--text); }
|
||||||
|
html[data-layout="bento-hub"] .list-filter-wrap { padding:0 16px 12px; }
|
||||||
|
html[data-layout="bento-hub"] .list-filter { min-height:44px; background:var(--bg-2); border:1px solid var(--line); border-radius:10px; font-size:13px; }
|
||||||
|
html[data-layout="bento-hub"] .cards { padding:4px 16px 24px; gap:12px; }
|
||||||
|
html[data-layout="bento-hub"] .cards[data-view="search"],
|
||||||
|
html[data-layout="bento-hub"] .cards[data-view="channel"],
|
||||||
|
html[data-layout="bento-hub"] .cards[data-view="library"] { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); align-content:start; gap:16px; }
|
||||||
|
html[data-layout="bento-hub"] .cards:is([data-view="search"],[data-view="channel"],[data-view="library"]) .card { display:flex; flex-direction:column; align-items:stretch; gap:10px; padding:12px; }
|
||||||
|
html[data-layout="bento-hub"] .cards:is([data-view="search"],[data-view="channel"],[data-view="library"]) .thumb { width:100%; aspect-ratio:16/9; }
|
||||||
|
html[data-layout="bento-hub"] .cards:is([data-view="search"],[data-view="channel"],[data-view="library"]) .card-actions { position:static; display:flex; justify-content:flex-end; }
|
||||||
|
html[data-layout="bento-hub"] .card { animation:none; border:1px solid var(--line); border-radius:16px; background:var(--bg-1); box-shadow:var(--shadow-sm); }
|
||||||
|
html[data-layout="bento-hub"] .card-title { font:700 13px/1.5 var(--ui); -webkit-line-clamp:2; }
|
||||||
|
html[data-layout="bento-hub"] .card-channel { font-size:12px; color:var(--text-2); }
|
||||||
|
html[data-layout="bento-hub"] .thumb { border-radius:10px; }
|
||||||
|
html[data-layout="bento-hub"] .duration { font:500 11px var(--mono); background:rgba(0,0,0,.8); }
|
||||||
|
html[data-layout="bento-hub"] .thumb::after { background:none; }
|
||||||
|
html[data-layout="bento-hub"] .card:hover .thumb img { transform:none; }
|
||||||
|
html[data-layout="bento-hub"] .card.playing { border-color:var(--accent-sec); background:var(--bg-2); }
|
||||||
|
html[data-layout="bento-hub"] .card.playing::after { background:var(--accent-sec); }
|
||||||
|
@media (hover:hover) {
|
||||||
|
html[data-layout="bento-hub"] :is(.home-pl-card,.cards[data-view="search"] .card):hover { transform:translateY(-2px); border-color:var(--accent); box-shadow:var(--shadow-md); transition:transform .18s ease-out,box-shadow .18s ease-out; }
|
||||||
|
}
|
||||||
|
@media (min-width:1024px) { html[data-layout="bento-hub"] .app { grid-template-columns:260px minmax(0,1fr); } }
|
||||||
|
@media (max-width:860px) {
|
||||||
|
html[data-layout="bento-hub"] .topbar { padding:12px; gap:8px; }
|
||||||
|
html[data-layout="bento-hub"] .player-pane { padding:12px; gap:12px; }
|
||||||
|
html[data-layout="bento-hub"] .list-pane { width:100%; border-left:0; }
|
||||||
|
html[data-layout="bento-hub"] .home-pl-grid { grid-template-columns:repeat(auto-fill,minmax(160px,1fr)); gap:12px; }
|
||||||
|
html[data-layout="bento-hub"] .home-pl-title { font-size:18px; }
|
||||||
|
html[data-layout="bento-hub"] .list-header { padding:16px 12px 12px; }
|
||||||
|
html[data-layout="bento-hub"] .list-header h2 { font-size:18px; }
|
||||||
|
html[data-layout="bento-hub"] .cards { padding-inline:12px; }
|
||||||
|
html[data-layout="bento-hub"] .cards:is([data-view="search"],[data-view="channel"],[data-view="library"]) { grid-template-columns:1fr; gap:12px; }
|
||||||
|
html[data-layout="bento-hub"] .hero-title { font-size:24px; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion:reduce) { html[data-layout="bento-hub"] *,html[data-layout="bento-hub"] *::before,html[data-layout="bento-hub"] *::after { animation:none!important; transition:none!important; } }
|
||||||
|
|
||||||
|
/* Actual playlist artwork keeps the reference's collage without synthetic data. */
|
||||||
|
html[data-layout="bento-hub"] .bento-collage { position:absolute; inset:0; display:grid; grid-template-columns:1fr 1fr; grid-template-rows:1fr 1fr; gap:2px; }
|
||||||
|
html[data-layout="bento-hub"] .bento-collage img { width:100%; height:100%; object-fit:cover; }
|
||||||
|
html[data-layout="bento-hub"] .home-pl-thumb:has(.bento-collage) > img { visibility:hidden; }
|
||||||
|
html[data-layout="bento-hub"] .cards:is([data-view="search"],[data-view="channel"],[data-view="library"]) .card-menu { align-self:flex-end; }
|
||||||
|
@media(max-width:860px) { html[data-layout="bento-hub"] .bottom-nav { display:flex; } }
|
||||||
|
html[data-layout="bento-hub"] body.service-open .bottom-nav { display:none; }
|
||||||
|
|
||||||
|
/* Library collections — compact rows, deliberate actions and truthful status. */
|
||||||
|
html[data-layout="bento-hub"] .cards:not([data-view="search"]):not([data-view="channel"]):not([data-view="library"]) .card { padding:12px; gap:12px; align-items:center; }
|
||||||
|
html[data-layout="bento-hub"] :is(.card-menu,.card-del,.card-note-btn,.card-act,.list-icon-btn,.saved-file-action) { position:static; flex:none; display:inline-flex; align-items:center; justify-content:center; width:44px; height:44px; min-width:44px; margin:0; padding:10px; border:1px solid var(--line); border-radius:10px; background:var(--bg-2); color:var(--text-2); opacity:1; }
|
||||||
|
html[data-layout="bento-hub"] :is(.card-menu,.list-icon-btn):hover { border-color:var(--accent); color:var(--accent); background:var(--bg-3); }
|
||||||
|
html[data-layout="bento-hub"] :is(.saved-clear,.card-del,.saved-file-action.danger,.list-icon-btn.danger) { color:var(--danger-text); background:var(--danger-bg); border-color:transparent; }
|
||||||
|
html[data-layout="bento-hub"] :is(.saved-clear,.card-del,.saved-file-action.danger):hover { border-color:var(--danger-text); color:var(--danger-text); }
|
||||||
|
html[data-layout="bento-hub"] :is(.saved-storage-summary,.dl-summary) { padding:12px 0 16px; color:var(--text-2); font-size:13px; }
|
||||||
|
html[data-layout="bento-hub"] .saved-clear { border-radius:10px; padding:10px 12px; font-size:13px; }
|
||||||
|
html[data-layout="bento-hub"] .card.saved-card { grid-template-columns:72px minmax(0,1fr) 96px; min-height:96px; }
|
||||||
|
html[data-layout="bento-hub"] .saved-file-actions { gap:8px; }
|
||||||
|
html[data-layout="bento-hub"] :is(.card .dur,.upnext-count,.saved-storage-text,.set-stat) { font-family:var(--mono); font-size:12px; font-variant-numeric:tabular-nums; }
|
||||||
|
html[data-layout="bento-hub"] .card.playing::after { content:''; width:6px; height:6px; top:8px; right:8px; border-radius:50%; background:var(--accent-sec); }
|
||||||
|
html[data-layout="bento-hub"] .saved-badge { color:var(--accent-sec); background:var(--bg-1); border:1px solid var(--line); }
|
||||||
|
html[data-layout="bento-hub"] .pl-offline-badge { color:var(--accent-sec); font-size:11px; }
|
||||||
|
html[data-layout="bento-hub"] .batch-bar { background:var(--bg-2); border:1px solid var(--accent); border-radius:10px; margin:0 16px 12px; padding:12px; gap:8px; color:var(--accent); box-shadow:var(--shadow-sm); }
|
||||||
|
html[data-layout="bento-hub"] .batch-bar button { min-width:44px; background:var(--bg-1); color:var(--text); border:1px solid var(--line); border-radius:10px; font-size:13px; }
|
||||||
|
html[data-layout="bento-hub"] .batch-bar .danger { color:var(--danger-text); background:var(--danger-bg); }
|
||||||
|
html[data-layout="bento-hub"] .card.selected { border-color:var(--accent); background:var(--bg-2); }
|
||||||
|
html[data-layout="bento-hub"] .batch-check { accent-color:var(--accent); }
|
||||||
|
html[data-layout="bento-hub"] .dl-group { margin-bottom:24px; }
|
||||||
|
html[data-layout="bento-hub"] .dl-group-title { font:700 16px var(--ui); margin:16px 0 10px; }
|
||||||
|
html[data-layout="bento-hub"] .card.dl-row { grid-template-columns:72px minmax(0,1fr); padding:16px; border-radius:10px; gap:12px; }
|
||||||
|
html[data-layout="bento-hub"] .card.dl-failed { border-color:color-mix(in srgb,var(--danger) 40%,var(--line)); }
|
||||||
|
html[data-layout="bento-hub"] .dl-failed .card-channel { color:var(--danger-text); }
|
||||||
|
html[data-layout="bento-hub"] .dl-progress { height:4px; border-radius:999px; overflow:hidden; background:var(--bg-3); }
|
||||||
|
html[data-layout="bento-hub"] .dl-bar { background:var(--accent-sec); }
|
||||||
|
html[data-layout="bento-hub"] :is(.dl-row,.download-job) .download-action { border-radius:10px; border-color:var(--line); background:var(--bg-2); color:var(--text); font-size:13px; }
|
||||||
|
html[data-layout="bento-hub"] :is(.dl-row,.download-job) .download-action[aria-label^="Retry "] { background:var(--accent); color:var(--on-accent); border-color:var(--accent); }
|
||||||
|
html[data-layout="bento-hub"] :is(.dl-row,.download-job) .download-action.danger { background:var(--danger-bg); color:var(--danger-text); border-color:transparent; }
|
||||||
|
html[data-layout="bento-hub"] :is(.dl-row,.download-job) .download-action:hover { border-color:var(--line-bright); filter:brightness(1.08); }
|
||||||
|
html[data-layout="bento-hub"] .download-error-details { font-size:12px; }
|
||||||
|
html[data-layout="bento-hub"] .download-error-details summary { color:var(--text-2); }
|
||||||
|
html[data-layout="bento-hub"] .download-storage { border-top:1px solid var(--line); padding-top:16px; }
|
||||||
|
html[data-layout="bento-hub"] :is(.channel-header,.saved-search-item,.p2p-row,.notes-video,.stat-card) { border:1px solid var(--line); border-radius:16px; background:var(--bg-1); padding:16px; }
|
||||||
|
html[data-layout="bento-hub"] :is(.channel-header h2,.notes-video-title) { color:var(--text); font:700 18px var(--ui); }
|
||||||
|
@media (max-width:640px) {
|
||||||
|
html[data-layout="bento-hub"] .cards:not([data-view="search"]):not([data-view="channel"]):not([data-view="library"]) .card:not(.saved-card):not(.dl-row) { display:grid; grid-template-columns:72px minmax(0,1fr) 44px; gap:10px; }
|
||||||
|
html[data-layout="bento-hub"] .cards:not([data-view="search"]):not([data-view="channel"]):not([data-view="library"]) .card:not(.saved-card):not(.dl-row) .thumb { width:72px; grid-column:1; grid-row:1; }
|
||||||
|
html[data-layout="bento-hub"] .cards:not([data-view="search"]):not([data-view="channel"]):not([data-view="library"]) .card:not(.saved-card):not(.dl-row) .card-info { grid-column:2; grid-row:1; }
|
||||||
|
html[data-layout="bento-hub"] .card-menu { grid-column:3; grid-row:1; }
|
||||||
|
html[data-layout="bento-hub"] :is(.card-del,.card-note-btn) { grid-column:3; }
|
||||||
|
html[data-layout="bento-hub"] .card.saved-card { gap:8px; padding:10px; grid-template-columns:56px minmax(0,1fr) 96px; }
|
||||||
|
html[data-layout="bento-hub"] .saved-card .thumb { width:56px; }
|
||||||
|
html[data-layout="bento-hub"] .card.dl-row { padding:12px; grid-template-columns:56px minmax(0,1fr); }
|
||||||
|
html[data-layout="bento-hub"] .dl-row .thumb { width:56px; }
|
||||||
|
html[data-layout="bento-hub"] .batch-bar { margin-inline:12px; flex-wrap:wrap; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Now Playing: a solar transport deck with cyan listening cues. */
|
||||||
|
html[data-layout="bento-hub"] .player-stage { border-radius:16px; border:1px solid var(--line-bright); background:#000; box-shadow:var(--shadow-md); }
|
||||||
|
html[data-layout="bento-hub"] .player-pane.empty .player-stage { border:0; border-radius:0; box-shadow:none; }
|
||||||
|
html[data-layout="bento-hub"] :is(#controls,.now-meta,.notes-panel,.upnext,.related-panel) { flex-shrink:0; padding:16px; border:1px solid var(--line); border-radius:16px; background:var(--bg-1); box-shadow:var(--shadow-sm); }
|
||||||
|
html[data-layout="bento-hub"] #controls { gap:12px; }
|
||||||
|
html[data-layout="bento-hub"] #controls .deck-main { gap:8px; }
|
||||||
|
html[data-layout="bento-hub"] #controls .deck-main .ctrl { width:44px; height:44px; min-width:44px; padding:10px; background:var(--bg-2); color:var(--text); border:1px solid var(--line); border-radius:16px; box-shadow:none; }
|
||||||
|
html[data-layout="bento-hub"] #controls .deck-main .ctrl.play { width:56px; height:56px; padding:14px; background:var(--accent); color:var(--on-accent); border:0; box-shadow:0 6px 18px -4px var(--accent-glow); }
|
||||||
|
html[data-layout="bento-hub"] #controls .deck-main .ctrl.play svg { width:28px; height:28px; }
|
||||||
|
html[data-layout="bento-hub"] #controls .deck-main .ctrl.active { color:var(--accent-sec); border-color:var(--accent-sec); }
|
||||||
|
html[data-layout="bento-hub"] :is(.time,.mini-time,.vol-pct,.lyr-time,.marker-time) { font:500 12px var(--mono); color:var(--text-2); }
|
||||||
|
html[data-layout="bento-hub"] #controls :is(.dk-more,.ctrl:not(.play)) { background:var(--bg-2); border-radius:10px; color:var(--text-2); }
|
||||||
|
html[data-layout="bento-hub"] :is(.dk-more small,.dk-more i,.np-more small) { color:var(--text-2); font-size:12px; letter-spacing:0; }
|
||||||
|
html[data-layout="bento-hub"] .np-title { font:800 22px/1.4 var(--ui); letter-spacing:-.02em; }
|
||||||
|
html[data-layout="bento-hub"] .np-channel { font-size:13px; color:var(--text-2); }
|
||||||
|
html[data-layout="bento-hub"] .np-actions { gap:8px; }
|
||||||
|
html[data-layout="bento-hub"] .np-btn svg { width:20px; height:20px; stroke-width:1.8; }
|
||||||
|
html[data-layout="bento-hub"] .np-more { background:var(--bg-2); border:1px solid var(--line); border-radius:10px; color:var(--text); }
|
||||||
|
html[data-layout="bento-hub"] #notesPanel { padding:0; overflow:hidden; container-type:inline-size; container-name:bento-notes; }
|
||||||
|
html[data-layout="bento-hub"] .notes-tabs { display:flex; flex-wrap:nowrap; align-items:center; gap:4px; padding:8px; border-bottom:1px solid var(--line); background:var(--bg-1); }
|
||||||
|
html[data-layout="bento-hub"] .notes-tab { display:inline-flex; align-items:center; gap:8px; min-width:44px; min-height:44px; flex:0 1 auto; padding:10px; border:1px solid transparent; border-radius:10px; background:transparent; color:var(--text-2); font:600 12px var(--ui); text-transform:none; letter-spacing:0; white-space:nowrap; }
|
||||||
|
html[data-layout="bento-hub"] .notes-tab.active { background:var(--bg-2); color:var(--accent-sec); border-color:var(--line); }
|
||||||
|
html[data-layout="bento-hub"] .glass-tab-label { overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
html[data-layout="bento-hub"] .notes-status { min-width:0; flex:1 1 0; font-size:11px; }
|
||||||
|
html[data-layout="bento-hub"] .notes-fold { display:grid; place-items:center; min-width:44px; width:44px; height:44px; margin-left:auto; border-radius:10px; background:var(--bg-2); color:var(--text-2); }
|
||||||
|
html[data-layout="bento-hub"] .notes-pane { padding:12px; }
|
||||||
|
html[data-layout="bento-hub"] :is(.lyrics-toolbar,.markers-toolbar) { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:12px; }
|
||||||
|
html[data-layout="bento-hub"] .lyrics-list { padding:0; max-height:320px; }
|
||||||
|
html[data-layout="bento-hub"] .lyr-line { padding:12px; border-radius:10px; border-left:3px solid transparent; color:var(--text-2); font:500 14px/1.6 var(--ui); }
|
||||||
|
html[data-layout="bento-hub"] .lyr-line.active { border-left-color:var(--accent-sec); background:color-mix(in srgb,var(--accent-sec) 8%,transparent); color:var(--accent-sec); font-weight:700; }
|
||||||
|
html[data-layout="bento-hub"] :is(.lyr-off-btn,.marker-row,.ts-query,.ts-source) { min-height:44px; border:1px solid var(--line); border-radius:10px; color:var(--text); background:var(--bg-2); }
|
||||||
|
html[data-layout="bento-hub"] .upnext-header { color:var(--text); font:700 16px var(--ui); text-transform:none; letter-spacing:0; }
|
||||||
|
html[data-layout="bento-hub"] .upnext-count { background:var(--bg-2); color:var(--accent-sec); }
|
||||||
|
html[data-layout="bento-hub"] .upnext-list { display:flex; flex-direction:column; gap:8px; }
|
||||||
|
html[data-layout="bento-hub"] .upnext-item { min-height:64px; padding:8px; border:1px solid var(--line); border-radius:10px; background:var(--bg-2); }
|
||||||
|
html[data-layout="bento-hub"] .upnext-title { font-size:13px; font-weight:700; }
|
||||||
|
html[data-layout="bento-hub"] .upnext-channel { color:var(--text-2); font-size:12px; }
|
||||||
|
html[data-layout="bento-hub"] .mini-bar { bottom:12px; left:calc(260px + 16px); right:16px; border:1px solid var(--line-bright); border-radius:16px; overflow:hidden; background:var(--bg-glass); backdrop-filter:blur(16px); -webkit-backdrop-filter:blur(16px); box-shadow:var(--shadow-lg); }
|
||||||
|
html[data-layout="bento-hub"] .mini-bar-inner { padding:10px 12px; gap:10px; border:0; background:transparent; backdrop-filter:none; }
|
||||||
|
html[data-layout="bento-hub"] .mini-btn { display:grid; place-items:center; width:44px; height:44px; min-width:44px; border-radius:999px; color:var(--text-2); border:1px solid var(--line); background:var(--bg-2); }
|
||||||
|
html[data-layout="bento-hub"] #miniPlayBtn { background:var(--accent); color:var(--on-accent); border-color:var(--accent); }
|
||||||
|
html[data-layout="bento-hub"] .mini-title { color:var(--text); font:700 13px var(--ui); }
|
||||||
|
html[data-layout="bento-hub"] .mini-channel { color:var(--text-2); font-size:12px; }
|
||||||
|
html[data-layout="bento-hub"] .mini-progress { height:3px; margin:0; background:var(--bg-3); }
|
||||||
|
html[data-layout="bento-hub"] .mini-progress-fill { background:var(--accent-sec); }
|
||||||
|
html[data-layout="bento-hub"] :is(.player-pane,.cards) { padding-bottom:var(--bento-bottom-clearance,16px); scroll-padding-bottom:var(--bento-bottom-clearance,16px); }
|
||||||
|
html[data-layout="bento-hub"] .sec-rail,
|
||||||
|
html[data-layout="bento-hub"] body:has(#miniBar:not(.hidden)) .sec-rail { position:fixed; top:auto; right:auto; left:50%; bottom:var(--rail-bottom,160px); transform:translateX(-50%); flex-direction:row; padding:6px; gap:4px; border-radius:999px; border:1px solid var(--line-bright); background:var(--bg-glass); }
|
||||||
|
html[data-layout="bento-hub"] .sec-rail-btn { display:grid; place-items:center; min-width:44px; width:44px; height:44px; border-radius:999px; background:transparent; color:var(--text-2); }
|
||||||
|
html[data-layout="bento-hub"] .sec-rail-btn.active { color:var(--accent); background:var(--bg-2); box-shadow:none; }
|
||||||
|
html[data-layout="bento-hub"] .sec-rail-label { left:50%; transform:translateX(-50%); color:var(--text); background:var(--bg-2); font:600 12px var(--ui); }
|
||||||
|
@container bento-notes (max-width:460px) { html[data-layout="bento-hub"] .notes-tab[data-tab="markers"] .glass-tab-label { display:none; } }
|
||||||
|
@container bento-notes (max-width:330px) { html[data-layout="bento-hub"] .notes-tab .glass-tab-label { display:none; } }
|
||||||
|
@media(max-width:860px) {
|
||||||
|
html[data-layout="bento-hub"] .mini-bar { left:12px; right:12px; bottom:calc(64px + env(safe-area-inset-bottom) + 8px); }
|
||||||
|
html[data-layout="bento-hub"] .mini-time { display:none; }
|
||||||
|
html[data-layout="bento-hub"] .body { padding-bottom:var(--bento-bottom-clearance,96px); scroll-padding-bottom:var(--bento-bottom-clearance,96px); }
|
||||||
|
html[data-layout="bento-hub"] :is(.player-pane,.cards) { padding-bottom:16px; }
|
||||||
|
html[data-layout="bento-hub"] :is(#controls,.now-meta,.upnext,.related-panel) { padding:12px; }
|
||||||
|
html[data-layout="bento-hub"] .np-title { font-size:18px; }
|
||||||
|
}
|
||||||
|
/* Service stays a full-screen performance surface, with the user's text scale. */
|
||||||
|
html[data-layout="bento-hub"] .service-mode { background:#05070a; color:#f1f5f9; --svc-ink:#f1f5f9; --svc-ink-2:#94a3b8; --svc-cyan:#00f5d4; }
|
||||||
|
html[data-layout="bento-hub"] .service-mode::before { display:none; }
|
||||||
|
html[data-layout="bento-hub"] :is(.svc-dock,.svc-tile,.svc-exit,.svc-font,.svc-wake) { background:#11151c; border-color:rgba(255,255,255,.09); color:#94a3b8; border-radius:10px; }
|
||||||
|
html[data-layout="bento-hub"] .svc-wake.awake { color:#00f5d4; }
|
||||||
|
html[data-layout="bento-hub"] .svc-label { font-size:12px; }
|
||||||
|
html[data-layout="bento-hub"] .service-title { font:800 clamp(26px,3vw,36px)/1.3 var(--ui); color:#f1f5f9; }
|
||||||
|
html[data-layout="bento-hub"] .service-channel { color:#94a3b8; font-size:14px; }
|
||||||
|
html[data-layout="bento-hub"] .service-lyrics .sl-line.active { color:#00f5d4; text-shadow:none; }
|
||||||
|
html[data-layout="bento-hub"] .service-btn { width:56px; height:56px; border:1px solid rgba(255,255,255,.09); border-radius:999px; background:#19202d; color:#f1f5f9; box-shadow:none; }
|
||||||
|
html[data-layout="bento-hub"] .service-btn-play { width:64px; height:64px; background:#ffb703; color:#090b0e; border:0; }
|
||||||
|
html[data-layout="bento-hub"] .svc-progress > i { background:#00f5d4; }
|
||||||
|
html[data-layout="bento-hub"] .service-time { font:500 12px var(--mono); color:#94a3b8; }
|
||||||
|
|
||||||
|
/* Settings, sheets and empty states use the same tile and type system. */
|
||||||
|
html[data-layout="bento-hub"] .settings-browser { padding-top:8px; }
|
||||||
|
html[data-layout="bento-hub"] .settings-search input { min-height:44px; padding:12px 16px; border-radius:999px; border-color:var(--line); background:var(--bg-2); font-size:14px; }
|
||||||
|
html[data-layout="bento-hub"] .settings-overview h3 { margin:20px 0 10px; font:700 13px var(--ui); letter-spacing:0; color:var(--text-2); }
|
||||||
|
html[data-layout="bento-hub"] .settings-section-row { padding:14px; gap:12px; min-height:72px; margin-bottom:10px; border:1px solid var(--line); border-radius:16px; background:var(--bg-1); }
|
||||||
|
html[data-layout="bento-hub"] .settings-section-row svg { width:24px; height:24px; color:var(--accent); stroke-width:1.8; }
|
||||||
|
html[data-layout="bento-hub"] .settings-section-row b { font:700 14px var(--ui); }
|
||||||
|
html[data-layout="bento-hub"] .settings-section-row small { color:var(--text-2); font-size:12px; }
|
||||||
|
html[data-layout="bento-hub"] .settings-section-row[aria-current="true"] { background:var(--bg-2); border-color:var(--accent); }
|
||||||
|
html[data-layout="bento-hub"] .settings-back { border-radius:10px; background:var(--bg-2); color:var(--text); font-size:13px; }
|
||||||
|
html[data-layout="bento-hub"] .set-group { padding:16px; border:1px solid var(--line); border-radius:16px; background:var(--bg-1); margin-bottom:16px; }
|
||||||
|
html[data-layout="bento-hub"] .set-group-title { padding:0 0 12px; font:700 16px var(--ui); color:var(--text); text-transform:none; letter-spacing:0; }
|
||||||
|
html[data-layout="bento-hub"] .set-row { padding:14px 0; gap:12px; border-color:var(--line); }
|
||||||
|
html[data-layout="bento-hub"] .set-row > span { font-size:14px; }
|
||||||
|
html[data-layout="bento-hub"] .set-row small { color:var(--text-2); font-size:12px; line-height:1.6; }
|
||||||
|
html[data-layout="bento-hub"] :is(.set-select,.set-input,.rename-input,.ts-query,.ts-source,.modal input,.modal textarea,.modal select) { min-height:44px; border:1px solid var(--line); border-radius:10px; background:var(--bg-2); color:var(--text); font-size:14px; box-shadow:none; }
|
||||||
|
html[data-layout="bento-hub"] input[type="range"] { accent-color:var(--accent); }
|
||||||
|
html[data-layout="bento-hub"] input[type="checkbox"] { accent-color:var(--accent-sec); }
|
||||||
|
html[data-layout="bento-hub"] .download-job { padding:16px; border:1px solid var(--line); border-radius:10px; background:var(--bg-2); margin:12px 0; }
|
||||||
|
html[data-layout="bento-hub"] .download-job b { font-size:13px; }
|
||||||
|
html[data-layout="bento-hub"] .download-job small { color:var(--text-2); font-size:12px; }
|
||||||
|
html[data-layout="bento-hub"] .download-job progress { height:6px; accent-color:var(--accent-sec); }
|
||||||
|
html[data-layout="bento-hub"] .modal-backdrop { background:rgba(0,0,0,.75); backdrop-filter:blur(8px); -webkit-backdrop-filter:blur(8px); }
|
||||||
|
html[data-layout="bento-hub"] .modal { padding:24px; border:1px solid var(--line-bright); border-radius:16px; background:var(--bg-1); box-shadow:var(--shadow-lg); }
|
||||||
|
html[data-layout="bento-hub"] .modal h3 { font:800 22px/1.3 var(--ui); color:var(--text); }
|
||||||
|
html[data-layout="bento-hub"] :is(.modal p,.modal-subhead,.modal-char-count) { color:var(--text-2); font-size:13px; line-height:1.6; }
|
||||||
|
html[data-layout="bento-hub"] :is(.modal-list button,.modal-actions button,.sp-chip,.set-actions button,.piano-actions button,.setlist-import button) { min-height:44px; padding:10px 14px; border:1px solid var(--line); border-radius:10px; background:var(--bg-2); color:var(--text); font-size:13px; }
|
||||||
|
html[data-layout="bento-hub"] .modal-actions .primary-btn { background:var(--accent); color:var(--on-accent); border-color:var(--accent); }
|
||||||
|
html[data-layout="bento-hub"] .modal-actions .danger { color:var(--danger-text); background:var(--danger-bg); border-color:transparent; }
|
||||||
|
html[data-layout="bento-hub"] .sp-chip.on { color:var(--on-accent); background:var(--accent); }
|
||||||
|
html[data-layout="bento-hub"] .toast { border:1px solid var(--line-bright); border-radius:10px; background:var(--bg-2); color:var(--text); box-shadow:var(--shadow-md); font-size:13px; }
|
||||||
|
html[data-layout="bento-hub"] .empty-state { width:100%; max-width:440px; padding:32px 24px; margin:24px auto; border:1px solid var(--line); border-radius:16px; background:var(--bg-1); }
|
||||||
|
html[data-layout="bento-hub"] .empty-icon { display:grid; place-items:center; width:56px; height:56px; border:1px solid var(--line); border-radius:999px; background:var(--bg-2); color:var(--accent-sec); box-shadow:none; transform:none; }
|
||||||
|
html[data-layout="bento-hub"] .empty-icon svg { width:24px; height:24px; }
|
||||||
|
html[data-layout="bento-hub"] .empty-title { font:800 18px/1.3 var(--ui); color:var(--text); }
|
||||||
|
html[data-layout="bento-hub"] :is(.empty-desc,.dl-how-to-save,.dl-storage-note) { font:400 13px/1.6 var(--ui); color:var(--text-2); }
|
||||||
|
html[data-layout="bento-hub"] .empty-cta { min-height:44px; padding:10px 18px; border:0; border-radius:10px; background:var(--accent); color:var(--on-accent); font-weight:700; }
|
||||||
|
html[data-layout="bento-hub"] .status { border-radius:10px; color:var(--text-2); font-size:14px; }
|
||||||
|
@media(max-width:860px) {
|
||||||
|
html[data-layout="bento-hub"] .modal { padding:20px 16px; }
|
||||||
|
html[data-layout="bento-hub"] .modal h3 { font-size:18px; }
|
||||||
|
html[data-layout="bento-hub"] .set-row { flex-wrap:wrap; }
|
||||||
|
html[data-layout="bento-hub"] .set-row > span { flex:1 1 180px; }
|
||||||
|
html[data-layout="bento-hub"] .set-row :is(select,input[type="text"],input[type="password"]) { max-width:100%; }
|
||||||
|
html[data-layout="bento-hub"] .empty-state { padding:24px 16px; }
|
||||||
|
}
|
||||||
|
/* Browsing is a full workspace; playback continues in the floating mini player. */
|
||||||
|
html[data-layout="bento-hub"] .body:has(#listPane:not(.hidden) .cards:is([data-view="search"],[data-view="channel"],[data-view="library"],[data-view="settings"],[data-view="queue"],[data-view="history"],[data-view="saved"],[data-view="downloads"])) .player-pane { display:none; }
|
||||||
|
html[data-layout="bento-hub"] .body:has(.cards:is([data-view="search"],[data-view="channel"],[data-view="library"],[data-view="settings"],[data-view="queue"],[data-view="history"],[data-view="saved"],[data-view="downloads"])) .list-pane { flex:1; width:100%; border-left:0; }
|
||||||
|
@media(min-width:900px) {
|
||||||
|
html[data-layout="bento-hub"] .settings-grid { grid-template-columns:minmax(240px,.65fr) minmax(0,1.35fr); gap:24px; }
|
||||||
|
html[data-layout="bento-hub"] .cards:is([data-view="search"],[data-view="channel"],[data-view="library"]) { grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Batched reference polish: one mobile scroller, generous controls, no clipping. */
|
||||||
|
html[data-layout="bento-hub"] { --bento-stacked:0; --ease-bento:cubic-bezier(.16,1,.3,1); }
|
||||||
|
html[data-layout="bento-hub"] button { min-width:44px; }
|
||||||
|
html[data-layout="bento-hub"] .switch { min-height:44px; }
|
||||||
|
html[data-layout="bento-hub"] .hero-title { background:none; -webkit-text-fill-color:var(--text); }
|
||||||
|
html[data-layout="bento-hub"] .ph-logo { width:56px; height:56px; border-radius:16px; color:var(--on-accent); animation:none; }
|
||||||
|
html[data-layout="bento-hub"] .player-pane.empty :is(#controls,#nowPlayingMeta,#notesPanel,.upnext) { display:none; }
|
||||||
|
html[data-layout="bento-hub"] .body:has(.cards[data-view="settings"]) #listFilterBar { display:none; }
|
||||||
|
html[data-layout="bento-hub"] #controls .deck-main .ctrl { border-radius:16px; }
|
||||||
|
html[data-layout="bento-hub"] #controls input[type="range"]::-webkit-slider-thumb { border:0; box-shadow:var(--shadow-sm); }
|
||||||
|
html[data-layout="bento-hub"] #nowPlayingMeta .np-actions :is(#addBtn,#saveBtn,#exportBtn,#serviceBtn) { border:1px solid var(--line); border-radius:10px; background:var(--bg-2); color:var(--text); box-shadow:none; min-width:44px; }
|
||||||
|
html[data-layout="bento-hub"] #nowPlayingMeta .np-actions #saveBtn.done { color:var(--accent-sec); }
|
||||||
|
html[data-layout="bento-hub"] .stage-lyrics { background:#05070a; }
|
||||||
|
html[data-layout="bento-hub"] :is(.stage-lyrics,.service-lyrics) .sl-line.active { color:#00f5d4; background:rgba(0,245,212,.08); text-shadow:none; }
|
||||||
|
html[data-layout="bento-hub"] .svc-dock { flex-wrap:wrap; gap:8px; padding:8px; }
|
||||||
|
html[data-layout="bento-hub"] .svc-dock > :is(.svc-tile,.np-btn) { flex:1 0 96px; min-width:96px; min-height:56px; }
|
||||||
|
html[data-layout="bento-hub"] .svc-dock > .np-btn { background:#19202d; border-color:rgba(255,255,255,.09); color:#f1f5f9; }
|
||||||
|
html[data-layout="bento-hub"] .svc-font { flex:1 0 112px; justify-content:center; min-height:56px; }
|
||||||
|
html[data-layout="bento-hub"] .svc-label { white-space:nowrap; }
|
||||||
|
html[data-layout="bento-hub"] .shortcut-overlay { background:rgba(0,0,0,.75); backdrop-filter:blur(8px); }
|
||||||
|
html[data-layout="bento-hub"] .shortcut-panel { border:1px solid var(--line); border-radius:16px; background:var(--bg-1); }
|
||||||
|
html[data-layout="bento-hub"] .shortcut-key { border-radius:6px; background:var(--bg-2); color:var(--text); font:500 12px var(--mono); }
|
||||||
|
html[data-layout="bento-hub"] :is(.modal-list button,.mc-row,.export-actions .btn) { min-height:44px; border-radius:10px; background:var(--bg-2); color:var(--text); border-color:var(--line); }
|
||||||
|
html[data-layout="bento-hub"] .bento-search-label { position:absolute; width:1px; height:1px; padding:0; margin:-1px; clip-path:inset(50%); overflow:hidden; white-space:nowrap; }
|
||||||
|
html[data-layout="bento-hub"] .bento-search-icon { display:none; width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; }
|
||||||
|
html[data-layout="bento-hub"] .saved-badge .glass-control-icon { width:14px; height:14px; }
|
||||||
|
html[data-layout="bento-hub"] .card.downloading .saved-badge::before { content:''; }
|
||||||
|
@media(max-width:1023px) {
|
||||||
|
html[data-layout="bento-hub"] .app { grid-template-columns:minmax(0,1fr); }
|
||||||
|
html[data-layout="bento-hub"] .sidebar { position:fixed; inset:0 auto 0 0; width:260px; max-width:calc(100vw - 48px); z-index:350; transform:translateX(-100%); transition:transform .25s var(--ease-bento); }
|
||||||
|
html[data-layout="bento-hub"] .app.sidebar-open .sidebar { transform:translateX(0); }
|
||||||
|
html[data-layout="bento-hub"] .app.sidebar-open .sidebar-backdrop { display:block; opacity:1; z-index:340; }
|
||||||
|
html[data-layout="bento-hub"] .sidebar-toggle { display:inline-flex; }
|
||||||
|
html[data-layout="bento-hub"] .mini-bar { left:12px; }
|
||||||
|
}
|
||||||
|
@media(max-width:860px) {
|
||||||
|
html[data-layout="bento-hub"] { --bento-stacked:1; }
|
||||||
|
html[data-layout="bento-hub"] .body { flex-direction:column; overflow-y:auto; overflow-x:hidden; min-height:0; }
|
||||||
|
html[data-layout="bento-hub"] .player-pane { flex:none; width:100%; overflow:visible; }
|
||||||
|
html[data-layout="bento-hub"] .list-pane { flex:none!important; min-height:100%; overflow:visible; padding-bottom:16px; }
|
||||||
|
html[data-layout="bento-hub"] .cards { flex:none; overflow:visible; }
|
||||||
|
html[data-layout="bento-hub"] .player-pane.empty.home-playlists .player-stage { max-height:none; }
|
||||||
|
html[data-layout="bento-hub"] .search-btn { display:grid; place-items:center; flex:0 0 44px; width:44px; min-width:44px; padding:0; border-radius:999px; }
|
||||||
|
html[data-layout="bento-hub"] .bento-search-icon { display:block; }
|
||||||
|
html[data-layout="bento-hub"] .search-form { min-width:0; flex:1; gap:8px; }
|
||||||
|
html[data-layout="bento-hub"] #searchInput { min-width:0; padding-inline:12px; }
|
||||||
|
html[data-layout="bento-hub"] .home-pl-grid { grid-template-columns:1fr; }
|
||||||
|
}
|
||||||
|
@media(min-width:861px) { html[data-layout="bento-hub"] .bento-search-label { position:static; width:auto; height:auto; margin:0; clip-path:none; overflow:visible; } }
|
||||||
|
/* Bound the scroll viewport above floating chrome, rather than painting rows
|
||||||
|
underneath it and relying only on extra padding at the end of the list. */
|
||||||
|
html[data-layout="bento-hub"] .main { padding-bottom:var(--bento-bottom-clearance,16px); min-height:0; }
|
||||||
|
html[data-layout="bento-hub"] :is(.body,.player-pane,.cards) { padding-bottom:16px; }
|
||||||
|
html[data-layout="bento-hub"] .modal-backdrop { z-index:600; }
|
||||||
|
html[data-layout="bento-hub"] .shortcut-overlay { z-index:600; }
|
||||||
345
frontend/theme-f7.css
Normal file
345
frontend/theme-f7.css
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
/* Framework7 Swipe layout: iOS navigation and grouped media lists, built on
|
||||||
|
the app's existing theme tokens. Every rule stays inside this layout. */
|
||||||
|
html[data-layout="f7-swipe"] {
|
||||||
|
--f7-group-bg: color-mix(in srgb, var(--bg-1) 94%, var(--text) 6%);
|
||||||
|
--f7-hairline: color-mix(in srgb, var(--line) 65%, transparent);
|
||||||
|
--f7-glass: color-mix(in srgb, var(--bg-1) 82%, transparent);
|
||||||
|
--f7-radius: 16px;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
html[data-layout="f7-swipe"][data-theme="light"] { color-scheme: light; }
|
||||||
|
html[data-layout="f7-swipe"] body { background: var(--bg); }
|
||||||
|
|
||||||
|
/* Translucent navigation and iOS-style section titles. */
|
||||||
|
html[data-layout="f7-swipe"] .topbar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 15;
|
||||||
|
background: var(--f7-glass);
|
||||||
|
border-bottom: 1px solid var(--f7-hairline);
|
||||||
|
-webkit-backdrop-filter: saturate(180%) blur(22px);
|
||||||
|
backdrop-filter: saturate(180%) blur(22px);
|
||||||
|
}
|
||||||
|
html[data-layout="f7-swipe"] .list-header { padding: 18px 20px 10px; }
|
||||||
|
html[data-layout="f7-swipe"] .list-title-row h2 {
|
||||||
|
font: 700 32px/1.12 var(--ui, system-ui, sans-serif);
|
||||||
|
letter-spacing: -.035em;
|
||||||
|
}
|
||||||
|
html[data-layout="f7-swipe"] .list-sub { color: var(--text-dim); }
|
||||||
|
html[data-layout="f7-swipe"] .list-actions button,
|
||||||
|
html[data-layout="f7-swipe"] .list-actions .btn {
|
||||||
|
min-height: 38px; border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inset grouped rows: one quiet container, hairlines between items. */
|
||||||
|
html[data-layout="f7-swipe"] .cards {
|
||||||
|
gap: 0;
|
||||||
|
margin: 4px 16px 28px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--f7-hairline);
|
||||||
|
border-radius: var(--f7-radius);
|
||||||
|
background: var(--f7-group-bg);
|
||||||
|
}
|
||||||
|
html[data-layout="f7-swipe"] .cards li.f7s-row {
|
||||||
|
list-style: none; position: relative; overflow: hidden;
|
||||||
|
transform-style: preserve-3d; touch-action: pan-y;
|
||||||
|
}
|
||||||
|
html[data-layout="f7-swipe"] .f7s-row > .swipeout-content {
|
||||||
|
position: relative; z-index: 10; background: transparent;
|
||||||
|
transition: background .16s, border-color .16s;
|
||||||
|
}
|
||||||
|
html[data-layout="f7-swipe"] .cards .card {
|
||||||
|
min-height: 88px; padding: 10px 12px; border: 0;
|
||||||
|
border-radius: 0; border-bottom: 1px solid var(--f7-hairline);
|
||||||
|
background: transparent; box-shadow: none;
|
||||||
|
}
|
||||||
|
html[data-layout="f7-swipe"] .cards .card:last-child { border-bottom: 0; }
|
||||||
|
html[data-layout="f7-swipe"] .cards .card:hover,
|
||||||
|
html[data-layout="f7-swipe"] .cards .card.playing { background: color-mix(in srgb, var(--bg-2) 76%, transparent); }
|
||||||
|
html[data-layout="f7-swipe"] .cards .card.playing::after { display: none; }
|
||||||
|
html[data-layout="f7-swipe"] .cards .card-menu,
|
||||||
|
html[data-layout="f7-swipe"] .cards .card-del {
|
||||||
|
width: 40px; height: 40px; min-width: 40px; border: 0;
|
||||||
|
border-radius: 50%; background: transparent; color: var(--text-dim);
|
||||||
|
}
|
||||||
|
html[data-layout="f7-swipe"] .f7s-row.swipeout-active > .swipeout-content,
|
||||||
|
html[data-layout="f7-swipe"] .f7s-row.swipeout-opened > .swipeout-content { background: var(--bg-2); }
|
||||||
|
html[data-layout="f7-swipe"] .f7s-row.swipeout-transitioning > .swipeout-content,
|
||||||
|
html[data-layout="f7-swipe"] .f7s-row.swipeout-transitioning .f7s-act {
|
||||||
|
transition-duration: .3s; transition-property: transform, left, width, background-color;
|
||||||
|
transition-timing-function: var(--ease, ease);
|
||||||
|
}
|
||||||
|
html[data-layout="f7-swipe"] .f7s-row.f7s-touch-open > .swipeout-content { transition: transform .24s var(--ease, ease); }
|
||||||
|
html[data-layout="f7-swipe"] .f7s-row .swipeout-actions-left,
|
||||||
|
html[data-layout="f7-swipe"] .f7s-row .swipeout-actions-right {
|
||||||
|
position: absolute; top: 0; height: 100%; display: flex; align-items: center;
|
||||||
|
gap: 8px; padding: 0 10px; direction: ltr;
|
||||||
|
}
|
||||||
|
html[data-layout="f7-swipe"] .f7s-row .swipeout-actions-left { left: 0; }
|
||||||
|
html[data-layout="f7-swipe"] .f7s-row .swipeout-actions-right { right: 0; }
|
||||||
|
html[data-layout="f7-swipe"] .f7s-act {
|
||||||
|
position: relative; left: 0; transform: scale(0); min-height: 44px;
|
||||||
|
border: 0; border-radius: 999px; padding: 10px 16px; font-family: inherit; font-size: 13px; font-weight: 600;
|
||||||
|
color: var(--text); background: var(--bg-3); cursor: pointer;
|
||||||
|
white-space: nowrap; text-decoration: none; user-select: none;
|
||||||
|
}
|
||||||
|
html[data-layout="f7-swipe"] .swipeout-actions-left .f7s-act { background: var(--accent); color: var(--on-accent, #fff); }
|
||||||
|
html[data-layout="f7-swipe"] .f7s-act.f7s-danger { background: var(--danger-bg, var(--accent-deep)); color: var(--danger-text, #fff); }
|
||||||
|
html[data-layout="f7-swipe"] .f7s-row.swipeout-overswipe-active .f7s-danger { box-shadow: 0 0 0 3px var(--accent-glow); }
|
||||||
|
@media (hover: none) { html[data-layout="f7-swipe"] .f7s-row .card-del { display: none; } }
|
||||||
|
|
||||||
|
/* Compact segmented glass control used by the video/audio choice. */
|
||||||
|
html[data-layout="f7-swipe"] .stg-seg {
|
||||||
|
padding: 3px; gap: 2px; border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--bg-1) 78%, transparent);
|
||||||
|
border: 1px solid var(--f7-hairline); backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
html[data-layout="f7-swipe"] .stg-seg button { min-height: 36px; border-radius: 999px; }
|
||||||
|
|
||||||
|
/* iOS tab bar: translucent, with a restrained active tint. */
|
||||||
|
html[data-layout="f7-swipe"] .bottom-nav {
|
||||||
|
background: var(--f7-glass); border-top: 1px solid var(--f7-hairline);
|
||||||
|
-webkit-backdrop-filter: saturate(180%) blur(24px);
|
||||||
|
backdrop-filter: saturate(180%) blur(24px);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
html[data-layout="f7-swipe"] .bottom-nav-btn { color: var(--text-dim); }
|
||||||
|
html[data-layout="f7-swipe"] .bottom-nav-btn.active {
|
||||||
|
color: var(--accent); background: transparent;
|
||||||
|
}
|
||||||
|
html[data-layout="f7-swipe"] .bottom-nav-btn.active .bottom-nav-icon { transform: none; }
|
||||||
|
|
||||||
|
/* The mini-player reads as a floating sheet above the tab bar. */
|
||||||
|
html[data-layout="f7-swipe"] #miniBar {
|
||||||
|
left: 10px; right: 10px; bottom: calc(64px + env(safe-area-inset-bottom) + 8px);
|
||||||
|
overflow: hidden; border: 1px solid var(--f7-hairline); border-radius: 16px;
|
||||||
|
background: var(--f7-glass); box-shadow: 0 8px 28px rgb(0 0 0 / .18);
|
||||||
|
-webkit-backdrop-filter: saturate(180%) blur(22px);
|
||||||
|
backdrop-filter: saturate(180%) blur(22px);
|
||||||
|
}
|
||||||
|
html[data-layout="f7-swipe"] .mini-bar-inner { border: 0; background: transparent; }
|
||||||
|
html[data-layout="f7-swipe"] .mini-btn { border-radius: 50%; }
|
||||||
|
html[data-layout="f7-swipe"] .mini-progress { height: 2px; }
|
||||||
|
|
||||||
|
/* ---- Up next sheet ---- */
|
||||||
|
html[data-layout="f7-swipe"] .sheet-backdrop {
|
||||||
|
position: fixed; inset: 0; z-index: 11000; background: rgb(0 0 0 / .42);
|
||||||
|
opacity: 0; visibility: hidden; pointer-events: none; transition: opacity .3s, visibility .3s;
|
||||||
|
}
|
||||||
|
html[data-layout="f7-swipe"] .sheet-backdrop.backdrop-in { opacity: 1; visibility: visible; pointer-events: auto; }
|
||||||
|
html[data-layout="f7-swipe"] .f7s-sheet {
|
||||||
|
--f7-sheet-transition-duration: 300ms;
|
||||||
|
position: fixed; left: 0; right: 0; bottom: 0; z-index: 12500; display: none;
|
||||||
|
height: min(85dvh, 720px); overflow: hidden; box-sizing: border-box;
|
||||||
|
border-radius: 18px 18px 0 0; border: 1px solid var(--f7-hairline); border-bottom: 0;
|
||||||
|
background: var(--bg-1); color: var(--text); box-shadow: 0 -12px 40px rgb(0 0 0 / .28);
|
||||||
|
transform: translate3d(0, 100%, 0); transition: transform cubic-bezier(.2,.8,.2,1);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
html[data-layout="f7-swipe"] .f7s-sheet.modal-in, html[data-layout="f7-swipe"] .f7s-sheet.modal-out { transition-duration: var(--f7-sheet-transition-duration); }
|
||||||
|
html[data-layout="f7-swipe"] .f7s-sheet.not-animated { transition-duration: 0s; }
|
||||||
|
html[data-layout="f7-swipe"] .f7s-sheet.modal-in { display: block; transform: translate3d(0,0,0); }
|
||||||
|
html[data-layout="f7-swipe"] .f7s-sheet.modal-in-breakpoint, html[data-layout="f7-swipe"] .f7s-sheet.modal-in-swipe-step { display: block; transform: translate3d(0,var(--f7-sheet-breakpoint,var(--f7-sheet-swipe-step,0)),0); }
|
||||||
|
html[data-layout="f7-swipe"] .f7s-sheet.modal-out { transform: translate3d(0,100%,0); }
|
||||||
|
html[data-layout="f7-swipe"] .f7s-sheet .sheet-modal-inner { height: 100%; display: flex; flex-direction: column; }
|
||||||
|
html[data-layout="f7-swipe"] .f7s-grab { flex: none; height: 22px; display: grid; place-items: center; cursor: grab; touch-action: none; }
|
||||||
|
html[data-layout="f7-swipe"] .f7s-grab::before { content: ''; width: 36px; height: 5px; border-radius: 3px; background: var(--text-dim); opacity: .55; }
|
||||||
|
html[data-layout="f7-swipe"] .f7s-sheet-head { flex: none; padding: 0 18px 10px; font-weight: 700; font-size: 17px; touch-action: none; }
|
||||||
|
html[data-layout="f7-swipe"] .f7s-sheet-list { flex: 1; overflow-y: auto; margin: 0; padding: 0 12px 16px; list-style: none; }
|
||||||
|
html[data-layout="f7-swipe"] .f7s-up { display: flex; gap: 12px; align-items: center; padding: 10px 8px; border-bottom: 1px solid var(--f7-hairline); cursor: pointer; }
|
||||||
|
html[data-layout="f7-swipe"] .f7s-up:active, html[data-layout="f7-swipe"] .f7s-up:hover { background: var(--bg-2); }
|
||||||
|
html[data-layout="f7-swipe"] .f7s-up img { width: 88px; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 8px; flex: none; background: var(--bg-3); }
|
||||||
|
html[data-layout="f7-swipe"] .f7s-up-info { min-width: 0; }
|
||||||
|
html[data-layout="f7-swipe"] .f7s-up-title { font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||||
|
html[data-layout="f7-swipe"] .f7s-up-ch { font-size: 12px; color: var(--text-dim); margin-top: 4px; }
|
||||||
|
html[data-layout="f7-swipe"] .f7s-empty { color: var(--text-dim); padding: 12px 8px; margin: 0; }
|
||||||
|
|
||||||
|
/* A grab hint on the mini bar: swipe up for Up next, sideways to skip. */
|
||||||
|
html[data-layout="f7-swipe"] #miniBar { touch-action: none; }
|
||||||
|
html[data-layout="f7-swipe"] #miniBar .mini-bar-inner { position: relative; }
|
||||||
|
html[data-layout="f7-swipe"] #miniBar .mini-bar-inner::before {
|
||||||
|
content: ''; position: absolute; top: 3px; left: 50%; width: 32px; height: 4px;
|
||||||
|
margin-left: -16px; border-radius: 2px; background: var(--text-dim); opacity: .5;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
html[data-layout="f7-swipe"] .list-header { padding: 16px 16px 8px; }
|
||||||
|
html[data-layout="f7-swipe"] .list-title-row h2 { font-size: 30px; }
|
||||||
|
html[data-layout="f7-swipe"] .cards { margin: 4px 12px 22px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 860px) and (orientation: portrait) {
|
||||||
|
html[data-layout="f7-swipe"] .body { padding-bottom: calc(64px + env(safe-area-inset-bottom)); box-sizing: border-box; }
|
||||||
|
html[data-layout="f7-swipe"] .bottom-nav { display: flex; height: calc(64px + env(safe-area-inset-bottom)); }
|
||||||
|
html[data-layout="f7-swipe"] .bottom-nav-btn { min-height: 64px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full Framework7 v9 iOS surface. */
|
||||||
|
html[data-layout="f7-swipe"] {
|
||||||
|
--f7-glass: color-mix(in srgb, var(--bg-1) 86%, transparent);
|
||||||
|
--f7-hairline: color-mix(in srgb, var(--line) 70%, transparent);
|
||||||
|
--f7-inset: color-mix(in srgb, var(--bg-1) 84%, var(--bg) 16%);
|
||||||
|
font-family: var(--ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
html[data-layout="f7-swipe"][data-theme="light"] { color-scheme: light; }
|
||||||
|
html[data-layout="f7-swipe"][data-theme="contrast"] { color-scheme: dark; }
|
||||||
|
html[data-layout="f7-swipe"] body { background: var(--bg); }
|
||||||
|
html[data-layout="f7-swipe"] body::before,
|
||||||
|
html[data-layout="f7-swipe"] body::after { display: none; }
|
||||||
|
html[data-layout="f7-swipe"] :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
html[data-layout="f7-swipe"] ::selection { color: var(--text); background: color-mix(in srgb, var(--accent) 42%, transparent); }
|
||||||
|
html[data-layout="f7-swipe"] ::-webkit-scrollbar-thumb { background: var(--line); background-clip: content-box; }
|
||||||
|
|
||||||
|
/* Navigation/search navbar */
|
||||||
|
html[data-layout="f7-swipe"] .topbar {
|
||||||
|
position: relative; z-index: 20; min-height: 64px;
|
||||||
|
/* Clear the notch / status bar (viewport-fit=cover + black-translucent status bar). */
|
||||||
|
padding: calc(9px + env(safe-area-inset-top, 0px)) max(16px, env(safe-area-inset-right, 0px)) 9px max(16px, env(safe-area-inset-left, 0px));
|
||||||
|
background: var(--f7-glass); border-bottom: 1px solid var(--f7-hairline);
|
||||||
|
-webkit-backdrop-filter: saturate(180%) blur(24px); backdrop-filter: saturate(180%) blur(24px);
|
||||||
|
}
|
||||||
|
html[data-layout="f7-swipe"] .sidebar-toggle,
|
||||||
|
html[data-layout="f7-swipe"] .topbar button:not(.search-btn) { border: 0; background: transparent; box-shadow: none; color: var(--accent); }
|
||||||
|
html[data-layout="f7-swipe"] .search-form { position: relative; gap: 8px; }
|
||||||
|
html[data-layout="f7-swipe"] #searchInput {
|
||||||
|
min-height: 42px; padding: 0 14px 0 38px; border: 0; border-radius: 12px;
|
||||||
|
background: color-mix(in srgb, var(--bg-2) 92%, transparent); color: var(--text);
|
||||||
|
box-shadow: none; font: 400 17px/1.25 var(--ui, system-ui, sans-serif);
|
||||||
|
}
|
||||||
|
html[data-layout="f7-swipe"] .search-form::before { content: ''; position: absolute; left: 13px; top: 12px; z-index: 1; width: 18px; height: 18px; background: var(--text-dim); -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='10.5' cy='10.5' r='6.5' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='m16 16 5 5' stroke='black' stroke-width='2'/%3E%3C/svg%3E") center/contain no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='10.5' cy='10.5' r='6.5' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='m16 16 5 5' stroke='black' stroke-width='2'/%3E%3C/svg%3E") center/contain no-repeat; pointer-events: none; }
|
||||||
|
html[data-layout="f7-swipe"] .search-btn { min-height: 42px; padding: 0 8px; border: 0; background: transparent; color: var(--accent); font-size: 16px; font-weight: 500; box-shadow: none; }
|
||||||
|
html[data-layout="f7-swipe"] #updateIconBtn { width: 40px; height: 40px; }
|
||||||
|
html[data-layout="f7-swipe"] .search-recent { top: calc(100% + 8px); border: 1px solid var(--f7-hairline); border-radius: 14px; background: var(--bg-1); box-shadow: 0 12px 36px rgb(0 0 0 / .22); overflow: hidden; }
|
||||||
|
html[data-layout="f7-swipe"] .search-recent > * { border-radius: 0; border: 0; border-bottom: 1px solid var(--f7-hairline); background: transparent; min-height: 48px; }
|
||||||
|
|
||||||
|
/* Sidebar panel and grouped destinations */
|
||||||
|
html[data-layout="f7-swipe"] #sidebar { background: var(--bg-1); border-right: 1px solid var(--f7-hairline); padding: calc(24px + env(safe-area-inset-top, 0px)) 12px max(16px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left, 0px)); }
|
||||||
|
html[data-layout="f7-swipe"] #sidebar .brand { font: 700 22px/1.2 var(--ui, system-ui, sans-serif); padding: 4px 12px 24px; }
|
||||||
|
html[data-layout="f7-swipe"] #sidebar .sidebar-section-title,
|
||||||
|
html[data-layout="f7-swipe"] #sidebar .pl-header,
|
||||||
|
html[data-layout="f7-swipe"] #sidebar h2,
|
||||||
|
html[data-layout="f7-swipe"] #sidebar h3 { color: var(--text-dim); font-size: 12px; letter-spacing: .04em; text-transform: uppercase; }
|
||||||
|
html[data-layout="f7-swipe"] #sidebar .nav-item,
|
||||||
|
html[data-layout="f7-swipe"] #sidebar .sidebar-nav button,
|
||||||
|
html[data-layout="f7-swipe"] #playlistList > *, html[data-layout="f7-swipe"] #smartPlaylistList > * {
|
||||||
|
min-height: 48px; border: 0; border-radius: 10px; background: transparent; box-shadow: none;
|
||||||
|
}
|
||||||
|
html[data-layout="f7-swipe"] #sidebar .nav-item.active,
|
||||||
|
html[data-layout="f7-swipe"] #sidebar .nav-item[aria-current="page"] { color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
|
||||||
|
html[data-layout="f7-swipe"] #sidebar .nav-item { display: flex; align-items: center; gap: 10px; }
|
||||||
|
html[data-layout="f7-swipe"] .legacy-nav-icon { display: none !important; }
|
||||||
|
html[data-layout="f7-swipe"] .classic-nav-icon { display: block !important; width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
|
html[data-layout="f7-swipe"] #sidebar .nav-item::before { display: none; }
|
||||||
|
html[data-layout="f7-swipe"] #sidebar .nav-item::after { content: '›'; margin-left: auto; color: var(--text-dim); font-size: 22px; line-height: 1; }
|
||||||
|
html[data-layout="f7-swipe"] #sidebar .nav-item > .classic-nav-icon { width: 30px; height: 30px; flex: none; padding: 6px; border-radius: 8px; background: var(--bg-2); }
|
||||||
|
html[data-layout="f7-swipe"] #sidebar .playlist-item { min-height: 46px; border-radius: 10px; }
|
||||||
|
html[data-layout="f7-swipe"] #sidebar .playlist-item::before { content: '›'; font-size: 20px; }
|
||||||
|
html[data-layout="f7-swipe"] #newPlaylistBtn { width: 40px; height: 40px; border: 0; border-radius: 50%; background: transparent; color: var(--accent); }
|
||||||
|
html[data-layout="f7-swipe"] #sidebar .switch { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
html[data-layout="f7-swipe"] #audioOnlyToggle { appearance: none; width: 48px; height: 29px; margin: 0; border-radius: 99px; background: var(--bg-3); transition: background .18s; }
|
||||||
|
html[data-layout="f7-swipe"] #audioOnlyToggle { background: radial-gradient(circle at 14px 50%, #fff 0 11px, transparent 12px), var(--bg-3); }
|
||||||
|
html[data-layout="f7-swipe"] #audioOnlyToggle:checked { background: radial-gradient(circle at 34px 50%, #fff 0 11px, transparent 12px), var(--accent); }
|
||||||
|
|
||||||
|
/* Large-title home and browsing pane */
|
||||||
|
html[data-layout="f7-swipe"] .player-placeholder { padding: 30px 24px; }
|
||||||
|
html[data-layout="f7-swipe"] #playerPane.empty .player-stage,
|
||||||
|
html[data-layout="f7-swipe"] #playerPane.home-playlists .player-stage { width: 100%; min-height: 0; max-height: none; aspect-ratio: auto; overflow: visible; border: 0; border-radius: 0; background: transparent; box-shadow: none; }
|
||||||
|
html[data-layout="f7-swipe"] #playerPane.empty .player-placeholder { position: relative; inset: auto; min-height: 0; padding: 34px 16px 24px; background: transparent; }
|
||||||
|
html[data-layout="f7-swipe"] #playerPane.empty .hero { align-items: flex-start; }
|
||||||
|
html[data-layout="f7-swipe"] #playerPane.empty .ph-logo { display: none; }
|
||||||
|
html[data-layout="f7-swipe"] #playerPane.empty .hero-pills { display: none; }
|
||||||
|
html[data-layout="f7-swipe"] .hero { max-width: 740px; margin: 0 auto; text-align: left; }
|
||||||
|
html[data-layout="f7-swipe"] .hero-title { font: 700 clamp(34px, 5vw, 40px)/1.08 var(--ui, system-ui, sans-serif); letter-spacing: -.035em; }
|
||||||
|
html[data-layout="f7-swipe"] .hero-tagline { max-width: 54ch; color: var(--text-2); }
|
||||||
|
html[data-layout="f7-swipe"] .hero-pills { gap: 8px; }
|
||||||
|
html[data-layout="f7-swipe"] .hero-pill, html[data-layout="f7-swipe"] .chip { border-radius: 999px; border: 1px solid var(--f7-hairline); background: var(--bg-2); box-shadow: none; }
|
||||||
|
html[data-layout="f7-swipe"] .home-pl { margin: 24px auto; max-width: 900px; }
|
||||||
|
html[data-layout="f7-swipe"] .home-pl-title { font-size: 25px; font-weight: 700; }
|
||||||
|
html[data-layout="f7-swipe"] .home-pl-grid { gap: 14px; }
|
||||||
|
html[data-layout="f7-swipe"] .home-pl-card { overflow: hidden; border: 0; border-radius: 14px; background: var(--bg-1); box-shadow: none; }
|
||||||
|
html[data-layout="f7-swipe"] .home-pl-card img { border-radius: 10px; }
|
||||||
|
html[data-layout="f7-swipe"] .list-pane { background: transparent; }
|
||||||
|
html[data-layout="f7-swipe"] .list-header { padding: 20px 20px 10px; }
|
||||||
|
html[data-layout="f7-swipe"] .list-title-row h2 { font: 700 32px/1.12 var(--ui, system-ui, sans-serif); letter-spacing: -.035em; }
|
||||||
|
html[data-layout="f7-swipe"] .list-sub { color: var(--text-dim); }
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
html[data-layout="f7-swipe"]:has(#bottomNav .bottom-nav-btn.active:not([data-view="search"])) #playerPane.empty { display: none; }
|
||||||
|
}
|
||||||
|
html[data-layout="f7-swipe"] #listActions button:first-child { background: var(--accent); color: var(--on-accent, #fff); border-color: transparent; }
|
||||||
|
html[data-layout="f7-swipe"] #listActions button:nth-child(2) { color: var(--text-dim); background: transparent; border-color: transparent; }
|
||||||
|
html[data-layout="f7-swipe"]:has(#bottomNav .bottom-nav-btn.active[data-view="settings"]) #listFilterBar { display: none !important; }
|
||||||
|
html[data-layout="f7-swipe"] #listTitleActions button, html[data-layout="f7-swipe"] #listActions button,
|
||||||
|
html[data-layout="f7-swipe"] #batchBar button { min-height: 40px; border-radius: 999px; }
|
||||||
|
html[data-layout="f7-swipe"] #listFilterBar { border-radius: 12px; background: var(--bg-2); }
|
||||||
|
html[data-layout="f7-swipe"] #listFilterInput { min-height: 42px; border: 0; background: transparent; box-shadow: none; }
|
||||||
|
html[data-layout="f7-swipe"] #batchBar { background: var(--f7-glass); border-block: 1px solid var(--f7-hairline); }
|
||||||
|
html[data-layout="f7-swipe"] #searchMeta, html[data-layout="f7-swipe"] #status, html[data-layout="f7-swipe"] #p2pStrip { color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* Shared inset grouped media list */
|
||||||
|
html[data-layout="f7-swipe"] #cards { gap: 0; margin: 8px 16px 28px; padding: 0; overflow: visible; border: 1px solid var(--f7-hairline); border-radius: 14px; background: var(--f7-inset); }
|
||||||
|
html[data-layout="f7-swipe"] #cards .card { min-height: 80px; padding: 8px 12px; border: 0; border-radius: 0; background: transparent; box-shadow: none; }
|
||||||
|
html[data-layout="f7-swipe"] #cards .f7s-row:not(:last-child) .card { border-bottom: 1px solid var(--f7-hairline); }
|
||||||
|
html[data-layout="f7-swipe"] #cards .thumb { width: 60px; height: 60px; flex: 0 0 60px; border-radius: 8px; }
|
||||||
|
html[data-layout="f7-swipe"] #cards .thumb img { width: 60px; height: 60px; object-fit: cover; border-radius: 8px; }
|
||||||
|
html[data-layout="f7-swipe"] #cards .card-title { font-size: 15px; font-weight: 600; }
|
||||||
|
html[data-layout="f7-swipe"] #cards .card-subtitle, html[data-layout="f7-swipe"] #cards .card-channel { color: var(--text-dim); }
|
||||||
|
html[data-layout="f7-swipe"] #cards .card-menu { width: 42px; height: 42px; border: 0; border-radius: 50%; background: transparent; box-shadow: none; }
|
||||||
|
@media (hover: none) { html[data-layout="f7-swipe"] #cards .card-del { display: none !important; } }
|
||||||
|
html[data-layout="f7-swipe"] #cards .card.playing { background: color-mix(in srgb, var(--accent) 9%, transparent); }
|
||||||
|
html[data-layout="f7-swipe"] #cards .badge, html[data-layout="f7-swipe"] #cards .saved-badge { border-radius: 999px; font-size: 11px; }
|
||||||
|
|
||||||
|
/* Settings, player deck, notes and related lists */
|
||||||
|
html[data-layout="f7-swipe"] #cards .settings-overview { padding: 14px 8px; border: 1px solid var(--f7-hairline); border-radius: 14px; background: var(--f7-inset); }
|
||||||
|
html[data-layout="f7-swipe"] #cards .settings-overview > * { min-height: 56px; border-bottom: 1px solid var(--f7-hairline); background: transparent; }
|
||||||
|
html[data-layout="f7-swipe"] #cards .settings-section { margin: 18px 0; overflow: hidden; border: 1px solid var(--f7-hairline); border-radius: 14px; background: var(--f7-inset); box-shadow: none; }
|
||||||
|
html[data-layout="f7-swipe"] #cards .settings-section-title { padding: 12px 16px 6px; color: var(--accent); font-size: 13px; }
|
||||||
|
html[data-layout="f7-swipe"] #cards .set-row, html[data-layout="f7-swipe"] #cards .settings-section-row { min-height: 52px; padding-inline: 16px; border-bottom: 1px solid var(--f7-hairline); }
|
||||||
|
html[data-layout="f7-swipe"] #cards .set-row input[type="checkbox"] { appearance: none; width: 48px; height: 29px; border: 0; border-radius: 99px; background: radial-gradient(circle at 14px 50%, #fff 0 11px, transparent 12px), var(--bg-3); }
|
||||||
|
html[data-layout="f7-swipe"] #cards .set-row input[type="checkbox"]:checked { background: radial-gradient(circle at 34px 50%, #fff 0 11px, transparent 12px), var(--accent); }
|
||||||
|
html[data-layout="f7-swipe"] #cards .set-row select { min-height: 38px; border: 1px solid var(--f7-hairline); border-radius: 9px; background: var(--bg-2); color: var(--text); }
|
||||||
|
html[data-layout="f7-swipe"] #controls { padding: 10px 16px; }
|
||||||
|
html[data-layout="f7-swipe"] #controls .seek { height: 3px; accent-color: var(--accent); }
|
||||||
|
html[data-layout="f7-swipe"] #controls .ctrl, html[data-layout="f7-swipe"] #controls .dk-more,
|
||||||
|
html[data-layout="f7-swipe"] #nowPlayingMeta .np-btn { border: 0; border-radius: 50%; background: transparent; box-shadow: none; }
|
||||||
|
html[data-layout="f7-swipe"] #controls .play { width: 62px; height: 62px; border-radius: 50%; background: var(--accent); color: var(--on-accent, #fff); }
|
||||||
|
html[data-layout="f7-swipe"] #nowPlayingMeta { border: 0; border-radius: 0; background: transparent; box-shadow: none; }
|
||||||
|
html[data-layout="f7-swipe"] .np-actions { display: flex; gap: 4px; }
|
||||||
|
html[data-layout="f7-swipe"] #notesPanel { border: 0; border-radius: 0; background: transparent; box-shadow: none; }
|
||||||
|
html[data-layout="f7-swipe"] #notesPanel [role="tablist"], html[data-layout="f7-swipe"] .notes-tabs { display: flex; border-radius: 999px; background: var(--bg-2); padding: 3px; }
|
||||||
|
html[data-layout="f7-swipe"] #notesPanel [role="tab"], html[data-layout="f7-swipe"] .notes-tabs button { border: 0; border-radius: 999px; background: transparent; }
|
||||||
|
html[data-layout="f7-swipe"] #notesPanel [role="tab"][aria-selected="true"], html[data-layout="f7-swipe"] #notesPanel .notes-tab.active { color: var(--text); background: var(--bg-3); }
|
||||||
|
html[data-layout="f7-swipe"] #upnext, html[data-layout="f7-swipe"] #relatedPanel { border: 0; border-radius: 0; background: transparent; box-shadow: none; }
|
||||||
|
html[data-layout="f7-swipe"] #relatedPanel .related-item { border-bottom: 1px solid var(--f7-hairline); }
|
||||||
|
|
||||||
|
/* Tab bar, toast, modal, action sheet and auxiliary modes */
|
||||||
|
html[data-layout="f7-swipe"] #bottomNav { background: var(--f7-glass); border-top: 1px solid var(--f7-hairline); -webkit-backdrop-filter: blur(22px); backdrop-filter: blur(22px); }
|
||||||
|
html[data-layout="f7-swipe"] .bottom-nav-btn { min-height: 54px; border-radius: 0; color: var(--text-dim); }
|
||||||
|
html[data-layout="f7-swipe"] .bottom-nav-icon .classic-nav-icon { width: 20px; height: 20px; padding: 0; }
|
||||||
|
html[data-layout="f7-swipe"] .bottom-nav-btn.active { color: var(--accent); background: transparent; }
|
||||||
|
html[data-layout="f7-swipe"] #toastContainer { bottom: calc(74px + env(safe-area-inset-bottom)); }
|
||||||
|
html[data-layout="f7-swipe"] #toastContainer .toast { border-radius: 999px; background: #242428; color: #fff; box-shadow: 0 4px 18px rgb(0 0 0 / .22); }
|
||||||
|
html[data-layout="f7-swipe"] #modal .modal { border-radius: 18px; box-shadow: 0 14px 42px rgb(0 0 0 / .2); }
|
||||||
|
html[data-layout="f7-swipe"] #modalBody { box-shadow: none; }
|
||||||
|
html[data-layout="f7-swipe"] #modalActions button { min-height: 48px; border-radius: 12px; }
|
||||||
|
html[data-layout="f7-swipe"] #modal .modal { background: var(--bg-1); }
|
||||||
|
html[data-layout="f7-swipe"] .f7s-sheet { box-shadow: none; border-radius: 18px 18px 0 0; }
|
||||||
|
html[data-layout="f7-swipe"] .f7s-sheet.f7s-full { top: 0; bottom: 0; height: 100dvh; max-height: none; border-radius: 0; padding-top: env(safe-area-inset-top, 0px); box-sizing: border-box; }
|
||||||
|
html[data-layout="f7-swipe"] #sectionRail { top: auto; right: 12px; bottom: calc(76px + env(safe-area-inset-bottom)); transform: none; border: 1px solid var(--f7-hairline); border-radius: 22px; background: var(--f7-glass); box-shadow: 0 4px 16px rgb(0 0 0 / .12); -webkit-backdrop-filter: blur(18px); backdrop-filter: blur(18px); }
|
||||||
|
html[data-layout="f7-swipe"] #sectionRail button { min-width: 44px; min-height: 44px; border: 0; border-radius: 50%; background: transparent; color: var(--text-dim); }
|
||||||
|
html[data-layout="f7-swipe"] #sectionRail button.active { color: var(--accent); }
|
||||||
|
html[data-layout="f7-swipe"] #serviceMode, html[data-layout="f7-swipe"] #partyPanel,
|
||||||
|
html[data-layout="f7-swipe"] #remoteView, html[data-layout="f7-swipe"] #presenterView { color: var(--text); font-family: var(--ui, system-ui, sans-serif); }
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
html[data-layout="f7-swipe"] .topbar { padding-left: max(10px, env(safe-area-inset-left, 0px)); padding-right: max(10px, env(safe-area-inset-right, 0px)); }
|
||||||
|
html[data-layout="f7-swipe"] .body { touch-action: pan-y; }
|
||||||
|
html[data-layout="f7-swipe"] #cards { margin-inline: 10px; }
|
||||||
|
html[data-layout="f7-swipe"] .bottom-nav { display: flex; height: calc(62px + env(safe-area-inset-bottom)); padding-bottom: env(safe-area-inset-bottom); }
|
||||||
|
html[data-layout="f7-swipe"] #miniBar { bottom: calc(66px + env(safe-area-inset-bottom)) !important; }
|
||||||
|
html[data-layout="f7-swipe"] #modal { align-items: flex-end; padding: 0; }
|
||||||
|
html[data-layout="f7-swipe"] #modal .modal { width: 100%; max-width: none; max-height: min(84dvh, 720px); margin: 0; overflow-y: auto; border-radius: 18px 18px 0 0; padding-bottom: max(18px, env(safe-area-inset-bottom)); }
|
||||||
|
}
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
html[data-layout="f7-swipe"] .list-header { padding-inline: 14px; }
|
||||||
|
html[data-layout="f7-swipe"] .list-title-row h2 { font-size: 29px; }
|
||||||
|
html[data-layout="f7-swipe"] #cards .card { min-height: 76px; }
|
||||||
|
}
|
||||||
@@ -14,12 +14,6 @@
|
|||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
/* Elements that only exist for this theme stay hidden elsewhere. */
|
/* Elements that only exist for this theme stay hidden elsewhere. */
|
||||||
.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; }
|
|
||||||
|
|
||||||
html[data-layout="glass-stage"] {
|
html[data-layout="glass-stage"] {
|
||||||
--bg: #121317;
|
--bg: #121317;
|
||||||
@@ -93,7 +87,7 @@ html[data-layout="glass-stage"] .stg-skip, html[data-layout="glass-stage"] .stg-
|
|||||||
html[data-layout="glass-stage"] .stg-skip { width: 46px; height: 46px; border-radius: 50%; background: rgba(0, 0, 0, 0.45); -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); }
|
html[data-layout="glass-stage"] .stg-skip { width: 46px; height: 46px; border-radius: 50%; background: rgba(0, 0, 0, 0.45); -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); }
|
||||||
html[data-layout="glass-stage"] .stg-skip svg { width: 26px; height: 26px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
|
html[data-layout="glass-stage"] .stg-skip svg { width: 26px; height: 26px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
html[data-layout="glass-stage"] .stg-play { width: 62px; height: 62px; border-radius: 50%; border: 0; background: linear-gradient(135deg, #ff553c, #ff8471); box-shadow: 0 0 34px rgba(255, 85, 60, 0.6); }
|
html[data-layout="glass-stage"] .stg-play { width: 62px; height: 62px; border-radius: 50%; border: 0; background: linear-gradient(135deg, #ff553c, #ff8471); box-shadow: 0 0 34px rgba(255, 85, 60, 0.6); }
|
||||||
html[data-layout="glass-stage"] .stg-play svg { width: 32px; height: 32px; fill: currentColor; stroke: none; }
|
html[data-layout="glass-stage"] .stg-play svg { width: 32px; height: 32px; fill: none; stroke: currentColor; stroke-width:1.5; }
|
||||||
|
|
||||||
/* ---- 2. deck ---- */
|
/* ---- 2. deck ---- */
|
||||||
html[data-layout="glass-stage"] #controls { padding: 16px; gap: 14px; }
|
html[data-layout="glass-stage"] #controls { padding: 16px; gap: 14px; }
|
||||||
@@ -101,7 +95,7 @@ html[data-layout="glass-stage"] #controls .deck-main { display: flex; justify-co
|
|||||||
html[data-layout="glass-stage"] #controls .deck-main .ctrl { width: 46px; height: 46px; border-radius: 16px; background: var(--tile); border: 1px solid var(--glass-line); }
|
html[data-layout="glass-stage"] #controls .deck-main .ctrl { width: 46px; height: 46px; border-radius: 16px; background: var(--tile); border: 1px solid var(--glass-line); }
|
||||||
html[data-layout="glass-stage"] #controls .deck-main .ctrl.skip10 { display: grid; width: 44px; height: 44px; color: var(--text-2); }
|
html[data-layout="glass-stage"] #controls .deck-main .ctrl.skip10 { display: grid; width: 44px; height: 44px; color: var(--text-2); }
|
||||||
html[data-layout="glass-stage"] #controls .deck-main .ctrl.skip10 svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
|
html[data-layout="glass-stage"] #controls .deck-main .ctrl.skip10 svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
html[data-layout="glass-stage"] #controls .deck-main .ctrl.play { width: 64px; height: 64px; border-radius: 50%; border: 0; background: var(--grad); box-shadow: 0 12px 30px rgba(255, 85, 60, 0.55), 0 0 20px rgba(255, 85, 60, 0.35); }
|
html[data-layout="glass-stage"] #controls .deck-main .ctrl.play { width: 64px; height: 64px; border-radius: 50%; border: 1px solid var(--glass-line); background: var(--tile); backdrop-filter:blur(12px); box-shadow:none; }
|
||||||
html[data-layout="glass-stage"] #controls .deck-main .ctrl.play svg { width: 32px; height: 32px; }
|
html[data-layout="glass-stage"] #controls .deck-main .ctrl.play svg { width: 32px; height: 32px; }
|
||||||
html[data-layout="glass-stage"] #controls .time { font: 600 12px var(--mono); }
|
html[data-layout="glass-stage"] #controls .time { font: 600 12px var(--mono); }
|
||||||
|
|
||||||
@@ -202,8 +196,6 @@ html[data-layout="glass-stage"] .stg-overlay { justify-content: flex-start; }
|
|||||||
html[data-layout="glass-stage"] .stg-mid { margin: auto 0; }
|
html[data-layout="glass-stage"] .stg-mid { margin: auto 0; }
|
||||||
|
|
||||||
/* =========================== fidelity pass (matches the approved mockup) =========================== */
|
/* =========================== fidelity pass (matches the approved mockup) =========================== */
|
||||||
.stg-bot, .ch-badge, .upnext-clear, .list-sub, .p2p-strip { display: none; }
|
|
||||||
.sleep-status .sl-ic, .sleep-status .sl-sub { display: none; }
|
|
||||||
|
|
||||||
/* stage HUD */
|
/* stage HUD */
|
||||||
html[data-layout="glass-stage"] .stg-bot { display: flex; position: absolute; left: 12px; right: 12px; bottom: 12px; justify-content: space-between; pointer-events: none; }
|
html[data-layout="glass-stage"] .stg-bot { display: flex; position: absolute; left: 12px; right: 12px; bottom: 12px; justify-content: space-between; pointer-events: none; }
|
||||||
@@ -292,7 +284,6 @@ html[data-layout="glass-stage"] .ps-txt small { font: 400 10px var(--mono); colo
|
|||||||
html[data-layout="glass-stage"] .ps-dl { width: 34px; height: 34px; border-radius: 12px; border: 0; background: rgba(255, 255, 255, 0.07); color: var(--accent-bright); font-size: 16px; cursor: pointer; }
|
html[data-layout="glass-stage"] .ps-dl { width: 34px; height: 34px; border-radius: 12px; border: 0; background: rgba(255, 255, 255, 0.07); color: var(--accent-bright); font-size: 16px; cursor: pointer; }
|
||||||
|
|
||||||
/* ===== quick row (clean rebuild): [A | B | ↺] [Fullscreen] [More] ===== */
|
/* ===== quick row (clean rebuild): [A | B | ↺] [Fullscreen] [More] ===== */
|
||||||
.ab-group { display: contents; }
|
|
||||||
html[data-layout="glass-stage"] #controls .dk-quick { display: flex; flex-wrap: nowrap; align-items: center; justify-content: space-between; gap: 8px; }
|
html[data-layout="glass-stage"] #controls .dk-quick { display: flex; flex-wrap: nowrap; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
html[data-layout="glass-stage"] #controls .ab-group { display: inline-flex; align-items: stretch; gap: 2px; padding: 3px; border-radius: 14px; background: var(--tile); border: 1px solid var(--glass-line); flex: 0 1 auto; min-width: 0; }
|
html[data-layout="glass-stage"] #controls .ab-group { display: inline-flex; align-items: stretch; gap: 2px; padding: 3px; border-radius: 14px; background: var(--tile); border: 1px solid var(--glass-line); flex: 0 1 auto; min-width: 0; }
|
||||||
html[data-layout="glass-stage"] #controls .ab-group .ctrl {
|
html[data-layout="glass-stage"] #controls .ab-group .ctrl {
|
||||||
@@ -339,7 +330,6 @@ html[data-layout="glass-stage"] #controls .ab-group .ctrl { padding: 0 7px !impo
|
|||||||
html[data-layout="glass-stage"] #controls .ab-group #abClearBtn { padding: 0 7px !important; font-size: 13px !important; }
|
html[data-layout="glass-stage"] #controls .ab-group #abClearBtn { padding: 0 7px !important; font-size: 13px !important; }
|
||||||
|
|
||||||
/* ===== A-B · Fullscreen · More — styled like the mockup ===== */
|
/* ===== A-B · Fullscreen · More — styled like the mockup ===== */
|
||||||
.mc-tune { display: none; }
|
|
||||||
html[data-layout="glass-stage"] #controls .dk-quick { flex-wrap: wrap; justify-content: space-between; gap: 8px; }
|
html[data-layout="glass-stage"] #controls .dk-quick { flex-wrap: wrap; justify-content: space-between; gap: 8px; }
|
||||||
html[data-layout="glass-stage"] #controls .ab-group { padding: 2px; border-radius: 12px; gap: 0; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.05); }
|
html[data-layout="glass-stage"] #controls .ab-group { padding: 2px; border-radius: 12px; gap: 0; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.05); }
|
||||||
html[data-layout="glass-stage"] #controls .ab-group .ctrl { height: 26px !important; padding: 0 10px !important; border-radius: 8px !important; font: 500 10px var(--mono) !important; color: var(--text-dim); }
|
html[data-layout="glass-stage"] #controls .ab-group .ctrl { height: 26px !important; padding: 0 10px !important; border-radius: 8px !important; font: 500 10px var(--mono) !important; color: var(--text-dim); }
|
||||||
|
|||||||
48
frontend/transport-icons.test.js
Normal file
48
frontend/transport-icons.test.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
const {test}=require('node:test');
|
||||||
|
const assert=require('node:assert/strict');
|
||||||
|
const {readFileSync,readdirSync}=require('node:fs');
|
||||||
|
const {join}=require('node:path');
|
||||||
|
const vm=require('node:vm');
|
||||||
|
const {transport}=require('./download-actions');
|
||||||
|
test('transport symbols cover all actions and layouts with monochrome SVG',()=>{
|
||||||
|
for(const kind of ['play','pause','previous','next','back10','forward10','back15','forward']){
|
||||||
|
assert.match(transport.svg(kind),new RegExp(`href="#transport-${kind}"`));
|
||||||
|
assert.match(transport.svg(kind),/stroke="currentColor"/);
|
||||||
|
assert.equal((transport.svg(kind).match(/<use /g)||[]).length,4,'layout selection lives on visible use elements');
|
||||||
|
for(const layout of ['classic','f7-swipe','glass-stage','bento-hub'])assert.ok(transport.variants[layout][kind]);
|
||||||
|
}
|
||||||
|
assert.throws(()=>transport.svg('unknown'),/Unknown transport/);
|
||||||
|
});
|
||||||
|
test('playback UI swaps shared symbols and accessible names without changing handlers',()=>{
|
||||||
|
const buttons=Object.fromEntries(['playBtn','stgPlay','miniPlayBtn','servicePlayBtn'].map(id=>[id,{onclick:()=>id,setAttribute(k,v){this[k]=v;}}]));
|
||||||
|
const handlers=Object.values(buttons).map(b=>b.onclick);let changes=0;
|
||||||
|
const Player={master:{paused:true}},classes=[];
|
||||||
|
const app=readFileSync(join(__dirname,'app.js'),'utf8');
|
||||||
|
const body=app.slice(app.indexOf('function updatePlayBtn() {'),app.indexOf('// "⚠ Broken"'));
|
||||||
|
const sandbox={Player,TransportIcons:transport,Remote:{changed(){changes++;}},$:id=>buttons[id],document:{querySelector:()=>({classList:{toggle:(...args)=>classes.push(args)}})}};
|
||||||
|
vm.runInNewContext(body,sandbox);
|
||||||
|
for(const paused of [true,false,true]){
|
||||||
|
Player.master.paused=paused;sandbox.updatePlayBtn();
|
||||||
|
for(const b of Object.values(buttons)){assert.match(b.innerHTML,new RegExp(`#transport-${paused?'play':'pause'}`));assert.equal(b['aria-label'],paused?'Play':'Pause');}
|
||||||
|
}
|
||||||
|
assert.equal(changes,3);assert.deepEqual(Object.values(buttons).map(b=>b.onclick),handlers);
|
||||||
|
assert.deepEqual(classes,[['is-paused',true],['is-paused',false],['is-paused',true]]);
|
||||||
|
});
|
||||||
|
test('runtime sources never render legacy transport glyphs',()=>{
|
||||||
|
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)=>{
|
||||||
|
// Legacy input decoder accepts old labels; its output uses shared symbols.
|
||||||
|
if(file==='glass-controls.js'&&line.startsWith(' const glyphs = '))return;
|
||||||
|
if(/[▶⏸⏮⏭⏪⏩⏯◀❚]/u.test(line))failures.push(`${file}:${i+1}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert.deepEqual(failures,[]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('state changes preserve named SVG nodes used by editor controls',()=>{
|
||||||
|
let icon={id:'playIcon'};
|
||||||
|
const button={querySelector(){return icon;},set innerHTML(value){this.markup=value;icon={id:''};},setAttribute(name,value){this[name]=value;}};
|
||||||
|
transport.set(button,'pause');
|
||||||
|
assert.equal(icon.id,'playIcon');assert.equal(button['aria-label'],'Pause');assert.match(button.markup,/#transport-pause-f7-swipe/);
|
||||||
|
});
|
||||||
13
frontend/vendor/framework7-swipe.entry.js
vendored
Normal file
13
frontend/vendor/framework7-swipe.entry.js
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// Build: bun build framework7-swipe.entry.js --minify --format=iife (framework7@9.2.0 installed alongside)
|
||||||
|
import Framework7 from './node_modules/framework7/components/app/app-class.js';
|
||||||
|
import DeviceModule from './node_modules/framework7/modules/device/device.js';
|
||||||
|
import SupportModule from './node_modules/framework7/modules/support/support.js';
|
||||||
|
import UtilsModule from './node_modules/framework7/modules/utils/utils.js';
|
||||||
|
import ResizeModule from './node_modules/framework7/modules/resize/resize.js';
|
||||||
|
import TouchModule from './node_modules/framework7/modules/touch/touch.js';
|
||||||
|
import StoreModule from './node_modules/framework7/modules/store/store.js';
|
||||||
|
import Swipeout from './node_modules/framework7/components/swipeout/swipeout.js';
|
||||||
|
import Modal from './node_modules/framework7/components/modal/modal.js';
|
||||||
|
import Sheet from './node_modules/framework7/components/sheet/sheet.js';
|
||||||
|
Framework7.use([DeviceModule, SupportModule, UtilsModule, ResizeModule, TouchModule, StoreModule, Swipeout, Modal, Sheet]);
|
||||||
|
window.Framework7 = Framework7;
|
||||||
234
frontend/vendor/framework7-swipe.min.js
vendored
Normal file
234
frontend/vendor/framework7-swipe.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/vendor/jamesdsp/LICENSE.txt
vendored
Normal file
1
frontend/vendor/jamesdsp/LICENSE.txt
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
JamesDSP Web port files in this directory are licensed under GPL-3.0-or-later; see the upstream project LICENSE and upstream notices for provenance. The upstream README notes that its bundled original JamesDSP license text says GPL-2.0, so recipients should retain the upstream notices when redistributing.
|
||||||
56
frontend/vendor/jamesdsp/controller.js
vendored
Normal file
56
frontend/vendor/jamesdsp/controller.js
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { JamesDSPNode } from './jamesdsp-node.js';
|
||||||
|
|
||||||
|
const presets = {
|
||||||
|
Flat: {},
|
||||||
|
'Bass boost': { 'bass.enabled': 1, 'bass.boost': 6, 'eq.enabled': 1, 'eq.band0': 3, 'eq.band1': 2, 'gain': -3 },
|
||||||
|
Vocal: { 'eq.enabled': 1, 'eq.band3': -2, 'eq.band5': 2, 'eq.band7': 3, 'eq.band9': 2, 'eq.band11': -1 },
|
||||||
|
'Headphone crossfeed': { 'crossfeed.enabled': 1, 'crossfeed.mode': 1 },
|
||||||
|
Loudness: { 'limiter.threshold': -1, 'gain': 2 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export class JamesDSPController {
|
||||||
|
constructor() { this.context = null; this.node = null; this.source = null; this.element = null; this.sources = new WeakMap(); }
|
||||||
|
async enable(element, settings) {
|
||||||
|
const AC = window.AudioContext || window.webkitAudioContext;
|
||||||
|
if (!AC) throw new Error('Web Audio is not supported in this browser');
|
||||||
|
if (!this.context) this.context = new AC();
|
||||||
|
if (![44100, 48000].includes(this.context.sampleRate)) throw new Error(`JamesDSP supports 44.1 or 48 kHz; this device uses ${this.context.sampleRate} Hz`);
|
||||||
|
if (!this.node) {
|
||||||
|
this.node = await JamesDSPNode.create(this.context);
|
||||||
|
this.node.addEventListener('error', (event) => this.fault(event.detail));
|
||||||
|
}
|
||||||
|
if (this.element !== element) {
|
||||||
|
if (this.source) { this.source.disconnect(this.node); this.source.connect(this.context.destination); }
|
||||||
|
this.source = this.sources.get(element) || this.context.createMediaElementSource(element);
|
||||||
|
this.sources.set(element, this.source);
|
||||||
|
this.node.connect(this.source);
|
||||||
|
this.element = element;
|
||||||
|
}
|
||||||
|
await this.node.setBypass(false);
|
||||||
|
await this.apply(settings);
|
||||||
|
if (this.context.state === 'suspended') await this.context.resume();
|
||||||
|
}
|
||||||
|
async apply(settings) {
|
||||||
|
if (!this.node) return;
|
||||||
|
const preset = presets[settings.preset] || presets.Flat;
|
||||||
|
const params = { ...preset, ...(settings.params || {}) };
|
||||||
|
await this.node.setBypass(true);
|
||||||
|
await this.node.applyPreset(params);
|
||||||
|
await this.node.setBypass(false);
|
||||||
|
}
|
||||||
|
async update(name, value, settings) {
|
||||||
|
settings.params ||= {};
|
||||||
|
settings.params[name] = value;
|
||||||
|
await this.apply(settings);
|
||||||
|
}
|
||||||
|
async disable() {
|
||||||
|
if (this.node) await this.node.setBypass(true).catch(() => {});
|
||||||
|
}
|
||||||
|
fault(error) {
|
||||||
|
console.warn('[JamesDSP] Processing failed; routing media dry:', error);
|
||||||
|
try { this.source?.disconnect(this.node); this.source?.connect(this.context.destination); } catch {}
|
||||||
|
this.node?.destroy(); this.node = null; this.source = null; this.element = null;
|
||||||
|
this.onError?.(error || new Error('Audio processor failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const JAMESDSP_PRESETS = Object.keys(presets);
|
||||||
2
frontend/vendor/jamesdsp/dsp/jamesdsp.js
vendored
Normal file
2
frontend/vendor/jamesdsp/dsp/jamesdsp.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
frontend/vendor/jamesdsp/dsp/jamesdsp.wasm
vendored
Executable file
BIN
frontend/vendor/jamesdsp/dsp/jamesdsp.wasm
vendored
Executable file
Binary file not shown.
72
frontend/vendor/jamesdsp/jamesdsp-node.js
vendored
Normal file
72
frontend/vendor/jamesdsp/jamesdsp-node.js
vendored
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
import {DEFAULT_PARAMS, validateParam} from './params.js';
|
||||||
|
const moduleLoads = new WeakMap();
|
||||||
|
export class JamesDSPNode extends EventTarget {
|
||||||
|
static async create(context, {wasmURL=new URL('./dsp/jamesdsp.wasm',import.meta.url), workletURL=new URL('./worklet.js',import.meta.url)}={}) {
|
||||||
|
if (![44100,48000].includes(context.sampleRate)) throw new RangeError('Use a 44.1 or 48 kHz AudioContext');
|
||||||
|
if (!moduleLoads.has(context)) moduleLoads.set(context,new Map());
|
||||||
|
const cache=moduleLoads.get(context), key=String(workletURL);
|
||||||
|
if (!cache.has(key)) cache.set(key,context.audioWorklet.addModule(workletURL).catch(e=>{cache.delete(key);throw e;}));
|
||||||
|
const response=await fetch(wasmURL);
|
||||||
|
if (!response.ok) throw new Error(`WASM fetch failed: ${response.status}`);
|
||||||
|
const wasmBytes=await response.arrayBuffer();
|
||||||
|
await cache.get(key);
|
||||||
|
const wrapper=new JamesDSPNode(context,wasmBytes);
|
||||||
|
try { await wrapper.ready; return wrapper; } catch(e) { wrapper.destroy(); throw e; }
|
||||||
|
}
|
||||||
|
constructor(context,wasmBytes) {
|
||||||
|
super(); this.context=context; this.params={...DEFAULT_PARAMS}; this.texts={}; this.pending=new Map(); this.serial=0; this.destroyed=false; this.source=null;
|
||||||
|
this.node=new AudioWorkletNode(context,'jamesdsp',{numberOfInputs:1,numberOfOutputs:1,outputChannelCount:[2],channelCount:2,channelCountMode:'explicit',processorOptions:{wasmBytes}});
|
||||||
|
this.ready=new Promise((resolve,reject)=> {
|
||||||
|
const timer=setTimeout(()=>reject(new Error('DSP initialization timed out')),15000);
|
||||||
|
this.node.onprocessorerror=()=>{const error=new Error('DSP processor stopped');clearTimeout(timer);reject(error);this.fail(error);};
|
||||||
|
this.node.port.onmessage=({data})=> {
|
||||||
|
if(data.type==='ready') {clearTimeout(timer);resolve();}
|
||||||
|
else if(data.type==='reply') { const p=this.pending.get(data.id); if(p) {this.pending.delete(data.id);clearTimeout(p.timer);data.error?p.reject(new Error(data.error)):p.resolve(data.result);} }
|
||||||
|
else if(data.type==='error') {const error=new Error(data.error); clearTimeout(timer); reject(error); this.fail(error);}
|
||||||
|
else this.dispatchEvent(new CustomEvent(data.type,{detail:data}));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fail(error) { for(const p of this.pending.values()) {clearTimeout(p.timer);p.reject(error);} this.pending.clear();this.dispatchEvent(new CustomEvent('error',{detail:error})); }
|
||||||
|
request(type,data={}) {
|
||||||
|
if(this.destroyed) return Promise.reject(new Error('DSP has been destroyed'));
|
||||||
|
const id=++this.serial;
|
||||||
|
return new Promise((resolve,reject)=> {
|
||||||
|
const timer=setTimeout(()=>{this.pending.delete(id);reject(new Error(`DSP ${type} timed out`));},15000);
|
||||||
|
this.pending.set(id,{resolve,reject,timer});this.node.port.postMessage({type,id,...data});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// One source → this DSP → destination. For arbitrary graphs use .node directly.
|
||||||
|
connect(source,destination=this.context.destination) {
|
||||||
|
if(this.destroyed) throw new Error('DSP has been destroyed');
|
||||||
|
if(this.source) this.source.disconnect(this.node);
|
||||||
|
this.node.disconnect();this.source=source;source.connect(this.node);this.node.connect(destination);return destination;
|
||||||
|
}
|
||||||
|
async setParam(name,value) {validateParam(name,value);await this.request('param',{name,value});this.params[name]=value;}
|
||||||
|
setBypass(enabled) {return this.setParam('bypass',Number(Boolean(enabled)));}
|
||||||
|
async setText(effect,text) {
|
||||||
|
if(!['liveprog','ddc','arbitrary'].includes(effect) || typeof text!=='string' || text.length>65536) throw new RangeError('Invalid text effect');
|
||||||
|
await this.request('text',{effect,text});this.texts[effect]=text;this.params[`${effect}.enabled`]=0;
|
||||||
|
}
|
||||||
|
async loadIR(buffer) {
|
||||||
|
if(buffer.sampleRate!==this.context.sampleRate || ![1,2].includes(buffer.numberOfChannels) || buffer.length>65536 || buffer.length<1) throw new RangeError('IR must be mono/stereo, at context rate, at most 65536 frames');
|
||||||
|
const pcm=new Float32Array(buffer.length*buffer.numberOfChannels);
|
||||||
|
for(let ch=0;ch<buffer.numberOfChannels;ch++) {const data=buffer.getChannelData(ch);for(let i=0;i<buffer.length;i++) pcm[i*buffer.numberOfChannels+ch]=data[i];}
|
||||||
|
await this.request('ir',{pcm,frames:buffer.length,channels:buffer.numberOfChannels});this.params['convolver.enabled']=0;
|
||||||
|
}
|
||||||
|
async applyPreset(preset) {
|
||||||
|
// All keys validated before mutation; applying a preset resets unspecified params.
|
||||||
|
const params={...DEFAULT_PARAMS,...(preset.params||preset)};
|
||||||
|
for(const [name,value] of Object.entries(params)) validateParam(name,value);
|
||||||
|
for(const [effect,text] of Object.entries(preset.texts||{})) await this.setText(effect,text);
|
||||||
|
// Configure before enabling; text and IR resources must have been loaded.
|
||||||
|
for(const [name,value] of Object.entries(params).sort(([a],[b])=>Number(a.endsWith('.enabled'))-Number(b.endsWith('.enabled')))) await this.setParam(name,value);
|
||||||
|
}
|
||||||
|
toJSON() {return {version:1,params:{...this.params},texts:{...this.texts}};}
|
||||||
|
destroy() {
|
||||||
|
if(this.destroyed) return;
|
||||||
|
if(this.source) {try {this.source.disconnect(this.node);} catch {}} this.node.disconnect();
|
||||||
|
this.node.port.postMessage({type:'destroy'});this.destroyed=true;this.fail(new Error('DSP destroyed'));this.node.port.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
27
frontend/vendor/jamesdsp/params.js
vendored
Normal file
27
frontend/vendor/jamesdsp/params.js
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
export const EQ_FREQUENCIES = [25,40,63,100,160,250,400,630,1000,1600,2500,4000,6300,10000,16000];
|
||||||
|
export const COMP_FREQUENCIES = [95,200,400,800,1600,3400,7500];
|
||||||
|
export const EFFECTS = ['eq','compressor','bass','stereo','reverb','crossfeed','tube','convolver','liveprog','ddc','arbitrary'];
|
||||||
|
export const PARAMS = {
|
||||||
|
bypass:[0,1,0], gain:[-24,12,0],
|
||||||
|
'limiter.threshold':[-24,0,-0.1], 'limiter.release':[10,1000,100],
|
||||||
|
'eq.mode':[0,5,1], 'eq.interpolation':[0,1,0],
|
||||||
|
'compressor.time':[0.01,2,0.1], 'compressor.mode':[0,3,3], 'compressor.granularity':[0,4,0],
|
||||||
|
'bass.boost':[0,12,6], 'stereo.mix':[0,1,0.5], 'reverb.preset':[0,18,0],
|
||||||
|
'crossfeed.mode':[0,5,0], 'tube.drive':[-12,12,0],
|
||||||
|
};
|
||||||
|
for (const effect of EFFECTS) PARAMS[`${effect}.enabled`] = [0,1,0];
|
||||||
|
EQ_FREQUENCIES.forEach((_, i) => PARAMS[`eq.band${i}`] = [-12,12,0]);
|
||||||
|
COMP_FREQUENCIES.forEach((_, i) => PARAMS[`compressor.band${i}`] = [-12,12,0]);
|
||||||
|
export const DEFAULT_PARAMS = Object.fromEntries(Object.entries(PARAMS).map(([k,v])=>[k,v[2]]));
|
||||||
|
export function validateParam(name, value) {
|
||||||
|
const spec=PARAMS[name];
|
||||||
|
if (!spec || typeof value!=='number' || !Number.isFinite(value) || value<spec[0] || value>spec[1]) throw new RangeError(`Invalid parameter ${name}: ${value}`);
|
||||||
|
if ((name.endsWith('.enabled') || /mode|preset|granularity|interpolation|bypass/.test(name)) && !Number.isInteger(value)) throw new RangeError(`${name} requires an integer`);
|
||||||
|
}
|
||||||
|
export const PRESETS = {
|
||||||
|
Flat: {},
|
||||||
|
'Warm lows': {'eq.band0':4,'eq.band1':4,'eq.band2':3,'eq.band3':2,'eq.enabled':1,'gain':-3},
|
||||||
|
'Headphones': {'crossfeed.enabled':1,'crossfeed.mode':1,'eq.band8':-2,'eq.enabled':1},
|
||||||
|
'Small room': {'reverb.preset':7,'reverb.enabled':1,'gain':-3},
|
||||||
|
};
|
||||||
55
frontend/vendor/jamesdsp/worklet.js
vendored
Normal file
55
frontend/vendor/jamesdsp/worklet.js
vendored
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
import createJamesDSP from './dsp/jamesdsp.js';
|
||||||
|
class JamesDSPProcessor extends AudioWorkletProcessor {
|
||||||
|
constructor(options) {
|
||||||
|
super(); this.closed=false;this.failed=false;
|
||||||
|
this.initialize(options);
|
||||||
|
}
|
||||||
|
async initialize(options) {
|
||||||
|
try {
|
||||||
|
this.wasm=await createJamesDSP({wasmBinary:options.processorOptions.wasmBytes,locateFile:()=>'jamesdsp.wasm',print:()=>{},printErr:()=>{}});
|
||||||
|
this.handle=this.wasm._jdsp_create(sampleRate);
|
||||||
|
this.pointer=this.wasm._malloc(128*2*4);
|
||||||
|
if(!this.handle || !this.pointer) throw new Error('DSP allocation failed or sample rate unsupported');
|
||||||
|
this.audio=this.wasm.HEAPF32.subarray(this.pointer/4,this.pointer/4+256);
|
||||||
|
this.port.onmessage=({data})=>this.message(data);
|
||||||
|
this.port.postMessage({type:'ready'});
|
||||||
|
} catch(error) {this.failed=true;this.port.postMessage({type:'error',error:String(error)});}
|
||||||
|
}
|
||||||
|
string(value,fn) {
|
||||||
|
const utf8=encodeURIComponent(value+'\0').replace(/%([0-9A-F]{2})/g,(_,hex)=>String.fromCharCode(parseInt(hex,16)));
|
||||||
|
const bytes=Uint8Array.from(utf8,c=>c.charCodeAt(0)), p=this.wasm._malloc(bytes.length);
|
||||||
|
if(!p) throw new Error('DSP text allocation failed');
|
||||||
|
try {this.wasm.HEAPU8.set(bytes,p);return fn(p);} finally {this.wasm._free(p);}
|
||||||
|
}
|
||||||
|
message(data) {
|
||||||
|
if(data.type==='destroy') {if(this.handle) this.wasm._jdsp_destroy(this.handle);if(this.pointer) this.wasm._free(this.pointer);this.closed=true;return;}
|
||||||
|
try {
|
||||||
|
let result;
|
||||||
|
if(data.type==='param') result=this.string(data.name,p=>this.wasm._jdsp_set_param(this.handle,p,data.value));
|
||||||
|
else if(data.type==='text') result=this.string(data.effect,e=>this.string(data.text,t=>this.wasm._jdsp_set_text(this.handle,e,t)));
|
||||||
|
else if(data.type==='ir') {
|
||||||
|
const p=this.wasm._malloc(data.pcm.byteLength);if(!p) throw new Error('IR allocation failed');
|
||||||
|
try {this.wasm.HEAPF32.set(data.pcm,p/4);result=this.wasm._jdsp_load_ir(this.handle,p,data.frames,data.channels);} finally {this.wasm._free(p);}
|
||||||
|
} else throw new Error('Unknown DSP message');
|
||||||
|
if(result!==0) throw new Error(`DSP rejected ${data.name||data.effect||data.type}`);
|
||||||
|
this.port.postMessage({type:'reply',id:data.id,result});
|
||||||
|
} catch(error) {this.port.postMessage({type:'reply',id:data.id,error:String(error)});}
|
||||||
|
}
|
||||||
|
process(inputs,outputs) {
|
||||||
|
if(this.closed) return false;
|
||||||
|
const out=outputs[0], input=inputs[0];
|
||||||
|
if(!out?.length || this.failed || !this.handle) return true;
|
||||||
|
// Mono duplicates to stereo; missing input becomes silence, preserving tails.
|
||||||
|
for(let offset=0;offset<out[0].length;offset+=128) {
|
||||||
|
const n=Math.min(128,out[0].length-offset);
|
||||||
|
for(let i=0;i<n;i++) {this.audio[i*2]=input[0]?.[offset+i]||0;this.audio[i*2+1]=(input[1]||input[0])?.[offset+i]||0;}
|
||||||
|
try {
|
||||||
|
if(this.wasm._jdsp_process(this.handle,this.pointer,n)!==0) throw new Error('DSP produced nonfinite output');
|
||||||
|
for(let i=0;i<n;i++) {out[0][offset+i]=this.audio[i*2];if(out[1]) out[1][offset+i]=this.audio[i*2+1];}
|
||||||
|
} catch(error) {out.forEach(channel=>channel.fill(0));this.failed=true;this.port.postMessage({type:'error',error:String(error)});break;}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registerProcessor('jamesdsp',JamesDSPProcessor);
|
||||||
1523
frontend/views-core.js
Normal file
1523
frontend/views-core.js
Normal file
File diff suppressed because it is too large
Load Diff
41
frontend/visible-timer.js
Normal file
41
frontend/visible-timer.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
(function (root) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function setIntervalWhenVisible(fn, ms, { immediate = false } = {}) {
|
||||||
|
if (typeof fn !== 'function') throw new TypeError('fn must be a function');
|
||||||
|
const doc = root.document;
|
||||||
|
if (!doc) throw new Error('document is required');
|
||||||
|
|
||||||
|
let timer = null;
|
||||||
|
let stopped = false;
|
||||||
|
const clear = () => {
|
||||||
|
if (timer !== null) root.clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
};
|
||||||
|
const start = (runNow) => {
|
||||||
|
if (stopped || doc.hidden) return;
|
||||||
|
clear();
|
||||||
|
if (runNow) fn();
|
||||||
|
timer = root.setInterval(fn, ms);
|
||||||
|
};
|
||||||
|
const onVisibilityChange = () => {
|
||||||
|
if (doc.hidden) clear();
|
||||||
|
else start(immediate);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!doc.hidden) start(immediate);
|
||||||
|
doc.addEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
return {
|
||||||
|
clear() {
|
||||||
|
if (stopped) return;
|
||||||
|
stopped = true;
|
||||||
|
clear();
|
||||||
|
doc.removeEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = { setIntervalWhenVisible };
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||||
|
else root.VisibleTimer = api;
|
||||||
|
})(globalThis);
|
||||||
90
frontend/visible-timer.test.js
Normal file
90
frontend/visible-timer.test.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { setIntervalWhenVisible } = require('./visible-timer');
|
||||||
|
|
||||||
|
class FakeDocument {
|
||||||
|
hidden = false;
|
||||||
|
listeners = new Set();
|
||||||
|
addEventListener(type, listener) { if (type === 'visibilitychange') this.listeners.add(listener); }
|
||||||
|
removeEventListener(type, listener) { if (type === 'visibilitychange') this.listeners.delete(listener); }
|
||||||
|
setHidden(hidden) {
|
||||||
|
this.hidden = hidden;
|
||||||
|
for (const listener of [...this.listeners]) listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeClock(t, hidden = false) {
|
||||||
|
const doc = new FakeDocument();
|
||||||
|
doc.hidden = hidden;
|
||||||
|
const original = {
|
||||||
|
document: globalThis.document,
|
||||||
|
setInterval: globalThis.setInterval,
|
||||||
|
clearInterval: globalThis.clearInterval,
|
||||||
|
};
|
||||||
|
let nextId = 1;
|
||||||
|
const timers = new Map();
|
||||||
|
globalThis.document = doc;
|
||||||
|
globalThis.setInterval = (fn, ms) => {
|
||||||
|
const id = nextId++;
|
||||||
|
timers.set(id, { fn, ms });
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
globalThis.clearInterval = (id) => timers.delete(id);
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.document = original.document;
|
||||||
|
globalThis.setInterval = original.setInterval;
|
||||||
|
globalThis.clearInterval = original.clearInterval;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
doc,
|
||||||
|
timers,
|
||||||
|
tick() { for (const timer of [...timers.values()]) timer.fn(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('runs on a visible page and has one interval until hidden', (t) => {
|
||||||
|
const clock = fakeClock(t);
|
||||||
|
let calls = 0;
|
||||||
|
const handle = setIntervalWhenVisible(() => calls++, 250);
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
assert.equal(clock.timers.size, 1);
|
||||||
|
assert.equal([...clock.timers.values()][0].ms, 250);
|
||||||
|
clock.tick();
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
clock.doc.setHidden(true);
|
||||||
|
assert.equal(clock.timers.size, 0);
|
||||||
|
clock.tick();
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
handle.clear();
|
||||||
|
assert.equal(clock.doc.listeners.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('immediate mode runs once initially and once when becoming visible', (t) => {
|
||||||
|
const clock = fakeClock(t);
|
||||||
|
let calls = 0;
|
||||||
|
const handle = setIntervalWhenVisible(() => calls++, 1000, { immediate: true });
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
assert.equal(clock.timers.size, 1);
|
||||||
|
clock.doc.setHidden(true);
|
||||||
|
assert.equal(clock.timers.size, 0);
|
||||||
|
clock.doc.setHidden(false);
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
assert.equal(clock.timers.size, 1);
|
||||||
|
handle.clear();
|
||||||
|
clock.doc.setHidden(true);
|
||||||
|
clock.doc.setHidden(false);
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
assert.equal(clock.timers.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a page that starts hidden waits until visible before calling or scheduling', (t) => {
|
||||||
|
const clock = fakeClock(t, true);
|
||||||
|
let calls = 0;
|
||||||
|
const handle = setIntervalWhenVisible(() => calls++, 500, { immediate: true });
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
assert.equal(clock.timers.size, 0);
|
||||||
|
clock.doc.setHidden(false);
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
assert.equal(clock.timers.size, 1);
|
||||||
|
handle.clear();
|
||||||
|
});
|
||||||
458
frontend/watch-party-client.js
Normal file
458
frontend/watch-party-client.js
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
/* Lazy classic chunk; its executing singleton stays pinned until reload. */
|
||||||
|
(window.YT ||= {}).Party = (() => {
|
||||||
|
const noop = { boot() {}, guestActive: () => false, intercept: () => false, afterLoad() {} };
|
||||||
|
if (!WEB || typeof WebSocket === 'undefined') return noop;
|
||||||
|
const PID_KEY = 'ytpPartyPid', HOST_KEY = 'ytpPartyHost', NAME_KEY = 'ytpPartyName';
|
||||||
|
const ls = {
|
||||||
|
get: (k) => { try { return localStorage.getItem(k); } catch { return null; } },
|
||||||
|
set: (k, v) => { try { if (v == null) localStorage.removeItem(k); else localStorage.setItem(k, v); } catch { /* blocked */ } },
|
||||||
|
};
|
||||||
|
let pid = ls.get(PID_KEY);
|
||||||
|
if (!pid) { pid = 'p' + Math.random().toString(36).slice(2, 12) + Date.now().toString(36); ls.set(PID_KEY, pid); }
|
||||||
|
const wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/party?${new URLSearchParams(q)}`;
|
||||||
|
|
||||||
|
const st = {
|
||||||
|
ws: null, role: null, code: '', secret: '', name: '', members: [], chat: [], state: null, offset: 0,
|
||||||
|
dj: { enabled: false, entries: [] }, djPending: false,
|
||||||
|
allowControl: false, quietUntil: 0, detached: false, retry: null, pushTimer: null, driftTimer: null, lastPush: '',
|
||||||
|
voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() },
|
||||||
|
minimized: false,
|
||||||
|
};
|
||||||
|
const inParty = () => !!st.role;
|
||||||
|
const isHost = () => st.role === 'host';
|
||||||
|
const guestActive = () => st.role === 'guest' && !st.detached;
|
||||||
|
const send = (m) => { if (st.ws && st.ws.readyState === 1) st.ws.send(JSON.stringify(m)); };
|
||||||
|
const serverNow = () => Date.now() + st.offset;
|
||||||
|
const myName = () => st.name || (data.profile && data.profile.name) || ls.get(NAME_KEY) || '';
|
||||||
|
|
||||||
|
// ---------- connection ----------
|
||||||
|
function connect() {
|
||||||
|
clearTimeout(st.retry);
|
||||||
|
const q = { role: st.role, pid, name: myName() || 'Guest' };
|
||||||
|
if (st.code) q.code = st.code;
|
||||||
|
if (isHost() && st.secret) q.secret = st.secret;
|
||||||
|
const ws = new WebSocket(wsUrl(q));
|
||||||
|
st.ws = ws;
|
||||||
|
ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onMessage(m); };
|
||||||
|
ws.onclose = (e) => {
|
||||||
|
if (st.ws !== ws) return;
|
||||||
|
st.ws = null;
|
||||||
|
if (!inParty()) return;
|
||||||
|
if (e.code === 4010 || e.code === 4004 || e.code === 4003) {
|
||||||
|
const why = e.code === 4004 ? 'That party code doesn’t exist (or has ended).' : e.code === 4003 ? 'This party belongs to another host.' : 'The party ended.';
|
||||||
|
leave(false);
|
||||||
|
toast(`🎉 ${why}`, { duration: 5000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus('Reconnecting…');
|
||||||
|
st.retry = setTimeout(connect, 3000);
|
||||||
|
};
|
||||||
|
ws.onerror = () => { if (!st.code && isHost()) setStatus('Could not reach the server'); };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMessage(m) {
|
||||||
|
DirectMedia.attach('party', { send, peers: () => st.members.filter(m => m.pid !== pid).map(m => ({ id: m.pid, name: m.name, files: m.host && st.state?.directAvailable && st.state?.v ? [st.state.v.id] : [] })) });
|
||||||
|
if (m.type === 'direct') { DirectMedia.handle('party', m); return; }
|
||||||
|
if (m.type === 'hello') {
|
||||||
|
st.code = m.code;
|
||||||
|
if (m.secret) { st.secret = m.secret; ls.set(HOST_KEY, JSON.stringify({ code: m.code, secret: m.secret })); }
|
||||||
|
st.offset = (m.now || Date.now()) - Date.now();
|
||||||
|
st.members = m.members || [];
|
||||||
|
st.chat = m.chat || [];
|
||||||
|
st.dj = m.dj || { enabled: false, entries: [] }; st.djPending = false;
|
||||||
|
st.allowControl = !!m.allowControl;
|
||||||
|
setStatus(isHost() ? 'You’re hosting' : 'Synced with the host');
|
||||||
|
render();
|
||||||
|
if (isHost()) pushState(true);
|
||||||
|
else if (m.state) applyState(m.state);
|
||||||
|
if (st.voice.on) { send({ type: 'voice', on: true }); connectVoicePeers(); }
|
||||||
|
} else if (m.type === 'dj') {
|
||||||
|
st.dj = { enabled: m.enabled, entries: m.entries || [] }; render();
|
||||||
|
} else if (m.type === 'dj-play' && isHost()) {
|
||||||
|
st.djPending = false; if (m.video) { Player._handoff = null; playVideoAt(m.video); } else { st.dj.entries = st.dj.entries.filter(e => !e.approved); advanceQueue(); }
|
||||||
|
} else if (m.type === 'state') {
|
||||||
|
st.state = m.state;
|
||||||
|
if (st.role === 'guest') applyState(m.state);
|
||||||
|
} else if (m.type === 'members') {
|
||||||
|
st.members = m.members || [];
|
||||||
|
if (m.joined) sysMsg(`${m.joined} joined`);
|
||||||
|
if (m.left) sysMsg(`${m.left} left${m.hostAway ? ' — waiting for the host to come back' : ''}`);
|
||||||
|
render();
|
||||||
|
syncVoicePeers();
|
||||||
|
} else if (m.type === 'chat') {
|
||||||
|
st.chat.push(m.msg);
|
||||||
|
if (st.chat.length > 100) st.chat.shift();
|
||||||
|
renderChat();
|
||||||
|
if (m.msg.pid !== pid && (st.minimized || $('partyPanel').classList.contains('hidden'))) toast(`💬 ${m.msg.from}: ${m.msg.text}`);
|
||||||
|
} else if (m.type === 'settings') {
|
||||||
|
st.allowControl = !!m.allowControl;
|
||||||
|
render();
|
||||||
|
if (!isHost()) sysMsg(st.allowControl ? 'The host lets everyone control playback' : 'Only the host controls playback now');
|
||||||
|
} else if (m.type === 'cmd' && isHost()) {
|
||||||
|
runCommand(m);
|
||||||
|
} else if (m.type === 'rtc') {
|
||||||
|
onRtc(m.from, m.data);
|
||||||
|
} else if (m.type === 'ended') {
|
||||||
|
toast(`🎉 ${m.reason || 'The party ended'}`, { duration: 5000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- host: broadcast ----------
|
||||||
|
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 || '', duration: meta.duration || 0 } : null,
|
||||||
|
cur: M ? M.currentTime || 0 : 0,
|
||||||
|
paused: !M || M.paused,
|
||||||
|
rate: (M && M.playbackRate) || 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function pushState(force = false) {
|
||||||
|
if (!isHost() || !st.ws) return;
|
||||||
|
const s = stateNow();
|
||||||
|
const key = JSON.stringify({ ...s, cur: Math.round(s.cur) });
|
||||||
|
if (!force && key === st.lastPush) return;
|
||||||
|
st.lastPush = key;
|
||||||
|
send({ type: 'state', state: s });
|
||||||
|
}
|
||||||
|
function runCommand(m) {
|
||||||
|
switch (m.cmd) {
|
||||||
|
case 'toggle': Player.toggle(); break;
|
||||||
|
case 'play': Player.play(); break;
|
||||||
|
case 'pause': Player.pause(); break;
|
||||||
|
case 'seek': if (Number.isFinite(Number(m.t))) Player.seek(Math.max(0, Number(m.t))); break;
|
||||||
|
case 'next': playNext(); break;
|
||||||
|
case 'prev': playPrev(); break;
|
||||||
|
default: return;
|
||||||
|
}
|
||||||
|
toast(`🎉 ${m.from}: ${m.cmd === 'seek' ? `jumped to ${fmtTime(Number(m.t))}` : m.cmd}`);
|
||||||
|
setTimeout(() => pushState(true), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- guest: follow ----------
|
||||||
|
const quiet = (ms = 1500) => { st.quietUntil = Date.now() + ms; };
|
||||||
|
function targetTime(s) {
|
||||||
|
if (!s) return 0;
|
||||||
|
return s.paused ? s.cur : s.cur + ((serverNow() - (s.ts || serverNow())) / 1000) * (s.rate || 1);
|
||||||
|
}
|
||||||
|
async function applyState(s) {
|
||||||
|
st.state = s;
|
||||||
|
if (st.role !== 'guest' || st.detached || !s || !s.v) { render(); return; }
|
||||||
|
const target = targetTime(s);
|
||||||
|
if (!current || !current.meta || current.meta.id !== s.v.id) {
|
||||||
|
quiet(6000);
|
||||||
|
await playVideoAt({ ...s.v }, target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const M = Player.master;
|
||||||
|
if (!M) return;
|
||||||
|
if ((M.playbackRate || 1) !== (s.rate || 1)) { els.speed.value = String(s.rate || 1); Player.applySpeed(); }
|
||||||
|
if (Math.abs((M.currentTime || 0) - target) > 1.5) { quiet(); Player.seek(target); }
|
||||||
|
if (s.paused && !M.paused) { quiet(); Player.pause(); updatePlayBtn(); }
|
||||||
|
else if (!s.paused && M.paused) { quiet(); Player.play(); updatePlayBtn(); }
|
||||||
|
}
|
||||||
|
// A guest's own actions: forwarded when allowed, else they go off sync.
|
||||||
|
function onLocalAction(kind) {
|
||||||
|
if (st.role !== 'guest' || st.detached || Date.now() < st.quietUntil) return;
|
||||||
|
if (st.allowControl) {
|
||||||
|
const M = Player.master;
|
||||||
|
if (kind === 'seeked') send({ type: 'cmd', cmd: 'seek', t: M.currentTime });
|
||||||
|
else send({ type: 'cmd', cmd: kind === 'pause' ? 'pause' : 'play' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
st.detached = true;
|
||||||
|
setStatus('Off sync — you paused or skipped on your own');
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
// playNext/playPrev guard: guests don't change songs themselves.
|
||||||
|
function intercept(cmd) {
|
||||||
|
if (st.role !== 'guest' || st.detached) return false;
|
||||||
|
if (st.allowControl) send({ type: 'cmd', cmd });
|
||||||
|
else toast('🎉 The host picks the songs in a watch party');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function resync() {
|
||||||
|
st.detached = false;
|
||||||
|
setStatus('Synced with the host');
|
||||||
|
render();
|
||||||
|
applyState(st.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- voice (WebRTC mesh) ----------
|
||||||
|
const ICE = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }];
|
||||||
|
function peer(other) {
|
||||||
|
let pc = st.voice.pcs.get(other);
|
||||||
|
if (pc) return pc;
|
||||||
|
pc = new RTCPeerConnection({ iceServers: ICE });
|
||||||
|
st.voice.pcs.set(other, pc);
|
||||||
|
if (st.voice.stream) st.voice.stream.getTracks().forEach((t) => pc.addTrack(t, st.voice.stream));
|
||||||
|
pc.onicecandidate = (e) => { if (e.candidate) send({ type: 'rtc', to: other, data: { ice: e.candidate } }); };
|
||||||
|
pc.ontrack = (e) => {
|
||||||
|
let a = st.voice.audios.get(other);
|
||||||
|
if (!a) { a = new Audio(); a.autoplay = true; a.setAttribute('playsinline', ''); st.voice.audios.set(other, a); }
|
||||||
|
a.srcObject = e.streams[0];
|
||||||
|
a.play().catch(() => {});
|
||||||
|
};
|
||||||
|
pc.onconnectionstatechange = () => { if (['failed', 'closed'].includes(pc.connectionState)) dropPeer(other); render(); };
|
||||||
|
return pc;
|
||||||
|
}
|
||||||
|
function dropPeer(other) {
|
||||||
|
const pc = st.voice.pcs.get(other);
|
||||||
|
if (pc) { try { pc.close(); } catch { /* closed */ } st.voice.pcs.delete(other); }
|
||||||
|
const a = st.voice.audios.get(other);
|
||||||
|
if (a) { a.srcObject = null; st.voice.audios.delete(other); }
|
||||||
|
}
|
||||||
|
async function offerTo(other) {
|
||||||
|
const pc = peer(other);
|
||||||
|
const offer = await pc.createOffer();
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
send({ type: 'rtc', to: other, data: { sdp: pc.localDescription } });
|
||||||
|
}
|
||||||
|
async function onRtc(from, d) {
|
||||||
|
if (!st.voice.on || !d) return;
|
||||||
|
try {
|
||||||
|
if (d.sdp) {
|
||||||
|
const pc = peer(from);
|
||||||
|
await pc.setRemoteDescription(d.sdp);
|
||||||
|
if (d.sdp.type === 'offer') {
|
||||||
|
const ans = await pc.createAnswer();
|
||||||
|
await pc.setLocalDescription(ans);
|
||||||
|
send({ type: 'rtc', to: from, data: { sdp: pc.localDescription } });
|
||||||
|
}
|
||||||
|
} else if (d.ice) {
|
||||||
|
await peer(from).addIceCandidate(d.ice);
|
||||||
|
}
|
||||||
|
} catch { /* a glare/ICE hiccup — the next members update retries */ }
|
||||||
|
}
|
||||||
|
// The member with the smaller pid makes the offer, so each pair connects once.
|
||||||
|
function connectVoicePeers() {
|
||||||
|
if (!st.voice.on) return;
|
||||||
|
for (const m of st.members) if (m.voice && m.pid !== pid && !st.voice.pcs.has(m.pid) && pid < m.pid) offerTo(m.pid).catch(() => {});
|
||||||
|
}
|
||||||
|
function syncVoicePeers() {
|
||||||
|
const voiced = new Set(st.members.filter((m) => m.voice).map((m) => m.pid));
|
||||||
|
for (const other of [...st.voice.pcs.keys()]) if (!voiced.has(other)) dropPeer(other);
|
||||||
|
connectVoicePeers();
|
||||||
|
}
|
||||||
|
async function toggleVoice() {
|
||||||
|
if (st.voice.on) {
|
||||||
|
st.voice.on = false;
|
||||||
|
for (const other of [...st.voice.pcs.keys()]) dropPeer(other);
|
||||||
|
if (st.voice.stream) st.voice.stream.getTracks().forEach((t) => t.stop());
|
||||||
|
st.voice.stream = null;
|
||||||
|
send({ type: 'voice', on: false });
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || typeof RTCPeerConnection === 'undefined') { toast('Voice chat isn’t supported in this browser'); return; }
|
||||||
|
try {
|
||||||
|
st.voice.stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
|
||||||
|
} catch (e) {
|
||||||
|
toast('🎤 Microphone blocked: ' + (e.message || e.name));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
st.voice.on = true;
|
||||||
|
st.voice.muted = false;
|
||||||
|
send({ type: 'voice', on: true });
|
||||||
|
toast('🎤 Voice on — headphones stop the music echoing into your mic', { duration: 5000 });
|
||||||
|
render();
|
||||||
|
connectVoicePeers();
|
||||||
|
}
|
||||||
|
function toggleMute() {
|
||||||
|
if (!st.voice.stream) return;
|
||||||
|
st.voice.muted = !st.voice.muted;
|
||||||
|
st.voice.stream.getAudioTracks().forEach((t) => { t.enabled = !st.voice.muted; });
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- lifecycle ----------
|
||||||
|
function start() {
|
||||||
|
st.role = 'host';
|
||||||
|
st.code = ''; st.secret = ''; st.chat = []; st.members = [];
|
||||||
|
ls.set(HOST_KEY, null);
|
||||||
|
showPanel();
|
||||||
|
setStatus('Starting…');
|
||||||
|
connect();
|
||||||
|
beginTimers();
|
||||||
|
}
|
||||||
|
function join(code, name) {
|
||||||
|
code = String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||||
|
if (code.length !== 6) { toast('Party codes have 6 letters/numbers'); return; }
|
||||||
|
if (name) { st.name = name; ls.set(NAME_KEY, name); }
|
||||||
|
st.role = 'guest';
|
||||||
|
st.code = code; st.detached = false; st.chat = []; st.members = [];
|
||||||
|
showPanel();
|
||||||
|
setStatus('Joining…');
|
||||||
|
connect();
|
||||||
|
beginTimers();
|
||||||
|
}
|
||||||
|
function beginTimers() {
|
||||||
|
clearInterval(st.pushTimer); clearInterval(st.driftTimer);
|
||||||
|
st.pushTimer = setInterval(() => pushState(), 3000);
|
||||||
|
st.driftTimer = setInterval(() => { if (st.role === 'guest' && st.state && !st.detached) applyState(st.state); }, 2500);
|
||||||
|
}
|
||||||
|
function leave(sendEnd = true) {
|
||||||
|
if (isHost() && sendEnd) send({ type: 'end' });
|
||||||
|
if (st.voice.on) toggleVoice();
|
||||||
|
const ws = st.ws;
|
||||||
|
st.ws = null;
|
||||||
|
st.role = null; st.code = ''; st.secret = '';
|
||||||
|
clearTimeout(st.retry); clearInterval(st.pushTimer); clearInterval(st.driftTimer);
|
||||||
|
try { if (ws) ws.close(); } catch { /* gone */ }
|
||||||
|
ls.set(HOST_KEY, null);
|
||||||
|
$('partyPanel').classList.add('hidden');
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- UI ----------
|
||||||
|
// Lyrics-instead-of-video is a per-device view: each guest chooses it for
|
||||||
|
// themselves, and the host's playback is untouched (the video keeps playing
|
||||||
|
// behind the lyrics, so sync and audio carry on).
|
||||||
|
const lyricsOn = () => !!(Notes.stageLyrics && Notes.stageLyrics.on());
|
||||||
|
function renderLyricsToggle() {
|
||||||
|
const b = document.querySelector('#partyPanel .pt-lyrics');
|
||||||
|
if (!b) return;
|
||||||
|
b.textContent = lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video';
|
||||||
|
b.classList.toggle('on', lyricsOn());
|
||||||
|
}
|
||||||
|
function setStatus(t) { const el = $('ptStatus'); if (el) el.textContent = t; }
|
||||||
|
function sysMsg(text) { st.chat.push({ sys: true, text, at: Date.now() }); renderChat(); }
|
||||||
|
function link() { return `${location.origin}/?party=${st.code}`; }
|
||||||
|
function showPanel() { Lazy.load('feature:party-dj').then(render).catch(error=>toast(error.message)); Lazy.load('feature:direct').catch(error=>toast(error.message)); $('partyPanel').classList.remove('hidden'); st.minimized = false; $('partyPanel').classList.remove('min'); render(); }
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const panel = $('partyPanel');
|
||||||
|
const chip = $('navPartyBtn');
|
||||||
|
if (chip) {
|
||||||
|
const label = chip.querySelector('.nav-label');
|
||||||
|
if (label) label.textContent = inParty() ? `Party · ${st.members.length}` : 'Watch party';
|
||||||
|
else chip.textContent = inParty() ? `🎉 Party · ${st.members.length}` : '🎉 Watch party';
|
||||||
|
}
|
||||||
|
if (!panel || panel.classList.contains('hidden')) return;
|
||||||
|
const nameOk = !!myName();
|
||||||
|
if (!inParty()) {
|
||||||
|
panel.querySelector('.pt-body').innerHTML = `
|
||||||
|
<p class="modal-text">Watch together: everyone's player follows the host, with chat and optional voice.</p>
|
||||||
|
${nameOk ? '' : '<label class="pt-row">Your name <input class="pt-name" maxlength="40" placeholder="e.g. Rex" /></label>'}
|
||||||
|
<button class="rv-primary" type="button" data-act="start">Start a party</button>
|
||||||
|
<div class="pt-or">or join one</div>
|
||||||
|
<div class="pt-row"><input class="pt-code" maxlength="7" placeholder="CODE" autocapitalize="characters" /><button class="np-btn" type="button" data-act="join">Join</button></div>`;
|
||||||
|
$('ptTitle').textContent = '🎉 Watch party';
|
||||||
|
setStatus('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('ptTitle').textContent = `🎉 Party ${st.code || '…'}`;
|
||||||
|
const s = st.state && st.state.v;
|
||||||
|
panel.querySelector('.pt-body').innerHTML = `
|
||||||
|
${st.code ? `<div class="pt-share"><code>${escapeHtml(link())}</code><button class="np-btn" type="button" data-act="copy">Copy</button>${navigator.share ? '<button class="np-btn" type="button" data-act="share">↗</button>' : ''}</div>` : ''}
|
||||||
|
${!isHost() && s ? `<div class="pt-now">${TransportIcons.svg('play')} ${escapeHtml(s.title)}</div>` : ''}
|
||||||
|
${!isHost() && st.detached ? '<button class="rv-primary" type="button" data-act="resync">↻ Re-sync with the host</button>' : ''}
|
||||||
|
<div class="pt-members">${st.members.map((m) => `<span class="pt-member${m.pid === pid ? ' me' : ''}">${m.host ? '👑 ' : ''}${escapeHtml(m.name)}${m.voice ? ' 🎤' : ''}</span>`).join('')}</div>
|
||||||
|
${isHost() ? `<label class="pt-toggle"><input type="checkbox" data-act="allow" ${st.allowControl ? 'checked' : ''} /> Guests can control playback</label>` : ''}
|
||||||
|
<button class="np-btn pt-lyrics${lyricsOn() ? ' on' : ''}" type="button" data-act="lyrics">${lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video'}</button>
|
||||||
|
<div class="pt-voice">
|
||||||
|
<button class="np-btn${st.voice.on ? ' on' : ''}" type="button" data-act="voice">${st.voice.on ? '🎤 Leave voice' : '🎤 Join voice'}</button>
|
||||||
|
${st.voice.on ? `<button class="np-btn" type="button" data-act="mute">${st.voice.muted ? '🔇 Unmute' : '🔈 Mute'}</button>` : ''}
|
||||||
|
<span class="pt-voice-n">${st.voice.on ? `${st.voice.pcs.size} connected` : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pt-chat" id="ptChat"></div>
|
||||||
|
<form class="pt-send"><input class="pt-msg" maxlength="500" placeholder="Message…" autocomplete="off" /><button class="np-btn" type="submit">Send</button></form>
|
||||||
|
<button class="pt-leave" type="button" data-act="leave">${isHost() ? 'End the party' : 'Leave the party'}</button>`;
|
||||||
|
const dj = document.createElement('div'); panel.querySelector('.pt-body').append(dj);
|
||||||
|
if (Lazy.loaded('feature:party-dj')) PartyDJ.render(dj, { state: st.dj, host: isHost(), pid, send, search: async q => { const result = await API.search(q); return result.results || result.videos || []; } });
|
||||||
|
renderChat();
|
||||||
|
}
|
||||||
|
function renderChat() {
|
||||||
|
const box = $('ptChat');
|
||||||
|
if (!box) return;
|
||||||
|
box.innerHTML = st.chat.map((c) => c.sys
|
||||||
|
? `<div class="pt-sys">${escapeHtml(c.text)}</div>`
|
||||||
|
: `<div class="pt-line${c.pid === pid ? ' me' : ''}"><b>${escapeHtml(c.from)}</b> ${escapeHtml(c.text)}</div>`).join('');
|
||||||
|
box.scrollTop = box.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
const panel = $('partyPanel');
|
||||||
|
if (typeof closeSidebar === 'function') { try { closeSidebar(); } catch { /* not open */ } }
|
||||||
|
if (panel.classList.contains('hidden')) showPanel();
|
||||||
|
else if (st.minimized) { st.minimized = false; panel.classList.remove('min'); }
|
||||||
|
else if (!inParty()) panel.classList.add('hidden');
|
||||||
|
else { st.minimized = true; panel.classList.add('min'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindUi() {
|
||||||
|
const panel = $('partyPanel');
|
||||||
|
$('ptMin').addEventListener('click', () => {
|
||||||
|
if (!inParty()) { panel.classList.add('hidden'); return; }
|
||||||
|
st.minimized = !st.minimized;
|
||||||
|
panel.classList.toggle('min', st.minimized);
|
||||||
|
});
|
||||||
|
panel.addEventListener('keydown', (e) => e.stopPropagation());
|
||||||
|
panel.addEventListener('click', async (e) => {
|
||||||
|
const a = e.target.closest('[data-act]');
|
||||||
|
if (!a || a.tagName === 'INPUT') return;
|
||||||
|
const act = a.dataset.act;
|
||||||
|
const nameIn = panel.querySelector('.pt-name');
|
||||||
|
const name = nameIn ? nameIn.value.trim() : '';
|
||||||
|
if ((act === 'start' || act === 'join') && !myName() && !name) { toast('Enter your name first'); if (nameIn) nameIn.focus(); return; }
|
||||||
|
if (name) { st.name = name; ls.set(NAME_KEY, name); }
|
||||||
|
if (act === 'start') start();
|
||||||
|
else if (act === 'join') join(panel.querySelector('.pt-code').value);
|
||||||
|
else if (act === 'copy') { try { await navigator.clipboard.writeText(link()); toast('Party link copied ✓'); } catch { toast('Copy blocked'); } }
|
||||||
|
else if (act === 'share') { try { await navigator.share({ title: 'Join my watch party', url: link() }); } catch { /* dismissed */ } }
|
||||||
|
else if (act === 'resync') resync();
|
||||||
|
else if (act === 'voice') toggleVoice();
|
||||||
|
else if (act === 'mute') toggleMute();
|
||||||
|
else if (act === 'lyrics') { Notes.stageLyrics.set(!lyricsOn()); renderLyricsToggle(); }
|
||||||
|
else if (act === 'leave') leave(true);
|
||||||
|
});
|
||||||
|
panel.addEventListener('change', (e) => {
|
||||||
|
if (e.target.dataset.act === 'allow') { st.allowControl = e.target.checked; send({ type: 'settings', allowControl: st.allowControl }); }
|
||||||
|
});
|
||||||
|
panel.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = panel.querySelector('.pt-msg');
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (text) { send({ type: 'chat', text }); input.value = ''; }
|
||||||
|
});
|
||||||
|
// Host: push on every player change; guests: detect their own actions.
|
||||||
|
['play', 'pause', 'seeked', 'ratechange', 'loadedmetadata'].forEach((ev) => {
|
||||||
|
[els.video, els.audio].forEach((el) => el.addEventListener(ev, (e) => {
|
||||||
|
if (el !== Player.master) return;
|
||||||
|
if (isHost()) pushState(true);
|
||||||
|
else if (ev === 'play' || ev === 'pause' || ev === 'seeked') onLocalAction(ev);
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function boot() {
|
||||||
|
bindUi();
|
||||||
|
render();
|
||||||
|
// Resume hosting after a reload.
|
||||||
|
try {
|
||||||
|
const h = JSON.parse(ls.get(HOST_KEY) || 'null');
|
||||||
|
if (h && h.code && h.secret) { st.role = 'host'; st.code = h.code; st.secret = h.secret; showPanel(); connect(); beginTimers(); }
|
||||||
|
} catch { /* none */ }
|
||||||
|
let code = '';
|
||||||
|
try { code = (new URLSearchParams(location.search).get('party') || '').toUpperCase(); } catch { /* no URL */ }
|
||||||
|
if (code) {
|
||||||
|
try { const u = new URL(location.href); u.searchParams.delete('party'); history.replaceState(null, '', u.pathname + u.search + u.hash); } catch { /* keep */ }
|
||||||
|
if (myName()) join(code);
|
||||||
|
else { showPanel(); const c = $('partyPanel').querySelector('.pt-code'); if (c) c.value = code; toast('Enter your name, then tap Join'); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nextDJ: () => { if (!isHost() || !st.dj.enabled || !st.dj.entries.some(e => e.approved)) return false; if (!st.djPending) { st.djPending = true; send({ type: 'dj', action: 'take' }); } return true; },
|
||||||
|
open, boot, guestActive, intercept, renderLyricsToggle, afterLoad: () => pushState(true),
|
||||||
|
state: () => ({
|
||||||
|
role: st.role, code: st.code, detached: st.detached, members: st.members.length, voice: st.voice.on,
|
||||||
|
peers: st.voice.pcs.size, peerStates: [...st.voice.pcs.values()].map((pc) => pc.connectionState),
|
||||||
|
remoteAudio: [...st.voice.audios.values()].filter((a) => a.srcObject && a.srcObject.getAudioTracks().some((t) => t.readyState === 'live')).length,
|
||||||
|
chat: st.chat.length, allowControl: st.allowControl,
|
||||||
|
}),
|
||||||
|
_join: join, _start: start,
|
||||||
|
};
|
||||||
|
})();
|
||||||
12
frontend/worker-imports.test.js
Normal file
12
frontend/worker-imports.test.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const {test}=require('node:test'), assert=require('node:assert/strict'), vm=require('node:vm'), fs=require('node:fs');
|
||||||
|
for(const name of ['hash-worker','direct-recv-worker','p2p-recv-worker','opfs-worker'])test(name+' imports its originating page hashes before handling the first operation',async()=>{
|
||||||
|
const urls=[], replies=[];const self={postMessage:m=>replies.push(m)};
|
||||||
|
const sandbox={self,console,URL,setTimeout,clearTimeout,Promise,TextEncoder,FileSystemFileHandle:class {createSyncAccessHandle(){}},navigator:{storage:{getDirectory:async()=>{throw Error('fixture: no disk writes')}}},fetch:async()=>{throw Error('fixture: no network')},importScripts:u=>{urls.push(u);if(u.includes('/sha256.js'))self.Sha256={create:()=>({})};if(u.includes('/resume-core.js'))self.ResumeCore={};}};
|
||||||
|
vm.runInNewContext(fs.readFileSync(__dirname+'/'+name+'.js','utf8'),sandbox);
|
||||||
|
assert.deepEqual(urls,[]);
|
||||||
|
await self.onmessage({data:{name:'x',op:'open',videoId:'x',url:'/fixture',file:{id:'x',cid:'a'.repeat(64)},assetUrls:{sha256:'/sha256.js?v=sha',wasm:'/sha256-wasm.js?v=wasm',resume:'/resume-core.js?v=resume'}}});
|
||||||
|
await new Promise(r=>setImmediate(r));
|
||||||
|
assert.equal(urls[0],'/sha256.js?v=sha');assert.equal(self.__ASSET_URLS__.wasm,'/sha256-wasm.js?v=wasm');
|
||||||
|
if(name==='opfs-worker')assert.ok(urls.includes('/resume-core.js?v=resume'));
|
||||||
|
assert.ok(replies.length>0);
|
||||||
|
});
|
||||||
486
perf/README.md
Normal file
486
perf/README.md
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
# Performance Baseline Harness (`perf/`)
|
||||||
|
|
||||||
|
Automated performance baseline and regression harness for the **ytplayer PWA**.
|
||||||
|
|
||||||
|
The harness drives the real Bun backend server (`server/server.js`) serving static Brotli-compressed assets, combined with Playwright browser automation on Chromium and WebKit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architecture
|
||||||
|
|
||||||
|
### Backend Server (`server/server.js`)
|
||||||
|
- Runs as an isolated child process using **Bun** on an ephemeral port.
|
||||||
|
- Configured with dedicated scratch directories under `perf/.tmp/` (`DATA_DIR`, `DB_PATH`, `MEDIA_DIR`) so no production or developer data is ever touched.
|
||||||
|
- Employs the real static asset delivery stack (`Brotli` encoding, `ETag` generation, `Cache-Control` immutable caching for version-stamped assets, and `computeBuildTag()`).
|
||||||
|
|
||||||
|
### Browser Automation & Network Throttling
|
||||||
|
- **Chromium**:
|
||||||
|
- Network emulation via Chrome DevTools Protocol (`Network.emulateNetworkConditions`).
|
||||||
|
- CPU throttling at **4× slowdown** (`Emulation.setCPUThrottlingRate`).
|
||||||
|
- Unrestricted audio/video playback via `--autoplay-policy=no-user-gesture-required`.
|
||||||
|
- **WebKit**:
|
||||||
|
- WebKit on Linux lacks CDP network emulation capabilities.
|
||||||
|
- Throttling and stalling are handled by a dedicated local proxy (`perf/proxy.mjs`) sitting between WebKit and the Bun server.
|
||||||
|
- The proxy shapes traffic by pacing chunk delivery through backpressured streams and injecting round-trip latency and stalls.
|
||||||
|
- **Network Profiles**:
|
||||||
|
- **`lte`**: 1.6 Mbps (200 KB/s) download, 750 kbps upload, 150 ms round-trip latency.
|
||||||
|
- **`lossy`**: 1.0 Mbps (125 KB/s) download, 250 ms latency, with intermittent connection stalls (every 7th request delayed by 1,500 ms).
|
||||||
|
|
||||||
|
### Shell Fixture Extractor (`perf/make-shell-fixture.sh`)
|
||||||
|
- Archives the frontend from git commits (`git archive <commit> frontend`) into `perf/fixtures/shell-<tag>/`.
|
||||||
|
- Computes the build tag using the exact sha256 walk algorithm from `server/server.js computeBuildTag()`.
|
||||||
|
- Validates the computed tag against live production (`https://worship.hesed.sbs/api/version`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Test Scenarios
|
||||||
|
|
||||||
|
1. **`cold` (Cold First Load)**:
|
||||||
|
- Fresh browser context with empty cache, no service worker, and clean storage.
|
||||||
|
- Measures initial page fetch, FCP, LCP, DOMContentLoaded, boot milestone (`#cards` render), media playback readiness, service worker precaching duration, and origin storage quota.
|
||||||
|
2. **`warm` (Warm Reload under SW)**:
|
||||||
|
- Re-navigates the page with active Service Worker and primed HTTP cache.
|
||||||
|
- Measures cached asset retrieval efficiency and second-load boot performance.
|
||||||
|
3. **`offline` (Offline Reload)**:
|
||||||
|
- Sets offline state and reloads the application.
|
||||||
|
- Verifies the app boots entirely from `CacheStorage` precached shell assets without network connectivity.
|
||||||
|
4. **`update-js` (Update N → N+1 with 1-line JS comment)**:
|
||||||
|
- Boots client against Tree N (base frontend), establishing an active Service Worker.
|
||||||
|
- Swaps server to Tree N+1 (one comment line appended to `app.js`, altering `BUILD_TAG`).
|
||||||
|
- Triggers `registration.update()` and measures total wire bytes and time until the new Service Worker reaches `waiting` state (`installed`).
|
||||||
|
5. **`update-css` (Update N → N+1 with 1-line CSS rule)**:
|
||||||
|
- Identical to `update-js`, but Tree N+1 has one CSS rule appended to `theme-glass.css`.
|
||||||
|
- Measures the full shell re-download penalty caused by any stylesheet modification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Metrics Collected
|
||||||
|
|
||||||
|
Each scenario records the following metrics across **5 independent runs** (reporting min, max, and median):
|
||||||
|
- **Request Count**: Total HTTP requests made across the wire.
|
||||||
|
- **Wire Bytes**: Total compressed bytes transferred over the network.
|
||||||
|
- **Wire Bytes by Type**: Categorized byte breakdown (`html`, `js`, `css`, `fonts`, `images`, `media`, `api`, `other`).
|
||||||
|
- **FCP (First Contentful Paint)**: Time from navigation start to first rendered content (ms).
|
||||||
|
- **LCP (Largest Contentful Paint)**: Time to largest contentful paint via `PerformanceObserver` (ms).
|
||||||
|
- **DOMContentLoaded**: Time to DOMContentLoaded event end (ms).
|
||||||
|
- **Boot Done**: Time from navigation start until the first `#cards` view rendering is completed (ms).
|
||||||
|
- **Long Tasks Total**: Cumulative execution time of main thread tasks exceeding 50 ms (ms, Chromium).
|
||||||
|
- **SW Install Duration**: Time from Service Worker registration until precaching completes (ms).
|
||||||
|
- **SW Install Wire Bytes**: Network bytes transmitted during Service Worker installation.
|
||||||
|
- **Storage Estimate**: Quota and usage reported by `navigator.storage.estimate()` (bytes).
|
||||||
|
- **Media Ready**: Elapsed time for deterministic media fixture (`benchmedia01`) to reach `readyState >= 3` / playing state. Notes if autoplay policy blocked playback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. How to Run
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
Symlink dependencies and ensure server dependencies are installed:
|
||||||
|
```bash
|
||||||
|
# Worktree root
|
||||||
|
ln -s ~/development/personal/ytplayer/node_modules node_modules
|
||||||
|
cd server && bun install && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
### Basic Commands
|
||||||
|
Run the complete baseline suite (5 runs, all browsers, LTE profile):
|
||||||
|
```bash
|
||||||
|
node perf/baseline.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Run a specific browser or profile:
|
||||||
|
```bash
|
||||||
|
# Chromium on LTE profile
|
||||||
|
node perf/baseline.mjs --browser chromium --profile lte
|
||||||
|
|
||||||
|
# WebKit on LTE profile
|
||||||
|
node perf/baseline.mjs --browser webkit --profile lte
|
||||||
|
|
||||||
|
# Both browsers on lossy profile
|
||||||
|
node perf/baseline.mjs --browser all --profile lossy
|
||||||
|
```
|
||||||
|
|
||||||
|
Run a single scenario:
|
||||||
|
```bash
|
||||||
|
# Cold load only
|
||||||
|
node perf/baseline.mjs --scenario cold
|
||||||
|
|
||||||
|
# Update scenario with 1-line JS change
|
||||||
|
node perf/baseline.mjs --scenario update-js
|
||||||
|
|
||||||
|
# Update scenario with 1-line CSS change
|
||||||
|
node perf/baseline.mjs --scenario update-css
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure run count:
|
||||||
|
```bash
|
||||||
|
# Quick sanity check (1 iteration)
|
||||||
|
node perf/baseline.mjs --runs 1
|
||||||
|
|
||||||
|
# High precision benchmark (7 iterations)
|
||||||
|
node perf/baseline.mjs --runs 7
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Phase 5 Comparison Mode (`--compare`)
|
||||||
|
|
||||||
|
To verify performance gains and detect regressions in later phases:
|
||||||
|
```bash
|
||||||
|
node perf/baseline.mjs --compare perf/results/baseline-2026-10-07.json
|
||||||
|
```
|
||||||
|
Prints an ASCII/Markdown delta comparison table highlighting percentage changes across wire bytes, boot duration, and update overhead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Hidden Timers Verification (`perf/hidden-timers.mjs`)
|
||||||
|
|
||||||
|
Verifies that non-playback UI intervals (`chips`, `reanchor`, `rotateChips`, `flush`, `checkBuildTag`) suspend while the document is hidden and restore upon visibility, while essential background intervals (`maybeCheck`, 120s resume check) remain active:
|
||||||
|
```bash
|
||||||
|
node perf/hidden-timers.mjs
|
||||||
|
```
|
||||||
|
Exits with code 0 on success, or code 1 if any interval fails to pause, fails to resume, or if page errors occur.
|
||||||
|
|
||||||
|
## Phase 1 production-client migration
|
||||||
|
|
||||||
|
Build the production fixture locally without contacting the unavailable production
|
||||||
|
site (COMMON.md takes precedence over the older live-tag instructions above):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
perf/make-shell-fixture.sh b77938a 1175f1a1d2c1
|
||||||
|
node perf/migration.mjs # Chromium and WebKit
|
||||||
|
node perf/migration.mjs --browser webkit # one browser
|
||||||
|
```
|
||||||
|
|
||||||
|
The harness archives the actual `b77938a` server and uses the reconstructed old
|
||||||
|
frontend, isolated database/media/upload paths, and an ephemeral origin behind the
|
||||||
|
`lossy` throttle/stall proxy. It restarts onto the current server and frontend at
|
||||||
|
that same origin, then invokes the OLD application's update check and clicks its
|
||||||
|
actual Refresh UI button. Assertions cover one banner, exactly one reload, cleared
|
||||||
|
update-attempt state, all new SHELL entries with correct hashes, no script errors,
|
||||||
|
and offline boot. Plain, stale `?v=<oldTag>` and `?__ytpfresh=` asset responses are
|
||||||
|
also compared byte-for-byte with the current files. Results are stored in
|
||||||
|
`perf/results/phase1-migration-2026-10-07.json`; scratch trees are removed on exit.
|
||||||
|
No service-worker source is patched or simulated. Offline checks stop Bun and
|
||||||
|
block the proxy; WebKit avoids its broken native setOffline API. Known WebKit
|
||||||
|
transport errors for disconnected version/recommendation requests are verified
|
||||||
|
separately from script errors.
|
||||||
|
|
||||||
|
The current server snapshots static bytes at boot; restart it after frontend
|
||||||
|
changes. `ASSET_HASHING=0` restores the previous single-tag stamp/cache behavior.
|
||||||
|
The manifest's tag hashes sorted canonical metadata: CSS/JS URLs are stamped
|
||||||
|
before hashing the index, with its derived build-meta placeholder intact. The
|
||||||
|
published index/SW hashes describe their final served bytes after tag injection;
|
||||||
|
these derived hashes are not fed recursively into their own build identity.
|
||||||
|
|
||||||
|
JS-created workers/importScripts/dynamic imports retain their existing plain URLs
|
||||||
|
in Phase 1. Adding a separate loader would require changing the intentionally
|
||||||
|
unchanged SHELL; worker import URL propagation belongs with the client migration.
|
||||||
|
WASM binaries are embedded in the existing `sha256-wasm.js`/`loudness-wasm.js`,
|
||||||
|
which receive hashed URLs as HTML scripts. Font preloads retain plain URLs to match
|
||||||
|
`fonts.css`; the webmanifest and its icon URLs also stay plain and precached.
|
||||||
|
`piano-engine.mjs` is manifested but is historically absent from SHELL; this
|
||||||
|
existing offline piano limitation is deferred to the loader phase.
|
||||||
|
|
||||||
|
## Phase 2 incremental-sync verification
|
||||||
|
|
||||||
|
The current migration harness verifies the production page's own ready fast path
|
||||||
|
into `ytplayer-assets`: no `__ytpfresh` downloads, no reload before Refresh UI,
|
||||||
|
one banner/reload, an open N-1 tab, playback guard, incremental refresh after
|
||||||
|
an eviction, and offline launch. It preserves the legacy user-triggered reload
|
||||||
|
exception. Async CacheStorage/registration conditions are polled with awaited
|
||||||
|
`page.evaluate`; Playwright's `waitForFunction` does not poll async predicates.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node perf/migration.mjs --browser all --profile lossy
|
||||||
|
node perf/migration.mjs --browser all --profile unthrottled
|
||||||
|
node perf/baseline.mjs --runs 1 --browser all --profile lte --scenario all \
|
||||||
|
--out perf/results/phase2-final-2026-10-07.json \
|
||||||
|
--compare perf/results/after-phase0-2026-10-07.json
|
||||||
|
ASSET_SYNC=0 node perf/baseline.mjs --runs 1 --browser all --profile lte \
|
||||||
|
--scenario offline --out perf/results/phase2-rollback-2026-10-07.json
|
||||||
|
ASSET_HASHING=0 node perf/baseline.mjs --runs 1 --browser all --profile lte \
|
||||||
|
--scenario offline --out perf/results/phase2-hashing-rollback-2026-10-07.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Results now include response paths/statuses and compressed response-body bytes
|
||||||
|
for cold/update runs. The total includes service-worker script checks/imports;
|
||||||
|
inspect individual responses to distinguish those from app asset downloads.
|
||||||
|
Migration results are `phase2-migration-<profile>-2026-10-07.json`.
|
||||||
|
`ASSET_SYNC=0` selects the legacy path while preserving the persistent utility
|
||||||
|
cache. `ASSET_HASHING=0` also selects legacy sync because single-tag URLs cannot
|
||||||
|
pass per-file hash checks. In Phase 2 all assets.json groups blocked; Phase 3 selects core plus the active
|
||||||
|
layout and warms background groups at idle. Incremental mode now caches the manifested
|
||||||
|
`piano-engine.mjs` even though it remains absent from the legacy SHELL.
|
||||||
|
|
||||||
|
|
||||||
|
## Phase 3 lazy loading and staged apply
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node perf/lazy.mjs --browser all
|
||||||
|
node perf/migration.mjs --browser all --profile lossy
|
||||||
|
node perf/migration.mjs --browser all --profile unthrottled
|
||||||
|
node perf/baseline.mjs --runs 1 --browser all --profile lte --scenario all \
|
||||||
|
--out perf/results/phase3-final-2026-10-08.json \
|
||||||
|
--compare perf/results/after-phase0-2026-10-07.json
|
||||||
|
node perf/baseline.mjs --runs 1 --browser all --profile lte --scenario cold \
|
||||||
|
--frontend-commit 7bc5b4f8b50d78ece3cca5100d54a97fece2748a \
|
||||||
|
--out perf/results/phase3-phase0-initial-2026-10-08.json
|
||||||
|
ASSET_SYNC=0 node perf/baseline.mjs --runs 1 --browser all --profile lte \
|
||||||
|
--scenario offline --out perf/results/phase3-sync-rollback-2026-10-08.json
|
||||||
|
ASSET_HASHING=0 node perf/baseline.mjs --runs 1 --browser all --profile lte \
|
||||||
|
--scenario offline --out perf/results/phase3-hashing-rollback-2026-10-08.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`initialJsCss` sums compressed Resource Timing bodies for page JS/CSS initiated
|
||||||
|
before DOMContentLoaded, excluding SW precache and later idle feature parsing.
|
||||||
|
The saved Phase 0 results lack that metric: `--frontend-commit` reconstructs its
|
||||||
|
frontend in scratch using the current delivery backend for a matched initial-byte
|
||||||
|
comparison. Historical boot/FCP comparisons still use `after-phase0`. Reduced
|
||||||
|
single-run measurements are directional; Phase 5 supplies repeated medians.
|
||||||
|
Update scenarios now wait for every build-N group to finish idle caching before
|
||||||
|
starting the counter, excluding unfinished initial downloads. Waiting-worker
|
||||||
|
bytes represent blocking readiness; an inactive layout's changed CSS downloads
|
||||||
|
later at idle after activation.
|
||||||
|
|
||||||
|
The browser harness checks pre-body layout selection, immediate offline reload,
|
||||||
|
four distinct Car mode controls, optional Settings search, all extension panels,
|
||||||
|
zero network for warmed first use/layout switches, contract guards and pinned
|
||||||
|
instances. Known WebKit disconnected API transport errors are recorded separately;
|
||||||
|
script errors still fail. Save-Data prevents automatic warming.
|
||||||
|
|
||||||
|
The index contains inert JSON and an external head bootstrap under unchanged
|
||||||
|
CSP. Source JSON mirrors assets.json for native/static shells; the server replaces
|
||||||
|
it with hashed build-local URLs. No boot-time manifest network request is added.
|
||||||
|
Shared layout visibility defaults remain eager in layout-base.css.
|
||||||
|
Pure EQ settings metadata and DeviceDB remain eager to preserve synchronous
|
||||||
|
player defaults and offline file bookkeeping. P2P/direct parse on actual use or
|
||||||
|
the existing eight-second sharing startup. Searching Settings explicitly loads
|
||||||
|
optional panels so their original labels remain searchable.
|
||||||
|
|
||||||
|
| JS-requested assets | Phase 3 URL treatment |
|
||||||
|
|---|---|
|
||||||
|
| opfs-worker.js, hash-worker.js, direct-recv-worker.js, p2p-recv-worker.js | Lazy.worker uses embedded per-file URL |
|
||||||
|
| Workers' sha256.js, resume-core.js, sha256-wasm.js | First postMessage carries originating build URLs; importScripts uses them |
|
||||||
|
| piano-engine.mjs | Dynamic import uses Lazy.url; incremental cache warms it |
|
||||||
|
| vendor/framework7-swipe.min.js | Ordered layout group and fallback use Lazy.url |
|
||||||
|
| loudness-wasm.js | Remains an eager stamped script; embedded binary needs no separate URL |
|
||||||
|
| fonts/fonts.css and font preloads; webmanifest/icons | Plain URLs stay aligned with CSS/manifest references; all belong to manifested cache groups |
|
||||||
|
|
||||||
|
Legacy pages cannot report their selected layout. Their one-time migration blocks
|
||||||
|
all layouts to preserve immediate offline selection; modern pages block only the
|
||||||
|
reported layout and its shared controls. Executing groups never re-evaluate live.
|
||||||
|
See plans/phase3-staged-apply-decision.md: Phase 4 must add disposal/state handoff.
|
||||||
|
|
||||||
|
## Phase 4 seams and verification
|
||||||
|
|
||||||
|
The dependency inventory in `plans/phase4-seams.md` records Phase 3 declarations,
|
||||||
|
resolved outside references and byte/coupling rankings. Reproduce its source using
|
||||||
|
`npm install --prefix perf/.tmp/analysis --no-audit --no-fund acorn@8 eslint-scope@8`
|
||||||
|
and `node perf/analyze-app.cjs cd4a25a` (development scratch dependencies only).
|
||||||
|
Shell/shared view/Settings/section-rail definitions remain eager before app.js.
|
||||||
|
Presenter, Remote client, Watch party, Share/External and Stats view register
|
||||||
|
classic singletons on `window.YT`; synchronous playback hooks remain facades.
|
||||||
|
Executing chunks remain pinned: disposal/state handoff is still required before
|
||||||
|
any live replacement can be enabled.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --test frontend/*.test.js
|
||||||
|
(cd server && bun install && bun run test)
|
||||||
|
node perf/hidden-timers.mjs
|
||||||
|
node perf/seams.mjs --browser all
|
||||||
|
node perf/lazy.mjs --browser all
|
||||||
|
node perf/migration.mjs --browser all --profile lossy \
|
||||||
|
--out perf/results/phase4-migration-lossy-2026-10-08.json
|
||||||
|
node perf/migration.mjs --browser all --profile unthrottled \
|
||||||
|
--out perf/results/phase4-migration-unthrottled-2026-10-08.json
|
||||||
|
node perf/baseline.mjs --runs 1 --browser all --profile lte --scenario all \
|
||||||
|
--out perf/results/phase4-final-2026-10-08.json \
|
||||||
|
--compare perf/results/after-phase0-2026-10-07.json
|
||||||
|
node perf/baseline.mjs --runs 3 --browser chromium --profile lte --scenario cold \
|
||||||
|
--out perf/results/phase4-cold-isolated-2026-10-08.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`update-feature` appends one comment to presenter.js in scratch, installs the worker,
|
||||||
|
explicitly activates, then waits for the changed background chunk to be verified
|
||||||
|
in CacheStorage. It asserts that no other application JS/CSS/font/icon payload
|
||||||
|
transfers; derived index/manifest and worker script checks are included in totals.
|
||||||
|
`all` includes this scenario when the source contains presenter.js. Cold records
|
||||||
|
now include individual long-task timing without changing the aggregate metric,
|
||||||
|
plus the source frontend build tag. WebKit does not expose the long-task API.
|
||||||
|
Migration supports `--out` to preserve earlier phase evidence. Existing lazy
|
||||||
|
harness results are copied to `phase4-lazy-2026-10-08.json` after each Phase 4 run
|
||||||
|
and the Phase 3 result is restored. No production requests or deploys are needed.
|
||||||
|
|
||||||
|
## Phase 5 complete measurements
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --test perf/measurement.test.mjs
|
||||||
|
node perf/baseline.mjs --runs 5 --browser all --profile all --scenario all \
|
||||||
|
--out perf/results/final-2026-10-08.json \
|
||||||
|
--compare perf/results/baseline-2026-10-07.json
|
||||||
|
node perf/lazy.mjs --browser all --out perf/results/phase5-lazy-2026-10-08.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Warm reloads now wait for verified idle asset caching before counters reset and
|
||||||
|
assert that no application asset body transfers. CSS update totals now include
|
||||||
|
activation and the changed inactive stylesheet's verified background cache fill,
|
||||||
|
matching the feature-update boundary. `readyWireBytes`/`readyDuration` retain the
|
||||||
|
blocking readiness snapshot separately. Core updates stop at blocking readiness
|
||||||
|
because app.js is blocking. Every update asserts that the only application asset
|
||||||
|
payload is the edited file; worker script checks/imports and index/manifest count
|
||||||
|
as overhead in the total. Historical Phase 3/4 CSS numbers were readiness only.
|
||||||
|
No product behavior changes are involved in these measurement corrections.
|
||||||
|
|
||||||
|
Keep browser/build/test processes idle during full timing measurements. WebKit's
|
||||||
|
zero long-task field means unavailable, not zero work. Autoplay-blocked/media-ready
|
||||||
|
null results do not establish playback performance or iPhone audio continuity.
|
||||||
|
|
||||||
|
## UI geometry and media icons
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node perf/ui-geometry.mjs
|
||||||
|
node perf/ui-icons.mjs
|
||||||
|
node --test frontend/media-icons.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Both harnesses run Chromium and WebKit at mobile dimensions with stubbed SpeechRecognition and local Saved/Queue metadata. They serve only the frontend, block service workers and external requests, and never download media. Geometry tests cold and switched layouts, six widths (320–430), mic visible/hidden and update icon visible/hidden: center spread ≤1px, touch targets ≥40px, Saved image/text gap ≥8px, no horizontal overflow or clipped car control. `--quick` limits widths and speech cases; `--browser` selects an engine; `--frontend` selects historical fixtures. `--measure-only --label before` records known broken geometry without exiting nonzero.
|
||||||
|
|
||||||
|
Icon checks switch the same page through four layouts × dark/light/contrast, assert SVG definitions, inherited colour, stroke family and save/progress/offline state, and save Queue/Now Playing/navigation shots. Glass Stage intentionally hides bottom navigation, so its full page is recorded instead. Any assertion failure exits nonzero. Shots and raw measurements live in git-ignored plans/ui-shots/. Full production inventory: plans/ui-icon-inventory.md. Native speech recognition, installed-PWA safe-area insets and iPhone rendering still need device verification.
|
||||||
|
|
||||||
|
### Cold waterfall diagnosis
|
||||||
|
|
||||||
|
`--source-root perf/.tmp/<historical-worktree>` runs that worktree's matching
|
||||||
|
frontend **and server**; `--frontend-commit` instead combines archived frontend
|
||||||
|
with the current backend. Run historical comparisons sequentially to avoid CPU
|
||||||
|
contention. The result records `sourceCommit`, `sourceRoot` and the served build.
|
||||||
|
Every cold sample has `waterfalls`: proxy request order/start/headers/end times
|
||||||
|
and page Resource Timing discovery/start/end times. Proxy times start when
|
||||||
|
tracking begins; page times start at navigation. Compare within each clock,
|
||||||
|
then use the page FCP to locate the rendering dependency.
|
||||||
|
|
||||||
|
Pass `--assert-cold` on new builds to require exactly one positive-byte app.js
|
||||||
|
response in every cold sample, including worker installation. Omit this assertion
|
||||||
|
when diagnosing historical regressions.
|
||||||
|
|
||||||
|
Phase 6 counts its fetch-owned app.js response in initial JS/CSS bytes. Since the
|
||||||
|
bootstrap consumes that response asynchronously, DOMContentLoaded no longer means
|
||||||
|
app execution is complete; compare the existing actual boot-done landmark.
|
||||||
|
|
||||||
|
`--scenario warm,offline,update-js,update-css,update-feature` runs a regression
|
||||||
|
sweep without repeating an already-completed cold acceptance run.
|
||||||
|
|
||||||
|
## Runtime manifest hygiene
|
||||||
|
|
||||||
|
`server/asset-manifest.js` excludes unit tests (`*.test.js`, `*.test.mjs`),
|
||||||
|
Markdown, text licences/data (`*.txt`), C build sources, `*.entry.js` build
|
||||||
|
entries, `/admin.html`, and files below any `perf/` directory from the runtime
|
||||||
|
manifest and both build-tag modes. They remain directly servable with the usual
|
||||||
|
hash and cache headers. If a matching file is genuinely needed at runtime, add
|
||||||
|
its exact root-relative path to the optional `include` array in
|
||||||
|
`frontend/assets.json`; group declarations referencing excluded files fail at boot
|
||||||
|
unless explicitly included.
|
||||||
|
|
||||||
|
The offline completeness harness compares cache contents with the runtime
|
||||||
|
manifest dynamically; it must not hard-code the old 151-file inventory. See
|
||||||
|
`plans/manifest-hygiene-report.md` and `perf/results/hygiene-*.json` for the
|
||||||
|
before/after measurements and compatibility checks.
|
||||||
|
|
||||||
|
## Existing-install settings preservation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node perf/settings-persist.mjs --browser all --target main --allow-unsupported-webkit
|
||||||
|
node perf/settings-persist.mjs --browser all --target 0b8e398 --allow-unsupported-webkit --out perf/results/settings-persist-live-base.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The harness archives b77938a and the requested target into worktree-local scratch,
|
||||||
|
serves actual Bun servers at the same origin, sets values through Settings UI
|
||||||
|
handlers, reloads, invokes the old banner's actual Refresh UI button, and diffs
|
||||||
|
every `_ytpdata.settings` key and control. A real local H.264/AAC clip verifies
|
||||||
|
OPFS storage and blob playback before/after in Chromium. A local HTTP proxy
|
||||||
|
mocks profile timestamps for worker-owned fetches on both engines and tests
|
||||||
|
backup import. There are no external profile writes or media requests.
|
||||||
|
|
||||||
|
Failures exit 1. Linux WebKit has no native OPFS; without the explicit allowance
|
||||||
|
the unverified storage capability exits 2. The allowance only skips unsupported
|
||||||
|
storage assertions; every preservation/profile/import assertion still runs.
|
||||||
|
No storage shim substitutes for a genuine iPhone test. Snapshot results and the
|
||||||
|
per-key table are in `plans/settings-persist-investigation.md`.
|
||||||
|
|
||||||
|
## Device autosave investigation (2026-10-09)
|
||||||
|
|
||||||
|
`autosave.mjs` starts an isolated real Bun server inside this worktree, seeds its
|
||||||
|
media-cache DB with a three-second H.264/AAC clip, and uses 414×896 Playwright
|
||||||
|
contexts with the Framework7 Swipe layout. It checks OPFS bytes, `cachedIds`,
|
||||||
|
Saved and Downloads views, and both Settings handlers. Public network requests
|
||||||
|
are blocked. No production access or YouTube download is needed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash perf/make-shell-fixture.sh b77938a 1175f1a1d2c1
|
||||||
|
node perf/autosave.mjs --frontend perf/fixtures/shell-1175f1a1d2c1 \
|
||||||
|
--asset-hashing 0 --asset-sync 0 --allow-unsupported-webkit \
|
||||||
|
--out perf/results/autosave-b77938a.json
|
||||||
|
node perf/autosave.mjs --frontend-commit main --allow-unsupported-webkit \
|
||||||
|
--out perf/results/autosave-main.json
|
||||||
|
node perf/autosave.mjs --browser chromium --out perf/results/autosave-current.json
|
||||||
|
node --test frontend/autosave.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Options: `--frontend <directory>` or `--frontend-commit <revision>`;
|
||||||
|
`--browser chromium|webkit|all`; `--scenarios <comma-separated names>`;
|
||||||
|
`--asset-hashing 0|1`, `--asset-sync 0|1`; `--out <json>`.
|
||||||
|
A failed assertion exits 1. Missing native OPFS exits 2 unless explicitly
|
||||||
|
allowed with `--allow-unsupported-webkit`. On this Linux Playwright WebKit build,
|
||||||
|
`navigator.storage` is absent, including in persistent contexts; enabling its
|
||||||
|
MiniBrowser storage feature flags also did not expose it. WebKit therefore
|
||||||
|
checks real UI/settings/save guards and records `supported:false`; those results
|
||||||
|
**do not establish a successful WebKit OPFS save**. No storage shim is installed.
|
||||||
|
Run without the allowance on a Safari-capable environment to require native OPFS.
|
||||||
|
|
||||||
|
Observed contracts in both b77938a and current code:
|
||||||
|
|
||||||
|
| Action | Device save |
|
||||||
|
| --- | --- |
|
||||||
|
| Open a video with default settings | No |
|
||||||
|
| Open with `saveBeforePlay:true`, video mode | Yes |
|
||||||
|
| Open audio-only, or `preferStream:true` | No |
|
||||||
|
| Create/add to a playlist, even with `autoPreload:false` | Yes |
|
||||||
|
| Launch with playlist videos and `autoPreload:true` | Yes |
|
||||||
|
| Launch with `autoPreload:false`, unpinned playlist | No |
|
||||||
|
| Launch with a pinned playlist, even with auto-preload off | Yes |
|
||||||
|
| Enable either save setting through Settings | Saves as above; persisted |
|
||||||
|
|
||||||
|
Chromium: all 12 cases pass for old/current builds and Phase 1/2 tips.
|
||||||
|
Phase 3/4/5 tips and both rollback flags pass the six positive cases
|
||||||
|
(`open-save-first,playlist-add,playlist-boot,pinned-boot,settings-enable,settings-save-first`).
|
||||||
|
JSON evidence is in `perf/results/autosave-*.json`. No old=saves/new=doesn't
|
||||||
|
transition was found, so there is no defensible breaking commit or product fix.
|
||||||
|
The unit tests cover playlist/pinning policy, duplicate saves, and failed or
|
||||||
|
paused saves remaining retryable without being marked Saved.
|
||||||
|
|
||||||
|
Neighbour audit: Downloads, Saved, OPFS, download controls and error helpers
|
||||||
|
remain eager core assets. Export awaits `feature:export`; Share/External await
|
||||||
|
`feature:share-external`; worker creation is synchronous and receives build-local
|
||||||
|
hash/resume URLs. DirectMedia's unloaded `hasSource()` facade returns false, so
|
||||||
|
an early paired-source lookup can choose the server instead; this does not
|
||||||
|
prevent the server save. P2P settings counts queried before its delayed lazy
|
||||||
|
startup can initially show no registration. These remain follow-up candidates,
|
||||||
|
not demonstrated causes of missing OPFS saves. The scout's proposed Data Saver
|
||||||
|
cause was rejected: its guards affect static warming and stream pre-resolution,
|
||||||
|
not `preload()` or `saveBeforePlay`.
|
||||||
|
|
||||||
|
Still required on the owner's installed iPhone PWA: record the running build,
|
||||||
|
check Save before playing / Audio-only / Auto-save settings, open a fresh video,
|
||||||
|
add it to a playlist, and inspect Downloads failure details and Saved after a
|
||||||
|
relaunch/offline playback. Verify available storage and whether iOS evicted
|
||||||
|
previous copies. A failing iPhone sequence is needed to continue the bisect.
|
||||||
|
|
||||||
|
Transport controls use the same shared-definition approach as media/save icons.
|
||||||
|
`node perf/ui-icons.mjs` also checks all four layouts × dark/light/contrast in
|
||||||
|
Chromium and WebKit: shared monochrome symbols, accessible labels, visible
|
||||||
|
44px targets, F7 filled glyphs, runtime layout changes, main/stage/mini/service
|
||||||
|
play-pause state swaps, fullscreen and Car mode refreshes, and actual paired
|
||||||
|
remote toggles against a mocked relay. Transport previews and remote screenshots
|
||||||
|
are saved in ignored `plans/transport-shots/`. Run `node perf/ui-geometry.mjs`
|
||||||
|
for the 320–430px search-bar and Saved-row geometry matrix. OS lock-screen buttons
|
||||||
|
remain platform-rendered through the existing MediaSession handlers.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user