Verify transport geometry, playback states and themed icons across both engines
This commit is contained in:
@@ -8,6 +8,7 @@ test('transport symbols cover all actions and layouts with monochrome SVG',()=>{
|
|||||||
for(const kind of ['play','pause','previous','next','back10','forward10','back15','forward']){
|
for(const kind of ['play','pause','previous','next','back10','forward10','back15','forward']){
|
||||||
assert.match(transport.svg(kind),new RegExp(`href="#transport-${kind}"`));
|
assert.match(transport.svg(kind),new RegExp(`href="#transport-${kind}"`));
|
||||||
assert.match(transport.svg(kind),/stroke="currentColor"/);
|
assert.match(transport.svg(kind),/stroke="currentColor"/);
|
||||||
|
assert.equal((transport.svg(kind).match(/<use /g)||[]).length,4,'layout selection lives on visible use elements');
|
||||||
for(const layout of ['classic','f7-swipe','glass-stage','bento-hub'])assert.ok(transport.variants[layout][kind]);
|
for(const layout of ['classic','f7-swipe','glass-stage','bento-hub'])assert.ok(transport.variants[layout][kind]);
|
||||||
}
|
}
|
||||||
assert.throws(()=>transport.svg('unknown'),/Unknown transport/);
|
assert.throws(()=>transport.svg('unknown'),/Unknown transport/);
|
||||||
@@ -38,3 +39,10 @@ test('runtime sources never render legacy transport glyphs',()=>{
|
|||||||
}
|
}
|
||||||
assert.deepEqual(failures,[]);
|
assert.deepEqual(failures,[]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('state changes preserve named SVG nodes used by editor controls',()=>{
|
||||||
|
let icon={id:'playIcon'};
|
||||||
|
const button={querySelector(){return icon;},set innerHTML(value){this.markup=value;icon={id:''};},setAttribute(name,value){this[name]=value;}};
|
||||||
|
transport.set(button,'pause');
|
||||||
|
assert.equal(icon.id,'playIcon');assert.equal(button['aria-label'],'Pause');assert.match(button.markup,/#transport-pause-f7-swipe/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -474,3 +474,13 @@ check Save before playing / Audio-only / Auto-save settings, open a fresh video,
|
|||||||
add it to a playlist, and inspect Downloads failure details and Saved after a
|
add it to a playlist, and inspect Downloads failure details and Saved after a
|
||||||
relaunch/offline playback. Verify available storage and whether iOS evicted
|
relaunch/offline playback. Verify available storage and whether iOS evicted
|
||||||
previous copies. A failing iPhone sequence is needed to continue the bisect.
|
previous copies. A failing iPhone sequence is needed to continue the bisect.
|
||||||
|
|
||||||
|
Transport controls use the same shared-definition approach as media/save icons.
|
||||||
|
`node perf/ui-icons.mjs` also checks all four layouts × dark/light/contrast in
|
||||||
|
Chromium and WebKit: shared monochrome symbols, accessible labels, visible
|
||||||
|
44px targets, F7 filled glyphs, runtime layout changes, main/stage/mini/service
|
||||||
|
play-pause state swaps, fullscreen and Car mode refreshes, and actual paired
|
||||||
|
remote toggles against a mocked relay. Transport previews and remote screenshots
|
||||||
|
are saved in ignored `plans/transport-shots/`. Run `node perf/ui-geometry.mjs`
|
||||||
|
for the 320–430px search-bar and Saved-row geometry matrix. OS lock-screen buttons
|
||||||
|
remain platform-rendered through the existing MediaSession handlers.
|
||||||
|
|||||||
6065
perf/results/transport-icons-2026-10-09.json
Normal file
6065
perf/results/transport-icons-2026-10-09.json
Normal file
File diff suppressed because it is too large
Load Diff
80
perf/transport-fixture.mjs
Normal file
80
perf/transport-fixture.mjs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {resolve} from 'node:path';
|
||||||
|
import {mkdir} from 'node:fs/promises';
|
||||||
|
import {contextFor,appearance} from './ui-fixture.mjs';
|
||||||
|
export async function transportChecks(page,engine,layout,theme) {
|
||||||
|
const shots=resolve('plans/transport-shots');await mkdir(shots,{recursive:true});
|
||||||
|
const result=await page.evaluate(async()=>{
|
||||||
|
const ids=['playBtn','prevBtn','nextBtn','back10Btn','fwd10Btn','stgBack','stgPlay','stgFwd','miniPlayBtn','servicePrevBtn','servicePlayBtn','serviceNextBtn','rvPrev','rvToggle','rvNext'];
|
||||||
|
const legacy=/[▶⏸⏮⏭⏪⏩⏯◀❚]/u;
|
||||||
|
const bad=[...document.querySelectorAll('button,a,.home-pl-play,.brand-mark,.ph-logo,.empty-icon')].filter(n=>legacy.test(n.textContent)).map(n=>n.outerHTML);
|
||||||
|
const inspect=el=>{
|
||||||
|
const svg=el.querySelector('.transport-icon'),style=getComputedStyle(svg),parent=getComputedStyle(el),box=el.getBoundingClientRect();
|
||||||
|
return {id:el.id,kind:svg?.dataset.transportIcon,href:[...svg.querySelectorAll('use')].find(n=>getComputedStyle(n).display!=='none')?.getAttribute('href'),fill:style.fill,stroke:style.stroke,color:style.color,parentColor:parent.color,legacy:!!el.querySelector('.fill,.legacy-nav-icon'),label:el.getAttribute('aria-label'),width:box.width,height:box.height,visible:box.width>0&&box.height>0};
|
||||||
|
};
|
||||||
|
const icons=ids.map(id=>inspect(document.getElementById(id)));
|
||||||
|
const previous=document.querySelector('#prevBtn svg');
|
||||||
|
const previousBox=[...previous.querySelectorAll('use')].find(n=>getComputedStyle(n).display!=='none').getBBox();
|
||||||
|
const previousX=previousBox.x;
|
||||||
|
const shapes=[...document.querySelectorAll('#playBtn .transport-shape')].filter(n=>getComputedStyle(n).display!=='none').map(n=>n.classList[1]);
|
||||||
|
const old=Object.getOwnPropertyDescriptor(Player.master,'paused');let paused=true;
|
||||||
|
Object.defineProperty(Player.master,'paused',{configurable:true,get:()=>paused});
|
||||||
|
const states=[];
|
||||||
|
for(const state of [true,false,true]){paused=state;updatePlayBtn();states.push(['playBtn','stgPlay','miniPlayBtn','servicePlayBtn'].map(id=>inspect(document.getElementById(id))));}
|
||||||
|
// Exercise the real fullscreen HUD and Car mode's own refresh callbacks.
|
||||||
|
const stage=document.querySelector('.player-stage'); const request=stage.requestFullscreen;stage.requestFullscreen=undefined;
|
||||||
|
await playerFullscreen.enter();paused=false;await new Promise(r=>setTimeout(r,300));
|
||||||
|
const hudPlaying=inspect(document.querySelector('.fullscreen-controls button'));
|
||||||
|
paused=true;await new Promise(r=>setTimeout(r,300));const hudPaused=inspect(document.querySelector('.fullscreen-controls button'));playerFullscreen.leave();stage.requestFullscreen=request;
|
||||||
|
CarMode.configure({input:els.searchInput,search(){},toast(){},state:()=>({paused,title:'Transport fixture'}),action(){}});CarMode.open();
|
||||||
|
const car=[...document.querySelectorAll('.car-controls button')].map(inspect);
|
||||||
|
paused=false;await new Promise(r=>setTimeout(r,550));const carPlaying=inspect(document.querySelectorAll('.car-controls button')[1]);CarMode.close();
|
||||||
|
if(old)Object.defineProperty(Player.master,'paused',old);else delete Player.master.paused;updatePlayBtn();
|
||||||
|
// A preview strip uses the very same symbols to make all actions visible together.
|
||||||
|
const strip=document.createElement('section');strip.id='transportPreview';strip.style.cssText='position:fixed;left:8px;right:8px;top:120px;z-index:30000;padding:20px;background:var(--bg-1);color:var(--text);border:1px solid var(--line);border-radius:20px;display:flex;flex-wrap:wrap;gap:12px';
|
||||||
|
for(const kind of ['previous','back10','play','pause','forward10','next']){const b=document.createElement('button');b.className=kind==='play'||kind==='pause'?'ctrl play':'ctrl';b.title=kind;if(kind==='play'||kind==='pause'){const s=getComputedStyle(document.getElementById('playBtn'));for(const prop of ['width','height','borderRadius','background','color','border','boxShadow','backdropFilter'])b.style[prop]=s[prop];}b.innerHTML=TransportIcons.svg(kind);strip.append(b);}document.body.append(strip);
|
||||||
|
return {bad,icons,previousX,shapes,states,hudPlaying,hudPaused,car,carPlaying};
|
||||||
|
});
|
||||||
|
const label=`${engine}/${layout}/${theme}`;
|
||||||
|
assert.deepEqual(result.bad,[],label+': legacy transport glyph');
|
||||||
|
assert.deepEqual(result.shapes,[`transport-shape-${layout}`],label+': wrong drawing');
|
||||||
|
if(result.icons.find(n=>n.id==='prevBtn').visible)assert.equal(result.previousX,layout==='f7-swipe'?4:5,label+': rendered geometry must switch');
|
||||||
|
for(const icon of [...result.icons,...result.car,result.hudPlaying,result.hudPaused,result.carPlaying]){
|
||||||
|
assert(icon.kind&&!icon.legacy&&icon.href===`#transport-${icon.kind}${layout==='classic'?'':'-'+layout}`,label+': nonshared icon');
|
||||||
|
assert(icon.label,label+': missing accessible name');
|
||||||
|
assert.equal(icon.stroke,icon.color,label+': nonmonochrome stroke');
|
||||||
|
if(layout==='f7-swipe'&&['play','pause','previous','next','forward'].includes(icon.kind))assert.equal(icon.fill,icon.color,label+': F7 glyph must be filled '+JSON.stringify(icon));assert.equal(icon.color,icon.parentColor,label+': detached control colour');
|
||||||
|
if(icon.visible)assert(icon.width>=44&&icon.height>=44,`${label}/${icon.id||icon.kind}: target ${icon.width}x${icon.height}`);
|
||||||
|
}
|
||||||
|
for(const [i,icons] of result.states.entries())for(const icon of icons){assert.equal(icon.kind,i===1?'pause':'play');assert.equal(icon.label,i===1?'Pause':'Play');}
|
||||||
|
assert.equal(result.hudPlaying.kind,'pause');assert.equal(result.hudPaused.kind,'play');assert.equal(result.carPlaying.kind,'pause');
|
||||||
|
await page.locator('#transportPreview').screenshot({path:resolve(shots,`${engine}-${layout}-${theme}-symbols.png`)});
|
||||||
|
await page.evaluate(()=>document.getElementById('transportPreview').remove());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function remoteTransportFixture(browser,origin,engine) {
|
||||||
|
const context=await contextFor(browser,origin);
|
||||||
|
await context.route('**/api/remote/pair',route=>route.fulfill({json:{ok:true,roomId:'fixture',remoteId:'phone',token:'fixture',hostName:'Transport fixture',name:'Phone'}}));
|
||||||
|
await context.routeWebSocket('**/ws/remote**',ws=>{setTimeout(()=>ws.send(JSON.stringify({type:'hello',hostName:'Transport fixture',state:{paused:true,cur:0,dur:240,volume:1,v:{id:'iconcheck00',title:'Transport fixture',channel:'Fixture'}}})),100);});
|
||||||
|
const page=await context.newPage();const errors=[];page.on('pageerror',error=>errors.push(error.message));
|
||||||
|
await page.goto(origin+'/?pair=123456');await page.waitForSelector('#rvControl:not(.hidden)');
|
||||||
|
await page.waitForFunction(()=>document.getElementById('rvTitle').textContent==='Transport fixture');
|
||||||
|
return {
|
||||||
|
async check(layout,theme){
|
||||||
|
await appearance(page,layout,theme);
|
||||||
|
const states=[];
|
||||||
|
for(let i=0;i<3;i++){
|
||||||
|
states.push(await page.evaluate(()=>['rvPrev','rvToggle','rvNext'].map(id=>{const b=document.getElementById(id),svg=b.querySelector('.transport-icon'),r=b.getBoundingClientRect();return {kind:svg.dataset.transportIcon,href:[...svg.querySelectorAll('use')].find(n=>getComputedStyle(n).display!=='none').getAttribute('href'),label:b.getAttribute('aria-label'),width:r.width,height:r.height};})));
|
||||||
|
if(i<2)await page.locator('#rvToggle').click();
|
||||||
|
}
|
||||||
|
for(const [i,icons] of states.entries()){
|
||||||
|
assert.equal(icons[1].kind,i===1?'pause':'play');assert.equal(icons[1].label,i===1?'Pause':'Play');
|
||||||
|
for(const icon of icons){assert.equal(icon.href,'#transport-'+icon.kind+(layout==='classic'?'':'-'+layout));assert(icon.width>=44&&icon.height>=44);}
|
||||||
|
}
|
||||||
|
await page.locator('.rv-transport').screenshot({path:resolve('plans/transport-shots',`${engine}-${layout}-${theme}-remote.png`)});
|
||||||
|
assert.deepEqual(errors,[],engine+': remote script errors');
|
||||||
|
return states;
|
||||||
|
},close:()=>context.close()
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
|
|||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { chromium, webkit } from 'playwright';
|
import { chromium, webkit } from 'playwright';
|
||||||
import { serve, layouts, themes, contextFor, appearance } from './ui-fixture.mjs';
|
import { serve, layouts, themes, contextFor, appearance } from './ui-fixture.mjs';
|
||||||
|
import {transportChecks,remoteTransportFixture} from './transport-fixture.mjs';
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const option = (key, fallback) => args.includes(key) ? args[args.indexOf(key)+1] : fallback;
|
const option = (key, fallback) => args.includes(key) ? args[args.indexOf(key)+1] : fallback;
|
||||||
@@ -17,6 +18,7 @@ try {
|
|||||||
try {
|
try {
|
||||||
const context = await contextFor(browser,host.origin);
|
const context = await contextFor(browser,host.origin);
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
const remote=await remoteTransportFixture(browser,host.origin,name);
|
||||||
const errors = [];
|
const errors = [];
|
||||||
page.on('pageerror', e => errors.push(e.message));
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
await page.goto(host.origin);
|
await page.goto(host.origin);
|
||||||
@@ -85,9 +87,12 @@ try {
|
|||||||
});
|
});
|
||||||
assert.deepEqual(states,{normal:['#media-download'],progress:['#media-progress'],done:['#media-offline']},`${name}/${layout}/${theme}: save states`);
|
assert.deepEqual(states,{normal:['#media-download'],progress:['#media-progress'],done:['#media-offline']},`${name}/${layout}/${theme}: save states`);
|
||||||
await page.evaluate(() => document.querySelector('#listPane').classList.remove('hidden'));
|
await page.evaluate(() => document.querySelector('#listPane').classList.remove('hidden'));
|
||||||
results.push({browser:name,layout,theme,...measurements});
|
const transport=await transportChecks(page,name,layout,theme);
|
||||||
|
const remoteStates=await remote.check(layout,theme);
|
||||||
|
results.push({browser:name,layout,theme,...measurements,transport,remoteStates});
|
||||||
}
|
}
|
||||||
assert.deepEqual(errors,[],'script errors');
|
assert.deepEqual(errors,[],'script errors');
|
||||||
|
await remote.close();
|
||||||
await context.close();
|
await context.close();
|
||||||
} finally { await browser.close(); }
|
} finally { await browser.close(); }
|
||||||
}
|
}
|
||||||
|
|||||||
74
plans/transport-icons.md
Normal file
74
plans/transport-icons.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Transport icon reconciliation
|
||||||
|
|
||||||
|
Branch `codex/transport-icons` starts at completed integration commit `0a5b113`.
|
||||||
|
|
||||||
|
The previous shell mixed filled inline SVGs (main/stage/fullscreen) with Unicode
|
||||||
|
play/pause/previous/next glyphs (mini, service, remote, gesture feedback, queue and
|
||||||
|
playlist actions). All rendered transport glyphs now use shared currentColor SVG
|
||||||
|
symbols defined in the already-eager `download-actions.js`; no new runtime file,
|
||||||
|
loader or worker change is required. The core/playback/queue bodies remain guarded
|
||||||
|
by their existing byte hashes. `updatePlayBtn` now has a behavioural state/handler
|
||||||
|
regression test instead of an obsolete byte-identity assertion.
|
||||||
|
|
||||||
|
| Location | Change |
|
||||||
|
|---|---|
|
||||||
|
| `frontend/download-actions.js:40` | Shared drawing/state or markup |
|
||||||
|
| `frontend/download-actions.css:13` | Shared drawing/state or markup |
|
||||||
|
| `frontend/index.html:212` | Shared drawing/state or markup |
|
||||||
|
| `frontend/app.js:3089` | Shared drawing/state or markup |
|
||||||
|
| `frontend/fullscreen-orientation.js:112` | Shared drawing/state or markup |
|
||||||
|
| `frontend/car-mode.js:13` | Shared drawing/state or markup |
|
||||||
|
| `frontend/remote-client.js:424` | Shared drawing/state or markup |
|
||||||
|
| `frontend/lyrics-window.js:27` | Shared drawing/state or markup |
|
||||||
|
| `frontend/views-core.js:225` | Shared drawing/state or markup |
|
||||||
|
| `frontend/shell-core.js:157` | Shared drawing/state or markup |
|
||||||
|
| `frontend/share-external.js:189` | Shared drawing/state or markup |
|
||||||
|
| `frontend/admin.html:388` | Shared drawing/state or markup |
|
||||||
|
| `frontend/glass-controls.js:10` | Shared drawing/state or markup |
|
||||||
|
| `frontend/stats-view.js:52` | Shared drawing/state or markup |
|
||||||
|
| `frontend/watch-party-client.js:350` | Shared drawing/state or markup |
|
||||||
|
|
||||||
|
Inventory: main deck previous/play/next/back10/forward10; stage back10/play/forward10;
|
||||||
|
mini play; service previous/play/next; remote previous/play/next and paired-screen
|
||||||
|
play; fullscreen HUD; gesture feedback; Car previous/play/back15/next; floating
|
||||||
|
lyrics play/next; homepage playlist play badges; Queue/sidebar/bottom-navigation
|
||||||
|
play symbols; Play all/queue/in-full, bookmark jump and Add-to-queue actions;
|
||||||
|
external-player launch links; admin editor play/pause and Play-here actions;
|
||||||
|
track-play statistics, party now-playing and empty-state/brand symbols. Speed's
|
||||||
|
old fast-forward glyph also uses the shared set. Glass's legacy decoder accepts
|
||||||
|
old input strings but emits shared SVG references; it never restores a transport
|
||||||
|
Unicode icon from the new markup.
|
||||||
|
|
||||||
|
F7 uses round filled shapes and a large circular play button. Classic keeps its
|
||||||
|
existing surfaces with clean line drawings. Glass uses thin strokes and translucent
|
||||||
|
surfaces. Bento uses rounded tiles. Labels, IDs and event handlers remain intact;
|
||||||
|
visible transport targets have at least 44px dimensions. Car retains its readable
|
||||||
|
high-contrast surface and text labels. Floating lyrics clones symbol definitions
|
||||||
|
into its separate document and observes playback/layout/theme changes.
|
||||||
|
|
||||||
|
Presenter has no transport controls. F7's Up-next sheet uses clickable track rows,
|
||||||
|
not old transport glyphs. Lock-screen controls are rendered by the OS through
|
||||||
|
unchanged MediaSession action handlers; website SVG/CSS cannot theme those buttons.
|
||||||
|
|
||||||
|
A screenshot/bounding-box check caught a Chromium SVG-reference quirk: ancestor
|
||||||
|
layout selectors changed styles in referenced groups without switching cloned
|
||||||
|
geometry (F7 previous remained x=5 instead of x=4). Variant selection now lives on
|
||||||
|
visible `<use>` elements; the harness checks the rendered bounds, not just styles.
|
||||||
|
|
||||||
|
Final verification:
|
||||||
|
|
||||||
|
```text
|
||||||
|
node --test frontend/*.test.js: 246 pass, 0 fail
|
||||||
|
bun test asset-manifest.test.js static-delivery.test.js: 21 pass, 0 fail
|
||||||
|
node perf/ui-geometry.mjs: 384 cases; 0 failed checks
|
||||||
|
node perf/ui-icons.mjs: 24 engine/layout/theme cases passed
|
||||||
|
```
|
||||||
|
|
||||||
|
Both engines passed transport state/geometry checks and the original download-icon
|
||||||
|
checks. All sources are free of rendered legacy transport glyphs. The only remaining
|
||||||
|
Unicode transport strings are the legacy decoder input mapping. Evidence is in
|
||||||
|
`perf/results/transport-icons-2026-10-09.json`; 48 transport preview/remote screenshots
|
||||||
|
were saved and the four dark-layout previews were visually inspected. Screenshots are local
|
||||||
|
and ignored in `plans/transport-shots/` and `plans/ui-shots/`. Real iPhone checks:
|
||||||
|
Car/fullscreen readability and native lock-screen controls; document PiP is browser
|
||||||
|
feature-dependent and was not verified on a real phone.
|
||||||
Reference in New Issue
Block a user