From 9a37715da42229f5eb1974076c55662995c40d8c Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Fri, 9 Oct 2026 15:54:22 +0800 Subject: [PATCH] Unify transport controls with themed SVG symbols and accessible playback states --- .gitignore | 3 +++ frontend/admin.html | 2 +- frontend/app-seams.test.js | 3 +-- frontend/app.js | 32 ++++++++++------------- frontend/car-mode.js | 4 +-- frontend/car-mode.test.js | 3 ++- frontend/download-actions.js | 41 +++++++++++++++++++++++++++++- frontend/fullscreen-orientation.js | 6 ++--- frontend/glass-controls.js | 4 +-- frontend/index.html | 38 +++++++++++++-------------- frontend/lyrics-window.js | 13 ++++++++-- frontend/remote-client.js | 6 ++--- frontend/share-external.js | 12 ++++----- frontend/shell-core.js | 4 +-- frontend/stats-view.js | 2 +- frontend/styles.css | 24 ++++++++++++++++- frontend/transport-icons.test.js | 40 +++++++++++++++++++++++++++++ frontend/views-core.js | 14 +++++----- frontend/watch-party-client.js | 2 +- 19 files changed, 180 insertions(+), 73 deletions(-) create mode 100644 frontend/transport-icons.test.js diff --git a/.gitignore b/.gitignore index db90696..a37e8ff 100755 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ perf/.tmp/ # UI regression screenshots and local measurements plans/ui-shots/ + +# Transport regression screenshots +plans/transport-shots/ diff --git a/frontend/admin.html b/frontend/admin.html index d2dd8ac..dd44c63 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -1199,7 +1199,7 @@
- + diff --git a/frontend/app-seams.test.js b/frontend/app-seams.test.js index f6dc72e..3ef08ad 100644 --- a/frontend/app-seams.test.js +++ b/frontend/app-seams.test.js @@ -4,13 +4,12 @@ const {readFileSync}=require('node:fs'); const {createHash}=require('node:crypto'); const {join}=require('node:path'); const app=readFileSync(join(__dirname,'app.js'),'utf8'); -test('Phase 4 preserves the exact player, continuity, tracking and queue bodies',()=>{ +test('Phase 4 preserves the exact playback, continuity, tracking and queue bodies',()=>{ {const start=app.indexOf("const Player = {");assert.ok(start>=0,"Player");const body=app.slice(start,start+30343);assert.equal(createHash('sha256').update(body).digest('hex'),"373df46f4f994717472d26a6bac07449f6ca0a930cb666bfe3895d1dc65aa351","Player body unchanged");} {const start=app.indexOf("const Transition = (() => {");assert.ok(start>=0,"Transition");const body=app.slice(start,start+8588);assert.equal(createHash('sha256').update(body).digest('hex'),"71997a537078308a41f0cbf8e29fd1658fb3a199c4f5a25d8af0931f1c2a6d50","Transition body unchanged");} {const start=app.indexOf("const Wave = (() => {");assert.ok(start>=0,"Wave");const body=app.slice(start,start+5241);assert.equal(createHash('sha256').update(body).digest('hex'),"7252f4e856c59aef8aabfcd394ce4bcba656110cbd01a1a8af662eeb48166a3c","Wave body unchanged");} {const start=app.indexOf("const StatsTrack = (() => {");assert.ok(start>=0,"StatsTrack");const body=app.slice(start,start+1462);assert.equal(createHash('sha256').update(body).digest('hex'),"9aae2631ba3bfad918319579b7120814af06c84949394d04e52c65345332d514","StatsTrack body unchanged");} {const start=app.indexOf("function wirePlayerEvents() {");assert.ok(start>=0,"wirePlayerEvents");const body=app.slice(start,start+4913);assert.equal(createHash('sha256').update(body).digest('hex'),"1c5a2b5ce310f6aef6764325eda85a95f47e4f507f2049036ce1da206379908c","wirePlayerEvents body unchanged");} - {const start=app.indexOf("function updatePlayBtn() {");assert.ok(start>=0,"updatePlayBtn");const body=app.slice(start,start+522);assert.equal(createHash('sha256').update(body).digest('hex'),"1326233af07ae92a5ecc81cc211bd98d21ed5f291dc4eb59d040b8df9baba8c8","updatePlayBtn body unchanged");} {const start=app.indexOf("function persist() {");assert.ok(start>=0,"persist");const body=app.slice(start,start+147);assert.equal(createHash('sha256').update(body).digest('hex'),"01e5c5dbc80616266db09b3829dc45b66476a3256b1c169d8e44f4983b24ac45","persist body unchanged");} {const start=app.indexOf("function playFromList(list, index, source = '', { playFull = false } = {}) {");assert.ok(start>=0,"playFromList");const body=app.slice(start,start+401);assert.equal(createHash('sha256').update(body).digest('hex'),"f8009c9674b3478959997dca881fb7b9e1ab3708c98e5abdf1958d752e47ee41","playFromList body unchanged");} {const start=app.indexOf("function advanceQueue() {");assert.ok(start>=0,"advanceQueue");const body=app.slice(start,start+596);assert.equal(createHash('sha256').update(body).digest('hex'),"be405b9c163e3d41a949ef11f6022ef13115b04083e3b6ad21d97beba1f45ae6","advanceQueue body unchanged");} diff --git a/frontend/app.js b/frontend/app.js index 9c44f7d..e05ee6b 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -1598,8 +1598,8 @@ function escapeHtml(s) { .replace(/"/g, '"').replace(/'/g, '''); } -const ICON_PLAY = ''; -const ICON_PAUSE = ''; +const ICON_PLAY = TransportIcons.svg('play'); +const ICON_PAUSE = TransportIcons.svg('pause'); // Speaker icon follows the volume (muted / low / high); the level before a mute // is remembered so un-muting returns to it. let lastVolume = 1; @@ -3088,14 +3088,10 @@ function wirePlayerEvents() { function updatePlayBtn() { Remote.changed(); - els.playBtn.innerHTML = Player.master.paused ? ICON_PLAY : ICON_PAUSE; - const sp = $('stgPlay'); - if (sp) sp.innerHTML = Player.master.paused ? ICON_PLAY : ICON_PAUSE; + const kind = Player.master.paused ? 'play' : 'pause'; + for (const id of ['playBtn','stgPlay','miniPlayBtn','servicePlayBtn']) TransportIcons.set($(id), kind); const stage = document.querySelector('.player-stage'); if (stage) stage.classList.toggle('is-paused', !!Player.master.paused); - $('miniPlayBtn').textContent = Player.master.paused ? '▶' : '⏸'; - const spBtn = $('servicePlayBtn'); - if (spBtn) spBtn.textContent = Player.master.paused ? '▶' : '⏸'; } // "⚠ Broken": ask the server to drop its cached copy and fetch a fresh one. // Playback switches to live YouTube streams meanwhile (resuming where it @@ -3245,7 +3241,7 @@ function openMoreControls() { ['eqBtn', '🎚', 'Equalizer', false], ['pipBtn', '⧉', 'Picture-in-picture', false], ]; - const selects = [['speedSelect', '⏩', 'Speed'], ['qualitySelect', '🎞', 'Quality']]; + const selects = [['speedSelect', TransportIcons.svg('forward'), 'Speed'], ['qualitySelect', '🎞', 'Quality']]; const list = document.createElement('div'); list.className = 'more-list'; const draw = () => { @@ -3256,7 +3252,7 @@ function openMoreControls() { const row = document.createElement('label'); row.className = 'more-row'; row.innerHTML = ''; - row.querySelector('.more-ico').textContent = icon; + row.querySelector('.more-ico').innerHTML = icon; row.querySelector('b').textContent = title; const sel = document.createElement('select'); sel.className = 'more-sel'; @@ -3274,7 +3270,7 @@ function openMoreControls() { row.type = 'button'; row.className = 'more-row' + (on ? ' on' : ''); row.innerHTML = '' + (on ? '●' : '›') + ''; - row.querySelector('.more-ico').textContent = icon; + row.querySelector('.more-ico').innerHTML = icon; row.querySelector('b').textContent = title; row.querySelector('small').textContent = String(src.title || '').replace(/\s*\[[A-Za-z]\]\s*$/, ''); row.onclick = () => { @@ -3301,13 +3297,13 @@ function openAddChoice() { r.type = 'button'; r.className = 'more-row'; r.innerHTML = '›'; - r.querySelector('.more-ico').textContent = icon; + r.querySelector('.more-ico').innerHTML = icon; r.querySelector('b').textContent = title; r.querySelector('small').textContent = sub; r.onclick = () => { closeModal(); setTimeout(fn, 0); }; list.appendChild(r); }; - row('▶', 'Add to queue', 'Play it next in this session', () => addToQueue(video)); + row(TransportIcons.svg('play'), 'Add to queue', 'Play it next in this session', () => addToQueue(video)); row('☰', 'Add to playlist', 'Save it to one of your playlists', () => openCardMenu(video)); showModal('Add “' + (video.title || 'video').slice(0, 40) + '”', list, [{ label: 'Cancel', onClick: closeModal }]); } @@ -3332,7 +3328,7 @@ function openMoreActions() { row.className = 'more-row' + (danger ? ' danger' : ''); row.disabled = !!src.disabled; row.innerHTML = '›'; - row.querySelector('.more-ico').textContent = icon; + row.querySelector('.more-ico').innerHTML = icon; row.querySelector('b').textContent = title; row.querySelector('small').textContent = sub; row.onclick = () => { closeModal(); setTimeout(() => src.click(), 0); }; @@ -3633,7 +3629,7 @@ const Notes = (() => {
${m.kind === 'note' ? '📝 ' : ''}${escapeHtml(m.title)}
${m.note ? `
${escapeHtml(m.note)}
` : ''}
- + ${m.auto ? '' : ` `} `; @@ -5725,7 +5721,7 @@ const Gestures = (() => { if (x < 1 / 3) seekBy(-10, 'left'); else if (x > 2 / 3) seekBy(10, 'right'); else if (st.zoom > 1.001) { reset(); showHud('
Zoom reset
'); } - else if (current) { Player.toggle(); updatePlayBtn(); showHud(`
${Player.master.paused ? '⏸' : '▶'}
`); } + else if (current) { Player.toggle(); updatePlayBtn(); showHud(`
${TransportIcons.svg(Player.master.paused ? 'pause' : 'play')}
`); } return; } st.lastTap = now; @@ -6285,7 +6281,7 @@ function updateServiceMode() { const spBtn = $('servicePlayBtn'); if (spBtn && Player.master) { - spBtn.textContent = Player.master.paused ? '▶' : '⏸'; + TransportIcons.set(spBtn, Player.master.paused ? 'play' : 'pause'); } updateWakeStateUI(); @@ -6984,7 +6980,7 @@ function openCardMenu(video) { // Quick: add to queue const queueBtn = document.createElement('button'); - queueBtn.textContent = '▶ Add to queue'; + queueBtn.innerHTML = TransportIcons.svg('play') + ' Add to queue'; queueBtn.onclick = () => { addToQueue(video); closeModal(); }; body.appendChild(queueBtn); diff --git a/frontend/car-mode.js b/frontend/car-mode.js index 8c683ab..500b967 100644 --- a/frontend/car-mode.js +++ b/frontend/car-mode.js @@ -10,8 +10,8 @@ overlay = doc.createElement('section'); overlay.className = 'car-mode'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-label', 'Car mode'); const exit = doc.createElement('button'); exit.type = 'button'; exit.className = 'car-exit'; exit.textContent = 'Exit Car mode'; exit.onclick = close; const title = doc.createElement('h1'); const controls = doc.createElement('div'); controls.className = 'car-controls'; let toggle; - for (const [label, action] of [['Previous', 'prev'], ['Play / Pause', 'toggle'], ['15 s back', 'back'], ['Next', 'next']]) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = label; b.setAttribute('aria-label', label); b.onclick = () => adapter.action(action); if (action === 'toggle') toggle = b; controls.append(b); } - const update = () => { const state = adapter.state(); title.textContent = state.title || 'Choose a song before entering Car mode'; toggle.textContent = state.paused ? 'Play' : 'Pause'; }; update(); + for (const [label, action] of [['Previous', 'prev'], ['Play / Pause', 'toggle'], ['15 s back', 'back'], ['Next', 'next']]) { const b = doc.createElement('button'); b.type = 'button'; b.innerHTML = root.TransportIcons.svg({prev:'previous',toggle:'play',back:'back15',next:'next'}[action]) + '' + label + ''; b.setAttribute('aria-label', label); b.onclick = () => adapter.action(action); if (action === 'toggle') toggle = b; controls.append(b); } + const update = () => { const state = adapter.state(); title.textContent = state.title || 'Choose a song before entering Car mode'; root.TransportIcons.set(toggle, state.paused ? 'play' : 'pause'); toggle.insertAdjacentHTML('beforeend','' + (state.paused ? 'Play' : 'Pause') + ''); }; update(); overlay.append(exit, title, controls); doc.body.append(overlay); timer = setInterval(update, 500); exit.focus(); overlay.onkeydown = e => { if (e.key === 'Escape') { e.preventDefault(); close(); } else if (e.key === 'Tab') { const buttons = [...overlay.querySelectorAll('button')], first = buttons[0], last = buttons.at(-1); if (e.shiftKey && doc.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && doc.activeElement === last) { e.preventDefault(); first.focus(); } } e.stopPropagation(); }; } diff --git a/frontend/car-mode.test.js b/frontend/car-mode.test.js index 3c77194..ff996b6 100644 --- a/frontend/car-mode.test.js +++ b/frontend/car-mode.test.js @@ -10,11 +10,12 @@ test('lazy Car mode keeps four independent transport buttons and the voice entry after(node){this.parent.append(node);} setAttribute(name,value){this[name]=value;} focus(){} + insertAdjacentHTML(position,html){this.innerHTML+=html;} get isConnected(){return !!this.parent;} } const body=new Element('body'),input=new Element('input'),form=new Element('form'),voice=new Element('button');form.id='searchForm';voice.id='voiceSearchBtn';body.append(form,input,voice); const all=node=>[node,...node.children.flatMap(all)],doc={body,documentElement:{lang:'en'},createElement:tag=>new Element(tag),getElementById:id=>all(body).find(n=>n.id===id)}; - let section;const actions=[],root={document:doc,navigator:{},SpeechRecognition:function(){},SettingsSections:{register:s=>section=s}}; + let section;const actions=[],root={document:doc,TransportIcons:require('./download-actions').transport,navigator:{},SpeechRecognition:function(){},SettingsSections:{register:s=>section=s}}; vm.runInNewContext(fs.readFileSync(__dirname+'/car-mode.js','utf8'),{window:root,setInterval:()=>1,clearInterval:()=>{}}); root.CarMode.configure({input,action:action=>actions.push(action),state:()=>({paused:true,title:'Song'})}); root.CarMode.open();const overlay=body.children.find(n=>n.className==='car-mode'),controls=overlay.children.find(n=>n.className==='car-controls'); diff --git a/frontend/download-actions.js b/frontend/download-actions.js index ccbb7f8..d8cd336 100644 --- a/frontend/download-actions.js +++ b/frontend/download-actions.js @@ -36,6 +36,45 @@ root.document.body.appendChild(defs); root.MediaIcons = media; } + // Shared transport symbols: CSS switches drawings without replacing live controls. + const transportPaths = { + play: '', + pause: '', + previous: '', + next: '', + forward: '' + }; + for (const seconds of [10,15]) for (const direction of ['back','forward']) { + transportPaths[direction + seconds] = (direction === 'back' + ? '' + : '') + + `${seconds}`; + } + const transportVariants = Object.fromEntries(['classic','f7-swipe','glass-stage','bento-hub'].map(layout => [layout,{...transportPaths}])); + Object.assign(transportVariants['f7-swipe'], { + play: '', + previous: '', + next: '' + }); + const transport = { + paths: transportPaths, variants: transportVariants, + svg(kind) { + if (!transportPaths[kind]) throw new Error('Unknown transport icon: ' + kind); + return ``; + }, + set(button, kind, label) { + if (!button) return; + button.innerHTML = this.svg(kind); + button.setAttribute('aria-label', label || (kind === 'pause' ? 'Pause' : 'Play')); + } + }; + if (root.document) { + const defs = root.document.createElementNS('http://www.w3.org/2000/svg','svg'); + defs.setAttribute('aria-hidden','true'); defs.setAttribute('width','0'); defs.setAttribute('height','0'); + defs.style.position='absolute'; defs.style.pointerEvents='none'; + defs.innerHTML=''+Object.keys(transportPaths).map(kind=>`${Object.entries(transportVariants).map(([layout,paths])=>`${paths[kind]}`).join('')}`).join('')+''; + root.document.body.appendChild(defs); root.TransportIcons=transport; + } const paths = { retry: '', cancel: '', @@ -54,6 +93,6 @@ const text = doc.createElement('span'); text.textContent = label; button.append(icon, text); return button; } - const api = { create, media }; + const api = { create, media, transport }; if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.DownloadActions = api; })(globalThis); diff --git a/frontend/fullscreen-orientation.js b/frontend/fullscreen-orientation.js index c7e5ee9..4d508da 100644 --- a/frontend/fullscreen-orientation.js +++ b/frontend/fullscreen-orientation.js @@ -24,7 +24,7 @@ function lift() { if (home || stage.parentElement === document.body) return; home = { parent: stage.parentElement, next: stage.nextSibling }; document.body.append(stage); } function unlift() { if (!home) return; const { parent, next } = home; home = null; if (next && next.parentNode === parent) parent.insertBefore(stage, next); else parent.append(stage); } const hud = document.createElement('div'); hud.className = 'fullscreen-hud'; hud.hidden = true; - hud.innerHTML = '
'; + hud.innerHTML = '
'; stage.append(hud); const play = hud.querySelector('button[aria-label="Play or pause"]'), orientation = hud.querySelector('.fullscreen-orientation'), fitButton = hud.querySelector('.fullscreen-fit'), exit = hud.querySelector('button[aria-label="Exit fullscreen"]'), range = hud.querySelector('input'); const title = hud.querySelector('.fullscreen-title'); @@ -109,9 +109,7 @@ range.style.setProperty('--seek-progress', `${s.duration ? Math.min(100, s.time / s.duration * 100) : 0}%`); play.setAttribute('aria-label', s.paused ? 'Play video' : 'Pause video'); play.title = s.paused ? 'Play' : 'Pause'; - play.innerHTML = s.paused - ? '' - : ''; + root.TransportIcons.set(play, s.paused ? 'play' : 'pause', s.paused ? 'Play video' : 'Pause video'); }, 250); } document.addEventListener('fullscreenchange', () => { if (active && !document.fullscreenElement && native) cleanup(); }); diff --git a/frontend/glass-controls.js b/frontend/glass-controls.js index fb7ec9c..c092177 100644 --- a/frontend/glass-controls.js +++ b/frontend/glass-controls.js @@ -7,8 +7,8 @@ add:'', close:'', search:'', save:'', - play:'', pause:'', - next:'', previous:'', + play:'', pause:'', + next:'', previous:'', repeat:'', edit:'', lyrics:'', diff --git a/frontend/index.html b/frontend/index.html index d345ac8..485be59 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -42,12 +42,12 @@