Compare commits
13 Commits
5e78374efb
...
49ba470740
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49ba470740 | ||
|
|
729e73e131 | ||
|
|
3c0adb2577 | ||
|
|
d3f5133572 | ||
|
|
90ef21297a | ||
|
|
d78b06ff4b | ||
|
|
5661eea6e6 | ||
|
|
795a5aaed4 | ||
|
|
08ff8398b5 | ||
|
|
88e2e67eeb | ||
|
|
ef70da1806 | ||
|
|
d93fc0d70f | ||
|
|
79a3c28c8e |
67
docs/glass-stage-iphone-followup.md
Normal file
67
docs/glass-stage-iphone-followup.md
Normal 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
153
docs/p2p-transfer.md
Normal 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.
|
||||
@@ -1795,18 +1795,24 @@ function recordDeviceFile(id, res) {
|
||||
const state = cid ? (res.expectedSha === cid ? 'verified' : 'unverified') : 'unhashed';
|
||||
const now = Date.now();
|
||||
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(() => {});
|
||||
}
|
||||
|
||||
// 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;
|
||||
// Custom (edited) videos have no YouTube source to (re)download — their
|
||||
// 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).
|
||||
if (video.custom) 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);
|
||||
downloadMeta.set(id, slim(video));
|
||||
markCardCacheState(id, 'downloading');
|
||||
@@ -2259,6 +2265,11 @@ const Player = {
|
||||
_currentBlobUrl = null;
|
||||
}
|
||||
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.
|
||||
if (!preferStream && cachedIds.has(videoObj.id)) {
|
||||
let localUrl = null;
|
||||
@@ -3104,6 +3115,8 @@ async function renderAvailability(id) {
|
||||
// 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.
|
||||
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;
|
||||
downloading.add(id);
|
||||
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)); };
|
||||
|
||||
function onHostMessage(m) {
|
||||
DirectMedia.attach('remote-host', { send: hostSend, peers: () => host.remotes.map(r => ({ id: r.rid, name: r.name })) });
|
||||
if (m.type === 'direct') { DirectMedia.handle('remote-host', m); return; }
|
||||
if (m.type === 'hello') {
|
||||
host.code = m.code; host.codeExpires = m.codeExpires; host.remotes = m.remotes || [];
|
||||
host.lastState = ''; host.lastQueue = '';
|
||||
@@ -4779,6 +4794,7 @@ const Remote = (() => {
|
||||
const meta = current && current.meta;
|
||||
const M = Player.master;
|
||||
return {
|
||||
directAvailable: !!meta && cachedIds.has(meta.id) && DirectMedia.enabled() && data.settings.p2pShare !== false,
|
||||
v: meta ? { id: meta.id, title: meta.title || '', channel: meta.channel || '', thumbnail: meta.thumbnail || '' } : null,
|
||||
cur: M ? Math.floor((M.currentTime || 0) * 10) / 10 : 0,
|
||||
dur: (M && isFinite(M.duration) && M.duration) || (meta && meta.duration) || 0,
|
||||
@@ -5061,6 +5077,8 @@ const Remote = (() => {
|
||||
}
|
||||
|
||||
function onRemoteMessage(m) {
|
||||
DirectMedia.attach('remote-client', { send: rcSend, peers: () => [{ id: 'host', name: 'Paired screen', files: rc.state?.directAvailable && rc.state?.v ? [rc.state.v.id] : [] }] });
|
||||
if (m.type === 'direct') { DirectMedia.handle('remote-client', m); return; }
|
||||
if (m.type === 'hello') {
|
||||
rc.online = true;
|
||||
$('rvHostName').textContent = m.hostName || 'Screen';
|
||||
@@ -5099,6 +5117,11 @@ const Remote = (() => {
|
||||
const v = s && s.v;
|
||||
$('rvTitle').textContent = v ? v.title : 'Nothing playing';
|
||||
$('rvChannel').textContent = v ? v.channel : '';
|
||||
let directPlay = $('rvDirectPlay');
|
||||
if (!directPlay) { directPlay = document.createElement('button'); directPlay.id = 'rvDirectPlay'; directPlay.className = 'np-btn'; directPlay.style.minHeight = '44px'; directPlay.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');
|
||||
if (v && v.thumbnail) { if (thumb.getAttribute('src') !== v.thumbnail) thumb.src = v.thumbnail; thumb.classList.remove('hidden'); }
|
||||
else thumb.classList.add('hidden');
|
||||
@@ -6069,6 +6092,8 @@ const Presenter = (() => {
|
||||
st.ws = ws;
|
||||
ws.onmessage = (e) => {
|
||||
let m; try { m = JSON.parse(e.data); } catch { return; }
|
||||
if (!overlay) { DirectMedia.attach('presenter', { send: m => ws.send(JSON.stringify(m)), peers: () => [{ id: 'host', name: st.hostName || 'Screen' }] }); }
|
||||
if (m.type === 'direct') { if (!overlay) DirectMedia.handle('presenter', m); return; }
|
||||
if (m.type === 'hello') { st.hostName = m.hostName || 'screen'; if (m.state) onState(m.state); else setStatus(`Following ${st.hostName}`); }
|
||||
else if (m.type === 'state') onState(m.state);
|
||||
else if (m.type === 'host-offline') { if (overlay) { st.all = []; LowerThird.paint([], -1); } setStatus(`${st.hostName || 'The screen'} is offline — waiting…`); }
|
||||
@@ -7452,6 +7477,8 @@ const Party = (() => {
|
||||
}
|
||||
|
||||
function onMessage(m) {
|
||||
DirectMedia.attach('party', { send, peers: () => st.members.filter(m => m.pid !== pid).map(m => ({ id: m.pid, name: m.name, files: m.host && st.state?.directAvailable && st.state?.v ? [st.state.v.id] : [] })) });
|
||||
if (m.type === 'direct') { DirectMedia.handle('party', m); return; }
|
||||
if (m.type === 'hello') {
|
||||
st.code = m.code;
|
||||
if (m.secret) { st.secret = m.secret; ls.set(HOST_KEY, JSON.stringify({ code: m.code, secret: m.secret })); }
|
||||
@@ -7501,6 +7528,7 @@ const Party = (() => {
|
||||
const meta = current && current.meta;
|
||||
const M = Player.master;
|
||||
return {
|
||||
directAvailable: !!meta && cachedIds.has(meta.id) && DirectMedia.enabled() && data.settings.p2pShare !== false,
|
||||
v: meta ? { id: meta.id, title: meta.title || '', channel: meta.channel || '', thumbnail: meta.thumbnail || '', duration: meta.duration || 0 } : null,
|
||||
cur: M ? M.currentTime || 0 : 0,
|
||||
paused: !M || M.paused,
|
||||
@@ -12527,6 +12555,18 @@ async function boot() {
|
||||
Party.boot();
|
||||
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`); } });
|
||||
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();
|
||||
LyricsDisplay.register();
|
||||
LowerThird.register(() => $('remoteChip').click());
|
||||
@@ -12561,8 +12601,8 @@ async function boot() {
|
||||
startCacheResyncWatch();
|
||||
// Peer-to-peer: report what this device holds (on by default; settings.p2pShare).
|
||||
if (WEB && window.P2PClient) {
|
||||
window.P2PClient.start({ getSettings: () => data.settings, getProfile: () => (data.profile && data.profile.name) || '' });
|
||||
if (window.P2PTransfer) window.P2PTransfer.start({ canShare: () => data.settings.p2pShare !== false });
|
||||
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: () => false }); // New scoped invitations replace the unconfirmed legacy sender.
|
||||
}
|
||||
data.playlists.forEach(preloadPlaylist);
|
||||
preloadPinnedPlaylists();
|
||||
|
||||
6
frontend/direct-media.css
Normal file
6
frontend/direct-media.css
Normal 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
168
frontend/direct-media.js
Normal 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));
|
||||
41
frontend/direct-protocol.js
Normal file
41
frontend/direct-protocol.js
Normal 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));
|
||||
18
frontend/direct-protocol.test.js
Normal file
18
frontend/direct-protocol.test.js
Normal 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);
|
||||
});
|
||||
40
frontend/direct-recv-worker.js
Normal file
40
frontend/direct-recv-worker.js
Normal 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
44
frontend/direct-stream.js
Normal 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));
|
||||
12
frontend/direct-stream.test.js
Normal file
12
frontend/direct-stream.test.js
Normal 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);
|
||||
});
|
||||
@@ -2,6 +2,8 @@
|
||||
(function(root) {
|
||||
'use strict';
|
||||
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"/>',
|
||||
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"/>',
|
||||
@@ -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"/>',
|
||||
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) {
|
||||
const prefix = text.match(/^\s*/)[0], rest = text.slice(prefix.length);
|
||||
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) };
|
||||
}
|
||||
function mount(doc) {
|
||||
const decorations = new Set(); let scheduled=false;
|
||||
const selector = '#listPane button, .sidebar button, .smart-item .pl-name, .notes-tab, .notes-fold, .mini-btn';
|
||||
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, .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 observer=new root.MutationObserver(schedule), themeObserver=new root.MutationObserver(schedule);
|
||||
function restore(record) {
|
||||
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');
|
||||
}
|
||||
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() {
|
||||
scheduled=false;observer.disconnect();
|
||||
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);}
|
||||
if(active) for(const parent of doc.querySelectorAll(selector)) {
|
||||
// Existing SVGs already express the action; only legacy glyphs change.
|
||||
if(parent.querySelector('svg:not(.glass-control-icon),.legacy-nav-icon'))continue;
|
||||
for(const node of [...parent.childNodes]) {
|
||||
if(node.nodeType!==3)continue;const match=leadingGlyph(node.textContent);if(!match)continue;
|
||||
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);
|
||||
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));
|
||||
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]) {
|
||||
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});
|
||||
}
|
||||
}
|
||||
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});
|
||||
}
|
||||
function schedule(){if(!scheduled){scheduled=true;root.queueMicrotask(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};
|
||||
else if(root.document.readyState==='loading')root.document.addEventListener('DOMContentLoaded',()=>mount(root.document),{once:true});
|
||||
|
||||
@@ -7,3 +7,9 @@ test('glass icon substitution preserves leading whitespace and remaining labels'
|
||||
assert.equal(leadingGlyph('Clear all'),null);
|
||||
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');
|
||||
});
|
||||
|
||||
42
frontend/glass-panel-layout.js
Normal file
42
frontend/glass-panel-layout.js
Normal 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));
|
||||
10
frontend/glass-panel-layout.test.js
Normal file
10
frontend/glass-panel-layout.test.js
Normal 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);
|
||||
});
|
||||
@@ -28,11 +28,13 @@
|
||||
<link rel="stylesheet" href="theme-classic.css" />
|
||||
<link rel="stylesheet" href="recommendations.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="car-mode.css" />
|
||||
<link rel="stylesheet" href="offline-pages.css" />
|
||||
<link rel="stylesheet" href="saved-page.css" />
|
||||
<link rel="stylesheet" href="theme-glass-controls.css" />
|
||||
<link rel="stylesheet" href="theme-glass-panel.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
@@ -660,12 +662,16 @@
|
||||
<script src="async-guard.js"></script>
|
||||
<script src="sw-update.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="downloads.js"></script>
|
||||
<script src="downloads-page.js"></script>
|
||||
<script src="saved-page.js"></script>
|
||||
<script src="server-backfill.js"></script>
|
||||
<script src="glass-controls.js"></script>
|
||||
<script src="glass-panel-layout.js"></script>
|
||||
<script src="party-dj.js"></script>
|
||||
<script src="lyrics-window.js"></script>
|
||||
<script src="midi.js"></script>
|
||||
|
||||
@@ -216,7 +216,7 @@
|
||||
ws = sock;
|
||||
let ping = null;
|
||||
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).
|
||||
ping = setInterval(() => { try { sock.send('{"type":"ping"}'); } catch { /* closing */ } }, 50_000);
|
||||
};
|
||||
@@ -285,7 +285,7 @@
|
||||
// and only for the exact cid this device holds.
|
||||
let restoring = false;
|
||||
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;
|
||||
if (!rec || rec.cid !== m.cid) return;
|
||||
restoring = true;
|
||||
@@ -304,6 +304,7 @@
|
||||
|
||||
window.P2PClient = {
|
||||
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),
|
||||
};
|
||||
}());
|
||||
|
||||
@@ -59,6 +59,8 @@ const SHELL = [
|
||||
'/service-ui.css',
|
||||
'/refresh.css',
|
||||
'/theme-glass.css',
|
||||
'/theme-glass-panel.css',
|
||||
'/glass-panel-layout.js',
|
||||
'/theme-classic.css',
|
||||
'/recommendations.css',
|
||||
'/flag-ui.js',
|
||||
@@ -81,6 +83,11 @@ const SHELL = [
|
||||
'/hash-worker.js',
|
||||
'/p2p-client.js',
|
||||
'/p2p-core.js',
|
||||
'/direct-media.css',
|
||||
'/direct-protocol.js',
|
||||
'/direct-media.js',
|
||||
'/direct-stream.js',
|
||||
'/direct-recv-worker.js',
|
||||
'/p2p-transfer.js',
|
||||
'/p2p-recv-worker.js',
|
||||
'/settings-sections.js',
|
||||
|
||||
@@ -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"] .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-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-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 .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; }
|
||||
@@ -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-label { right:auto; left:0; bottom:50px; }
|
||||
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); }
|
||||
|
||||
56
frontend/theme-glass-panel.css
Normal file
56
frontend/theme-glass-panel.css
Normal 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; }
|
||||
}
|
||||
2
playwright.direct.config.js
Normal file
2
playwright.direct.config.js
Normal 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 } });
|
||||
2
playwright.glass-navigation.config.js
Normal file
2
playwright.glass-navigation.config.js
Normal file
@@ -0,0 +1,2 @@
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
module.exports = defineConfig({ ...require('./playwright.classic.config'), testMatch: /glass-navigation\.spec\.js/ });
|
||||
2
playwright.glass-panel.config.js
Normal file
2
playwright.glass-panel.config.js
Normal 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
42
server/direct-relay.js
Normal 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 };
|
||||
}
|
||||
23
server/direct-relay.test.js
Normal file
23
server/direct-relay.test.js
Normal 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);
|
||||
});
|
||||
@@ -15,6 +15,7 @@
|
||||
* reports both: `online` (now) and `lastVerifiedAt` / `stale` (history).
|
||||
* Peers are addressed by an opaque id (peerIdOf), never by device id.
|
||||
* ========================================================================== */
|
||||
import { createDirectRelay } from './direct-relay.js';
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
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 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 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 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; }
|
||||
ws.data.profile = await authorizeProfile(m.profile, m.profileSecret, deviceId).catch(() => '');
|
||||
ws.data.inventory = [];
|
||||
ws.data.authed = true;
|
||||
ws.data.deviceId = deviceId;
|
||||
ws.data.peer = peerIdOf(deviceId);
|
||||
@@ -54,9 +58,18 @@ export function createP2pHub({ getDevice, enabled = () => true, now = () => Date
|
||||
online.set(deviceId, ws);
|
||||
byPeer.set(ws.data.peer, deviceId);
|
||||
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) {
|
||||
if (typeof raw !== 'string') return;
|
||||
const t = now();
|
||||
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; }
|
||||
@@ -66,11 +79,17 @@ export function createP2pHub({ getDevice, enabled = () => true, now = () => Date
|
||||
let m;
|
||||
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 (m && m.type === 'signal' && typeof m.to === 'string') {
|
||||
const target = online.get(byPeer.get(m.to));
|
||||
if (!target || target === ws) { send(ws, { type: 'error', error: 'peer offline', to: m.to }); return; }
|
||||
send(target, { type: 'signal', from: ws.data.peer, data: m.data });
|
||||
if (m?.type === 'direct-inventory' && ws.data.profile) {
|
||||
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) : [];
|
||||
directory(ws.data.profile); return;
|
||||
}
|
||||
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) {
|
||||
@@ -78,6 +97,8 @@ export function createP2pHub({ getDevice, enabled = () => true, now = () => Date
|
||||
if (deviceId && online.get(deviceId) === ws) {
|
||||
online.delete(deviceId);
|
||||
byPeer.delete(peer);
|
||||
directory(ws.data.profile);
|
||||
if (![...online.values()].some(x => x.data.profile === ws.data.profile)) directRooms.delete(ws.data.profile);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
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 a = 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(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' } }));
|
||||
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);
|
||||
expect(hub.isOnline('dev_000000000000000b')).toBe(false);
|
||||
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_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('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);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
* 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 { 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) {
|
||||
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);
|
||||
}
|
||||
p.hostPid = d.pid;
|
||||
@@ -118,7 +119,9 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
|
||||
const me = p && p.members.get(d.pid);
|
||||
if (!me || me.ws !== ws || !m || typeof m !== 'object') return;
|
||||
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) }); }
|
||||
else if (changeDJQueue(p.dj, m, me.pid, isHost)) broadcast(p, { type: 'dj', ...queueSnapshot(p.dj) });
|
||||
} else if (m.type === 'state' && isHost) {
|
||||
@@ -167,7 +170,7 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
|
||||
|
||||
return {
|
||||
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,
|
||||
stop() { clearInterval(sweeper); },
|
||||
};
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
* 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';
|
||||
|
||||
export const REMOTE_COMMANDS = new Set([
|
||||
@@ -145,7 +147,7 @@ export function createRemoteHub({ requireSameNetwork = false } = {}) {
|
||||
const id = roomIdFor(d.secret);
|
||||
let room = rooms.get(id);
|
||||
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);
|
||||
}
|
||||
if (room.host && room.host !== ws) {
|
||||
@@ -158,7 +160,7 @@ export function createRemoteHub({ requireSameNetwork = false } = {}) {
|
||||
d.roomId = id;
|
||||
issueCode(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) {
|
||||
@@ -172,7 +174,7 @@ export function createRemoteHub({ requireSameNetwork = false } = {}) {
|
||||
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.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 });
|
||||
}
|
||||
|
||||
@@ -185,6 +187,14 @@ export function createRemoteHub({ requireSameNetwork = false } = {}) {
|
||||
const room = rooms.get(d.roomId);
|
||||
if (!room) return;
|
||||
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 (room.host !== ws) return;
|
||||
@@ -248,7 +258,7 @@ export function createRemoteHub({ requireSameNetwork = false } = {}) {
|
||||
maxPayloadLength: MAX_MSG,
|
||||
idleTimeout: 120,
|
||||
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); },
|
||||
};
|
||||
|
||||
|
||||
@@ -2338,7 +2338,18 @@ async function fileForCid(cid) {
|
||||
return (await f.exists()) ? { path, size: f.size } : null;
|
||||
}
|
||||
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
|
||||
// persistent; `stale` flags a holder not re-verified for P2P_STALE_DAYS.
|
||||
|
||||
28
tests/direct-transfer.spec.js
Normal file
28
tests/direct-transfer.spec.js
Normal 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
27
tests/fixtures/direct-server.js
vendored
Normal 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); },
|
||||
}
|
||||
});
|
||||
83
tests/glass-navigation.spec.js
Normal file
83
tests/glass-navigation.spec.js
Normal 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
137
tests/glass-panel.spec.js
Normal 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');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user