Add party player: YouTube queue with crossfading playback

Two frontends sharing one WebSocket-backed state on the server:

- Player: fullscreen poster background with a small frame (30% of the
  screen) that drifts diagonally and reflects off the edges. Two stacked
  YouTube players hand over with a 5s equal-power crossfade so there is
  never silence between tracks.
- Queue: add by URL, drag to reorder, delete, click to jump. Shows title
  and length per item, highlights what is playing, and tracks remaining
  vs. total playtime.

No API key is required — titles come from YouTube's oEmbed endpoint and
durations from a hidden muted probe player that cues each new video just
to read its length.

The fade is driven by both requestAnimationFrame and the interval tick:
a hidden or throttled tab stops serving frames entirely, which would
otherwise strand a handover half-faded with two tracks audible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
This commit is contained in:
simon
2026-08-09 21:22:13 +02:00
co-authored by Claude Opus 5
commit ad999e1c3e
17 changed files with 2540 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
queue.json
queue.json.tmp
+42
View File
@@ -0,0 +1,42 @@
# Party Player
A YouTube playlist player for parties. Two browser windows run side by side:
- **Player** (`http://localhost:3000/`) — fullscreen poster background with a
small video frame drifting diagonally and bouncing off the edges. Consecutive
videos overlap in a 5 second audio/video crossfade, so there is never silence
between tracks.
- **Queue** (`http://localhost:3000/queue`) — add videos by URL, drag to
reorder, delete, and see how much playtime is left.
## Run
```sh
npm install
npm start
```
Put the player on the projector/TV screen (F11 for fullscreen) and open the
queue on a laptop or phone. On the same network, other devices reach both pages
at `http://<this-machine-ip>:3000`.
**The player page opens behind a "Start" button.** Browsers block unmuted
autoplay, so the first click is what unlocks audio. One click per session.
## Notes
- No YouTube API key is needed. Titles come from YouTube's public oEmbed
endpoint; durations are measured by a hidden muted player on the player page,
which is why a freshly added video shows `–:––` for a moment (and keeps
showing it while the player page is closed). Totals are prefixed with `≥`
whenever a length is still unknown.
- The queue is saved to `queue.json` on every change and restored at startup.
- Settings live in `config.js`: overlap length, frame size, drift speed, port,
and the selectable backgrounds.
## Adding animations
`#frameMover` carries only the motion transform (see `public/player/motion.js`).
`#frameSkin` inside it is the cosmetic layer, and the two video decks sit inside
that. New effects should nest a fresh wrapper rather than sharing a transform
with the bounce loop.
+24
View File
@@ -0,0 +1,24 @@
// Central knobs for the party player.
export const PORT = Number(process.env.PORT) || 3000;
// Seconds of overlap between the outgoing and incoming video.
// Audio and video crossfade over this whole window.
export const OVERLAP_SECONDS = 5;
// How fast the player switches when the user jumps around manually.
export const HARD_CUT_SECONDS = 0.4;
// The video frame takes up this fraction of the screen.
export const FRAME_SCALE = 0.3;
// Pixels per second the frame drifts across the screen.
export const BOUNCE_SPEED = 90;
// Selectable fullscreen backgrounds, keyed by the id sent over the socket.
export const BACKGROUNDS = {
pink: { label: 'Pink', src: '/assets/PLAKAT_A2_pink_426x600mm.png' },
plain: { label: 'Classic', src: '/assets/PLAKAT_A2_426x600mm.png' },
};
export const DEFAULT_BACKGROUND = 'pink';
+850
View File
@@ -0,0 +1,850 @@
{
"name": "party-player",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "party-player",
"version": "1.0.0",
"dependencies": {
"express": "^4.21.2",
"ws": "^8.18.0"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "party-player",
"version": "1.0.0",
"description": "YouTube playlist player with crossfade, for parties",
"type": "module",
"main": "server/index.js",
"scripts": {
"start": "node server/index.js"
},
"dependencies": {
"express": "^4.21.2",
"ws": "^8.18.0"
}
}
+36
View File
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Party Player</title>
<link rel="stylesheet" href="/player/player.css" />
</head>
<body>
<div id="backgrounds"></div>
<!-- #frameMover owns the motion transform and nothing else, so future
animations can nest their own transform layers without conflict. -->
<div id="frameMover">
<div id="frameSkin">
<div class="deck" data-deck="a"><div id="deck-a"></div></div>
<div class="deck" data-deck="b"><div id="deck-b"></div></div>
</div>
</div>
<!-- Offscreen, muted, 1x1: cues queued videos purely to read their length. -->
<div id="probe"><div id="probe-player"></div></div>
<div id="gate">
<div class="gate-inner">
<h1>Party Player</h1>
<p id="gate-hint">Loading queue…</p>
<button id="start" type="button" disabled>Start</button>
</div>
</div>
<div id="status" hidden>reconnecting…</div>
<script type="module" src="/player/player.js"></script>
</body>
</html>
+110
View File
@@ -0,0 +1,110 @@
// Frame motion. Everything here writes to #frameMover's transform and nothing
// else, so additional animation layers can be nested inside without conflict.
import { FRAME_SCALE, BOUNCE_SPEED } from '/config.js';
const ASPECT = 9 / 16;
/**
* Frame size for a viewport: FRAME_SCALE of the width, but never taller than
* FRAME_SCALE of the height, so a wide screen does not get a letterbox strip.
*/
export function frameSize(viewportWidth, viewportHeight) {
let width = viewportWidth * FRAME_SCALE;
let height = width * ASPECT;
const maxHeight = viewportHeight * FRAME_SCALE;
if (height > maxHeight) {
height = maxHeight;
width = height / ASPECT;
}
return { width, height };
}
/**
* Diagonal drift with reflection off the viewport edges. Pure state + step, so
* the physics can be exercised without a browser and without waiting on rAF.
*/
export function createBouncer({ x = 0, y = 0 } = {}) {
const bouncer = {
x,
y,
// A shallow diagonal reads better than 45°, and never retraces its path.
vx: BOUNCE_SPEED,
vy: BOUNCE_SPEED * 0.62,
step(dt, bounds) {
const maxX = Math.max(0, bounds.width);
const maxY = Math.max(0, bounds.height);
bouncer.x += bouncer.vx * dt;
bouncer.y += bouncer.vy * dt;
// Reflect, mirroring the overshoot back inside the viewport.
if (bouncer.x <= 0) {
bouncer.x = Math.min(-bouncer.x, maxX);
bouncer.vx = Math.abs(bouncer.vx);
} else if (bouncer.x >= maxX) {
bouncer.x = Math.max(maxX - (bouncer.x - maxX), 0);
bouncer.vx = -Math.abs(bouncer.vx);
}
if (bouncer.y <= 0) {
bouncer.y = Math.min(-bouncer.y, maxY);
bouncer.vy = Math.abs(bouncer.vy);
} else if (bouncer.y >= maxY) {
bouncer.y = Math.max(maxY - (bouncer.y - maxY), 0);
bouncer.vy = -Math.abs(bouncer.vy);
}
return bouncer;
},
};
return bouncer;
}
export function startBounce(element) {
let size = frameSize(window.innerWidth, window.innerHeight);
const bouncer = createBouncer({
x: (window.innerWidth - size.width) / 2,
y: (window.innerHeight - size.height) / 2,
});
function bounds() {
return {
width: window.innerWidth - size.width,
height: window.innerHeight - size.height,
};
}
function measure() {
size = frameSize(window.innerWidth, window.innerHeight);
element.style.width = `${size.width}px`;
element.style.height = `${size.height}px`;
const limit = bounds();
bouncer.x = Math.min(bouncer.x, Math.max(0, limit.width));
bouncer.y = Math.min(bouncer.y, Math.max(0, limit.height));
}
measure();
window.addEventListener('resize', measure);
let last = performance.now();
function frame(now) {
// Clamped so a stalled tab does not teleport the frame on resume.
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
bouncer.step(dt, bounds());
element.style.transform = `translate3d(${bouncer.x.toFixed(1)}px, ${bouncer.y.toFixed(1)}px, 0)`;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
return bouncer;
}
+156
View File
@@ -0,0 +1,156 @@
:root {
--accent: #ff4fc3;
--ink: #14060f;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
overflow: hidden;
background: var(--ink);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
cursor: none;
}
/* --- background ------------------------------------------------------- */
#backgrounds {
position: fixed;
inset: 0;
}
#backgrounds img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 900ms ease;
/* Knock the poster back so the video frame stays the focal point — the
posters are pale and read far brighter on a projector than on a desk. */
filter: brightness(0.45) saturate(1.15);
}
#backgrounds img.active {
opacity: 1;
}
/* --- the moving frame -------------------------------------------------- */
#frameMover {
position: fixed;
top: 0;
left: 0;
will-change: transform;
}
#frameSkin {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 10px;
border: 2px solid rgba(255, 255, 255, 0.85);
box-shadow:
0 0 0 4px rgba(255, 79, 195, 0.55),
0 24px 70px rgba(0, 0, 0, 0.7);
background: #000;
}
.deck {
position: absolute;
inset: 0;
opacity: 0;
/* Opacity is driven frame-by-frame during a crossfade, so no CSS transition
here — it would fight the JS ramp. */
}
.deck iframe {
width: 100%;
height: 100%;
border: 0;
/* The iframe would otherwise swallow nothing useful, but keeping it inert
avoids stray clicks pausing playback. */
pointer-events: none;
}
#probe {
position: fixed;
left: -400px;
top: -400px;
width: 320px;
height: 180px;
opacity: 0;
pointer-events: none;
}
/* --- start gate -------------------------------------------------------- */
#gate {
position: fixed;
inset: 0;
display: grid;
place-items: center;
background: rgba(20, 6, 15, 0.92);
backdrop-filter: blur(6px);
z-index: 10;
cursor: pointer;
transition: opacity 400ms ease;
}
#gate.hidden {
opacity: 0;
pointer-events: none;
}
.gate-inner {
text-align: center;
color: #fff;
}
.gate-inner h1 {
font-size: clamp(2rem, 7vw, 5rem);
margin: 0 0 0.3em;
letter-spacing: -0.03em;
color: var(--accent);
}
.gate-inner p {
margin: 0 0 2em;
opacity: 0.7;
font-size: 1.05rem;
}
#start {
font: inherit;
font-size: 1.3rem;
padding: 0.7em 3em;
border: 0;
border-radius: 999px;
background: var(--accent);
color: var(--ink);
font-weight: 700;
cursor: pointer;
}
#start:disabled {
opacity: 0.35;
cursor: default;
}
#status {
position: fixed;
bottom: 12px;
right: 16px;
z-index: 20;
color: var(--accent);
font-size: 0.8rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
+330
View File
@@ -0,0 +1,330 @@
import { connect } from '/shared/socket.js';
import { OVERLAP_SECONDS, HARD_CUT_SECONDS, BACKGROUNDS } from '/config.js';
import { createPlayer, loadApi, STATE } from './yt.js';
import { startBounce } from './motion.js';
const TICK_MS = 250;
let state = { items: [], currentId: null, background: 'pink' };
let decks = [];
let active = 0;
let transition = null; // { until, duration, from, to } while crossfading
let started = false;
const socket = connect({ onMessage, onStatus });
// --- backgrounds --------------------------------------------------------
const backgroundLayer = document.getElementById('backgrounds');
const backgroundImages = new Map();
for (const [key, { src }] of Object.entries(BACKGROUNDS)) {
const img = document.createElement('img');
img.src = src;
img.alt = '';
backgroundLayer.appendChild(img);
backgroundImages.set(key, img);
}
function applyBackground(key) {
for (const [name, img] of backgroundImages) img.classList.toggle('active', name === key);
}
applyBackground(state.background);
// --- start gate ---------------------------------------------------------
const gate = document.getElementById('gate');
const startButton = document.getElementById('start');
const gateHint = document.getElementById('gate-hint');
startButton.addEventListener('click', start);
gate.addEventListener('click', (event) => {
if (event.target === gate && !startButton.disabled) start();
});
function updateGate() {
if (started) return;
const ready = state.items.length > 0;
startButton.disabled = !ready;
gateHint.textContent = ready
? `${state.items.length} video${state.items.length === 1 ? '' : 's'} queued — click to start the music`
: 'Add videos from the queue page to begin.';
}
async function start() {
if (started) return;
started = true;
gate.classList.add('hidden');
startBounce(document.getElementById('frameMover'));
decks = await Promise.all([
makeDeck('deck-a', 0),
makeDeck('deck-b', 1),
]);
const current = currentItem();
if (current) playOn(active, current, { volume: 100, opacity: 1 });
setInterval(tick, TICK_MS);
setInterval(reportProgress, 1000);
}
// --- decks --------------------------------------------------------------
async function makeDeck(elementId, index) {
const element = document.querySelector(`.deck[data-deck="${index === 0 ? 'a' : 'b'}"]`);
const deck = {
element,
itemId: null,
player: null,
setOpacity(value) {
element.style.opacity = String(value);
},
};
deck.player = await createPlayer(elementId, {
onStateChange: (code) => onDeckState(index, code),
});
deck.player.setVolume(0);
deck.setOpacity(0);
return deck;
}
function playOn(index, item, { volume, opacity }) {
const deck = decks[index];
deck.itemId = item.id;
deck.player.loadVideoById(item.videoId);
deck.player.setVolume(volume);
deck.player.unMute();
deck.setOpacity(opacity);
}
function onDeckState(index, code) {
if (index !== active || transition) return;
// Safety net: a track that ended without our tick catching it (very short
// clips, or a stalled tab) still has to hand over.
if (code === STATE.ENDED) {
const next = nextItem();
if (!next || next.id === decks[active].itemId) {
decks[active].player.seekTo(0, true);
decks[active].player.playVideo();
} else {
beginCrossfade(next, HARD_CUT_SECONDS, { advance: true });
}
}
}
// --- playback loop ------------------------------------------------------
function tick() {
if (!decks.length) return;
if (transition) {
stepTransition();
return;
}
const deck = decks[active];
// Queue emptied out from under us — stop rather than loop the video the
// user just deleted.
if (state.items.length === 0) {
if (deck.itemId) {
deck.player.stopVideo();
deck.setOpacity(0);
deck.itemId = null;
}
return;
}
// Nothing playing but the queue has items again (all deleted, then re-added).
if (!deck.itemId) {
const resume = currentItem();
if (resume) playOn(active, resume, { volume: 100, opacity: 1 });
return;
}
// The server pointer moved somewhere we are not playing — a jump, or the
// current item was deleted. Fade there quickly instead of waiting.
if (state.currentId && deck.itemId !== state.currentId) {
const target = state.items.find((item) => item.id === state.currentId);
if (target) {
beginCrossfade(target, HARD_CUT_SECONDS, { advance: false });
return;
}
}
const duration = deck.player.getDuration?.() ?? 0;
const currentTime = deck.player.getCurrentTime?.() ?? 0;
if (!duration) return;
if (duration - currentTime <= OVERLAP_SECONDS) {
const next = nextItem();
// A single-item queue loops instead of crossfading with itself.
if (!next || next.id === deck.itemId) return;
beginCrossfade(next, OVERLAP_SECONDS, { advance: true });
}
}
function beginCrossfade(item, seconds, { advance }) {
const from = active;
const to = 1 - active;
playOn(to, item, { volume: 0, opacity: 0 });
decks[to].player.playVideo();
transition = {
startedAt: performance.now(),
duration: seconds * 1000,
from,
to,
advance,
outgoingId: decks[from].itemId,
};
rampTransition();
}
/**
* Apply the fade for the current point in time. Driven by animation frames for
* smoothness, and also by the tick — a hidden or throttled tab stops serving
* rAF entirely, and a half-faded handover would leave two tracks audible.
*/
function stepTransition() {
if (!transition) return false;
const progress = Math.min((performance.now() - transition.startedAt) / transition.duration, 1);
const outgoing = decks[transition.from];
const incoming = decks[transition.to];
// Equal-power curve: the combined loudness stays flat through the middle
// of the mix instead of dipping.
outgoing.player.setVolume(Math.round(Math.cos((progress * Math.PI) / 2) * 100));
incoming.player.setVolume(Math.round(Math.sin((progress * Math.PI) / 2) * 100));
outgoing.setOpacity(1 - progress);
incoming.setOpacity(progress);
if (progress < 1) return true;
finishTransition();
return false;
}
function rampTransition() {
if (stepTransition()) requestAnimationFrame(rampTransition);
}
function finishTransition() {
if (!transition) return;
const { from, to, advance, outgoingId } = transition;
transition = null;
decks[from].player.stopVideo();
decks[from].player.setVolume(0);
decks[from].setOpacity(0);
decks[from].itemId = null;
decks[to].player.setVolume(100);
decks[to].setOpacity(1);
active = to;
if (advance) socket.send({ type: 'trackEnded', id: outgoingId });
}
function reportProgress() {
const deck = decks[active];
if (!deck?.itemId || transition) return;
const currentTime = deck.player.getCurrentTime?.() ?? 0;
socket.send({ type: 'progress', id: deck.itemId, currentTime });
}
// --- queue helpers ------------------------------------------------------
function currentItem() {
return state.items.find((item) => item.id === state.currentId) || state.items[0] || null;
}
function nextItem() {
if (state.items.length === 0) return null;
const index = state.items.findIndex((item) => item.id === decks[active]?.itemId);
if (index === -1) return currentItem();
return state.items[(index + 1) % state.items.length];
}
// --- duration probe -----------------------------------------------------
// Cues (never plays) each unmeasured video in a hidden muted player purely to
// read getDuration(), which is the only keyless way to learn a video's length.
let probe = null;
let probing = null;
async function initProbe() {
await loadApi();
probe = await createPlayer('probe-player', { onStateChange: onProbeState });
probe.mute();
probe.setVolume(0);
pumpProbe();
}
function onProbeState(code) {
if (!probing || (code !== STATE.CUED && code !== STATE.PLAYING)) return;
const seconds = probe.getDuration();
if (seconds > 0) {
socket.send({ type: 'duration', id: probing, seconds });
probing = null;
pumpProbe();
}
}
function pumpProbe() {
if (!probe || probing) return;
const pending = state.items.find((item) => item.duration === null);
if (!pending) return;
probing = pending.id;
probe.cueVideoById(pending.videoId);
// If the video is unavailable the CUED event never carries a length; move on
// rather than stalling every later item behind it.
const stuckOn = pending.id;
setTimeout(() => {
if (probing === stuckOn) {
probing = null;
pumpProbe();
}
}, 10000);
}
initProbe();
// --- socket -------------------------------------------------------------
function onMessage(msg) {
if (msg.type !== 'state') return;
state = msg.state;
applyBackground(state.background);
updateGate();
pumpProbe();
}
function onStatus(status) {
document.getElementById('status').hidden = status === 'online';
}
updateGate();
// Debug helper used when verifying the crossfade without waiting out a track.
window.__player = {
seekToEnd(secondsBefore = OVERLAP_SECONDS + 2) {
const deck = decks[active];
const duration = deck?.player.getDuration?.() ?? 0;
if (duration) deck.player.seekTo(Math.max(0, duration - secondsBefore), true);
},
get state() {
return { state, active, transition, decks: decks.map((d) => d.itemId) };
},
};
+58
View File
@@ -0,0 +1,58 @@
// Thin wrapper around the YouTube IFrame Player API.
let apiReady = null;
/** Load the IFrame API once; resolves when window.YT is usable. */
export function loadApi() {
if (apiReady) return apiReady;
apiReady = new Promise((resolve) => {
if (window.YT?.Player) return resolve(window.YT);
window.onYouTubeIframeAPIReady = () => resolve(window.YT);
const script = document.createElement('script');
script.src = 'https://www.youtube.com/iframe_api';
document.head.appendChild(script);
});
return apiReady;
}
const PLAYER_VARS = {
controls: 0,
disablekb: 1,
modestbranding: 1,
rel: 0,
playsinline: 1,
iv_load_policy: 3,
fs: 0,
cc_load_policy: 0, // no burned-in captions over the visuals
};
/**
* Create a player in the given element id. Resolves once the player is ready.
* `onStateChange` receives the raw YT state code.
*/
export async function createPlayer(elementId, { onStateChange } = {}) {
const YT = await loadApi();
return new Promise((resolve) => {
const player = new YT.Player(elementId, {
width: '100%',
height: '100%',
playerVars: { ...PLAYER_VARS, origin: location.origin },
events: {
onReady: () => resolve(player),
onStateChange: (event) => onStateChange?.(event.data, player),
},
});
});
}
export const STATE = {
UNSTARTED: -1,
ENDED: 0,
PLAYING: 1,
PAUSED: 2,
BUFFERING: 3,
CUED: 5,
};
+48
View File
@@ -0,0 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Party Player — Queue</title>
<link rel="stylesheet" href="/queue/queue.css" />
</head>
<body>
<header>
<h1>Queue</h1>
<div id="status" class="offline">offline</div>
</header>
<form id="add-form" autocomplete="off">
<input
id="url"
type="text"
placeholder="Paste a YouTube URL…"
aria-label="YouTube URL"
required
/>
<button type="submit">Add</button>
</form>
<p id="error" hidden></p>
<div id="totals">
<div class="total">
<span class="label">Remaining</span>
<span class="value" id="remaining">0:00</span>
</div>
<div class="total">
<span class="label">Total</span>
<span class="value" id="total">0:00</span>
</div>
</div>
<ol id="list"></ol>
<p id="empty">Nothing queued yet.</p>
<footer>
<span class="label">Background</span>
<div id="backgrounds"></div>
</footer>
<script type="module" src="/queue/queue.js"></script>
</body>
</html>
+265
View File
@@ -0,0 +1,265 @@
:root {
--accent: #ff4fc3;
--ink: #14060f;
--panel: #1f0c1a;
--panel-2: #2a1124;
--text: #f7e9f3;
--muted: #a8859c;
}
* {
box-sizing: border-box;
}
body {
margin: 0 auto;
padding: 24px 20px 60px;
max-width: 720px;
background: var(--ink);
color: var(--text);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
-webkit-tap-highlight-color: transparent;
}
header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
h1 {
margin: 0;
font-size: 1.8rem;
letter-spacing: -0.02em;
color: var(--accent);
}
#status {
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
}
#status.offline {
color: #ff8a5c;
}
#status.online::before {
content: "";
display: inline-block;
width: 7px;
height: 7px;
margin-right: 6px;
border-radius: 50%;
background: #55e08a;
}
/* --- add form ----------------------------------------------------------- */
#add-form {
display: flex;
gap: 8px;
}
#url {
flex: 1;
min-width: 0;
font: inherit;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid #43213a;
background: var(--panel);
color: var(--text);
}
#url:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
#add-form button {
font: inherit;
font-weight: 700;
padding: 12px 24px;
border: 0;
border-radius: 10px;
background: var(--accent);
color: var(--ink);
cursor: pointer;
}
#error {
margin: 10px 2px 0;
color: #ff8a5c;
font-size: 0.9rem;
}
/* --- totals ------------------------------------------------------------- */
#totals {
display: flex;
gap: 10px;
margin: 22px 0 12px;
}
.total {
flex: 1;
padding: 12px 14px;
border-radius: 10px;
background: var(--panel);
border: 1px solid #37172f;
}
.label {
display: block;
font-size: 0.68rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
}
.value {
display: block;
margin-top: 4px;
font-size: 1.5rem;
font-variant-numeric: tabular-nums;
font-weight: 600;
}
/* --- list --------------------------------------------------------------- */
#list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 10px;
background: var(--panel);
border: 1px solid transparent;
cursor: pointer;
}
.item.current {
background: var(--panel-2);
border-color: var(--accent);
box-shadow: 0 0 0 1px rgba(255, 79, 195, 0.35);
}
.item.dragging {
opacity: 0.35;
}
.item.drop-before {
border-top-color: var(--accent);
box-shadow: 0 -2px 0 var(--accent);
}
.item.drop-after {
border-bottom-color: var(--accent);
box-shadow: 0 2px 0 var(--accent);
}
.handle {
flex: none;
cursor: grab;
color: var(--muted);
padding: 0 2px;
font-size: 1.1rem;
line-height: 1;
user-select: none;
}
.index {
flex: none;
width: 1.6em;
text-align: right;
color: var(--muted);
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
}
.item.current .index {
color: var(--accent);
}
.title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.duration {
flex: none;
color: var(--muted);
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.remove {
flex: none;
border: 0;
background: transparent;
color: var(--muted);
font: inherit;
font-size: 1.2rem;
line-height: 1;
padding: 2px 6px;
border-radius: 6px;
cursor: pointer;
}
.remove:hover {
background: #46203c;
color: #ff8a5c;
}
#empty {
color: var(--muted);
text-align: center;
padding: 30px 0;
}
/* --- footer ------------------------------------------------------------- */
footer {
margin-top: 32px;
padding-top: 18px;
border-top: 1px solid #331630;
}
#backgrounds {
display: flex;
gap: 8px;
margin-top: 8px;
}
#backgrounds button {
font: inherit;
padding: 8px 18px;
border-radius: 999px;
border: 1px solid #43213a;
background: var(--panel);
color: var(--text);
cursor: pointer;
}
#backgrounds button.active {
background: var(--accent);
border-color: var(--accent);
color: var(--ink);
font-weight: 600;
}
+223
View File
@@ -0,0 +1,223 @@
import { connect, formatDuration } from '/shared/socket.js';
import { BACKGROUNDS } from '/config.js';
let state = { items: [], currentId: null, background: 'pink' };
// Last playhead report, kept with its arrival time so the countdown can be
// extrapolated smoothly between the 1 Hz updates.
let progress = { id: null, currentTime: 0, at: 0 };
const list = document.getElementById('list');
const empty = document.getElementById('empty');
const errorBox = document.getElementById('error');
const statusBox = document.getElementById('status');
const backgroundBox = document.getElementById('backgrounds');
const socket = connect({ onMessage, onStatus });
// --- adding -------------------------------------------------------------
document.getElementById('add-form').addEventListener('submit', (event) => {
event.preventDefault();
const input = document.getElementById('url');
const url = input.value.trim();
if (!url) return;
showError(null);
socket.send({ type: 'add', url });
input.value = '';
input.focus();
});
function showError(message) {
errorBox.textContent = message || '';
errorBox.hidden = !message;
}
// --- rendering ----------------------------------------------------------
function render() {
list.replaceChildren(...state.items.map(renderItem));
empty.hidden = state.items.length > 0;
renderTotals();
}
function renderItem(item, index) {
const li = document.createElement('li');
li.className = 'item';
li.dataset.id = item.id;
li.draggable = true;
if (item.id === state.currentId) li.classList.add('current');
const handle = document.createElement('span');
handle.className = 'handle';
handle.textContent = '⠿';
handle.setAttribute('aria-hidden', 'true');
const position = document.createElement('span');
position.className = 'index';
position.textContent = item.id === state.currentId ? '▶' : String(index + 1);
const title = document.createElement('span');
title.className = 'title';
title.textContent = item.title;
title.title = item.title;
const duration = document.createElement('span');
duration.className = 'duration';
duration.textContent = item.duration === null ? '–:––' : formatDuration(item.duration);
const remove = document.createElement('button');
remove.className = 'remove';
remove.type = 'button';
remove.textContent = '×';
remove.title = 'Remove';
remove.addEventListener('click', (event) => {
event.stopPropagation();
socket.send({ type: 'remove', id: item.id });
});
li.append(handle, position, title, duration, remove);
li.addEventListener('click', () => socket.send({ type: 'jump', id: item.id }));
attachDrag(li);
return li;
}
function renderTotals() {
let total = 0;
let remaining = 0;
let unknown = false;
let passedCurrent = false;
const currentIndex = state.items.findIndex((item) => item.id === state.currentId);
state.items.forEach((item, index) => {
if (item.duration === null) {
unknown = true;
return;
}
total += item.duration;
if (currentIndex === -1) return;
if (index === currentIndex) {
remaining += Math.max(0, item.duration - playheadFor(item));
passedCurrent = true;
} else if (index > currentIndex) {
remaining += item.duration;
}
});
if (currentIndex === -1) remaining = total;
else if (!passedCurrent) unknown = true;
const prefix = unknown ? '≥ ' : '';
document.getElementById('total').textContent = prefix + formatDuration(total);
document.getElementById('remaining').textContent = prefix + formatDuration(remaining);
}
/** Playhead of the current item, extrapolated since the last report. */
function playheadFor(item) {
if (progress.id !== item.id) return 0;
const elapsed = (Date.now() - progress.at) / 1000;
return Math.min(progress.currentTime + elapsed, item.duration ?? Infinity);
}
// Keeps the countdown ticking between server messages.
setInterval(renderTotals, 1000);
// --- drag to reorder ----------------------------------------------------
let draggingId = null;
function attachDrag(li) {
li.addEventListener('dragstart', (event) => {
draggingId = li.dataset.id;
li.classList.add('dragging');
event.dataTransfer.effectAllowed = 'move';
// Firefox refuses to start a drag without payload.
event.dataTransfer.setData('text/plain', li.dataset.id);
});
li.addEventListener('dragend', () => {
draggingId = null;
li.classList.remove('dragging');
clearDropMarkers();
});
li.addEventListener('dragover', (event) => {
if (!draggingId || li.dataset.id === draggingId) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
const box = li.getBoundingClientRect();
const after = event.clientY > box.top + box.height / 2;
clearDropMarkers();
li.classList.add(after ? 'drop-after' : 'drop-before');
});
li.addEventListener('dragleave', () => {
li.classList.remove('drop-before', 'drop-after');
});
li.addEventListener('drop', (event) => {
if (!draggingId || li.dataset.id === draggingId) return;
event.preventDefault();
const box = li.getBoundingClientRect();
const after = event.clientY > box.top + box.height / 2;
const ids = state.items.map((item) => item.id).filter((id) => id !== draggingId);
const target = ids.indexOf(li.dataset.id);
ids.splice(after ? target + 1 : target, 0, draggingId);
clearDropMarkers();
// Apply locally so the list does not flicker back before the echo lands.
const byId = new Map(state.items.map((item) => [item.id, item]));
state.items = ids.map((id) => byId.get(id));
render();
socket.send({ type: 'reorder', ids });
});
}
function clearDropMarkers() {
for (const el of list.querySelectorAll('.drop-before, .drop-after')) {
el.classList.remove('drop-before', 'drop-after');
}
}
// --- background switcher ------------------------------------------------
const backgroundButtons = new Map();
for (const [key, { label }] of Object.entries(BACKGROUNDS)) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = label;
button.addEventListener('click', () => socket.send({ type: 'setBackground', key }));
backgroundBox.appendChild(button);
backgroundButtons.set(key, button);
}
function renderBackground() {
for (const [key, button] of backgroundButtons) {
button.classList.toggle('active', key === state.background);
}
}
// --- socket -------------------------------------------------------------
function onMessage(msg) {
if (msg.type === 'state') {
state = msg.state;
render();
renderBackground();
} else if (msg.type === 'progress') {
progress = { id: msg.id, currentTime: msg.currentTime, at: Date.now() };
renderTotals();
} else if (msg.type === 'error') {
showError(msg.message);
}
}
function onStatus(status) {
statusBox.textContent = status;
statusBox.className = status;
}
+59
View File
@@ -0,0 +1,59 @@
// Reconnecting WebSocket shared by both frontends. A party laptop that sleeps,
// or a server restart, must not require reloading the tabs.
export function connect({ onMessage, onStatus } = {}) {
let ws = null;
let retry = 500;
const outbox = [];
function open() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}`);
ws.addEventListener('open', () => {
retry = 500;
onStatus?.('online');
while (outbox.length) ws.send(outbox.shift());
});
ws.addEventListener('message', (event) => {
let msg;
try {
msg = JSON.parse(event.data);
} catch {
return;
}
onMessage?.(msg);
});
ws.addEventListener('close', () => {
onStatus?.('offline');
setTimeout(open, retry);
retry = Math.min(retry * 2, 5000);
});
ws.addEventListener('error', () => ws.close());
}
open();
return {
send(message) {
const payload = JSON.stringify(message);
if (ws && ws.readyState === WebSocket.OPEN) ws.send(payload);
else outbox.push(payload);
},
};
}
/** Seconds → H:MM:SS (or M:SS below an hour). */
export function formatDuration(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '?';
const total = Math.round(seconds);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
return h > 0
? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
: `${m}:${String(s).padStart(2, '0')}`;
}
+136
View File
@@ -0,0 +1,136 @@
// Express serves the two frontends; a single WebSocket keeps them in sync.
import { createServer } from 'node:http';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import express from 'express';
import { WebSocketServer } from 'ws';
import { PORT, OVERLAP_SECONDS, HARD_CUT_SECONDS, FRAME_SCALE, BOUNCE_SPEED, BACKGROUNDS } from '../config.js';
import { parseVideoId, fetchTitle } from './youtube.js';
import * as store from './state.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
store.load();
const app = express();
// Declared before the static middleware, which would otherwise redirect
// /queue to /queue/ on its way to the directory index.
app.get('/', (_req, res) => res.sendFile(join(ROOT, 'public/player/index.html')));
app.get('/queue', (_req, res) => res.sendFile(join(ROOT, 'public/queue/index.html')));
app.use('/assets', express.static(join(ROOT, 'assets'), { maxAge: '1h' }));
app.use(express.static(join(ROOT, 'public')));
// Client-side mirror of the shared config, so the constants live in one place.
app.get('/config.js', (_req, res) => {
res.type('application/javascript').send(
`export const OVERLAP_SECONDS = ${OVERLAP_SECONDS};\n` +
`export const HARD_CUT_SECONDS = ${HARD_CUT_SECONDS};\n` +
`export const FRAME_SCALE = ${FRAME_SCALE};\n` +
`export const BOUNCE_SPEED = ${BOUNCE_SPEED};\n` +
`export const BACKGROUNDS = ${JSON.stringify(BACKGROUNDS)};\n`,
);
});
const server = createServer(app);
const wss = new WebSocketServer({ server });
function broadcast(message) {
const payload = JSON.stringify(message);
for (const client of wss.clients) {
if (client.readyState === client.OPEN) client.send(payload);
}
}
function broadcastState() {
broadcast({ type: 'state', state: store.getState() });
}
/** Persist and push in one go — every structural change ends with this. */
function commit() {
store.save();
broadcastState();
}
const handlers = {
async add(_ws, msg) {
const videoId = parseVideoId(msg.url);
if (!videoId) throw new Error('Not a YouTube video URL');
const title = await fetchTitle(videoId);
store.addItem({ videoId, title });
commit();
},
remove(_ws, msg) {
if (store.removeItem(msg.id)) commit();
},
reorder(_ws, msg) {
if (!Array.isArray(msg.ids)) return;
store.reorder(msg.ids);
commit();
},
jump(_ws, msg) {
if (store.indexOf(msg.id) === -1) return;
store.getState().currentId = msg.id;
commit();
},
duration(_ws, msg) {
if (store.setDuration(msg.id, msg.seconds)) commit();
},
// The player finished its crossfade and wants the pointer moved along.
trackEnded(_ws, msg) {
const state = store.getState();
// Ignore stale reports from a track we already moved past.
if (msg.id !== state.currentId) return;
const next = store.nextItem(msg.id);
state.currentId = next ? next.id : null;
commit();
},
setBackground(_ws, msg) {
if (!(msg.key in BACKGROUNDS)) return;
store.getState().background = msg.key;
commit();
},
// Relayed straight through: this arrives once a second and must never
// touch the disk.
progress(_ws, msg) {
broadcast({ type: 'progress', id: msg.id, currentTime: msg.currentTime });
},
};
wss.on('connection', (ws) => {
ws.send(JSON.stringify({ type: 'state', state: store.getState() }));
ws.on('message', async (raw) => {
let msg;
try {
msg = JSON.parse(raw);
} catch {
return;
}
const handler = handlers[msg.type];
if (!handler) return;
try {
await handler(ws, msg);
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: err.message, forType: msg.type }));
}
});
});
server.listen(PORT, () => {
console.log(`\n Player : http://localhost:${PORT}/`);
console.log(` Queue : http://localhost:${PORT}/queue\n`);
});
+123
View File
@@ -0,0 +1,123 @@
// The single source of truth for the queue, plus JSON persistence so an
// accidental restart mid-party does not wipe the playlist.
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomUUID } from 'node:crypto';
import { DEFAULT_BACKGROUND, BACKGROUNDS } from '../config.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const STORE_PATH = join(ROOT, 'queue.json');
const state = {
items: [],
currentId: null,
background: DEFAULT_BACKGROUND,
};
export function getState() {
return state;
}
export function load() {
try {
const saved = JSON.parse(readFileSync(STORE_PATH, 'utf8'));
if (Array.isArray(saved.items)) {
state.items = saved.items
.filter((item) => item && item.videoId)
.map((item) => ({
id: item.id || randomUUID(),
videoId: item.videoId,
title: item.title || item.videoId,
duration: Number.isFinite(item.duration) ? item.duration : null,
}));
}
if (saved.background in BACKGROUNDS) state.background = saved.background;
// Resume at the item that was playing, otherwise at the top of the list.
state.currentId = state.items.some((i) => i.id === saved.currentId)
? saved.currentId
: state.items[0]?.id ?? null;
console.log(`[state] restored ${state.items.length} item(s) from queue.json`);
} catch (err) {
if (err.code !== 'ENOENT') console.warn(`[state] could not read queue.json: ${err.message}`);
}
}
/**
* Write through a temp file so a crash mid-write cannot leave a truncated
* queue.json behind. Called only on structural changes — never on progress
* ticks, which arrive once a second all night.
*/
export function save() {
const payload = JSON.stringify(
{ items: state.items, currentId: state.currentId, background: state.background },
null,
2,
);
try {
const tmp = `${STORE_PATH}.tmp`;
writeFileSync(tmp, payload);
renameSync(tmp, STORE_PATH);
} catch (err) {
console.warn(`[state] could not write queue.json: ${err.message}`);
}
}
export function indexOf(id) {
return state.items.findIndex((item) => item.id === id);
}
export function addItem({ videoId, title }) {
const item = { id: randomUUID(), videoId, title, duration: null };
state.items.push(item);
if (!state.currentId) state.currentId = item.id;
return item;
}
/**
* Remove an item. When the currently playing one goes, the current pointer
* moves to whatever slid into its place (or the new last item), so the player
* has somewhere to fade to.
*/
export function removeItem(id) {
const index = indexOf(id);
if (index === -1) return false;
const wasCurrent = state.currentId === id;
state.items.splice(index, 1);
if (wasCurrent) {
const next = state.items[index] || state.items[state.items.length - 1] || null;
state.currentId = next ? next.id : null;
}
return true;
}
/** Reorder to match the given id order, ignoring ids that are no longer present. */
export function reorder(ids) {
const byId = new Map(state.items.map((item) => [item.id, item]));
const reordered = ids.map((id) => byId.get(id)).filter(Boolean);
// Anything the client did not know about keeps its place at the end.
for (const item of state.items) {
if (!reordered.includes(item)) reordered.push(item);
}
state.items = reordered;
}
export function setDuration(id, seconds) {
const item = state.items[indexOf(id)];
if (!item || !Number.isFinite(seconds) || seconds <= 0) return false;
if (item.duration === seconds) return false;
item.duration = seconds;
return true;
}
/** The item after `id`, wrapping around so the party never stops. */
export function nextItem(id) {
if (state.items.length === 0) return null;
const index = indexOf(id);
if (index === -1) return state.items[0];
return state.items[(index + 1) % state.items.length];
}
+63
View File
@@ -0,0 +1,63 @@
// Keyless YouTube metadata: id parsing plus title lookup via the public oEmbed
// endpoint. Durations are not available here — they come from the probe player
// in the player frontend and arrive over the socket later.
const ID_PATTERN = /^[A-Za-z0-9_-]{11}$/;
/**
* Pull the 11-character video id out of anything a user might paste.
* Returns null when the input is not a recognisable YouTube video.
*/
export function parseVideoId(input) {
const raw = String(input || '').trim();
if (!raw) return null;
// A bare id pasted on its own.
if (ID_PATTERN.test(raw)) return raw;
let url;
try {
url = new URL(raw.includes('://') ? raw : `https://${raw}`);
} catch {
return null;
}
const host = url.hostname.replace(/^www\./, '');
const segments = url.pathname.split('/').filter(Boolean);
if (host === 'youtu.be') {
return ID_PATTERN.test(segments[0]) ? segments[0] : null;
}
if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'music.youtube.com' || host === 'youtube-nocookie.com') {
const v = url.searchParams.get('v');
if (v && ID_PATTERN.test(v)) return v;
// /shorts/ID, /embed/ID, /live/ID, /v/ID
if (['shorts', 'embed', 'live', 'v'].includes(segments[0])) {
return ID_PATTERN.test(segments[1]) ? segments[1] : null;
}
}
return null;
}
/**
* Fetch the video title. Falls back to the id so a failed lookup still leaves
* a usable queue entry rather than blocking the add.
*/
export async function fetchTitle(videoId) {
const endpoint = `https://www.youtube.com/oembed?url=${encodeURIComponent(
`https://www.youtube.com/watch?v=${videoId}`,
)}&format=json`;
try {
const res = await fetch(endpoint, { signal: AbortSignal.timeout(8000) });
if (!res.ok) throw new Error(`oembed ${res.status}`);
const data = await res.json();
return data.title || videoId;
} catch (err) {
console.warn(`[youtube] title lookup failed for ${videoId}: ${err.message}`);
return videoId;
}
}