Compare commits
47 Commits
528e7c1ea1
...
codex/jame
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04421a2145 | ||
|
|
491ec407a1 | ||
|
|
dce186c187 | ||
|
|
93d3b05263 | ||
|
|
eb6aae7ae7 | ||
|
|
09125f3f5c | ||
|
|
7bc5b4f8b5 | ||
|
|
4bc15a55e3 | ||
|
|
64f8eae92b | ||
|
|
3f37c2a9ef | ||
|
|
233bf94e4c | ||
|
|
722df3125f | ||
|
|
2e58924971 | ||
|
|
b6dd2be40f | ||
|
|
b77938ab4a | ||
|
|
b4c776dcfe | ||
|
|
2b286ae146 | ||
|
|
128f8e95e3 | ||
|
|
63ff2173d8 | ||
|
|
a143c8331d | ||
|
|
5b259a1ac1 | ||
|
|
1eda52e4c5 | ||
|
|
3be7a7b194 | ||
|
|
bd80ad1ea0 | ||
|
|
c30fd9fe78 | ||
|
|
605d21a3e2 | ||
|
|
cc4fb0d5c9 | ||
|
|
af38affdbe | ||
|
|
034b3df7ae | ||
|
|
d4497b0c05 | ||
|
|
67fc6f584f | ||
|
|
a81f4ffa51 | ||
|
|
1cad18826b | ||
|
|
1ba3f8704b | ||
|
|
0bda242a98 | ||
|
|
1754933f7e | ||
|
|
4e5b1584c6 | ||
|
|
9589c38773 | ||
|
|
3baf67349f | ||
|
|
e100d47143 | ||
|
|
838798ca9b | ||
|
|
04b620e3c7 | ||
|
|
2042c9b6fc | ||
|
|
df4acfc9f1 | ||
|
|
536555e12d | ||
|
|
d43cce441b | ||
|
|
25ae810463 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -38,3 +38,9 @@ legacy/releases/
|
||||
|
||||
# python caches from scripts/lyrics
|
||||
__pycache__/
|
||||
plans/codex-shots*/
|
||||
plans/codex-report*.md
|
||||
|
||||
# performance harness artifacts and temp files
|
||||
perf/fixtures/
|
||||
perf/.tmp/
|
||||
|
||||
@@ -12,6 +12,15 @@
|
||||
],
|
||||
"createdAt": "2026-10-02T23:14:10.804Z",
|
||||
"reason": "Agent verified all three matching images are hidden dynamic artwork targets (artImg, serviceArt, rvThumb); app.js assigns their src when media metadata is available."
|
||||
},
|
||||
{
|
||||
"rule": "layout-transition",
|
||||
"value": "*",
|
||||
"files": [
|
||||
"frontend/theme-f7.css"
|
||||
],
|
||||
"createdAt": "2026-10-05T18:33:43.625Z",
|
||||
"reason": "Framework7 swipeout reveal retains its existing width transition for the action area"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
target_identity: "file:/home/josh/development/personal/ytplayer/frontend/index.html"
|
||||
target_fingerprint: "sha256:5f16cc1d66caa47d5d1ae35afe92b5206825e66772d974d72fd90effac161d27"
|
||||
target_path: /home/josh/development/personal/ytplayer/frontend/index.html
|
||||
timestamp: 2026-10-05T20-14-49Z
|
||||
slug: frontend-index-html
|
||||
---
|
||||
Method: dual-agent (A: /root/critique_design · B: /root/critique_evidence)
|
||||
|
||||
# Design Health Score
|
||||
|
||||
| # | Heuristic | Score | Key issue |
|
||||
|---|---|---:|---|
|
||||
| 1 | Visibility of System Status | 3/4 | Active tab and playback state are clear; row actions depend on an undisclosed swipe. |
|
||||
| 2 | Match System / Real World | 3/4 | Grouped lists, bottom tabs and sheet use familiar iOS patterns. |
|
||||
| 3 | User Control and Freedom | 3/4 | Player and sheets can be collapsed or closed; long swipe removal remains a high consequence gesture. |
|
||||
| 4 | Consistency and Standards | 3/4 | F7 components now share the same token-based visual treatment; some underlying CSS layers overlap. |
|
||||
| 5 | Error Prevention | 2/4 | Long-left removes a row without a confirmation step. |
|
||||
| 6 | Recognition Rather Than Recall | 2/4 | Swipe row actions are not apparent before discovery. |
|
||||
| 7 | Flexibility and Efficiency | 3/4 | Gestures and bottom tabs offer quick navigation for touch users. |
|
||||
| 8 | Aesthetic and Minimalist Design | 3/4 | Light, dark and contrast treatments stay restrained; row actions can obscure content while open. |
|
||||
| 9 | Error Recovery | 2/4 | No explicit undo is provided for a swipe removal. |
|
||||
| 10 | Help and Documentation | 1/4 | Only the mini-player has a visual gesture affordance; row and tab gestures lack a persistent hint. |
|
||||
| **Total** | | **25/40** | **Acceptable; interaction discovery is the main gap.** |
|
||||
|
||||
# Design Specificity Verdict
|
||||
|
||||
**LLM assessment:** The F7 layout now reads as a media player: native line icons, an inset media list, accent-led controls, glass navigation and a sheet for Up Next. The large title hierarchy and player/list relationship fit the product. It is no longer just the mini-player that changes. The remaining weakest point is that row actions and tab movement rely on gesture knowledge users may not have.
|
||||
|
||||
**Deterministic scan:** The scoped Impeccable detector scan across `frontend/index.html`, `frontend/app.js`, `frontend/f7-layout.js`, `frontend/fullscreen-orientation.css`, `frontend/fullscreen-orientation.js` and `frontend/theme-f7.css` reported 129 findings: 60 warnings and 69 advisories. Categories: low contrast 22, design-system-radius 35, undersized-ui-text 22, design-system-font-size 20, thin-border/wide-shadow 10, dark-glow 4, design-system-color 4, clipped overflow 3, gradient text 2, layout transition 2, tiny text 2, marquee 2, pulsing dot 1. It found no issues in the two gesture/fullscreen logic modules. Most radius/type findings refer to the intended F7 control scale or circular/icon controls; many remaining findings point to shared prototype content and pre-existing theme variants in the large single HTML document. The contrast findings around bright accent fills and dim secondary text merit future contextual review, especially against the detector's assumed body-text size.
|
||||
|
||||
**Browser visualization:** The detector overlay could not be injected into the selected live page. The available browser inspection loaded the default Classic layout and returned `Unexpected token '<'` from API/script requests served as HTML, so no reliable user-visible overlay is available. Separate Playwright captures were made on the static frontend with the F7 layout enabled and inspected directly.
|
||||
|
||||
# Cognitive Load
|
||||
|
||||
The tab bar reduces destination recall to six visible choices. The list header, filter and grouped items scan predictably. Hidden row actions and swipe-to-change-tab are not discoverable from the screen itself; the small grabber only hints at mini-player movement and does not label what the movement does. No screen presents more than four primary decision points at once.
|
||||
|
||||
# Emotional Journey
|
||||
|
||||
Search and queue selection are direct, and the full-player sheet preserves the sense of continuity while browsing. Filling the screen with a real video makes fullscreen feel intentional. A long swipe that removes a row can create a negative surprise because the outcome is not previewed or recoverable.
|
||||
|
||||
# What's Working
|
||||
|
||||
- The F7 treatment now spans search, sidebar, home, list, settings, player, mini-player, tabs, sheets and auxiliary panels.
|
||||
- Fill/Fit is explicit, persisted, and also toggles by center double-tap or multi-touch.
|
||||
- The classic regression pair is pixel-identical at 390×844.
|
||||
|
||||
# Priority Issues
|
||||
|
||||
1. **[P2] Row gestures are hard to discover.** Users see an ellipsis, but do not know that left/right reveals Playlist, Remove and Queue. Add a one-time, dismissible hint or a small visible interaction cue. Suggested command: `/impeccable clarify`.
|
||||
2. **[P2] Swipe removal has weak recovery.** A long left swipe removes immediately, with no Undo affordance. Give the toast a short Undo action or reduce the destructive gesture to a normal revealed button. Suggested command: `/impeccable harden`.
|
||||
|
||||
# Persona Red Flags
|
||||
|
||||
- **Jordan (First-Timer):** Can find Search and Queue from the bottom bar, but cannot infer that a song row can be swiped to add it to a playlist or remove it.
|
||||
- **Alex (Power User):** Can move quickly through tabs and the mini-player, but a long-left removal has no quick undo if the wrong row is removed.
|
||||
- **Morgan (Accessibility-focused listener):** Touch actions have generous targets, but gestures have no equivalent hint and depend on a screen reader user discovering the menu path.
|
||||
|
||||
# Minor Observations
|
||||
|
||||
- The critique found overlapping F7 declarations in `theme-f7.css`; current precedence is predictable but the file would be easier to maintain if these layers were consolidated.
|
||||
- The queue/settings duplicate heading and the redundant settings list filter were fixed during this pass; Queue's Play action is now the filled primary action.
|
||||
- The section rail remains usable as a compact floating control above the tab bar.
|
||||
- The detector's font-size advisories in compact badges and section labels are intentional density choices; check any future increase in text scale against them.
|
||||
|
||||
# Questions to Consider
|
||||
|
||||
- Would a single dismissible swipe hint be enough, or should Queue/Playlist/Remove stay visible behind each row menu?
|
||||
- Should long-left remove remain as a shortcut if the toast gains Undo, or should removal require tapping the revealed action?
|
||||
@@ -54,6 +54,12 @@ JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone
|
||||
refreshed every 1.5 s by the `glassStage` IIFE), four stacked icon tiles and secondary actions as a pill row,
|
||||
floating mini player. Elements that only this theme shows (`.stg-overlay`, `.skip10`, `.deck-chips`, `.vol-pod`
|
||||
extras, `.mode-name`) are in the shared markup and hidden by default.
|
||||
- **f7-swipe** (`frontend/f7-layout.js` + `theme-f7.css`): classic look + Framework7 gestures. Loads
|
||||
`vendor/framework7-swipe.min.js` (F7 9.2.0 core+Store+Swipeout+Modal+Sheet, rebuilt from
|
||||
`vendor/framework7-swipe.entry.js`) only when picked. Cards are wrapped in `li.swipeout` (Queue / Playlist /
|
||||
Remove, via `card._video`), swipe up on the mini bar = F7 "Up next" sheet, sideways = next/prev, left-edge
|
||||
swipe = sidebar. F7's touch module swallows programmatic `.click()`s, so `app.off('click')` +
|
||||
`app.off('touchend')` run after init — keep that, or app.js buttons driven by `.click()` silently stop working.
|
||||
- Now-playing tiles are `[svg.ti][span.tl]`; change state text with `setTile(btn, label)`, never `textContent`.
|
||||
|
||||
## Saved searches + caching notes
|
||||
|
||||
34
docker-compose.beta.yml
Normal file
34
docker-compose.beta.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
# Beta stack (beta.worship.hesed.sbs). Separate from prod on purpose: own named
|
||||
# volumes (no shared DB or media drive), no hardcoded Traefik labels (Dokploy
|
||||
# injects the domain routing), no lyrics/piano workers.
|
||||
services:
|
||||
ytplayer:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ytplayer-beta-data:/app/data
|
||||
environment:
|
||||
PORT: "3000"
|
||||
DB_PATH: "/app/data/ytplayer.db"
|
||||
MEDIA_DIR: "/app/data/media"
|
||||
UPLOAD_DIR: "/app/data/uploads"
|
||||
MEDIA_CACHE_MAX_BYTES: "10737418240"
|
||||
APP_VERSION: "1.0.0-beta"
|
||||
ADMIN_PASSWORD: "${ADMIN_PASSWORD:-}"
|
||||
P2P_ENABLED: "${P2P_ENABLED:-0}"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:3000/api/version"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- dokploy-network
|
||||
|
||||
volumes:
|
||||
ytplayer-beta-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
dokploy-network:
|
||||
external: true
|
||||
904
docs/design/f7-swipe-contrast.json
Normal file
904
docs/design/f7-swipe-contrast.json
Normal file
@@ -0,0 +1,904 @@
|
||||
{
|
||||
"title": "YT Player \u2014 Framework7 Swipe (contrast)",
|
||||
"brief": "Ad-free YouTube/worship player PWA. Framework7 iOS-style layout with swipe gestures, built on the app's own tokens.",
|
||||
"frame": "phone",
|
||||
"platform": "web",
|
||||
"paletteKey": "coral",
|
||||
"theme": {
|
||||
"dark": true,
|
||||
"bothModes": false,
|
||||
"contrast": "high",
|
||||
"shape": "rounded",
|
||||
"font": "system",
|
||||
"emphasized": false,
|
||||
"motion": "expressive"
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"id": "search",
|
||||
"name": "Search",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"note": "Home + search results. Big title, rounded search field, results as an inset grouped list.",
|
||||
"swipe": {
|
||||
"left": "queue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "queue",
|
||||
"name": "Queue",
|
||||
"x": 492,
|
||||
"y": 0,
|
||||
"note": "Queued songs. Swipe a row left for Playlist/Remove (long swipe removes), right for Queue. Swipe sideways on empty space to change tab.",
|
||||
"swipe": {
|
||||
"right": "search"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "player",
|
||||
"name": "Now playing",
|
||||
"x": 984,
|
||||
"y": 0,
|
||||
"note": "Video on top, Apple Music\u2013style transport, icon action row. Swipe down to collapse to the mini player.",
|
||||
"swipe": {
|
||||
"down": "queue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "upnext",
|
||||
"name": "Up next",
|
||||
"x": 1476,
|
||||
"y": 0,
|
||||
"note": "Bottom sheet opened by swiping up on the mini player. Half height, drag up for full, down to close."
|
||||
},
|
||||
{
|
||||
"id": "fullscreen",
|
||||
"name": "Fullscreen",
|
||||
"x": 1968,
|
||||
"y": 0,
|
||||
"note": "Edge-to-edge video (Fill by default). Minimal icon-only controls over a soft bottom gradient; auto-hide.",
|
||||
"bg": "inverseSurface"
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": "g2",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i1",
|
||||
"kind": "topAppBar",
|
||||
"label": "Search",
|
||||
"icon": "menu",
|
||||
"variant": "filled",
|
||||
"icon2": "directions_car",
|
||||
"note": "Translucent navbar; menu opens the sidebar (also left-edge swipe)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g4",
|
||||
"x": 16,
|
||||
"y": 96,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i3",
|
||||
"kind": "searchBar",
|
||||
"label": "Search YouTube",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"icon2": "mic",
|
||||
"note": "Rounded F7 searchbar with inline mic; Enter searches."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g8",
|
||||
"x": 16,
|
||||
"y": 164,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i5",
|
||||
"kind": "chip",
|
||||
"label": "lofi beats",
|
||||
"icon": null,
|
||||
"variant": "filled"
|
||||
},
|
||||
{
|
||||
"id": "i6",
|
||||
"kind": "chip",
|
||||
"label": "live news",
|
||||
"icon": null,
|
||||
"variant": "filled"
|
||||
},
|
||||
{
|
||||
"id": "i7",
|
||||
"kind": "chip",
|
||||
"label": "worship",
|
||||
"icon": null,
|
||||
"variant": "filled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g10",
|
||||
"x": 16,
|
||||
"y": 212,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i9",
|
||||
"kind": "text",
|
||||
"label": "Results",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 28,
|
||||
"bold": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g15",
|
||||
"x": 16,
|
||||
"y": 260,
|
||||
"axis": "y",
|
||||
"items": [
|
||||
{
|
||||
"id": "i11",
|
||||
"kind": "listItem",
|
||||
"label": "Goodness of God",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Bethel Music \u00b7 5:12",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i12",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i13",
|
||||
"kind": "listItem",
|
||||
"label": "Build My Life",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Housefires \u00b7 6:04",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i14",
|
||||
"kind": "listItem",
|
||||
"label": "Graves Into Gardens",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Elevation \u00b7 6:41",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g17",
|
||||
"x": 16,
|
||||
"y": 700,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i16",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "music_note",
|
||||
"variant": "tonal",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "play_arrow",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slideUp"
|
||||
},
|
||||
"note": "Floating glass mini player. Tap opens the player, swipe left/right = next/previous, swipe up = Up next sheet."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g19",
|
||||
"x": 0,
|
||||
"y": 788,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i18",
|
||||
"kind": "bottomNav",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"tabs": [
|
||||
{
|
||||
"icon": "search",
|
||||
"label": "Search"
|
||||
},
|
||||
{
|
||||
"icon": "queue_music",
|
||||
"label": "Queue"
|
||||
},
|
||||
{
|
||||
"icon": "history",
|
||||
"label": "History"
|
||||
},
|
||||
{
|
||||
"icon": "download_done",
|
||||
"label": "Saved"
|
||||
},
|
||||
{
|
||||
"icon": "more_horiz",
|
||||
"label": "More"
|
||||
}
|
||||
],
|
||||
"selected": 0,
|
||||
"actions": {
|
||||
"tab:0": {
|
||||
"to": "search",
|
||||
"transition": "fade"
|
||||
},
|
||||
"tab:1": {
|
||||
"to": "queue",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
"note": "Translucent blurred tab bar; badge on Queue."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g21",
|
||||
"x": 492,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i20",
|
||||
"kind": "topAppBar",
|
||||
"label": "Queue",
|
||||
"icon": "menu",
|
||||
"variant": "filled",
|
||||
"icon2": "checklist"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g24",
|
||||
"x": 508,
|
||||
"y": 96,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i22",
|
||||
"kind": "button",
|
||||
"label": "Play queue",
|
||||
"icon": "play_arrow",
|
||||
"variant": "filled",
|
||||
"size": 182
|
||||
},
|
||||
{
|
||||
"id": "i23",
|
||||
"kind": "button",
|
||||
"label": "Clear",
|
||||
"icon": "clear_all",
|
||||
"variant": "tonal",
|
||||
"size": 182
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g29",
|
||||
"x": 508,
|
||||
"y": 168,
|
||||
"axis": "y",
|
||||
"items": [
|
||||
{
|
||||
"id": "i25",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "drag_handle",
|
||||
"note": "Swipe right \u2192 Queue (accent action)."
|
||||
},
|
||||
{
|
||||
"id": "i26",
|
||||
"kind": "listItem",
|
||||
"label": "Build My Life",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Housefires \u00b7 6:04",
|
||||
"icon2": "drag_handle",
|
||||
"note": "Swipe left \u2192 Playlist + Remove pills."
|
||||
},
|
||||
{
|
||||
"id": "i27",
|
||||
"kind": "listItem",
|
||||
"label": "Graves Into Gardens",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Elevation \u00b7 6:41",
|
||||
"icon2": "drag_handle",
|
||||
"note": "Long swipe left removes the row."
|
||||
},
|
||||
{
|
||||
"id": "i28",
|
||||
"kind": "listItem",
|
||||
"label": "Goodness of God",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Bethel Music \u00b7 5:12",
|
||||
"icon2": "drag_handle",
|
||||
"note": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g32",
|
||||
"x": 508,
|
||||
"y": 480,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i30",
|
||||
"kind": "button",
|
||||
"label": "Playlist",
|
||||
"icon": "playlist_add",
|
||||
"variant": "tonal"
|
||||
},
|
||||
{
|
||||
"id": "i31",
|
||||
"kind": "button",
|
||||
"label": "Remove",
|
||||
"icon": "delete",
|
||||
"variant": "filled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g34",
|
||||
"x": 508,
|
||||
"y": 548,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i33",
|
||||
"kind": "text",
|
||||
"label": "Swipe actions revealed (example row)",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g36",
|
||||
"x": 508,
|
||||
"y": 700,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i35",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "music_note",
|
||||
"variant": "tonal",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "play_arrow",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slideUp"
|
||||
},
|
||||
"note": "Floating glass mini player. Tap opens the player, swipe left/right = next/previous, swipe up = Up next sheet."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g38",
|
||||
"x": 492,
|
||||
"y": 788,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i37",
|
||||
"kind": "bottomNav",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"tabs": [
|
||||
{
|
||||
"icon": "search",
|
||||
"label": "Search"
|
||||
},
|
||||
{
|
||||
"icon": "queue_music",
|
||||
"label": "Queue"
|
||||
},
|
||||
{
|
||||
"icon": "history",
|
||||
"label": "History"
|
||||
},
|
||||
{
|
||||
"icon": "download_done",
|
||||
"label": "Saved"
|
||||
},
|
||||
{
|
||||
"icon": "more_horiz",
|
||||
"label": "More"
|
||||
}
|
||||
],
|
||||
"selected": 1,
|
||||
"actions": {
|
||||
"tab:0": {
|
||||
"to": "search",
|
||||
"transition": "fade"
|
||||
},
|
||||
"tab:1": {
|
||||
"to": "queue",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
"note": "Translucent blurred tab bar; badge on Queue."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g40",
|
||||
"x": 984,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i39",
|
||||
"kind": "topAppBar",
|
||||
"label": "Now playing",
|
||||
"icon": "keyboard_arrow_down",
|
||||
"variant": "filled",
|
||||
"icon2": "more_horiz",
|
||||
"actions": {
|
||||
"icon": {
|
||||
"to": "back",
|
||||
"transition": "slideDown"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g42",
|
||||
"x": 984,
|
||||
"y": 88,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i41",
|
||||
"kind": "image",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 412,
|
||||
"note": "16:9 video stage; the fullscreen button opens Fullscreen."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g44",
|
||||
"x": 1000,
|
||||
"y": 320,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i43",
|
||||
"kind": "text",
|
||||
"label": "Way Maker",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 24,
|
||||
"bold": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g46",
|
||||
"x": 1000,
|
||||
"y": 356,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i45",
|
||||
"kind": "text",
|
||||
"label": "Leeland",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 16
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g48",
|
||||
"x": 1000,
|
||||
"y": 396,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i47",
|
||||
"kind": "slider",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"value": 35,
|
||||
"note": "Thin seek track, accent fill."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g54",
|
||||
"x": 1048,
|
||||
"y": 456,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i49",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "skip_previous",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i50",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "replay_10",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i51",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "play_arrow",
|
||||
"variant": "filled"
|
||||
},
|
||||
{
|
||||
"id": "i52",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "forward_10",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i53",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "skip_next",
|
||||
"variant": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g61",
|
||||
"x": 1024,
|
||||
"y": 540,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i55",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "playlist_add",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i56",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "download",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i57",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "lyrics",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i58",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "share",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i59",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "fullscreen",
|
||||
"variant": "text",
|
||||
"action": {
|
||||
"to": "fullscreen",
|
||||
"transition": "expand"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i60",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "more_horiz",
|
||||
"variant": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g63",
|
||||
"x": 984,
|
||||
"y": 788,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i62",
|
||||
"kind": "bottomNav",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"tabs": [
|
||||
{
|
||||
"icon": "search",
|
||||
"label": "Search"
|
||||
},
|
||||
{
|
||||
"icon": "queue_music",
|
||||
"label": "Queue"
|
||||
},
|
||||
{
|
||||
"icon": "history",
|
||||
"label": "History"
|
||||
},
|
||||
{
|
||||
"icon": "download_done",
|
||||
"label": "Saved"
|
||||
},
|
||||
{
|
||||
"icon": "more_horiz",
|
||||
"label": "More"
|
||||
}
|
||||
],
|
||||
"selected": 1,
|
||||
"actions": {
|
||||
"tab:0": {
|
||||
"to": "search",
|
||||
"transition": "fade"
|
||||
},
|
||||
"tab:1": {
|
||||
"to": "queue",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
"note": "Translucent blurred tab bar; badge on Queue."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g65",
|
||||
"x": 1476,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i64",
|
||||
"kind": "topAppBar",
|
||||
"label": "Queue",
|
||||
"icon": "menu",
|
||||
"variant": "filled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g67",
|
||||
"x": 1476,
|
||||
"y": 380,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i66",
|
||||
"kind": "box",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 412,
|
||||
"size2": 512,
|
||||
"checked": true,
|
||||
"fill": "surfaceContainerHigh",
|
||||
"note": "F7 sheet with grab handle; backdrop tap closes."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g69",
|
||||
"x": 1492,
|
||||
"y": 404,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i68",
|
||||
"kind": "text",
|
||||
"label": "Up next",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 20,
|
||||
"bold": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g73",
|
||||
"x": 1492,
|
||||
"y": 448,
|
||||
"axis": "y",
|
||||
"items": [
|
||||
{
|
||||
"id": "i70",
|
||||
"kind": "listItem",
|
||||
"label": "Build My Life",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Housefires \u00b7 6:04",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i71",
|
||||
"kind": "listItem",
|
||||
"label": "Graves Into Gardens",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Elevation \u00b7 6:41",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i72",
|
||||
"kind": "listItem",
|
||||
"label": "Goodness of God",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Bethel Music \u00b7 5:12",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g75",
|
||||
"x": 1968,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i74",
|
||||
"kind": "box",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 412,
|
||||
"size2": 892,
|
||||
"fill": "inverseSurface",
|
||||
"note": "Video fills the screen (object-fit cover). Fit/Fill toggle in the controls."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g77",
|
||||
"x": 1984,
|
||||
"y": 24,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i76",
|
||||
"kind": "text",
|
||||
"label": "Way Maker \u2014 Leeland",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g79",
|
||||
"x": 2316,
|
||||
"y": 12,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i78",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "fullscreen_exit",
|
||||
"variant": "text",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g81",
|
||||
"x": 1984,
|
||||
"y": 790,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i80",
|
||||
"kind": "slider",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"value": 35
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g85",
|
||||
"x": 1984,
|
||||
"y": 828,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i82",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "pause",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i83",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "screen_rotation",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i84",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "fit_screen",
|
||||
"variant": "text",
|
||||
"note": "Fit \u2194 Fill."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
904
docs/design/f7-swipe-dark.json
Normal file
904
docs/design/f7-swipe-dark.json
Normal file
@@ -0,0 +1,904 @@
|
||||
{
|
||||
"title": "YT Player \u2014 Framework7 Swipe (dark)",
|
||||
"brief": "Ad-free YouTube/worship player PWA. Framework7 iOS-style layout with swipe gestures, built on the app's own tokens.",
|
||||
"frame": "phone",
|
||||
"platform": "web",
|
||||
"paletteKey": "coral",
|
||||
"theme": {
|
||||
"dark": true,
|
||||
"bothModes": true,
|
||||
"contrast": "standard",
|
||||
"shape": "rounded",
|
||||
"font": "system",
|
||||
"emphasized": false,
|
||||
"motion": "expressive"
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"id": "search",
|
||||
"name": "Search",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"note": "Home + search results. Big title, rounded search field, results as an inset grouped list.",
|
||||
"swipe": {
|
||||
"left": "queue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "queue",
|
||||
"name": "Queue",
|
||||
"x": 492,
|
||||
"y": 0,
|
||||
"note": "Queued songs. Swipe a row left for Playlist/Remove (long swipe removes), right for Queue. Swipe sideways on empty space to change tab.",
|
||||
"swipe": {
|
||||
"right": "search"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "player",
|
||||
"name": "Now playing",
|
||||
"x": 984,
|
||||
"y": 0,
|
||||
"note": "Video on top, Apple Music\u2013style transport, icon action row. Swipe down to collapse to the mini player.",
|
||||
"swipe": {
|
||||
"down": "queue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "upnext",
|
||||
"name": "Up next",
|
||||
"x": 1476,
|
||||
"y": 0,
|
||||
"note": "Bottom sheet opened by swiping up on the mini player. Half height, drag up for full, down to close."
|
||||
},
|
||||
{
|
||||
"id": "fullscreen",
|
||||
"name": "Fullscreen",
|
||||
"x": 1968,
|
||||
"y": 0,
|
||||
"note": "Edge-to-edge video (Fill by default). Minimal icon-only controls over a soft bottom gradient; auto-hide.",
|
||||
"bg": "inverseSurface"
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": "g2",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i1",
|
||||
"kind": "topAppBar",
|
||||
"label": "Search",
|
||||
"icon": "menu",
|
||||
"variant": "filled",
|
||||
"icon2": "directions_car",
|
||||
"note": "Translucent navbar; menu opens the sidebar (also left-edge swipe)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g4",
|
||||
"x": 16,
|
||||
"y": 96,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i3",
|
||||
"kind": "searchBar",
|
||||
"label": "Search YouTube",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"icon2": "mic",
|
||||
"note": "Rounded F7 searchbar with inline mic; Enter searches."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g8",
|
||||
"x": 16,
|
||||
"y": 164,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i5",
|
||||
"kind": "chip",
|
||||
"label": "lofi beats",
|
||||
"icon": null,
|
||||
"variant": "filled"
|
||||
},
|
||||
{
|
||||
"id": "i6",
|
||||
"kind": "chip",
|
||||
"label": "live news",
|
||||
"icon": null,
|
||||
"variant": "filled"
|
||||
},
|
||||
{
|
||||
"id": "i7",
|
||||
"kind": "chip",
|
||||
"label": "worship",
|
||||
"icon": null,
|
||||
"variant": "filled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g10",
|
||||
"x": 16,
|
||||
"y": 212,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i9",
|
||||
"kind": "text",
|
||||
"label": "Results",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 28,
|
||||
"bold": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g15",
|
||||
"x": 16,
|
||||
"y": 260,
|
||||
"axis": "y",
|
||||
"items": [
|
||||
{
|
||||
"id": "i11",
|
||||
"kind": "listItem",
|
||||
"label": "Goodness of God",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Bethel Music \u00b7 5:12",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i12",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i13",
|
||||
"kind": "listItem",
|
||||
"label": "Build My Life",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Housefires \u00b7 6:04",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i14",
|
||||
"kind": "listItem",
|
||||
"label": "Graves Into Gardens",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Elevation \u00b7 6:41",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g17",
|
||||
"x": 16,
|
||||
"y": 700,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i16",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "music_note",
|
||||
"variant": "tonal",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "play_arrow",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slideUp"
|
||||
},
|
||||
"note": "Floating glass mini player. Tap opens the player, swipe left/right = next/previous, swipe up = Up next sheet."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g19",
|
||||
"x": 0,
|
||||
"y": 788,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i18",
|
||||
"kind": "bottomNav",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"tabs": [
|
||||
{
|
||||
"icon": "search",
|
||||
"label": "Search"
|
||||
},
|
||||
{
|
||||
"icon": "queue_music",
|
||||
"label": "Queue"
|
||||
},
|
||||
{
|
||||
"icon": "history",
|
||||
"label": "History"
|
||||
},
|
||||
{
|
||||
"icon": "download_done",
|
||||
"label": "Saved"
|
||||
},
|
||||
{
|
||||
"icon": "more_horiz",
|
||||
"label": "More"
|
||||
}
|
||||
],
|
||||
"selected": 0,
|
||||
"actions": {
|
||||
"tab:0": {
|
||||
"to": "search",
|
||||
"transition": "fade"
|
||||
},
|
||||
"tab:1": {
|
||||
"to": "queue",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
"note": "Translucent blurred tab bar; badge on Queue."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g21",
|
||||
"x": 492,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i20",
|
||||
"kind": "topAppBar",
|
||||
"label": "Queue",
|
||||
"icon": "menu",
|
||||
"variant": "filled",
|
||||
"icon2": "checklist"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g24",
|
||||
"x": 508,
|
||||
"y": 96,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i22",
|
||||
"kind": "button",
|
||||
"label": "Play queue",
|
||||
"icon": "play_arrow",
|
||||
"variant": "filled",
|
||||
"size": 182
|
||||
},
|
||||
{
|
||||
"id": "i23",
|
||||
"kind": "button",
|
||||
"label": "Clear",
|
||||
"icon": "clear_all",
|
||||
"variant": "tonal",
|
||||
"size": 182
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g29",
|
||||
"x": 508,
|
||||
"y": 168,
|
||||
"axis": "y",
|
||||
"items": [
|
||||
{
|
||||
"id": "i25",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "drag_handle",
|
||||
"note": "Swipe right \u2192 Queue (accent action)."
|
||||
},
|
||||
{
|
||||
"id": "i26",
|
||||
"kind": "listItem",
|
||||
"label": "Build My Life",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Housefires \u00b7 6:04",
|
||||
"icon2": "drag_handle",
|
||||
"note": "Swipe left \u2192 Playlist + Remove pills."
|
||||
},
|
||||
{
|
||||
"id": "i27",
|
||||
"kind": "listItem",
|
||||
"label": "Graves Into Gardens",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Elevation \u00b7 6:41",
|
||||
"icon2": "drag_handle",
|
||||
"note": "Long swipe left removes the row."
|
||||
},
|
||||
{
|
||||
"id": "i28",
|
||||
"kind": "listItem",
|
||||
"label": "Goodness of God",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Bethel Music \u00b7 5:12",
|
||||
"icon2": "drag_handle",
|
||||
"note": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g32",
|
||||
"x": 508,
|
||||
"y": 480,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i30",
|
||||
"kind": "button",
|
||||
"label": "Playlist",
|
||||
"icon": "playlist_add",
|
||||
"variant": "tonal"
|
||||
},
|
||||
{
|
||||
"id": "i31",
|
||||
"kind": "button",
|
||||
"label": "Remove",
|
||||
"icon": "delete",
|
||||
"variant": "filled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g34",
|
||||
"x": 508,
|
||||
"y": 548,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i33",
|
||||
"kind": "text",
|
||||
"label": "Swipe actions revealed (example row)",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g36",
|
||||
"x": 508,
|
||||
"y": 700,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i35",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "music_note",
|
||||
"variant": "tonal",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "play_arrow",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slideUp"
|
||||
},
|
||||
"note": "Floating glass mini player. Tap opens the player, swipe left/right = next/previous, swipe up = Up next sheet."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g38",
|
||||
"x": 492,
|
||||
"y": 788,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i37",
|
||||
"kind": "bottomNav",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"tabs": [
|
||||
{
|
||||
"icon": "search",
|
||||
"label": "Search"
|
||||
},
|
||||
{
|
||||
"icon": "queue_music",
|
||||
"label": "Queue"
|
||||
},
|
||||
{
|
||||
"icon": "history",
|
||||
"label": "History"
|
||||
},
|
||||
{
|
||||
"icon": "download_done",
|
||||
"label": "Saved"
|
||||
},
|
||||
{
|
||||
"icon": "more_horiz",
|
||||
"label": "More"
|
||||
}
|
||||
],
|
||||
"selected": 1,
|
||||
"actions": {
|
||||
"tab:0": {
|
||||
"to": "search",
|
||||
"transition": "fade"
|
||||
},
|
||||
"tab:1": {
|
||||
"to": "queue",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
"note": "Translucent blurred tab bar; badge on Queue."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g40",
|
||||
"x": 984,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i39",
|
||||
"kind": "topAppBar",
|
||||
"label": "Now playing",
|
||||
"icon": "keyboard_arrow_down",
|
||||
"variant": "filled",
|
||||
"icon2": "more_horiz",
|
||||
"actions": {
|
||||
"icon": {
|
||||
"to": "back",
|
||||
"transition": "slideDown"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g42",
|
||||
"x": 984,
|
||||
"y": 88,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i41",
|
||||
"kind": "image",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 412,
|
||||
"note": "16:9 video stage; the fullscreen button opens Fullscreen."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g44",
|
||||
"x": 1000,
|
||||
"y": 320,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i43",
|
||||
"kind": "text",
|
||||
"label": "Way Maker",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 24,
|
||||
"bold": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g46",
|
||||
"x": 1000,
|
||||
"y": 356,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i45",
|
||||
"kind": "text",
|
||||
"label": "Leeland",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 16
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g48",
|
||||
"x": 1000,
|
||||
"y": 396,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i47",
|
||||
"kind": "slider",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"value": 35,
|
||||
"note": "Thin seek track, accent fill."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g54",
|
||||
"x": 1048,
|
||||
"y": 456,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i49",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "skip_previous",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i50",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "replay_10",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i51",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "play_arrow",
|
||||
"variant": "filled"
|
||||
},
|
||||
{
|
||||
"id": "i52",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "forward_10",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i53",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "skip_next",
|
||||
"variant": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g61",
|
||||
"x": 1024,
|
||||
"y": 540,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i55",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "playlist_add",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i56",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "download",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i57",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "lyrics",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i58",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "share",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i59",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "fullscreen",
|
||||
"variant": "text",
|
||||
"action": {
|
||||
"to": "fullscreen",
|
||||
"transition": "expand"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i60",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "more_horiz",
|
||||
"variant": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g63",
|
||||
"x": 984,
|
||||
"y": 788,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i62",
|
||||
"kind": "bottomNav",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"tabs": [
|
||||
{
|
||||
"icon": "search",
|
||||
"label": "Search"
|
||||
},
|
||||
{
|
||||
"icon": "queue_music",
|
||||
"label": "Queue"
|
||||
},
|
||||
{
|
||||
"icon": "history",
|
||||
"label": "History"
|
||||
},
|
||||
{
|
||||
"icon": "download_done",
|
||||
"label": "Saved"
|
||||
},
|
||||
{
|
||||
"icon": "more_horiz",
|
||||
"label": "More"
|
||||
}
|
||||
],
|
||||
"selected": 1,
|
||||
"actions": {
|
||||
"tab:0": {
|
||||
"to": "search",
|
||||
"transition": "fade"
|
||||
},
|
||||
"tab:1": {
|
||||
"to": "queue",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
"note": "Translucent blurred tab bar; badge on Queue."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g65",
|
||||
"x": 1476,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i64",
|
||||
"kind": "topAppBar",
|
||||
"label": "Queue",
|
||||
"icon": "menu",
|
||||
"variant": "filled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g67",
|
||||
"x": 1476,
|
||||
"y": 380,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i66",
|
||||
"kind": "box",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 412,
|
||||
"size2": 512,
|
||||
"checked": true,
|
||||
"fill": "surfaceContainerHigh",
|
||||
"note": "F7 sheet with grab handle; backdrop tap closes."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g69",
|
||||
"x": 1492,
|
||||
"y": 404,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i68",
|
||||
"kind": "text",
|
||||
"label": "Up next",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 20,
|
||||
"bold": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g73",
|
||||
"x": 1492,
|
||||
"y": 448,
|
||||
"axis": "y",
|
||||
"items": [
|
||||
{
|
||||
"id": "i70",
|
||||
"kind": "listItem",
|
||||
"label": "Build My Life",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Housefires \u00b7 6:04",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i71",
|
||||
"kind": "listItem",
|
||||
"label": "Graves Into Gardens",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Elevation \u00b7 6:41",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i72",
|
||||
"kind": "listItem",
|
||||
"label": "Goodness of God",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Bethel Music \u00b7 5:12",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g75",
|
||||
"x": 1968,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i74",
|
||||
"kind": "box",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 412,
|
||||
"size2": 892,
|
||||
"fill": "inverseSurface",
|
||||
"note": "Video fills the screen (object-fit cover). Fit/Fill toggle in the controls."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g77",
|
||||
"x": 1984,
|
||||
"y": 24,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i76",
|
||||
"kind": "text",
|
||||
"label": "Way Maker \u2014 Leeland",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g79",
|
||||
"x": 2316,
|
||||
"y": 12,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i78",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "fullscreen_exit",
|
||||
"variant": "text",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g81",
|
||||
"x": 1984,
|
||||
"y": 790,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i80",
|
||||
"kind": "slider",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"value": 35
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g85",
|
||||
"x": 1984,
|
||||
"y": 828,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i82",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "pause",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i83",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "screen_rotation",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i84",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "fit_screen",
|
||||
"variant": "text",
|
||||
"note": "Fit \u2194 Fill."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
904
docs/design/f7-swipe-light.json
Normal file
904
docs/design/f7-swipe-light.json
Normal file
@@ -0,0 +1,904 @@
|
||||
{
|
||||
"title": "YT Player \u2014 Framework7 Swipe (light)",
|
||||
"brief": "Ad-free YouTube/worship player PWA. Framework7 iOS-style layout with swipe gestures, built on the app's own tokens.",
|
||||
"frame": "phone",
|
||||
"platform": "web",
|
||||
"paletteKey": "coral",
|
||||
"theme": {
|
||||
"dark": false,
|
||||
"bothModes": true,
|
||||
"contrast": "standard",
|
||||
"shape": "rounded",
|
||||
"font": "system",
|
||||
"emphasized": false,
|
||||
"motion": "expressive"
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"id": "search",
|
||||
"name": "Search",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"note": "Home + search results. Big title, rounded search field, results as an inset grouped list.",
|
||||
"swipe": {
|
||||
"left": "queue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "queue",
|
||||
"name": "Queue",
|
||||
"x": 492,
|
||||
"y": 0,
|
||||
"note": "Queued songs. Swipe a row left for Playlist/Remove (long swipe removes), right for Queue. Swipe sideways on empty space to change tab.",
|
||||
"swipe": {
|
||||
"right": "search"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "player",
|
||||
"name": "Now playing",
|
||||
"x": 984,
|
||||
"y": 0,
|
||||
"note": "Video on top, Apple Music\u2013style transport, icon action row. Swipe down to collapse to the mini player.",
|
||||
"swipe": {
|
||||
"down": "queue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "upnext",
|
||||
"name": "Up next",
|
||||
"x": 1476,
|
||||
"y": 0,
|
||||
"note": "Bottom sheet opened by swiping up on the mini player. Half height, drag up for full, down to close."
|
||||
},
|
||||
{
|
||||
"id": "fullscreen",
|
||||
"name": "Fullscreen",
|
||||
"x": 1968,
|
||||
"y": 0,
|
||||
"note": "Edge-to-edge video (Fill by default). Minimal icon-only controls over a soft bottom gradient; auto-hide.",
|
||||
"bg": "inverseSurface"
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": "g2",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i1",
|
||||
"kind": "topAppBar",
|
||||
"label": "Search",
|
||||
"icon": "menu",
|
||||
"variant": "filled",
|
||||
"icon2": "directions_car",
|
||||
"note": "Translucent navbar; menu opens the sidebar (also left-edge swipe)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g4",
|
||||
"x": 16,
|
||||
"y": 96,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i3",
|
||||
"kind": "searchBar",
|
||||
"label": "Search YouTube",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"icon2": "mic",
|
||||
"note": "Rounded F7 searchbar with inline mic; Enter searches."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g8",
|
||||
"x": 16,
|
||||
"y": 164,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i5",
|
||||
"kind": "chip",
|
||||
"label": "lofi beats",
|
||||
"icon": null,
|
||||
"variant": "filled"
|
||||
},
|
||||
{
|
||||
"id": "i6",
|
||||
"kind": "chip",
|
||||
"label": "live news",
|
||||
"icon": null,
|
||||
"variant": "filled"
|
||||
},
|
||||
{
|
||||
"id": "i7",
|
||||
"kind": "chip",
|
||||
"label": "worship",
|
||||
"icon": null,
|
||||
"variant": "filled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g10",
|
||||
"x": 16,
|
||||
"y": 212,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i9",
|
||||
"kind": "text",
|
||||
"label": "Results",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 28,
|
||||
"bold": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g15",
|
||||
"x": 16,
|
||||
"y": 260,
|
||||
"axis": "y",
|
||||
"items": [
|
||||
{
|
||||
"id": "i11",
|
||||
"kind": "listItem",
|
||||
"label": "Goodness of God",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Bethel Music \u00b7 5:12",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i12",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i13",
|
||||
"kind": "listItem",
|
||||
"label": "Build My Life",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Housefires \u00b7 6:04",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i14",
|
||||
"kind": "listItem",
|
||||
"label": "Graves Into Gardens",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Elevation \u00b7 6:41",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g17",
|
||||
"x": 16,
|
||||
"y": 700,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i16",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "music_note",
|
||||
"variant": "tonal",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "play_arrow",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slideUp"
|
||||
},
|
||||
"note": "Floating glass mini player. Tap opens the player, swipe left/right = next/previous, swipe up = Up next sheet."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g19",
|
||||
"x": 0,
|
||||
"y": 788,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i18",
|
||||
"kind": "bottomNav",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"tabs": [
|
||||
{
|
||||
"icon": "search",
|
||||
"label": "Search"
|
||||
},
|
||||
{
|
||||
"icon": "queue_music",
|
||||
"label": "Queue"
|
||||
},
|
||||
{
|
||||
"icon": "history",
|
||||
"label": "History"
|
||||
},
|
||||
{
|
||||
"icon": "download_done",
|
||||
"label": "Saved"
|
||||
},
|
||||
{
|
||||
"icon": "more_horiz",
|
||||
"label": "More"
|
||||
}
|
||||
],
|
||||
"selected": 0,
|
||||
"actions": {
|
||||
"tab:0": {
|
||||
"to": "search",
|
||||
"transition": "fade"
|
||||
},
|
||||
"tab:1": {
|
||||
"to": "queue",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
"note": "Translucent blurred tab bar; badge on Queue."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g21",
|
||||
"x": 492,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i20",
|
||||
"kind": "topAppBar",
|
||||
"label": "Queue",
|
||||
"icon": "menu",
|
||||
"variant": "filled",
|
||||
"icon2": "checklist"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g24",
|
||||
"x": 508,
|
||||
"y": 96,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i22",
|
||||
"kind": "button",
|
||||
"label": "Play queue",
|
||||
"icon": "play_arrow",
|
||||
"variant": "filled",
|
||||
"size": 182
|
||||
},
|
||||
{
|
||||
"id": "i23",
|
||||
"kind": "button",
|
||||
"label": "Clear",
|
||||
"icon": "clear_all",
|
||||
"variant": "tonal",
|
||||
"size": 182
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g29",
|
||||
"x": 508,
|
||||
"y": 168,
|
||||
"axis": "y",
|
||||
"items": [
|
||||
{
|
||||
"id": "i25",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "drag_handle",
|
||||
"note": "Swipe right \u2192 Queue (accent action)."
|
||||
},
|
||||
{
|
||||
"id": "i26",
|
||||
"kind": "listItem",
|
||||
"label": "Build My Life",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Housefires \u00b7 6:04",
|
||||
"icon2": "drag_handle",
|
||||
"note": "Swipe left \u2192 Playlist + Remove pills."
|
||||
},
|
||||
{
|
||||
"id": "i27",
|
||||
"kind": "listItem",
|
||||
"label": "Graves Into Gardens",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Elevation \u00b7 6:41",
|
||||
"icon2": "drag_handle",
|
||||
"note": "Long swipe left removes the row."
|
||||
},
|
||||
{
|
||||
"id": "i28",
|
||||
"kind": "listItem",
|
||||
"label": "Goodness of God",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Bethel Music \u00b7 5:12",
|
||||
"icon2": "drag_handle",
|
||||
"note": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g32",
|
||||
"x": 508,
|
||||
"y": 480,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i30",
|
||||
"kind": "button",
|
||||
"label": "Playlist",
|
||||
"icon": "playlist_add",
|
||||
"variant": "tonal"
|
||||
},
|
||||
{
|
||||
"id": "i31",
|
||||
"kind": "button",
|
||||
"label": "Remove",
|
||||
"icon": "delete",
|
||||
"variant": "filled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g34",
|
||||
"x": 508,
|
||||
"y": 548,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i33",
|
||||
"kind": "text",
|
||||
"label": "Swipe actions revealed (example row)",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g36",
|
||||
"x": 508,
|
||||
"y": 700,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i35",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "music_note",
|
||||
"variant": "tonal",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "play_arrow",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slideUp"
|
||||
},
|
||||
"note": "Floating glass mini player. Tap opens the player, swipe left/right = next/previous, swipe up = Up next sheet."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g38",
|
||||
"x": 492,
|
||||
"y": 788,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i37",
|
||||
"kind": "bottomNav",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"tabs": [
|
||||
{
|
||||
"icon": "search",
|
||||
"label": "Search"
|
||||
},
|
||||
{
|
||||
"icon": "queue_music",
|
||||
"label": "Queue"
|
||||
},
|
||||
{
|
||||
"icon": "history",
|
||||
"label": "History"
|
||||
},
|
||||
{
|
||||
"icon": "download_done",
|
||||
"label": "Saved"
|
||||
},
|
||||
{
|
||||
"icon": "more_horiz",
|
||||
"label": "More"
|
||||
}
|
||||
],
|
||||
"selected": 1,
|
||||
"actions": {
|
||||
"tab:0": {
|
||||
"to": "search",
|
||||
"transition": "fade"
|
||||
},
|
||||
"tab:1": {
|
||||
"to": "queue",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
"note": "Translucent blurred tab bar; badge on Queue."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g40",
|
||||
"x": 984,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i39",
|
||||
"kind": "topAppBar",
|
||||
"label": "Now playing",
|
||||
"icon": "keyboard_arrow_down",
|
||||
"variant": "filled",
|
||||
"icon2": "more_horiz",
|
||||
"actions": {
|
||||
"icon": {
|
||||
"to": "back",
|
||||
"transition": "slideDown"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g42",
|
||||
"x": 984,
|
||||
"y": 88,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i41",
|
||||
"kind": "image",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 412,
|
||||
"note": "16:9 video stage; the fullscreen button opens Fullscreen."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g44",
|
||||
"x": 1000,
|
||||
"y": 320,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i43",
|
||||
"kind": "text",
|
||||
"label": "Way Maker",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 24,
|
||||
"bold": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g46",
|
||||
"x": 1000,
|
||||
"y": 356,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i45",
|
||||
"kind": "text",
|
||||
"label": "Leeland",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 16
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g48",
|
||||
"x": 1000,
|
||||
"y": 396,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i47",
|
||||
"kind": "slider",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"value": 35,
|
||||
"note": "Thin seek track, accent fill."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g54",
|
||||
"x": 1048,
|
||||
"y": 456,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i49",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "skip_previous",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i50",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "replay_10",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i51",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "play_arrow",
|
||||
"variant": "filled"
|
||||
},
|
||||
{
|
||||
"id": "i52",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "forward_10",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i53",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "skip_next",
|
||||
"variant": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g61",
|
||||
"x": 1024,
|
||||
"y": 540,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i55",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "playlist_add",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i56",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "download",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i57",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "lyrics",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i58",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "share",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i59",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "fullscreen",
|
||||
"variant": "text",
|
||||
"action": {
|
||||
"to": "fullscreen",
|
||||
"transition": "expand"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i60",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "more_horiz",
|
||||
"variant": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g63",
|
||||
"x": 984,
|
||||
"y": 788,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i62",
|
||||
"kind": "bottomNav",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"tabs": [
|
||||
{
|
||||
"icon": "search",
|
||||
"label": "Search"
|
||||
},
|
||||
{
|
||||
"icon": "queue_music",
|
||||
"label": "Queue"
|
||||
},
|
||||
{
|
||||
"icon": "history",
|
||||
"label": "History"
|
||||
},
|
||||
{
|
||||
"icon": "download_done",
|
||||
"label": "Saved"
|
||||
},
|
||||
{
|
||||
"icon": "more_horiz",
|
||||
"label": "More"
|
||||
}
|
||||
],
|
||||
"selected": 1,
|
||||
"actions": {
|
||||
"tab:0": {
|
||||
"to": "search",
|
||||
"transition": "fade"
|
||||
},
|
||||
"tab:1": {
|
||||
"to": "queue",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
"note": "Translucent blurred tab bar; badge on Queue."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g65",
|
||||
"x": 1476,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i64",
|
||||
"kind": "topAppBar",
|
||||
"label": "Queue",
|
||||
"icon": "menu",
|
||||
"variant": "filled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g67",
|
||||
"x": 1476,
|
||||
"y": 380,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i66",
|
||||
"kind": "box",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 412,
|
||||
"size2": 512,
|
||||
"checked": true,
|
||||
"fill": "surfaceContainerHigh",
|
||||
"note": "F7 sheet with grab handle; backdrop tap closes."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g69",
|
||||
"x": 1492,
|
||||
"y": 404,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i68",
|
||||
"kind": "text",
|
||||
"label": "Up next",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 20,
|
||||
"bold": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g73",
|
||||
"x": 1492,
|
||||
"y": 448,
|
||||
"axis": "y",
|
||||
"items": [
|
||||
{
|
||||
"id": "i70",
|
||||
"kind": "listItem",
|
||||
"label": "Build My Life",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Housefires \u00b7 6:04",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i71",
|
||||
"kind": "listItem",
|
||||
"label": "Graves Into Gardens",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Elevation \u00b7 6:41",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i72",
|
||||
"kind": "listItem",
|
||||
"label": "Goodness of God",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Bethel Music \u00b7 5:12",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g75",
|
||||
"x": 1968,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i74",
|
||||
"kind": "box",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 412,
|
||||
"size2": 892,
|
||||
"fill": "inverseSurface",
|
||||
"note": "Video fills the screen (object-fit cover). Fit/Fill toggle in the controls."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g77",
|
||||
"x": 1984,
|
||||
"y": 24,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i76",
|
||||
"kind": "text",
|
||||
"label": "Way Maker \u2014 Leeland",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g79",
|
||||
"x": 2316,
|
||||
"y": 12,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i78",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "fullscreen_exit",
|
||||
"variant": "text",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g81",
|
||||
"x": 1984,
|
||||
"y": 790,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i80",
|
||||
"kind": "slider",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"value": 35
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g85",
|
||||
"x": 1984,
|
||||
"y": 828,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i82",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "pause",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i83",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "screen_rotation",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i84",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "fit_screen",
|
||||
"variant": "text",
|
||||
"note": "Fit \u2194 Fill."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
904
docs/design/f7-swipe.json
Normal file
904
docs/design/f7-swipe.json
Normal file
@@ -0,0 +1,904 @@
|
||||
{
|
||||
"title": "YT Player \u2014 Framework7 Swipe",
|
||||
"brief": "Ad-free YouTube/worship player PWA. Framework7 iOS-style layout with swipe gestures, built on the app's own tokens.",
|
||||
"frame": "phone",
|
||||
"platform": "web",
|
||||
"paletteKey": "coral",
|
||||
"theme": {
|
||||
"dark": true,
|
||||
"bothModes": true,
|
||||
"contrast": "standard",
|
||||
"shape": "rounded",
|
||||
"font": "system",
|
||||
"emphasized": false,
|
||||
"motion": "expressive"
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"id": "search",
|
||||
"name": "Search",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"note": "Home + search results. Big title, rounded search field, results as an inset grouped list.",
|
||||
"swipe": {
|
||||
"left": "queue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "queue",
|
||||
"name": "Queue",
|
||||
"x": 492,
|
||||
"y": 0,
|
||||
"note": "Queued songs. Swipe a row left for Playlist/Remove (long swipe removes), right for Queue. Swipe sideways on empty space to change tab.",
|
||||
"swipe": {
|
||||
"right": "search"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "player",
|
||||
"name": "Now playing",
|
||||
"x": 984,
|
||||
"y": 0,
|
||||
"note": "Video on top, Apple Music\u2013style transport, icon action row. Swipe down to collapse to the mini player.",
|
||||
"swipe": {
|
||||
"down": "queue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "upnext",
|
||||
"name": "Up next",
|
||||
"x": 1476,
|
||||
"y": 0,
|
||||
"note": "Bottom sheet opened by swiping up on the mini player. Half height, drag up for full, down to close."
|
||||
},
|
||||
{
|
||||
"id": "fullscreen",
|
||||
"name": "Fullscreen",
|
||||
"x": 1968,
|
||||
"y": 0,
|
||||
"note": "Edge-to-edge video (Fill by default). Minimal icon-only controls over a soft bottom gradient; auto-hide.",
|
||||
"bg": "inverseSurface"
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": "g2",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i1",
|
||||
"kind": "topAppBar",
|
||||
"label": "Search",
|
||||
"icon": "menu",
|
||||
"variant": "filled",
|
||||
"icon2": "directions_car",
|
||||
"note": "Translucent navbar; menu opens the sidebar (also left-edge swipe)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g4",
|
||||
"x": 16,
|
||||
"y": 96,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i3",
|
||||
"kind": "searchBar",
|
||||
"label": "Search YouTube",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"icon2": "mic",
|
||||
"note": "Rounded F7 searchbar with inline mic; Enter searches."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g8",
|
||||
"x": 16,
|
||||
"y": 164,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i5",
|
||||
"kind": "chip",
|
||||
"label": "lofi beats",
|
||||
"icon": null,
|
||||
"variant": "filled"
|
||||
},
|
||||
{
|
||||
"id": "i6",
|
||||
"kind": "chip",
|
||||
"label": "live news",
|
||||
"icon": null,
|
||||
"variant": "filled"
|
||||
},
|
||||
{
|
||||
"id": "i7",
|
||||
"kind": "chip",
|
||||
"label": "worship",
|
||||
"icon": null,
|
||||
"variant": "filled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g10",
|
||||
"x": 16,
|
||||
"y": 212,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i9",
|
||||
"kind": "text",
|
||||
"label": "Results",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 28,
|
||||
"bold": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g15",
|
||||
"x": 16,
|
||||
"y": 260,
|
||||
"axis": "y",
|
||||
"items": [
|
||||
{
|
||||
"id": "i11",
|
||||
"kind": "listItem",
|
||||
"label": "Goodness of God",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Bethel Music \u00b7 5:12",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i12",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i13",
|
||||
"kind": "listItem",
|
||||
"label": "Build My Life",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Housefires \u00b7 6:04",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i14",
|
||||
"kind": "listItem",
|
||||
"label": "Graves Into Gardens",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Elevation \u00b7 6:41",
|
||||
"icon2": "more_horiz",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slide"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g17",
|
||||
"x": 16,
|
||||
"y": 700,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i16",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "music_note",
|
||||
"variant": "tonal",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "play_arrow",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slideUp"
|
||||
},
|
||||
"note": "Floating glass mini player. Tap opens the player, swipe left/right = next/previous, swipe up = Up next sheet."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g19",
|
||||
"x": 0,
|
||||
"y": 788,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i18",
|
||||
"kind": "bottomNav",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"tabs": [
|
||||
{
|
||||
"icon": "search",
|
||||
"label": "Search"
|
||||
},
|
||||
{
|
||||
"icon": "queue_music",
|
||||
"label": "Queue"
|
||||
},
|
||||
{
|
||||
"icon": "history",
|
||||
"label": "History"
|
||||
},
|
||||
{
|
||||
"icon": "download_done",
|
||||
"label": "Saved"
|
||||
},
|
||||
{
|
||||
"icon": "more_horiz",
|
||||
"label": "More"
|
||||
}
|
||||
],
|
||||
"selected": 0,
|
||||
"actions": {
|
||||
"tab:0": {
|
||||
"to": "search",
|
||||
"transition": "fade"
|
||||
},
|
||||
"tab:1": {
|
||||
"to": "queue",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
"note": "Translucent blurred tab bar; badge on Queue."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g21",
|
||||
"x": 492,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i20",
|
||||
"kind": "topAppBar",
|
||||
"label": "Queue",
|
||||
"icon": "menu",
|
||||
"variant": "filled",
|
||||
"icon2": "checklist"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g24",
|
||||
"x": 508,
|
||||
"y": 96,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i22",
|
||||
"kind": "button",
|
||||
"label": "Play queue",
|
||||
"icon": "play_arrow",
|
||||
"variant": "filled",
|
||||
"size": 182
|
||||
},
|
||||
{
|
||||
"id": "i23",
|
||||
"kind": "button",
|
||||
"label": "Clear",
|
||||
"icon": "clear_all",
|
||||
"variant": "tonal",
|
||||
"size": 182
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g29",
|
||||
"x": 508,
|
||||
"y": 168,
|
||||
"axis": "y",
|
||||
"items": [
|
||||
{
|
||||
"id": "i25",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "drag_handle",
|
||||
"note": "Swipe right \u2192 Queue (accent action)."
|
||||
},
|
||||
{
|
||||
"id": "i26",
|
||||
"kind": "listItem",
|
||||
"label": "Build My Life",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Housefires \u00b7 6:04",
|
||||
"icon2": "drag_handle",
|
||||
"note": "Swipe left \u2192 Playlist + Remove pills."
|
||||
},
|
||||
{
|
||||
"id": "i27",
|
||||
"kind": "listItem",
|
||||
"label": "Graves Into Gardens",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Elevation \u00b7 6:41",
|
||||
"icon2": "drag_handle",
|
||||
"note": "Long swipe left removes the row."
|
||||
},
|
||||
{
|
||||
"id": "i28",
|
||||
"kind": "listItem",
|
||||
"label": "Goodness of God",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Bethel Music \u00b7 5:12",
|
||||
"icon2": "drag_handle",
|
||||
"note": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g32",
|
||||
"x": 508,
|
||||
"y": 480,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i30",
|
||||
"kind": "button",
|
||||
"label": "Playlist",
|
||||
"icon": "playlist_add",
|
||||
"variant": "tonal"
|
||||
},
|
||||
{
|
||||
"id": "i31",
|
||||
"kind": "button",
|
||||
"label": "Remove",
|
||||
"icon": "delete",
|
||||
"variant": "filled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g34",
|
||||
"x": 508,
|
||||
"y": 548,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i33",
|
||||
"kind": "text",
|
||||
"label": "Swipe actions revealed (example row)",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g36",
|
||||
"x": 508,
|
||||
"y": 700,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i35",
|
||||
"kind": "listItem",
|
||||
"label": "Way Maker",
|
||||
"icon": "music_note",
|
||||
"variant": "tonal",
|
||||
"supporting": "Leeland \u00b7 3:12",
|
||||
"icon2": "play_arrow",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "slideUp"
|
||||
},
|
||||
"note": "Floating glass mini player. Tap opens the player, swipe left/right = next/previous, swipe up = Up next sheet."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g38",
|
||||
"x": 492,
|
||||
"y": 788,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i37",
|
||||
"kind": "bottomNav",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"tabs": [
|
||||
{
|
||||
"icon": "search",
|
||||
"label": "Search"
|
||||
},
|
||||
{
|
||||
"icon": "queue_music",
|
||||
"label": "Queue"
|
||||
},
|
||||
{
|
||||
"icon": "history",
|
||||
"label": "History"
|
||||
},
|
||||
{
|
||||
"icon": "download_done",
|
||||
"label": "Saved"
|
||||
},
|
||||
{
|
||||
"icon": "more_horiz",
|
||||
"label": "More"
|
||||
}
|
||||
],
|
||||
"selected": 1,
|
||||
"actions": {
|
||||
"tab:0": {
|
||||
"to": "search",
|
||||
"transition": "fade"
|
||||
},
|
||||
"tab:1": {
|
||||
"to": "queue",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
"note": "Translucent blurred tab bar; badge on Queue."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g40",
|
||||
"x": 984,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i39",
|
||||
"kind": "topAppBar",
|
||||
"label": "Now playing",
|
||||
"icon": "keyboard_arrow_down",
|
||||
"variant": "filled",
|
||||
"icon2": "more_horiz",
|
||||
"actions": {
|
||||
"icon": {
|
||||
"to": "back",
|
||||
"transition": "slideDown"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g42",
|
||||
"x": 984,
|
||||
"y": 88,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i41",
|
||||
"kind": "image",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 412,
|
||||
"note": "16:9 video stage; the fullscreen button opens Fullscreen."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g44",
|
||||
"x": 1000,
|
||||
"y": 320,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i43",
|
||||
"kind": "text",
|
||||
"label": "Way Maker",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 24,
|
||||
"bold": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g46",
|
||||
"x": 1000,
|
||||
"y": 356,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i45",
|
||||
"kind": "text",
|
||||
"label": "Leeland",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 16
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g48",
|
||||
"x": 1000,
|
||||
"y": 396,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i47",
|
||||
"kind": "slider",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"value": 35,
|
||||
"note": "Thin seek track, accent fill."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g54",
|
||||
"x": 1048,
|
||||
"y": 456,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i49",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "skip_previous",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i50",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "replay_10",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i51",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "play_arrow",
|
||||
"variant": "filled"
|
||||
},
|
||||
{
|
||||
"id": "i52",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "forward_10",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i53",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "skip_next",
|
||||
"variant": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g61",
|
||||
"x": 1024,
|
||||
"y": 540,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i55",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "playlist_add",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i56",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "download",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i57",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "lyrics",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i58",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "share",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i59",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "fullscreen",
|
||||
"variant": "text",
|
||||
"action": {
|
||||
"to": "fullscreen",
|
||||
"transition": "expand"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i60",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "more_horiz",
|
||||
"variant": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g63",
|
||||
"x": 984,
|
||||
"y": 788,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i62",
|
||||
"kind": "bottomNav",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"tabs": [
|
||||
{
|
||||
"icon": "search",
|
||||
"label": "Search"
|
||||
},
|
||||
{
|
||||
"icon": "queue_music",
|
||||
"label": "Queue"
|
||||
},
|
||||
{
|
||||
"icon": "history",
|
||||
"label": "History"
|
||||
},
|
||||
{
|
||||
"icon": "download_done",
|
||||
"label": "Saved"
|
||||
},
|
||||
{
|
||||
"icon": "more_horiz",
|
||||
"label": "More"
|
||||
}
|
||||
],
|
||||
"selected": 1,
|
||||
"actions": {
|
||||
"tab:0": {
|
||||
"to": "search",
|
||||
"transition": "fade"
|
||||
},
|
||||
"tab:1": {
|
||||
"to": "queue",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
"note": "Translucent blurred tab bar; badge on Queue."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g65",
|
||||
"x": 1476,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i64",
|
||||
"kind": "topAppBar",
|
||||
"label": "Queue",
|
||||
"icon": "menu",
|
||||
"variant": "filled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g67",
|
||||
"x": 1476,
|
||||
"y": 380,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i66",
|
||||
"kind": "box",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 412,
|
||||
"size2": 512,
|
||||
"checked": true,
|
||||
"fill": "surfaceContainerHigh",
|
||||
"note": "F7 sheet with grab handle; backdrop tap closes."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g69",
|
||||
"x": 1492,
|
||||
"y": 404,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i68",
|
||||
"kind": "text",
|
||||
"label": "Up next",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 20,
|
||||
"bold": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g73",
|
||||
"x": 1492,
|
||||
"y": 448,
|
||||
"axis": "y",
|
||||
"items": [
|
||||
{
|
||||
"id": "i70",
|
||||
"kind": "listItem",
|
||||
"label": "Build My Life",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Housefires \u00b7 6:04",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i71",
|
||||
"kind": "listItem",
|
||||
"label": "Graves Into Gardens",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Elevation \u00b7 6:41",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "i72",
|
||||
"kind": "listItem",
|
||||
"label": "Goodness of God",
|
||||
"icon": "smart_display",
|
||||
"variant": "filled",
|
||||
"supporting": "Bethel Music \u00b7 5:12",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g75",
|
||||
"x": 1968,
|
||||
"y": 0,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i74",
|
||||
"kind": "box",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 412,
|
||||
"size2": 892,
|
||||
"fill": "inverseSurface",
|
||||
"note": "Video fills the screen (object-fit cover). Fit/Fill toggle in the controls."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g77",
|
||||
"x": 1984,
|
||||
"y": 24,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i76",
|
||||
"kind": "text",
|
||||
"label": "Way Maker \u2014 Leeland",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"size": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g79",
|
||||
"x": 2316,
|
||||
"y": 12,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i78",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "fullscreen_exit",
|
||||
"variant": "text",
|
||||
"action": {
|
||||
"to": "player",
|
||||
"transition": "fade"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g81",
|
||||
"x": 1984,
|
||||
"y": 790,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i80",
|
||||
"kind": "slider",
|
||||
"label": "",
|
||||
"icon": null,
|
||||
"variant": "filled",
|
||||
"value": 35
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g85",
|
||||
"x": 1984,
|
||||
"y": 828,
|
||||
"axis": "x",
|
||||
"items": [
|
||||
{
|
||||
"id": "i82",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "pause",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i83",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "screen_rotation",
|
||||
"variant": "text"
|
||||
},
|
||||
{
|
||||
"id": "i84",
|
||||
"kind": "iconButton",
|
||||
"label": "",
|
||||
"icon": "fit_screen",
|
||||
"variant": "text",
|
||||
"note": "Fit \u2194 Fill."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
88
docs/fullscreen-background-audio.md
Normal file
88
docs/fullscreen-background-audio.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Fullscreen orientation and iPhone audio continuity
|
||||
|
||||
## Task A — done
|
||||
|
||||
Settings → Listening → Video & fullscreen offers Auto (follow device), Landscape,
|
||||
and Portrait. The choice persists on this device and can be cycled from the
|
||||
fullscreen controls. Supported browsers request fullscreen and then lock the
|
||||
selected orientation; Auto leaves orientation unlocked. Exit releases our lock.
|
||||
|
||||
When orientation locking or element fullscreen is unavailable, the player uses a
|
||||
viewport overlay. A portrait viewport with Landscape selected rotates the existing
|
||||
stage and its controls 90 degrees; Portrait in a landscape viewport rotates it
|
||||
in the opposite direction. Safe-area padding follows the rotated axes. Touch
|
||||
seeking, double-tap seeking, volume/brightness swipes and pinch movement use
|
||||
inverse coordinates. The video stays in its existing DOM position to avoid
|
||||
interrupting WebKit playback. Controls and browser fullscreen exit restore the
|
||||
inline surface, including when a native orientation lock was rejected.
|
||||
|
||||
Safari's custom overlay fills the available dynamic viewport; it cannot force
|
||||
Safari to hide browser chrome or change the phone's physical orientation. An
|
||||
installed PWA has no browser toolbar occupying that viewport.
|
||||
|
||||
Files: `frontend/fullscreen-orientation.js`, `.css`, `.test.js`, small hooks in
|
||||
`app.js`, `index.html`, `sw.js`, `tests/fullscreen-orientation.spec.js` and
|
||||
`playwright.fullscreen.config.js`.
|
||||
|
||||
## Task B — done; physical iPhone verification remains
|
||||
|
||||
The previous progressive handoff loaded the audio source and sought it only when
|
||||
the page was hidden, then paused the video before transferring master ownership.
|
||||
That required a cold audio decoder start and allowed pause-handler re-entry.
|
||||
Foreground recovery also sought the video, whose shared seeking handler then
|
||||
sought the still-playing audio. Repeated play/playing/focus/watchdog handlers
|
||||
could independently issue the same audio play/seek requests.
|
||||
|
||||
On iPhone, progressive playback now uses the existing dual playback path from the
|
||||
start: a muted video and an audible audio element. Saved files use the same local
|
||||
source; streams prefer their audio-only URL, falling back to the muxed source.
|
||||
EQ-render playback already used this path and remains unchanged. Locking promotes
|
||||
the already-playing audio to master without loading, seeking or restarting it.
|
||||
Unlocking aligns only the silent video; its internal seek does not propagate to
|
||||
the audible audio. Pending play requests are shared, small drift does not cause
|
||||
seeks, and the watchdog never seeks an element to itself. Hidden focus events do
|
||||
not restore the video prematurely. The audio clock still owns background progress
|
||||
and ended events, preserving auto-advance and lock-screen playback intent.
|
||||
|
||||
This uses an audio decoder alongside video while visible. Audio-only playback
|
||||
and tracks that start while already backgrounded retain their existing paths.
|
||||
Browsers other than iPhone retain their foreground progressive path. Their cold
|
||||
handoff fallback now sets master ownership before media events and waits for audio
|
||||
to play before pausing video; returning keeps that audio playing while video resumes.
|
||||
|
||||
Files: `frontend/audio-continuity.js`, `.test.js`, player/event hooks in `app.js`,
|
||||
`index.html`, `sw.js`, `tests/audio-continuity.spec.js`, and
|
||||
`playwright.audio-continuity.config.js`.
|
||||
|
||||
## Validation and hardware review
|
||||
|
||||
- 132 frontend unit tests pass, including orientation mapping, source selection,
|
||||
small-drift policy, concurrent play deduplication, autoplay rejection retry, and
|
||||
stale pending requests after pause/source reset.
|
||||
- `node --check frontend/app.js` and `bun build --no-bundle server/server.js` pass.
|
||||
- Eight fullscreen browser cases pass in Chromium/WebKit at 390 × 844, covering
|
||||
Classic and Glass Stage overlays, visible controls, quick toggle, exit/restore,
|
||||
lock/unlock calls and browser exit after lock rejection.
|
||||
- Four audio browser cases use real local PCM media in Chromium/WebKit, with an
|
||||
iPhone user agent, covering saved and streamed progressive attachments. Repeated
|
||||
visibility/pagehide/focus/pageshow events keep the same advancing audio element
|
||||
with zero audio loads, pauses, seeks or additional play calls.
|
||||
- All 155 server tests pass with `YTDLP_PATH=/tmp/ytplayer-test-yt-dlp` (the existing
|
||||
local test ZIPAPP). The initial default run failed because the system Python
|
||||
environment lacked `yt_dlp`; no server changes were necessary.
|
||||
|
||||
Review on an actual iPhone in Safari and installed PWA: saved MP4, streamed songs,
|
||||
EQ renders, multiple lock/unlock and app switches, auto-advance while locked,
|
||||
Media Session controls, seeking and PiP. Test Landscape/Portrait with safe areas,
|
||||
rotation and touch swipes in both themes. On Android/PWA, verify an actual native
|
||||
orientation lock and release; browser tests verify API calls with a controlled
|
||||
orientation implementation.
|
||||
|
||||
iOS may suspend JavaScript, pause media or interrupt the audio session during lock,
|
||||
app switching, route changes or incoming calls. A web app cannot guarantee a zero
|
||||
OS-level gap. These tests demonstrate removal of application-induced source
|
||||
reloads and audio seeks; desktop WebKit cannot simulate physical iOS suspension.
|
||||
Tracks first started in the background still reattach their picture on return;
|
||||
that existing transition also needs hardware review.
|
||||
|
||||
No server imports were added, and nothing was pushed or deployed.
|
||||
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.
|
||||
300
frontend/app.js
300
frontend/app.js
@@ -572,6 +572,9 @@ const API = {
|
||||
search: (query, { refresh = false } = {}) => WEB
|
||||
? webFetch(`/api/search?q=${encodeURIComponent(query)}${refresh ? '&refresh=1' : ''}`)
|
||||
: call('yt.search', 'yt_search', { query }),
|
||||
related: (meta, { refresh = false } = {}) => WEB && /^[\w-]{11}$/.test(meta.id)
|
||||
? fetch(`/api/related?${new URLSearchParams({ videoId: meta.id, title: meta.title || '', channel: meta.channel || meta.artist || '', ...(refresh ? { refresh: '1' } : {}) })}`).then(response => response.json())
|
||||
: API.search(meta.title || meta.channel, { refresh }),
|
||||
// Videos the server already knows — answers fast while the real search loads.
|
||||
searchLocal: (query) => WEB
|
||||
? webFetch(`/api/search/local?q=${encodeURIComponent(query)}`)
|
||||
@@ -641,6 +644,7 @@ function toAssetUrl(path) {
|
||||
// ---------- State ----------
|
||||
const DEFAULT_SETTINGS = {
|
||||
quality: 'auto', volume: 1, audioOnly: false, autoPreload: true,
|
||||
jamesDsp: { enabled: false, preset: 'Flat', params: {} },
|
||||
p2pShare: true, // P2P (docs/p2p-architecture.md): share my saved videos with other devices — ON by default
|
||||
p2pReceive: true, // fetch from other devices when YouTube and the server can't — ON by default
|
||||
saveBeforePlay: false, // download (server-muxed single file) before playing instead of streaming
|
||||
@@ -655,7 +659,7 @@ const DEFAULT_SETTINGS = {
|
||||
density: 'comfortable', // 'comfortable' | 'compact'
|
||||
perfMode: false, // disable heavy visual effects for speed
|
||||
reduceMotion: false, // disable animations
|
||||
layout: 'classic', // 'classic'|'glass-stage'|'sanctuary-stage'|'liturgical'|'living-hymnal'|'broadcaster'|'contemplative'|'bento-hub'
|
||||
layout: 'classic', // 'classic'|'glass-stage'|'sanctuary-stage'|'liturgical'|'living-hymnal'|'broadcaster'|'contemplative'|'bento-hub'|'f7-swipe'
|
||||
autoBackupEnabled: false,
|
||||
autoBackupIntervalDays: 7,
|
||||
serviceVideo: false, // show the video panel inside service mode (lyric videos)
|
||||
@@ -2495,6 +2499,19 @@ const Player = {
|
||||
}
|
||||
},
|
||||
|
||||
// Use one uninterrupted audio decoder on iPhone from the first play. Locking
|
||||
// then promotes that existing clock; it never loads/seeks a new sound source.
|
||||
prepareBackgroundAudio() {
|
||||
if (!IS_IOS || this.mode !== 'progressive') return;
|
||||
const source = AudioContinuity.progressiveSource(current, els.video.getAttribute('src'));
|
||||
if (!source) return;
|
||||
this.mode = 'dual';
|
||||
this.secondary = els.audio;
|
||||
els.video.muted = true;
|
||||
els.audio.src = source;
|
||||
els.audio.load();
|
||||
},
|
||||
|
||||
attach(quality) {
|
||||
const V = els.video, A = els.audio;
|
||||
// An uploaded audio file has no picture track: play it like audio-only and
|
||||
@@ -2508,6 +2525,7 @@ const Player = {
|
||||
const audioOnly = data.settings.audioOnly || (current && current.kind === 'audio') || bgStart;
|
||||
|
||||
// Reset
|
||||
audioContinuity.reset(V); audioContinuity.reset(A);
|
||||
V.pause(); A.pause();
|
||||
this.stopDrift();
|
||||
this._bgHandoff = false;
|
||||
@@ -2551,6 +2569,7 @@ const Player = {
|
||||
A.removeAttribute('src'); A.load();
|
||||
els.art.classList.add('hidden');
|
||||
}
|
||||
this.prepareBackgroundAudio();
|
||||
this.applyVolume();
|
||||
this.applySpeed();
|
||||
this.master.load();
|
||||
@@ -2596,6 +2615,7 @@ const Player = {
|
||||
return;
|
||||
}
|
||||
|
||||
this.prepareBackgroundAudio();
|
||||
this.applyVolume();
|
||||
this.applySpeed();
|
||||
this.master.load();
|
||||
@@ -2639,15 +2659,15 @@ const Player = {
|
||||
|
||||
play() {
|
||||
this._wantsPlaying = true;
|
||||
this.master.play().catch(() => {});
|
||||
if (this.secondary) {
|
||||
this.secondary.currentTime = this.master.currentTime;
|
||||
this.secondary.play().catch(() => {});
|
||||
}
|
||||
if (data.settings.jamesDsp?.enabled) JamesDSP.activate(this.soundEl).catch((e) => JamesDSP.fail(e));
|
||||
audioContinuity.play(this.master);
|
||||
audioContinuity.resume(this.secondary, this.master);
|
||||
},
|
||||
pause() {
|
||||
this._wantsPlaying = false;
|
||||
this.clearBufferGrace();
|
||||
audioContinuity.reset(this.master);
|
||||
if (this.secondary) audioContinuity.reset(this.secondary);
|
||||
this.master.pause();
|
||||
if (this.secondary) this.secondary.pause();
|
||||
},
|
||||
@@ -2686,6 +2706,24 @@ const Player = {
|
||||
SOFT_SYNC_THRESHOLD: 0.08,
|
||||
HARD_SYNC_THRESHOLD: 0.3,
|
||||
SLEW_RATE: 0.04, // playbackRate offset applied while easing back into sync
|
||||
SNAP_COOLDOWN_MS: 3000, // after a hard snap, only slew for this long
|
||||
_seekLag: 0.25, // seconds the last video seek took (measured), used as the snap lead
|
||||
|
||||
// Seek the muted video to where the sounding clock WILL be once the seek lands,
|
||||
// measuring how long seeks take on this device so the lead adapts.
|
||||
snapVideoTo(V, clock) {
|
||||
const rate = (clock && clock.playbackRate) || 1;
|
||||
const lead = clock && !clock.paused ? Math.min(1.5, this._seekLag) * rate : 0;
|
||||
const target = ((clock && clock.currentTime) || 0) + lead;
|
||||
const started = performance.now();
|
||||
this._internalSeek = true;
|
||||
this._lastSnapAt = Date.now();
|
||||
V.addEventListener('seeked', () => {
|
||||
const took = (performance.now() - started) / 1000;
|
||||
this._seekLag = Math.max(0.05, Math.min(1.5, this._seekLag * 0.5 + took * 0.5));
|
||||
}, { once: true });
|
||||
V.currentTime = target;
|
||||
},
|
||||
|
||||
// Shared by the 1s watchdog tick and the master 'playing' handler so both
|
||||
// paths get the same no-pop behavior.
|
||||
@@ -2701,12 +2739,18 @@ const Player = {
|
||||
correctDrift() {
|
||||
if (this.mode !== 'dual' || !this.secondary || this.master.paused) return;
|
||||
if (this.secondary.paused) return; // resume paths own this case
|
||||
// A seek still in flight is not drift: re-snapping now restarts the seek and,
|
||||
// on iPhone, loops "seek → land behind → seek again" (stutter after resuming).
|
||||
if (this.master.seeking) return;
|
||||
const drift = this.secondary.currentTime - this.master.currentTime;
|
||||
const abs = Math.abs(drift);
|
||||
if (abs >= this.HARD_SYNC_THRESHOLD) {
|
||||
if (abs >= this.HARD_SYNC_THRESHOLD && Date.now() - (this._lastSnapAt || 0) < this.SNAP_COOLDOWN_MS) {
|
||||
// Just snapped: ease in instead of snapping again.
|
||||
const base = parseFloat(els.speed.value) || 1;
|
||||
this.master.playbackRate = drift > 0 ? base + this.SLEW_RATE * 2 : base - this.SLEW_RATE * 2;
|
||||
} else if (abs >= this.HARD_SYNC_THRESHOLD) {
|
||||
// Snap the muted video onto the audio clock (inaudible).
|
||||
this._internalSeek = true;
|
||||
this.master.currentTime = this.secondary.currentTime;
|
||||
this.snapVideoTo(this.master, this.secondary);
|
||||
this._resyncSpeed();
|
||||
} else if (abs >= this.SOFT_SYNC_THRESHOLD) {
|
||||
// Video behind audio -> speed the video up; ahead -> slow it down.
|
||||
@@ -2743,8 +2787,7 @@ const Player = {
|
||||
// The element that's actually producing sound in the current mode.
|
||||
const sounder = this.soundEl;
|
||||
if (sounder && sounder.paused) {
|
||||
sounder.currentTime = this.master.currentTime;
|
||||
sounder.play().catch(() => {});
|
||||
audioContinuity.resume(sounder, this.master);
|
||||
}
|
||||
}, 1000);
|
||||
},
|
||||
@@ -2785,25 +2828,26 @@ const Player = {
|
||||
if (!src) return; // nothing to continue with — leave playback alone
|
||||
const V = els.video, A = els.audio;
|
||||
try {
|
||||
A.src = src;
|
||||
A.currentTime = V.currentTime || 0;
|
||||
this._bgHandoff = true;
|
||||
this.master = A;
|
||||
if (A.getAttribute('src') !== src) A.src = src;
|
||||
if (AudioContinuity.needsAlignment(A.currentTime, V.currentTime)) A.currentTime = V.currentTime || 0;
|
||||
A.volume = V.volume;
|
||||
A.muted = V.muted;
|
||||
const p = A.play();
|
||||
if (p && p.catch) p.catch(() => {});
|
||||
V.pause();
|
||||
// Making <audio> the master keeps soundEl, the drift watchdog and the
|
||||
// progress UI pointed at whatever is actually producing sound.
|
||||
this.master = A;
|
||||
this._bgHandoff = true;
|
||||
audioContinuity.play(A).then(() => {
|
||||
if (this._bgHandoff && this.master === A && !A.paused) V.pause();
|
||||
});
|
||||
// Master ownership is set before play/pause can dispatch another handler.
|
||||
} catch {
|
||||
this._bgHandoff = false;
|
||||
this.master = V;
|
||||
}
|
||||
},
|
||||
|
||||
// Undo handoffToBackgroundAudio, putting the video back in charge at the
|
||||
// position the audio reached while the app was away.
|
||||
restoreFromBackgroundAudio() {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
// A track that STARTED in the background is audio-only: re-attach its
|
||||
// picture at the position the audio reached.
|
||||
if (this._bgAudioMode && current && !data.settings.audioOnly) {
|
||||
@@ -2823,8 +2867,8 @@ const Player = {
|
||||
this.master = V;
|
||||
this.secondary = A;
|
||||
try {
|
||||
V.currentTime = t;
|
||||
if (this._wantsPlaying) { const p = V.play(); if (p && p.catch) p.catch(() => {}); }
|
||||
if (AudioContinuity.needsAlignment(V.currentTime, t, 0.12)) this.snapVideoTo(V, A);
|
||||
if (this._wantsPlaying) audioContinuity.play(V);
|
||||
} catch { /* the watchdog picks up whatever is left */ }
|
||||
return;
|
||||
}
|
||||
@@ -2832,14 +2876,11 @@ const Player = {
|
||||
this._bgHandoff = false;
|
||||
this.master = V;
|
||||
try {
|
||||
V.currentTime = t;
|
||||
A.pause();
|
||||
A.removeAttribute('src');
|
||||
A.load();
|
||||
if (this._wantsPlaying) {
|
||||
const p = V.play();
|
||||
if (p && p.catch) p.catch(() => {});
|
||||
}
|
||||
this.mode = 'dual';
|
||||
this.secondary = A;
|
||||
V.muted = true;
|
||||
if (AudioContinuity.needsAlignment(V.currentTime, t, 0.12)) this.snapVideoTo(V, A);
|
||||
if (this._wantsPlaying) audioContinuity.play(V);
|
||||
} catch { /* the watchdog picks up whatever is left */ }
|
||||
},
|
||||
|
||||
@@ -2851,12 +2892,10 @@ const Player = {
|
||||
if (!this._wantsPlaying) return;
|
||||
const sounder = this.soundEl;
|
||||
if (sounder && sounder.paused) {
|
||||
sounder.currentTime = this.master.currentTime;
|
||||
sounder.play().catch(() => {});
|
||||
audioContinuity.resume(sounder, this.master);
|
||||
}
|
||||
if (this.mode === 'dual' && this.secondary && this.secondary.paused && !this.master.paused) {
|
||||
this.secondary.currentTime = this.master.currentTime;
|
||||
this.secondary.play().catch(() => {});
|
||||
audioContinuity.resume(this.secondary, this.master);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -2933,7 +2972,7 @@ function wirePlayerEvents() {
|
||||
const masterIs = (el) => Player.master === el;
|
||||
|
||||
function bind(el) {
|
||||
el.addEventListener('play', () => { if (masterIs(el) && Player.secondary && Player.secondary.paused) { Player.secondary.currentTime = el.currentTime; Player.secondary.play().catch(() => {}); } updatePlayBtn(); });
|
||||
el.addEventListener('play', () => { if (masterIs(el)) audioContinuity.resume(Player.secondary, el); updatePlayBtn(); });
|
||||
el.addEventListener('pause', () => {
|
||||
// iOS paused the master video because the app went to the background
|
||||
// (sometimes before visibilitychange arrives): hand the sound over now.
|
||||
@@ -2983,8 +3022,7 @@ function wirePlayerEvents() {
|
||||
if (Player.secondary && !el.paused && Player.secondary.paused) {
|
||||
// Resume-only path (audio silently paused by iOS or a real stall):
|
||||
// seeking a *paused* audio element is inaudible, so sync then play.
|
||||
Player.secondary.currentTime = el.currentTime;
|
||||
Player.secondary.play().catch(() => {});
|
||||
audioContinuity.resume(Player.secondary, el);
|
||||
} else if (Player.secondary && !el.paused) {
|
||||
// Audio kept playing through the video stall — realign the video.
|
||||
Player.correctDrift();
|
||||
@@ -4693,8 +4731,7 @@ const Notes = (() => {
|
||||
const v = $('stgVideo'), a = $('stgAudio');
|
||||
if (v) v.classList.toggle('on', !ao); if (a) a.classList.toggle('on', ao);
|
||||
}
|
||||
setInterval(chips, 1500);
|
||||
setTimeout(chips, 400);
|
||||
VisibleTimer.setIntervalWhenVisible(chips, 1500, { immediate: true });
|
||||
})();
|
||||
|
||||
// ============================================================================
|
||||
@@ -5033,7 +5070,7 @@ const Remote = (() => {
|
||||
// ==========================================================================
|
||||
// Remote (this phone drives a host)
|
||||
// ==========================================================================
|
||||
const rc = { ws: null, open: false, state: null, stateAt: 0, queue: { items: [], idx: -1 }, retry: null, ping: null, latency: null, online: false, seeking: false, volTimer: null, drag: null };
|
||||
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 });
|
||||
@@ -5263,6 +5300,7 @@ const Remote = (() => {
|
||||
|
||||
function openRemoteView({ code = '' } = {}) {
|
||||
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');
|
||||
@@ -5273,6 +5311,8 @@ const Remote = (() => {
|
||||
|
||||
function closeRemoteView() {
|
||||
rc.open = false;
|
||||
rc.seekTimer?.clear();
|
||||
rc.seekTimer = null;
|
||||
clearTimeout(rc.retry);
|
||||
clearInterval(rc.ping);
|
||||
const ws = rc.ws;
|
||||
@@ -5351,7 +5391,6 @@ const Remote = (() => {
|
||||
toast(next ? `Screen pauses in ${next} min` : 'Sleep timer off');
|
||||
});
|
||||
$('rvService').addEventListener('click', () => cmd('service', { on: !(rc.state && rc.state.service) }));
|
||||
setInterval(() => { if (rc.open) renderSeek(); }, 500);
|
||||
// Hosts keep phones current once a second while anything plays.
|
||||
setInterval(() => { if (host.ws && host.remotes.length) { pushState(); pushQueue(); } }, 1000);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
@@ -5764,7 +5803,7 @@ const CatalogSync = (() => {
|
||||
}
|
||||
}
|
||||
window.addEventListener('online', flush);
|
||||
if (WEB) setInterval(flush, 60000);
|
||||
if (WEB) VisibleTimer.setIntervalWhenVisible(flush, 60000, { immediate: true });
|
||||
return { send, flush };
|
||||
})();
|
||||
function collectSearchMetadata(q, cards) { if (WEB) CatalogSync.send(q, cards); }
|
||||
@@ -6780,6 +6819,36 @@ const PiP = (() => {
|
||||
return { toggle, render };
|
||||
})();
|
||||
|
||||
// Optional JamesDSP stage. The module and WASM are fetched only after opt-in.
|
||||
const JamesDSP = (() => {
|
||||
const state = { controller: null, importing: null };
|
||||
async function activate(element = Player.soundEl) {
|
||||
if (!data.settings.jamesDsp?.enabled || !element) return;
|
||||
try {
|
||||
if (!state.importing) state.importing = import('./vendor/jamesdsp/controller.js');
|
||||
const mod = await state.importing;
|
||||
if (!state.controller) { state.controller = new mod.JamesDSPController(); state.controller.onError = fail; }
|
||||
await state.controller.enable(element, data.settings.jamesDsp);
|
||||
} catch (error) { fail(error); throw error; }
|
||||
}
|
||||
function fail(error) {
|
||||
console.warn('[JamesDSP] Disabled:', error);
|
||||
data.settings.jamesDsp.enabled = false;
|
||||
state.controller?.disable();
|
||||
persist();
|
||||
const checkbox = $('setJamesDsp'); if (checkbox) checkbox.checked = false;
|
||||
const note = $('jamesDspStatus'); if (note) note.textContent = `Audio effects unavailable: ${error?.message || error}`;
|
||||
toast(`Audio effects disabled: ${error?.message || error}`);
|
||||
}
|
||||
async function toggle(on) {
|
||||
data.settings.jamesDsp.enabled = !!on; persist();
|
||||
if (!on) { await state.controller?.disable(); return; }
|
||||
await activate(Player.soundEl);
|
||||
}
|
||||
async function apply() { if (data.settings.jamesDsp?.enabled) await activate(Player.soundEl); }
|
||||
return { activate, fail, toggle, apply, get controller() { return state.controller; } };
|
||||
})();
|
||||
|
||||
// ============================================================================
|
||||
// Equalizer — 10-band graphic EQ (Web Audio)
|
||||
// ============================================================================
|
||||
@@ -6990,11 +7059,13 @@ const Gestures = (() => {
|
||||
showHud(`<div class="gh-seek ${side}">${d < 0 ? '⟲' : '⟳'} ${Math.abs(d)} s</div>`);
|
||||
}
|
||||
|
||||
const point = e => window.playerFullscreen?.point(e) || FullscreenOrientation.coordinates(e.clientX, e.clientY, stage.getBoundingClientRect());
|
||||
function onTap(e) {
|
||||
const p = point(e);
|
||||
const r = stage.getBoundingClientRect();
|
||||
const x = (e.clientX - r.left) / r.width;
|
||||
const x = point(e).x / point(e).width;
|
||||
const now = performance.now();
|
||||
if (now - st.lastTap < 320 && Math.abs(e.clientX - st.lastX) < 60) {
|
||||
if (now - st.lastTap < 320 && Math.abs(p.x - st.lastX) < 60) {
|
||||
st.lastTap = 0;
|
||||
if (x < 1 / 3) seekBy(-10, 'left');
|
||||
else if (x > 2 / 3) seekBy(10, 'right');
|
||||
@@ -7003,12 +7074,12 @@ const Gestures = (() => {
|
||||
return;
|
||||
}
|
||||
st.lastTap = now;
|
||||
st.lastX = e.clientX;
|
||||
st.lastX = p.x;
|
||||
}
|
||||
|
||||
stage.addEventListener('pointerdown', (e) => {
|
||||
if (e.pointerType !== 'touch') return;
|
||||
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
pts.set(e.pointerId, { x: point(e).x, y: point(e).y });
|
||||
st.moved = false;
|
||||
if (pts.size === 2) {
|
||||
const [a, b] = [...pts.values()];
|
||||
@@ -7016,23 +7087,23 @@ const Gestures = (() => {
|
||||
st.swipe = null;
|
||||
} else if (pts.size === 1 && isFs()) {
|
||||
const r = stage.getBoundingClientRect();
|
||||
const right = (e.clientX - r.left) / r.width > 0.5;
|
||||
st.swipe = { y0: e.clientY, right, v0: data.settings.volume ?? 1, b0: st.bright, h: r.height, active: false };
|
||||
const right = point(e).x / point(e).width > 0.5;
|
||||
st.swipe = { y0: point(e).y, right, v0: data.settings.volume ?? 1, b0: st.bright, h: point(e).height, active: false };
|
||||
}
|
||||
});
|
||||
|
||||
stage.addEventListener('pointermove', (e) => {
|
||||
if (!pts.has(e.pointerId)) return;
|
||||
const p = pts.get(e.pointerId);
|
||||
if (Math.hypot(e.clientX - p.x, e.clientY - p.y) > 10) st.moved = true;
|
||||
pts.set(e.pointerId, { ...p, cx: e.clientX, cy: e.clientY });
|
||||
if (Math.hypot(point(e).x - p.x, point(e).y - p.y) > 10) st.moved = true;
|
||||
pts.set(e.pointerId, { ...p, cx: point(e).x, cy: point(e).y });
|
||||
if (st.pinch && pts.size >= 2) {
|
||||
const [a, b] = [...pts.values()].map((q) => ({ x: q.cx ?? q.x, y: q.cy ?? q.y }));
|
||||
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
||||
st.zoom = Math.max(1, Math.min(3, st.pinch.zoom * (d / (st.pinch.d || 1))));
|
||||
const cx = (a.x + b.x) / 2, cy = (a.y + b.y) / 2;
|
||||
const r = stage.getBoundingClientRect();
|
||||
const maxX = (r.width * (st.zoom - 1)) / 2, maxY = (r.height * (st.zoom - 1)) / 2;
|
||||
const maxX = (point(e).width * (st.zoom - 1)) / 2, maxY = (point(e).height * (st.zoom - 1)) / 2;
|
||||
st.panX = Math.max(-maxX, Math.min(maxX, st.pinch.panX + (cx - st.pinch.cx)));
|
||||
st.panY = Math.max(-maxY, Math.min(maxY, st.pinch.panY + (cy - st.pinch.cy)));
|
||||
applyView();
|
||||
@@ -7042,7 +7113,7 @@ const Gestures = (() => {
|
||||
}
|
||||
const sw = st.swipe;
|
||||
if (!sw) return;
|
||||
const dy = sw.y0 - e.clientY;
|
||||
const dy = sw.y0 - point(e).y;
|
||||
if (!sw.active && Math.abs(dy) < 16) return;
|
||||
sw.active = true;
|
||||
const delta = dy / (sw.h * 0.8);
|
||||
@@ -8169,6 +8240,9 @@ function onTrackEnded() {
|
||||
if (!playFullMode && data.settings.loopOne) { Player.seek(0); Player.play(); return; }
|
||||
if (!advanceQueue()) {
|
||||
if (playFullMode) { playFullMode = false; toast('Finished playing in full'); }
|
||||
// Nothing left: drop the intent to play, or the watchdog "resumes" the ended
|
||||
// <audio> from 0 and the last song repeats while the lyrics stay frozen.
|
||||
Player.pause();
|
||||
updatePlayBtn();
|
||||
$('upnext').classList.add('hidden');
|
||||
}
|
||||
@@ -8655,6 +8729,7 @@ function renderSidebar() {
|
||||
// the fallback so a first-time visitor still gets the landing.
|
||||
// Driven from renderSidebar() so every playlist mutation refreshes both.
|
||||
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');
|
||||
@@ -8804,6 +8879,8 @@ function resetListHeaderExtras() {
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
// Theme composition follows the real view without changing its controls.
|
||||
els.cards.dataset.view = view.type;
|
||||
resetListHeaderExtras();
|
||||
{ const ls = $('listSub'); if (ls) ls.textContent = ''; const ps = $('p2pStrip'); if (ps) ps.classList.add('hidden'); }
|
||||
if (view.type !== 'search') { const sm = $('searchMeta'); if (sm) sm.classList.add('hidden'); }
|
||||
@@ -9518,7 +9595,7 @@ async function renderSettings() {
|
||||
</label>
|
||||
<label class="set-row">
|
||||
<span>Layout theme</span>
|
||||
${sel('setLayout', data.settings.layout || 'classic', [['classic', 'Classic (default)'], ['glass-stage', 'Glass Stage'], ['sanctuary-stage', 'Sanctuary Stage'], ['liturgical', 'Liturgical Chronicle'], ['living-hymnal', 'Living Hymnal'], ['broadcaster', 'Sunday Broadcaster'], ['contemplative', 'Contemplative'], ['bento-hub', 'Bento Hub']])}
|
||||
${sel('setLayout', data.settings.layout || 'classic', [['classic', 'Classic (default)'], ['glass-stage', 'Glass Stage'], ['sanctuary-stage', 'Sanctuary Stage'], ['liturgical', 'Liturgical Chronicle'], ['living-hymnal', 'Living Hymnal'], ['broadcaster', 'Sunday Broadcaster'], ['contemplative', 'Contemplative'], ['bento-hub', 'Bento Hub'], ['f7-swipe', 'Framework7 Swipe']])}
|
||||
</label>
|
||||
<label class="set-row">
|
||||
<span>Font size</span>
|
||||
@@ -9598,6 +9675,19 @@ async function renderSettings() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="set-group" id="jamesDspSettings">
|
||||
<div class="set-group-title">Audio effects (JamesDSP)</div>
|
||||
<label class="set-row"><span>Enable JamesDSP<small>Loads the audio processor only when enabled. iOS background playback may be interrupted.</small></span><input id="setJamesDsp" type="checkbox" ${data.settings.jamesDsp?.enabled ? 'checked' : ''} /></label>
|
||||
<label class="set-row"><span>Preset</span>${sel('setJamesDspPreset', data.settings.jamesDsp?.preset || 'Flat', [['Flat','Flat'],['Bass boost','Bass boost'],['Vocal','Vocal'],['Headphone crossfeed','Headphone crossfeed'],['Loudness','Loudness']])}</label>
|
||||
<label class="set-row"><span>EQ low band<small>25 Hz · −12 to +12 dB</small></span><input type="range" min="-12" max="12" step="1" value="${data.settings.jamesDsp?.params?.['eq.band0'] ?? 0}" id="setDspEqLow" /></label>
|
||||
<label class="set-row"><span>EQ mid band<small>1 kHz · −12 to +12 dB</small></span><input type="range" min="-12" max="12" step="1" value="${data.settings.jamesDsp?.params?.['eq.band8'] ?? 0}" id="setDspEqMid" /></label>
|
||||
<label class="set-row"><span>EQ high band<small>16 kHz · −12 to +12 dB</small></span><input type="range" min="-12" max="12" step="1" value="${data.settings.jamesDsp?.params?.['eq.band14'] ?? 0}" id="setDspEqHigh" /></label>
|
||||
<label class="set-row"><span>Bass boost<small>0 to 12 dB</small></span><input type="range" min="0" max="12" step="1" value="${data.settings.jamesDsp?.params?.['bass.boost'] ?? 6}" id="setDspBass" /></label>
|
||||
<label class="set-row"><span>Headphone crossfeed</span><input id="setDspCrossfeed" type="checkbox" ${data.settings.jamesDsp?.params?.['crossfeed.enabled'] ? 'checked' : ''} /></label>
|
||||
<label class="set-row"><span>Limiter threshold<small>−24 to 0 dB</small></span><input type="range" min="-24" max="0" step="1" value="${data.settings.jamesDsp?.params?.['limiter.threshold'] ?? -0.1}" id="setDspLimiter" /></label>
|
||||
<small id="jamesDspStatus" class="sp-note">Supported sample rates: 44.1 and 48 kHz.</small>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-title">Sharing (peer-to-peer)</div>
|
||||
<label class="set-row">
|
||||
@@ -9791,6 +9881,7 @@ async function renderSettings() {
|
||||
const definitions = [
|
||||
['appearance', 'Appearance', 'Listening', () => `${data.settings.theme || 'dark'} · ${data.settings.fontScale || 'normal'} text`, '<circle cx="12" cy="12" r="8"/><path d="M12 4v16"/>'],
|
||||
['playback', 'Playback & sound', 'Listening', () => `${data.settings.quality || 'auto'} quality · ${Level.enabled() ? 'Levelled volume' : 'Original volume'}`, '<path d="m9 5 10 7-10 7zM4 5v14"/>'],
|
||||
['jamesdsp', 'Audio effects (JamesDSP)', 'Listening', () => data.settings.jamesDsp?.enabled ? `On · ${data.settings.jamesDsp.preset || 'Flat'}` : 'Off', '<path d="M4 12h3l2-7 4 14 2-7h5"/>'],
|
||||
['sharing', 'Sharing', 'Library & storage', () => data.settings.p2pShare !== false ? 'Sharing saved videos' : 'Sharing off', '<circle cx="6" cy="12" r="2"/><circle cx="18" cy="5" r="2"/><circle cx="18" cy="19" r="2"/><path d="m8 11 8-5M8 13l8 5"/>'],
|
||||
['offline', 'Offline storage', 'Library & storage', () => `${SaveSlots.limit ? SaveSlots.limit() : Number(data.settings.parallelSaves) || 4} at once · ${$('cacheTotal')?.textContent || 'Checking storage'}`, '<path d="M12 3v12m-4-4 4 4 4-4M4 16v5h16v-5"/>'],
|
||||
['searches', 'Saved searches', 'Library & storage', () => `${SearchLibrary.list().length} saved searches`, '<circle cx="10" cy="10" r="6"/><path d="m15 15 6 6"/>'],
|
||||
@@ -9802,6 +9893,13 @@ async function renderSettings() {
|
||||
];
|
||||
const groups = [...wrap.querySelectorAll(':scope > .set-group')];
|
||||
definitions.forEach(([id, title, cluster, summary, icon], i) => SettingsSections.register({ id, title, cluster, summary, icon, render(container) { container.append(groups[i]); } }));
|
||||
SettingsSections.register({ id: 'video-fullscreen', title: 'Video & fullscreen', cluster: 'Listening', icon: '<path d="M4 9V4h5m6 0h5v5M4 15v5h5m6 0h5v-5"/>', summary: () => `Fullscreen: ${FullscreenOrientation.normalize(data.settings.fullscreenOrientation)}`, render(container) {
|
||||
const row = document.createElement('label'); row.className = 'set-row';
|
||||
row.innerHTML = '<span>Fullscreen orientation</span><select id="setFullscreenOrientation"><option value="auto">Auto (follow device)</option><option value="landscape">Landscape</option><option value="portrait">Portrait</option></select>';
|
||||
const select = row.querySelector('select'); select.value = FullscreenOrientation.normalize(data.settings.fullscreenOrientation);
|
||||
select.onchange = () => { data.settings.fullscreenOrientation = select.value; persist(); };
|
||||
container.append(row);
|
||||
} });
|
||||
SettingsSections.mount(wrap, { settings: data.settings, data, persist, toast });
|
||||
|
||||
// ---- Wire appearance / accessibility ----
|
||||
@@ -9862,6 +9960,12 @@ async function renderSettings() {
|
||||
refresh();
|
||||
}
|
||||
|
||||
$('setJamesDsp').addEventListener('change', (e) => { JamesDSP.toggle(e.target.checked).catch(() => {}); });
|
||||
$('setJamesDspPreset').addEventListener('change', async (e) => { data.settings.jamesDsp ||= { enabled: false, params: {} }; data.settings.jamesDsp.preset = e.target.value; persist(); try { await JamesDSP.apply(); } catch {} });
|
||||
const dspParam = (id, name, parse = Number) => $(id).addEventListener('input', async (e) => { data.settings.jamesDsp ||= { enabled: false, preset: 'Flat', params: {} }; const value = parse(e.target.value); data.settings.jamesDsp.params ||= {}; data.settings.jamesDsp.params[name] = value; persist(); try { if (JamesDSP.controller) await JamesDSP.controller.update(name, value, data.settings.jamesDsp); } catch (error) { JamesDSP.fail(error); } });
|
||||
dspParam('setDspEqLow', 'eq.band0'); dspParam('setDspEqMid', 'eq.band8'); dspParam('setDspEqHigh', 'eq.band14'); dspParam('setDspBass', 'bass.boost'); dspParam('setDspLimiter', 'limiter.threshold');
|
||||
$('setDspCrossfeed').addEventListener('change', async (e) => { const s = data.settings.jamesDsp ||= { enabled: false, preset: 'Flat', params: {} }; s.params ||= {}; s.params['crossfeed.enabled'] = Number(e.target.checked); persist(); try { if (JamesDSP.controller) await JamesDSP.controller.update('crossfeed.enabled', Number(e.target.checked), s); } catch (error) { JamesDSP.fail(error); } });
|
||||
|
||||
// ---- Wire playback controls ----
|
||||
$('setQuality').addEventListener('change', (e) => {
|
||||
data.settings.quality = e.target.value;
|
||||
@@ -10104,6 +10208,7 @@ function renderCard(v, index, list) {
|
||||
const isDownloading = downloading.has(v.id);
|
||||
card.className = 'card' + (isCached ? ' cached' : '') + (isDownloading ? ' downloading' : '') + (selectedIds.has(v.id) ? ' selected' : '');
|
||||
card.dataset.id = v.id;
|
||||
card._video = v; // read by f7-layout.js swipe actions
|
||||
card.innerHTML = `
|
||||
<div class="batch-check"></div>
|
||||
<div class="thumb">
|
||||
@@ -10863,6 +10968,9 @@ function isStackedPlayerLayout() {
|
||||
if (document.documentElement.dataset.layout === 'classic') {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue('--classic-stacked').trim() === '1';
|
||||
}
|
||||
if (document.documentElement.dataset.layout === 'bento-hub') {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue('--bento-stacked').trim() === '1';
|
||||
}
|
||||
return isPortraitPWA();
|
||||
}
|
||||
|
||||
@@ -11006,7 +11114,7 @@ function setupViewportAnchorGuard() {
|
||||
// Last-resort safety net for offsets none of the events above report:
|
||||
// a 2s tick that reads scrollY (cheap) and snaps back only when displaced,
|
||||
// so "nav taps silently do nothing" can never persist longer than a beat.
|
||||
setInterval(reanchor, 2000);
|
||||
VisibleTimer.setIntervalWhenVisible(reanchor, 2000);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -11249,7 +11357,7 @@ function rotateChips() {
|
||||
}
|
||||
});
|
||||
}
|
||||
setInterval(rotateChips, 8000);
|
||||
VisibleTimer.setIntervalWhenVisible(rotateChips, 8000);
|
||||
|
||||
document.querySelectorAll('.chip').forEach((c) => {
|
||||
c.addEventListener('click', () => {
|
||||
@@ -11424,47 +11532,14 @@ document.querySelectorAll('.chip').forEach((c) => {
|
||||
els.loopBtn.addEventListener('click', toggleLoopOne);
|
||||
els.repeatBtn.addEventListener('click', toggleRepeat);
|
||||
if ($('fsMini')) $('fsMini').addEventListener('click', () => els.fsBtn.click());
|
||||
els.fsBtn.addEventListener('click', () => {
|
||||
const stage = els.video.parentElement;
|
||||
const video = els.video;
|
||||
// iOS Safari — including an installed PWA running in standalone mode —
|
||||
// does not implement the standard Fullscreen API for arbitrary elements.
|
||||
// stage.requestFullscreen is simply undefined there, so the button did
|
||||
// nothing (this is the iPhone-in-portrait-PWA bug report). WebKit instead
|
||||
// exposes a video-only, non-standard fullscreen API that *does* work in
|
||||
// standalone mode: HTMLVideoElement.webkitEnterFullscreen/ExitFullscreen.
|
||||
// Try the standard API first everywhere else, then fall back to the
|
||||
// WebKit video API before giving up.
|
||||
if (document.fullscreenElement || video.webkitDisplayingFullscreen) {
|
||||
try {
|
||||
if (document.exitFullscreen) document.exitFullscreen()?.catch(() => {});
|
||||
else if (video.webkitExitFullscreen) video.webkitExitFullscreen();
|
||||
} catch { /* e.g. InvalidStateError — nothing more we can do */ }
|
||||
return;
|
||||
}
|
||||
if (stage.requestFullscreen) {
|
||||
// requestFullscreen() can both throw synchronously (e.g.
|
||||
// InvalidStateError when preconditions like active user-gesture
|
||||
// transient activation aren't met) and return a promise that rejects
|
||||
// asynchronously. Guard against both instead of letting either surface
|
||||
// as an uncaught error.
|
||||
try {
|
||||
stage.requestFullscreen()?.catch(() => {});
|
||||
} catch { /* no-op — fullscreen simply won't engage this time */ }
|
||||
} else if (video.webkitEnterFullscreen) {
|
||||
if (Player.mode === 'audio') {
|
||||
toast('Fullscreen isn’t available in audio-only mode');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Throws InvalidStateError if the element has no loaded media (e.g.
|
||||
// no video source yet) — nothing to show fullscreen in that case.
|
||||
video.webkitEnterFullscreen();
|
||||
} catch { /* no-op — no media loaded to go fullscreen with */ }
|
||||
} else {
|
||||
toast('Fullscreen isn’t supported on this device');
|
||||
}
|
||||
const fullscreen = FullscreenOrientation.create({
|
||||
stage: els.video.parentElement, video: els.video,
|
||||
settings: () => data.settings, persist,
|
||||
toggle: () => Player.toggle(), seek: time => Player.seek(time),
|
||||
state: () => ({ time: Player.master.currentTime, duration: Player.master.duration, paused: Player.master.paused, title: current?.meta?.title || '' }),
|
||||
});
|
||||
window.playerFullscreen = fullscreen;
|
||||
els.fsBtn.addEventListener('click', () => fullscreen.enter());
|
||||
|
||||
els.seek.addEventListener('input', () => {
|
||||
const dur = Player.master.duration || 0;
|
||||
@@ -11850,23 +11925,33 @@ function updateAbUI() {
|
||||
// ============================================================================
|
||||
// Related videos
|
||||
// ============================================================================
|
||||
async function loadRelated() {
|
||||
if (!current || !current.meta) return;
|
||||
$('relatedPanel').classList.add('hidden');
|
||||
relatedVideos = [];
|
||||
try {
|
||||
const res = await API.search(current.meta.title);
|
||||
if (!res || !res.ok || !current) return;
|
||||
relatedVideos = (res.results || []).filter((v) => v.id !== current.meta.id).slice(0, 8);
|
||||
renderRelated();
|
||||
} catch { /* ignore */ }
|
||||
let relatedState = { status: 'idle', message: '' };
|
||||
const fetchRelatedVideos = RelatedVideos.createLoader({
|
||||
fetchRelated: (meta, options) => API.related(meta, options),
|
||||
update(state) {
|
||||
if (current?.meta?.id !== state.id) return;
|
||||
relatedState = state; relatedVideos = state.cards; renderRelated();
|
||||
},
|
||||
});
|
||||
async function loadRelated(options = {}) {
|
||||
if (current?.meta) return fetchRelatedVideos(current.meta, options);
|
||||
}
|
||||
function renderRelated() {
|
||||
const panel = $('relatedPanel'), list = $('relatedList');
|
||||
if (!panel || !list || !relatedVideos.length) { if (panel) panel.classList.add('hidden'); return; }
|
||||
if (!panel || !list || (relatedState.status === 'idle' && !relatedVideos.length)) { if (panel) panel.classList.add('hidden'); return; }
|
||||
panel.classList.remove('hidden');
|
||||
list.classList.toggle('hidden', relatedCollapsed);
|
||||
list.innerHTML = '';
|
||||
if (relatedState.message) {
|
||||
const notice = document.createElement('div'); notice.className = 'related-notice'; notice.setAttribute('role', 'status');
|
||||
const message = document.createElement('p'); message.textContent = relatedState.message; notice.append(message);
|
||||
if (relatedState.status === 'error') {
|
||||
const retry = document.createElement('button'); retry.type = 'button'; retry.className = 'related-retry'; retry.setAttribute('aria-label', 'Retry related videos');
|
||||
retry.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 11a9 9 0 1 1 2.6 7M3 3v8h8"/></svg><span>Retry</span>';
|
||||
retry.onclick = () => loadRelated({ refresh: true }); notice.append(retry);
|
||||
}
|
||||
list.append(notice);
|
||||
}
|
||||
relatedVideos.forEach((v) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'related-item';
|
||||
@@ -12119,8 +12204,7 @@ function checkBuildTag() {
|
||||
}
|
||||
|
||||
function pollBuildTag() {
|
||||
checkBuildTag();
|
||||
setInterval(checkBuildTag, 5 * 60 * 1000); // recheck every 5 minutes
|
||||
VisibleTimer.setIntervalWhenVisible(checkBuildTag, 5 * 60 * 1000, { immediate: true });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
31
frontend/audio-continuity.js
Normal file
31
frontend/audio-continuity.js
Normal file
@@ -0,0 +1,31 @@
|
||||
(function (root) {
|
||||
'use strict';
|
||||
function progressiveSource(current, videoSource) {
|
||||
return current?.eqAudioUrl || current?.localUrl || current?.audioUrl || videoSource || '';
|
||||
}
|
||||
function needsAlignment(time, clock, threshold = 0.3) {
|
||||
return Number.isFinite(time) && Number.isFinite(clock) && Math.abs(time - clock) >= threshold;
|
||||
}
|
||||
function create() {
|
||||
const pending = new WeakMap();
|
||||
function play(element) {
|
||||
if (pending.has(element)) return pending.get(element);
|
||||
if (!element.paused) return Promise.resolve();
|
||||
let request;
|
||||
try { request = Promise.resolve(element.play()); } catch (error) { request = Promise.reject(error); }
|
||||
// Preserve the intent to play after an OS interruption; the watchdog retries.
|
||||
request = request.catch(() => {}).finally(() => { if (pending.get(element) === request) pending.delete(element); });
|
||||
pending.set(element, request); return request;
|
||||
}
|
||||
function resume(element, clock) {
|
||||
// An ended element is finished, not interrupted: play() would restart it from 0.
|
||||
if (!element || !element.paused || element.ended || pending.has(element)) return;
|
||||
if (clock && element !== clock && needsAlignment(element.currentTime, clock.currentTime)) element.currentTime = clock.currentTime;
|
||||
return play(element);
|
||||
}
|
||||
return { play, resume, reset: element => pending.delete(element) };
|
||||
}
|
||||
const api = { progressiveSource, needsAlignment, create };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else { root.AudioContinuity = api; root.audioContinuity = create(); }
|
||||
})(globalThis);
|
||||
49
frontend/audio-continuity.test.js
Normal file
49
frontend/audio-continuity.test.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { progressiveSource, needsAlignment, create } = require('./audio-continuity');
|
||||
test('iPhone progressive audio uses saved/EQ sources before remote fallbacks', () => {
|
||||
assert.equal(progressiveSource({ localUrl: 'blob:saved', audioUrl: 'remote' }, 'mux'), 'blob:saved');
|
||||
assert.equal(progressiveSource({ eqAudioUrl: 'blob:eq', localUrl: 'blob:saved' }), 'blob:eq');
|
||||
assert.equal(progressiveSource({ audioUrl: 'audio' }, 'mux'), 'audio');
|
||||
assert.equal(progressiveSource({}, 'mux'), 'mux');
|
||||
});
|
||||
test('small drift and invalid clocks do not trigger an audible seek', () => {
|
||||
assert.equal(needsAlignment(50.05, 50), false);
|
||||
assert.equal(needsAlignment(49, 50), true);
|
||||
assert.equal(needsAlignment(NaN, 50), false);
|
||||
});
|
||||
test('simultaneous play, visibility and watchdog resumes share one pending play', async () => {
|
||||
let finish, calls = 0, seeks = 0;
|
||||
const audio = { paused: true, get currentTime() { return 10; }, set currentTime(value) { seeks++; }, play() { calls++; return new Promise(resolve => { finish = resolve; }); } };
|
||||
const controller = create();
|
||||
controller.resume(audio, { currentTime: 10.05 });
|
||||
controller.resume(audio, { currentTime: 12 });
|
||||
const pending = controller.play(audio);
|
||||
assert.equal(calls, 1); assert.equal(seeks, 0);
|
||||
finish(); await pending;
|
||||
controller.resume(audio, audio); // never seek an element to itself
|
||||
assert.equal(seeks, 0); assert.equal(calls, 2); finish();
|
||||
});
|
||||
test('rejected autoplay can retry and only paused, misaligned secondaries seek', async () => {
|
||||
const controller = create(); let calls = 0;
|
||||
const audio = { paused: true, currentTime: 0, play() { calls++; return Promise.reject(new Error('OS suspension')); } };
|
||||
await controller.play(audio); await controller.resume(audio, { currentTime: 4 });
|
||||
assert.equal(calls, 2); assert.equal(audio.currentTime, 4);
|
||||
audio.paused = false; controller.resume(audio, { currentTime: 10 });
|
||||
assert.equal(audio.currentTime, 4); assert.equal(calls, 2);
|
||||
});
|
||||
test('a new play after pause/source reset is not blocked by a stale promise', async () => {
|
||||
const controller = create(), finishes = []; let calls = 0;
|
||||
const audio = { paused: true, play() { calls++; return new Promise(resolve => finishes.push(resolve)); } };
|
||||
const old = controller.play(audio); controller.reset(audio);
|
||||
const fresh = controller.play(audio); assert.equal(calls, 2);
|
||||
finishes[0](); await old;
|
||||
assert.equal(controller.play(audio), fresh); // old completion cannot discard the new request
|
||||
finishes[1](); await fresh;
|
||||
});
|
||||
test('an ended element is never resumed (last song must not restart from 0)', () => {
|
||||
let calls = 0;
|
||||
const audio = { paused: true, ended: true, currentTime: 200, play() { calls++; return Promise.resolve(); } };
|
||||
create().resume(audio, { currentTime: 200 });
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
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);
|
||||
});
|
||||
11
frontend/download-actions.css
Normal file
11
frontend/download-actions.css
Normal file
@@ -0,0 +1,11 @@
|
||||
/* 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); }
|
||||
24
frontend/download-actions.js
Normal file
24
frontend/download-actions.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/* One action control for the Downloads page and Settings download manager. */
|
||||
(function(root) {
|
||||
'use strict';
|
||||
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 };
|
||||
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. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
const actionButtons = typeof module !== 'undefined' ? require('./download-actions.js') : root.DownloadActions;
|
||||
const errors = typeof module !== 'undefined' ? require('./download-errors.js') : root.DownloadErrors;
|
||||
|
||||
const bytes = n => {
|
||||
@@ -222,10 +223,7 @@
|
||||
acts.className = 'dl-actions';
|
||||
|
||||
if (isActive) {
|
||||
const pauseBtn = doc.createElement('button');
|
||||
pauseBtn.type = 'button';
|
||||
pauseBtn.className = 'dl-act';
|
||||
pauseBtn.textContent = 'Pause';
|
||||
const pauseBtn = actionButtons.create(doc, 'pause', job.title || job.id);
|
||||
pauseBtn.setAttribute('aria-label', `Pause ${job.title || job.id}`);
|
||||
pauseBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -234,10 +232,7 @@
|
||||
};
|
||||
acts.appendChild(pauseBtn);
|
||||
} else if (isPaused) {
|
||||
const resumeBtn = doc.createElement('button');
|
||||
resumeBtn.type = 'button';
|
||||
resumeBtn.className = 'dl-act';
|
||||
resumeBtn.textContent = 'Resume';
|
||||
const resumeBtn = actionButtons.create(doc, 'resume', job.title || job.id);
|
||||
resumeBtn.setAttribute('aria-label', `Resume ${job.title || job.id}`);
|
||||
resumeBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -246,10 +241,7 @@
|
||||
};
|
||||
acts.appendChild(resumeBtn);
|
||||
} else if (isFailed) {
|
||||
const retryBtn = doc.createElement('button');
|
||||
retryBtn.type = 'button';
|
||||
retryBtn.className = 'dl-act';
|
||||
retryBtn.textContent = 'Retry';
|
||||
const retryBtn = actionButtons.create(doc, 'retry', job.title || job.id);
|
||||
retryBtn.setAttribute('aria-label', `Retry ${job.title || job.id}`);
|
||||
retryBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -259,10 +251,7 @@
|
||||
acts.appendChild(retryBtn);
|
||||
}
|
||||
|
||||
const cancelBtn = doc.createElement('button');
|
||||
cancelBtn.type = 'button';
|
||||
cancelBtn.className = 'dl-act danger';
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
const cancelBtn = actionButtons.create(doc, 'cancel', job.title || job.id);
|
||||
cancelBtn.setAttribute('aria-label', `Cancel ${job.title || job.id}`);
|
||||
cancelBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* Download controls and device storage accounting. */
|
||||
(function(root) {
|
||||
'use strict';
|
||||
const actionButtons = typeof module !== 'undefined' ? require('./download-actions.js') : root.DownloadActions;
|
||||
const errors = typeof module !== 'undefined' ? require('./download-errors.js') : root.DownloadErrors;
|
||||
const bytes = n => n >= 1073741824 ? `${(n / 1073741824).toFixed(1)} GB` : `${(n / 1048576).toFixed(1)} MB`;
|
||||
function breakdown(files, playlists) {
|
||||
@@ -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 actions = doc.createElement('div');
|
||||
for (const action of [...(['active', 'queued'].includes(job.status) ? ['pause'] : [job.status === 'failed' ? 'retry' : 'resume']), 'cancel']) {
|
||||
const button = doc.createElement('button'); button.type = 'button'; button.textContent = action[0].toUpperCase() + action.slice(1); button.setAttribute('aria-label', `${button.textContent} ${job.title || job.id}`); button.onclick = () => { adapter.action(job.id, action); signature = ''; paint(); }; actions.append(button);
|
||||
const button = actionButtons.create(doc, action, job.title || job.id); button.onclick = () => { adapter.action(job.id, action); signature = ''; paint(); }; actions.append(button);
|
||||
}
|
||||
row.append(title, status, progress, actions);
|
||||
if (job.error) errors.appendDetails(row, job.error);
|
||||
|
||||
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=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);
|
||||
});
|
||||
93
frontend/fonts/PlusJakartaSans-OFL.txt
Normal file
93
frontend/fonts/PlusJakartaSans-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Plus Jakarta Sans Project Authors (https://github.com/tokotype/PlusJakartaSans)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
frontend/fonts/PlusJakartaSans-latin-ext.woff2
Normal file
BIN
frontend/fonts/PlusJakartaSans-latin-ext.woff2
Normal file
Binary file not shown.
BIN
frontend/fonts/PlusJakartaSans-latin.woff2
Normal file
BIN
frontend/fonts/PlusJakartaSans-latin.woff2
Normal file
Binary file not shown.
75
frontend/fullscreen-orientation.css
Normal file
75
frontend/fullscreen-orientation.css
Normal file
@@ -0,0 +1,75 @@
|
||||
/* Shared fullscreen surface; inline player styling is unchanged. */
|
||||
.orientation-fullscreen .fullscreen-hud[hidden] { display: none; }
|
||||
.orientation-fullscreen .fullscreen-hud {
|
||||
--hud-ink: var(--text, #fff);
|
||||
position: absolute; z-index: 20; right: max(12px, env(safe-area-inset-right));
|
||||
bottom: max(8px, env(safe-area-inset-bottom)); left: max(12px, env(safe-area-inset-left));
|
||||
display: flex; flex-direction: column; align-items: stretch; gap: 2px; padding: 4px 2px 2px;
|
||||
color: #fff; background: linear-gradient(to bottom, transparent, rgb(0 0 0 / .45));
|
||||
opacity: 1; transition: opacity .22s ease;
|
||||
}
|
||||
.fullscreen-top { display: flex; align-items: center; justify-content: space-between; min-height: 38px; }
|
||||
.fullscreen-top .fullscreen-title { min-width: 0; max-width: calc(100% - 52px); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 13px; font-weight: 500; color: #fff; text-shadow: 0 1px 5px #000; }
|
||||
.fullscreen-controls { display: flex; align-items: center; gap: 6px; }
|
||||
.fullscreen-top button { width: 38px; min-width: 38px; height: 38px; min-height: 38px; }
|
||||
.orientation-fullscreen .fullscreen-hud.hud-hidden { opacity: 0; pointer-events: none; }
|
||||
.fullscreen-hud button {
|
||||
display: grid; place-items: center; flex: none;
|
||||
width: 44px; min-width: 44px; height: 44px; min-height: 44px; padding: 0;
|
||||
border: 0; border-radius: 50%; background: transparent;
|
||||
color: var(--hud-ink, #fff); font: inherit; cursor: pointer;
|
||||
filter: drop-shadow(0 1px 3px rgb(0 0 0 / .8));
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.fullscreen-hud button:hover { background: rgb(255 255 255 / .12); }
|
||||
.fullscreen-hud button:active { transform: scale(.94); }
|
||||
.fullscreen-hud button svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.fullscreen-hud button .fill { fill: currentColor; stroke: none; }
|
||||
.fullscreen-hud button:focus-visible { outline: 2px solid var(--accent, #fff); outline-offset: 2px; }
|
||||
.fullscreen-hud input { min-width: 40px; min-height: 18px; height: 18px; flex: 1; margin: 0 4px; accent-color: var(--accent, #fff); touch-action: none; appearance: none; background: transparent; }
|
||||
.fullscreen-hud input::-webkit-slider-runnable-track { height: 3px; border-radius: 2px; background: linear-gradient(to right, var(--accent, #fff) 0 var(--seek-progress, 0%), rgb(255 255 255 / .55) var(--seek-progress, 0%) 100%); }
|
||||
.fullscreen-hud input::-webkit-slider-thumb { appearance: none; width: 9px; height: 9px; margin-top: -3px; border-radius: 50%; background: #fff; box-shadow: 0 1px 4px #000; }
|
||||
.fullscreen-hud input:active::-webkit-slider-runnable-track { height: 5px; }
|
||||
.fullscreen-hud input::-moz-range-track { height: 3px; border-radius: 2px; background: rgb(255 255 255 / .55); }
|
||||
.fullscreen-hud input::-moz-range-progress { height: 3px; background: var(--accent, #fff); }
|
||||
.fullscreen-hud input::-moz-range-thumb { width: 9px; height: 9px; border: 0; border-radius: 50%; background: #fff; }
|
||||
.player-stage.orientation-fullscreen { background: #000; }
|
||||
.player-stage.orientation-overlay {
|
||||
position: fixed !important; z-index: 10000 !important; inset: 0 !important;
|
||||
width: 100dvw !important; height: 100dvh !important;
|
||||
min-width: 0 !important; min-height: 0 !important;
|
||||
max-width: none !important; max-height: none !important;
|
||||
aspect-ratio: auto !important; margin: 0 !important; padding: 0 !important;
|
||||
border: 0 !important; border-radius: 0 !important; box-shadow: none !important;
|
||||
}
|
||||
.player-stage.orientation-overlay[data-fullscreen-rotation="90"],
|
||||
.player-stage.orientation-overlay[data-fullscreen-rotation="-90"] {
|
||||
inset: auto !important; left: 50% !important; top: 50% !important;
|
||||
width: 100dvh !important; height: 100dvw !important;
|
||||
transform: translate(-50%, -50%) rotate(90deg) !important;
|
||||
}
|
||||
.player-stage.orientation-overlay[data-fullscreen-rotation="-90"] { transform: translate(-50%, -50%) rotate(-90deg) !important; }
|
||||
.orientation-fullscreen video {
|
||||
flex: none !important; width: 100% !important; height: 100% !important;
|
||||
max-width: none !important; max-height: none !important; object-fit: cover;
|
||||
}
|
||||
.orientation-fullscreen[data-fullscreen-fit="fit"] video { object-fit: contain; }
|
||||
.orientation-fullscreen[data-fullscreen-fit="fill"] video { object-fit: cover; }
|
||||
.orientation-fullscreen #stgOverlay { display: none !important; }
|
||||
.orientation-overlay[data-fullscreen-rotation="90"] .fullscreen-hud {
|
||||
bottom: max(12px, env(safe-area-inset-left)); left: max(12px, env(safe-area-inset-top)); right: max(12px, env(safe-area-inset-bottom));
|
||||
}
|
||||
.orientation-overlay[data-fullscreen-rotation="-90"] .fullscreen-hud {
|
||||
bottom: max(12px, env(safe-area-inset-right)); left: max(12px, env(safe-area-inset-bottom)); right: max(12px, env(safe-area-inset-top));
|
||||
}
|
||||
@media (max-width: 460px) {
|
||||
.orientation-fullscreen .fullscreen-hud { gap: 2px; padding: 4px 2px 2px; }
|
||||
.fullscreen-hud button { width: 44px; min-width: 44px; height: 44px; min-height: 44px; }
|
||||
}
|
||||
/* Raise existing ancestry instead of moving a playing video in WebKit. */
|
||||
body.landscape-fs .orientation-ancestor { z-index: 10001 !important; transform: none !important; filter: none !important; overflow: visible !important; }
|
||||
body.landscape-fs { overflow: hidden; }
|
||||
/* While the app's own fullscreen is up, nothing of the app may show around or over it. */
|
||||
body.landscape-fs .orientation-ancestor { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; contain: none !important; will-change: auto !important; perspective: none !important; clip-path: none !important; isolation: auto !important; }
|
||||
body.landscape-fs :is(.topbar, #bottomNav, #miniBar, #sectionRail, #toastContainer, .sheet-backdrop, .f7s-sheet) { visibility: hidden !important; }
|
||||
body.landscape-fs > .player-stage.orientation-overlay { z-index: 2147483000 !important; }
|
||||
124
frontend/fullscreen-orientation.js
Normal file
124
frontend/fullscreen-orientation.js
Normal file
@@ -0,0 +1,124 @@
|
||||
(function (root) {
|
||||
'use strict';
|
||||
const choices = ['auto', 'landscape', 'portrait'];
|
||||
const normalize = value => choices.includes(value) ? value : 'auto';
|
||||
function rotation(value, width, height) {
|
||||
value = normalize(value);
|
||||
return value === 'landscape' && width < height ? 90 : value === 'portrait' && width > height ? -90 : 0;
|
||||
}
|
||||
function coordinates(x, y, rect, angle = 0) {
|
||||
if (angle === 90) return { x: y - rect.top, y: rect.right - x, width: rect.height, height: rect.width };
|
||||
if (angle === -90) return { x: rect.bottom - y, y: x - rect.left, width: rect.height, height: rect.width };
|
||||
return { x: x - rect.left, y: y - rect.top, width: rect.width, height: rect.height };
|
||||
}
|
||||
function normalizeFit(value) { return value === 'fit' ? 'fit' : 'fill'; }
|
||||
function create(options) {
|
||||
const { stage, settings, persist, toggle, seek, state } = options;
|
||||
let active = false, custom = false, native = false, locked = false, angle = 0, timer, hideTimer;
|
||||
const ancestors = [];
|
||||
for (let el = stage.parentElement; el && el !== document.body; el = el.parentElement) ancestors.push(el);
|
||||
// iOS (no element fullscreen) draws its own fixed overlay. Any ancestor with a filter,
|
||||
// backdrop-filter, containment or clipping traps a fixed child, which showed the app
|
||||
// around the video on iPhone. Lift the stage to <body> for the overlay and put it back after.
|
||||
let home = null;
|
||||
function lift() { if (home || stage.parentElement === document.body) return; home = { parent: stage.parentElement, next: stage.nextSibling }; document.body.append(stage); }
|
||||
function unlift() { if (!home) return; const { parent, next } = home; home = null; if (next && next.parentNode === parent) parent.insertBefore(stage, next); else parent.append(stage); }
|
||||
const hud = document.createElement('div'); hud.className = 'fullscreen-hud'; hud.hidden = true;
|
||||
hud.innerHTML = '<div class="fullscreen-top"><div class="fullscreen-title" aria-live="polite"></div><button type="button" aria-label="Exit fullscreen" title="Exit fullscreen"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 4H4v5m0-5 6 6m5 9h5v-5m0 5-6-6"/></svg></button></div><div class="fullscreen-controls"><button type="button" aria-label="Play or pause" title="Play or pause"><svg class="fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg></button><input type="range" min="0" max="1000" value="0" aria-label="Seek video"><button type="button" class="fullscreen-orientation" aria-label="Change fullscreen orientation" title="Change fullscreen orientation"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8V4h4M20 16v4h-4M4 4l6 6m4 4 6 6M20 8V4h-4M4 16v4h4M20 4l-6 6m-4 4-6 6"/></svg></button><button type="button" class="fullscreen-fit" aria-label="Change video fit" title="Change video fit"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7V4h3M17 4h3v3M20 17v3h-3M7 20H4v-3M7 9h10v6H7z"/></svg></button></div>';
|
||||
stage.append(hud);
|
||||
const play = hud.querySelector('button[aria-label="Play or pause"]'), orientation = hud.querySelector('.fullscreen-orientation'), fitButton = hud.querySelector('.fullscreen-fit'), exit = hud.querySelector('button[aria-label="Exit fullscreen"]'), range = hud.querySelector('input');
|
||||
const title = hud.querySelector('.fullscreen-title');
|
||||
function setFit(value) {
|
||||
const fit = normalizeFit(value); settings().fullscreenFit = fit; persist();
|
||||
stage.dataset.fullscreenFit = fit;
|
||||
fitButton.setAttribute('aria-label', `Video fill mode: ${fit === 'fill' ? 'Fill' : 'Fit'}`);
|
||||
fitButton.title = `${fit === 'fill' ? 'Fill' : 'Fit'} video frame`;
|
||||
}
|
||||
function revealControls() {
|
||||
if (!active) return;
|
||||
hud.classList.remove('hud-hidden'); clearTimeout(hideTimer);
|
||||
hideTimer = setTimeout(() => {
|
||||
if (!hud.contains(document.activeElement)) hud.classList.add('hud-hidden');
|
||||
}, 3200);
|
||||
}
|
||||
stage.addEventListener('pointerdown', revealControls, { passive: true });
|
||||
stage.addEventListener('pointermove', event => { if (event.pointerType === 'mouse') revealControls(); }, { passive: true });
|
||||
hud.addEventListener('focusin', () => { clearTimeout(hideTimer); hud.classList.remove('hud-hidden'); });
|
||||
hud.addEventListener('focusout', () => setTimeout(revealControls, 0));
|
||||
hud.addEventListener('click', revealControls);
|
||||
play.onclick = toggle; exit.onclick = leave;
|
||||
fitButton.onclick = () => setFit(settings().fullscreenFit === 'fill' ? 'fit' : 'fill');
|
||||
// Fit/Fill is only the HUD button: a centre double-tap is play/pause and two fingers pinch-zoom (app.js Gestures).
|
||||
function layout() {
|
||||
if (!active) return;
|
||||
angle = custom ? rotation(settings().fullscreenOrientation, innerWidth, innerHeight) : 0;
|
||||
stage.dataset.fullscreenRotation = String(angle);
|
||||
title.textContent = state().title || '';
|
||||
orientation.setAttribute('aria-label', `Change fullscreen orientation (currently ${normalize(settings().fullscreenOrientation)})`);
|
||||
orientation.title = `Orientation: ${normalize(settings().fullscreenOrientation)}`;
|
||||
}
|
||||
async function orient() {
|
||||
if (locked) { screen.orientation?.unlock?.(); locked = false; }
|
||||
const value = normalize(settings().fullscreenOrientation);
|
||||
if (!custom && value !== 'auto') {
|
||||
try { await screen.orientation.lock(value); locked = true; if (!active) { screen.orientation.unlock(); locked = false; return; } }
|
||||
catch { if (!active) return; custom = true; lift(); stage.classList.add('orientation-overlay'); }
|
||||
}
|
||||
layout();
|
||||
}
|
||||
orientation.onclick = () => { settings().fullscreenOrientation = choices[(choices.indexOf(normalize(settings().fullscreenOrientation)) + 1) % choices.length]; persist(); orient(); };
|
||||
range.oninput = () => seek(Number(range.value) / 1000 * (state().duration || 0));
|
||||
// Explicit inverse coordinates also work for rotated range controls on WebKit.
|
||||
const rangeSeek = event => {
|
||||
const point = coordinates(event.clientX, event.clientY, range.getBoundingClientRect(), angle);
|
||||
seek(Math.max(0, Math.min(1, point.x / point.width)) * (state().duration || 0));
|
||||
};
|
||||
range.onpointerdown = event => { event.preventDefault(); range.setPointerCapture(event.pointerId); rangeSeek(event); };
|
||||
range.onpointermove = event => { if (range.hasPointerCapture(event.pointerId)) rangeSeek(event); };
|
||||
hud.addEventListener('pointerdown', event => event.stopPropagation());
|
||||
hud.addEventListener('pointerup', event => event.stopPropagation());
|
||||
function cleanup() {
|
||||
active = false; custom = false; native = false; angle = 0; clearInterval(timer); clearTimeout(hideTimer);
|
||||
ancestors.forEach(el => el.classList.remove('orientation-ancestor'));
|
||||
hud.hidden = true; hud.classList.remove('hud-hidden'); stage.classList.remove('orientation-overlay', 'orientation-fullscreen');
|
||||
delete stage.dataset.fullscreenRotation; document.body.classList.remove('landscape-fs');
|
||||
delete stage.dataset.fullscreenFit;
|
||||
unlift();
|
||||
if (locked) { screen.orientation?.unlock?.(); locked = false; }
|
||||
}
|
||||
function leave() {
|
||||
if (document.fullscreenElement === stage) document.exitFullscreen().catch(() => {});
|
||||
cleanup();
|
||||
}
|
||||
async function enter() {
|
||||
if (active) { leave(); return; }
|
||||
active = true; ancestors.forEach(el => el.classList.add('orientation-ancestor')); stage.classList.add('orientation-fullscreen'); hud.hidden = false; revealControls();
|
||||
setFit(normalizeFit(settings().fullscreenFit));
|
||||
document.body.classList.add('landscape-fs');
|
||||
try {
|
||||
if (!stage.requestFullscreen) throw new Error('Custom fullscreen');
|
||||
await stage.requestFullscreen();
|
||||
native = true;
|
||||
} catch { custom = true; lift(); stage.classList.add('orientation-overlay'); }
|
||||
if (!active) return;
|
||||
await orient();
|
||||
if (!active) return;
|
||||
timer = setInterval(() => {
|
||||
const s = state();
|
||||
range.value = String(s.duration ? s.time / s.duration * 1000 : 0);
|
||||
range.style.setProperty('--seek-progress', `${s.duration ? Math.min(100, s.time / s.duration * 100) : 0}%`);
|
||||
play.setAttribute('aria-label', s.paused ? 'Play video' : 'Pause video');
|
||||
play.title = s.paused ? 'Play' : 'Pause';
|
||||
play.innerHTML = s.paused
|
||||
? '<svg class="fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>'
|
||||
: '<svg class="fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 5h4v14H6zm8 0h4v14h-4z"/></svg>';
|
||||
}, 250);
|
||||
}
|
||||
document.addEventListener('fullscreenchange', () => { if (active && !document.fullscreenElement && native) cleanup(); });
|
||||
document.addEventListener('keydown', event => { if (active && event.key === 'Escape') leave(); });
|
||||
window.addEventListener('resize', layout);
|
||||
return { enter, leave, point: event => coordinates(event.clientX, event.clientY, stage.getBoundingClientRect(), angle) };
|
||||
}
|
||||
const api = { normalize, normalizeFit, rotation, coordinates, create };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.FullscreenOrientation = api;
|
||||
})(globalThis);
|
||||
22
frontend/fullscreen-orientation.test.js
Normal file
22
frontend/fullscreen-orientation.test.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { normalize, normalizeFit, rotation, coordinates } = require('./fullscreen-orientation');
|
||||
test('fullscreen fit defaults safely to fill and accepts fit', () => {
|
||||
assert.equal(normalizeFit(undefined), 'fill');
|
||||
assert.equal(normalizeFit('fit'), 'fit');
|
||||
assert.equal(normalizeFit('unexpected'), 'fill');
|
||||
});
|
||||
test('orientation defaults and only rotates mismatched viewports', () => {
|
||||
assert.equal(normalize('invalid'), 'auto');
|
||||
assert.equal(rotation('auto', 390, 844), 0);
|
||||
assert.equal(rotation('landscape', 390, 844), 90);
|
||||
assert.equal(rotation('landscape', 844, 390), 0);
|
||||
assert.equal(rotation('portrait', 844, 390), -90);
|
||||
});
|
||||
test('rotated touch coordinates preserve local seek and swipe axes', () => {
|
||||
const rect = { left: 10, top: 20, right: 400, bottom: 864, width: 390, height: 844 };
|
||||
assert.deepEqual(coordinates(400, 20, rect, 90), { x: 0, y: 0, width: 844, height: 390 });
|
||||
assert.deepEqual(coordinates(10, 864, rect, 90), { x: 844, y: 390, width: 844, height: 390 });
|
||||
assert.deepEqual(coordinates(10, 864, rect, -90), { x: 0, y: 0, width: 844, height: 390 });
|
||||
assert.deepEqual(coordinates(205, 442, rect), { x: 195, y: 422, width: 390, height: 844 });
|
||||
});
|
||||
@@ -1,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) {
|
||||
'use strict';
|
||||
const paths = {
|
||||
@@ -20,15 +20,17 @@
|
||||
clock:'<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
||||
shuffle:'<path d="m18 3 3 3-3 3M3 6h3c5 0 7 12 12 12h3m-3-3 3 3-3 3M3 18h3c2 0 4-3 6-6s4-6 6-6h3"/>',
|
||||
down:'<path d="m6 9 6 6 6-6"/>', right:'<path d="m9 6 6 6-6 6"/>', back:'<path d="M20 12H4m6-6-6 6 6 6"/>', more:'<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
|
||||
television:'<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4"/>', pin:'<path d="M9 3h6l-1 6 4 4H6l4-4zM12 13v8"/>',
|
||||
devices:'<rect x="3" y="4" width="14" height="12" rx="2"/><path d="M7 20h6M10 16v4"/><rect x="17" y="10" width="5" height="11" rx="1"/>'
|
||||
};
|
||||
const glyphs = {'+':'add','✕':'close','×':'close','✖':'close','🔍':'search','🔎':'search','⬇':'save','⤓':'save','💾':'save','▶':'play','⏸':'pause','⏭':'next','⏮':'previous','↻':'repeat','↺':'repeat','✎':'edit','✏':'edit','📝':'edit','🎤':'lyrics','↗':'share','⧉':'copy','✓':'check','📁':'folder','⚙':'settings','📋':'chapters','🕘':'clock','🔀':'shuffle','📡':'devices','▾':'down','▸':'right','←':'back','⋯':'more','⋮':'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 glyph = Object.keys(glyphs).find(key => rest.startsWith(key));
|
||||
const glyph = Object.keys(mapping).find(key => rest.startsWith(key));
|
||||
if (!glyph) return null;
|
||||
const length = glyph.length + (rest[glyph.length] === '\uFE0F' ? 1 : 0);
|
||||
return { kind:glyphs[glyph], original:prefix+rest.slice(0,length), rest:rest.slice(length) };
|
||||
return { kind:mapping[glyph], original:prefix+rest.slice(0,length), rest:rest.slice(length) };
|
||||
}
|
||||
function mount(doc) {
|
||||
const decorations = new Set(), tabLabels = new Map(); let scheduled=false;
|
||||
@@ -45,14 +47,15 @@
|
||||
}
|
||||
function paint() {
|
||||
scheduled=false;observer.disconnect();
|
||||
const active=doc.documentElement.dataset.layout==='glass-stage';
|
||||
const bento=doc.documentElement.dataset.layout==='bento-hub';
|
||||
const active=bento || doc.documentElement.dataset.layout==='glass-stage';
|
||||
for(const [parent,record] of tabLabels)if(!active || !parent.isConnected){restoreTab(parent,record);tabLabels.delete(parent);}
|
||||
for(const record of decorations)if(!active || !record.svg.isConnected){restore(record);decorations.delete(record);}
|
||||
if(active) for(const parent of doc.querySelectorAll(selector)) {
|
||||
if(active) for(const parent of doc.querySelectorAll(selector+(bento?', .service-btn, .np-btn, .ctrl, .empty-icon, .dl-saved-cta, .brand-mark, .upnext-head button, .ph-logo, .saved-badge':''))) {
|
||||
// Existing SVGs already express the action; only legacy glyphs change.
|
||||
if(parent.querySelector('svg:not(.glass-control-icon),.legacy-nav-icon'))continue;
|
||||
for(const node of [...parent.childNodes]) {
|
||||
if(node.nodeType!==3)continue;const match=leadingGlyph(node.textContent);if(!match)continue;
|
||||
if(node.nodeType!==3)continue;const match=leadingGlyph(node.textContent,bento);if(!match)continue;
|
||||
const icon=svg(match.kind);
|
||||
if(parent.matches('.pl-offline-badge,.home-pl-offline')) { icon.removeAttribute('aria-hidden'); icon.setAttribute('role','img'); icon.setAttribute('aria-label',parent.title || 'Available offline'); }
|
||||
const original=doc.createElement('span'); original.className='glass-original-glyph';original.textContent=match.original;
|
||||
|
||||
@@ -13,3 +13,10 @@ test('playlist trash, menu and music glyphs use drawn glass icons',()=>{
|
||||
assert.equal(leadingGlyph('⋮').kind,'more');
|
||||
assert.equal(leadingGlyph('🎵').kind,'music');
|
||||
});
|
||||
|
||||
test('Bento-only legacy icons leave the Glass glyph set unchanged',()=>{
|
||||
assert.equal(leadingGlyph('📺 Show video'),null);
|
||||
assert.deepEqual(leadingGlyph('📺 Show video',true),{kind:'television',original:'📺',rest:' Show video'});
|
||||
assert.equal(leadingGlyph('⏱ Tap-sync',true).kind,'clock');
|
||||
assert.equal(leadingGlyph('📌 Bookmark',true).kind,'pin');
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/* Glass Stage section shortcuts participate in layout; Classic keeps its rail. */
|
||||
/* Glass Stage: measure the bottom chrome so panels keep clear of it. The
|
||||
section rail stays a fixed floating bar (body-level); never move it into a
|
||||
scrolling container or it scrolls with the content. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
function bottomClearance(viewport, boxes, gap = 16) {
|
||||
@@ -6,9 +8,7 @@
|
||||
return Math.ceil(occupied + (occupied ? gap : 0));
|
||||
}
|
||||
function mount(doc) {
|
||||
const win = doc.defaultView, html = doc.documentElement, rail = doc.getElementById('sectionRail'), panel = doc.getElementById('listPane');
|
||||
if (!rail || !panel) return;
|
||||
const original = doc.createComment('section rail original position'); rail.before(original);
|
||||
const win = doc.defaultView, html = doc.documentElement;
|
||||
let frame = 0;
|
||||
function active() { return html.dataset.layout === 'glass-stage'; }
|
||||
function schedule() { if (!frame) frame = win.requestAnimationFrame(measure); }
|
||||
@@ -23,9 +23,8 @@
|
||||
set('--glass-bottom-clearance', `${bottomClearance(win.innerHeight, [box(nav), box(mini)])}px`);
|
||||
}
|
||||
function sync() {
|
||||
if (active()) { if (rail.parentElement !== panel) panel.prepend(rail); schedule(); }
|
||||
if (active()) schedule();
|
||||
else {
|
||||
if (rail.parentNode !== original.parentNode) original.after(rail);
|
||||
html.style.removeProperty('--glass-nav-height'); html.style.removeProperty('--glass-bottom-clearance');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,11 @@
|
||||
<link rel="stylesheet" href="saved-page.css" />
|
||||
<link rel="stylesheet" href="theme-glass-controls.css" />
|
||||
<link rel="stylesheet" href="theme-glass-panel.css" />
|
||||
<link rel="stylesheet" href="fullscreen-orientation.css" />
|
||||
<link rel="stylesheet" href="related-videos.css" />
|
||||
<link rel="stylesheet" href="download-actions.css" />
|
||||
<link rel="stylesheet" href="theme-bento-hub.css" />
|
||||
<link rel="stylesheet" href="theme-f7.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
@@ -124,8 +129,8 @@
|
||||
<!-- Player pane -->
|
||||
<section id="playerPane" class="player-pane empty">
|
||||
<div class="player-stage">
|
||||
<video id="video" playsinline></video>
|
||||
<audio id="audio"></audio>
|
||||
<video crossorigin="anonymous" id="video" playsinline></video>
|
||||
<audio id="audio" crossorigin="anonymous"></audio>
|
||||
<!-- Lyrics shown in place of the video (watch party / sing-along) -->
|
||||
<div id="stageLyrics" class="stage-lyrics hidden">
|
||||
<div id="stageLyricsList" class="sl-list"></div>
|
||||
@@ -661,16 +666,22 @@
|
||||
<script src="p2p-transfer.js"></script>
|
||||
<script src="async-guard.js"></script>
|
||||
<script src="sw-update.js"></script>
|
||||
<script src="related-videos.js"></script>
|
||||
<script src="audio-continuity.js"></script>
|
||||
<script src="fullscreen-orientation.js"></script>
|
||||
<script src="settings-sections.js"></script>
|
||||
<script src="direct-protocol.js"></script>
|
||||
<script src="direct-stream.js"></script>
|
||||
<script src="direct-media.js"></script>
|
||||
<script src="download-actions.js"></script>
|
||||
<script src="download-errors.js"></script>
|
||||
<script src="downloads.js"></script>
|
||||
<script src="downloads-page.js"></script>
|
||||
<script src="saved-page.js"></script>
|
||||
<script src="server-backfill.js"></script>
|
||||
<script src="glass-controls.js"></script>
|
||||
<script src="bento-hub.js"></script>
|
||||
<script src="f7-layout.js"></script>
|
||||
<script src="glass-panel-layout.js"></script>
|
||||
<script src="party-dj.js"></script>
|
||||
<script src="lyrics-window.js"></script>
|
||||
@@ -682,6 +693,7 @@
|
||||
<script src="piano.js"></script>
|
||||
<script src="setlist-import.js"></script>
|
||||
<script src="car-mode.js"></script>
|
||||
<script src="visible-timer.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
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');
|
||||
});
|
||||
29
frontend/shell-consistency.test.js
Normal file
29
frontend/shell-consistency.test.js
Normal file
@@ -0,0 +1,29 @@
|
||||
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`);
|
||||
});
|
||||
@@ -3345,84 +3345,6 @@ html[data-layout="contemplative"] .brand-mark {
|
||||
box-shadow: 0 6px 18px -4px rgba(45, 212, 191, 0.4), inset 0 1px 0 rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
/* --- Bento Hub -------------------------------------------------------------
|
||||
* GitHub-dark-ish with honey-gold accent. Tighter borders give a
|
||||
* "dashboard card" feel throughout.
|
||||
* ------------------------------------------------------------------------- */
|
||||
html[data-layout="bento-hub"] {
|
||||
--bg: #0d1117;
|
||||
--bg-1: #13181f;
|
||||
--bg-2: #1c2333;
|
||||
--bg-3: #252d3d;
|
||||
--line: #30363d;
|
||||
--line-soft: #21262d;
|
||||
--text: #e6edf3;
|
||||
--text-2: #8b949e;
|
||||
--text-dim: #484f58;
|
||||
--accent: #eab308;
|
||||
--accent-bright: #fbbf24;
|
||||
--accent-deep: #ca8a04;
|
||||
--accent-glow: rgba(234, 179, 8, 0.4);
|
||||
--radius: 10px;
|
||||
--radius-sm: 6px;
|
||||
}
|
||||
html[data-layout="bento-hub"] body::before {
|
||||
background:
|
||||
radial-gradient(900px 600px at 18% -10%, rgba(234, 179, 8, 0.06), transparent 60%),
|
||||
radial-gradient(700px 500px at 100% 110%, rgba(234, 179, 8, 0.03), transparent 55%);
|
||||
}
|
||||
html[data-layout="bento-hub"] .brand-mark {
|
||||
background: linear-gradient(145deg, #fbbf24, #ca8a04);
|
||||
box-shadow: 0 6px 18px -4px rgba(234, 179, 8, 0.45), inset 0 1px 0 rgba(255,255,255,0.25);
|
||||
}
|
||||
html[data-layout="bento-hub"] .sidebar {
|
||||
background: #13181f;
|
||||
border-right: 1px solid #30363d;
|
||||
}
|
||||
html[data-layout="bento-hub"] .brand {
|
||||
border-bottom: 1px solid #21262d;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
html[data-layout="bento-hub"] .nav-item {
|
||||
border-radius: 6px;
|
||||
}
|
||||
html[data-layout="bento-hub"] .nav-item.active {
|
||||
background: rgba(234, 179, 8, 0.08);
|
||||
color: #fbbf24;
|
||||
}
|
||||
html[data-layout="bento-hub"] .nav-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 60%;
|
||||
background: #eab308;
|
||||
border-radius: 0 3px 3px 0;
|
||||
}
|
||||
html[data-layout="bento-hub"] .playlist-item {
|
||||
background: #0d1117;
|
||||
border: 1px solid #21262d;
|
||||
border-radius: 8px;
|
||||
margin: 3px 0;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
html[data-layout="bento-hub"] .playlist-item.active {
|
||||
border-color: #eab308;
|
||||
background: rgba(234, 179, 8, 0.05);
|
||||
}
|
||||
html[data-layout="bento-hub"] .topbar {
|
||||
background: #13181f;
|
||||
border-bottom: 2px solid #21262d;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
html[data-layout="bento-hub"] .mini-bar-inner {
|
||||
background: linear-gradient(180deg, #1c2333, #13181f);
|
||||
border-top: 1px solid #30363d;
|
||||
}
|
||||
|
||||
/* Grid-column overrides only apply on screens wide enough for an inline sidebar
|
||||
(≥861px). Below that the sidebar is position:fixed (out of grid flow) so
|
||||
a two-column grid would leave .main stranded in the narrow first column. */
|
||||
@@ -3983,6 +3905,35 @@ body.presenter-open { overflow: hidden; }
|
||||
.player-stage { touch-action: pan-y; } /* page still scrolls; we get double-tap + pinch */
|
||||
:fullscreen .player-stage, .player-stage:fullscreen,
|
||||
body.landscape-fs .player-stage { touch-action: none; } /* fullscreen (real or the iOS landscape one): swipes too */
|
||||
/* Native fullscreen keeps the ordinary stage's aspect ratio and pane width unless
|
||||
all sizing constraints are explicitly reset. This also covers every layout's
|
||||
player because fullscreen is a cross-layout surface. */
|
||||
:is(.player-stage:fullscreen, .player-stage.orientation-fullscreen.orientation-overlay) {
|
||||
position: fixed !important;
|
||||
inset: 0 !important;
|
||||
width: 100dvw !important;
|
||||
height: 100dvh !important;
|
||||
min-width: 0 !important;
|
||||
min-height: 0 !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
aspect-ratio: auto !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
overflow: hidden !important;
|
||||
background: #000 !important;
|
||||
}
|
||||
.player-stage.orientation-fullscreen > video {
|
||||
flex: none !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
object-fit: contain;
|
||||
}
|
||||
#video { transform-origin: center center; transition: transform 0.05s linear; }
|
||||
.gesture-hud {
|
||||
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 6;
|
||||
|
||||
@@ -59,6 +59,10 @@ const SHELL = [
|
||||
'/service-ui.css',
|
||||
'/refresh.css',
|
||||
'/theme-glass.css',
|
||||
'/theme-bento-hub.css',
|
||||
'/theme-f7.css',
|
||||
'/fonts/PlusJakartaSans-latin.woff2',
|
||||
'/fonts/PlusJakartaSans-latin-ext.woff2',
|
||||
'/theme-glass-panel.css',
|
||||
'/glass-panel-layout.js',
|
||||
'/theme-classic.css',
|
||||
@@ -66,6 +70,7 @@ const SHELL = [
|
||||
'/flag-ui.js',
|
||||
'/export.js',
|
||||
'/async-guard.js',
|
||||
'/visible-timer.js',
|
||||
'/sw-update.js',
|
||||
'/fingerprint.js',
|
||||
'/opfs.js',
|
||||
@@ -91,6 +96,19 @@ const SHELL = [
|
||||
'/p2p-transfer.js',
|
||||
'/p2p-recv-worker.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',
|
||||
'/fullscreen-orientation.js',
|
||||
'/fullscreen-orientation.css',
|
||||
'/download-actions.js',
|
||||
'/download-actions.css',
|
||||
'/download-errors.js',
|
||||
'/downloads.js',
|
||||
'/downloads-page.js',
|
||||
@@ -98,6 +116,9 @@ const SHELL = [
|
||||
'/saved-page.js',
|
||||
'/server-backfill.js',
|
||||
'/glass-controls.js',
|
||||
'/bento-hub.js',
|
||||
'/f7-layout.js',
|
||||
'/vendor/framework7-swipe.min.js',
|
||||
'/theme-glass-controls.css',
|
||||
'/saved-page.css',
|
||||
'/party-dj.js',
|
||||
|
||||
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:999px; 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:999px; }
|
||||
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; }
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
/* In-flow section shortcuts cannot cover a heading, filter or list row. */
|
||||
html[data-layout="glass-stage"] #listPane > .sec-rail {
|
||||
position:static; inset:auto; transform:none; display:flex !important;
|
||||
align-self:flex-start; flex:none; flex-direction:row; flex-wrap:nowrap;
|
||||
width:max-content; max-width:100%; margin:0 0 12px; z-index:auto;
|
||||
/* Section shortcuts float, centred above the mini player and bottom nav
|
||||
(bottom comes from --rail-bottom, measured by SectionRail.placeRail()). */
|
||||
html[data-layout="glass-stage"] .sec-rail,
|
||||
html[data-layout="glass-stage"] body:has(#miniBar:not(.hidden)) .sec-rail {
|
||||
position:fixed; top:auto; right:auto; left:50%; transform:translateX(-50%);
|
||||
flex-direction:row; flex-wrap:nowrap; width:max-content; max-width:calc(100% - 24px); z-index:60;
|
||||
}
|
||||
html[data-layout="glass-stage"] #listPane > .sec-rail.hidden { display:none !important; }
|
||||
html[data-layout="glass-stage"] #listPane > .sec-rail .sec-rail-btn { flex:none; }
|
||||
html[data-layout="glass-stage"] #listPane > .sec-rail .sec-rail-label { z-index:1; }
|
||||
html[data-layout="glass-stage"] .sec-rail-label { left:50%; transform:translateX(-50%); }
|
||||
|
||||
/* Keep the progress strip inside the same rounded card as the controls. */
|
||||
html[data-layout="glass-stage"] .mini-bar {
|
||||
bottom:calc(var(--glass-nav-height, 66px + env(safe-area-inset-bottom)) + 8px);
|
||||
|
||||
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);
|
||||
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();
|
||||
});
|
||||
143
perf/README.md
Normal file
143
perf/README.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# 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.
|
||||
|
||||
954
perf/baseline.mjs
Executable file
954
perf/baseline.mjs
Executable file
@@ -0,0 +1,954 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* perf/baseline.mjs
|
||||
*
|
||||
* Performance baseline harness for ytplayer PWA.
|
||||
* Drives the real Bun server and runs Playwright (Chromium with CDP throttling + CPU 4x;
|
||||
* WebKit with a local throttling & stalling proxy).
|
||||
*
|
||||
* Scenarios:
|
||||
* - cold: fresh context, network download, SW install, boot, media playback check
|
||||
* - warm: re-navigate with active SW and primed HTTP cache
|
||||
* - offline: context.setOffline(true), reload under SW cache
|
||||
* - update-js: update N->N+1 (1-line comment appended to app.js) -> wire bytes + time until new SW ready
|
||||
* - update-css: update N->N+1 (1-line rule appended to theme-glass.css) -> wire bytes + time until new SW ready
|
||||
*
|
||||
* Usage:
|
||||
* node perf/baseline.mjs [options]
|
||||
* Options:
|
||||
* --runs <number> Runs per scenario for median calculation (default: 5)
|
||||
* --browser <chr|wk|all> Browser(s) to test (default: all)
|
||||
* --profile <lte|lossy|all> Network profile (default: lte)
|
||||
* --scenario <name|all> Specific scenario or all (default: all)
|
||||
* --out <path> Output JSON path
|
||||
* --compare <json-path> Compare results against a previous baseline JSON
|
||||
*/
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, rmSync, cpSync, appendFileSync, statSync, writeFileSync, readFileSync, symlinkSync, unlinkSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createThrottleProxy } from './proxy.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
// Dynamically resolve playwright from node_modules
|
||||
let pw;
|
||||
try {
|
||||
pw = await import('playwright');
|
||||
} catch {
|
||||
pw = await import(path.join(REPO_ROOT, 'node_modules', 'playwright', 'index.mjs'));
|
||||
}
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
const req = createRequire(import.meta.url);
|
||||
const { createClient } = req(path.join(REPO_ROOT, 'server', 'node_modules', '@libsql/client'));
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const options = {
|
||||
runs: 5,
|
||||
browser: 'all', // 'chromium', 'webkit', or 'all'
|
||||
profile: 'lte', // 'lte', 'lossy', or 'all'
|
||||
scenario: 'all', // 'cold', 'warm', 'offline', 'update-js', 'update-css', or 'all'
|
||||
out: null,
|
||||
compare: null,
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--runs' && i + 1 < args.length) options.runs = parseInt(args[++i], 10);
|
||||
else if (a === '--browser' && i + 1 < args.length) options.browser = args[++i];
|
||||
else if (a === '--profile' && i + 1 < args.length) options.profile = args[++i];
|
||||
else if (a === '--scenario' && i + 1 < args.length) options.scenario = args[++i];
|
||||
else if (a === '--out' && i + 1 < args.length) options.out = args[++i];
|
||||
else if (a === '--compare' && i + 1 < args.length) options.compare = args[++i];
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function median(values) {
|
||||
const nums = values.filter((v) => typeof v === 'number' && !Number.isNaN(v)).sort((a, b) => a - b);
|
||||
if (!nums.length) return null;
|
||||
const mid = Math.floor(nums.length / 2);
|
||||
return nums.length % 2 !== 0 ? nums[mid] : Math.round(((nums[mid - 1] + nums[mid]) / 2) * 10) / 10;
|
||||
}
|
||||
|
||||
function summarizeList(values) {
|
||||
const valid = values.filter((v) => v !== null && v !== undefined && !Number.isNaN(v));
|
||||
if (!valid.length) return { median: null, min: null, max: null, values };
|
||||
return {
|
||||
median: median(valid),
|
||||
min: Math.min(...valid),
|
||||
max: Math.max(...valid),
|
||||
values,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup deterministic local media fixture
|
||||
*/
|
||||
function prepareMediaFiles(mediaDir) {
|
||||
mkdirSync(mediaDir, { recursive: true });
|
||||
const mp4 = path.join(mediaDir, 'benchmedia01.1.mp4');
|
||||
const m4a = path.join(mediaDir, 'benchmedia01.1.m4a');
|
||||
|
||||
if (!existsSync(mp4)) {
|
||||
spawnSync('ffmpeg', [
|
||||
'-y', '-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=30:duration=5',
|
||||
'-f', 'lavfi', '-i', 'sine=frequency=440:duration=5',
|
||||
'-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p',
|
||||
'-c:a', 'aac', '-movflags', '+faststart', '-shortest', mp4,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!existsSync(m4a)) {
|
||||
spawnSync('ffmpeg', [
|
||||
'-y', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=5',
|
||||
'-c:a', 'aac', '-shortest', m4a,
|
||||
]);
|
||||
}
|
||||
|
||||
return { mp4, m4a };
|
||||
}
|
||||
|
||||
async function seedDatabase(dbPath, mediaFiles) {
|
||||
const db = createClient({ url: 'file:' + dbPath });
|
||||
const mp4Size = statSync(mediaFiles.mp4).size;
|
||||
const m4aSize = statSync(mediaFiles.m4a).size;
|
||||
|
||||
await db.execute({
|
||||
sql: `INSERT OR REPLACE INTO media_cache (
|
||||
video_id, status, gen, size, height, vcodec, acodec, duration, meta, last_access
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
args: [
|
||||
'benchmedia01',
|
||||
'ready',
|
||||
1,
|
||||
mp4Size + m4aSize,
|
||||
360,
|
||||
'h264',
|
||||
'aac',
|
||||
5.0,
|
||||
JSON.stringify({ title: 'Baseline Deterministic Clip', channel: 'Perf Benchmark' }),
|
||||
Date.now(),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bun Server Manager
|
||||
*/
|
||||
class ServerManager {
|
||||
constructor(scratchDir, publicDir) {
|
||||
this.scratchDir = scratchDir;
|
||||
this.publicDir = publicDir;
|
||||
this.dataDir = path.join(scratchDir, 'data');
|
||||
this.mediaDir = path.join(this.dataDir, 'media');
|
||||
this.dbPath = path.join(this.dataDir, 'ytplayer.db');
|
||||
this.serverProcess = null;
|
||||
this.port = 0;
|
||||
}
|
||||
|
||||
async start() {
|
||||
mkdirSync(this.dataDir, { recursive: true });
|
||||
const fixtureDir = path.join(REPO_ROOT, 'perf', 'fixtures', 'media');
|
||||
const mediaFiles = prepareMediaFiles(fixtureDir);
|
||||
|
||||
// Create a dedicated server working dir with public symlink
|
||||
const srvCwd = path.join(this.scratchDir, 'srv');
|
||||
mkdirSync(srvCwd, { recursive: true });
|
||||
const publicLink = path.join(srvCwd, 'public');
|
||||
if (existsSync(publicLink)) unlinkSync(publicLink);
|
||||
symlinkSync(this.publicDir, publicLink);
|
||||
|
||||
// Pick random available ephemeral port once and reuse across restarts
|
||||
this.port = this.port || (4300 + Math.floor(Math.random() * 2000));
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
PORT: String(this.port),
|
||||
DATA_DIR: this.dataDir,
|
||||
MEDIA_DIR: this.mediaDir,
|
||||
DB_PATH: this.dbPath,
|
||||
};
|
||||
|
||||
const serverScript = path.join(REPO_ROOT, 'server', 'server.js');
|
||||
this.serverProcess = spawn('bun', [serverScript], {
|
||||
cwd: srvCwd,
|
||||
env,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
|
||||
// Wait for server readiness
|
||||
let ready = false;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${this.port}/api/version`);
|
||||
if (res.ok) {
|
||||
ready = true;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
throw new Error(`Server failed to start on port ${this.port}`);
|
||||
}
|
||||
|
||||
// Copy media files to mediaDir and seed media table AFTER server startup sweep
|
||||
mkdirSync(this.mediaDir, { recursive: true });
|
||||
const targetMp4 = path.join(this.mediaDir, 'benchmedia01.1.mp4');
|
||||
const targetM4a = path.join(this.mediaDir, 'benchmedia01.1.m4a');
|
||||
cpSync(mediaFiles.mp4, targetMp4);
|
||||
cpSync(mediaFiles.m4a, targetM4a);
|
||||
await seedDatabase(this.dbPath, { mp4: targetMp4, m4a: targetM4a });
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (this.serverProcess) {
|
||||
const proc = this.serverProcess;
|
||||
this.serverProcess = null;
|
||||
await new Promise((resolve) => {
|
||||
if (proc.exitCode !== null) return resolve();
|
||||
proc.once('exit', () => resolve());
|
||||
proc.kill('SIGKILL');
|
||||
setTimeout(resolve, 2000);
|
||||
});
|
||||
await sleep(200);
|
||||
}
|
||||
}
|
||||
|
||||
async restartWithPublic(newPublicDir) {
|
||||
await this.stop();
|
||||
this.publicDir = newPublicDir;
|
||||
await this.start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure page for metrics collection
|
||||
*/
|
||||
async function setupPageMetrics(page) {
|
||||
await page.addInitScript(() => {
|
||||
window.__bootDone = null;
|
||||
window.__fcp = null;
|
||||
window.__lcp = null;
|
||||
window.__longTasksTotal = 0;
|
||||
|
||||
// Observe boot completion landmark (#cards view set by renderList())
|
||||
const markBoot = () => {
|
||||
const cards = document.getElementById('cards');
|
||||
if (cards && cards.dataset && cards.dataset.view) {
|
||||
if (!window.__bootDone) window.__bootDone = performance.now();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const t = setInterval(() => {
|
||||
if (markBoot()) clearInterval(t);
|
||||
}, 5);
|
||||
|
||||
// Observe Long Tasks API (supported on Chromium)
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
window.__longTasksTotal += entry.duration;
|
||||
}
|
||||
}).observe({ type: 'longtask', buffered: true });
|
||||
} catch {}
|
||||
|
||||
// Observe LCP
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries();
|
||||
if (entries.length > 0) {
|
||||
window.__lcp = entries[entries.length - 1].startTime;
|
||||
}
|
||||
}).observe({ type: 'largest-contentful-paint', buffered: true });
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Scenario: Cold Load
|
||||
*/
|
||||
async function runColdScenario({ browserType, server, proxy, profile }) {
|
||||
const isWebKit = browserType.name() === 'webkit';
|
||||
const launchArgs = isWebKit ? [] : ['--autoplay-policy=no-user-gesture-required'];
|
||||
const browser = await browserType.launch({
|
||||
headless: true,
|
||||
args: launchArgs,
|
||||
});
|
||||
|
||||
const contextOptions = { proxy: { server: `http://127.0.0.1:${proxy.port}` } };
|
||||
const context = await browser.newContext(contextOptions);
|
||||
const page = await context.newPage();
|
||||
|
||||
// If Chromium, configure CDP Network conditions and 4x CPU throttle
|
||||
if (!isWebKit) {
|
||||
const cdp = await context.newCDPSession(page);
|
||||
await cdp.send('Network.enable');
|
||||
const isLossy = profile === 'lossy';
|
||||
await cdp.send('Network.emulateNetworkConditions', {
|
||||
offline: false,
|
||||
latency: isLossy ? 250 : 150,
|
||||
downloadThroughput: isLossy ? (1.0 * 1000 * 1000) / 8 : (1.6 * 1000 * 1000) / 8,
|
||||
uploadThroughput: (750 * 1000) / 8,
|
||||
connectionType: 'cellular4g',
|
||||
});
|
||||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
|
||||
}
|
||||
|
||||
await setupPageMetrics(page);
|
||||
|
||||
proxy.resetStats();
|
||||
proxy.startTracking();
|
||||
|
||||
const swInstallStart = Date.now();
|
||||
const navUrl = `http://127.0.0.1:${server.port}/?v=benchmedia01`;
|
||||
|
||||
await page.goto(navUrl);
|
||||
|
||||
// Wait for boot landmark
|
||||
await page.waitForFunction(() => window.__bootDone !== null, { timeout: 15000 });
|
||||
|
||||
// Check deterministic media playing-ready
|
||||
let mediaReadyMs = null;
|
||||
let autoplayBlocked = false;
|
||||
try {
|
||||
const mediaCheck = await page.waitForFunction(() => {
|
||||
const v = document.querySelector('video') || document.querySelector('audio');
|
||||
if (!v) return false;
|
||||
return v.readyState >= 3 || (!v.paused && v.currentTime > 0);
|
||||
}, { timeout: 8000 });
|
||||
if (mediaCheck) {
|
||||
const state = await page.evaluate(() => {
|
||||
const v = document.querySelector('video') || document.querySelector('audio');
|
||||
return { paused: v.paused, readyState: v.readyState };
|
||||
});
|
||||
autoplayBlocked = state.paused;
|
||||
mediaReadyMs = await page.evaluate(() => performance.now());
|
||||
}
|
||||
} catch {
|
||||
autoplayBlocked = true;
|
||||
}
|
||||
|
||||
// Wait for initial Service Worker activation
|
||||
await page.evaluate(async () => {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
if (reg.active && reg.active.state === 'activated') return;
|
||||
await new Promise((resolve) => {
|
||||
const w = reg.active || reg.installing;
|
||||
if (w.state === 'activated') return resolve();
|
||||
w.addEventListener('statechange', () => {
|
||||
if (w.state === 'activated') resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const swInstallDuration = Date.now() - swInstallStart;
|
||||
proxy.stopTracking();
|
||||
|
||||
const stats = proxy.getStats();
|
||||
|
||||
const pageMetrics = await page.evaluate(() => {
|
||||
const nav = performance.getEntriesByType('navigation')[0];
|
||||
const paint = performance.getEntriesByType('paint');
|
||||
const fcp = paint.find((p) => p.name === 'first-contentful-paint');
|
||||
|
||||
return {
|
||||
fcp: fcp ? Math.round(fcp.startTime) : null,
|
||||
lcp: window.__lcp ? Math.round(window.__lcp) : null,
|
||||
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
|
||||
bootDone: window.__bootDone ? Math.round(window.__bootDone) : null,
|
||||
longTaskTotal: Math.round(window.__longTasksTotal || 0),
|
||||
};
|
||||
});
|
||||
|
||||
const storage = await page.evaluate(async () => {
|
||||
if (navigator.storage && navigator.storage.estimate) {
|
||||
const est = await navigator.storage.estimate();
|
||||
return { usage: est.usage || 0, quota: est.quota || 0 };
|
||||
}
|
||||
return { usage: null, quota: null };
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
return {
|
||||
requestCount: stats.requestCount,
|
||||
wireBytes: stats.totalWireBytes,
|
||||
wireBytesByType: stats.bytesByType,
|
||||
fcp: pageMetrics.fcp,
|
||||
lcp: pageMetrics.lcp,
|
||||
domContentLoaded: pageMetrics.domContentLoaded,
|
||||
bootDone: pageMetrics.bootDone,
|
||||
longTaskTotal: pageMetrics.longTaskTotal,
|
||||
swInstallDuration,
|
||||
swInstallBytes: stats.totalWireBytes,
|
||||
storageEstimate: storage,
|
||||
mediaReady: mediaReadyMs ? Math.round(mediaReadyMs) : null,
|
||||
autoplayBlocked,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Scenario: Warm Load (re-navigation with active SW)
|
||||
*/
|
||||
async function runWarmScenario({ browserType, server, proxy, profile }) {
|
||||
const isWebKit = browserType.name() === 'webkit';
|
||||
const launchArgs = isWebKit ? [] : ['--autoplay-policy=no-user-gesture-required'];
|
||||
const browser = await browserType.launch({
|
||||
headless: true,
|
||||
args: launchArgs,
|
||||
});
|
||||
|
||||
const contextOptions = { proxy: { server: `http://127.0.0.1:${proxy.port}` } };
|
||||
const context = await browser.newContext(contextOptions);
|
||||
const page = await context.newPage();
|
||||
|
||||
if (!isWebKit) {
|
||||
const cdp = await context.newCDPSession(page);
|
||||
await cdp.send('Network.enable');
|
||||
const isLossy = profile === 'lossy';
|
||||
await cdp.send('Network.emulateNetworkConditions', {
|
||||
offline: false,
|
||||
latency: isLossy ? 250 : 150,
|
||||
downloadThroughput: isLossy ? (1.0 * 1000 * 1000) / 8 : (1.6 * 1000 * 1000) / 8,
|
||||
uploadThroughput: (750 * 1000) / 8,
|
||||
connectionType: 'cellular4g',
|
||||
});
|
||||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
|
||||
}
|
||||
|
||||
await setupPageMetrics(page);
|
||||
|
||||
const navUrl = `http://127.0.0.1:${server.port}/`;
|
||||
|
||||
// Prime context with cold load
|
||||
await page.goto(navUrl);
|
||||
await page.waitForFunction(() => window.__bootDone !== null, { timeout: 15000 });
|
||||
await page.evaluate(async () => {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
if (reg.active && reg.active.state === 'activated') return;
|
||||
await new Promise((resolve) => {
|
||||
const w = reg.active || reg.installing;
|
||||
if (w.state === 'activated') return resolve();
|
||||
w.addEventListener('statechange', () => {
|
||||
if (w.state === 'activated') resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Now measure warm re-navigation
|
||||
proxy.resetStats();
|
||||
proxy.startTracking();
|
||||
|
||||
await page.goto(navUrl);
|
||||
await page.waitForFunction(() => window.__bootDone !== null, { timeout: 15000 });
|
||||
|
||||
proxy.stopTracking();
|
||||
const stats = proxy.getStats();
|
||||
|
||||
const pageMetrics = await page.evaluate(() => {
|
||||
const nav = performance.getEntriesByType('navigation')[0];
|
||||
const paint = performance.getEntriesByType('paint');
|
||||
const fcp = paint.find((p) => p.name === 'first-contentful-paint');
|
||||
|
||||
return {
|
||||
fcp: fcp ? Math.round(fcp.startTime) : null,
|
||||
lcp: window.__lcp ? Math.round(window.__lcp) : null,
|
||||
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
|
||||
bootDone: window.__bootDone ? Math.round(window.__bootDone) : null,
|
||||
longTaskTotal: Math.round(window.__longTasksTotal || 0),
|
||||
};
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
return {
|
||||
requestCount: stats.requestCount,
|
||||
wireBytes: stats.totalWireBytes,
|
||||
wireBytesByType: stats.bytesByType,
|
||||
fcp: pageMetrics.fcp,
|
||||
lcp: pageMetrics.lcp,
|
||||
domContentLoaded: pageMetrics.domContentLoaded,
|
||||
bootDone: pageMetrics.bootDone,
|
||||
longTaskTotal: pageMetrics.longTaskTotal,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Scenario: Offline Reload under SW
|
||||
*/
|
||||
async function runOfflineScenario({ browserType, server, proxy, profile }) {
|
||||
const browser = await browserType.launch({ headless: true });
|
||||
const isWebKit = browserType.name() === 'webkit';
|
||||
const contextOptions = { proxy: { server: `http://127.0.0.1:${proxy.port}` } };
|
||||
const context = await browser.newContext(contextOptions);
|
||||
const page = await context.newPage();
|
||||
|
||||
await setupPageMetrics(page);
|
||||
const navUrl = `http://127.0.0.1:${server.port}/`;
|
||||
|
||||
// Prime SW
|
||||
await page.goto(navUrl);
|
||||
await page.waitForFunction(() => window.__bootDone !== null, { timeout: 15000 });
|
||||
await page.evaluate(async () => {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
if (reg.active && reg.active.state === 'activated') return;
|
||||
await new Promise((resolve) => {
|
||||
const w = reg.active || reg.installing;
|
||||
if (w.state === 'activated') return resolve();
|
||||
w.addEventListener('statechange', () => {
|
||||
if (w.state === 'activated') resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Switch to offline mode:
|
||||
// On Chromium, context.setOffline(true) works.
|
||||
// On Linux WebKit, Playwright context.setOffline throws an internal browser error,
|
||||
// so proxy.setOffline(true) cuts upstream network to simulate offline truthfully.
|
||||
if (!isWebKit) {
|
||||
await context.setOffline(true);
|
||||
}
|
||||
proxy.setOffline(true);
|
||||
|
||||
proxy.resetStats();
|
||||
proxy.startTracking();
|
||||
|
||||
const t0 = Date.now();
|
||||
await page.reload();
|
||||
await page.waitForFunction(() => window.__bootDone !== null, { timeout: 15000 });
|
||||
const reloadDuration = Date.now() - t0;
|
||||
|
||||
proxy.stopTracking();
|
||||
proxy.setOffline(false);
|
||||
const stats = proxy.getStats();
|
||||
|
||||
const pageMetrics = await page.evaluate(() => {
|
||||
const nav = performance.getEntriesByType('navigation')[0];
|
||||
const paint = performance.getEntriesByType('paint');
|
||||
const fcp = paint.find((p) => p.name === 'first-contentful-paint');
|
||||
|
||||
return {
|
||||
fcp: fcp ? Math.round(fcp.startTime) : null,
|
||||
lcp: window.__lcp ? Math.round(window.__lcp) : null,
|
||||
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
|
||||
bootDone: window.__bootDone ? Math.round(window.__bootDone) : null,
|
||||
};
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
return {
|
||||
requestCount: stats.requestCount,
|
||||
wireBytes: stats.totalWireBytes,
|
||||
fcp: pageMetrics.fcp,
|
||||
lcp: pageMetrics.lcp,
|
||||
domContentLoaded: pageMetrics.domContentLoaded,
|
||||
bootDone: pageMetrics.bootDone,
|
||||
reloadDuration,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Scenario: Update N -> N+1
|
||||
*/
|
||||
async function runUpdateScenario({ browserType, server, proxy, profile, updateKind, trees }) {
|
||||
const browser = await browserType.launch({ headless: true });
|
||||
const isWebKit = browserType.name() === 'webkit';
|
||||
const contextOptions = { proxy: { server: `http://127.0.0.1:${proxy.port}` } };
|
||||
const context = await browser.newContext(contextOptions);
|
||||
const page = await context.newPage();
|
||||
|
||||
if (!isWebKit) {
|
||||
const cdp = await context.newCDPSession(page);
|
||||
await cdp.send('Network.enable');
|
||||
const isLossy = profile === 'lossy';
|
||||
await cdp.send('Network.emulateNetworkConditions', {
|
||||
offline: false,
|
||||
latency: isLossy ? 250 : 150,
|
||||
downloadThroughput: isLossy ? (1.0 * 1000 * 1000) / 8 : (1.6 * 1000 * 1000) / 8,
|
||||
uploadThroughput: (750 * 1000) / 8,
|
||||
connectionType: 'cellular4g',
|
||||
});
|
||||
}
|
||||
|
||||
// Start on Tree N (base)
|
||||
await server.restartWithPublic(trees.treeBase);
|
||||
|
||||
const navUrl = `http://127.0.0.1:${server.port}/`;
|
||||
|
||||
await page.goto(navUrl);
|
||||
|
||||
// Wait for steady state: active worker present, no waiting worker
|
||||
while (true) {
|
||||
const ready = await page.evaluate(async () => {
|
||||
const reg = await navigator.serviceWorker.getRegistration();
|
||||
return reg?.active?.state === 'activated' && !reg?.installing && !reg?.waiting;
|
||||
});
|
||||
if (ready) break;
|
||||
await sleep(50);
|
||||
}
|
||||
|
||||
// Switch server to Tree N+1 (app.js or theme-glass.css modification)
|
||||
const targetTree = updateKind === 'js' ? trees.treeUpdateJs : trees.treeUpdateCss;
|
||||
await server.restartWithPublic(targetTree);
|
||||
|
||||
// Measure wire bytes and duration from reg.update() until new SW is waiting/installed
|
||||
proxy.resetStats();
|
||||
proxy.startTracking();
|
||||
|
||||
const t0 = Date.now();
|
||||
await page.evaluate(() => navigator.serviceWorker.getRegistration().then((r) => r.update()));
|
||||
|
||||
// Wait until new worker reaches waiting state
|
||||
while (true) {
|
||||
const waiting = await page.evaluate(async () => {
|
||||
const reg = await navigator.serviceWorker.getRegistration();
|
||||
return reg?.waiting?.state === 'installed';
|
||||
});
|
||||
if (waiting) break;
|
||||
await sleep(50);
|
||||
}
|
||||
|
||||
const duration = Date.now() - t0;
|
||||
proxy.stopTracking();
|
||||
const stats = proxy.getStats();
|
||||
|
||||
await browser.close();
|
||||
|
||||
return {
|
||||
updateKind,
|
||||
duration,
|
||||
requestCount: stats.requestCount,
|
||||
wireBytes: stats.totalWireBytes,
|
||||
wireBytesByType: stats.bytesByType,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main execution
|
||||
*/
|
||||
async function main() {
|
||||
const options = parseArgs();
|
||||
console.log('=== YTPlayer Performance Baseline Harness ===');
|
||||
console.log(`Runs per scenario: ${options.runs}`);
|
||||
console.log(`Browser target: ${options.browser}`);
|
||||
console.log(`Network profile: ${options.profile}`);
|
||||
console.log(`Scenario target: ${options.scenario}`);
|
||||
|
||||
const browsers = [];
|
||||
if (options.browser === 'all' || options.browser === 'chromium') browsers.push(pw.chromium);
|
||||
if (options.browser === 'all' || options.browser === 'webkit') browsers.push(pw.webkit);
|
||||
|
||||
const profiles = [];
|
||||
if (options.profile === 'all' || options.profile === 'lte') profiles.push('lte');
|
||||
if (options.profile === 'all' || options.profile === 'lossy') profiles.push('lossy');
|
||||
|
||||
const tmpRoot = path.join(REPO_ROOT, 'perf', '.tmp', `bench-${Date.now()}`);
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
|
||||
// Setup scratch trees for N -> N+1 update scenarios
|
||||
const treeBase = path.join(tmpRoot, 'tree-base');
|
||||
const treeUpdateJs = path.join(tmpRoot, 'tree-update-js');
|
||||
const treeUpdateCss = path.join(tmpRoot, 'tree-update-css');
|
||||
|
||||
cpSync(path.join(REPO_ROOT, 'frontend'), treeBase, { recursive: true });
|
||||
cpSync(path.join(REPO_ROOT, 'frontend'), treeUpdateJs, { recursive: true });
|
||||
cpSync(path.join(REPO_ROOT, 'frontend'), treeUpdateCss, { recursive: true });
|
||||
|
||||
// Append 1 comment line to app.js in treeUpdateJs
|
||||
appendFileSync(path.join(treeUpdateJs, 'app.js'), '\n// baseline bench: 1-line app.js comment\n');
|
||||
// Append 1 rule to theme-glass.css in treeUpdateCss
|
||||
appendFileSync(path.join(treeUpdateCss, 'theme-glass.css'), '\n/* baseline bench: 1-line css rule */\n.baseline-marker { opacity: 0.99; }\n');
|
||||
|
||||
const trees = { treeBase, treeUpdateJs, treeUpdateCss };
|
||||
|
||||
const serverScratch = path.join(tmpRoot, 'server-run');
|
||||
const server = new ServerManager(serverScratch, treeBase);
|
||||
await server.start();
|
||||
console.log(`Bun server running on ephemeral port ${server.port}`);
|
||||
|
||||
const results = {
|
||||
date: new Date().toISOString(),
|
||||
commit: spawnSync('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT }).stdout.toString().trim(),
|
||||
runsConfigured: options.runs,
|
||||
browsers: {},
|
||||
};
|
||||
|
||||
try {
|
||||
for (const bType of browsers) {
|
||||
const bName = bType.name();
|
||||
results.browsers[bName] = {};
|
||||
|
||||
for (const prof of profiles) {
|
||||
console.log(`\n>>> Running Benchmark: Browser=[${bName}] Profile=[${prof}]`);
|
||||
results.browsers[bName][prof] = {};
|
||||
|
||||
const proxyProfile = bName === 'chromium' ? 'unthrottled' : prof;
|
||||
const proxy = createThrottleProxy({ targetPort: server.port, profile: proxyProfile });
|
||||
proxy.port = await proxy.listen();
|
||||
console.log(`Proxy listening on port ${proxy.port} (mode: ${proxyProfile})`);
|
||||
|
||||
// 1. Cold Scenario
|
||||
if (options.scenario === 'all' || options.scenario === 'cold') {
|
||||
console.log(` [Scenario: Cold Load] running ${options.runs} iterations...`);
|
||||
const coldRuns = [];
|
||||
for (let r = 0; r < options.runs; r++) {
|
||||
process.stdout.write(` run ${r + 1}/${options.runs}... `);
|
||||
const res = await runColdScenario({ browserType: bType, server, proxy, profile: prof });
|
||||
coldRuns.push(res);
|
||||
console.log(`done (${res.wireBytes} bytes, ${res.requestCount} reqs, boot=${res.bootDone}ms)`);
|
||||
}
|
||||
|
||||
results.browsers[bName][prof].cold = {
|
||||
requestCount: summarizeList(coldRuns.map((r) => r.requestCount)),
|
||||
wireBytes: summarizeList(coldRuns.map((r) => r.wireBytes)),
|
||||
fcp: summarizeList(coldRuns.map((r) => r.fcp)),
|
||||
lcp: summarizeList(coldRuns.map((r) => r.lcp)),
|
||||
domContentLoaded: summarizeList(coldRuns.map((r) => r.domContentLoaded)),
|
||||
bootDone: summarizeList(coldRuns.map((r) => r.bootDone)),
|
||||
longTaskTotal: summarizeList(coldRuns.map((r) => r.longTaskTotal)),
|
||||
swInstallDuration: summarizeList(coldRuns.map((r) => r.swInstallDuration)),
|
||||
swInstallBytes: summarizeList(coldRuns.map((r) => r.swInstallBytes)),
|
||||
storageEstimate: coldRuns[0]?.storageEstimate || null,
|
||||
mediaReady: summarizeList(coldRuns.map((r) => r.mediaReady)),
|
||||
autoplayBlocked: coldRuns.some((r) => r.autoplayBlocked),
|
||||
wireBytesByType: coldRuns[0]?.wireBytesByType || {},
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Warm Scenario
|
||||
if (options.scenario === 'all' || options.scenario === 'warm') {
|
||||
console.log(` [Scenario: Warm Load] running ${options.runs} iterations...`);
|
||||
const warmRuns = [];
|
||||
for (let r = 0; r < options.runs; r++) {
|
||||
process.stdout.write(` run ${r + 1}/${options.runs}... `);
|
||||
const res = await runWarmScenario({ browserType: bType, server, proxy, profile: prof });
|
||||
warmRuns.push(res);
|
||||
console.log(`done (${res.wireBytes} bytes, ${res.requestCount} reqs, boot=${res.bootDone}ms)`);
|
||||
}
|
||||
|
||||
results.browsers[bName][prof].warm = {
|
||||
requestCount: summarizeList(warmRuns.map((r) => r.requestCount)),
|
||||
wireBytes: summarizeList(warmRuns.map((r) => r.wireBytes)),
|
||||
fcp: summarizeList(warmRuns.map((r) => r.fcp)),
|
||||
lcp: summarizeList(warmRuns.map((r) => r.lcp)),
|
||||
domContentLoaded: summarizeList(warmRuns.map((r) => r.domContentLoaded)),
|
||||
bootDone: summarizeList(warmRuns.map((r) => r.bootDone)),
|
||||
longTaskTotal: summarizeList(warmRuns.map((r) => r.longTaskTotal)),
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Offline Scenario
|
||||
if (options.scenario === 'all' || options.scenario === 'offline') {
|
||||
console.log(` [Scenario: Offline Reload] running ${options.runs} iterations...`);
|
||||
const offRuns = [];
|
||||
for (let r = 0; r < options.runs; r++) {
|
||||
process.stdout.write(` run ${r + 1}/${options.runs}... `);
|
||||
const res = await runOfflineScenario({ browserType: bType, server, proxy, profile: prof });
|
||||
offRuns.push(res);
|
||||
console.log(`done (reload=${res.reloadDuration}ms, boot=${res.bootDone}ms)`);
|
||||
}
|
||||
|
||||
results.browsers[bName][prof].offline = {
|
||||
requestCount: summarizeList(offRuns.map((r) => r.requestCount)),
|
||||
wireBytes: summarizeList(offRuns.map((r) => r.wireBytes)),
|
||||
fcp: summarizeList(offRuns.map((r) => r.fcp)),
|
||||
lcp: summarizeList(offRuns.map((r) => r.lcp)),
|
||||
domContentLoaded: summarizeList(offRuns.map((r) => r.domContentLoaded)),
|
||||
bootDone: summarizeList(offRuns.map((r) => r.bootDone)),
|
||||
reloadDuration: summarizeList(offRuns.map((r) => r.reloadDuration)),
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Update N -> N+1 (JS)
|
||||
if (options.scenario === 'all' || options.scenario === 'update-js') {
|
||||
console.log(` [Scenario: Update N->N+1 (1-line app.js)] running ${options.runs} iterations...`);
|
||||
const updJsRuns = [];
|
||||
for (let r = 0; r < options.runs; r++) {
|
||||
process.stdout.write(` run ${r + 1}/${options.runs}... `);
|
||||
const res = await runUpdateScenario({
|
||||
browserType: bType,
|
||||
server,
|
||||
proxy,
|
||||
profile: prof,
|
||||
updateKind: 'js',
|
||||
trees,
|
||||
});
|
||||
updJsRuns.push(res);
|
||||
console.log(`done (${res.wireBytes} bytes, ${res.requestCount} reqs, dur=${res.duration}ms)`);
|
||||
}
|
||||
|
||||
results.browsers[bName][prof].updateJs = {
|
||||
requestCount: summarizeList(updJsRuns.map((r) => r.requestCount)),
|
||||
wireBytes: summarizeList(updJsRuns.map((r) => r.wireBytes)),
|
||||
duration: summarizeList(updJsRuns.map((r) => r.duration)),
|
||||
wireBytesByType: updJsRuns[0]?.wireBytesByType || {},
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Update N -> N+1 (CSS)
|
||||
if (options.scenario === 'all' || options.scenario === 'update-css') {
|
||||
console.log(` [Scenario: Update N->N+1 (1-line theme-glass.css)] running ${options.runs} iterations...`);
|
||||
const updCssRuns = [];
|
||||
for (let r = 0; r < options.runs; r++) {
|
||||
process.stdout.write(` run ${r + 1}/${options.runs}... `);
|
||||
const res = await runUpdateScenario({
|
||||
browserType: bType,
|
||||
server,
|
||||
proxy,
|
||||
profile: prof,
|
||||
updateKind: 'css',
|
||||
trees,
|
||||
});
|
||||
updCssRuns.push(res);
|
||||
console.log(`done (${res.wireBytes} bytes, ${res.requestCount} reqs, dur=${res.duration}ms)`);
|
||||
}
|
||||
|
||||
results.browsers[bName][prof].updateCss = {
|
||||
requestCount: summarizeList(updCssRuns.map((r) => r.requestCount)),
|
||||
wireBytes: summarizeList(updCssRuns.map((r) => r.wireBytes)),
|
||||
duration: summarizeList(updCssRuns.map((r) => r.duration)),
|
||||
wireBytesByType: updCssRuns[0]?.wireBytesByType || {},
|
||||
};
|
||||
}
|
||||
|
||||
await proxy.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await server.stop();
|
||||
rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Write results JSON
|
||||
const now = new Date();
|
||||
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
const outPath = options.out || path.join(REPO_ROOT, 'perf', 'results', `baseline-${dateStr}.json`);
|
||||
mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
writeFileSync(outPath, JSON.stringify(results, null, 2));
|
||||
console.log(`\nBaseline results saved to: ${outPath}`);
|
||||
|
||||
// Print Summary Table
|
||||
printSummaryTable(results);
|
||||
|
||||
// Compare mode if requested
|
||||
if (options.compare) {
|
||||
compareResults(options.compare, results);
|
||||
}
|
||||
}
|
||||
|
||||
function printSummaryTable(results) {
|
||||
console.log('\n============================= HEADLINE BASELINE SUMMARY =============================');
|
||||
for (const [browser, profiles] of Object.entries(results.browsers)) {
|
||||
for (const [profile, scenarios] of Object.entries(profiles)) {
|
||||
console.log(`\n[Browser: ${browser} | Profile: ${profile}]`);
|
||||
console.log('| Scenario | Req Count | Wire Bytes | Boot / Ready | SW Dur / Reload |');
|
||||
console.log('|---------------------------|-----------|------------|--------------|-----------------|');
|
||||
|
||||
if (scenarios.cold) {
|
||||
const c = scenarios.cold;
|
||||
console.log(
|
||||
`| Cold First Load | ${String(c.requestCount.median).padEnd(9)} | ${formatBytes(c.wireBytes.median).padEnd(10)} | ${String(c.bootDone.median + 'ms').padEnd(12)} | ${String(c.swInstallDuration.median + 'ms').padEnd(15)} |`
|
||||
);
|
||||
}
|
||||
if (scenarios.warm) {
|
||||
const w = scenarios.warm;
|
||||
console.log(
|
||||
`| Warm Reload (under SW) | ${String(w.requestCount.median).padEnd(9)} | ${formatBytes(w.wireBytes.median).padEnd(10)} | ${String(w.bootDone.median + 'ms').padEnd(12)} | ${'-'.padEnd(15)} |`
|
||||
);
|
||||
}
|
||||
if (scenarios.offline) {
|
||||
const o = scenarios.offline;
|
||||
console.log(
|
||||
`| Offline Reload (under SW) | ${String(o.requestCount.median).padEnd(9)} | ${formatBytes(o.wireBytes.median).padEnd(10)} | ${String(o.bootDone.median + 'ms').padEnd(12)} | ${String(o.reloadDuration.median + 'ms').padEnd(15)} |`
|
||||
);
|
||||
}
|
||||
if (scenarios.updateJs) {
|
||||
const u = scenarios.updateJs;
|
||||
console.log(
|
||||
`| Update N->N+1 (1-line JS) | ${String(u.requestCount.median).padEnd(9)} | ${formatBytes(u.wireBytes.median).padEnd(10)} | ${'-'.padEnd(12)} | ${String(u.duration.median + 'ms').padEnd(15)} |`
|
||||
);
|
||||
}
|
||||
if (scenarios.updateCss) {
|
||||
const u = scenarios.updateCss;
|
||||
console.log(
|
||||
`| Update N->N+1 (1-line CSS)| ${String(u.requestCount.median).padEnd(9)} | ${formatBytes(u.wireBytes.median).padEnd(10)} | ${'-'.padEnd(12)} | ${String(u.duration.median + 'ms').padEnd(15)} |`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('\n=====================================================================================');
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === null || bytes === undefined) return 'N/A';
|
||||
if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return bytes + ' B';
|
||||
}
|
||||
|
||||
function compareResults(baselinePath, currentResults) {
|
||||
console.log(`\n================ Phase 5 Comparison against: ${baselinePath} ================`);
|
||||
if (!existsSync(baselinePath)) {
|
||||
console.error(`Error: Baseline file not found: ${baselinePath}`);
|
||||
return;
|
||||
}
|
||||
const base = JSON.parse(readFileSync(baselinePath, 'utf8'));
|
||||
|
||||
for (const [browser, profiles] of Object.entries(currentResults.browsers)) {
|
||||
for (const [profile, scenarios] of Object.entries(profiles)) {
|
||||
console.log(`\n[Comparison: ${browser} / ${profile}]`);
|
||||
console.log('| Metric | Baseline | Current | Delta (%) |');
|
||||
console.log('|------------------------------------|-------------|-------------|--------------|');
|
||||
|
||||
const baseScen = base.browsers?.[browser]?.[profile];
|
||||
if (!baseScen) {
|
||||
console.log('| (No matching baseline data) | | | |');
|
||||
continue;
|
||||
}
|
||||
|
||||
const rows = [
|
||||
['Cold: Wire Bytes', baseScen.cold?.wireBytes?.median, scenarios.cold?.wireBytes?.median, true],
|
||||
['Cold: Boot Done (ms)', baseScen.cold?.bootDone?.median, scenarios.cold?.bootDone?.median, false],
|
||||
['Cold: Requests', baseScen.cold?.requestCount?.median, scenarios.cold?.requestCount?.median, false],
|
||||
['Warm: Boot Done (ms)', baseScen.warm?.bootDone?.median, scenarios.warm?.bootDone?.median, false],
|
||||
['Update (1-line JS): Wire Bytes', baseScen.updateJs?.wireBytes?.median, scenarios.updateJs?.wireBytes?.median, true],
|
||||
['Update (1-line JS): Time (ms)', baseScen.updateJs?.duration?.median, scenarios.updateJs?.duration?.median, false],
|
||||
['Update (1-line CSS): Wire Bytes', baseScen.updateCss?.wireBytes?.median, scenarios.updateCss?.wireBytes?.median, true],
|
||||
['Update (1-line CSS): Time (ms)', baseScen.updateCss?.duration?.median, scenarios.updateCss?.duration?.median, false],
|
||||
];
|
||||
|
||||
for (const [name, bVal, cVal, isBytes] of rows) {
|
||||
if (bVal === undefined || cVal === undefined) continue;
|
||||
const bStr = isBytes ? formatBytes(bVal) : bVal !== null ? `${bVal}` : 'N/A';
|
||||
const cStr = isBytes ? formatBytes(cVal) : cVal !== null ? `${cVal}` : 'N/A';
|
||||
let deltaStr = '-';
|
||||
if (typeof bVal === 'number' && typeof cVal === 'number' && bVal > 0) {
|
||||
const pct = (((cVal - bVal) / bVal) * 100).toFixed(1);
|
||||
const sign = cVal >= bVal ? '+' : '';
|
||||
deltaStr = `${sign}${pct}%`;
|
||||
}
|
||||
console.log(`| ${name.padEnd(34)} | ${bStr.padEnd(11)} | ${cStr.padEnd(11)} | ${deltaStr.padEnd(12)} |`);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('==============================================================================');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Benchmark fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
208
perf/hidden-timers.mjs
Normal file
208
perf/hidden-timers.mjs
Normal file
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* perf/hidden-timers.mjs
|
||||
*
|
||||
* Verifies that non-playback UI intervals are paused when the page is hidden
|
||||
* and restored when the page becomes visible again.
|
||||
*
|
||||
* Assertions:
|
||||
* - While hidden: none of [chips, reanchor, rotateChips, flush, checkBuildTag] is live.
|
||||
* - After becoming visible again: all five gated intervals are live.
|
||||
* - Must-run intervals (e.g. maybeCheck, 120s resume check) remain live while hidden.
|
||||
* - No page errors during lifecycle transitions.
|
||||
* - Exits non-zero on failure.
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
let pw;
|
||||
try {
|
||||
pw = await import('playwright');
|
||||
} catch {
|
||||
pw = await import(path.join(REPO_ROOT, 'node_modules', 'playwright', 'index.mjs'));
|
||||
}
|
||||
const { chromium } = pw;
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function main() {
|
||||
const scratchDir = path.join(REPO_ROOT, 'perf', '.tmp', `hidden-timers-${Date.now()}`);
|
||||
mkdirSync(scratchDir, { recursive: true });
|
||||
const dataDir = path.join(scratchDir, 'data');
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
const srvCwd = path.join(scratchDir, 'srv');
|
||||
mkdirSync(srvCwd, { recursive: true });
|
||||
const publicLink = path.join(srvCwd, 'public');
|
||||
symlinkSync(path.join(REPO_ROOT, 'frontend'), publicLink);
|
||||
|
||||
const port = 4900 + Math.floor(Math.random() * 2000);
|
||||
const serverProcess = spawn('bun', [path.join(REPO_ROOT, 'server', 'server.js')], {
|
||||
cwd: srvCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
DATA_DIR: dataDir,
|
||||
DB_PATH: path.join(dataDir, 'ytplayer.db'),
|
||||
MEDIA_DIR: path.join(dataDir, 'media'),
|
||||
},
|
||||
stdio: 'ignore',
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
try {
|
||||
if (serverProcess) serverProcess.kill('SIGKILL');
|
||||
} catch {}
|
||||
try {
|
||||
rmSync(scratchDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => { cleanup(); process.exit(1); });
|
||||
process.on('SIGTERM', () => { cleanup(); process.exit(1); });
|
||||
|
||||
let browser;
|
||||
try {
|
||||
let ready = false;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/api/version`);
|
||||
if (res.ok) {
|
||||
ready = true;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
await sleep(100);
|
||||
}
|
||||
if (!ready) {
|
||||
throw new Error(`Bun server failed to start on port ${port}`);
|
||||
}
|
||||
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
hasTouch: true,
|
||||
isMobile: true,
|
||||
serviceWorkers: 'block',
|
||||
});
|
||||
|
||||
await ctx.addInitScript(() => {
|
||||
const act = new Map();
|
||||
const si = window.setInterval.bind(window);
|
||||
const ci = window.clearInterval.bind(window);
|
||||
window.setInterval = (fn, ms, ...a) => {
|
||||
const id = si(fn, ms, ...a);
|
||||
act.set(id, (fn && fn.name) || 'anon' + ms);
|
||||
return id;
|
||||
};
|
||||
window.clearInterval = (id) => {
|
||||
act.delete(id);
|
||||
return ci(id);
|
||||
};
|
||||
window.__names = () => [...act.values()].sort();
|
||||
});
|
||||
|
||||
const page = await ctx.newPage();
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', (err) => pageErrors.push(err.message));
|
||||
|
||||
await page.goto(`http://127.0.0.1:${port}/index.html`);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const getNames = async () => (await page.evaluate(() => window.__names()));
|
||||
const gated = ['chips', 'reanchor', 'rotateChips', 'flush', 'checkBuildTag'];
|
||||
const mustRun = ['maybeCheck', 'anon120000'];
|
||||
|
||||
const vt = await page.evaluate(() => typeof window.VisibleTimer);
|
||||
if (vt !== 'object') {
|
||||
throw new Error(`VisibleTimer not available on window (got ${vt})`);
|
||||
}
|
||||
|
||||
// 1. Initial visible state
|
||||
const visible1 = await getNames();
|
||||
const visibleGated = gated.filter((g) => visible1.includes(g));
|
||||
|
||||
// 2. Transition to hidden
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => true });
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
const hidden = await getNames();
|
||||
const hiddenGated = gated.filter((g) => hidden.includes(g));
|
||||
|
||||
// 3. Transition back to visible
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false });
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
const visible2 = await getNames();
|
||||
const backVisibleGated = gated.filter((g) => visible2.includes(g));
|
||||
|
||||
console.log('=== Hidden Timers Verification ===');
|
||||
console.log(`Initial visible intervals (${visible1.length}):`, visible1.join(', '));
|
||||
console.log(`Hidden intervals (${hidden.length}):`, hidden.join(', '));
|
||||
console.log(`Restored visible intervals (${visible2.length}):`, visible2.join(', '));
|
||||
console.log('Page errors:', pageErrors.length ? pageErrors.join(', ') : 'none');
|
||||
|
||||
// Assertions
|
||||
const failures = [];
|
||||
|
||||
// Assert all 5 gated timers were live initially
|
||||
for (const name of gated) {
|
||||
if (!visible1.includes(name)) {
|
||||
failures.push(`Expected '${name}' to be live initially`);
|
||||
}
|
||||
}
|
||||
|
||||
// Assert none of the 5 gated timers is live while hidden
|
||||
if (hiddenGated.length > 0) {
|
||||
failures.push(`Expected 0 gated timers while hidden, but found: ${hiddenGated.join(', ')}`);
|
||||
}
|
||||
|
||||
// Assert all 5 gated timers are live after becoming visible again
|
||||
for (const name of gated) {
|
||||
if (!visible2.includes(name)) {
|
||||
failures.push(`Expected '${name}' to be live after becoming visible again`);
|
||||
}
|
||||
}
|
||||
|
||||
// Assert must-run timers stay live while hidden
|
||||
for (const name of mustRun) {
|
||||
if (!hidden.includes(name)) {
|
||||
failures.push(`Expected must-run timer '${name}' to stay live while hidden`);
|
||||
}
|
||||
}
|
||||
|
||||
// Assert no page errors
|
||||
if (pageErrors.length > 0) {
|
||||
failures.push(`Encountered page errors: ${pageErrors.join('; ')}`);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('\nFAILURES:');
|
||||
for (const f of failures) console.error(` - ${f}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log('\nResult: ALL ASSERTIONS PASSED');
|
||||
}
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error in hidden-timers check:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
126
perf/make-shell-fixture.sh
Executable file
126
perf/make-shell-fixture.sh
Executable file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ==============================================================================
|
||||
# perf/make-shell-fixture.sh
|
||||
# Archives frontend from <commit> into perf/fixtures/shell-<tag>/,
|
||||
# computes the build tag the way server.js computeBuildTag() does,
|
||||
# and verifies that it matches expected-tag (or live prod tag).
|
||||
# ==============================================================================
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Usage: $0 <commit> [expected-tag]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
COMMIT="$1"
|
||||
EXPECTED_TAG="${2:-}"
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PERF_DIR="$REPO_ROOT/perf"
|
||||
FIXTURES_DIR="$PERF_DIR/fixtures"
|
||||
TMP_DIR="$PERF_DIR/.tmp"
|
||||
|
||||
mkdir -p "$FIXTURES_DIR" "$TMP_DIR"
|
||||
|
||||
PROD_URL="https://worship.hesed.sbs/api/version"
|
||||
|
||||
fetch_prod_tag() {
|
||||
curl -s -S --max-time 10 "$PROD_URL" | node -e '
|
||||
let data = "";
|
||||
process.stdin.on("data", chunk => { data += chunk; });
|
||||
process.stdin.on("end", () => {
|
||||
try {
|
||||
const j = JSON.parse(data);
|
||||
if (j.buildTag) {
|
||||
process.stdout.write(j.buildTag);
|
||||
process.exit(0);
|
||||
}
|
||||
} catch {}
|
||||
process.exit(1);
|
||||
});
|
||||
' 2>/dev/null || echo ""
|
||||
}
|
||||
|
||||
# Verify the commit exists in git
|
||||
if ! git rev-parse --verify "$COMMIT^{commit}" >/dev/null 2>&1; then
|
||||
echo "Error: commit '$COMMIT' not found in git repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RESOLVED_COMMIT="$(git rev-parse --short=7 "$COMMIT")"
|
||||
echo "Extracting frontend from commit $RESOLVED_COMMIT..."
|
||||
|
||||
STAGING_DIR="$(mktemp -d "$TMP_DIR/fixture-staging-XXXXXX")"
|
||||
cleanup() {
|
||||
rm -rf "$STAGING_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Archive frontend tree into staging dir
|
||||
git archive "$COMMIT" frontend | tar -x -C "$STAGING_DIR" --strip-components=1
|
||||
|
||||
# Compute build tag using the exact logic from server/server.js computeBuildTag()
|
||||
COMPUTED_TAG=$(node -e '
|
||||
const { createHash } = require("crypto");
|
||||
const { readdirSync, statSync, readFileSync } = require("fs");
|
||||
|
||||
function computeTag(dir) {
|
||||
const hash = createHash("sha256");
|
||||
const walk = (d, prefix) => {
|
||||
for (const name of readdirSync(d).sort()) {
|
||||
const full = `${d}/${name}`;
|
||||
const rel = `${prefix}/${name}`;
|
||||
if (statSync(full).isDirectory()) {
|
||||
walk(full, rel);
|
||||
} else {
|
||||
hash.update(rel);
|
||||
hash.update(readFileSync(full));
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(dir, "./public");
|
||||
return hash.digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
const tag = computeTag(process.argv[1]);
|
||||
process.stdout.write(tag);
|
||||
' "$STAGING_DIR")
|
||||
|
||||
echo "Computed build tag for $RESOLVED_COMMIT: $COMPUTED_TAG"
|
||||
|
||||
# Determine target expected tag
|
||||
if [ -z "$EXPECTED_TAG" ]; then
|
||||
echo "Querying live production tag from $PROD_URL..."
|
||||
EXPECTED_TAG="$(fetch_prod_tag)"
|
||||
if [ -z "$EXPECTED_TAG" ]; then
|
||||
echo "Error: failed to fetch live buildTag from $PROD_URL" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Live production build tag: $EXPECTED_TAG"
|
||||
|
||||
# If mismatch, deploy might be in progress: wait 5 minutes and retry once
|
||||
if [ "$COMPUTED_TAG" != "$EXPECTED_TAG" ]; then
|
||||
echo "Tag mismatch detected (computed=$COMPUTED_TAG vs prod=$EXPECTED_TAG)."
|
||||
echo "Deploy may be in progress. Waiting 300 seconds to retry once..."
|
||||
sleep 300
|
||||
EXPECTED_TAG="$(fetch_prod_tag)"
|
||||
echo "Retried production build tag: $EXPECTED_TAG"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$COMPUTED_TAG" != "$EXPECTED_TAG" ]; then
|
||||
echo "Error: build tag mismatch! Computed '$COMPUTED_TAG' does not match expected '$EXPECTED_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARGET_FIXTURE_DIR="$FIXTURES_DIR/shell-$COMPUTED_TAG"
|
||||
echo "Installing fixture into $TARGET_FIXTURE_DIR..."
|
||||
rm -rf "$TARGET_FIXTURE_DIR"
|
||||
mkdir -p "$TARGET_FIXTURE_DIR"
|
||||
cp -a "$STAGING_DIR"/. "$TARGET_FIXTURE_DIR"/
|
||||
|
||||
FILE_COUNT=$(find "$TARGET_FIXTURE_DIR" -type f | wc -l)
|
||||
echo "Success: shell fixture created at $TARGET_FIXTURE_DIR with $FILE_COUNT files."
|
||||
echo "Verified commit: $RESOLVED_COMMIT"
|
||||
echo "Verified build tag: $COMPUTED_TAG"
|
||||
201
perf/proxy.mjs
Normal file
201
perf/proxy.mjs
Normal file
@@ -0,0 +1,201 @@
|
||||
import http from 'node:http';
|
||||
import { Transform } from 'node:stream';
|
||||
|
||||
/**
|
||||
* ThrottleTransform
|
||||
* Delays stream chunks to simulate bandwidth caps (e.g. 1.6 Mbps = 200,000 B/s).
|
||||
*/
|
||||
class ThrottleTransform extends Transform {
|
||||
constructor(bytesPerSec) {
|
||||
super();
|
||||
this.bytesPerSec = bytesPerSec;
|
||||
}
|
||||
|
||||
_transform(chunk, encoding, callback) {
|
||||
if (!this.bytesPerSec || this.bytesPerSec <= 0) {
|
||||
this.push(chunk);
|
||||
return callback();
|
||||
}
|
||||
const delayMs = (chunk.length / this.bytesPerSec) * 1000;
|
||||
setTimeout(() => {
|
||||
this.push(chunk);
|
||||
callback();
|
||||
}, Math.min(delayMs, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorize Content-Type into standard metric buckets.
|
||||
*/
|
||||
export function categorizeContentType(contentType, urlPath) {
|
||||
const ct = (contentType || '').toLowerCase();
|
||||
const path = (urlPath || '').toLowerCase();
|
||||
|
||||
if (ct.includes('text/html') || path.endsWith('.html')) return 'html';
|
||||
if (ct.includes('javascript') || path.endsWith('.js') || path.endsWith('.mjs')) return 'js';
|
||||
if (ct.includes('text/css') || path.endsWith('.css')) return 'css';
|
||||
if (ct.includes('font') || path.endsWith('.woff2') || path.endsWith('.woff') || path.endsWith('.ttf')) return 'fonts';
|
||||
if (ct.includes('image/') || path.endsWith('.png') || path.endsWith('.jpg') || path.endsWith('.svg') || path.endsWith('.webp') || path.endsWith('.ico')) return 'images';
|
||||
if (ct.includes('video/') || ct.includes('audio/') || path.endsWith('.mp4') || path.endsWith('.m4a') || path.endsWith('.mp3')) return 'media';
|
||||
if (ct.includes('application/json') || path.startsWith('/api/')) return 'api';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an HTTP throttling & stalling proxy for WebKit (and browser network tracing).
|
||||
* Supports both forward proxying and reverse proxying to targetPort.
|
||||
*/
|
||||
export function createThrottleProxy({ targetPort, profile = 'lte' }) {
|
||||
let activeTracking = false;
|
||||
let requestCount = 0;
|
||||
let totalWireBytes = 0;
|
||||
let bytesByType = {
|
||||
html: 0,
|
||||
js: 0,
|
||||
css: 0,
|
||||
fonts: 0,
|
||||
images: 0,
|
||||
media: 0,
|
||||
api: 0,
|
||||
other: 0,
|
||||
};
|
||||
|
||||
let reqIndex = 0;
|
||||
let isOfflineMode = false;
|
||||
|
||||
// Profile configuration:
|
||||
// LTE: 150ms roundtrip latency, 1.6 Mbps (200 KB/s) download, 750 kbps upload
|
||||
// Lossy: 250ms latency, 1.0 Mbps (125 KB/s) download, stall every 7th request by 1500ms
|
||||
// Unthrottled: 0 latency, 0 throttle (for Chromium where CDP throttles network/CPU)
|
||||
const isLossy = profile === 'lossy';
|
||||
const isUnthrottled = profile === 'unthrottled';
|
||||
const latencyMs = isUnthrottled ? 0 : (isLossy ? 250 : 150);
|
||||
const downloadBytesPerSec = isUnthrottled ? 0 : (isLossy ? (1.0 * 1000 * 1000) / 8 : (1.6 * 1000 * 1000) / 8);
|
||||
|
||||
const server = http.createServer((clientReq, clientRes) => {
|
||||
if (isOfflineMode) {
|
||||
clientReq.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
reqIndex++;
|
||||
const currentReqIndex = reqIndex;
|
||||
|
||||
let stallMs = 0;
|
||||
if (isLossy && currentReqIndex % 7 === 0) {
|
||||
stallMs = 1500; // Simulated lossy stall
|
||||
}
|
||||
|
||||
const totalDelay = isUnthrottled ? 0 : (latencyMs + stallMs);
|
||||
|
||||
const rawUrl = clientReq.url;
|
||||
const targetUrl = rawUrl.startsWith('http://') || rawUrl.startsWith('https://')
|
||||
? rawUrl
|
||||
: `http://127.0.0.1:${targetPort}${rawUrl}`;
|
||||
|
||||
const parsedTarget = new URL(targetUrl);
|
||||
const pathname = parsedTarget.pathname;
|
||||
|
||||
const options = {
|
||||
protocol: parsedTarget.protocol,
|
||||
hostname: parsedTarget.hostname,
|
||||
port: parsedTarget.port,
|
||||
path: parsedTarget.pathname + parsedTarget.search,
|
||||
method: clientReq.method,
|
||||
headers: { ...clientReq.headers, host: parsedTarget.host },
|
||||
};
|
||||
|
||||
const forward = () => {
|
||||
const upReq = http.request(options, (upRes) => {
|
||||
const ct = upRes.headers['content-type'] || '';
|
||||
const cat = categorizeContentType(ct, pathname);
|
||||
|
||||
if (activeTracking) {
|
||||
requestCount++;
|
||||
}
|
||||
|
||||
clientRes.writeHead(upRes.statusCode, upRes.headers);
|
||||
|
||||
if (isUnthrottled) {
|
||||
upRes.on('data', (chunk) => {
|
||||
if (activeTracking) {
|
||||
totalWireBytes += chunk.length;
|
||||
bytesByType[cat] = (bytesByType[cat] || 0) + chunk.length;
|
||||
}
|
||||
clientRes.write(chunk);
|
||||
});
|
||||
upRes.on('end', () => clientRes.end());
|
||||
} else {
|
||||
const throttler = new ThrottleTransform(downloadBytesPerSec);
|
||||
|
||||
throttler.on('data', (chunk) => {
|
||||
if (activeTracking) {
|
||||
totalWireBytes += chunk.length;
|
||||
bytesByType[cat] = (bytesByType[cat] || 0) + chunk.length;
|
||||
}
|
||||
});
|
||||
|
||||
upRes.pipe(throttler).pipe(clientRes);
|
||||
}
|
||||
});
|
||||
|
||||
upReq.on('error', (err) => {
|
||||
try {
|
||||
clientRes.statusCode = 502;
|
||||
clientRes.end(err.message);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
clientReq.pipe(upReq);
|
||||
};
|
||||
|
||||
if (totalDelay > 0) {
|
||||
setTimeout(forward, totalDelay);
|
||||
} else {
|
||||
forward();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
server,
|
||||
listen: () =>
|
||||
new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const port = server.address().port;
|
||||
resolve(port);
|
||||
});
|
||||
}),
|
||||
close: () =>
|
||||
new Promise((resolve) => {
|
||||
server.close(resolve);
|
||||
}),
|
||||
startTracking: () => {
|
||||
activeTracking = true;
|
||||
},
|
||||
stopTracking: () => {
|
||||
activeTracking = false;
|
||||
},
|
||||
resetStats: () => {
|
||||
requestCount = 0;
|
||||
totalWireBytes = 0;
|
||||
bytesByType = {
|
||||
html: 0,
|
||||
js: 0,
|
||||
css: 0,
|
||||
fonts: 0,
|
||||
images: 0,
|
||||
media: 0,
|
||||
api: 0,
|
||||
other: 0,
|
||||
};
|
||||
},
|
||||
setOffline: (val) => {
|
||||
isOfflineMode = !!val;
|
||||
},
|
||||
getStats: () => ({
|
||||
requestCount,
|
||||
totalWireBytes,
|
||||
bytesByType: { ...bytesByType },
|
||||
}),
|
||||
};
|
||||
}
|
||||
1639
perf/results/after-phase0-2026-10-07.json
Normal file
1639
perf/results/after-phase0-2026-10-07.json
Normal file
File diff suppressed because it is too large
Load Diff
1639
perf/results/baseline-2026-10-07.json
Normal file
1639
perf/results/baseline-2026-10-07.json
Normal file
File diff suppressed because it is too large
Load Diff
249
plans/perf-baseline.md
Normal file
249
plans/perf-baseline.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# Performance Baseline Report (Phase 0 / UNCHANGED code)
|
||||
|
||||
**Date**: 2026-10-07
|
||||
**Commit**: `b77938a`
|
||||
**Dataset**: `perf/results/baseline-2026-10-07.json`
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary & Headline Baseline Numbers
|
||||
|
||||
This baseline captures the performance metrics of the unoptimized ytplayer codebase (commit `b77938a`, matching production tag `1175f1a1d2c1`) across 5 scenarios on both Chromium and WebKit under simulated LTE and Lossy network profiles (median of 5 runs).
|
||||
|
||||
### Headline Baseline Numbers
|
||||
|
||||
- **Cold First-Load Wire Transfer**:
|
||||
- **Chromium**: 164 requests, **1,015.0 KB** compressed wire bytes (boot-done median: **10,163 ms** under 4× CPU throttle + LTE).
|
||||
- **WebKit**: 162 requests, **1,152.0 KB (1.12 MB)** wire bytes (boot-done median: **3,201 ms** under proxy LTE).
|
||||
- Breakdown: ~477 KB JavaScript, ~397 KB Web Fonts, ~131 KB CSS, ~27 KB HTML.
|
||||
|
||||
- **Warm Reload (under Service Worker)**:
|
||||
- **Chromium**: 4 requests, **5.2 KB** wire bytes (periodic API version poll), boot-done median: **675 ms** (LTE) / **651 ms** (Lossy).
|
||||
- **WebKit**: 0 requests, **0 KB** wire bytes, boot-done median: **131 ms** (LTE) / **131 ms** (Lossy).
|
||||
|
||||
- **Offline Reload (under Service Worker)**:
|
||||
- **Chromium**: 0 requests, **0 B** wire bytes, reload median: **240 ms**, boot-done median: **219 ms**.
|
||||
- **WebKit**: 0 requests, **0 B** wire bytes, reload median: **135 ms**, boot-done median: **128 ms**.
|
||||
|
||||
- **Update N -> N+1 Wire Cost (The Invalidation Penalty)**:
|
||||
- **1-line `app.js` change**:
|
||||
- **Chromium**: 88 requests, **596.6 KB** wire bytes (~611 KB uncompressed/brotli), duration: **2,013 ms** (LTE) / **2,031 ms** (Lossy).
|
||||
- **WebKit**: 86 requests, **664.3 KB** wire bytes (~680 KB wire), duration: **4,457 ms** (LTE) / **8,135 ms** (Lossy).
|
||||
- **1-line `theme-glass.css` change**:
|
||||
- **Chromium**: 88 requests, **596.7 KB** wire bytes, duration: **1,970 ms** (LTE) / **2,004 ms** (Lossy).
|
||||
- **WebKit**: 86-87 requests, **664.3 KB** wire bytes, duration: **4,510 ms** (LTE) / **8,269 ms** (Lossy).
|
||||
|
||||
> **Core Invalidation Finding**: A single 1-line change to *any* static file (`app.js` or `theme-glass.css`) mutates `BUILD_TAG` calculated by `computeBuildTag()`. This causes `sw.js` to re-fetch and re-cache all 86-88 shell assets (~600-664 KB wire payload) rather than just the mutated resource.
|
||||
|
||||
---
|
||||
|
||||
## 2. Benchmark Harness Execution
|
||||
|
||||
The benchmark harness is located in `perf/baseline.mjs` and driven by Playwright with an isolated ephemeral Bun server and a custom pacing proxy (`perf/proxy.mjs`).
|
||||
|
||||
### Quick Start / Exact Commands
|
||||
|
||||
```bash
|
||||
# Ensure node_modules is symlinked to root
|
||||
ln -s ~/development/personal/ytplayer/node_modules node_modules
|
||||
|
||||
# Run full baseline suite (all scenarios, both browsers, both profiles, 5 runs)
|
||||
node perf/baseline.mjs --runs 5 --browser all --profile all --scenario all --out perf/results/baseline-2026-10-07.json
|
||||
|
||||
# Run individual browser or scenario
|
||||
node perf/baseline.mjs --runs 5 --browser chromium --profile lte --scenario cold
|
||||
node perf/baseline.mjs --runs 5 --browser webkit --profile lte --scenario update-js
|
||||
|
||||
# Run comparison against baseline after optimizations
|
||||
node perf/baseline.mjs --runs 5 --browser all --profile all --scenario all --compare perf/results/baseline-2026-10-07.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Results by Browser and Profile (Median of 5 Runs)
|
||||
|
||||
### Chromium — LTE (1.6 Mbps down, 750 kbps up, 150 ms RTT, 4× CPU throttle)
|
||||
|
||||
| Scenario | Reqs (med) | Wire Bytes (med) | FCP (ms) | LCP (ms) | Boot Done (ms) | SW Ready / Reload (ms) | Long Task Total (ms) |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **Cold First Load** | 164 | 1,015.0 KB | 1,540 | 1,896 | 10,163 | 38,446 | 397 |
|
||||
| **Warm Reload (SW)** | 4 | 5.2 KB | 180 | 684 | 675 | - | 156 |
|
||||
| **Offline Reload** | 0 | 0.0 KB | 72 | 108 | 219 | 240 | - |
|
||||
| **Update N->N+1 (JS)** | 88 | 596.6 KB | - | - | - | 2,013 | - |
|
||||
| **Update N->N+1 (CSS)** | 88 | 596.7 KB | - | - | - | 1,970 | - |
|
||||
|
||||
- **Storage Estimate**: Quota = 4,298,447,325 B (~4.0 GB), Usage = 3,480,029 B (~3.31 MB).
|
||||
- **Cold Wire Bytes by Type**: JS 477.2 KB, Fonts 397.1 KB, CSS 130.7 KB, HTML 27.1 KB, Images 6.3 KB, API/Other 1.0 KB.
|
||||
|
||||
---
|
||||
|
||||
### Chromium — Lossy (1.0 Mbps down, 500 kbps up, 250 ms RTT, 4× CPU throttle)
|
||||
|
||||
| Scenario | Reqs (med) | Wire Bytes (med) | FCP (ms) | LCP (ms) | Boot Done (ms) | SW Ready / Reload (ms) | Long Task Total (ms) |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **Cold First Load** | 164 | 1,015.0 KB | 2,344 | 2,912 | 16,015 | 42,518 | 432 |
|
||||
| **Warm Reload (SW)** | 4 | 5.2 KB | 184 | 644 | 651 | - | 155 |
|
||||
| **Offline Reload** | 0 | 0.0 KB | 80 | 92 | 237 | 253 | - |
|
||||
| **Update N->N+1 (JS)** | 88 | 596.6 KB | - | - | - | 2,031 | - |
|
||||
| **Update N->N+1 (CSS)** | 88 | 596.7 KB | - | - | - | 2,004 | - |
|
||||
|
||||
---
|
||||
|
||||
### WebKit — LTE (1.6 Mbps down, 150 ms RTT via proxy)
|
||||
|
||||
| Scenario | Reqs (med) | Wire Bytes (med) | FCP (ms) | LCP (ms) | Boot Done (ms) | SW Ready / Reload (ms) | Long Task Total (ms) |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **Cold First Load** | 162 | 1.12 MB | 1,088 | 1,287 | 3,201 | 30,508 | 0* |
|
||||
| **Warm Reload (SW)** | 0 | 0.0 KB | 110 | 110 | 131 | - | 0* |
|
||||
| **Offline Reload** | 0 | 0.0 KB | 107 | 107 | 128 | 135 | - |
|
||||
| **Update N->N+1 (JS)** | 86 | 664.3 KB | - | - | - | 4,457 | - |
|
||||
| **Update N->N+1 (CSS)** | 86 | 664.3 KB | - | - | - | 4,510 | - |
|
||||
|
||||
---
|
||||
|
||||
### WebKit — Lossy (1.0 Mbps down, 250 ms RTT + 1,500 ms stalls via proxy)
|
||||
|
||||
| Scenario | Reqs (med) | Wire Bytes (med) | FCP (ms) | LCP (ms) | Boot Done (ms) | SW Ready / Reload (ms) | Long Task Total (ms) |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **Cold First Load** | 162 | 1.12 MB | 3,557 | 3,735 | 7,303 | 35,449 | 0* |
|
||||
| **Warm Reload (SW)** | 0 | 0.0 KB | 111 | 112 | 131 | - | 0* |
|
||||
| **Offline Reload** | 0 | 0.0 KB | 109 | 119 | 129 | 141 | - |
|
||||
| **Update N->N+1 (JS)** | 86 | 664.3 KB | - | - | - | 8,135 | - |
|
||||
| **Update N->N+1 (CSS)** | 87 | 664.4 KB | - | - | - | 8,269 | - |
|
||||
|
||||
*\*Note: WebKit does not expose `PerformanceLongTaskTiming` (`longtask` entries), so long task total evaluates to 0 ms.*
|
||||
|
||||
---
|
||||
|
||||
## 4. Variance & Noise Observed
|
||||
|
||||
Over the 5 runs per configuration, measurements demonstrated high stability:
|
||||
- **Wire Bytes & Request Counts**: Deterministic (0% variance across identical runs).
|
||||
- **Cold Boot-Done**:
|
||||
- Chromium LTE: Min 10,129 ms, Max 10,798 ms (<6% variance).
|
||||
- Chromium Lossy: Min 15,871 ms, Max 16,031 ms (<1% variance).
|
||||
- WebKit LTE: Min 3,138 ms, Max 3,245 ms (<3% variance).
|
||||
- WebKit Lossy: Min 6,963 ms, Max 8,710 ms (~20% variance due to proxy random stalls).
|
||||
- **Warm & Offline Reload**:
|
||||
- Chromium Offline Reload: Min 238 ms, Max 257 ms (<8% variance).
|
||||
- WebKit Offline Reload: Min 131 ms, Max 141 ms (<7% variance).
|
||||
- **Update Duration**:
|
||||
- Chromium: Runs cluster around ~2,000 ms (2,013 ms median). Occasional outlier runs (~45–200 ms) occur if the browser resolves `reg.update()` immediately before network completion reporting.
|
||||
- WebKit: Steady scaling between LTE (~4.5 s) and Lossy (~8.2 s) pacing.
|
||||
|
||||
---
|
||||
|
||||
## 5. Limitations & Caveats
|
||||
|
||||
1. **WebKit Network Throttling**:
|
||||
WebKit lacks Chrome DevTools Protocol (CDP) support for hardware network emulation and CPU throttling. We paced throughput (1.6 Mbps) and injected latency/stalls using a custom local reverse proxy (`perf/proxy.mjs`). This throttles HTTP response chunks truthfully but cannot simulate OS-level socket buffer delays or 4× CPU throttling.
|
||||
2. **Autoplay Policy in WebKit**:
|
||||
Playwright WebKit on Linux rejects `--autoplay-policy=no-user-gesture-required` and enforces Safari-style gesture requirements on unmuted audio elements. The media playback benchmark detects this and correctly reports `autoplayBlocked: true` without failing the test run.
|
||||
3. **WebKit SetOffline Incompatibility**:
|
||||
Calling Playwright's `context.setOffline(true)` on Linux WebKit causes an unrecoverable engine crash (`WebKit encountered an internal error`). Offline mode for WebKit is simulated by terminating the proxy upstream connection (`proxy.setOffline(true)`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Manual iPhone Testing Protocol (Phase 0 / iOS Safari PWA)
|
||||
|
||||
Because automated headless WebKit cannot fully replicate iOS Safari's mobile background constraints and battery telemetry, the following manual protocol is required:
|
||||
|
||||
### Protocol Steps
|
||||
|
||||
1. **Install PWA**:
|
||||
- Open Safari on iPhone and navigate to `https://worship.hesed.sbs`.
|
||||
- Tap the Share button -> **"Add to Home Screen"**.
|
||||
- Launch ytplayer directly from the Home Screen icon.
|
||||
|
||||
2. **Verify Version & Cache**:
|
||||
- Verify the footer / version string matches `1175f1a1d2c1` (or current production build tag).
|
||||
- Turn on Airplane Mode and force-reload to confirm offline readiness. Turn Airplane Mode back off.
|
||||
|
||||
3. **30-Minute Screen-Off Battery Test**:
|
||||
- Note baseline battery percentage (e.g., 85%).
|
||||
- Select a pre-cached playlist or track and tap **Play**. Ensure continuous audio is heard.
|
||||
- Lock the screen (display off) and let playback run uninterrupted for **30 minutes**.
|
||||
- Note battery percentage at 30 minutes (calculate drain %/hr).
|
||||
- Listen for any audio dropouts, stuttering, or premature process suspension.
|
||||
|
||||
4. **Interruption & Resumption Test**:
|
||||
- Trigger an interruption while locked (e.g., initiate an incoming phone call or start Siri).
|
||||
- End the interruption and observe if playback automatically resumes via `MediaSession` handlers.
|
||||
- Test Lock Screen Controls (Play/Pause, Next Track, scrubber).
|
||||
|
||||
5. **Log Findings**:
|
||||
- Record iPhone Model, iOS Version, Battery Drain (% per 30m), Dropouts Count (0 = pass), and Resumption Status (Pass/Fail).
|
||||
|
||||
---
|
||||
|
||||
## 7. After Phase 0 Benchmark (Step S5 Verification)
|
||||
|
||||
**Date**: 2026-10-07
|
||||
**Commit**: `eb6aae7`
|
||||
**Dataset**: `perf/results/after-phase0-2026-10-07.json` (compared against `perf/results/baseline-2026-10-07.json`)
|
||||
|
||||
### Headline Before vs. After Comparison Table (Median of 5 Runs)
|
||||
|
||||
| Browser / Profile | Scenario | Requests (Before → After) | Wire Bytes (Before → After) | FCP / LCP (ms) | Boot Done (ms) | SW Dur / Reload (ms) |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **Chromium LTE** | Cold First Load | 164 → 166 (+1.2%) | 1,015.0 KB → 1,015.8 KB (+0.1%) | 1540/1896 → 1572/1916 | 10,163 → 10,480 (+3.1%) | 38,446 → 38,364 |
|
||||
| | Warm Reload (SW) | 4 → 4 (0.0%) | 5.2 KB → 5.2 KB (0.0%) | 180/684 → 184/680 | 675 → 692 (+2.5%) | - |
|
||||
| | Offline Reload | 0 → 0 (0.0%) | 0 B → 0 B (0.0%) | 72/108 → 76/112 | 219 → 259 | 240 → 282 |
|
||||
| | Update (1-line JS) | 88 → 89 (+1.1%) | 596.6 KB → 597.0 KB (+0.1%) | - | - | 2,013 → 2,155 (+7.1%) |
|
||||
| | Update (1-line CSS) | 88 → 89 (+1.1%) | 596.7 KB → 597.1 KB (+0.1%) | - | - | 1,970 → 2,107 (+7.0%) |
|
||||
| **Chromium Lossy** | Cold First Load | 164 → 166 (+1.2%) | 1,015.0 KB → 1,015.8 KB (+0.1%) | 2344/2912 → 2372/2940 | 16,015 → 16,408 (+2.5%) | 42,518 → 42,031 |
|
||||
| | Warm Reload (SW) | 4 → 4 (0.0%) | 5.2 KB → 5.2 KB (0.0%) | 184/644 → 188/648 | 651 → 768 (+18.0%) | - |
|
||||
| | Offline Reload | 0 → 0 (0.0%) | 0 B → 0 B (0.0%) | 80/92 → 84/96 | 237 → 241 | 253 → 262 |
|
||||
| | Update (1-line JS) | 88 → 89 (+1.1%) | 596.6 KB → 597.0 KB (+0.1%) | - | - | 2,031 → 2,115 (+4.1%) |
|
||||
| | Update (1-line CSS) | 88 → 89 (+1.1%) | 596.7 KB → 597.1 KB (+0.1%) | - | - | 2,004 → 2,116 (+5.6%) |
|
||||
| **WebKit LTE** | Cold First Load | 162 → 164 (+1.2%) | 1.12 MB → 1.13 MB (+0.1%) | 1088/1287 → 1100/1303 | 3,201 → 3,290 (+2.8%) | 30,508 → 31,026 |
|
||||
| | Warm Reload (SW) | 0 → 0 (0.0%) | 0 B → 0 B (0.0%) | 110/110 → 112/112 | 131 → 139 (+6.1%) | - |
|
||||
| | Offline Reload | 0 → 0 (0.0%) | 0 B → 0 B (0.0%) | 107/107 → 110/110 | 128 → 137 | 135 → 148 |
|
||||
| | Update (1-line JS) | 86 → 87 (+1.2%) | 664.3 KB → 664.9 KB (+0.1%) | - | - | 4,457 → 4,584 (+2.8%) |
|
||||
| | Update (1-line CSS) | 86 → 87 (+1.2%) | 664.3 KB → 664.9 KB (+0.1%) | - | - | 4,510 → 4,550 (+0.9%) |
|
||||
| **WebKit Lossy** | Cold First Load | 162 → 164 (+1.2%) | 1.12 MB → 1.13 MB (+0.1%) | 3557/3735 → 3738/3905 | 7,303 → 7,305 (+0.0%) | 35,449 → 35,101 |
|
||||
| | Warm Reload (SW) | 0 → 0 (0.0%) | 0 B → 0 B (0.0%) | 111/112 → 112/114 | 131 → 128 (-2.3%) | - |
|
||||
| | Offline Reload | 0 → 0 (0.0%) | 0 B → 0 B (0.0%) | 109/119 → 111/120 | 129 → 132 | 141 → 139 |
|
||||
| | Update (1-line JS) | 86 → 87 (+1.2%) | 664.3 KB → 664.9 KB (+0.1%) | - | - | 8,135 → 10,017 (+23.1%) |
|
||||
| | Update (1-line CSS) | 87 → 87 (0.0%) | 664.4 KB → 664.9 KB (+0.1%) | - | - | 8,269 → 10,013 (+21.1%) |
|
||||
|
||||
### Hidden Timers Verification Result
|
||||
|
||||
Executed via `node perf/hidden-timers.mjs`:
|
||||
```
|
||||
=== Hidden Timers Verification ===
|
||||
Initial visible intervals (8): anon1000, anon120000, checkBuildTag, chips, flush, maybeCheck, reanchor, rotateChips
|
||||
Hidden intervals (3): anon1000, anon120000, maybeCheck
|
||||
Restored visible intervals (9): anon1000, anon120000, anon50000, checkBuildTag, chips, flush, maybeCheck, reanchor, rotateChips
|
||||
Page errors: none
|
||||
|
||||
Result: ALL ASSERTIONS PASSED
|
||||
```
|
||||
All five non-playback UI timers (`chips`, `reanchor`, `rotateChips`, `flush`, `checkBuildTag`) suspend immediately when `document.hidden` becomes true and resume immediately upon returning to visible, while essential background loops (`maybeCheck` inbox poll and `anon120000` 120s OPFS download resume check) remain continuously active.
|
||||
|
||||
### Variance & Noise Observed
|
||||
|
||||
Across the 5 iterations per scenario:
|
||||
- **Transfer Volumes & Request Counts**: Completely deterministic (0.0% variance across all runs).
|
||||
- **Boot Milestones & Paint Timings**: Stable within ±1–3% under LTE emulation on both Chromium and WebKit.
|
||||
- **Update Duration under Lossy WebKit**: Exhibited expected network noise (~8.1 s vs ~10.0 s, ~21–23% delta) due to the random 1,500 ms connection stall injection pattern in `perf/proxy.mjs`.
|
||||
- **Chromium Update Duration**: Clustered closely around ~2,100 ms with occasional ~100 ms fast runs when the browser resolves registration updates asynchronously.
|
||||
|
||||
### Findings
|
||||
|
||||
Introducing `frontend/visible-timer.js` successfully eliminates unnecessary CPU wakeups and timer execution during hidden tab states while adding virtually zero runtime transfer overhead (+1 request and +373–471 wire bytes on service worker updates; +2 requests and +843 wire bytes on initial cold visit). Background audio playback and essential network recovery handlers are preserved without regressing paint, boot, or offline capabilities.
|
||||
|
||||
### Structural Transfer Analysis: Fonts & Icons vs. Code, and Cold Double-Download
|
||||
|
||||
Detailed breakdown of network transfer patterns revealed two major inefficiencies in the current asset delivery architecture:
|
||||
|
||||
1. **Static Fonts and Icons vs. JS/CSS**:
|
||||
- **Cold First Visit**: Total wire transfer is 1,040.2 KB. Web fonts (`PlusJakartaSans`, `BricolageGrotesque`, `HankenGrotesk`, `JetBrainsMono`, and `fonts.css`) consume **397.1 KB (38.2%)** and PNG icons consume **6.3 KB (0.6%)**, totaling **403.4 KB (38.8%)** of immutable media. Application JavaScript consumes **478.1 KB (46.0%)** and CSS consumes **130.7 KB (12.6%)**, totaling **608.8 KB (58.5%)**.
|
||||
- **Update N -> N+1 Wire Cost**: On any single-line change to `app.js` or `theme-glass.css`, `sw.js` invalidates the entire shell cache and re-downloads all 89 `SHELL` items. Out of 611.3 KB transferred, **~259.5 KB (~42.5%)** is spent re-downloading static web fonts and icons that never changed, compared to **~321.9 KB (52.7%)** for JS/CSS.
|
||||
2. **Cold First-Visit Shell Double-Download**:
|
||||
- The initial visit downloads every app shell resource **twice over the wire**:
|
||||
- First, the browser's HTML parser requests `index.html`, stylesheets, scripts, and fonts to render the document (83 requests, ~520 KB).
|
||||
- Second, once `boot()` registers `/sw.js`, the service worker `install` event triggers `precacheShell()`. Line 165 of `frontend/sw.js` executes `fetch(new Request(url, { cache: 'reload' }))`.
|
||||
- The explicit `{ cache: 'reload' }` directive bypasses the browser's HTTP cache entirely, forcing a redundant full re-download of all 89 shell assets from the server.
|
||||
- As measured by the harness request log, cold first visit request count is **166 requests** (exactly double the shell count) and wire data transfer is **1,040 KB** (twice the unprimed payload).
|
||||
|
||||
2
playwright.audio-continuity.config.js
Normal file
2
playwright.audio-continuity.config.js
Normal file
@@ -0,0 +1,2 @@
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
module.exports = defineConfig({ testDir: './tests', testMatch: /audio-continuity\.spec\.js/, timeout: 30000, workers: 1, projects: [{ name: 'chromium', use: { browserName: 'chromium' } }, { name: 'webkit', use: { browserName: 'webkit' } }], use: { baseURL: 'http://localhost:8098', viewport: { width: 390, height: 844 }, serviceWorkers: 'block' }, webServer: { command: 'npx serve frontend -l 8098 --no-clipboard', port: 8098 } });
|
||||
2
playwright.bento.config.js
Normal file
2
playwright.bento.config.js
Normal file
@@ -0,0 +1,2 @@
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
module.exports = defineConfig({ testDir:'./tests', testMatch:/bento-hub\.spec\.js/, timeout:30000, workers:2, projects:[{name:'chromium',use:{browserName:'chromium'}},{name:'webkit',use:{browserName:'webkit'}}], use:{baseURL:'http://localhost:8105',serviceWorkers:'block'}, webServer:{command:`npx serve ${process.env.BENTO_BASELINE || 'frontend'} -l 8105 --no-clipboard`,port:8105} });
|
||||
2
playwright.download-actions.config.js
Normal file
2
playwright.download-actions.config.js
Normal file
@@ -0,0 +1,2 @@
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
module.exports = defineConfig({ testDir: './tests', testMatch: /download-actions\.spec\.js/, timeout: 30000, workers: 1, projects: [{ name: 'chromium', use: { browserName: 'chromium' } }, { name: 'webkit', use: { browserName: 'webkit' } }], use: { baseURL: 'http://localhost:8104', serviceWorkers: 'block' }, webServer: { command: 'npx serve frontend -l 8104 --no-clipboard', port: 8104 } });
|
||||
2
playwright.fullscreen.config.js
Normal file
2
playwright.fullscreen.config.js
Normal file
@@ -0,0 +1,2 @@
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
module.exports = defineConfig({ testDir: './tests', testMatch: /fullscreen-orientation\.spec\.js/, workers: 1, projects: [{ name: 'chromium', use: { browserName: 'chromium' } }, { name: 'webkit', use: { browserName: 'webkit' } }], use: { baseURL: 'http://localhost:8097', serviceWorkers: 'block' }, webServer: { command: 'npx serve frontend -l 8097 --no-clipboard', port: 8097 } });
|
||||
2
playwright.glass-rail.config.js
Normal file
2
playwright.glass-rail.config.js
Normal file
@@ -0,0 +1,2 @@
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
module.exports = defineConfig({ testDir: './tests', testMatch: /glass-rail-float\.spec\.js/, timeout: 30000, workers: 1, projects: [{ name: 'chromium', use: { browserName: 'chromium' } }, { name: 'webkit', use: { browserName: 'webkit' } }], use: { baseURL: 'http://localhost:8098', serviceWorkers: 'block' }, webServer: { command: 'npx serve frontend -l 8098 --no-clipboard', port: 8098, reuseExistingServer: false } });
|
||||
6
playwright.jamesdsp.config.js
Normal file
6
playwright.jamesdsp.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
module.exports = defineConfig({
|
||||
testDir: './tests', testMatch: /jamesdsp\.spec\.js/, timeout: 60000, workers: 1,
|
||||
use: { baseURL: 'http://localhost:8088', browserName: 'chromium', serviceWorkers: 'block', viewport: { width: 1280, height: 900 } },
|
||||
webServer: { command: 'npx serve frontend -l 8088 --no-clipboard', port: 8088, reuseExistingServer: !process.env.CI },
|
||||
});
|
||||
2
playwright.related.config.js
Normal file
2
playwright.related.config.js
Normal file
@@ -0,0 +1,2 @@
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
module.exports = defineConfig({ testDir: './tests', testMatch: /related-videos\.spec\.js/, timeout: 30000, workers: 1, projects: [{ name: 'chromium', use: { browserName: 'chromium' } }, { name: 'webkit', use: { browserName: 'webkit' } }], use: { baseURL: 'http://localhost:8101', serviceWorkers: 'block' }, webServer: { command: 'npx serve frontend -l 8101 --no-clipboard', port: 8101 } });
|
||||
12
scripts/test-bento-theme.js
Normal file
12
scripts/test-bento-theme.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/* Reproducible before/after comparison without changing the working tree. */
|
||||
const {execFileSync,spawnSync}=require('node:child_process');
|
||||
const {mkdtempSync,rmSync}=require('node:fs');
|
||||
const {join}=require('node:path');
|
||||
const {tmpdir}=require('node:os');
|
||||
const baseline=mkdtempSync(join(tmpdir(),'ytplayer-bento-baseline-'));
|
||||
try {
|
||||
const archive=execFileSync('git',['archive',process.env.BENTO_BASE_COMMIT||'04b620e','frontend']);
|
||||
execFileSync('tar',['-x','-C',baseline],{input:archive});
|
||||
const run=env=>{const result=spawnSync('npx',['playwright','test','--config','playwright.bento.config.js','--workers','4'],{stdio:'inherit',env:{...process.env,...env}});if(result.status!==0)process.exitCode=result.status||1;return result.status===0;};
|
||||
if(run({CAPTURE_BEFORE:'1',BENTO_BASELINE:join(baseline,'frontend')}))run({CAPTURE_BEFORE:'0',BENTO_BASELINE:''});
|
||||
} finally {rmSync(baseline,{recursive:true,force:true});}
|
||||
@@ -104,3 +104,42 @@ export async function searchDeep(q, { limit = 200, fetchImpl = fetch, timeoutMs
|
||||
}
|
||||
return all.slice(0, limit);
|
||||
}
|
||||
|
||||
// Watch-next recommendations use compact renderers or the newer lockup model.
|
||||
// Traverse only the recommendation contents, never player configuration/ads.
|
||||
export function parseRelated(json) {
|
||||
const contents = json?.contents?.twoColumnWatchNextResults?.secondaryResults?.secondaryResults?.results
|
||||
|| json?.contents?.singleColumnWatchNextResults?.results?.results?.contents;
|
||||
if (!Array.isArray(contents)) throw new Error('innertube: unexpected related response shape');
|
||||
const cards = [], seen = new Set();
|
||||
function visit(value) {
|
||||
if (!value || typeof value !== 'object') return;
|
||||
const renderer = value.compactVideoRenderer || value.videoRenderer;
|
||||
const model = value.lockupViewModel;
|
||||
const id = renderer?.videoId || (model?.contentType === 'LOCKUP_CONTENT_TYPE_VIDEO' ? model.contentId : '');
|
||||
if (typeof id === 'string' && /^[\w-]{11}$/.test(id) && !seen.has(id)) {
|
||||
const meta = model?.metadata?.lockupMetadataViewModel;
|
||||
const part = meta?.metadata?.contentMetadataViewModel?.metadataRows?.[0]?.metadataParts?.[0]?.text;
|
||||
const owner = renderer?.shortBylineText?.runs?.[0] || renderer?.longBylineText?.runs?.[0] || {};
|
||||
const browse = owner.navigationEndpoint?.browseEndpoint || part?.commandRuns?.[0]?.onTap?.innertubeCommand?.browseEndpoint || {};
|
||||
const badge = model?.contentImage?.thumbnailViewModel?.overlays?.flatMap(o => o.thumbnailOverlayBadgeViewModel?.thumbnailBadges || []).find(b => b.thumbnailBadgeViewModel?.text)?.thumbnailBadgeViewModel?.text;
|
||||
const title = renderer?.title?.simpleText || renderer?.title?.runs?.map(r => r.text).join('') || meta?.title?.content;
|
||||
if (title) { seen.add(id); cards.push({ id, title, channel: owner.text || part?.content || '', channelId: browse.browseId || '', channelUrl: browse.canonicalBaseUrl ? `https://www.youtube.com${browse.canonicalBaseUrl}` : '', duration: lengthToSeconds(renderer?.lengthText?.simpleText || badge), thumbnail: `https://i.ytimg.com/vi/${id}/mqdefault.jpg` }); }
|
||||
return;
|
||||
}
|
||||
// Ignore advertised placements rather than recursing into their videos.
|
||||
for (const [key, child] of Object.entries(value)) if (!/adSlot|promoted|adPlacement/i.test(key)) {
|
||||
if (Array.isArray(child)) child.forEach(visit); else visit(child);
|
||||
}
|
||||
}
|
||||
contents.forEach(visit);
|
||||
return cards;
|
||||
}
|
||||
export async function related(videoId, { fetchImpl = fetch, timeoutMs = 4000 } = {}) {
|
||||
const response = await fetchImpl('https://www.youtube.com/youtubei/v1/next?prettyPrint=false', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ context: { client: CLIENT }, videoId }), signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!response.ok) throw new Error(`innertube related: HTTP ${response.status}`);
|
||||
return parseRelated(await response.json());
|
||||
}
|
||||
|
||||
@@ -47,3 +47,16 @@ test('searchDeep follows continuation tokens, de-duplicates and stops at the lim
|
||||
test('parseContinuation of an empty response is empty', () => {
|
||||
expect(parseContinuation({})).toEqual({ cards: [], next: '' });
|
||||
});
|
||||
|
||||
test('related parser reads compact and modern lockup cards, excluding playlists and ads', async () => {
|
||||
const { parseRelated } = await import('./innertube.js');
|
||||
const compact = { compactVideoRenderer: { videoId: 'aaaaaaaaaaa', title: { simpleText: 'First' }, shortBylineText: { runs: [{ text: 'Artist' }] }, lengthText: { simpleText: '3:12' } } };
|
||||
const modern = { lockupViewModel: { contentId: 'bbbbbbbbbbb', contentType: 'LOCKUP_CONTENT_TYPE_VIDEO', metadata: { lockupMetadataViewModel: { title: { content: 'Second' }, metadata: { contentMetadataViewModel: { metadataRows: [{ metadataParts: [{ text: { content: 'Band' } }] }] } } } } } };
|
||||
const result = parseRelated({ contents: { twoColumnWatchNextResults: { secondaryResults: { secondaryResults: { results: [compact, modern, compact, { lockupViewModel: { ...modern.lockupViewModel, contentType: 'LOCKUP_CONTENT_TYPE_PLAYLIST' } }, { adSlotRenderer: { contents: compact } }] } } } } });
|
||||
expect(result.map(c => [c.id, c.title, c.channel])).toEqual([['aaaaaaaaaaa', 'First', 'Artist'], ['bbbbbbbbbbb', 'Second', 'Band']]);expect(result[0].duration).toBe(192);
|
||||
});
|
||||
test('related HTTP failures and unsupported shapes throw for search fallback', async () => {
|
||||
const { related, parseRelated } = await import('./innertube.js');
|
||||
expect(() => parseRelated({ error: {} })).toThrow('response shape');
|
||||
await expect(related('aaaaaaaaaaa', { fetchImpl: async () => ({ ok: false, status: 429 }) })).rejects.toThrow('429');
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"start": "bun server.js",
|
||||
"dev": "bun --hot server.js",
|
||||
"test": "bun test ./recommendations.test.js && bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./transcriptions.test.js && bun test ./admin-analytics.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js && bun test ./innertube.test.js && bun test ./ytdlp-pool.test.js && bun test ./p2p-db.test.js && bun test ./p2p-admit.test.js && bun test ./p2p-retention.test.js && bun test ./p2p-routes.test.js && bun test ./p2p-hub.test.js && bun test --timeout 60000 ./p2p-intake.test.js && bun test ./flags.test.js"
|
||||
"test": "bun test ./recommendations.test.js && bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./transcriptions.test.js && bun test ./admin-analytics.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js && bun test ./innertube.test.js && bun test ./related.test.js && bun test ./ytdlp-pool.test.js && bun test ./warm-queue.test.js && bun test ./p2p-db.test.js && bun test ./p2p-admit.test.js && bun test ./p2p-retention.test.js && bun test ./p2p-routes.test.js && bun test ./p2p-hub.test.js && bun test --timeout 60000 ./p2p-intake.test.js && bun test ./flags.test.js && bun test ./static-delivery.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.14.0",
|
||||
|
||||
65
server/related.js
Normal file
65
server/related.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// Related discovery is bounded and independent of the deep search collector.
|
||||
export function relatedQueries({ title = '', channel = '' }) {
|
||||
title = String(title).replace(/\([^)]*(?:official|lyrics?|music video)[^)]*\)|\[[^\]]*(?:official|lyrics?|music video)[^\]]*\]/gi, '').replace(/\s+/g, ' ').trim().slice(0, 300);
|
||||
channel = String(channel).trim().slice(0, 120);
|
||||
return [...new Set([title && channel ? `${title} ${channel}` : '', title, channel].filter(Boolean))];
|
||||
}
|
||||
export function selectRelated(cards, id, limit = 8) {
|
||||
const seen = new Set([id]);
|
||||
return (Array.isArray(cards) ? cards : []).filter(card => {
|
||||
if (!card || typeof card.id !== 'string' || !card.title || seen.has(card.id)) return false;
|
||||
seen.add(card.id); return true;
|
||||
}).slice(0, limit);
|
||||
}
|
||||
export function createRelatedService({ next, search, local, collect = async () => {}, decorate = async cards => cards, now = Date.now, budgetMs = 18000 }) {
|
||||
const cache = new Map(), pending = new Map();
|
||||
async function load(seed, { refresh = false } = {}) {
|
||||
const key = JSON.stringify([seed.id, seed.title, seed.channel]);
|
||||
const cached = cache.get(key);
|
||||
if (!refresh && cached && now() - cached.at < cached.ttl) return cached.result;
|
||||
if (pending.has(key)) return pending.get(key);
|
||||
const task = (async () => {
|
||||
const deadline = now() + budgetMs, details = [];
|
||||
let results = [], source = 'youtube';
|
||||
try { results = selectRelated(await next(seed.id, Math.min(4000, budgetMs)), seed.id); }
|
||||
catch (error) { details.push(String(error.message || error).slice(0, 300)); }
|
||||
const queries = relatedQueries(seed);
|
||||
for (const query of queries) {
|
||||
if (results.length || now() >= deadline) break;
|
||||
try { results = selectRelated(await search(query, Math.max(1, deadline - now())), seed.id); if (results.length) source = 'search'; }
|
||||
catch (error) { details.push(String(error.message || error).slice(0, 300)); }
|
||||
}
|
||||
if (!results.length) {
|
||||
source = 'catalog';
|
||||
// Local results still work when YouTube blocks or times out. Broad queries
|
||||
// avoid requiring every word of a long video title to be in another title.
|
||||
const localQueries = [...new Set([seed.channel, seed.title?.split(/\s+/).slice(0, 3).join(' '), ...queries].filter(Boolean))];
|
||||
for (const query of localQueries) {
|
||||
try { results = selectRelated(await local(query), seed.id); } catch { /* try another local query */ }
|
||||
if (results.length) break;
|
||||
}
|
||||
}
|
||||
if (!results.length) return { ok: false, error: 'Related videos aren’t available right now. Please try again.', detail: details.join('\n') };
|
||||
// Metadata/thumbnail persistence must not discard otherwise playable cards.
|
||||
try { await collect(results); } catch { /* discovery remains usable */ }
|
||||
try { results = await decorate(results); } catch { /* remote thumbnails remain */ }
|
||||
const result = { ok: true, results, source, ...(source === 'catalog' ? { message: 'YouTube is unavailable. Showing related videos already known to this server.' } : {}) };
|
||||
if (cache.size >= 500) cache.delete(cache.keys().next().value);
|
||||
cache.set(key, { result, at: now(), ttl: source === 'catalog' ? 60000 : 600000 });
|
||||
return result;
|
||||
})().finally(() => pending.delete(key));
|
||||
pending.set(key, task); return task;
|
||||
}
|
||||
return { load };
|
||||
}
|
||||
export function registerRelatedRoute(app, service) {
|
||||
app.get('/api/related', async c => {
|
||||
const id = (c.req.query('videoId') || '').trim();
|
||||
if (!/^[\w-]{11}$/.test(id)) return c.json({ ok: false, error: 'Invalid video id.' }, 400);
|
||||
const seed = { id, title: (c.req.query('title') || '').trim().slice(0, 300), channel: (c.req.query('channel') || '').trim().slice(0, 120) };
|
||||
try {
|
||||
const result = await service.load(seed, { refresh: c.req.query('refresh') === '1' });
|
||||
return c.json(result, result.ok ? 200 : 503);
|
||||
} catch { return c.json({ ok: false, error: 'Related videos aren’t available right now. Please try again.' }, 503); }
|
||||
});
|
||||
}
|
||||
43
server/related.test.js
Normal file
43
server/related.test.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { test, expect } from 'bun:test';
|
||||
import { Hono } from 'hono';
|
||||
import { createRelatedService, relatedQueries, selectRelated, registerRelatedRoute } from './related.js';
|
||||
const seed = { id: 'aaaaaaaaaaa', title: 'Grace (Official Lyric Video)', channel: 'Artist' };
|
||||
const card = { id: 'bbbbbbbbbbb', title: 'Another song', channel: 'Artist' };
|
||||
const fail = async () => { throw new Error('YouTube HTTP 429'); };
|
||||
test('fallback queries remove video boilerplate and deduplicate; cards exclude seed', () => {
|
||||
expect(relatedQueries(seed)).toEqual(['Grace Artist', 'Grace', 'Artist']);
|
||||
expect(selectRelated([{ ...card, id: seed.id }, card, card, null], seed.id)).toEqual([card]);
|
||||
});
|
||||
test('watch-next results bypass deep search and collect metadata', async () => {
|
||||
let searches = 0, collected;
|
||||
const service = createRelatedService({ next: async () => [card], search: async () => { searches++; return []; }, local: async () => [], collect: async cards => { collected = cards; } });
|
||||
expect((await service.load(seed)).source).toBe('youtube'); expect(searches).toBe(0); expect(collected).toEqual([card]);
|
||||
});
|
||||
test('YouTube next failure and empty narrow query fall back to title search', async () => {
|
||||
const queries = [];
|
||||
const service = createRelatedService({ next: fail, search: async q => { queries.push(q); return q === 'Grace' ? [card] : []; }, local: async () => [] });
|
||||
expect((await service.load(seed)).source).toBe('search'); expect(queries).toEqual(['Grace Artist', 'Grace']);
|
||||
});
|
||||
test('catalog fallback works without YouTube and metadata failures do not hide cards', async () => {
|
||||
const service = createRelatedService({ next: fail, search: fail, local: async q => q === 'Artist' ? [card] : [], collect: fail });
|
||||
const answer = await service.load(seed); expect(answer.ok).toBe(true); expect(answer.source).toBe('catalog'); expect(answer.results).toEqual([card]);
|
||||
});
|
||||
test('shared inflight request, successful cache, and explicit refresh', async () => {
|
||||
let finish, calls=0;
|
||||
const service=createRelatedService({next:async()=>{calls++;return new Promise(resolve=>{finish=resolve;});},search:fail,local:async()=>[]});
|
||||
const a=service.load(seed),b=service.load(seed); finish([card]); await Promise.all([a,b]); expect(calls).toBe(1);
|
||||
await service.load(seed); expect(calls).toBe(1);
|
||||
const retry=service.load(seed,{refresh:true}); finish([card]); await retry; expect(calls).toBe(2);
|
||||
});
|
||||
test('failed responses are never cached, and exhausted budget goes straight to catalog', async () => {
|
||||
let time=0, calls=0, searches=0;
|
||||
const service=createRelatedService({now:()=>time,budgetMs:10,next:async()=>{calls++;time+=20;throw new Error('timeout');},search:async()=>{searches++;return[];},local:async()=>[]});
|
||||
expect((await service.load(seed)).ok).toBe(false); expect((await service.load(seed)).ok).toBe(false);
|
||||
expect(calls).toBe(2); expect(searches).toBe(0);
|
||||
});
|
||||
test('endpoint validates ids and provides a friendly retryable failure', async () => {
|
||||
const app=new Hono();registerRelatedRoute(app,createRelatedService({next:fail,search:fail,local:async()=>[]}));
|
||||
expect((await app.request('/api/related?videoId=bad')).status).toBe(400);
|
||||
const response=await app.request(`/api/related?videoId=${seed.id}&title=Grace`); expect(response.status).toBe(503);
|
||||
const body=await response.json(); expect(body.error).toContain('Please try again');expect(body.detail).toContain('429');
|
||||
});
|
||||
@@ -61,11 +61,13 @@ import { registerIntakeRoutes } from './p2p-intake.js';
|
||||
import { createP2pHub, holdersPayload, createRehydrator } from './p2p-hub.js';
|
||||
import { sha256Range } from './hash.js';
|
||||
import * as innertube from './innertube.js';
|
||||
import { createRelatedService, registerRelatedRoute } from './related.js';
|
||||
import * as searchCacheDb from './search-cache.js';
|
||||
import { ingest as collectVideoMetadata, syncListening, linkListening, startThumbnails } from './video-catalog.js';
|
||||
import { registerCatalogRoutes } from './recommendations.js';
|
||||
import QRCode from 'qrcode';
|
||||
import { createYtdlpPool } from './ytdlp-pool.js';
|
||||
import { createWarmQueue } from './warm-queue.js';
|
||||
import { dirname, join as pathJoin } from 'node:path';
|
||||
|
||||
// A media proxy must not die because one client's stream hit an edge case
|
||||
@@ -488,6 +490,25 @@ app.get('/api/search', async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Eight related cards should not depend on a 200-result deep search succeeding.
|
||||
registerRelatedRoute(app, createRelatedService({
|
||||
next: (id, timeoutMs) => process.env.SEARCH_INNERTUBE === '0' ? Promise.reject(new Error('InnerTube disabled')) : innertube.related(id, { timeoutMs }),
|
||||
search: async (query, budgetMs) => {
|
||||
const start = Date.now();
|
||||
try {
|
||||
if (process.env.SEARCH_INNERTUBE !== '0') {
|
||||
const cards = await innertube.search(query, { timeoutMs: Math.min(3500, budgetMs) });
|
||||
if (cards.length) return cards;
|
||||
}
|
||||
} catch { /* use the existing resilient yt-dlp search path */ }
|
||||
const remaining = budgetMs - (Date.now() - start);
|
||||
if (remaining <= 0) throw new Error('Related search timed out');
|
||||
return parseCards(await runYtdlpResilient([`ytsearch12:${query}`, '--dump-json', '--flat-playlist', '--no-warnings', '--ignore-errors', '--socket-timeout', '4', '--retries', '1'], { signal: AbortSignal.timeout(Math.min(6000, remaining)) }));
|
||||
},
|
||||
local: query => searchCacheDb.searchVideos(query, 20),
|
||||
collect: cards => collectVideoMetadata(cards, 'related'),
|
||||
}));
|
||||
|
||||
// GET /api/channel?c=<channel>
|
||||
// Channel listing that works for every kind of channel, not one-off cases:
|
||||
// 1. /videos, /streams, /shorts tabs — normal channels
|
||||
@@ -708,15 +729,20 @@ function pickFormat(formats, { formatId, wantAudio, wantHeight }) {
|
||||
// GET /api/streams/warm?v=<id> — fire-and-forget: resolve streams into
|
||||
// streamCache so the real /api/streams a moment later is instant. Bounded
|
||||
// so a scrolling user can't queue dozens of yt-dlp processes.
|
||||
const WARM_MAX = 2;
|
||||
let warmActive = 0;
|
||||
const streamWarmQueue = createWarmQueue({
|
||||
concurrency: 2,
|
||||
maxQueued: 8,
|
||||
run: async (id) => {
|
||||
try { if (await media.getReady(id)) return; } catch { /* fall through */ }
|
||||
await resolveStreams(id);
|
||||
},
|
||||
});
|
||||
app.get('/api/streams/warm', async (c) => {
|
||||
const id = (c.req.query('v') || '').trim();
|
||||
if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return c.body(null, 204);
|
||||
if (warmActive >= WARM_MAX) return c.body(null, 204);
|
||||
try { if (await media.getReady(id)) return c.body(null, 204); } catch { /* fall through */ }
|
||||
warmActive++;
|
||||
resolveStreams(id).catch(() => {}).finally(() => { warmActive--; });
|
||||
// Best effort: bounded queue preserves a small burst (e.g. the first three
|
||||
// search cards) while limiting background extractor work.
|
||||
streamWarmQueue.enqueue(id);
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
|
||||
158
server/static-delivery.test.js
Normal file
158
server/static-delivery.test.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { createServer } from 'node:net';
|
||||
|
||||
const repo = resolve(import.meta.dir, '..');
|
||||
const serverFile = join(repo, 'server', 'server.js');
|
||||
const root = mkdtempSync(join(tmpdir(), 'ytp-static-delivery-'));
|
||||
const publicDir = join(root, 'public');
|
||||
const dataDir = join(root, 'data');
|
||||
mkdirSync(publicDir);
|
||||
mkdirSync(dataDir);
|
||||
mkdirSync(join(publicDir, 'fonts'));
|
||||
mkdirSync(join(publicDir, 'icons'));
|
||||
writeFileSync(join(publicDir, 'index.html'), '<!doctype html><meta name="ytp-build" content="__BUILD_TAG__"><link rel="stylesheet" href="app.css"><script src="app.js"></script><script src="https://cdn.example/x.js"></script><link rel="stylesheet" href="/root.css">');
|
||||
writeFileSync(join(publicDir, 'app.css'), 'body{}');
|
||||
writeFileSync(join(publicDir, 'app.js'), 'window.app=true;');
|
||||
writeFileSync(join(publicDir, 'root.css'), 'body{color:red}');
|
||||
writeFileSync(join(publicDir, 'sw.js'), "const BUILD_TAG = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v-test';\n");
|
||||
writeFileSync(join(publicDir, 'fonts', 'font.woff2'), 'font');
|
||||
writeFileSync(join(publicDir, 'icons', 'icon.png'), 'icon');
|
||||
|
||||
async function freePort() {
|
||||
return await new Promise((resolvePort, reject) => {
|
||||
const probe = createServer();
|
||||
probe.once('error', reject);
|
||||
probe.listen(0, '127.0.0.1', () => {
|
||||
const port = probe.address().port;
|
||||
probe.close((error) => error ? reject(error) : resolvePort(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
const port = await freePort();
|
||||
const child = Bun.spawn([process.execPath, serverFile], {
|
||||
cwd: root,
|
||||
env: { ...process.env, PORT: String(port), DB_PATH: join(dataDir, `db-${port}.sqlite`), MEDIA_DIR: join(dataDir, 'media'), UPLOAD_DIR: join(dataDir, 'uploads') },
|
||||
stdout: 'pipe', stderr: 'pipe',
|
||||
});
|
||||
const stdout = new Response(child.stdout).text();
|
||||
const stderr = new Response(child.stderr).text();
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
const deadline = Date.now() + 20000;
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) throw new Error(`server exited (${child.exitCode})\n${await stdout}\n${await stderr}`);
|
||||
try { if ((await fetch(`${base}/api/version`)).ok) return { child, base, stdout, stderr }; } catch {}
|
||||
await Bun.sleep(100);
|
||||
}
|
||||
child.kill();
|
||||
throw new Error(`server did not start\n${await stdout}\n${await stderr}`);
|
||||
}
|
||||
|
||||
async function stopServer(server) {
|
||||
if (!server) return;
|
||||
server.child.kill();
|
||||
await server.child.exited;
|
||||
await server.stdout;
|
||||
await server.stderr;
|
||||
}
|
||||
|
||||
let server;
|
||||
beforeAll(async () => { server = await startServer(); });
|
||||
afterAll(async () => { await stopServer(server); rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
describe('static delivery characterization', () => {
|
||||
test('build tag is stable for the same tree and changes when any served file changes', async () => {
|
||||
const first = await (await fetch(`${server.base}/api/version`)).json();
|
||||
await stopServer(server);
|
||||
server = await startServer();
|
||||
const same = await (await fetch(`${server.base}/api/version`)).json();
|
||||
expect(same.buildTag).toBe(first.buildTag);
|
||||
writeFileSync(join(publicDir, 'unlisted.txt'), 'included in recursive build tag');
|
||||
await stopServer(server);
|
||||
server = await startServer();
|
||||
const changed = await (await fetch(`${server.base}/api/version`)).json();
|
||||
expect(changed.buildTag).not.toBe(first.buildTag);
|
||||
});
|
||||
|
||||
test('unreadable public tree uses the fixed dev-build fallback', async () => {
|
||||
const moved = join(root, 'public.saved');
|
||||
Bun.spawnSync(['mv', publicDir, moved]);
|
||||
let fallback;
|
||||
try {
|
||||
await stopServer(server);
|
||||
server = await startServer();
|
||||
fallback = await (await fetch(`${server.base}/api/version`)).json();
|
||||
} finally {
|
||||
await stopServer(server);
|
||||
Bun.spawnSync(['mv', moved, publicDir]);
|
||||
server = await startServer();
|
||||
}
|
||||
expect(fallback.buildTag).toBe('dev-build');
|
||||
});
|
||||
|
||||
test('index stamps only relative local css/js and serves expected cache headers', async () => {
|
||||
const index = await fetch(`${server.base}/`);
|
||||
const html = await index.text();
|
||||
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();
|
||||
expect(html).toContain(`<meta name="ytp-build" content="${buildTag}">`);
|
||||
expect(html).toContain(`href="app.css?v=${buildTag}"`);
|
||||
expect(html).toContain(`src="app.js?v=${buildTag}"`);
|
||||
expect(html).toContain('https://cdn.example/x.js');
|
||||
expect(html).toContain('href="/root.css"');
|
||||
expect(index.headers.get('cache-control')).toBe('no-cache');
|
||||
expect((await fetch(`${server.base}/index.html`)).headers.get('cache-control')).toBe('no-cache');
|
||||
|
||||
const current = await fetch(`${server.base}/app.js?v=${buildTag}`, { headers: { 'Accept-Encoding': 'br' } });
|
||||
expect(current.headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
|
||||
expect(current.headers.get('content-encoding')).toBe('br');
|
||||
expect(current.headers.get('etag')).toBeTruthy();
|
||||
expect((await fetch(`${server.base}/app.js?v=stale`)).headers.get('cache-control')).toBe('no-cache');
|
||||
expect((await fetch(`${server.base}/app.js`)).headers.get('cache-control')).toBe('no-cache');
|
||||
expect((await fetch(`${server.base}/fonts/font.woff2`)).headers.get('cache-control')).toBe('public, max-age=2592000');
|
||||
expect((await fetch(`${server.base}/icons/icon.png`)).headers.get('cache-control')).toBe('public, max-age=2592000');
|
||||
for (const path of ['/api/version', '/sw.js']) {
|
||||
expect((await fetch(`${server.base}${path}`)).headers.get('cache-control')).toBe('no-store, no-cache, must-revalidate');
|
||||
}
|
||||
const etag = current.headers.get('etag');
|
||||
const notModified = await fetch(`${server.base}/app.js?v=${buildTag}`, { headers: { 'If-None-Match': etag } });
|
||||
expect(notModified.status).toBe(304);
|
||||
expect(await notModified.text()).toBe('');
|
||||
});
|
||||
|
||||
test('service worker injection tracks fallback literal and reports missing placeholder', async () => {
|
||||
let sw = await (await fetch(`${server.base}/sw.js`)).text();
|
||||
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();
|
||||
expect(sw).toContain(`const BUILD_TAG = "${buildTag}";`);
|
||||
writeFileSync(join(publicDir, 'sw.js'), "const BUILD_TAG = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v-next-literal';\n");
|
||||
await stopServer(server);
|
||||
server = await startServer();
|
||||
sw = await (await fetch(`${server.base}/sw.js`)).text();
|
||||
expect(sw).toContain(`const BUILD_TAG = "${(await (await fetch(`${server.base}/api/version`)).json()).buildTag}";`);
|
||||
expect(sw).not.toContain('__BUILD_TAG__');
|
||||
|
||||
writeFileSync(join(publicDir, 'sw.js'), "const BUILD_TAG = 'missing-placeholder';\n");
|
||||
await stopServer(server);
|
||||
server = await startServer();
|
||||
const response = await fetch(`${server.base}/sw.js`);
|
||||
expect(await response.text()).toContain('missing-placeholder');
|
||||
// The startup process logs the existing server.js diagnostic when injection misses.
|
||||
server.child.kill();
|
||||
const stderr = await server.stderr;
|
||||
await server.child.exited;
|
||||
expect(stderr).toContain('[sw] BUILD_TAG injection failed — placeholder not found in sw.js');
|
||||
server = await startServer();
|
||||
});
|
||||
|
||||
test('SPA routes fall back to the stamped index', async () => {
|
||||
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();
|
||||
for (const path of ['/search', '/playlist/x']) {
|
||||
const response = await fetch(`${server.base}${path}`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toContain(`<meta name="ytp-build" content="${buildTag}">`);
|
||||
}
|
||||
});
|
||||
});
|
||||
30
server/warm-queue.js
Normal file
30
server/warm-queue.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// Bounded scheduler for best-effort stream prewarming. Requests beyond the
|
||||
// active limit wait in a short FIFO instead of being silently discarded.
|
||||
export function createWarmQueue({ concurrency = 2, maxQueued = 8, run }) {
|
||||
const queued = [];
|
||||
const known = new Set();
|
||||
let active = 0;
|
||||
|
||||
function pump() {
|
||||
while (active < concurrency && queued.length) {
|
||||
const id = queued.shift();
|
||||
active++;
|
||||
Promise.resolve().then(() => run(id)).catch(() => {}).finally(() => {
|
||||
active--;
|
||||
known.delete(id);
|
||||
pump();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(id) {
|
||||
if (known.has(id)) return true;
|
||||
if (active >= concurrency && queued.length >= maxQueued) return false;
|
||||
known.add(id);
|
||||
queued.push(id);
|
||||
pump();
|
||||
return true;
|
||||
}
|
||||
|
||||
return { enqueue, get pending() { return queued.length + active; } };
|
||||
}
|
||||
39
server/warm-queue.test.js
Normal file
39
server/warm-queue.test.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { createWarmQueue } from './warm-queue.js';
|
||||
|
||||
describe('stream warm queue', () => {
|
||||
test('runs at the concurrency limit and drains queued requests FIFO', async () => {
|
||||
const started = [];
|
||||
const releases = new Map();
|
||||
const q = createWarmQueue({ concurrency: 2, maxQueued: 2, run: (id) => {
|
||||
started.push(id);
|
||||
return new Promise((resolve) => releases.set(id, resolve));
|
||||
} });
|
||||
|
||||
q.enqueue('a'); q.enqueue('b'); q.enqueue('c');
|
||||
expect(q.pending).toBe(3);
|
||||
await Promise.resolve();
|
||||
expect(started).toEqual(['a', 'b']);
|
||||
releases.get('a')();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(started).toEqual(['a', 'b', 'c']);
|
||||
releases.get('b')(); releases.get('c')();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(q.pending).toBe(0);
|
||||
});
|
||||
|
||||
test('deduplicates in-flight ids and rejects work past the queue bound', async () => {
|
||||
const releases = [];
|
||||
const q = createWarmQueue({ concurrency: 1, maxQueued: 1, run: () => new Promise((resolve) => releases.push(resolve)) });
|
||||
expect(q.enqueue('a')).toBe(true);
|
||||
await Promise.resolve();
|
||||
expect(q.enqueue('a')).toBe(true);
|
||||
expect(q.enqueue('b')).toBe(true);
|
||||
expect(q.enqueue('c')).toBe(false);
|
||||
releases[0]();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
releases[1]();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(q.pending).toBe(0);
|
||||
});
|
||||
});
|
||||
46
tests/audio-continuity.spec.js
Normal file
46
tests/audio-continuity.spec.js
Normal file
@@ -0,0 +1,46 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { openClassic, showPlayingFixture } = require('./helpers/classic-fixture');
|
||||
// A small local PCM fixture exercises actual media elements without YouTube.
|
||||
function wav() {
|
||||
const rate = 8000, samples = rate * 12, bytes = Buffer.alloc(44 + samples * 2);
|
||||
bytes.write('RIFF'); bytes.writeUInt32LE(bytes.length - 8, 4); bytes.write('WAVEfmt ', 8);
|
||||
bytes.writeUInt32LE(16, 16); bytes.writeUInt16LE(1, 20); bytes.writeUInt16LE(1, 22);
|
||||
bytes.writeUInt32LE(rate, 24); bytes.writeUInt32LE(rate * 2, 28); bytes.writeUInt16LE(2, 32); bytes.writeUInt16LE(16, 34);
|
||||
bytes.write('data', 36); bytes.writeUInt32LE(samples * 2, 40);
|
||||
for (let i = 0; i < samples; i++) bytes.writeInt16LE(Math.sin(i * 2 * Math.PI * 220 / rate) * 2000, 44 + i * 2);
|
||||
return bytes;
|
||||
}
|
||||
for (const source of ['saved', 'streamed']) test(`${source}: iPhone keeps the same playing audio across repeated lock/unlock events`, async ({ page }) => {
|
||||
await page.addInitScript(() => Object.defineProperty(navigator, 'userAgent', { value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15' }));
|
||||
await page.route('**/continuity.wav', route => route.fulfill({ contentType: 'audio/wav', body: wav() }));
|
||||
await openClassic(page, { populated: true }); await showPlayingFixture(page);
|
||||
await page.evaluate(source => {
|
||||
current.localUrl = source === 'saved' ? '/continuity.wav' : '';
|
||||
current.audioUrl = '/continuity.wav';
|
||||
Player.attach(source === 'streamed' ? { url: '/continuity.wav', hasAudio: true } : null);
|
||||
}, source);
|
||||
await expect.poll(() => page.evaluate(() => els.audio.readyState)).toBeGreaterThanOrEqual(2);
|
||||
await page.evaluate(() => Player.play());
|
||||
await expect.poll(() => page.evaluate(() => !els.audio.paused && els.audio.currentTime > 0)).toBe(true);
|
||||
const result = await page.evaluate(async () => {
|
||||
const A = els.audio, V = els.video;
|
||||
const initial = A.currentTime, src = A.src;
|
||||
let playCalls = 0, audioSeeks = 0, loads = 0, pauses = 0;
|
||||
const play = A.play.bind(A), load = A.load.bind(A), pause = A.pause.bind(A);
|
||||
A.play = () => { playCalls++; return play(); }; A.load = () => { loads++; load(); }; A.pause = () => { pauses++; pause(); };
|
||||
A.addEventListener('seeking', () => audioSeeks++);
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' });
|
||||
document.dispatchEvent(new Event('visibilitychange')); window.dispatchEvent(new Event('pagehide'));
|
||||
window.dispatchEvent(new Event('focus')); // focus arriving while hidden must not restore
|
||||
const promoted = Player.master === A && Player._bgHandoff === 'dual';
|
||||
V.pause();
|
||||
await new Promise(resolve => setTimeout(resolve, 350));
|
||||
const advanced = A.currentTime > initial;
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
|
||||
document.dispatchEvent(new Event('visibilitychange')); window.dispatchEvent(new Event('pageshow')); window.dispatchEvent(new Event('focus'));
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
const answer = { mode: Player.mode, promoted, advanced, sameSource: A.src === src, playing: !A.paused, playCalls, audioSeeks, loads, pauses, restored: Player.master === V && Player.secondary === A };
|
||||
Player.pause(); Player.stopDrift(); return answer;
|
||||
});
|
||||
expect(result).toEqual({ mode: 'dual', promoted: true, advanced: true, sameSource: true, playing: true, playCalls: 0, audioSeeks: 0, loads: 0, pauses: 0, restored: true });
|
||||
});
|
||||
102
tests/bento-hub.spec.js
Normal file
102
tests/bento-hub.spec.js
Normal file
@@ -0,0 +1,102 @@
|
||||
const {test,expect}=require('@playwright/test');
|
||||
const {openClassic,showPlayingFixture}=require('./helpers/classic-fixture');
|
||||
const {mkdirSync,readFileSync,existsSync}=require('node:fs');
|
||||
const {join}=require('node:path');
|
||||
const {homedir}=require('node:os');
|
||||
const {differentPixels}=require('./helpers/png-diff');
|
||||
const out=process.env.BENTO_SCREENSHOT_DIR||join(homedir(),'deliverables','ytplayer-done13');mkdirSync(out,{recursive:true});
|
||||
const before=process.env.CAPTURE_BEFORE==='1';
|
||||
const screens=['home','search','playlist','queue','history','saved','downloads','channel','settings','settings-detail','now-playing','lyrics','chapters','lyric-search','service','modal','sheet','stage-lyrics','batch','library','empty-queue','empty-saved','empty-downloads'];
|
||||
async function scene(page,layout,screen){
|
||||
// Freeze the automatic Classic mini-player observer in these static scenes.
|
||||
// Otherwise its pending first callback races our intentionally visible mini bar.
|
||||
await page.addInitScript(()=>{const Observer=window.IntersectionObserver;window.IntersectionObserver=class extends Observer{observe(el){if(el.id!=='controls')super.observe(el);}};});
|
||||
await openClassic(page,{populated:true,standalone:page.viewportSize().width<900});await showPlayingFixture(page);
|
||||
await page.evaluate(({layout,screen})=>{
|
||||
data.settings.layout=layout;applyAppearance();
|
||||
const videos=data.playlists[0].videos;
|
||||
API.cacheList=async()=>({ok:true,items:screen==='empty-saved'?[]:videos.map(v=>({...v,size:84000000})),total:screen==='empty-saved'?0:1008000000});
|
||||
Downloads.configure({slots:()=>4,files:async()=>[],playlists:()=>data.playlists,jobs:()=>!['downloads','settings-detail'].includes(screen)?[]:['active','queued','paused','failed'].map((status,i)=>({...videos[i],status,error:status==='failed'?'ERROR: [youtube] Video unavailable':null,progress:status==='active'?{received:24000000,total:84000000}:null})),action:()=>{}});
|
||||
channelData={name:'Preview fixtures',url:'https://www.youtube.com/@preview',key:'preview',results:videos,loading:false};
|
||||
searchResults=screen==='home'?[]:videos; searchAttempted=screen!=='home';
|
||||
if(screen==='empty-queue'){queue=[];data.queue=[];}
|
||||
view=screen==='home'?{type:'search'}:screen==='playlist'?{type:'playlist',id:'studio'}:{type:screen.replace('empty-','')};
|
||||
if(screen==='home'){current=null;els.playerPane.classList.add('empty');els.placeholder.classList.remove('hidden');}
|
||||
if(['now-playing','lyrics','chapters','lyric-search','service','modal','sheet','stage-lyrics','batch','library'].includes(screen))view={type:'playlist',id:'studio'};
|
||||
if(screen==='settings-detail')view={type:'settings'};
|
||||
render();renderUpNext();
|
||||
if(screen!=='home')showMiniBar();
|
||||
if(['lyrics','chapters','lyric-search'].includes(screen)){
|
||||
$('notesPanel').classList.remove('hidden');
|
||||
const tab=screen==='lyrics'?'lyrics':screen==='chapters'?'markers':'search';
|
||||
document.querySelectorAll('.notes-tab').forEach(b=>b.classList.toggle('active',b.dataset.tab===tab));
|
||||
['lyrics','markers','search'].forEach(t=>$(t+'Pane').classList.toggle('hidden',t!==tab));
|
||||
$('lyricsList').innerHTML='<div class="lyr-line"><span class="lyr-text">Because You are God</span></div><div class="lyr-line active"><span class="lyr-text">You can do anything<br>You can do anything</span></div><div class="lyr-line"><span class="lyr-text">Your grace is enough</span></div>';
|
||||
$('chapterList').innerHTML='<button class="marker-row"><span>0:00</span><b>Opening verse</b></button><button class="marker-row"><span>1:24</span><b>Chorus</b></button>';
|
||||
}
|
||||
if(screen==='service'){data.settings.serviceLyrics=true;enterServiceMode(data.playlists[0]);$('serviceLyrics').classList.remove('hidden');$('serviceLyricsList').innerHTML='<div class="sl-line">Because You are God</div><div class="sl-line active">You can do anything</div>';}
|
||||
if(screen==='batch'){selectMode=true;selectedIds=new Set(videos.slice(0,2).map(v=>v.id));renderList();}
|
||||
if(screen==='sheet')openMoreControls();
|
||||
if(screen==='stage-lyrics'){ $('stageLyrics').classList.remove('hidden');$('stageLyricsList').innerHTML='<div class="sl-line">Because You are God</div><div class="sl-line active">You can do anything</div>'; }
|
||||
if(screen==='modal')showModal('Add to playlist',document.createTextNode('Keep this song in your Sunday setlist.'),[{label:'Cancel',onClick:closeModal},{label:'Add',primary:true,onClick:closeModal}]);
|
||||
},{layout,screen});
|
||||
if(screen==='settings-detail')await page.getByRole('button',{name:/Downloads & storage/}).click();
|
||||
if(layout==='bento-hub'&&['now-playing','lyrics','chapters','lyric-search','stage-lyrics'].includes(screen))await page.evaluate(()=>document.querySelector('.body').scrollTop=0);
|
||||
await page.evaluate(()=>document.querySelectorAll('progress:not([value])').forEach(el=>el.value=0));
|
||||
await page.addStyleTag({content:'*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important;scroll-behavior:auto!important}'});
|
||||
await page.evaluate(async()=>{ await document.fonts.ready; await Promise.all([...document.images].map(img=>{img.loading='eager';return img.decode().catch(()=>{});})); if(document.activeElement instanceof HTMLElement)document.activeElement.blur(); });await page.waitForTimeout(300);
|
||||
// Short Classic empty lists otherwise clamp navigation one pixel before
|
||||
// their header on WebKit. An offscreen spacer makes the static route exact.
|
||||
if(layout==='classic'&&screen==='empty-saved'&&page.viewportSize().width===390)await page.evaluate(()=>{const body=document.querySelector('.body');$('listPane').style.minHeight=(body.clientHeight+8)+'px';});
|
||||
// render() navigates before fonts finish; settle the real list route once
|
||||
// after font metrics are known, so WebKit cannot capture an old scroll offset.
|
||||
if(!['home','now-playing','lyrics','chapters','lyric-search','stage-lyrics','service','modal','sheet','batch'].includes(screen)){await page.evaluate(()=>scrollListIntoViewPortrait());await page.waitForTimeout(100);}
|
||||
}
|
||||
for(const width of [390,1440])for(const screen of screens)for(const layout of before?['classic','glass-stage']:['classic','glass-stage','bento-hub'])test(`${layout} ${width} ${screen}`,async({page},info)=>{
|
||||
await page.setViewportSize({width,height:844});await scene(page,layout,screen);
|
||||
const name=`${layout}-${width}-${screen}-${info.project.name}.png`;
|
||||
const buffer=await page.screenshot({path:`${out}/${before?'before':'after'}-${name}`});
|
||||
if(before)return;
|
||||
if(layout!=='bento-hub'){expect(differentPixels(readFileSync(`${out}/before-${name}`),buffer)).toBeLessThanOrEqual(40);return;}
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true);
|
||||
if(['lyrics','chapters','lyric-search'].includes(screen)){await page.locator('#notesPanel').scrollIntoViewIfNeeded();await page.screenshot({path:`${out}/panel-${name}`});}
|
||||
if(screen==='now-playing')await expect(page.locator('#controls')).toBeVisible();
|
||||
if(screen==='downloads'){const retry=page.getByRole('button',{name:/^Retry /});await expect(retry).toBeVisible();expect(await retry.evaluate(el=>getComputedStyle(el).backgroundColor)).toBe('rgb(255, 183, 3)');}
|
||||
if(width===390&&!['service','modal','sheet'].includes(screen)){await expect(page.locator('#bottomNav')).toBeVisible(); for(const button of await page.locator('#bottomNav button').all()){const icon=await button.locator('.bottom-nav-icon').boundingBox(),label=await button.locator('.bottom-nav-label').boundingBox();expect(Math.abs(icon.x+icon.width/2-label.x-label.width/2)).toBeLessThan(1);}}
|
||||
expect(await page.locator('#sectionRail').evaluate(el=>el.parentElement===document.body)).toBe(true);
|
||||
if(!['service','modal','sheet','stage-lyrics','batch','library'].includes(screen)&&width===390){const tabs=page.locator('#bottomNav button');for(const b of await tabs.all()){if(await b.isVisible()){const r=await b.boundingBox();expect(r.height).toBeGreaterThanOrEqual(44);}}}
|
||||
});
|
||||
for(const width of [390,1440])test(`reference ${width}`,async({page},info)=>{
|
||||
test.skip(!existsSync('docs/mockups/bento-hub/index.html'),'The design reference is an optional local deliverable.');
|
||||
await page.setViewportSize({width,height:844});await page.route('**/__bento-reference',r=>r.fulfill({contentType:'text/html',body:readFileSync('docs/mockups/bento-hub/index.html','utf8')}));await page.goto('/__bento-reference');await page.evaluate(()=>document.fonts.ready);
|
||||
await page.screenshot({path:`${out}/reference-${width}-${info.project.name}.png`,fullPage:true});
|
||||
for(const section of await page.locator('.screen-section').all()){
|
||||
await section.evaluate(el=>{document.querySelectorAll('.screen-section').forEach(s=>s.classList.remove('active'));el.classList.add('active');});
|
||||
const id=await section.getAttribute('id');
|
||||
const frame=section.locator(width===390?'.frame-phone':'.frame-desktop');
|
||||
if(await frame.count())await frame.screenshot({path:`${out}/reference-${id}-${width}-${info.project.name}.png`});
|
||||
}
|
||||
});
|
||||
|
||||
if(!before){
|
||||
for(const width of [390,960])test(`Bento ${width} navigation and floating clearance`,async({page})=>{
|
||||
await page.setViewportSize({width,height:844});await scene(page,'bento-hub','downloads');
|
||||
await page.locator('#sidebarToggle').click();await expect(page.locator('.app')).toHaveClass(/sidebar-open/);await page.locator('#sidebarBackdrop').click({position:{x:width-8,y:20}});
|
||||
const chrome=await page.evaluate(()=>{const body=document.querySelector('.body').getBoundingClientRect(),mini=$('miniBar').getBoundingClientRect();return {bodyBottom:body.bottom,miniTop:mini.top};});
|
||||
expect(chrome.bodyBottom).toBeLessThan(chrome.miniTop);
|
||||
if(width===390){await page.locator('#miniTitle').click();await expect(page.locator('#controls')).toBeInViewport();}
|
||||
});
|
||||
for(const layout of ['classic','glass-stage'])test(`Bento restores ${layout} after switching`,async({page})=>{
|
||||
await page.setViewportSize({width:390,height:844});await scene(page,layout,'home');await page.evaluate(()=>document.querySelector('.body').scrollTop=0);const original=await page.screenshot();
|
||||
await page.evaluate(()=>{data.settings.layout='bento-hub';applyAppearance();});await page.waitForTimeout(100);
|
||||
await page.evaluate(layout=>{data.settings.layout=layout;applyAppearance();},layout);await page.waitForTimeout(100);await page.evaluate(()=>document.querySelector('.body').scrollTop=0);
|
||||
// Chromium may rerasterize composited corners by one RGB level after a theme switch.
|
||||
expect(differentPixels(original,await page.screenshot(),1)).toBeLessThanOrEqual(40);
|
||||
});
|
||||
for(const theme of ['light','contrast'])test(`Bento ${theme} appearance`,async({page},info)=>{
|
||||
await page.setViewportSize({width:390,height:844});await scene(page,'bento-hub','saved');await page.evaluate(theme=>{data.settings.theme=theme;applyAppearance();},theme);
|
||||
await page.screenshot({path:`${out}/after-bento-hub-390-${theme}-${info.project.name}.png`});
|
||||
await expect(page.locator('.saved-file-action').first()).toBeVisible();
|
||||
});
|
||||
|
||||
}
|
||||
41
tests/download-actions.spec.js
Normal file
41
tests/download-actions.spec.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { openClassic } = require('./helpers/classic-fixture');
|
||||
const { mkdirSync } = require('node:fs');
|
||||
const before = process.env.CAPTURE_BEFORE === '1';
|
||||
for (const layout of ['classic', 'glass-stage']) for (const width of [390, 1440]) for (const screen of ['downloads', 'settings']) test(`${layout} ${width} ${screen}: failed download actions`, async ({ page }, info) => {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
await openClassic(page);
|
||||
await page.evaluate(({ layout, screen }) => {
|
||||
data.settings.layout = layout; applyAppearance();
|
||||
window.actionCalls = [];
|
||||
Downloads.configure({ slots: () => 4, files: async () => [], playlists: () => [], jobs: () => [{ id: 'aaaaaaaaaaa', title: 'A long worship recording with piano, strings and choir — evening session', channel: 'Studio', thumbnail: '/fixture-cover.svg', status: 'failed', error: 'ERROR: [youtube] Video unavailable' }], action: (id, action) => actionCalls.push([id, action]) });
|
||||
API.cacheList = async () => ({ ok: true, items: [], total: 0 });
|
||||
view = { type: screen }; render();
|
||||
}, { layout, screen });
|
||||
if (screen === 'settings') await page.getByRole('button', { name: /Downloads & storage/ }).click();
|
||||
const retry = page.getByRole('button', { name: /^Retry A long/ });
|
||||
const cancel = page.getByRole('button', { name: /^Cancel A long/ });
|
||||
await expect(retry).toBeVisible(); await expect(cancel).toBeVisible();
|
||||
await retry.scrollIntoViewIfNeeded();
|
||||
if (before) {
|
||||
mkdirSync('/home/josh/deliverables/ytplayer-done12', { recursive: true });
|
||||
await page.screenshot({ path: `/home/josh/deliverables/ytplayer-done12/before-${layout}-${width}-${screen}-${info.project.name}.png` }); return;
|
||||
}
|
||||
for (const button of [retry, cancel]) {
|
||||
await expect(button.locator('svg')).toHaveAttribute('aria-hidden', 'true');
|
||||
const rect = await button.boundingBox(); expect(rect.height).toBeGreaterThanOrEqual(44); expect(rect.width).toBeGreaterThanOrEqual(44);
|
||||
await expect(button.locator('span')).toHaveText(button === retry ? 'Retry' : 'Cancel');
|
||||
}
|
||||
const a = await retry.boundingBox(), b = await cancel.boundingBox();
|
||||
expect(a.x + a.width <= b.x || a.y + a.height <= b.y).toBe(true);
|
||||
const visual = await retry.evaluate(el => { const s = getComputedStyle(el), probe = document.createElement('span'); probe.style.backgroundColor = getComputedStyle(document.documentElement).getPropertyValue(document.documentElement.dataset.layout === 'glass-stage' ? '--tile' : '--bg-2').trim(); document.body.append(probe); const expected = getComputedStyle(probe).backgroundColor; probe.remove(); return { radius: s.borderRadius, background: s.backgroundColor, expected }; });
|
||||
expect(visual.radius).toBe(layout === 'glass-stage' ? '18px' : '8px');
|
||||
expect(visual.background).toBe(visual.expected);
|
||||
await page.keyboard.press('Tab'); await retry.focus(); expect(await retry.evaluate(el => getComputedStyle(el).outlineStyle)).not.toBe('none');
|
||||
mkdirSync('/home/josh/deliverables/ytplayer-done12', { recursive: true });
|
||||
await page.screenshot({ path: `/home/josh/deliverables/ytplayer-done12/after-${layout}-${width}-${screen}-${info.project.name}.png` });
|
||||
await retry.click(); expect(await page.evaluate(() => actionCalls)).toContainEqual(['aaaaaaaaaaa', 'retry']);
|
||||
await cancel.click();
|
||||
if (screen === 'downloads') await page.locator('.modal').getByRole('button', { name: 'Cancel save', exact: true }).click();
|
||||
expect(await page.evaluate(() => actionCalls)).toContainEqual(['aaaaaaaaaaa', 'cancel']);
|
||||
});
|
||||
56
tests/fullscreen-orientation.spec.js
Normal file
56
tests/fullscreen-orientation.spec.js
Normal file
@@ -0,0 +1,56 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { openClassic, showPlayingFixture } = require('./helpers/classic-fixture');
|
||||
for (const layout of ['classic', 'glass-stage']) test(`${layout}: portrait forced landscape controls and restore`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openClassic(page, { populated: true }); await showPlayingFixture(page);
|
||||
await page.evaluate(layout => {
|
||||
data.settings.layout = layout; data.settings.fullscreenOrientation = 'landscape'; applyAppearance();
|
||||
els.video.parentElement.requestFullscreen = undefined;
|
||||
}, layout);
|
||||
await page.locator('#fsBtn').click();
|
||||
const stage = page.locator('.orientation-overlay');
|
||||
await expect(stage).toHaveAttribute('data-fullscreen-rotation', '90');
|
||||
await expect.poll(async () => Math.round((await stage.boundingBox()).height)).toBe(844);
|
||||
const rect = await stage.boundingBox();
|
||||
expect(Math.abs(rect.width - 390)).toBeLessThan(2); expect(Math.abs(rect.height - 844)).toBeLessThan(2);
|
||||
for (const button of await stage.locator('.fullscreen-hud button').all()) {
|
||||
const r = await button.boundingBox(); expect(r.x).toBeGreaterThanOrEqual(0); expect(r.y).toBeGreaterThanOrEqual(0); expect(r.x + r.width).toBeLessThanOrEqual(391); expect(r.y + r.height).toBeLessThanOrEqual(845);
|
||||
}
|
||||
await page.screenshot({ path: testInfo.outputPath(`${layout}-landscape.png`) });
|
||||
await stage.getByRole('button', { name: 'Change fullscreen orientation' }).click();
|
||||
await expect(stage).toHaveAttribute('data-fullscreen-rotation', '0');
|
||||
await stage.getByRole('button', { name: 'Exit fullscreen' }).click();
|
||||
await expect(page.locator('.orientation-overlay')).toHaveCount(0);
|
||||
await expect(page.locator('.fullscreen-hud')).toBeHidden();
|
||||
});
|
||||
test('standard fullscreen requests the selected orientation and releases it', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openClassic(page, { populated: true }); await showPlayingFixture(page);
|
||||
await page.evaluate(() => {
|
||||
data.settings.fullscreenOrientation = 'landscape';
|
||||
window.orientationCalls = [];
|
||||
const stage = els.video.parentElement;
|
||||
stage.requestFullscreen = async () => { Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: stage }); };
|
||||
document.exitFullscreen = async () => { Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null }); };
|
||||
Object.defineProperty(screen, 'orientation', { configurable: true, value: { lock: async value => orientationCalls.push(value), unlock: () => orientationCalls.push('unlock') } });
|
||||
});
|
||||
await page.locator('#fsBtn').click();
|
||||
await expect.poll(() => page.evaluate(() => orientationCalls)).toEqual(['landscape']);
|
||||
await page.locator('.fullscreen-hud').getByRole('button', { name: 'Exit fullscreen' }).click();
|
||||
expect(await page.evaluate(() => orientationCalls)).toEqual(['landscape', 'unlock']);
|
||||
});
|
||||
test('browser fullscreen exit restores a CSS fallback after orientation lock rejects', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openClassic(page, { populated: true }); await showPlayingFixture(page);
|
||||
await page.evaluate(() => {
|
||||
data.settings.fullscreenOrientation = 'landscape';
|
||||
const stage = els.video.parentElement;
|
||||
stage.requestFullscreen = async () => { Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: stage }); };
|
||||
Object.defineProperty(screen, 'orientation', { configurable: true, value: { lock: async () => { throw new Error('Unsupported'); } } });
|
||||
});
|
||||
await page.locator('#fsBtn').click();
|
||||
await expect(page.locator('.orientation-overlay')).toHaveAttribute('data-fullscreen-rotation', '90');
|
||||
await page.evaluate(() => { Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null }); document.dispatchEvent(new Event('fullscreenchange')); });
|
||||
await expect(page.locator('.orientation-overlay')).toHaveCount(0);
|
||||
await expect(page.locator('.fullscreen-hud')).toBeHidden();
|
||||
});
|
||||
@@ -20,42 +20,7 @@ async function setup(page, width, layout = 'glass-stage') {
|
||||
}, layout);
|
||||
await page.evaluate(async () => { await document.fonts.ready; await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); });
|
||||
}
|
||||
for (const width of [390, 1440]) test(`panel rail and bottom clearance at ${width}`, async ({ page }, info) => {
|
||||
await setup(page, width);
|
||||
// The desktop rail was hidden in the old build; expose it for baseline evidence.
|
||||
if (before && width === 1440) await page.addStyleTag({ content: 'html[data-layout="glass-stage"] #sectionRail{display:flex!important}' });
|
||||
for (const section of ['player', 'meta', 'notes', 'related', 'list']) {
|
||||
await page.locator(`#sectionRail [data-sec="${section}"]`).click();
|
||||
await page.evaluate(() => { document.getElementById('miniBar').classList.remove('hidden'); updateBottomChrome(); });
|
||||
await page.waitForTimeout(100);
|
||||
if (!before) {
|
||||
const metrics = await page.evaluate(() => {
|
||||
const rail = document.getElementById('sectionRail'), header = document.querySelector('#listPane .list-header');
|
||||
const r = rail.getBoundingClientRect(), h = header.getBoundingClientRect();
|
||||
const m = document.getElementById('miniBar').getBoundingClientRect(), p = document.getElementById('miniProgress').getBoundingClientRect(), nav = document.getElementById('bottomNav').getBoundingClientRect();
|
||||
return { parent: rail.parentElement.id, railBottom: r.bottom, headingTop: h.top, miniBottom: m.bottom, progressBottom: p.bottom, navTop: nav.height ? nav.top : innerHeight, overflow: getComputedStyle(document.getElementById('miniBar')).overflow };
|
||||
});
|
||||
expect(metrics.parent).toBe('listPane');
|
||||
expect(metrics.railBottom).toBeLessThanOrEqual(metrics.headingTop);
|
||||
expect(metrics.miniBottom).toBeLessThanOrEqual(metrics.navTop);
|
||||
expect(metrics.progressBottom).toBeLessThanOrEqual(metrics.miniBottom);
|
||||
expect(metrics.overflow).toBe('hidden');
|
||||
}
|
||||
await page.screenshot({ path: `/tmp/done9-${before ? 'before' : 'after'}-${info.project.name}-${width}-${section}.png` });
|
||||
}
|
||||
if (!before) {
|
||||
await page.evaluate(() => {
|
||||
const body = document.querySelector('.body'); body.scrollTop = body.scrollHeight;
|
||||
const cards = document.getElementById('cards'); cards.scrollTop = cards.scrollHeight;
|
||||
document.getElementById('miniBar').classList.remove('hidden'); updateBottomChrome();
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
const tail = await page.locator('#cards .card').last().boundingBox(), mini = await page.locator('#miniBar').boundingBox();
|
||||
expect(tail.y + tail.height).toBeLessThanOrEqual(mini.y - 8);
|
||||
}
|
||||
});
|
||||
module.exports = { setup };
|
||||
|
||||
// The rail-in-panel test was removed: the Glass rail floats again (see glass-rail-float.spec.js).
|
||||
for (const width of [390, 1440]) test(`Classic panel layout unchanged at ${width}`, async ({ page, context }, info) => {
|
||||
const baseline = await context.newPage();
|
||||
await baseline.route('**/theme-glass-panel.css', r => r.fulfill({ contentType: 'text/css', body: '' }));
|
||||
|
||||
32
tests/glass-rail-float.spec.js
Normal file
32
tests/glass-rail-float.spec.js
Normal file
@@ -0,0 +1,32 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { openClassic, showPlayingFixture } = require('./helpers/classic-fixture');
|
||||
const phoneCss = readFileSync('frontend/styles.css', 'utf8').replaceAll('(display-mode: standalone)', '(min-width: 0px)').replaceAll('env(safe-area-inset-bottom)', '34px');
|
||||
|
||||
// Glass Stage section rail floats centred above the mini player and stays put while the page scrolls.
|
||||
test('glass rail floats above the mini player and ignores scroll', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.route('**/styles.css', r => r.fulfill({ contentType: 'text/css', body: phoneCss }));
|
||||
await openClassic(page, { populated: true, standalone: true, settings: { autoPreload: false } });
|
||||
await showPlayingFixture(page);
|
||||
await page.evaluate(() => {
|
||||
data.settings.layout = 'glass-stage'; applyAppearance();
|
||||
document.getElementById('miniBar').classList.remove('hidden'); updateBottomChrome(); SectionRail.refresh();
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
const at = () => page.evaluate(() => {
|
||||
const r = document.getElementById('sectionRail').getBoundingClientRect(), m = document.getElementById('miniBar').getBoundingClientRect();
|
||||
return { top: r.top, bottom: r.bottom, center: (r.left + r.right) / 2, miniTop: m.top, parent: document.getElementById('sectionRail').parentElement.id, pos: getComputedStyle(document.getElementById('sectionRail')).position };
|
||||
});
|
||||
const a = await at();
|
||||
expect(a.pos).toBe('fixed');
|
||||
expect(a.parent).not.toBe('listPane');
|
||||
expect(Math.abs(a.center - 195)).toBeLessThan(2);
|
||||
expect(a.bottom).toBeLessThanOrEqual(a.miniTop);
|
||||
await page.screenshot({ path: process.env.SHOT_DIR ? `${process.env.SHOT_DIR}/rail-a.png` : undefined });
|
||||
await page.evaluate(() => { for (const el of [document.scrollingElement, ...document.querySelectorAll('.body,#listPane,.player-pane')]) el && (el.scrollTop += 600); });
|
||||
await page.waitForTimeout(200);
|
||||
const b = await at();
|
||||
expect(Math.abs(b.top - a.top)).toBeLessThan(1);
|
||||
await page.screenshot({ path: process.env.SHOT_DIR ? `${process.env.SHOT_DIR}/rail-b.png` : undefined });
|
||||
});
|
||||
@@ -19,9 +19,9 @@ function decode(buffer) {
|
||||
}
|
||||
return {width,height,channels,pixels};
|
||||
}
|
||||
function differentPixels(first,second){
|
||||
function differentPixels(first,second,channelTolerance=0){
|
||||
const a=decode(first),b=decode(second);if(a.width!==b.width||a.height!==b.height||a.channels!==b.channels)return Infinity;
|
||||
let count=0;for(let i=0;i<a.pixels.length;i+=a.channels)if(!a.pixels.subarray(i,i+a.channels).equals(b.pixels.subarray(i,i+a.channels)))count++;
|
||||
let count=0;for(let i=0;i<a.pixels.length;i+=a.channels)if([...a.pixels.subarray(i,i+a.channels)].some((value,channel)=>Math.abs(value-b.pixels[i+channel])>channelTolerance))count++;
|
||||
return count;
|
||||
}
|
||||
module.exports={differentPixels};
|
||||
|
||||
43
tests/jamesdsp.spec.js
Normal file
43
tests/jamesdsp.spec.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { openClassic } = require('./helpers/classic-fixture');
|
||||
|
||||
function wavTone() {
|
||||
const rate = 48000, frames = rate;
|
||||
const b = Buffer.alloc(44 + frames * 2);
|
||||
b.write('RIFF', 0); b.writeUInt32LE(b.length - 8, 4); b.write('WAVEfmt ', 8);
|
||||
b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
|
||||
b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34);
|
||||
b.write('data', 36); b.writeUInt32LE(frames * 2, 40);
|
||||
for (let i = 0; i < frames; i++) b.writeInt16LE(Math.round(Math.sin(2 * Math.PI * 440 * i / rate) * 9000), 44 + i * 2);
|
||||
return b;
|
||||
}
|
||||
|
||||
test('JamesDSP stays lazy when off, plays a fixture tone when enabled, and persists settings', async ({ page }) => {
|
||||
await page.route('**/tone.wav', route => route.fulfill({ contentType: 'audio/wav', body: wavTone() }));
|
||||
await openClassic(page);
|
||||
await page.evaluate(() => {
|
||||
window.__audioContexts = 0;
|
||||
const AC = window.AudioContext;
|
||||
window.AudioContext = class extends AC { constructor(...args) { super(...args); window.__audioContexts++; } };
|
||||
});
|
||||
expect(await page.evaluate(() => window.__audioContexts)).toBe(0);
|
||||
await page.locator('.nav-item[data-view="settings"]').click();
|
||||
await page.locator('[data-section="jamesdsp"]').click();
|
||||
await page.locator('#setJamesDsp').check();
|
||||
await expect.poll(() => page.evaluate(() => window.__audioContexts)).toBe(1);
|
||||
await page.evaluate(async () => {
|
||||
const audio = document.getElementById('audio');
|
||||
Player.mode = 'audio'; Player.master = audio; Player.secondary = null;
|
||||
await JamesDSP.activate(audio);
|
||||
audio.src = '/tone.wav'; audio.load(); await audio.play();
|
||||
});
|
||||
await expect.poll(() => page.locator('#audio').evaluate(a => a.currentTime)).toBeGreaterThan(0.1);
|
||||
await page.waitForTimeout(600);
|
||||
const reloaded = await page.context().newPage();
|
||||
await reloaded.goto('/');
|
||||
await reloaded.locator('.nav-item[data-view="settings"]').click();
|
||||
await expect(reloaded.locator('[data-section="jamesdsp"]')).toBeVisible();
|
||||
await reloaded.locator('[data-section="jamesdsp"]').click();
|
||||
await expect(reloaded.locator('#setJamesDsp')).toBeChecked();
|
||||
await expect(reloaded.locator('#setJamesDspPreset')).toHaveValue('Flat');
|
||||
});
|
||||
19
tests/related-videos.spec.js
Normal file
19
tests/related-videos.spec.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { openClassic, showPlayingFixture } = require('./helpers/classic-fixture');
|
||||
for (const layout of ['classic', 'glass-stage']) test(`${layout}: related failure stays visible and Retry refreshes`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openClassic(page, { populated: true }); await showPlayingFixture(page);
|
||||
let calls = 0;
|
||||
await page.route('**/api/related?**', route => {
|
||||
calls++; if (calls > 1) expect(new URL(route.request().url()).searchParams.get('refresh')).toBe('1');
|
||||
return route.fulfill({ status: calls === 1 ? 503 : 200, contentType: 'application/json', body: JSON.stringify(calls === 1 ? { ok: false, error: 'Raw YouTube failure' } : { ok: true, results: [{ id: 'bbbbbbbbbbb', title: 'A related song', channel: 'Artist' }] }) });
|
||||
});
|
||||
const railParent = await page.locator('#sectionRail').evaluate(el => el.parentElement.id || el.parentElement.className);
|
||||
await page.evaluate(async layout => { data.settings.layout = layout; applyAppearance(); view = { type: 'nowPlaying' }; render(); relatedCollapsed = false; await loadRelated(); }, layout);
|
||||
await expect(page.locator('#relatedPanel')).toBeVisible();
|
||||
await expect(page.locator('#relatedList')).toContainText('Please try again');
|
||||
await expect(page.locator('#relatedList')).not.toContainText('Raw YouTube failure');
|
||||
await page.getByRole('button', { name: 'Retry related videos' }).click();
|
||||
await expect(page.locator('.ri-title')).toHaveText('A related song');
|
||||
expect(await page.locator('#sectionRail').evaluate(el => el.parentElement.id || el.parentElement.className)).toBe(railParent);
|
||||
});
|
||||
Reference in New Issue
Block a user