Compare commits

..

30 Commits

Author SHA1 Message Date
Jonathan Sykes
04421a2145 Add an isolated beta compose stack 2026-10-07 23:30:29 +08:00
Jonathan Sykes
491ec407a1 Expose more JamesDSP EQ bands 2026-10-07 19:24:36 +08:00
Jonathan Sykes
dce186c187 Add optional JamesDSP audio effects 2026-10-07 19:23:35 +08:00
Jonathan Sykes
93d3b05263 Document after-phase0 benchmark comparison and hidden timer validation 2026-10-07 17:23:01 +08:00
Jonathan Sykes
eb6aae7ae7 Add hidden timers Playwright verification script 2026-10-07 17:21:18 +08:00
Jonathan Sykes
09125f3f5c Record after-phase0 performance benchmark results 2026-10-07 17:21:08 +08:00
Jonathan Sykes
7bc5b4f8b5 Merge branch 'agy/phase0-perf' 2026-10-07 16:36:56 +08:00
Jonathan Sykes
4bc15a55e3 Add baseline benchmark results and report 2026-10-07 04:25:14 +08:00
Jonathan Sykes
64f8eae92b Add performance baseline harness, throttling proxy, and documentation 2026-10-07 03:53:20 +08:00
Jonathan Sykes
3f37c2a9ef Add shell fixture script and verify production build tag 2026-10-07 03:32:10 +08:00
Jonathan Sykes
233bf94e4c Pause non-playback UI timers while the page is hidden 2026-10-07 03:30:57 +08:00
Jonathan Sykes
722df3125f Add visibility-aware interval helper 2026-10-07 03:30:22 +08:00
Jonathan Sykes
2e58924971 Add shell consistency test between index.html and the service worker 2026-10-07 03:29:24 +08:00
Jonathan Sykes
b6dd2be40f Add server characterization tests for build tag, index stamping and cache headers 2026-10-07 03:28:45 +08:00
Jonathan Sykes
b77938ab4a Queue bounded stream prewarm requests 2026-10-07 03:13:30 +08:00
Jonathan Sykes
b4c776dcfe Add dark, light and contrast variants of the M3E sketch 2026-10-07 02:16:13 +08:00
Jonathan Sykes
2b286ae146 Stop the last song from restarting and freezing service-mode lyrics 2026-10-07 02:12:07 +08:00
Jonathan Sykes
128f8e95e3 Respect the notch in the Framework7 top bar, sidebar and full sheet 2026-10-06 09:16:49 +08:00
Jonathan Sykes
63ff2173d8 Lift player to body for iOS fullscreen so the app never shows around it 2026-10-06 09:14:06 +08:00
Jonathan Sykes
a143c8331d Stop video seek loop when returning from background 2026-10-06 09:14:06 +08:00
Jonathan Sykes
5b259a1ac1 Add M3E sketch of the Framework7 Swipe layout 2026-10-06 04:20:05 +08:00
Jonathan Sykes
1eda52e4c5 Stop tracking review screenshots 2026-10-06 04:19:01 +08:00
Jonathan Sykes
3be7a7b194 Fix fullscreen fit gesture clash and double swipe-remove in Framework7 layout 2026-10-06 04:19:01 +08:00
Jonathan Sykes
bd80ad1ea0 Align the design review score with its rating band 2026-10-06 04:16:15 +08:00
Jonathan Sykes
c30fd9fe78 Record the Framework7 design review 2026-10-06 04:15:25 +08:00
Jonathan Sykes
605d21a3e2 Use native icons in the Framework7 sidebar 2026-10-06 04:13:55 +08:00
Jonathan Sykes
cc4fb0d5c9 Simplify fullscreen playback controls 2026-10-06 04:12:28 +08:00
Jonathan Sykes
af38affdbe Make fullscreen video fill the viewport 2026-10-06 04:12:25 +08:00
Jonathan Sykes
034b3df7ae Complete Framework7 swipe gestures 2026-10-06 04:12:15 +08:00
Jonathan Sykes
d4497b0c05 Restyle the Framework7 layout 2026-10-06 04:12:09 +08:00
43 changed files with 10004 additions and 63 deletions

6
.gitignore vendored
View File

@@ -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/

View File

@@ -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?

34
docker-compose.beta.yml Normal file
View 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

View 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."
}
]
}
]
}

View 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."
}
]
}
]
}

View 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
View 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."
}
]
}
]
}

View File

@@ -644,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
@@ -2658,6 +2659,7 @@ const Player = {
play() {
this._wantsPlaying = true;
if (data.settings.jamesDsp?.enabled) JamesDSP.activate(this.soundEl).catch((e) => JamesDSP.fail(e));
audioContinuity.play(this.master);
audioContinuity.resume(this.secondary, this.master);
},
@@ -2704,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.
@@ -2719,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.
@@ -2841,7 +2867,7 @@ const Player = {
this.master = V;
this.secondary = A;
try {
if (AudioContinuity.needsAlignment(V.currentTime, t, 0.12)) { this._internalSeek = true; V.currentTime = t; }
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;
@@ -2853,7 +2879,7 @@ const Player = {
this.mode = 'dual';
this.secondary = A;
V.muted = true;
if (AudioContinuity.needsAlignment(V.currentTime, t, 0.12)) { this._internalSeek = true; V.currentTime = t; }
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 */ }
},
@@ -4705,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 });
})();
// ============================================================================
@@ -5045,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 });
@@ -5275,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');
@@ -5285,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;
@@ -5363,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', () => {
@@ -5776,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); }
@@ -6792,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)
// ============================================================================
@@ -8183,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');
}
@@ -9615,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">
@@ -9808,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"/>'],
@@ -9886,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;
@@ -11034,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);
}
// ============================================================================
@@ -11277,7 +11357,7 @@ function rotateChips() {
}
});
}
setInterval(rotateChips, 8000);
VisibleTimer.setIntervalWhenVisible(rotateChips, 8000);
document.querySelectorAll('.chip').forEach((c) => {
c.addEventListener('click', () => {
@@ -11456,7 +11536,7 @@ document.querySelectorAll('.chip').forEach((c) => {
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 }),
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());
@@ -12124,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 });
}
// ============================================================================

View File

@@ -18,7 +18,8 @@
pending.set(element, request); return request;
}
function resume(element, clock) {
if (!element || !element.paused || pending.has(element)) return;
// An ended element is finished, not interrupted: play() would restart it from 0.
if (!element || !element.paused || element.ended || pending.has(element)) return;
if (clock && element !== clock && needsAlignment(element.currentTime, clock.currentTime)) element.currentTime = clock.currentTime;
return play(element);
}

View File

@@ -41,3 +41,9 @@ test('a new play after pause/source reset is not blocked by a stale promise', as
assert.equal(controller.play(audio), fresh); // old completion cannot discard the new request
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);
});

View File

@@ -8,7 +8,7 @@
// Which swipe actions a card gets in a given view. Pure, so it is testable.
function cardActions(viewType,canDelete){
const left=viewType==='queue'?[]:[{act:'queue',label:'Queue'}];
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};
@@ -21,6 +21,10 @@
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;
@@ -49,7 +53,22 @@
// ---- 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='f7s-act'+(a.danger?' f7s-danger swipeout-overswipe':'');b.dataset.act=a.act;b.textContent=a.label;return b;}
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';
@@ -67,7 +86,7 @@
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();if(app)app.swipeout.close(li);if(!v)return;
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();}
@@ -81,8 +100,9 @@
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='<img alt="" loading="lazy" decoding="async"><div class="f7s-up-info"><div class="f7s-up-title"></div><div class="f7s-up-ch"></div></div>';
li.querySelector('img').src=v.thumbnail||'';li.querySelector('.f7s-up-title').textContent=v.title||'';li.querySelector('.f7s-up-ch').textContent=v.channel||'';
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);
});
@@ -98,17 +118,29 @@
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==='mouse'&&e.button!==0)return;
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');
if(!mini&&!edge&&!inSidebar)return;
drag={x:e.clientX,y:e.clientY,kind:mini?'mini':edge?'edge':'sidebar'};
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;
@@ -118,10 +150,67 @@
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;
@@ -140,12 +229,16 @@
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};
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);

View File

@@ -1,9 +1,9 @@
const test = require('node:test');
const assert = require('node:assert');
const { cardActions, swipeDirection } = require('./f7-layout');
const { cardActions, swipeDirection, tabSwipeIndex } = require('./f7-layout');
test('cardActions: queue view has no Queue action, removable rows get Remove', () => {
assert.deepStrictEqual(cardActions('queue', true).left, []);
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']);
@@ -17,3 +17,11 @@ test('swipeDirection classifies clear swipes and ignores short or diagonal drags
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);
});

View File

@@ -1,36 +1,38 @@
/* Shared fullscreen surface; inline player styling is unchanged. */
.orientation-fullscreen .fullscreen-hud[hidden] { display: none; }
.orientation-fullscreen .fullscreen-hud {
--hud-glass: color-mix(in srgb, var(--bg-1, #111) 74%, transparent);
--hud-ink: var(--text, #fff);
--hud-line: color-mix(in srgb, var(--line, #fff) 32%, transparent);
position: absolute; z-index: 20;
right: max(12px, env(safe-area-inset-right));
bottom: max(12px, env(safe-area-inset-bottom));
left: max(12px, env(safe-area-inset-left));
display: flex; align-items: center; gap: 6px;
padding: 5px; border: 1px solid var(--hud-line); border-radius: 999px;
background: var(--hud-glass); color: var(--hud-ink);
-webkit-backdrop-filter: saturate(150%) blur(18px);
backdrop-filter: saturate(150%) blur(18px);
box-shadow: 0 8px 28px rgb(0 0 0 / .2);
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: 48px; min-width: 48px; height: 48px; min-height: 48px; padding: 0;
border: 1px solid transparent; border-radius: 50%;
background: color-mix(in srgb, var(--hud-ink, #fff) 10%, transparent);
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: color-mix(in srgb, var(--hud-ink, #fff) 18%, 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: 44px; height: 44px; flex: 1; margin: 0 4px; accent-color: var(--accent, #fff); touch-action: none; }
.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;
@@ -49,8 +51,10 @@
.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: contain;
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));
@@ -59,9 +63,13 @@
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: 3px; }
.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; }

View File

@@ -11,15 +11,29 @@
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 = '<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" 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>';
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, orientation, exit] = hud.querySelectorAll('button'), range = hud.querySelector('input');
const play = hud.querySelector('button[aria-label="Play or pause"]'), orientation = hud.querySelector('.fullscreen-orientation'), fitButton = hud.querySelector('.fullscreen-fit'), exit = hud.querySelector('button[aria-label="Exit fullscreen"]'), range = hud.querySelector('input');
const title = hud.querySelector('.fullscreen-title');
function setFit(value) {
const fit = normalizeFit(value); settings().fullscreenFit = fit; persist();
stage.dataset.fullscreenFit = fit;
fitButton.setAttribute('aria-label', `Video fill mode: ${fit === 'fill' ? 'Fill' : 'Fit'}`);
fitButton.title = `${fit === 'fill' ? 'Fill' : 'Fit'} video frame`;
}
function revealControls() {
if (!active) return;
hud.classList.remove('hud-hidden'); clearTimeout(hideTimer);
@@ -33,10 +47,13 @@
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)}`;
}
@@ -45,7 +62,7 @@
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; stage.classList.add('orientation-overlay'); }
catch { if (!active) return; custom = true; lift(); stage.classList.add('orientation-overlay'); }
}
layout();
}
@@ -65,6 +82,8 @@
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() {
@@ -74,18 +93,20 @@
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; stage.classList.add('orientation-overlay'); }
} 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
@@ -98,6 +119,6 @@
window.addEventListener('resize', layout);
return { enter, leave, point: event => coordinates(event.clientX, event.clientY, stage.getBoundingClientRect(), angle) };
}
const api = { normalize, rotation, coordinates, create };
const api = { normalize, normalizeFit, rotation, coordinates, create };
if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.FullscreenOrientation = api;
})(globalThis);

View File

@@ -1,6 +1,11 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { normalize, rotation, coordinates } = require('./fullscreen-orientation');
const { normalize, normalizeFit, rotation, coordinates } = require('./fullscreen-orientation');
test('fullscreen fit defaults safely to fill and accepts fit', () => {
assert.equal(normalizeFit(undefined), 'fill');
assert.equal(normalizeFit('fit'), 'fit');
assert.equal(normalizeFit('unexpected'), 'fill');
});
test('orientation defaults and only rotates mismatched viewports', () => {
assert.equal(normalize('invalid'), 'auto');
assert.equal(rotation('auto', 390, 844), 0);

View File

@@ -129,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>
@@ -693,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>

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

View File

@@ -70,6 +70,7 @@ const SHELL = [
'/flag-ui.js',
'/export.js',
'/async-guard.js',
'/visible-timer.js',
'/sw-update.js',
'/fingerprint.js',
'/opfs.js',
@@ -95,6 +96,12 @@ 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',

View File

@@ -69,6 +69,7 @@ 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;
@@ -109,7 +110,7 @@ html[data-layout="f7-swipe"] .bottom-nav-btn.active {
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"] .mini-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);
@@ -170,3 +171,175 @@ html[data-layout="f7-swipe"] #miniBar .mini-bar-inner::before {
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
frontend/vendor/jamesdsp/LICENSE.txt vendored Normal file
View 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
View 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);

File diff suppressed because one or more lines are too long

BIN
frontend/vendor/jamesdsp/dsp/jamesdsp.wasm vendored Executable file

Binary file not shown.

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

249
plans/perf-baseline.md Normal file
View 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).

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

View File

@@ -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 ./related.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",

View File

@@ -67,6 +67,7 @@ import { ingest as collectVideoMetadata, syncListening, linkListening, startThum
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
@@ -728,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);
});

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

43
tests/jamesdsp.spec.js Normal file
View 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');
});