Compare commits

...

13 Commits

Author SHA1 Message Date
Jonathan Sykes
49ba470740 Keep the Glass Stage notes toolbar on one row at every player width 2026-10-03 21:58:03 +08:00
Jonathan Sykes
729e73e131 Verify iPhone Glass tab alignment in WebKit with badges and safe-area spacing 2026-10-03 21:47:45 +08:00
Jonathan Sykes
3c0adb2577 Keep Glass Stage section navigation and panel content clear of playback chrome 2026-10-03 21:45:34 +08:00
Jonathan Sykes
d3f5133572 Record direct transfer coverage, fallback paths and verification results 2026-10-03 21:36:10 +08:00
Jonathan Sykes
90ef21297a Keep resume preparation and final verification outside connection timeouts 2026-10-03 20:49:08 +08:00
Jonathan Sykes
d78b06ff4b Close unconfirmed transfer paths and reject binary signalling envelopes 2026-10-03 20:45:04 +08:00
Jonathan Sykes
5661eea6e6 Preview compatible fragmented MP4 transfers while saving the verified copy 2026-10-03 20:43:03 +08:00
Jonathan Sykes
795a5aaed4 Prefer direct copies for playback, saves and exports within verified device groups 2026-10-03 20:38:51 +08:00
Jonathan Sykes
08ff8398b5 Transfer saved songs directly between paired devices with resumable OPFS writes 2026-10-03 20:32:45 +08:00
Jonathan Sykes
88e2e67eeb Authorize direct file invitations within paired rooms and parties 2026-10-03 20:26:07 +08:00
Jonathan Sykes
ef70da1806 Document device media paths and direct transfer requirements 2026-10-03 20:24:10 +08:00
Jonathan Sykes
d93fc0d70f Use Glass Stage icons for playlist and offline controls 2026-10-03 20:14:19 +08:00
Jonathan Sykes
79a3c28c8e Center Glass Stage tab icons above their labels 2026-10-03 20:11:10 +08:00
33 changed files with 1191 additions and 28 deletions

View File

@@ -0,0 +1,67 @@
# Glass Stage iPhone layout follow-up
The supplied production screenshots came from build `16c08b099a31`, before the
Task A/B changes. This follow-up preserves the Glass Stage colours, glass tiles,
stroke icons and all existing control ids/actions. Every CSS rule is scoped to
`html[data-layout="glass-stage"]`; Classic retains its original rail and labels.
## Panel shortcuts and bottom chrome
The Glass section rail was a fixed horizontal overlay positioned above the mini
player. Clearing the player did not clear a panel heading or filter underneath.
`glass-panel-layout.js` moves that same rail, with its existing listeners, into
`#listPane` before the heading. It restores the original location when leaving
Glass Stage. The rail now occupies its own row instead of covering content.
The module measures the visible fixed bottom navigation and mini player. The
navigation's measured height already contains the home-indicator safe area;
the mini player sits eight pixels above that height. Scroll clearance includes
both bars and a 16 px gap, without counting the safe area twice. Portrait uses
one shared scroller and reserves that clearance once at its tail; desktop panes
reserve it individually. The mini player's outer card clips the progress strip
inside its rounded border, rather than leaving a separate line below it.
## Bottom navigation
The uploaded rightward icon shift is reproduced by the pre-Task-A CSS from
`f2c9459`: installed-PWA padding of `4px 16px` remained on a fixed-width icon box.
Task A (`79a3c28`) already resets that padding to zero. The new regression test
runs an iPhone 13 browser profile at 390 px, including 34 px bottom safe-area
allowance, Queue/Downloads badges and an active pill. In both Chromium and
WebKit, SVG and icon-box centres match label centres within 0.6 CSS px. The old
CSS shifts every icon more than eight CSS px. No further nav CSS change was
needed; this is committed verification of the existing fix.
## Notes toolbar
The toolbar's Glass override explicitly enabled `flex-wrap:wrap`. It now keeps
one row. Container queries respond to the player card's available width, even
when a desktop player column is narrow. Spacing tightens first, the longer
Chapters & bookmarks label becomes icon/count at narrow card widths, and all
labels become icon-only on the smallest cards. Full accessible names and title
hints remain. Every action retains a minimum 44 px target, and the fold chevron
stays at the row's right end. Status text truncates within the remaining space.
Glass label wrappers and added names restore exactly when switching to Classic.
## Verification
- 125 frontend unit tests pass, including measured chrome-clearance logic.
- `node --check frontend/app.js` and `bun build --no-bundle server/server.js` pass.
- Chromium and WebKit: all five panel shortcuts at 390 and 1440 px; final list
row clears playback chrome; progress strip stays inside its card.
- Classic geometry and before/after screenshot comparisons pass in both browsers
at 390 and 1440 px, within the 32-pixel isolated rasterization tolerance.
- iPhone-profile navigation alignment/badges pass in both engines at 390 px.
- Toolbar stays one row at 320, 360, 390, 430 and 1440 px in both engines, including
long status text and chapter counts; theme round-trip restoration passes.
- Batched visual review of six WebKit captures found no overlap, alignment,
clipping or wrapping defects in the requested areas.
Run `npx playwright test -c playwright.glass-panel.config.js` for the 22 browser
cases. WebKit was installed locally for these checks; these are browser/iPhone
profile tests, not tests on physical iPhone hardware. Review the installed PWA
on an actual iPhone after the reviewer deploys, particularly when the mini
player appears/disappears and after rotating the phone.
Before/after captures are saved under
`/home/josh/deliverables/ytplayer-done9/`. No push or deployment was performed.

153
docs/p2p-transfer.md Normal file
View File

@@ -0,0 +1,153 @@
# Direct device media transfers
## Existing paths (audit before implementation)
| Path | Existing media-byte route | Relevant code |
| --- | --- | --- |
| Paired remote | Controls only; the host plays its own source. No existing file-send action. | `frontend/app.js` Remote; `server/remote.js` |
| Presenter and OBS | Lyrics, timing and metadata only; neither currently plays host media. | Presenter and overlay modules; remote relay |
| Watch Party playback | Each guest loads independently through normal server/YouTube playback, unless already saved locally. | Party `applyState`, `Player.loadVideo`; `server/party.js` |
| Watch Party voice | Direct WebRTC audio; server forwards SDP/ICE only. | Party voice mesh, party `rtc` relay |
| On other devices / failed playback | Existing verified-file DataChannel download, 64 KiB frames, SHA-256 validation, worker writes OPFS. Only offered after server failure in playback. | `p2p-transfer.js`, `p2p-recv-worker.js`, `getFromPeers` |
| Save / preload | Server download first, then OPFS. | `preload`, `API.cacheDownload`, download endpoints |
| Export to Files / Photos | Local OPFS first, server URL next, peer fallback last. Native share is an OS export, not paired-device transfer. | `exportToDevice`, `export.js` |
| Profile / playlist share and send | Metadata only. Sending a playlist does not transfer its media files. | Profile/playlist sync APIs |
| Verify & share | Explicit device upload to server intake; hash and media validation admit it into the server cache. | `P2PClient.contribute`, `p2p-intake.js` |
| Automatic rehydration | Server requests a holder to upload its full file to intake. This is an implicit media-byte server path. | `createRehydrator`, client `upload-request` handler |
| Admin uploads / original YouTube downloads | Intentional server ingestion of original media; separate from direct device transfer. | Upload and media-cache endpoints |
| VLC / external player URLs | HTTP server media URLs; these players cannot consume this application's DataChannel. | External player actions |
The current file protocol has no resume: its receiver truncates partial files and
removes them on failure. Connection timeout is 20 seconds per holder. Device
signalling authenticates a device, but does not constrain its destination to a
verified profile or paired room. Profile names supplied at device registration
are not proof of profile membership. Existing server-verified holdings also
exclude files whose bytes the server has never admitted.
## Implementation design
Reuse ordered RTCDataChannel transfers with public STUN only. The WebSocket
server carries bounded, validated signalling and metadata, never file chunks.
Pair authorization must come from an authenticated remote/party room or a
verified profile session, not an arbitrary client-supplied profile name.
A receiver explicitly accepts an invitation before saving. A cryptographically
random, expiring, one-use invitation binds sender, receiver and file identity;
subsequent signalling remains bound to that pair. Reject replay, mismatched
identities, binary WebSocket messages and unrecognized protocol fields.
Send 64 KiB chunks with bufferedAmount backpressure. Persist a partial OPFS file
and resume at its exact committed byte offset. Rehash the retained prefix before
continuing and verify total size and SHA-256 before promoting the file into the
saved store; only then record metadata. A changed hash/size cannot reuse a partial.
Use a 10-second connection deadline and an explicit, clearly labelled server
fallback offer. Direct mode must not silently rehydrate server media from a
device. Register “Direct device transfer (P2P)” in Settings, default on.
Party playback should request a host's saved file when announced, then play the
receiver's OPFS copy. Remote controls and presenter lyrics retain their existing
behaviour; add explicit receive/play actions rather than automatically starting
media in a control-only screen. A manual send action targets authenticated peers.
Ordinary faststart MP4 cannot safely be appended as arbitrary MediaSource chunks.
Progressive playback requires supported fragmented media and codec detection;
otherwise use the completed, verified file (including iOS). Do not claim that
raw MP4 chunks provide progressive playback.
## Verification required before release
Pure tests cover protocol parsing, chunk bounds, offsets, invitation replay and
pair authorization. A two-page localhost browser test must transfer real bytes
into OPFS through WebRTC and assert that server HTTP/WS logs contain only
signalling and metadata. Exercise interruption/resume and explicit fallback.
Review real iPhone foreground/background behaviour and restrictive NAT failure.
## Implemented behaviour
- `direct-protocol.js` strips unexpected fields and validates file claims and
bounded signalling; `direct-relay.js` binds invitations to authenticated room
members. Accepting an invitation is one-use, and only the bound pair can signal.
- Remote, presenter and party relays carry the new protocol. Presenter and OBS
still display lyrics/control metadata; they do not automatically play media.
A presenter can explicitly accept a file sent by its paired host.
- Settings → Direct device transfer provides the default-on toggle and send
buttons for currently connected paired/party/profile peers. Send the currently
playing **saved** song; the receiving device confirms every copy.
- The paired remote has “Play here from paired screen” when its host has saved
media. Party guests and ordinary playback resolve an advertised direct source
before loading media. Saves and exports do the same; the existing OS export
sheet is preserved.
- `/ws/p2p` now has a profile-scoped, ephemeral file-id directory. Protected
profiles require password/key proof, checked against the server profile hash.
Unprotected profiles retain their existing name-based access policy. Claims
are **not** admitted as server-verified media. Each device advertises at most
1,000 local ids per directory update; use paired-room sending for larger libraries.
- The legacy unconfirmed global signalling path is refused. Older clients need
to reload the updated application to transfer files. This closes a bypass of
receiver confirmation and room/profile membership.
- `direct-media.js` uses public STUN, ordered 64 KiB DataChannel chunks and
bufferedAmount backpressure. `direct-recv-worker.js` writes partials directly
to OPFS, rehashes their retained prefix and verifies exact size plus SHA-256
before promotion. File extensions are preserved. A disconnected partial can
be resumed by sending/requesting the same file again, including non-aligned offsets.
- The 10-second deadline covers connection establishment. Prefix rehashing and
final verification do not consume it. Idle transfer timeout is 30 seconds.
Failures retain partial data and offer an explicit **Use server copy** action.
Automatic holder-to-server rehydration is blocked while direct mode is on;
**Verify & share** remains an intentional, explicit server upload.
- `direct-stream.js` supports progressive preview for compatible fragmented
H.264 MP4 through MediaSource. Ordinary faststart MP4, unsupported codecs,
unavailable MediaSource and preview buffer exhaustion use the complete file.
iPhone playback should be reviewed using that completed-file path. A preview
is interactive and independent of the main player's host clock; main party
playback follows the host after the verified copy is ready.
## Paths that still use server media
Original YouTube acquisition, server uploads, intentional Verify & share intake,
external VLC/M3U HTTP URLs and explicitly accepted server fallbacks still use the
server. If no connected device advertises a local copy, normal original-source
playback/download is preserved; this is not a transfer of another device's file.
Profile/playlist shares, chapters, lyrics, presenter state and OBS state remain
metadata/control only. Direct discovery requires an online paired room or an
existing profile, and the browser must remain available to serve its OPFS file.
No TURN service, deployment change or secret configuration is required.
## Checks and reviewer follow-up
Run:
```sh
node --test frontend/*.test.js
node --check frontend/app.js
bun build --no-bundle server/server.js
bun test server/direct-relay.test.js server/p2p-hub.test.js server/remote.test.js server/party.test.js
npx playwright test -c playwright.direct.config.js
npx playwright test -c playwright.glass-navigation.config.js
```
The two-page localhost test uses the production relay and transport, separate
browser stores, a real RTCDataChannel and actual OPFS writes. It resumes a 2 MB
file at byte 12,345, verifies the resulting SHA-256 and asserts that the server
log contains only invitation/acceptance/signalling/completion. A second case
adds 12 seconds of resume preparation to prove it is outside the 10-second
connection deadline. This is a local transport proof, not proof that arbitrary
NATs will connect.
Check real iPhone foreground/background suspension, large-file quota and final
promotion, iOS completed-file playback, restrictive/symmetric NAT fallback,
paired screen playback, party host changes during a copy, protected-profile
credential changes/reconnect, OS export gestures, and an actual fragmented MP4
MediaSource preview on desktop. Public STUN cannot overcome every NAT; declined
invitations do not start media acquisition. Peer directories update when the
presence socket connects and when local saves complete, rather than promising
availability of offline/backgrounded devices.
Final verification on 2026-10-03: 123 frontend unit tests, 22 touched server
tests (including remote and party), app syntax and server build, 8 Glass/Classic
navigation browser tests at 390/1440 px, and both direct-transfer browser cases
passed. The first navigation run lost its reused localhost server; a fresh-server
rerun passed all eight cases without a code change. Classic screenshot comparisons
remain within the test's rasterization tolerance. No real iPhone or restrictive
NAT test was performed here, and no code was pushed or deployed.

View File

@@ -1795,18 +1795,24 @@ function recordDeviceFile(id, res) {
const state = cid ? (res.expectedSha === cid ? 'verified' : 'unverified') : 'unhashed'; const state = cid ? (res.expectedSha === cid ? 'verified' : 'unverified') : 'unhashed';
const now = Date.now(); const now = Date.now();
window.DeviceDB.putFile({ videoId: id, cid, size: (res && res.size) || 0, savedAt: now, lastCheckedAt: now, state }) window.DeviceDB.putFile({ videoId: id, cid, size: (res && res.size) || 0, savedAt: now, lastCheckedAt: now, state })
.then(() => { if (window.P2PClient) window.P2PClient.changed(); }) .then(() => { if (window.P2PClient) window.P2PClient.changed(); window.DirectMedia?.publish(); })
.catch(() => {}); .catch(() => {});
} }
// Download a video into the permanent offline cache. Safe to call repeatedly. // Download a video into the permanent offline cache. Safe to call repeatedly.
async function preload(video, { quiet = false, mux = false, retry = false } = {}) { async function preload(video, { quiet = false, mux = false, retry = false, directFallback = false } = {}) {
const id = video.id; const id = video.id;
// Custom (edited) videos have no YouTube source to (re)download — their // Custom (edited) videos have no YouTube source to (re)download — their
// media is produced once by the editor. Never route them through the normal // media is produced once by the editor. Never route them through the normal
// cache-download path (a fake edit_… id would 404 on /api/download). // cache-download path (a fake edit_… id would 404 on /api/download).
if (video.custom) return; if (video.custom) return;
if (!id || cachedIds.has(id) || downloading.has(id)) return; if (!id || cachedIds.has(id) || downloading.has(id)) return;
if (!directFallback && window.DirectMedia?.hasSource(id)) {
const received = await DirectMedia.obtain(id);
if (received) return;
toast('Direct transfer did not complete. Choose Use server copy in the transfer message to download through the server.');
return;
}
downloading.add(id); downloading.add(id);
downloadMeta.set(id, slim(video)); downloadMeta.set(id, slim(video));
markCardCacheState(id, 'downloading'); markCardCacheState(id, 'downloading');
@@ -2259,6 +2265,11 @@ const Player = {
_currentBlobUrl = null; _currentBlobUrl = null;
} }
try { try {
if (WEB && !cachedIds.has(videoObj.id) && window.DirectMedia?.hasSource(videoObj.id)) {
const received = await DirectMedia.obtain(videoObj.id);
if (seq !== this._loadSeq) return;
if (!received) { showSpinner(false); return; }
}
// Play from the offline cache when available — instant and works offline. // Play from the offline cache when available — instant and works offline.
if (!preferStream && cachedIds.has(videoObj.id)) { if (!preferStream && cachedIds.has(videoObj.id)) {
let localUrl = null; let localUrl = null;
@@ -3104,6 +3115,8 @@ async function renderAvailability(id) {
// Fetch a verified copy from another device (docs/p2p-architecture.md flow 6). // Fetch a verified copy from another device (docs/p2p-architecture.md flow 6).
// Download-then-play: on success the file is saved offline like any save. // Download-then-play: on success the file is saved offline like any save.
async function getFromPeers(videoObj, c, peers, onProgress) { async function getFromPeers(videoObj, c, peers, onProgress) {
if (window.DirectMedia && !DirectMedia.enabled()) { toast('Direct device transfer is off. Enable it in Settings or use a server copy.'); return false; }
if (window.DirectMedia?.enabled()) { const ok = await DirectMedia.obtain(videoObj.id); if (!ok) toast('Pair the source device through Remote or use the same profile to receive its saved copy.'); return !!ok; }
const id = videoObj.id; const id = videoObj.id;
downloading.add(id); downloading.add(id);
markCardCacheState(id, 'downloading'); markCardCacheState(id, 'downloading');
@@ -4754,6 +4767,8 @@ const Remote = (() => {
const hostSend = (m) => { if (host.ws && host.ws.readyState === 1) host.ws.send(JSON.stringify(m)); }; const hostSend = (m) => { if (host.ws && host.ws.readyState === 1) host.ws.send(JSON.stringify(m)); };
function onHostMessage(m) { function onHostMessage(m) {
DirectMedia.attach('remote-host', { send: hostSend, peers: () => host.remotes.map(r => ({ id: r.rid, name: r.name })) });
if (m.type === 'direct') { DirectMedia.handle('remote-host', m); return; }
if (m.type === 'hello') { if (m.type === 'hello') {
host.code = m.code; host.codeExpires = m.codeExpires; host.remotes = m.remotes || []; host.code = m.code; host.codeExpires = m.codeExpires; host.remotes = m.remotes || [];
host.lastState = ''; host.lastQueue = ''; host.lastState = ''; host.lastQueue = '';
@@ -4779,6 +4794,7 @@ const Remote = (() => {
const meta = current && current.meta; const meta = current && current.meta;
const M = Player.master; const M = Player.master;
return { return {
directAvailable: !!meta && cachedIds.has(meta.id) && DirectMedia.enabled() && data.settings.p2pShare !== false,
v: meta ? { id: meta.id, title: meta.title || '', channel: meta.channel || '', thumbnail: meta.thumbnail || '' } : null, v: meta ? { id: meta.id, title: meta.title || '', channel: meta.channel || '', thumbnail: meta.thumbnail || '' } : null,
cur: M ? Math.floor((M.currentTime || 0) * 10) / 10 : 0, cur: M ? Math.floor((M.currentTime || 0) * 10) / 10 : 0,
dur: (M && isFinite(M.duration) && M.duration) || (meta && meta.duration) || 0, dur: (M && isFinite(M.duration) && M.duration) || (meta && meta.duration) || 0,
@@ -5061,6 +5077,8 @@ const Remote = (() => {
} }
function onRemoteMessage(m) { function onRemoteMessage(m) {
DirectMedia.attach('remote-client', { send: rcSend, peers: () => [{ id: 'host', name: 'Paired screen', files: rc.state?.directAvailable && rc.state?.v ? [rc.state.v.id] : [] }] });
if (m.type === 'direct') { DirectMedia.handle('remote-client', m); return; }
if (m.type === 'hello') { if (m.type === 'hello') {
rc.online = true; rc.online = true;
$('rvHostName').textContent = m.hostName || 'Screen'; $('rvHostName').textContent = m.hostName || 'Screen';
@@ -5099,6 +5117,11 @@ const Remote = (() => {
const v = s && s.v; const v = s && s.v;
$('rvTitle').textContent = v ? v.title : 'Nothing playing'; $('rvTitle').textContent = v ? v.title : 'Nothing playing';
$('rvChannel').textContent = v ? v.channel : ''; $('rvChannel').textContent = v ? v.channel : '';
let directPlay = $('rvDirectPlay');
if (!directPlay) { directPlay = document.createElement('button'); directPlay.id = 'rvDirectPlay'; directPlay.className = 'np-btn'; directPlay.style.minHeight = '44px'; directPlay.textContent = 'Play here from paired screen'; $('rvChannel').after(directPlay); }
directPlay.hidden = !v || !s.directAvailable || !DirectMedia.enabled();
directPlay.onclick = () => v && playVideoAt({ ...v, duration: s.dur || 0 }, liveCur());
const thumb = $('rvThumb'); const thumb = $('rvThumb');
if (v && v.thumbnail) { if (thumb.getAttribute('src') !== v.thumbnail) thumb.src = v.thumbnail; thumb.classList.remove('hidden'); } if (v && v.thumbnail) { if (thumb.getAttribute('src') !== v.thumbnail) thumb.src = v.thumbnail; thumb.classList.remove('hidden'); }
else thumb.classList.add('hidden'); else thumb.classList.add('hidden');
@@ -6069,6 +6092,8 @@ const Presenter = (() => {
st.ws = ws; st.ws = ws;
ws.onmessage = (e) => { ws.onmessage = (e) => {
let m; try { m = JSON.parse(e.data); } catch { return; } let m; try { m = JSON.parse(e.data); } catch { return; }
if (!overlay) { DirectMedia.attach('presenter', { send: m => ws.send(JSON.stringify(m)), peers: () => [{ id: 'host', name: st.hostName || 'Screen' }] }); }
if (m.type === 'direct') { if (!overlay) DirectMedia.handle('presenter', m); return; }
if (m.type === 'hello') { st.hostName = m.hostName || 'screen'; if (m.state) onState(m.state); else setStatus(`Following ${st.hostName}`); } if (m.type === 'hello') { st.hostName = m.hostName || 'screen'; if (m.state) onState(m.state); else setStatus(`Following ${st.hostName}`); }
else if (m.type === 'state') onState(m.state); else if (m.type === 'state') onState(m.state);
else if (m.type === 'host-offline') { if (overlay) { st.all = []; LowerThird.paint([], -1); } setStatus(`${st.hostName || 'The screen'} is offline — waiting…`); } else if (m.type === 'host-offline') { if (overlay) { st.all = []; LowerThird.paint([], -1); } setStatus(`${st.hostName || 'The screen'} is offline — waiting…`); }
@@ -7452,6 +7477,8 @@ const Party = (() => {
} }
function onMessage(m) { function onMessage(m) {
DirectMedia.attach('party', { send, peers: () => st.members.filter(m => m.pid !== pid).map(m => ({ id: m.pid, name: m.name, files: m.host && st.state?.directAvailable && st.state?.v ? [st.state.v.id] : [] })) });
if (m.type === 'direct') { DirectMedia.handle('party', m); return; }
if (m.type === 'hello') { if (m.type === 'hello') {
st.code = m.code; st.code = m.code;
if (m.secret) { st.secret = m.secret; ls.set(HOST_KEY, JSON.stringify({ code: m.code, secret: m.secret })); } if (m.secret) { st.secret = m.secret; ls.set(HOST_KEY, JSON.stringify({ code: m.code, secret: m.secret })); }
@@ -7501,6 +7528,7 @@ const Party = (() => {
const meta = current && current.meta; const meta = current && current.meta;
const M = Player.master; const M = Player.master;
return { return {
directAvailable: !!meta && cachedIds.has(meta.id) && DirectMedia.enabled() && data.settings.p2pShare !== false,
v: meta ? { id: meta.id, title: meta.title || '', channel: meta.channel || '', thumbnail: meta.thumbnail || '', duration: meta.duration || 0 } : null, v: meta ? { id: meta.id, title: meta.title || '', channel: meta.channel || '', thumbnail: meta.thumbnail || '', duration: meta.duration || 0 } : null,
cur: M ? M.currentTime || 0 : 0, cur: M ? M.currentTime || 0 : 0,
paused: !M || M.paused, paused: !M || M.paused,
@@ -12527,6 +12555,18 @@ async function boot() {
Party.boot(); Party.boot();
PartyDJ.register(() => $('navPartyBtn').click()); PartyDJ.register(() => $('navPartyBtn').click());
SetlistImport.configure({ library: async () => { let uploads = []; try { const j = await webFetch('/api/uploads?limit=200'); uploads = j.uploads || []; } catch {} return [...data.history, ...data.playlists.flatMap(p => p.videos || []), ...uploads]; }, search: async q => (await API.search(q)).results || [], modal: showModal, close: closeModal, create: (name, videos) => { const pl = { id: uid(), name, videos: videos.map(v => ({ ...slim(v), tags: v.tags, key: v.key, chordPro: v.chordPro })) }; data.playlists.push(pl); persist(); view = { type: 'playlist', id: pl.id }; render(); toast(`Imported ${videos.length} songs`); } }); SetlistImport.configure({ library: async () => { let uploads = []; try { const j = await webFetch('/api/uploads?limit=200'); uploads = j.uploads || []; } catch {} return [...data.history, ...data.playlists.flatMap(p => p.videos || []), ...uploads]; }, search: async q => (await API.search(q)).results || [], modal: showModal, close: closeModal, create: (name, videos) => { const pl = { id: uid(), name, videos: videos.map(v => ({ ...slim(v), tags: v.tags, key: v.key, chordPro: v.chordPro })) }; data.playlists.push(pl); persist(); view = { type: 'playlist', id: pl.id }; render(); toast(`Imported ${videos.length} songs`); } });
DirectMedia.configure({ settings: () => data.settings, persist, current: () => current?.meta, meta: id => data.history.find(v => v.id === id) || data.playlists.flatMap(p => p.videos).find(v => v.id === id), notify: toast,
saved: async file => { cachedIds.add(file.id); await DeviceDB.putFile({ videoId: file.id, cid: file.cid, size: file.size, savedAt: Date.now(), state: 'unverified' }); if (!data.history.some(v => v.id === file.id)) data.history.unshift({ ...file, thumbnail: '' }); persist(); renderSidebar(); },
fallback: file => { const body = document.createElement('p'); body.textContent = 'The devices could not finish a direct connection. Keep both screens open and send again to resume, or choose the existing server download.'; showModal('Direct transfer unavailable', body, [{ label: 'Keep partial copy', onClick: closeModal }, { label: 'Use server copy', onClick: () => { closeModal(); preload(file, { retry: true, directFallback: true }); } }]); },
});
// Resolve peer bytes before opening the existing native export sheet.
// The sheet itself and its user-gesture actions remain unchanged.
const exportWithExistingSheet = exportToDevice;
exportToDevice = async (...args) => {
const video = args[0];
if (video?.id && !cachedIds.has(video.id) && DirectMedia.hasSource(video.id) && !await DirectMedia.obtain(video.id)) return;
return exportWithExistingSheet(...args);
};
Remote.boot(); Remote.boot();
LyricsDisplay.register(); LyricsDisplay.register();
LowerThird.register(() => $('remoteChip').click()); LowerThird.register(() => $('remoteChip').click());
@@ -12561,8 +12601,8 @@ async function boot() {
startCacheResyncWatch(); startCacheResyncWatch();
// Peer-to-peer: report what this device holds (on by default; settings.p2pShare). // Peer-to-peer: report what this device holds (on by default; settings.p2pShare).
if (WEB && window.P2PClient) { if (WEB && window.P2PClient) {
window.P2PClient.start({ getSettings: () => data.settings, getProfile: () => (data.profile && data.profile.name) || '' }); window.P2PClient.start({ getSettings: () => data.settings, getProfile: () => (data.profile && data.profile.name) || '', getProfileSecret: () => ProfileSecret.getFor(data.profile?.name) });
if (window.P2PTransfer) window.P2PTransfer.start({ canShare: () => data.settings.p2pShare !== false }); if (window.P2PTransfer) window.P2PTransfer.start({ canShare: () => false }); // New scoped invitations replace the unconfirmed legacy sender.
} }
data.playlists.forEach(preloadPlaylist); data.playlists.forEach(preloadPlaylist);
preloadPinnedPlaylists(); preloadPinnedPlaylists();

View File

@@ -0,0 +1,6 @@
.direct-transfer-dialog{max-width:min(420px,calc(100vw - 32px));padding:24px;border:1px solid var(--border);border-radius:var(--radius,16px);background:var(--surface,#202026);color:var(--text,#fff)}
.direct-transfer-dialog::backdrop{background:rgba(0,0,0,.65)}
.direct-transfer-dialog p{line-height:1.6;color:var(--text-2);overflow-wrap:anywhere}
.direct-transfer-dialog button,#settings-section-direct-transfer button{min-height:44px;padding:10px 16px;margin:4px;border:1px solid var(--border);border-radius:10px;background:var(--surface-2,#303039);color:var(--text,#fff)}
.direct-transfer-dialog button:focus-visible,#settings-section-direct-transfer button:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
#settings-section-direct-transfer label{display:flex;align-items:center;gap:12px;min-height:44px}

168
frontend/direct-media.js Normal file
View File

@@ -0,0 +1,168 @@
/* Paired-room transport; signalling only goes to the server. */
(function (root) {
'use strict';
const waiting = new Map();
let profilePeers = [];
const rooms = new Map(), transfers = new Map(), pending = new Map();
let hooks = { settings: () => ({}), persist() {}, current: () => null, saved() {}, notify() {}, fallback() {} };
const enabled = () => hooks.settings().directTransfer !== false && !!root.RTCPeerConnection;
function attach(key, room) { rooms.set(key, room); }
function send(key, m) { const r = rooms.get(key); if (!r) throw Error('Paired device disconnected'); r.send(m); }
function signal(key, token, data) { send(key, { type: 'direct', action: 'signal', token, data }); }
async function offer(key, to, meta) {
if (!enabled()) throw Error('Direct device transfer is unavailable or turned off');
if (!meta?.id) throw Error('Choose a saved song first');
if (pending.has(`${key}:${to}:${meta.id}`) || [...transfers.values()].some(s => s.key === key && s.peer === to && s.claim.id === meta.id)) return;
const file = await root.OPFS.getFileObject(meta.id);
if (!file) throw Error('Save this song on this device before sending it');
const hash = root.Sha256.create();
for (let at = 0; at < file.size; at += 1024 * 1024) hash.update(new Uint8Array(await file.slice(at, at + 1024 * 1024).arrayBuffer()));
const claim = root.DirectProtocol.file({ ...meta, cid: hash.hex(), size: file.size, extension: file.name.split('.').pop().toLowerCase(), streamMime: await root.DirectStream?.inspect(file), title: meta.title || meta.id });
if (!claim) throw Error('Unsupported file metadata');
const pendingKey = `${key}:${to}:${claim.id}`;
const item = { file, claim }; pending.set(pendingKey, item);
setTimeout(() => { if (pending.get(pendingKey) === item) pending.delete(pendingKey); }, 60000);
send(key, { type: 'direct', action: 'invite', to, file: claim });
hooks.notify('Waiting for the receiving device to accept');
}
function finish(s, error) {
if (s.done) return; s.done = true; clearTimeout(s.timer); clearTimeout(s.idle);
s.pc?.close(); transfers.delete(s.token);
if (error) s.preview?.close(); else s.preview?.end();
if (s.worker) { s.worker.postMessage({ op: 'abort' }); setTimeout(() => s.worker.terminate(), 1000); }
if (error) { waiting.get(s.claim.id)?.resolve(false); waiting.delete(s.claim.id); hooks.notify(`${error}. Partial copy kept for resume.`); hooks.fallback(s.claim, () => offer(s.key, s.peer, s.claim).catch(e => hooks.notify(e.message))); }
}
function connection(s, receiver) {
clearTimeout(s.timer);
const pc = s.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
s.ice = s.ice || [];
pc.onicecandidate = e => { if (e.candidate) signal(s.key, s.token, { kind: 'ice', candidate: e.candidate.toJSON() }); };
s.timer = setTimeout(() => finish(s, 'Direct connection failed after 10 seconds'), 10000);
pc.onconnectionstatechange = () => { if (['failed', 'closed'].includes(pc.connectionState)) finish(s, 'Direct connection closed'); };
const bump = () => { clearTimeout(s.idle); s.idle = setTimeout(() => finish(s, 'Direct transfer stalled'), 30000); };
const channel = dc => {
s.dc = dc; dc.binaryType = 'arraybuffer'; dc.bufferedAmountLowThreshold = 1024 * 1024;
dc.onopen = () => { clearTimeout(s.timer); bump(); if (receiver && s.offset != null) dc.send(JSON.stringify({ t: 'get', offset: s.offset })); };
dc.onclose = () => { if (!s.done && !s.verifying) setTimeout(() => { if (!s.done && !s.verifying) finish(s, 'Direct channel closed'); }, 3000); };
dc.onmessage = async e => {
bump();
try {
if (receiver) {
if (typeof e.data === 'string') { const m = JSON.parse(e.data); if (m.t === 'end') { s.verifying = true; clearTimeout(s.idle); s.worker.postMessage({ op: 'finish' }); } else throw Error('Unexpected file control'); }
else { s.preview?.push(e.data); s.worker.postMessage({ op: 'chunk', buf: e.data }, [e.data]); }
} else {
if (typeof e.data !== 'string' || s.started) throw Error('Unexpected file request');
const m = JSON.parse(e.data); if (m.t !== 'get') throw Error('Unexpected file request');
let pos = root.DirectProtocol.offset(m.offset, s.file.size); s.started = true;
while (pos < s.file.size && !s.done) {
if (dc.bufferedAmount > 4 * 1024 * 1024) await new Promise((resolve, reject) => {
const timeout = setTimeout(() => { cleanup(); reject(Error('Peer stopped receiving')); }, 10000);
const cleanup = () => { clearTimeout(timeout); dc.removeEventListener('bufferedamountlow', low); dc.removeEventListener('close', closed); };
const low = () => { cleanup(); resolve(); }, closed = () => { cleanup(); reject(Error('Peer disconnected')); };
dc.addEventListener('bufferedamountlow', low); dc.addEventListener('close', closed);
});
const range = root.DirectProtocol.nextChunk(pos, s.file.size);
dc.send(await s.file.slice(range.start, range.end).arrayBuffer()); pos = range.end; bump();
}
if (!s.done) { dc.send(JSON.stringify({ t: 'end' })); clearTimeout(s.idle); s.idle = setTimeout(() => finish(s, 'Receiver did not finish verification'), 30 * 60000); }
}
} catch (err) { finish(s, err.message); }
};
};
if (receiver) channel(pc.createDataChannel('file', { ordered: true })); else pc.ondatachannel = e => channel(e.channel);
return pc;
}
async function accept(key, m) {
const s = { key, token: m.token, claim: m.file, peer: m.from, receiver: true }; transfers.set(m.token, s);
s.preview = m.file.streamMime ? root.DirectStream?.create(m.file.streamMime, m.file.title) : null;
const wait = waiting.get(m.file.id); if (wait) clearTimeout(wait.timer);
send(key, { type: 'direct', action: 'accept', token: m.token });
s.worker = new Worker('/direct-recv-worker.js');
s.worker.onmessage = async e => {
const result = e.data;
if (result.op === 'opened') {
s.offset = result.offset;
connection(s, true);
const offer = await s.pc.createOffer(); await s.pc.setLocalDescription(offer); signal(key, m.token, { kind: 'offer', sdp: offer.sdp });
} else if (result.op === 'done') {
send(key, { type: 'direct', action: 'complete', token: m.token });
await hooks.saved(result.file); waiting.get(result.file.id)?.resolve(true); waiting.delete(result.file.id); publish(); hooks.notify(`Saved ${result.file.title} directly from the other device`); finish(s);
} else if (result.op === 'error') finish(s, result.error);
};
s.worker.postMessage({ op: 'open', file: m.file });
}
async function handle(key, m) {
if (m.type !== 'direct') return;
if (m.action === 'request') { if (enabled() && hooks.settings().p2pShare !== false) offer(key, m.from, hooks.meta?.(m.id) || { id: m.id, title: m.id }).catch(e => hooks.notify(e.message)); return; }
try {
if (m.action === 'invite') {
if (!enabled() || !root.DirectProtocol.file(m.file)) return send(key, { type: 'direct', action: 'decline', token: m.token });
confirmReceive(key, m); return;
}
if (m.action === 'invited') {
const p = pending.get(`${key}:${m.to}:${m.file.id}`); if (!p) return;
pending.delete(`${key}:${m.to}:${m.file.id}`);
const s = { key, token: m.token, peer: m.to, claim: p.claim, file: p.file }; transfers.set(m.token, s); s.timer = setTimeout(() => finish(s), 60000); return;
}
const s = transfers.get(m.token); if (!s || s.key !== key || m.from !== s.peer) return;
if (m.action === 'accept') { s.accepted = true; clearTimeout(s.timer); s.timer = setTimeout(() => finish(s, 'Receiver stopped preparing the file'), 30 * 60000); }
else if (m.action === 'decline' || m.action === 'complete') finish(s);
else if (m.action === 'signal') {
const d = m.data;
if (!s.pc && !s.receiver && s.accepted) { if (d.kind === 'ice') { (s.ice ||= []).push(d.candidate); return; } if (d.kind === 'offer') connection(s, false); }
if (!s.pc) return;
if (d.kind === 'ice') { if (s.pc.remoteDescription) await s.pc.addIceCandidate(d.candidate); else s.ice.push(d.candidate); }
else {
await s.pc.setRemoteDescription({ type: d.kind, sdp: d.sdp });
for (const c of s.ice.splice(0)) await s.pc.addIceCandidate(c);
if (d.kind === 'offer') { const a = await s.pc.createAnswer(); await s.pc.setLocalDescription(a); signal(key, m.token, { kind: 'answer', sdp: a.sdp }); }
}
}
} catch (e) { const s = transfers.get(m.token); if (s) finish(s, e.message); else hooks.notify(e.message); }
}
function confirmReceive(key, m) {
const dialog = document.createElement('dialog'); dialog.className = 'direct-transfer-dialog';
const heading = document.createElement('h2'); heading.textContent = 'Save from your paired device?';
const text = document.createElement('p'); text.textContent = `${m.file.title} · ${(m.file.size / 1024 ** 2).toFixed(1)} MB. Media travels directly between devices. Keep both screens open. Playback starts after the full copy is verified.`;
const yes = document.createElement('button'); yes.textContent = 'Accept & save';
const no = document.createElement('button'); no.textContent = 'Decline';
const decline = () => { waiting.get(m.file.id)?.resolve(false); waiting.delete(m.file.id); send(key, { type: 'direct', action: 'decline', token: m.token }); dialog.remove(); };
yes.onclick = () => { dialog.remove(); accept(key, m).catch(e => hooks.notify(e.message)); }; no.onclick = decline; dialog.oncancel = decline;
dialog.append(heading, text, yes, no); document.body.append(dialog); dialog.showModal();
}
async function publish() {
if (!root.P2PClient?.isConnected()) return;
const files = await root.OPFS.listVideos({ strict: true });
root.P2PClient.send({ type: 'direct-inventory', ids: enabled() && hooks.settings().p2pShare !== false ? files.map(f => f.id) : [] });
}
function source(id) { if (!enabled()) return null; for (const [key, room] of rooms) { const peer = room.peers().find(p => p.files?.includes(id)); if (peer) return { key, peer }; } return null; }
function hasSource(id) { return !!source(id); }
function obtain(id) {
if (!hasSource(id)) return Promise.resolve(null);
if (waiting.has(id)) return waiting.get(id).promise;
const { key, peer } = source(id);
let resolve; const promise = new Promise(r => { resolve = r; });
const wait = { promise, resolve }; waiting.set(id, wait);
send(key, { type: 'direct', action: 'request', to: peer.id, id });
wait.timer = setTimeout(() => { if (waiting.get(id)?.promise === promise) { waiting.delete(id); resolve(false); hooks.notify('The other device did not accept or is unavailable'); hooks.fallback(hooks.meta?.(id) || { id, title: id }); } }, 60000);
return promise;
}
function configure(h) {
hooks = { ...hooks, ...h };
if (root.P2PClient) {
root.P2PClient.onMessage('hello', () => { attach('profile', { send: root.P2PClient.send, peers: () => profilePeers }); publish(); });
root.P2PClient.onMessage('direct-peers', m => { profilePeers = m.peers || []; });
root.P2PClient.onMessage('direct', m => handle('profile', m));
}
root.SettingsSections.register({ id: 'direct-transfer', title: 'Direct device transfer', cluster: 'Library & storage', summary: () => enabled() ? 'On · paired devices only' : 'Off or unsupported', icon: '<path d="M4 6h6v12H4zM14 6h6v12h-6M10 10h4m-4 4h4"/>', render(container) {
const label = document.createElement('label'); const toggle = document.createElement('input'); toggle.type = 'checkbox'; toggle.checked = hooks.settings().directTransfer !== false;
toggle.onchange = () => { hooks.settings().directTransfer = toggle.checked; hooks.persist(); publish(); }; label.append(toggle, ' Direct device transfer (P2P)');
const help = document.createElement('p'); help.textContent = 'Pair through Remote or join a Watch Party, then send your currently playing saved song. The receiver confirms before saving. Keep both devices open. No TURN relay is used.';
container.append(label, help);
for (const [key, r] of rooms) for (const peer of r.peers()) {
const b = document.createElement('button'); b.textContent = `Send saved song to ${peer.name}`; b.style.minHeight = '44px'; b.onclick = () => offer(key, peer.id, hooks.current()).catch(e => hooks.notify(e.message)); container.append(b);
}
} });
}
root.DirectMedia = { configure, attach, handle, offer, enabled, obtain, hasSource, publish };
}(globalThis));

View File

@@ -0,0 +1,41 @@
/* Shared bounded control protocol: media bytes belong exclusively on DataChannel. */
(function (root) {
'use strict';
const CHUNK = 65536;
const text = (v, n) => typeof v === 'string' && v.length <= n && !/[\u0000-\u001f]/.test(v);
const id = (v) => text(v, 128) && /^[A-Za-z0-9_-]+$/.test(v);
function file(v) {
if (!v || !id(v.id) || !/^[a-f0-9]{64}$/.test(v.cid) || !Number.isSafeInteger(v.size) || v.size <= 0 || v.size > 4 * 1024 ** 3) return null;
if (!text(v.title, 300) || !text(v.channel || '', 200)) return null;
const streamMime = typeof v.streamMime === 'string' && /^video\/mp4; codecs="avc1\.[a-f0-9]{6}(, mp4a\.40\.2)?"$/.test(v.streamMime) ? v.streamMime : '';
return { streamMime, extension: /^(mp4|m4a|webm|ogg|opus|mkv|mp3|wav|aac)$/.test(v.extension) ? v.extension : 'mp4', id: v.id, cid: v.cid, size: v.size, title: v.title, channel: v.channel || '', duration: Number.isFinite(v.duration) && v.duration >= 0 ? v.duration : 0 };
}
function parse(value) {
if (typeof value === 'string') { if (value.length > 65536) return null; try { value = JSON.parse(value); } catch { return null; } }
if (!value || typeof value !== 'object' || Array.isArray(value) || value.type !== 'direct') return null;
const m = { type: 'direct', action: value.action };
if (value.action === 'request') return id(value.to) && id(value.id) ? { ...m, to: value.to, id: value.id } : null;
if (value.action === 'invite') {
const f = file(value.file); if (!id(value.to) || !f) return null;
return { ...m, to: value.to, file: f };
}
if (!['accept', 'decline', 'signal', 'complete'].includes(value.action) || !id(value.token)) return null;
m.token = value.token;
if (value.action === 'signal') {
const d = value.data;
if (!d || typeof d !== 'object') return null;
if (['offer', 'answer'].includes(d.kind) && typeof d.sdp === 'string' && d.sdp.length <= 60000) m.data = { kind: d.kind, sdp: d.sdp };
else if (d.kind === 'ice' && d.candidate && text(d.candidate.candidate, 2048)) {
const c = d.candidate;
if (!(c.sdpMid == null || text(c.sdpMid, 64)) || !(c.sdpMLineIndex == null || Number.isInteger(c.sdpMLineIndex) && c.sdpMLineIndex >= 0 && c.sdpMLineIndex < 100)) return null;
m.data = { kind: 'ice', candidate: { candidate: c.candidate, sdpMid: c.sdpMid ?? null, sdpMLineIndex: c.sdpMLineIndex ?? null } };
} else return null;
}
return m;
}
function offset(v, size) { if (!Number.isSafeInteger(v) || v < 0 || v > size) throw new Error('Invalid resume offset'); return v; }
function nextChunk(pos, size) { offset(pos, size); return { start: pos, end: Math.min(size, pos + CHUNK) }; }
const api = { CHUNK, file, parse, offset, nextChunk };
if (typeof module !== 'undefined') module.exports = api;
root.DirectProtocol = api;
}(typeof globalThis !== 'undefined' ? globalThis : this));

View File

@@ -0,0 +1,18 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const p = require('./direct-protocol.js');
test('direct protocol strips unexpected payload and validates file claims', () => {
const m = p.parse({ type: 'direct', action: 'invite', to: 'peer', file: { id: 'video', title: 'Song', cid: 'a'.repeat(64), size: 10, bytes: 'secret' }, bytes: 'not relayed' });
assert.equal(m.file.size, 10); assert.equal(m.bytes, undefined); assert.equal(m.file.bytes, undefined);
assert.equal(p.parse({ ...m, file: { ...m.file, size: Infinity } }), null);
assert.equal(p.parse(new Uint8Array(4)), null);
});
test('chunks retain exact resume offset and never exceed file end', () => {
assert.deepEqual(p.nextChunk(17, 100000), { start: 17, end: 65553 });
assert.deepEqual(p.nextChunk(99999, 100000), { start: 99999, end: 100000 });
for (const n of [-1, 100001, 0.5, NaN]) assert.throws(() => p.offset(n, 100000));
});
test('signalling only admits bounded SDP or ICE, never byte frames', () => {
assert.equal(p.parse({ type: 'direct', action: 'signal', token: 'abc', data: { kind: 'bytes', data: [1, 2] } }), null);
assert.equal(p.parse({ type: 'direct', action: 'signal', token: 'abc', data: { kind: 'offer', sdp: 'x'.repeat(60001) } }), null);
});

View File

@@ -0,0 +1,40 @@
'use strict';
importScripts('/sha256.js');
let dir, handle, access, hash, pos = 0, claim, name;
let chain = Promise.resolve();
self.onmessage = e => { chain = chain.then(() => run(e.data)); };
async function close() { if (access) { access.flush(); access.close(); access = null; } }
async function run(m) {
try {
if (m.op === 'open') {
claim = m.file;
if (!/^[A-Za-z0-9_-]{1,128}$/.test(claim.id) || !/^[a-f0-9]{64}$/.test(claim.cid)) throw Error('Invalid file identity');
dir = await (await navigator.storage.getDirectory()).getDirectoryHandle('videos', { create: true });
name = `${claim.id}.${claim.cid}.direct.part`;
handle = await dir.getFileHandle(name, { create: true });
access = await handle.createSyncAccessHandle();
pos = access.getSize(); if (pos > claim.size) { access.truncate(0); pos = 0; }
hash = self.Sha256.create();
for (let at = 0; at < pos; at += 1024 * 1024) { const b = new Uint8Array(Math.min(1024 * 1024, pos - at)); access.read(b, { at }); hash.update(b); }
self.postMessage({ op: 'opened', offset: pos });
} else if (m.op === 'chunk') {
const b = new Uint8Array(m.buf);
if (!access || b.length > 65536 || pos + b.length > claim.size) throw Error('Invalid file chunk');
if (access.write(b, { at: pos }) !== b.length) throw Error('Incomplete OPFS write');
hash.update(b); pos += b.length; access.flush();
self.postMessage({ op: 'progress', received: pos });
} else if (m.op === 'abort') { await close(); self.postMessage({ op: 'closed' }); }
else if (m.op === 'finish') {
await close();
if (pos !== claim.size || hash.hex() !== claim.cid) { await dir.removeEntry(name); throw Error('File verification failed; retry starts from zero'); }
const extension = /^(mp4|m4a|webm|ogg|opus|mkv|mp3|wav|aac)$/.test(claim.extension) ? claim.extension : 'mp4';
const final = `${claim.id}.${extension}`;
// Copy only after verification; existing saved playback remains intact until then.
const out = await (await dir.getFileHandle(final, { create: true })).createSyncAccessHandle();
try { const f = await handle.getFile(); for (let at = 0; at < f.size; at += 1024 * 1024) out.write(new Uint8Array(await f.slice(at, at + 1024 * 1024).arrayBuffer()), { at }); out.truncate(f.size); out.flush(); } finally { out.close(); }
await dir.removeEntry(name);
for await (const [old, entry] of dir.entries()) { if (entry.kind === 'file' && old !== final && old.startsWith(claim.id + '.') && /\.(mp4|m4a|webm|ogg|opus|mkv|mp3|wav|aac)$/.test(old)) await dir.removeEntry(old); }
self.postMessage({ op: 'done', file: claim });
}
} catch (e) { await close(); self.postMessage({ op: 'error', error: e.message }); }
}

44
frontend/direct-stream.js Normal file
View File

@@ -0,0 +1,44 @@
/* Progressive preview is only safe for fragmented MP4, never ordinary MP4 chunks. */
(function (root) {
'use strict';
function inspectBytes(bytes) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const str = (at, n) => String.fromCharCode(...bytes.subarray(at, at + n));
let at = 0, moov = null;
while (at + 8 <= bytes.length) {
const size = view.getUint32(at); if (size < 8 || at + size > bytes.length) break;
if (str(at + 4, 4) === 'moov') { moov = bytes.subarray(at + 8, at + size); break; }
at += size;
}
if (!moov) return null;
const tagAt = (tag) => { for (let i = 4; i + 4 <= moov.length; i++) if (String.fromCharCode(...moov.subarray(i, i + 4)) === tag) return i; return -1; };
if (tagAt('mvex') < 0) return null;
const avc = tagAt('avcC'), audio = tagAt('mp4a');
if (avc < 0 || avc + 8 > moov.length || moov[avc + 4] !== 1) return null;
const codec = Array.from(moov.subarray(avc + 5, avc + 8), b => b.toString(16).padStart(2, '0')).join('');
return `video/mp4; codecs="avc1.${codec}${audio >= 0 ? ', mp4a.40.2' : ''}"`;
}
async function inspect(file) { return inspectBytes(new Uint8Array(await file.slice(0, 1024 * 1024).arrayBuffer())); }
function create(mime, title) {
if (!root.MediaSource?.isTypeSupported(mime)) return null;
const media = new MediaSource(), url = URL.createObjectURL(media), queue = [];
let buffer, queued = 0, ended = false, stopped = false;
const dialog = document.createElement('dialog'); dialog.className = 'direct-transfer-dialog';
const heading = document.createElement('h2'); heading.textContent = title;
const note = document.createElement('p'); note.textContent = 'Direct preview · the full copy is still being saved and verified. Tap Play when ready.';
const video = document.createElement('video'); video.controls = true; video.playsInline = true; video.src = url; video.style.width = '100%';
const close = document.createElement('button'); close.textContent = 'Close preview';
const dispose = () => { stopped = true; queue.length = 0; video.pause(); video.removeAttribute('src'); video.load(); URL.revokeObjectURL(url); dialog.remove(); };
close.onclick = dispose; dialog.oncancel = e => { e.preventDefault(); dispose(); }; dialog.append(heading, note, video, close); document.body.append(dialog); dialog.showModal();
function flush() {
if (stopped || !buffer || buffer.updating) return;
if (queue.length) { const b = queue.shift(); queued -= b.byteLength; try { buffer.appendBuffer(b); } catch { dispose(); } }
else if (ended && media.readyState === 'open') { try { media.endOfStream(); } catch {} }
}
media.addEventListener('sourceopen', () => { try { buffer = media.addSourceBuffer(mime); buffer.addEventListener('updateend', flush); buffer.addEventListener('error', dispose); flush(); } catch { dispose(); } }, { once: true });
return { push(bytes) { if (stopped) return; queued += bytes.byteLength; if (queued > 8 * 1024 * 1024) { dispose(); return; } queue.push(bytes.slice(0)); flush(); }, end() { ended = true; flush(); }, close: dispose };
}
const api = { inspectBytes, inspect, create };
if (typeof module !== 'undefined') module.exports = api;
root.DirectStream = api;
}(globalThis));

View File

@@ -0,0 +1,12 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { inspectBytes } = require('./direct-stream.js');
function box(type, payload = Buffer.alloc(0)) { const b = Buffer.alloc(8 + payload.length); b.writeUInt32BE(b.length); b.write(type, 4); payload.copy(b, 8); return b; }
test('progressive playback only accepts a complete fragmented MP4 initialization', () => {
const codec = box('avcC', Buffer.from([1, 0x64, 0, 0x1f]));
const fragmented = Buffer.concat([box('ftyp'), box('moov', Buffer.concat([box('mvex'), codec, box('mp4a')]))]);
assert.equal(inspectBytes(fragmented), 'video/mp4; codecs="avc1.64001f, mp4a.40.2"');
assert.equal(inspectBytes(Buffer.concat([box('ftyp'), box('moov', codec)])), null);
assert.equal(inspectBytes(fragmented.subarray(0, fragmented.length - 1)), null);
assert.equal(inspectBytes(Buffer.alloc(0)), null);
});

View File

@@ -2,6 +2,8 @@
(function(root) { (function(root) {
'use strict'; 'use strict';
const paths = { const paths = {
trash:'<path d="M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7M14 10v7"/>',
music:'<path d="M9 18V5l11-2v13M9 8l11-2"/><ellipse cx="6" cy="18" rx="3" ry="2"/><ellipse cx="17" cy="16" rx="3" ry="2"/>',
add:'<path d="M12 5v14M5 12h14"/>', close:'<path d="m6 6 12 12M18 6 6 18"/>', add:'<path d="M12 5v14M5 12h14"/>', close:'<path d="m6 6 12 12M18 6 6 18"/>',
search:'<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>', search:'<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
save:'<path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4"/>', save:'<path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4"/>',
@@ -20,7 +22,7 @@
down:'<path d="m6 9 6 6 6-6"/>', right:'<path d="m9 6 6 6-6 6"/>', back:'<path d="M20 12H4m6-6-6 6 6 6"/>', more:'<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>', down:'<path d="m6 9 6 6 6-6"/>', right:'<path d="m9 6 6 6-6 6"/>', back:'<path d="M20 12H4m6-6-6 6 6 6"/>', more:'<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
devices:'<rect x="3" y="4" width="14" height="12" rx="2"/><path d="M7 20h6M10 16v4"/><rect x="17" y="10" width="5" height="11" rx="1"/>' devices:'<rect x="3" y="4" width="14" height="12" rx="2"/><path d="M7 20h6M10 16v4"/><rect x="17" y="10" width="5" height="11" rx="1"/>'
}; };
const glyphs = {'+':'add','✕':'close','×':'close','✖':'close','🔍':'search','🔎':'search','⬇':'save','⤓':'save','💾':'save','▶':'play','⏸':'pause','⏭':'next','⏮':'previous','↻':'repeat','↺':'repeat','✎':'edit','✏':'edit','📝':'edit','🎤':'lyrics','↗':'share','⧉':'copy','✓':'check','📁':'folder','⚙':'settings','📋':'chapters','🕘':'clock','🔀':'shuffle','📡':'devices','▾':'down','▸':'right','←':'back','⋯':'more'}; const glyphs = {'+':'add','✕':'close','×':'close','✖':'close','🔍':'search','🔎':'search','⬇':'save','⤓':'save','💾':'save','▶':'play','⏸':'pause','⏭':'next','⏮':'previous','↻':'repeat','↺':'repeat','✎':'edit','✏':'edit','📝':'edit','🎤':'lyrics','↗':'share','⧉':'copy','✓':'check','📁':'folder','⚙':'settings','📋':'chapters','🕘':'clock','🔀':'shuffle','📡':'devices','▾':'down','▸':'right','←':'back','⋯':'more','⋮':'more','🗑':'trash','🎵':'music'};
function leadingGlyph(text) { function leadingGlyph(text) {
const prefix = text.match(/^\s*/)[0], rest = text.slice(prefix.length); const prefix = text.match(/^\s*/)[0], rest = text.slice(prefix.length);
const glyph = Object.keys(glyphs).find(key => rest.startsWith(key)); const glyph = Object.keys(glyphs).find(key => rest.startsWith(key));
@@ -29,39 +31,61 @@
return { kind:glyphs[glyph], original:prefix+rest.slice(0,length), rest:rest.slice(length) }; return { kind:glyphs[glyph], original:prefix+rest.slice(0,length), rest:rest.slice(length) };
} }
function mount(doc) { function mount(doc) {
const decorations = new Set(); let scheduled=false; const decorations = new Set(), tabLabels = new Map(); let scheduled=false;
const selector = '#listPane button, .sidebar button, .smart-item .pl-name, .notes-tab, .notes-fold, .mini-btn'; const selector = '#listPane button, .sidebar button, .smart-item .pl-name, .notes-tab, .notes-fold, .mini-btn, .sidebar .playlist-item:not(.smart-item), .pl-offline-badge.all, .home-pl-offline.all, .home-pl-play, .home-pl-thumb-fallback';
const svg = kind => { const icon=doc.createElementNS('http://www.w3.org/2000/svg','svg'); icon.setAttribute('viewBox','0 0 24 24');icon.setAttribute('aria-hidden','true');icon.classList.add('glass-control-icon');icon.innerHTML=paths[kind];return icon; }; const svg = kind => { const icon=doc.createElementNS('http://www.w3.org/2000/svg','svg'); icon.setAttribute('viewBox','0 0 24 24');icon.setAttribute('aria-hidden','true');icon.classList.add('glass-control-icon');icon.innerHTML=paths[kind];return icon; };
const observer=new root.MutationObserver(schedule), themeObserver=new root.MutationObserver(schedule); const observer=new root.MutationObserver(schedule), themeObserver=new root.MutationObserver(schedule);
function restore(record) { function restore(record) {
record.svg.remove(); if(record.original.isConnected)record.original.replaceWith(doc.createTextNode(record.original.textContent)); record.svg.remove(); if(record.original.isConnected)record.original.replaceWith(doc.createTextNode(record.original.textContent));
if(record.addedLabel && record.parent.isConnected)record.parent.removeAttribute('aria-label'); if(record.addedLabel && record.parent.isConnected)record.parent.removeAttribute('aria-label');
} }
function restoreTab(parent, record) {
for(const label of record.labels)if(label.isConnected)label.replaceWith(doc.createTextNode(label.textContent));
for(const [name,value] of Object.entries(record.attributes)) { if(value===null)parent.removeAttribute(name);else parent.setAttribute(name,value); }
}
function paint() { function paint() {
scheduled=false;observer.disconnect(); scheduled=false;observer.disconnect();
const active=doc.documentElement.dataset.layout==='glass-stage'; const active=doc.documentElement.dataset.layout==='glass-stage';
for(const [parent,record] of tabLabels)if(!active || !parent.isConnected){restoreTab(parent,record);tabLabels.delete(parent);}
for(const record of decorations)if(!active || !record.svg.isConnected){restore(record);decorations.delete(record);} for(const record of decorations)if(!active || !record.svg.isConnected){restore(record);decorations.delete(record);}
if(active) for(const parent of doc.querySelectorAll(selector)) { if(active) for(const parent of doc.querySelectorAll(selector)) {
// Existing SVGs already express the action; only legacy glyphs change. // Existing SVGs already express the action; only legacy glyphs change.
if(parent.querySelector('svg:not(.glass-control-icon),.legacy-nav-icon'))continue; if(parent.querySelector('svg:not(.glass-control-icon),.legacy-nav-icon'))continue;
for(const node of [...parent.childNodes]) { for(const node of [...parent.childNodes]) {
if(node.nodeType!==3)continue;const match=leadingGlyph(node.textContent);if(!match)continue; if(node.nodeType!==3)continue;const match=leadingGlyph(node.textContent);if(!match)continue;
const icon=svg(match.kind), original=doc.createElement('span'); original.className='glass-original-glyph';original.textContent=match.original; const icon=svg(match.kind);
if(parent.matches('.pl-offline-badge,.home-pl-offline')) { icon.removeAttribute('aria-hidden'); icon.setAttribute('role','img'); icon.setAttribute('aria-label',parent.title || 'Available offline'); }
const original=doc.createElement('span'); original.className='glass-original-glyph';original.textContent=match.original;
node.textContent=match.rest;parent.insertBefore(icon,node);parent.insertBefore(original,node); node.textContent=match.rest;parent.insertBefore(icon,node);parent.insertBefore(original,node);
const addedLabel=parent.tagName==='BUTTON'&&!parent.hasAttribute('aria-label')&&!parent.textContent.replace(match.original,'').trim(); const addedLabel=parent.tagName==='BUTTON'&&!parent.hasAttribute('aria-label')&&!parent.textContent.replace(match.original,'').trim();
if(addedLabel)parent.setAttribute('aria-label',parent.title || ({play:'Play',pause:'Pause',close:'Close',add:'Add',save:'Save'}[match.kind] || match.kind)); if(addedLabel)parent.setAttribute('aria-label',parent.title || ({play:'Play',pause:'Pause',close:'Close',add:'Add',save:'Save'}[match.kind] || match.kind));
decorations.add({parent,svg:icon,original,addedLabel});break; decorations.add({parent,svg:icon,original,addedLabel});break;
} }
if(parent.matches('.sidebar .playlist-item:not(.smart-item)')&&!parent.querySelector('svg')) {
const icon=svg('folder'); parent.prepend(icon);
decorations.add({parent,svg:icon,original:doc.createElement('span'),addedLabel:false});
}
if(parent.matches('.notes-tab')&&!parent.querySelector('svg')&&paths[parent.dataset.tab==='markers'?'chapters':parent.dataset.tab]) { if(parent.matches('.notes-tab')&&!parent.querySelector('svg')&&paths[parent.dataset.tab==='markers'?'chapters':parent.dataset.tab]) {
const kind=parent.dataset.tab==='markers'?'chapters':parent.dataset.tab,icon=svg(kind);parent.prepend(icon); const kind=parent.dataset.tab==='markers'?'chapters':parent.dataset.tab,icon=svg(kind);parent.prepend(icon);
const original=doc.createElement('span');decorations.add({parent,svg:icon,original,addedLabel:false}); const original=doc.createElement('span');decorations.add({parent,svg:icon,original,addedLabel:false});
} }
} }
if(active)for(const parent of doc.querySelectorAll('.notes-tab')) {
if(tabLabels.has(parent))continue;
const record={labels:[],attributes:{'aria-label':parent.getAttribute('aria-label'),title:parent.getAttribute('title')}};
const name=parent.dataset.tab==='markers'?'Chapters & bookmarks':parent.dataset.tab==='lyrics'?'Lyrics':'Search';
if(!parent.hasAttribute('aria-label'))parent.setAttribute('aria-label',name);
if(!parent.hasAttribute('title'))parent.setAttribute('title',name);
for(const node of [...parent.childNodes])if(node.nodeType===3 && node.textContent.trim()) {
const label=doc.createElement('span');label.className='glass-tab-label';node.replaceWith(label);label.append(node);record.labels.push(label);
}
tabLabels.set(parent,record);
}
if(active)observer.observe(doc.body,{childList:true,subtree:true,characterData:true}); if(active)observer.observe(doc.body,{childList:true,subtree:true,characterData:true});
} }
function schedule(){if(!scheduled){scheduled=true;root.queueMicrotask(paint);}} function schedule(){if(!scheduled){scheduled=true;root.queueMicrotask(paint);}}
themeObserver.observe(doc.documentElement,{attributes:true,attributeFilter:['data-layout']});paint(); themeObserver.observe(doc.documentElement,{attributes:true,attributeFilter:['data-layout']});paint();
return ()=>{observer.disconnect();themeObserver.disconnect();for(const record of decorations)restore(record);}; return ()=>{observer.disconnect();themeObserver.disconnect();for(const record of decorations)restore(record);for(const [parent,record] of tabLabels)restoreTab(parent,record);};
} }
if(typeof module!=='undefined')module.exports={leadingGlyph,paths}; if(typeof module!=='undefined')module.exports={leadingGlyph,paths};
else if(root.document.readyState==='loading')root.document.addEventListener('DOMContentLoaded',()=>mount(root.document),{once:true}); else if(root.document.readyState==='loading')root.document.addEventListener('DOMContentLoaded',()=>mount(root.document),{once:true});

View File

@@ -7,3 +7,9 @@ test('glass icon substitution preserves leading whitespace and remaining labels'
assert.equal(leadingGlyph('Clear all'),null); assert.equal(leadingGlyph('Clear all'),null);
assert.equal(leadingGlyph('⏸').kind,'pause'); assert.equal(leadingGlyph('⏸').kind,'pause');
}); });
test('playlist trash, menu and music glyphs use drawn glass icons',()=>{
assert.equal(leadingGlyph('🗑️ Delete').kind,'trash');
assert.equal(leadingGlyph('⋮').kind,'more');
assert.equal(leadingGlyph('🎵').kind,'music');
});

View File

@@ -0,0 +1,42 @@
/* Glass Stage section shortcuts participate in layout; Classic keeps its rail. */
(function (root) {
'use strict';
function bottomClearance(viewport, boxes, gap = 16) {
const occupied = boxes.reduce((height, b) => b.fixed && b.height > 0 && b.top < viewport && b.bottom > 0 ? Math.max(height, viewport - b.top) : height, 0);
return Math.ceil(occupied + (occupied ? gap : 0));
}
function mount(doc) {
const win = doc.defaultView, html = doc.documentElement, rail = doc.getElementById('sectionRail'), panel = doc.getElementById('listPane');
if (!rail || !panel) return;
const original = doc.createComment('section rail original position'); rail.before(original);
let frame = 0;
function active() { return html.dataset.layout === 'glass-stage'; }
function schedule() { if (!frame) frame = win.requestAnimationFrame(measure); }
function set(name, value) { if (html.style.getPropertyValue(name) !== value) html.style.setProperty(name, value); }
function box(el) { const r = el.getBoundingClientRect(); return { top: r.top, bottom: r.bottom, height: el.classList.contains('hidden') ? 0 : r.height, fixed: win.getComputedStyle(el).position === 'fixed' }; }
function measure() {
frame = 0; if (!active()) return;
const nav = doc.getElementById('bottomNav'), mini = doc.getElementById('miniBar');
const navBox = box(nav);
set('--glass-nav-height', `${Math.ceil(navBox.height)}px`);
// Read again after nav-height changes: the mini player's bottom depends on it.
set('--glass-bottom-clearance', `${bottomClearance(win.innerHeight, [box(nav), box(mini)])}px`);
}
function sync() {
if (active()) { if (rail.parentElement !== panel) panel.prepend(rail); schedule(); }
else {
if (rail.parentNode !== original.parentNode) original.after(rail);
html.style.removeProperty('--glass-nav-height'); html.style.removeProperty('--glass-bottom-clearance');
}
}
const observer = new MutationObserver(sync); observer.observe(html, { attributes: true, attributeFilter: ['data-layout'] });
const chrome = [doc.getElementById('bottomNav'), doc.getElementById('miniBar')].filter(Boolean);
const changes = new MutationObserver(schedule); chrome.forEach(el => changes.observe(el, { attributes: true, attributeFilter: ['class', 'style'] }));
const resize = typeof win.ResizeObserver === 'function' ? new win.ResizeObserver(schedule) : null; chrome.forEach(el => resize?.observe(el));
win.addEventListener('resize', schedule); win.addEventListener('orientationchange', schedule); win.visualViewport?.addEventListener('resize', schedule);
sync();
}
if (typeof module !== 'undefined') module.exports = { bottomClearance };
else if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', () => mount(root.document), { once: true });
else mount(root.document);
}(typeof window !== 'undefined' ? window : globalThis));

View File

@@ -0,0 +1,10 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { bottomClearance } = require('./glass-panel-layout.js');
test('clearance covers stacked floating chrome and includes its viewport inset once', () => {
assert.equal(bottomClearance(844, [{ fixed: true, top: 740, bottom: 844, height: 104 }, { fixed: true, top: 668, bottom: 732, height: 64 }]), 192);
assert.equal(bottomClearance(844, [{ fixed: true, top: 740, bottom: 844, height: 104 }]), 120);
});
test('hidden, nonfixed and offscreen elements do not reserve chrome space', () => {
assert.equal(bottomClearance(844, [{ fixed: true, top: 740, bottom: 844, height: 0 }, { fixed: false, top: 668, bottom: 732, height: 64 }, { fixed: true, top: 900, bottom: 964, height: 64 }]), 0);
});

View File

@@ -28,11 +28,13 @@
<link rel="stylesheet" href="theme-classic.css" /> <link rel="stylesheet" href="theme-classic.css" />
<link rel="stylesheet" href="recommendations.css" /> <link rel="stylesheet" href="recommendations.css" />
<link rel="stylesheet" href="settings-sections.css" /> <link rel="stylesheet" href="settings-sections.css" />
<link rel="stylesheet" href="direct-media.css" />
<link rel="stylesheet" href="lower-third.css" /> <link rel="stylesheet" href="lower-third.css" />
<link rel="stylesheet" href="car-mode.css" /> <link rel="stylesheet" href="car-mode.css" />
<link rel="stylesheet" href="offline-pages.css" /> <link rel="stylesheet" href="offline-pages.css" />
<link rel="stylesheet" href="saved-page.css" /> <link rel="stylesheet" href="saved-page.css" />
<link rel="stylesheet" href="theme-glass-controls.css" /> <link rel="stylesheet" href="theme-glass-controls.css" />
<link rel="stylesheet" href="theme-glass-panel.css" />
</head> </head>
<body> <body>
<div class="app"> <div class="app">
@@ -660,12 +662,16 @@
<script src="async-guard.js"></script> <script src="async-guard.js"></script>
<script src="sw-update.js"></script> <script src="sw-update.js"></script>
<script src="settings-sections.js"></script> <script src="settings-sections.js"></script>
<script src="direct-protocol.js"></script>
<script src="direct-stream.js"></script>
<script src="direct-media.js"></script>
<script src="download-errors.js"></script> <script src="download-errors.js"></script>
<script src="downloads.js"></script> <script src="downloads.js"></script>
<script src="downloads-page.js"></script> <script src="downloads-page.js"></script>
<script src="saved-page.js"></script> <script src="saved-page.js"></script>
<script src="server-backfill.js"></script> <script src="server-backfill.js"></script>
<script src="glass-controls.js"></script> <script src="glass-controls.js"></script>
<script src="glass-panel-layout.js"></script>
<script src="party-dj.js"></script> <script src="party-dj.js"></script>
<script src="lyrics-window.js"></script> <script src="lyrics-window.js"></script>
<script src="midi.js"></script> <script src="midi.js"></script>

View File

@@ -216,7 +216,7 @@
ws = sock; ws = sock;
let ping = null; let ping = null;
sock.onopen = () => { sock.onopen = () => {
sock.send(JSON.stringify({ type: 'auth', device: d.deviceId, secret: d.secret })); sock.send(JSON.stringify({ type: 'auth', device: d.deviceId, secret: d.secret, profile: hooks.getProfile(), profileSecret: hooks.getProfileSecret?.() || '' }));
// The server drops sockets idle for 120 s (server.js websocketHandler). // The server drops sockets idle for 120 s (server.js websocketHandler).
ping = setInterval(() => { try { sock.send('{"type":"ping"}'); } catch { /* closing */ } }, 50_000); ping = setInterval(() => { try { sock.send('{"type":"ping"}'); } catch { /* closing */ } }, 50_000);
}; };
@@ -285,7 +285,7 @@
// and only for the exact cid this device holds. // and only for the exact cid this device holds.
let restoring = false; let restoring = false;
onMessage('upload-request', async (m) => { onMessage('upload-request', async (m) => {
if (restoring || hooks.getSettings().p2pShare === false) return; if (restoring || hooks.getSettings().p2pShare === false || hooks.getSettings().directTransfer !== false) return;
const rec = window.DeviceDB ? await window.DeviceDB.getFile(m.videoId) : null; const rec = window.DeviceDB ? await window.DeviceDB.getFile(m.videoId) : null;
if (!rec || rec.cid !== m.cid) return; if (!rec || rec.cid !== m.cid) return;
restoring = true; restoring = true;
@@ -304,6 +304,7 @@
window.P2PClient = { window.P2PClient = {
start, changed, sync, device, authHeaders, config, contribute, unknownVideos, start, changed, sync, device, authHeaders, config, contribute, unknownVideos,
send: m => { if (!ws || ws.readyState !== 1) return false; ws.send(JSON.stringify(m)); return true; },
onMessage, signal, peer: () => myPeer, isConnected: () => !!(ws && ws.readyState === 1 && myPeer), onMessage, signal, peer: () => myPeer, isConnected: () => !!(ws && ws.readyState === 1 && myPeer),
}; };
}()); }());

View File

@@ -59,6 +59,8 @@ const SHELL = [
'/service-ui.css', '/service-ui.css',
'/refresh.css', '/refresh.css',
'/theme-glass.css', '/theme-glass.css',
'/theme-glass-panel.css',
'/glass-panel-layout.js',
'/theme-classic.css', '/theme-classic.css',
'/recommendations.css', '/recommendations.css',
'/flag-ui.js', '/flag-ui.js',
@@ -81,6 +83,11 @@ const SHELL = [
'/hash-worker.js', '/hash-worker.js',
'/p2p-client.js', '/p2p-client.js',
'/p2p-core.js', '/p2p-core.js',
'/direct-media.css',
'/direct-protocol.js',
'/direct-media.js',
'/direct-stream.js',
'/direct-recv-worker.js',
'/p2p-transfer.js', '/p2p-transfer.js',
'/p2p-recv-worker.js', '/p2p-recv-worker.js',
'/settings-sections.js', '/settings-sections.js',

View File

@@ -6,8 +6,9 @@ html[data-layout="glass-stage"] :is(#listPane button,.settings-section-row,.note
html[data-layout="glass-stage"] :is(.bottom-nav-btn,.nav-item,.playlist-item) { min-height:44px; color:var(--text-2); } html[data-layout="glass-stage"] :is(.bottom-nav-btn,.nav-item,.playlist-item) { min-height:44px; color:var(--text-2); }
html[data-layout="glass-stage"] .bottom-nav { gap:2px; padding-inline:4px; } html[data-layout="glass-stage"] .bottom-nav { gap:2px; padding-inline:4px; }
html[data-layout="glass-stage"] .bottom-nav-btn { border:1px solid transparent; border-radius:18px; gap:6px; min-width:44px; background:transparent; } html[data-layout="glass-stage"] .bottom-nav-btn { border:1px solid transparent; border-radius:18px; gap:6px; min-width:44px; background:transparent; }
html[data-layout="glass-stage"] .bottom-nav-icon { display:grid; place-items:center; width:24px; height:24px; background:none; box-shadow:none; transform:none; } html[data-layout="glass-stage"] .bottom-nav-icon { display:grid; place-items:center; width:24px; height:24px; padding:0; background:none; box-shadow:none; transform:none; }
html[data-layout="glass-stage"] .bottom-nav-label { font-size:11px; } html[data-layout="glass-stage"] .bottom-nav-label { font-size:11px; }
html[data-layout="glass-stage"] .bottom-nav-badge { top:6px; left:calc(50% + 8px); margin-left:0; }
html[data-layout="glass-stage"] .bottom-nav-btn.active { color:var(--accent-bright); background:var(--tile-hi); border-color:var(--glass-line); } html[data-layout="glass-stage"] .bottom-nav-btn.active { color:var(--accent-bright); background:var(--tile-hi); border-color:var(--glass-line); }
html[data-layout="glass-stage"] .bottom-nav-btn.active .bottom-nav-icon { background:none; box-shadow:none; transform:none; } html[data-layout="glass-stage"] .bottom-nav-btn.active .bottom-nav-icon { background:none; box-shadow:none; transform:none; }
html[data-layout="glass-stage"] :is(.bottom-nav-badge,.nav-badge,.pl-count) { background:var(--tile-hi); border:1px solid var(--glass-line); color:var(--text-2); border-radius:99px; font-size:11px; min-width:18px; font-variant-numeric:tabular-nums; } html[data-layout="glass-stage"] :is(.bottom-nav-badge,.nav-badge,.pl-count) { background:var(--tile-hi); border:1px solid var(--glass-line); color:var(--text-2); border-radius:99px; font-size:11px; min-width:18px; font-variant-numeric:tabular-nums; }
@@ -51,3 +52,9 @@ html[data-layout="glass-stage"] .sec-rail-btn { width:44px; height:44px; border-
html[data-layout="glass-stage"] .sec-rail-btn.active { background:var(--tile-hi); color:var(--accent-bright); border-color:var(--glass-line); } html[data-layout="glass-stage"] .sec-rail-btn.active { background:var(--tile-hi); color:var(--accent-bright); border-color:var(--glass-line); }
html[data-layout="glass-stage"] .sec-rail-label { right:auto; left:0; bottom:50px; } html[data-layout="glass-stage"] .sec-rail-label { right:auto; left:0; bottom:50px; }
html[data-layout="glass-stage"] #listPane { padding-bottom:calc(var(--bottom-chrome,0px) + 88px) !important; } html[data-layout="glass-stage"] #listPane { padding-bottom:calc(var(--bottom-chrome,0px) + 88px) !important; }
/* Playlist rows and offline state use the same drawn icons as Now Playing. */
html[data-layout="glass-stage"] .sidebar .playlist-item::before { content:none; }
html[data-layout="glass-stage"] :is(.pl-offline-badge.all,.home-pl-offline.all) { display:inline-grid; place-items:center; flex:none; width:24px; height:24px; padding:0; border-radius:8px; background:var(--tile-hi); border:1px solid var(--glass-line); color:var(--accent-bright); box-shadow:none; }
html[data-layout="glass-stage"] :is(.pl-offline-badge.partial,.home-pl-offline.partial) { font-size:11px; }
html[data-layout="glass-stage"] .home-pl-play { display:grid; place-items:center; width:44px; height:44px; border-radius:18px; background:var(--tile); border:1px solid var(--glass-line); color:var(--text); }

View File

@@ -0,0 +1,56 @@
/* In-flow section shortcuts cannot cover a heading, filter or list row. */
html[data-layout="glass-stage"] #listPane > .sec-rail {
position:static; inset:auto; transform:none; display:flex !important;
align-self:flex-start; flex:none; flex-direction:row; flex-wrap:nowrap;
width:max-content; max-width:100%; margin:0 0 12px; z-index:auto;
}
html[data-layout="glass-stage"] #listPane > .sec-rail.hidden { display:none !important; }
html[data-layout="glass-stage"] #listPane > .sec-rail .sec-rail-btn { flex:none; }
html[data-layout="glass-stage"] #listPane > .sec-rail .sec-rail-label { z-index:1; }
/* Keep the progress strip inside the same rounded card as the controls. */
html[data-layout="glass-stage"] .mini-bar {
bottom:calc(var(--glass-nav-height, 66px + env(safe-area-inset-bottom)) + 8px);
border-radius:18px; overflow:hidden; border:1px solid var(--glass-line);
background:var(--glass); -webkit-backdrop-filter:blur(24px); backdrop-filter:blur(24px);
}
html[data-layout="glass-stage"] .mini-bar-inner {
border:0; border-radius:0; background:transparent; box-shadow:none;
-webkit-backdrop-filter:none; backdrop-filter:none;
}
html[data-layout="glass-stage"] .mini-progress { margin:0; height:3px; overflow:hidden; }
html[data-layout="glass-stage"] .player-pane {
padding-bottom:var(--glass-bottom-clearance, 32px);
scroll-padding-bottom:var(--glass-bottom-clearance, 32px);
}
html[data-layout="glass-stage"] #listPane {
padding-bottom:var(--glass-bottom-clearance, 32px) !important;
scroll-padding-bottom:var(--glass-bottom-clearance, 32px);
}
@media (max-width:860px) {
/* Portrait uses one shared scroller: reserve chrome once, at its tail. */
html[data-layout="glass-stage"] .body {
padding-bottom:var(--glass-bottom-clearance, calc(82px + env(safe-area-inset-bottom)));
scroll-padding-bottom:var(--glass-bottom-clearance, calc(82px + env(safe-area-inset-bottom)));
}
html[data-layout="glass-stage"] .player-pane { padding-bottom:14px; }
html[data-layout="glass-stage"] #listPane { padding-bottom:16px !important; }
}
/* Keep the notes toolbar one row, even when the player is a narrow desktop pane. */
html[data-layout="glass-stage"] #notesPanel { container-type:inline-size; container-name:glass-notes; min-width:0; }
html[data-layout="glass-stage"] .notes-tabs { flex-wrap:nowrap; min-width:0; align-items:center; }
html[data-layout="glass-stage"] .notes-tab { flex:0 1 auto; min-width:44px; min-height:44px; white-space:nowrap; letter-spacing:.04em; }
html[data-layout="glass-stage"] .glass-tab-label { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
html[data-layout="glass-stage"] .notes-status { flex:1 1 0; min-width:0; margin-left:0; font-size:11px; }
html[data-layout="glass-stage"] .notes-count { flex:none; margin-left:0; }
html[data-layout="glass-stage"] .notes-fold { flex:none; width:44px; height:44px; margin-left:auto; }
@container glass-notes (max-width:560px) {
html[data-layout="glass-stage"] .notes-tabs { gap:4px; padding:6px; }
html[data-layout="glass-stage"] .notes-tab { padding:8px; letter-spacing:0; font-size:11px; }
}
@container glass-notes (max-width:440px) {
html[data-layout="glass-stage"] .notes-tab[data-tab="markers"] .glass-tab-label { display:none; }
}
@container glass-notes (max-width:340px) {
html[data-layout="glass-stage"] .notes-tab .glass-tab-label { display:none; }
}

View File

@@ -0,0 +1,2 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({ testDir: './tests', testMatch: /direct-transfer\.spec\.js/, timeout: 40000, workers: 1, use: { browserName: 'chromium', baseURL: 'http://localhost:8095', serviceWorkers: 'block' }, webServer: { command: 'bun tests/fixtures/direct-server.js', port: 8095, reuseExistingServer: false } });

View File

@@ -0,0 +1,2 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({ ...require('./playwright.classic.config'), testMatch: /glass-navigation\.spec\.js/ });

View File

@@ -0,0 +1,2 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({ testDir: './tests', testMatch: /glass-panel\.spec\.js/, timeout: 30000, workers: 1, projects: [{ name: 'chromium', use: { browserName: 'chromium' } }, { name: 'webkit', use: { browserName: 'webkit' } }], use: { baseURL: 'http://localhost:8096', serviceWorkers: 'block' }, webServer: { command: 'npx serve frontend -l 8096 --no-clipboard', port: 8096, reuseExistingServer: false } });

42
server/direct-relay.js Normal file
View File

@@ -0,0 +1,42 @@
import { randomBytes } from 'node:crypto';
import protocol from '../frontend/direct-protocol.js';
// One instance per authenticated room. It has no media endpoint or binary path.
export function createDirectRelay({ now = Date.now } = {}) {
const transfers = new Map();
const rates = new Map();
function handle(from, raw, peers, send) {
const m = protocol.parse(raw);
if (!m || !peers.has(from)) return false;
const t = now();
for (const [token, x] of transfers) if (x.expires < t || !peers.has(x.from) || !peers.has(x.to)) transfers.delete(token);
const rate = rates.get(from);
const r = rate && t - rate.start < 60000 ? rate : { start: t, count: 0 };
rates.set(from, r);
if (++r.count > 240) return false;
if (m.action === 'request') {
if (m.to === from || !peers.has(m.to)) return false;
send(peers.get(m.to), { type: 'direct', action: 'request', from, id: m.id }); return true;
}
if (m.action === 'invite') {
if (m.to === from || !peers.has(m.to) || transfers.size >= 64) return false;
const token = randomBytes(24).toString('base64url');
transfers.set(token, { from, to: m.to, file: m.file, accepted: false, expires: t + 60000 });
send(peers.get(m.to), { type: 'direct', action: 'invite', from, token, file: m.file });
send(peers.get(from), { type: 'direct', action: 'invited', to: m.to, token, file: m.file });
return true;
}
const x = transfers.get(m.token);
if (!x || ![x.from, x.to].includes(from)) return false;
const target = from === x.from ? x.to : x.from;
if (m.action === 'accept') {
if (from !== x.to || x.accepted) return false;
x.accepted = true;
x.expires = t + 30 * 60000;
} else if (m.action === 'signal' && !x.accepted) return false;
else if (m.action === 'decline' || m.action === 'complete') transfers.delete(m.token);
send(peers.get(target), { ...m, from });
return true;
}
return { handle };
}

View File

@@ -0,0 +1,23 @@
import { test, expect } from 'bun:test';
import { createDirectRelay } from './direct-relay.js';
test('room membership, receiver acceptance and one-use invitation bind signalling', () => {
let clock = 0; const relay = createDirectRelay({ now: () => clock });
const peers = new Map([['host', 'hostSocket'], ['guest', 'guestSocket'], ['other', 'otherSocket']]);
const out = []; const send = (ws, m) => out.push({ ws, m });
const invite = { type: 'direct', action: 'invite', to: 'guest', file: { id: 'song', title: 'Song', cid: 'a'.repeat(64), size: 123 } };
expect(relay.handle('outsider', invite, peers, send)).toBe(false);
expect(relay.handle('host', { ...invite, to: 'outsider' }, peers, send)).toBe(false);
expect(relay.handle('host', invite, peers, send)).toBe(true);
const token = out[0].m.token;
const signal = { type: 'direct', action: 'signal', token, data: { kind: 'offer', sdp: 'valid' } };
expect(relay.handle('host', signal, peers, send)).toBe(false);
expect(relay.handle('other', { type: 'direct', action: 'accept', token }, peers, send)).toBe(false);
expect(relay.handle('guest', { type: 'direct', action: 'accept', token }, peers, send)).toBe(true);
expect(relay.handle('guest', { type: 'direct', action: 'accept', token }, peers, send)).toBe(false);
expect(relay.handle('other', signal, peers, send)).toBe(false);
expect(relay.handle('host', signal, peers, send)).toBe(true);
relay.handle('guest', { type: 'direct', action: 'complete', token }, peers, send);
expect(relay.handle('host', signal, peers, send)).toBe(false);
relay.handle('host', invite, peers, send); clock = 61000;
expect(relay.handle('guest', { type: 'direct', action: 'accept', token: out.at(-1).m.token }, peers, send)).toBe(false);
});

View File

@@ -15,6 +15,7 @@
* reports both: `online` (now) and `lastVerifiedAt` / `stale` (history). * reports both: `online` (now) and `lastVerifiedAt` / `stale` (history).
* Peers are addressed by an opaque id (peerIdOf), never by device id. * Peers are addressed by an opaque id (peerIdOf), never by device id.
* ========================================================================== */ * ========================================================================== */
import { createDirectRelay } from './direct-relay.js';
import { createHash, timingSafeEqual } from 'node:crypto'; import { createHash, timingSafeEqual } from 'node:crypto';
const sha = (s) => createHash('sha256').update(String(s)).digest('hex'); const sha = (s) => createHash('sha256').update(String(s)).digest('hex');
@@ -24,7 +25,8 @@ export const peerIdOf = (deviceId) => sha('peer:' + deviceId).slice(0, 12);
const MAX_MSG = 64 * 1024; const MAX_MSG = 64 * 1024;
const BUDGET_PER_MIN = 300; const BUDGET_PER_MIN = 300;
export function createP2pHub({ getDevice, enabled = () => true, now = () => Date.now(), log = console } = {}) { export function createP2pHub({ getDevice, authorizeProfile = async () => '', enabled = () => true, now = () => Date.now(), log = console } = {}) {
const directRooms = new Map();
const online = new Map(); // deviceId -> ws const online = new Map(); // deviceId -> ws
const byPeer = new Map(); // peer -> deviceId (online only) const byPeer = new Map(); // peer -> deviceId (online only)
@@ -46,6 +48,8 @@ export function createP2pHub({ getDevice, enabled = () => true, now = () => Date
const secret = String(m.secret || ''); const secret = String(m.secret || '');
const d = /^dev_[0-9a-f]{16}$/.test(deviceId) ? await getDevice(deviceId).catch(() => null) : null; const d = /^dev_[0-9a-f]{16}$/.test(deviceId) ? await getDevice(deviceId).catch(() => null) : null;
if (!d || !same(d.secret_hash, sha(secret))) { try { ws.close(4401, 'unknown device'); } catch { /* gone */ } return; } if (!d || !same(d.secret_hash, sha(secret))) { try { ws.close(4401, 'unknown device'); } catch { /* gone */ } return; }
ws.data.profile = await authorizeProfile(m.profile, m.profileSecret, deviceId).catch(() => '');
ws.data.inventory = [];
ws.data.authed = true; ws.data.authed = true;
ws.data.deviceId = deviceId; ws.data.deviceId = deviceId;
ws.data.peer = peerIdOf(deviceId); ws.data.peer = peerIdOf(deviceId);
@@ -54,9 +58,18 @@ export function createP2pHub({ getDevice, enabled = () => true, now = () => Date
online.set(deviceId, ws); online.set(deviceId, ws);
byPeer.set(ws.data.peer, deviceId); byPeer.set(ws.data.peer, deviceId);
send(ws, { type: 'hello', peer: ws.data.peer }); send(ws, { type: 'hello', peer: ws.data.peer });
directory(ws.data.profile);
}
function directory(profile) {
if (!profile) return;
const sockets = [...online.values()].filter(ws => ws.data.profile === profile);
const peers = sockets.map(ws => ({ id: ws.data.peer, name: 'Device ' + ws.data.peer.slice(-4), files: ws.data.inventory || [] }));
for (const ws of sockets) send(ws, { type: 'direct-peers', peers: peers.filter(p => p.id !== ws.data.peer) });
} }
async function message(ws, raw) { async function message(ws, raw) {
if (typeof raw !== 'string') return;
const t = now(); const t = now();
ws.data.budget = ws.data.budget.filter((x) => t - x < 60_000); ws.data.budget = ws.data.budget.filter((x) => t - x < 60_000);
if (ws.data.budget.length >= BUDGET_PER_MIN) { send(ws, { type: 'error', error: 'slow down' }); return; } if (ws.data.budget.length >= BUDGET_PER_MIN) { send(ws, { type: 'error', error: 'slow down' }); return; }
@@ -66,11 +79,17 @@ export function createP2pHub({ getDevice, enabled = () => true, now = () => Date
let m; let m;
try { m = JSON.parse(s); } catch { return; } try { m = JSON.parse(s); } catch { return; }
if (!ws.data.authed) { if (m && m.type === 'auth' && !ws.data.authing) { ws.data.authing = true; await auth(ws, m); } return; } if (!ws.data.authed) { if (m && m.type === 'auth' && !ws.data.authing) { ws.data.authing = true; await auth(ws, m); } return; }
if (m && m.type === 'signal' && typeof m.to === 'string') { if (m?.type === 'direct-inventory' && ws.data.profile) {
const target = online.get(byPeer.get(m.to)); ws.data.inventory = Array.isArray(m.ids) ? [...new Set(m.ids.filter(id => typeof id === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(id)))].slice(0, 1000) : [];
if (!target || target === ws) { send(ws, { type: 'error', error: 'peer offline', to: m.to }); return; } directory(ws.data.profile); return;
send(target, { type: 'signal', from: ws.data.peer, data: m.data });
} }
if (m?.type === 'direct' && ws.data.profile) {
const profile = ws.data.profile;
const peers = new Map([...online.values()].filter(x => x.data.profile === profile).map(x => [x.data.peer, x]));
if (!directRooms.has(profile)) directRooms.set(profile, createDirectRelay());
directRooms.get(profile).handle(ws.data.peer, m, peers, send); return;
}
if (m?.type === 'signal') send(ws, { type: 'error', error: 'use a confirmed direct-transfer invitation', to: m.to });
} }
function close(ws) { function close(ws) {
@@ -78,6 +97,8 @@ export function createP2pHub({ getDevice, enabled = () => true, now = () => Date
if (deviceId && online.get(deviceId) === ws) { if (deviceId && online.get(deviceId) === ws) {
online.delete(deviceId); online.delete(deviceId);
byPeer.delete(peer); byPeer.delete(peer);
directory(ws.data.profile);
if (![...online.values()].some(x => x.data.profile === ws.data.profile)) directRooms.delete(ws.data.profile);
} }
} }

View File

@@ -51,7 +51,7 @@ test('auth on open, hello with opaque peer id, bad secret is closed', async () =
expect(hub.isOnline('dev_000000000000000b')).toBe(false); expect(hub.isOnline('dev_000000000000000b')).toBe(false);
}); });
test('signals are relayed between online peers only; close drops presence', async () => { test('legacy unconfirmed signalling is refused; close drops presence', async () => {
const hub = createP2pHub({ getDevice: p2pDb.getDevice }); const hub = createP2pHub({ getDevice: p2pDb.getDevice });
const a = fakeWs({ budget: [] }); const a = fakeWs({ budget: [] });
const b = fakeWs({ budget: [] }); const b = fakeWs({ budget: [] });
@@ -60,11 +60,12 @@ test('signals are relayed between online peers only; close drops presence', asyn
await hub.websocket.message(a, JSON.stringify({ type: 'auth', device: 'dev_000000000000000a', secret: SECRET_A })); await hub.websocket.message(a, JSON.stringify({ type: 'auth', device: 'dev_000000000000000a', secret: SECRET_A }));
await hub.websocket.message(b, JSON.stringify({ type: 'auth', device: 'dev_000000000000000b', secret: SECRET_B })); await hub.websocket.message(b, JSON.stringify({ type: 'auth', device: 'dev_000000000000000b', secret: SECRET_B }));
await hub.websocket.message(a, JSON.stringify({ type: 'signal', to: b.data.peer, data: { sdp: 'x' } })); await hub.websocket.message(a, JSON.stringify({ type: 'signal', to: b.data.peer, data: { sdp: 'x' } }));
expect(b.sent.at(-1)).toEqual({ type: 'signal', from: a.data.peer, data: { sdp: 'x' } }); expect(a.sent.at(-1)).toMatchObject({ type: 'error', error: 'use a confirmed direct-transfer invitation' });
expect(b.sent.some(m => m.type === 'signal')).toBe(false);
hub.websocket.close(b); hub.websocket.close(b);
expect(hub.isOnline('dev_000000000000000b')).toBe(false); expect(hub.isOnline('dev_000000000000000b')).toBe(false);
await hub.websocket.message(a, JSON.stringify({ type: 'signal', to: b.data.peer, data: {} })); await hub.websocket.message(a, JSON.stringify({ type: 'signal', to: b.data.peer, data: {} }));
expect(a.sent.at(-1)).toMatchObject({ type: 'error', error: 'peer offline' }); expect(a.sent.at(-1)).toMatchObject({ type: 'error', error: 'use a confirmed direct-transfer invitation' });
expect(hub.send('dev_000000000000000a', { type: 'ping' })).toBe(true); expect(hub.send('dev_000000000000000a', { type: 'ping' })).toBe(true);
expect(hub.send('dev_000000000000000b', { type: 'ping' })).toBe(false); expect(hub.send('dev_000000000000000b', { type: 'ping' })).toBe(false);
}); });
@@ -103,3 +104,26 @@ test('rehydrator asks one online sharing holder, once per 10 min, only when the
expect(await rehydrate('dQw4w9WgXcQ')).toBe(false); // server copy is back expect(await rehydrate('dQw4w9WgXcQ')).toBe(false); // server copy is back
expect(await rehydrate('unknownVid1')).toBe(false); // nobody holds it expect(await rehydrate('unknownVid1')).toBe(false); // nobody holds it
}); });
test('direct directory and invitations are restricted to authorized profile sockets', async () => {
const hub = createP2pHub({ getDevice: p2pDb.getDevice, authorizeProfile: async (name, secret) => secret === 'proof' ? name : '' });
const a = fakeWs({ budget: [] }), b = fakeWs({ budget: [] }), c = fakeWs({ budget: [] });
for (const [ws, id, secret, profile] of [[a, 'a', SECRET_A, 'shared'], [b, 'b', SECRET_B, 'shared'], [c, 'c', 'x', 'different']]) {
await hub.websocket.message(ws, JSON.stringify({ type: 'auth', device: 'dev_000000000000000' + id, secret, profile, profileSecret: 'proof' }));
}
await hub.websocket.message(a, JSON.stringify({ type: 'direct-inventory', ids: ['localSong'] }));
expect(b.sent.at(-1).peers[0].files).toEqual(['localSong']);
expect(c.sent.at(-1).peers).toEqual([]);
const before = c.sent.length;
await hub.websocket.message(a, JSON.stringify({ type: 'direct', action: 'request', to: peerIdOf('dev_000000000000000c'), id: 'localSong' }));
expect(c.sent.length).toBe(before);
await hub.websocket.message(b, JSON.stringify({ type: 'direct', action: 'request', to: peerIdOf('dev_000000000000000a'), id: 'localSong' }));
expect(a.sent.at(-1).action).toBe('request');
});
test('binary websocket payloads cannot serve as signalling envelopes', async () => {
const hub = createP2pHub({ getDevice: p2pDb.getDevice });
const ws = fakeWs({ budget: [] });
await hub.websocket.message(ws, Buffer.from(JSON.stringify({ type: 'auth', device: 'dev_000000000000000a', secret: SECRET_A })));
expect(ws.sent).toEqual([]); expect(ws.data.authed).not.toBe(true);
});

View File

@@ -18,6 +18,7 @@
* link); the host resumes it with the secret it was given. * link); the host resumes it with the secret it was given.
* ========================================================================== */ * ========================================================================== */
import { createDirectRelay } from './direct-relay.js';
import { createDJQueue, changeDJQueue, queueSnapshot, takeDJVideo } from './party-dj.js'; import { createDJQueue, changeDJQueue, queueSnapshot, takeDJVideo } from './party-dj.js';
import { randomBytes, randomInt, timingSafeEqual } from 'node:crypto'; import { randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
@@ -89,7 +90,7 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
if (p && !same(p.secret, d.secret)) { ws.close(4003, 'not the host of this party'); return; } if (p && !same(p.secret, d.secret)) { ws.close(4003, 'not the host of this party'); return; }
if (!p) { if (!p) {
const code = newCode(); const code = newCode();
p = { code, secret: randomBytes(18).toString('base64url'), hostPid: d.pid, members: new Map(), state: null, chat: [], dj: createDJQueue(), allowControl: false, hostOnline: true, hostLeftAt: 0 }; p = { direct: createDirectRelay(), code, secret: randomBytes(18).toString('base64url'), hostPid: d.pid, members: new Map(), state: null, chat: [], dj: createDJQueue(), allowControl: false, hostOnline: true, hostLeftAt: 0 };
parties.set(code, p); parties.set(code, p);
} }
p.hostPid = d.pid; p.hostPid = d.pid;
@@ -118,7 +119,9 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
const me = p && p.members.get(d.pid); const me = p && p.members.get(d.pid);
if (!me || me.ws !== ws || !m || typeof m !== 'object') return; if (!me || me.ws !== ws || !m || typeof m !== 'object') return;
const isHost = d.pid === p.hostPid; const isHost = d.pid === p.hostPid;
if (m.type === 'dj') { if (m.type === 'direct') {
p.direct.handle(me.pid, m, new Map([...p.members].map(([pid, member]) => [pid, member.ws])), send);
} else if (m.type === 'dj') {
if (m.action === 'take' && isHost) { const video = takeDJVideo(p.dj); send(ws, { type: 'dj-play', video }); if (video) broadcast(p, { type: 'dj', ...queueSnapshot(p.dj) }); } if (m.action === 'take' && isHost) { const video = takeDJVideo(p.dj); send(ws, { type: 'dj-play', video }); if (video) broadcast(p, { type: 'dj', ...queueSnapshot(p.dj) }); }
else if (changeDJQueue(p.dj, m, me.pid, isHost)) broadcast(p, { type: 'dj', ...queueSnapshot(p.dj) }); else if (changeDJQueue(p.dj, m, me.pid, isHost)) broadcast(p, { type: 'dj', ...queueSnapshot(p.dj) });
} else if (m.type === 'state' && isHost) { } else if (m.type === 'state' && isHost) {
@@ -167,7 +170,7 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
return { return {
upgrade, upgrade,
websocket: { maxPayloadLength: MAX_MSG, idleTimeout: 120, open, message: (ws, msg) => message(ws, typeof msg === 'string' ? msg : Buffer.from(msg).toString('utf8')), close }, websocket: { maxPayloadLength: MAX_MSG, idleTimeout: 120, open, message: (ws, msg) => message(ws, typeof msg === 'string' ? msg : null), close },
_parties: parties, _parties: parties,
stop() { clearInterval(sweeper); }, stop() { clearInterval(sweeper); },
}; };

View File

@@ -28,6 +28,8 @@
* reach the server from the same public IP (i.e. the same home/church Wi-Fi). * reach the server from the same public IP (i.e. the same home/church Wi-Fi).
* ========================================================================== */ * ========================================================================== */
import { createDirectRelay } from './direct-relay.js';
import { createHash, createHmac, randomBytes, randomInt, timingSafeEqual } from 'node:crypto'; import { createHash, createHmac, randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
export const REMOTE_COMMANDS = new Set([ export const REMOTE_COMMANDS = new Set([
@@ -145,7 +147,7 @@ export function createRemoteHub({ requireSameNetwork = false } = {}) {
const id = roomIdFor(d.secret); const id = roomIdFor(d.secret);
let room = rooms.get(id); let room = rooms.get(id);
if (!room) { if (!room) {
room = { id, secret: d.secret, host: null, hostName: d.name, hostIp: d.ip, remotes: new Map(), state: null, queue: null, code: null, codeExpires: 0, lastSeen: Date.now() }; room = { direct: createDirectRelay(), id, secret: d.secret, host: null, hostName: d.name, hostIp: d.ip, remotes: new Map(), state: null, queue: null, code: null, codeExpires: 0, lastSeen: Date.now() };
rooms.set(id, room); rooms.set(id, room);
} }
if (room.host && room.host !== ws) { if (room.host && room.host !== ws) {
@@ -158,7 +160,7 @@ export function createRemoteHub({ requireSameNetwork = false } = {}) {
d.roomId = id; d.roomId = id;
issueCode(room); issueCode(room);
send(ws, { type: 'hello', roomId: id, code: room.code, codeExpires: room.codeExpires, remotes: remoteList(room) }); send(ws, { type: 'hello', roomId: id, code: room.code, codeExpires: room.codeExpires, remotes: remoteList(room) });
for (const r of room.remotes.values()) send(r.ws, { type: 'hello', hostName: room.hostName, state: room.state, queue: room.queue }); for (const r of room.remotes.values()) send(r.ws, { type: 'hello', you: r.ws.data.rid, hostName: room.hostName, state: room.state, queue: room.queue });
} }
function openRemote(ws) { function openRemote(ws) {
@@ -172,7 +174,7 @@ export function createRemoteHub({ requireSameNetwork = false } = {}) {
if (prev && prev.ws !== ws) { try { prev.ws.close(4000, 'replaced'); } catch { /* gone */ } } if (prev && prev.ws !== ws) { try { prev.ws.close(4000, 'replaced'); } catch { /* gone */ } }
room.remotes.set(d.rid, { ws, name: d.name, since: Date.now() }); room.remotes.set(d.rid, { ws, name: d.name, since: Date.now() });
room.lastSeen = Date.now(); room.lastSeen = Date.now();
send(ws, { type: 'hello', hostName: room.hostName, state: room.state, queue: room.queue }); send(ws, { type: 'hello', you: d.rid, hostName: room.hostName, state: room.state, queue: room.queue });
send(room.host, { type: 'remotes', remotes: remoteList(room), joined: d.name }); send(room.host, { type: 'remotes', remotes: remoteList(room), joined: d.name });
} }
@@ -185,6 +187,14 @@ export function createRemoteHub({ requireSameNetwork = false } = {}) {
const room = rooms.get(d.roomId); const room = rooms.get(d.roomId);
if (!room) return; if (!room) return;
room.lastSeen = Date.now(); room.lastSeen = Date.now();
if (m.type === 'direct') {
const me = d.role === 'host' ? (room.host === ws ? 'host' : null) : (room.remotes.get(d.rid)?.ws === ws ? d.rid : null);
if (!me) return;
const peers = new Map([...room.remotes].map(([rid, r]) => [rid, r.ws]));
if (room.host) peers.set('host', room.host);
room.direct.handle(me, m, peers, send);
return;
}
if (d.role === 'host') { if (d.role === 'host') {
if (room.host !== ws) return; if (room.host !== ws) return;
@@ -248,7 +258,7 @@ export function createRemoteHub({ requireSameNetwork = false } = {}) {
maxPayloadLength: MAX_MSG, maxPayloadLength: MAX_MSG,
idleTimeout: 120, idleTimeout: 120,
open(ws) { if (ws.data.role === 'host') openHost(ws); else openRemote(ws); }, open(ws) { if (ws.data.role === 'host') openHost(ws); else openRemote(ws); },
message(ws, msg) { onMessage(ws, typeof msg === 'string' ? msg : Buffer.from(msg).toString('utf8')); }, message(ws, msg) { onMessage(ws, typeof msg === 'string' ? msg : null); },
close(ws) { onClose(ws); }, close(ws) { onClose(ws); },
}; };

View File

@@ -2338,7 +2338,18 @@ async function fileForCid(cid) {
return (await f.exists()) ? { path, size: f.size } : null; return (await f.exists()) ? { path, size: f.size } : null;
} }
const p2p = registerP2pRoutes(app, { cfg: P2P, p2pDb, fileForCid, sha256Range }); const p2p = registerP2pRoutes(app, { cfg: P2P, p2pDb, fileForCid, sha256Range });
const p2pHub = createP2pHub({ getDevice: p2pDb.getDevice, enabled: () => P2P.enabled }); const directProfileFails = new Map();
const p2pHub = createP2pHub({ getDevice: p2pDb.getDevice, enabled: () => P2P.enabled, authorizeProfile: async (name, secret, device) => {
name = typeof name === 'string' ? name.trim().toLowerCase() : '';
if (!name || name.length > 80) return '';
const row = await getProfile(name); if (!row) return '';
if (!row.secretHash) return name;
const key = name + ':' + device, old = directProfileFails.get(key);
if (old && old.count >= 10 && Date.now() - old.at < 900000) return '';
if (typeof secret === 'string' && secret.length <= 1024 && await Bun.password.verify(secret, row.secretHash).catch(() => false)) { directProfileFails.delete(key); return name; }
directProfileFails.set(key, { count: old && Date.now() - old.at < 900000 ? old.count + 1 : 1, at: Date.now() });
if (directProfileFails.size > 5000) directProfileFails.clear(); return '';
} });
// GET /api/p2p/holders?v=<videoId>|cid=<sha256> — who holds a copy. Rows are // GET /api/p2p/holders?v=<videoId>|cid=<sha256> — who holds a copy. Rows are
// persistent; `stale` flags a holder not re-verified for P2P_STALE_DAYS. // persistent; `stale` flags a holder not re-verified for P2P_STALE_DAYS.

View File

@@ -0,0 +1,28 @@
const { test, expect } = require('@playwright/test');
for (const prepareDelay of [0, 12000]) test(`prepare delay ${prepareDelay}: paired pages save verified bytes directly over WebRTC with metadata-only server logs`, async ({ browser, request }) => {
const a = await browser.newContext(), b = await browser.newContext();
const sender = await a.newPage(), receiver = await b.newPage();
await sender.goto('/?id=sender'); await receiver.goto('/?id=receiver&prepareDelay=' + prepareDelay);
await sender.waitForFunction(() => window.ready); await receiver.waitForFunction(() => window.ready);
await receiver.evaluate(async () => {
const bytes = Uint8Array.from({ length: 2000000 }, (_, i) => i % 251), h = Sha256.create(); h.update(bytes);
const dir = await (await navigator.storage.getDirectory()).getDirectoryHandle('videos', { create: true });
const w = await (await dir.getFileHandle(`testvideo.${h.hex()}.direct.part`, { create: true })).createWritable();
await w.write(bytes.slice(0, 12345)); await w.close();
});
await sender.locator('#send').click();
await expect(receiver.getByRole('dialog')).toBeVisible();
expect(await receiver.evaluate(() => !!window.received)).toBe(false);
await receiver.getByRole('button', { name: 'Accept & save' }).click();
await receiver.waitForFunction(() => !!window.received);
const result = await receiver.evaluate(async () => {
const d = await (await navigator.storage.getDirectory()).getDirectoryHandle('videos');
const f = await (await d.getFileHandle('testvideo.mp4')).getFile();
const hash = Sha256.create(); hash.update(new Uint8Array(await f.arrayBuffer())); return { size: f.size, cid: hash.hex(), expected: window.received.cid };
});
expect(await receiver.evaluate(() => window.resumeOffsets)).toEqual([12345]);
expect(result.size).toBe(2000000); expect(result.cid).toBe(result.expected);
const logs = await (await request.get('/audit')).json();
expect(logs.every(x => !x.http && ['invite','accept','signal','complete'].includes(x.signalling))).toBe(true);
await a.close(); await b.close();
});

27
tests/fixtures/direct-server.js vendored Normal file
View File

@@ -0,0 +1,27 @@
import { createDirectRelay } from '../../server/direct-relay.js';
const relay = createDirectRelay(), peers = new Map(), log = [];
Bun.serve({ port: 8095,
async fetch(req, server) {
const url = new URL(req.url);
if (url.pathname === '/ws') { const id = url.searchParams.get('id'); if (!['sender', 'receiver'].includes(id)) return new Response('test member required', { status: 403 }); server.upgrade(req, { data: { id } }); return; }
if (url.pathname === '/audit') return Response.json(log);
if (url.pathname.startsWith('/api/')) { log.push({ http: url.pathname }); return new Response('Media forbidden in direct fixture', { status: 500 }); }
if (url.pathname === '/') return new Response(`<!doctype html><button id="send">Send</button><div id="status"></div><script src="/sha256-wasm.js"></script><script src="/sha256.js"></script><script src="/direct-protocol.js"></script><script src="/settings-sections.js"></script><script src="/direct-media.js"></script><script>
const id = new URL(location.href).searchParams.get('id');
const ws = new WebSocket('ws://localhost:8095/ws?id=' + id);
window.resumeOffsets = []; const NativeWorker = window.Worker; window.Worker = class extends NativeWorker { constructor(...args) { super(...args); this.addEventListener('message', e => { if(e.data.op === 'opened') window.resumeOffsets.push(e.data.offset); }); } set onmessage(fn) { super.onmessage = e => { const delay = Number(new URL(location.href).searchParams.get('prepareDelay')) || 0; if(e.data.op === 'opened' && delay) setTimeout(() => fn(e), delay); else fn(e); }; } };
const meta = { id: 'testvideo', title: 'Direct test', channel: 'Fixture', duration: 1 };
window.OPFS = { getFileObject: async () => new File([Uint8Array.from({length: 2000000}, (_,i)=>i%251)], 'test.mp4') };
DirectMedia.configure({ current: () => meta, notify: text => document.getElementById('status').textContent = text, saved: file => window.received = file, fallback: () => window.failed = true });
ws.onopen = () => { DirectMedia.attach('test', { send: m => ws.send(JSON.stringify(m)), peers: () => [{id: id === 'sender' ? 'receiver' : 'sender', name: 'Other'}] }); window.ready = true; };
ws.onmessage = e => DirectMedia.handle('test', JSON.parse(e.data));
document.getElementById('send').onclick = () => DirectMedia.offer('test','receiver',meta);
</script>`, { headers: { 'Content-Type': 'text/html' } });
const path = url.pathname.slice(1); if (!/^[a-z0-9-]+\.js$/.test(path)) return new Response('not found', { status: 404 });
return new Response(Bun.file(`frontend/${path}`));
}, websocket: {
open(ws) { peers.set(ws.data.id, ws); },
message(ws, message) { log.push({ signalling: typeof message === 'string' ? JSON.parse(message).action : 'BINARY' }); relay.handle(ws.data.id, typeof message === 'string' ? message : null, peers, (target,m) => target.send(JSON.stringify(m))); },
close(ws) { peers.delete(ws.data.id); },
}
});

View File

@@ -0,0 +1,83 @@
const { test, expect } = require('@playwright/test');
const { execFileSync } = require('node:child_process');
const { openClassic } = require('./helpers/classic-fixture');
const { differentPixels } = require('./helpers/png-diff');
const { readFileSync } = require('node:fs');
const phoneCss = readFileSync('frontend/styles.css', 'utf8').replaceAll('(display-mode: standalone)', '(min-width: 0px)');
const beforeCss = execFileSync('git', ['show', '5e78374:frontend/theme-glass-controls.css'], { encoding: 'utf8' });
async function setup(page, layout) {
const phone = page.viewportSize().width === 390;
if (phone) await page.route('**/styles.css', route => route.fulfill({ contentType: 'text/css', body: phoneCss }));
await openClassic(page, { populated: true, standalone: phone, settings: { autoPreload: false } });
await page.addStyleTag({ content: '*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}#bottomNav{display:flex!important}' });
await page.evaluate(layout => { data.settings.layout = layout; applyAppearance(); view = { type: 'queue' }; render(); }, layout);
await page.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); });
}
for (const layout of ['glass-stage', 'classic']) for (const width of [390, 1440]) {
test(`${layout} bottom tabs at ${width}`, async ({ page, context }) => {
await page.setViewportSize({ width, height: 900 });
const before = await context.newPage(); await before.setViewportSize({ width, height: 900 });
await before.route('**/theme-glass-controls.css', route => route.fulfill({ contentType: 'text/css', body: beforeCss }));
await setup(before, layout);
const baseline = await before.screenshot({ path: `/tmp/ytplayer-done8-A-before-${layout}-${width}.png` });
await setup(page, layout);
const result = await page.screenshot({ path: `/tmp/ytplayer-done8-A-after-${layout}-${width}.png` });
if (layout === 'classic') {
const geometry = p => p.locator('.bottom-nav-btn,.bottom-nav-icon,.bottom-nav-label').evaluateAll(nodes => nodes.map(el => { const r = el.getBoundingClientRect(), s = getComputedStyle(el); return [r.x,r.y,r.width,r.height,s.padding,s.color,s.display]; }));
expect(await geometry(page)).toEqual(await geometry(before));
expect(differentPixels(baseline, result)).toBeLessThanOrEqual(32);
}
else {
const positions = await page.locator('.bottom-nav-btn').evaluateAll(buttons => buttons.map(button => {
const icon = button.querySelector('svg').getBoundingClientRect(), label = button.querySelector('.bottom-nav-label').getBoundingClientRect();
return { offset: Math.abs(icon.x + icon.width / 2 - label.x - label.width / 2), above: icon.bottom <= label.top, target: button.getBoundingClientRect().height };
}));
expect(positions.every(p => p.offset <= 1 && p.above && p.target >= 44)).toBe(true);
const badge = await page.locator('#bnQueueCount').evaluate(el => {
const icon = el.parentElement.querySelector('svg').getBoundingClientRect(), r = el.getBoundingClientRect();
return { x: r.left - icon.right, y: r.top - icon.top };
});
expect(Math.abs(badge.x)).toBeLessThanOrEqual(5);
expect(Math.abs(badge.y)).toBeLessThanOrEqual(5);
await expect(page.locator('.bottom-nav-btn.active')).toHaveAttribute('aria-label', 'Queue');
}
await before.close();
});
}
const beforeIcons = execFileSync('git', ['show', '79a3c28:frontend/glass-controls.js'], { encoding: 'utf8' });
async function sidebarFixture(page, layout) {
await openClassic(page, { populated: true, settings: { autoPreload: false } });
await page.addStyleTag({ content: '*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}' });
await page.evaluate(layout => {
data.settings.layout = layout; applyAppearance();
data.playlists[0].videos.forEach(v => cachedIds.add(v.id));
view = { type: 'playlist', id: 'studio' }; render(); openSidebar();
}, layout);
await page.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); });
}
for (const layout of ['glass-stage', 'classic']) for (const width of [390, 1440]) {
test(`${layout} playlist sidebar icons at ${width}`, async ({ page, context }) => {
await page.setViewportSize({ width, height: 900 });
const before = await context.newPage(); await before.setViewportSize({ width, height: 900 });
await before.route('**/glass-controls.js', route => route.fulfill({ contentType: 'text/javascript', body: beforeIcons }));
await sidebarFixture(before, layout);
const baseline = await before.screenshot({ path: `/tmp/ytplayer-done8-B-before-${layout}-${width}.png` });
await sidebarFixture(page, layout);
const result = await page.screenshot({ path: `/tmp/ytplayer-done8-B-after-${layout}-${width}.png` });
const badge = page.locator('.sidebar .pl-offline-badge.all').first();
if (layout === 'classic') {
expect(differentPixels(baseline, result)).toBeLessThanOrEqual(32);
await expect(badge).toHaveText('⬇');
} else {
await expect(badge.locator('svg')).toHaveCount(1);
expect(await badge.locator('svg').evaluate(el => getComputedStyle(el).strokeWidth)).toBe('1.9px');
await expect(page.locator('.home-pl-play svg').first()).toHaveCount(1);
await expect(page.locator('#listActions .la-primary svg')).toHaveCount(1);
await page.evaluate(() => { data.settings.layout = 'classic'; applyAppearance(); });
await expect(badge).toHaveText('⬇');
await expect(page.locator('.glass-control-icon')).toHaveCount(0);
}
await before.close();
});
}

137
tests/glass-panel.spec.js Normal file
View File

@@ -0,0 +1,137 @@
const { test, expect } = require('@playwright/test');
const { readFileSync } = require('node:fs');
const { openClassic, showPlayingFixture } = require('./helpers/classic-fixture');
const before = process.env.CAPTURE_BEFORE === '1';
const phoneCss = readFileSync('frontend/styles.css', 'utf8').replaceAll('(display-mode: standalone)', '(min-width: 0px)').replaceAll('env(safe-area-inset-bottom)', '34px');
async function setup(page, width, layout = 'glass-stage') {
await page.setViewportSize({ width, height: 844 });
if (width < 900) await page.route('**/styles.css', r => r.fulfill({ contentType: 'text/css', body: phoneCss }));
await openClassic(page, { populated: true, standalone: width < 900, settings: { autoPreload: false } });
await showPlayingFixture(page);
await page.addStyleTag({ content: '*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}' });
await page.evaluate(layout => {
data.settings.layout = layout; applyAppearance();
document.getElementById('notesPanel').classList.remove('hidden');
relatedVideos = data.history.slice(1, 4); renderRelated();
document.getElementById('miniTitle').textContent = current.meta.title;
document.getElementById('miniChannel').textContent = current.meta.channel;
document.getElementById('miniProgressFill').style.width = '25%';
SectionRail.refresh();
}, layout);
await page.evaluate(async () => { await document.fonts.ready; await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); });
}
for (const width of [390, 1440]) test(`panel rail and bottom clearance at ${width}`, async ({ page }, info) => {
await setup(page, width);
// The desktop rail was hidden in the old build; expose it for baseline evidence.
if (before && width === 1440) await page.addStyleTag({ content: 'html[data-layout="glass-stage"] #sectionRail{display:flex!important}' });
for (const section of ['player', 'meta', 'notes', 'related', 'list']) {
await page.locator(`#sectionRail [data-sec="${section}"]`).click();
await page.evaluate(() => { document.getElementById('miniBar').classList.remove('hidden'); updateBottomChrome(); });
await page.waitForTimeout(100);
if (!before) {
const metrics = await page.evaluate(() => {
const rail = document.getElementById('sectionRail'), header = document.querySelector('#listPane .list-header');
const r = rail.getBoundingClientRect(), h = header.getBoundingClientRect();
const m = document.getElementById('miniBar').getBoundingClientRect(), p = document.getElementById('miniProgress').getBoundingClientRect(), nav = document.getElementById('bottomNav').getBoundingClientRect();
return { parent: rail.parentElement.id, railBottom: r.bottom, headingTop: h.top, miniBottom: m.bottom, progressBottom: p.bottom, navTop: nav.height ? nav.top : innerHeight, overflow: getComputedStyle(document.getElementById('miniBar')).overflow };
});
expect(metrics.parent).toBe('listPane');
expect(metrics.railBottom).toBeLessThanOrEqual(metrics.headingTop);
expect(metrics.miniBottom).toBeLessThanOrEqual(metrics.navTop);
expect(metrics.progressBottom).toBeLessThanOrEqual(metrics.miniBottom);
expect(metrics.overflow).toBe('hidden');
}
await page.screenshot({ path: `/tmp/done9-${before ? 'before' : 'after'}-${info.project.name}-${width}-${section}.png` });
}
if (!before) {
await page.evaluate(() => {
const body = document.querySelector('.body'); body.scrollTop = body.scrollHeight;
const cards = document.getElementById('cards'); cards.scrollTop = cards.scrollHeight;
document.getElementById('miniBar').classList.remove('hidden'); updateBottomChrome();
});
await page.waitForTimeout(100);
const tail = await page.locator('#cards .card').last().boundingBox(), mini = await page.locator('#miniBar').boundingBox();
expect(tail.y + tail.height).toBeLessThanOrEqual(mini.y - 8);
}
});
module.exports = { setup };
for (const width of [390, 1440]) test(`Classic panel layout unchanged at ${width}`, async ({ page, context }, info) => {
const baseline = await context.newPage();
await baseline.route('**/theme-glass-panel.css', r => r.fulfill({ contentType: 'text/css', body: '' }));
await baseline.route('**/glass-panel-layout.js', r => r.fulfill({ contentType: 'text/javascript', body: '' }));
await setup(baseline, width, 'classic'); await setup(page, width, 'classic');
const geometry = p => p.locator('#listPane,#playerPane,#sectionRail,#miniBar,.notes-tabs').evaluateAll(nodes => nodes.map(el => { const r = el.getBoundingClientRect(), s = getComputedStyle(el); return [el.id, r.x,r.y,r.width,r.height,s.padding,s.position,s.overflow]; }));
expect(await geometry(page)).toEqual(await geometry(baseline));
const a = await baseline.screenshot({ path: `/tmp/done9-classic-before-${info.project.name}-${width}.png` });
const b = await page.screenshot({ path: `/tmp/done9-classic-after-${info.project.name}-${width}.png` });
const { differentPixels } = require('./helpers/png-diff');
expect(differentPixels(a, b)).toBeLessThanOrEqual(32);
await baseline.close();
});
for (const width of [390]) test(`iPhone bottom icons centered above labels with badges at ${width}`, async ({ browser }, info) => {
const { devices } = require('@playwright/test');
const { execFileSync } = require('node:child_process');
const context = await browser.newContext({ ...devices['iPhone 13'], viewport: { width, height: 844 }, serviceWorkers: 'block', baseURL: 'http://localhost:8096' });
const baseline = await context.newPage(), page = await context.newPage();
const oldCss = execFileSync('git', ['show', 'f2c9459:frontend/theme-glass-controls.css'], { encoding: 'utf8' });
await baseline.route('**/theme-glass-controls.css', r => r.fulfill({ contentType: 'text/css', body: oldCss }));
for (const p of [baseline, page]) {
await setup(p, width);
await p.evaluate(() => {
view = { type: 'history' }; render();
for (const [id, count] of [['bnQueueCount', '12'], ['bnDlCount', '7']]) { const b = document.getElementById(id); b.textContent = count; b.classList.remove('hidden'); }
});
}
const centers = p => p.locator('.bottom-nav-btn').evaluateAll(buttons => buttons.map(b => {
const icon = b.querySelector('svg').getBoundingClientRect(), box = b.querySelector('.bottom-nav-icon').getBoundingClientRect(), label = b.querySelector('.bottom-nav-label').getBoundingClientRect();
const s = getComputedStyle(b.querySelector('.bottom-nav-icon')), badge = b.querySelector('.bottom-nav-badge'), r = badge?.getBoundingClientRect();
return { icon: icon.x + icon.width / 2, box: box.x + box.width / 2, label: label.x + label.width / 2, iconBottom: icon.bottom, labelTop: label.top, padding: s.padding, buttonWidth: b.getBoundingClientRect().width, badge: r?.height ? { left: r.left, top: r.top } : null, iconRight: icon.right, iconTop: icon.top };
}));
// Reproduce the uploaded pre-Task-A build's shift before asserting the corrected build.
expect((await centers(baseline)).every(x => x.icon - x.label > 8)).toBe(true);
for (const x of await centers(page)) {
expect(Math.abs(x.icon - x.label)).toBeLessThan(0.6);
expect(Math.abs(x.box - x.label)).toBeLessThan(0.6);
expect(x.padding).toBe('0px'); expect(x.buttonWidth).toBeGreaterThanOrEqual(44);
expect(x.iconBottom).toBeLessThan(x.labelTop);
if (x.badge) { expect(Math.abs(x.badge.left - x.iconRight)).toBeLessThanOrEqual(4); expect(x.badge.top).toBeLessThan(x.iconTop); }
}
await expect(page.locator('.bottom-nav-btn[data-view="history"]')).toHaveClass(/active/);
await baseline.locator('#bottomNav').screenshot({ path: `/tmp/done9-nav-before-${info.project.name}-390.png` });
await page.locator('#bottomNav').screenshot({ path: `/tmp/done9-nav-after-${info.project.name}-390.png` });
await context.close();
});
for (const width of [320, 360, 390, 430, 1440]) test(`Now Playing toolbar remains one row at ${width}`, async ({ page }, info) => {
await setup(page, width);
await page.evaluate(() => { document.getElementById('notesStatus').textContent = 'Verse · shared lyrics'; document.getElementById('markersCount').textContent = '12'; });
await page.locator('#notesPanel').scrollIntoViewIfNeeded();
if (!before) {
const metrics = await page.locator('.notes-tabs').evaluate(bar => {
const b = bar.getBoundingClientRect(), buttons = [...bar.querySelectorAll('button')].map(el => { const r = el.getBoundingClientRect(); return { top: r.top, bottom: r.bottom, left: r.left, right: r.right, width: r.width, height: r.height, label: el.getAttribute('aria-label') || el.getAttribute('title') }; });
return { bar: { left: b.left, right: b.right }, buttons, overflow: bar.scrollWidth - bar.clientWidth };
});
expect(Math.max(...metrics.buttons.map(b => b.top)) - Math.min(...metrics.buttons.map(b => b.top))).toBeLessThan(1);
expect(metrics.overflow).toBeLessThanOrEqual(1);
for (const b of metrics.buttons) { expect(b.width).toBeGreaterThanOrEqual(44); expect(b.height).toBeGreaterThanOrEqual(44); expect(b.left).toBeGreaterThanOrEqual(metrics.bar.left); expect(b.right).toBeLessThanOrEqual(metrics.bar.right); expect(b.label).toBeTruthy(); }
expect(metrics.buttons.at(-1).right).toBeGreaterThan(metrics.bar.right - 12);
}
await page.locator('#notesPanel .notes-tabs').screenshot({ path: `/tmp/done9-toolbar-${before ? 'before' : 'after'}-${info.project.name}-${width}.png` });
});
test('compact toolbar labels and accessible names restore on leaving Glass Stage', async ({ page }) => {
await setup(page, 390, 'classic');
const original = await page.locator('.notes-tab').evaluateAll(nodes => nodes.map(n => ({ html: n.innerHTML, label: n.getAttribute('aria-label'), title: n.getAttribute('title') })));
for (let i = 0; i < 2; i++) {
await page.evaluate(() => { data.settings.layout = 'glass-stage'; applyAppearance(); });
await expect(page.locator('.glass-tab-label')).toHaveCount(3);
for (const name of ['Lyrics', 'Chapters & bookmarks', 'Search']) await expect(page.getByRole('tab', { name, exact: true })).toHaveCount(1);
await page.evaluate(() => { data.settings.layout = 'classic'; applyAppearance(); });
await expect(page.locator('.glass-tab-label')).toHaveCount(0);
expect(await page.locator('.notes-tab').evaluateAll(nodes => nodes.map(n => ({ html: n.innerHTML, label: n.getAttribute('aria-label'), title: n.getAttribute('title') })))).toEqual(original);
await expect(page.locator('#sectionRail')).not.toHaveJSProperty('parentElement', null);
expect(await page.locator('#sectionRail').evaluate(n => n.parentElement.tagName)).toBe('BODY');
}
});