Unify transport controls with themed SVG symbols and accessible playback states
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -47,3 +47,6 @@ perf/.tmp/
|
||||
|
||||
# UI regression screenshots and local measurements
|
||||
plans/ui-shots/
|
||||
|
||||
# Transport regression screenshots
|
||||
plans/transport-shots/
|
||||
|
||||
@@ -1199,7 +1199,7 @@
|
||||
<div class="ln-tools">
|
||||
<input class="tin" value="${esc(fmtT(line.t))}" placeholder="0:00.00" aria-label="Time" />
|
||||
<button class="btn" data-act="set" type="button">◎ Stamp now</button>
|
||||
<button class="btn" data-act="seek" type="button">▶ Play here</button>
|
||||
<button class="btn" data-act="seek" type="button">${TransportIcons.svg('play')} Play here</button>
|
||||
<button class="btn" data-act="nudge" data-d="-0.2" type="button">−0.2</button>
|
||||
<button class="btn" data-act="nudge" data-d="0.2" type="button">+0.2</button>
|
||||
<select class="mini" data-kind aria-label="Line type"><option value="line">Line</option><option value="section">Section</option><option value="cue">Cue</option></select>
|
||||
|
||||
@@ -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");}
|
||||
|
||||
@@ -1598,8 +1598,8 @@ function escapeHtml(s) {
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
const ICON_PLAY = '<svg viewBox="0 0 24 24" aria-hidden="true" class="fill"><path d="M9 5.5v13l10-6.5z"/></svg>';
|
||||
const ICON_PAUSE = '<svg viewBox="0 0 24 24" aria-hidden="true" class="fill"><path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z"/></svg>';
|
||||
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 = '<span class="more-ico"></span><span class="more-txt"><b></b></span>';
|
||||
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 = '<span class="more-ico"></span><span class="more-txt"><b></b><small></small></span><span class="more-go">' + (on ? '●' : '›') + '</span>';
|
||||
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 = '<span class="more-ico"></span><span class="more-txt"><b></b><small></small></span><span class="more-go">›</span>';
|
||||
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 = '<span class="more-ico"></span><span class="more-txt"><b></b><small></small></span><span class="more-go">›</span>';
|
||||
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 = (() => {
|
||||
<div class="mk-title">${m.kind === 'note' ? '📝 ' : ''}${escapeHtml(m.title)}</div>
|
||||
${m.note ? `<div class="mk-note${m.kind === 'note' ? ' long' : ''}">${escapeHtml(m.note)}</div>` : ''}
|
||||
</div>
|
||||
<button class="mk-btn" type="button" data-act="jump" title="Play from here">▶</button>
|
||||
<button class="mk-btn" type="button" data-act="jump" title="Play from here">${TransportIcons.svg('play')}</button>
|
||||
${m.auto ? '' : `<button class="mk-btn" type="button" data-act="edit" title="Edit">✎</button>
|
||||
<button class="mk-btn" type="button" data-act="del" title="Delete">🗑</button>`}
|
||||
</div>`;
|
||||
@@ -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('<div class="gh-label">Zoom reset</div>'); }
|
||||
else if (current) { Player.toggle(); updatePlayBtn(); showHud(`<div class="gh-seek">${Player.master.paused ? '⏸' : '▶'}</div>`); }
|
||||
else if (current) { Player.toggle(); updatePlayBtn(); showHud(`<div class="gh-seek">${TransportIcons.svg(Player.master.paused ? 'pause' : 'play')}</div>`); }
|
||||
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);
|
||||
|
||||
|
||||
@@ -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]) + '<span>' + label + '</span>'; 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','<span>' + (state.paused ? 'Play' : 'Pause') + '</span>'); }; 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(); };
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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: '<path d="m8 5 11 7-11 7z"/>',
|
||||
pause: '<rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/>',
|
||||
previous: '<path d="m19 5-10 7 10 7zM5 5v14"/>',
|
||||
next: '<path d="m5 5 10 7-10 7zM19 5v14"/>',
|
||||
forward: '<path d="m3 5 9 7-9 7zM12 5l9 7-9 7z"/>'
|
||||
};
|
||||
for (const seconds of [10,15]) for (const direction of ['back','forward']) {
|
||||
transportPaths[direction + seconds] = (direction === 'back'
|
||||
? '<path d="M4 12a8 8 0 1 0 3-6.2M4 4v4.5h4.5"/>'
|
||||
: '<path d="M20 12a8 8 0 1 1-3-6.2M20 4v4.5h-4.5"/>') +
|
||||
`<text x="12" y="15.5" text-anchor="middle" font-size="7.5" font-weight="700" fill="currentColor" stroke="none">${seconds}</text>`;
|
||||
}
|
||||
const transportVariants = Object.fromEntries(['classic','f7-swipe','glass-stage','bento-hub'].map(layout => [layout,{...transportPaths}]));
|
||||
Object.assign(transportVariants['f7-swipe'], {
|
||||
play: '<path d="M8 5.8a1 1 0 0 1 1.5-.86l10 6.2a1 1 0 0 1 0 1.72l-10 6.2A1 1 0 0 1 8 18.2z"/>',
|
||||
previous: '<rect x="4" y="5" width="3" height="14" rx="1.5"/><path d="M19 6a1 1 0 0 0-1.5-.86l-8.5 6a1 1 0 0 0 0 1.72l8.5 6A1 1 0 0 0 19 18z"/>',
|
||||
next: '<rect x="17" y="5" width="3" height="14" rx="1.5"/><path d="M5 6a1 1 0 0 1 1.5-.86l8.5 6a1 1 0 0 1 0 1.72l-8.5 6A1 1 0 0 1 5 18z"/>'
|
||||
});
|
||||
const transport = {
|
||||
paths: transportPaths, variants: transportVariants,
|
||||
svg(kind) {
|
||||
if (!transportPaths[kind]) throw new Error('Unknown transport icon: ' + kind);
|
||||
return `<svg class="transport-icon" data-transport-icon="${kind}" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><use href="#transport-${kind}"/></svg>`;
|
||||
},
|
||||
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='<defs>'+Object.keys(transportPaths).map(kind=>`<g id="transport-${kind}">${Object.entries(transportVariants).map(([layout,paths])=>`<g class="transport-shape transport-shape-${layout}">${paths[kind]}</g>`).join('')}</g>`).join('')+'</defs>';
|
||||
root.document.body.appendChild(defs); root.TransportIcons=transport;
|
||||
}
|
||||
const paths = {
|
||||
retry: '<path d="M3 11a9 9 0 1 1 2.6 7M3 3v8h8"/>',
|
||||
cancel: '<path d="m6 6 12 12M6 18 18 6"/>',
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = '<div class="fullscreen-top"><div class="fullscreen-title" aria-live="polite"></div><button type="button" aria-label="Exit fullscreen" title="Exit fullscreen"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 4H4v5m0-5 6 6m5 9h5v-5m0 5-6-6"/></svg></button></div><div class="fullscreen-controls"><button type="button" aria-label="Play or pause" title="Play or pause"><svg class="fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg></button><input type="range" min="0" max="1000" value="0" aria-label="Seek video"><button type="button" class="fullscreen-orientation" aria-label="Change fullscreen orientation" title="Change fullscreen orientation"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8V4h4M20 16v4h-4M4 4l6 6m4 4 6 6M20 8V4h-4M4 16v4h4M20 4l-6 6m-4 4-6 6"/></svg></button><button type="button" class="fullscreen-fit" aria-label="Change video fit" title="Change video fit"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7V4h3M17 4h3v3M20 17v3h-3M7 20H4v-3M7 9h10v6H7z"/></svg></button></div>';
|
||||
hud.innerHTML = '<div class="fullscreen-top"><div class="fullscreen-title" aria-live="polite"></div><button type="button" aria-label="Exit fullscreen" title="Exit fullscreen"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 4H4v5m0-5 6 6m5 9h5v-5m0 5-6-6"/></svg></button></div><div class="fullscreen-controls"><button type="button" aria-label="Play or pause" title="Play or pause">' + root.TransportIcons.svg('play') + '</button><input type="range" min="0" max="1000" value="0" aria-label="Seek video"><button type="button" class="fullscreen-orientation" aria-label="Change fullscreen orientation" title="Change fullscreen orientation"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8V4h4M20 16v4h-4M4 4l6 6m4 4 6 6M20 8V4h-4M4 16v4h4M20 4l-6 6m-4 4-6 6"/></svg></button><button type="button" class="fullscreen-fit" aria-label="Change video fit" title="Change video fit"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7V4h3M17 4h3v3M20 17v3h-3M7 20H4v-3M7 9h10v6H7z"/></svg></button></div>';
|
||||
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
|
||||
? '<svg class="fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>'
|
||||
: '<svg class="fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 5h4v14H6zm8 0h4v14h-4z"/></svg>';
|
||||
root.TransportIcons.set(play, s.paused ? 'play' : 'pause', s.paused ? 'Play video' : 'Pause video');
|
||||
}, 250);
|
||||
}
|
||||
document.addEventListener('fullscreenchange', () => { if (active && !document.fullscreenElement && native) cleanup(); });
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
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:'<use href="#media-download"/>',
|
||||
play:'<path d="m8 5 11 7-11 7z"/>', pause:'<path d="M8 5v14M16 5v14"/>',
|
||||
next:'<path d="m5 5 10 7-10 7zM19 5v14"/>', previous:'<path d="m19 5-10 7 10 7zM5 5v14"/>',
|
||||
play:'<use href="#transport-play"/>', pause:'<use href="#transport-pause"/>',
|
||||
next:'<use href="#transport-next"/>', previous:'<use href="#transport-previous"/>',
|
||||
repeat:'<path d="m17 2 4 4-4 4M3 11V8a2 2 0 0 1 2-2h16M7 22l-4-4 4-4m14-1v3a2 2 0 0 1-2 2H3"/>',
|
||||
edit:'<path d="m14 5 5 5M4 20l5-1L21 7l-4-4L5 15z"/>',
|
||||
lyrics:'<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3M8 22h8"/>',
|
||||
|
||||
@@ -42,12 +42,12 @@
|
||||
<!-- Sidebar -->
|
||||
<aside id="sidebar" class="sidebar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">▶</span> YT Player
|
||||
<span class="brand-mark"><svg class="transport-icon" data-transport-icon="play" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><use href="#transport-play"/></svg></span> YT Player
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<button class="nav-item active" data-view="search"><span class="legacy-nav-icon" aria-hidden="true">🔍</span><svg class="classic-nav-icon" hidden viewBox="0 0 24 24" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/></svg> Search</button>
|
||||
<button class="nav-item" data-view="queue"><span class="legacy-nav-icon" aria-hidden="true">▶</span><svg class="classic-nav-icon" hidden viewBox="0 0 24 24" aria-hidden="true"><path d="m8 5 11 7-11 7z"/></svg> Queue <span id="navQueueCount" class="nav-badge hidden">0</span></button>
|
||||
<button class="nav-item" data-view="queue"><svg class="transport-icon" data-transport-icon="play" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><use href="#transport-play"/></svg> Queue <span id="navQueueCount" class="nav-badge hidden">0</span></button>
|
||||
<button class="nav-item" data-view="history"><span class="legacy-nav-icon" aria-hidden="true">🕘</span><svg class="classic-nav-icon" hidden viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg> History</button>
|
||||
<button class="nav-item" data-view="saved"><svg class="media-icon media-nav-icon" data-media-icon="offline" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-offline"/></svg> Saved</button>
|
||||
<button class="nav-item" data-view="downloads"><svg class="media-icon media-nav-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-download"/></svg> Downloads <span id="navDlCount" class="nav-badge hidden">0</span></button>
|
||||
@@ -140,7 +140,7 @@
|
||||
</div>
|
||||
<div id="playerPlaceholder" class="player-placeholder">
|
||||
<div class="hero">
|
||||
<div class="ph-logo">▶</div>
|
||||
<div class="ph-logo"><svg class="transport-icon" data-transport-icon="play" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><use href="#transport-play"/></svg></div>
|
||||
<h1 class="hero-title">YT Player</h1>
|
||||
<p class="hero-tagline">Ad-free YouTube — no login needed. Search, play, and discover videos shaped by what you listen to.</p>
|
||||
<div class="hero-pills">
|
||||
@@ -178,9 +178,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="stg-mid">
|
||||
<button id="stgBack" class="stg-skip" type="button" aria-label="Back 10 seconds"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12a8 8 0 1 0 3-6.2M4 4v4.5h4.5"/><text x="12" y="15.5" text-anchor="middle" font-size="7.5" font-weight="700" fill="currentColor" stroke="none">10</text></svg></button>
|
||||
<button id="stgPlay" class="stg-play" type="button" aria-label="Play or pause"><svg viewBox="0 0 24 24" aria-hidden="true" class="fill"><path d="M9 5.5v13l10-6.5z"/></svg></button>
|
||||
<button id="stgFwd" class="stg-skip" type="button" aria-label="Forward 10 seconds"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 12a8 8 0 1 1-3-6.2M20 4v4.5h-4.5"/><text x="12" y="15.5" text-anchor="middle" font-size="7.5" font-weight="700" fill="currentColor" stroke="none">10</text></svg></button>
|
||||
<button id="stgBack" class="stg-skip" type="button" aria-label="Back 10 seconds"><svg class="transport-icon" data-transport-icon="back10" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><use href="#transport-back10"/></svg></button>
|
||||
<button id="stgPlay" class="stg-play" type="button" aria-label="Play or pause"><svg class="transport-icon" data-transport-icon="play" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><use href="#transport-play"/></svg></button>
|
||||
<button id="stgFwd" class="stg-skip" type="button" aria-label="Forward 10 seconds"><svg class="transport-icon" data-transport-icon="forward10" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><use href="#transport-forward10"/></svg></button>
|
||||
</div>
|
||||
<div class="stg-bot"><span class="stg-chip"><b id="stgZoom">Pinch 1.0×</b></span><span class="stg-chip"><i class="stg-dot"></i><b id="stgBuf">Buffered</b></span></div>
|
||||
</div>
|
||||
@@ -207,11 +207,11 @@
|
||||
</div>
|
||||
<button id="waveLoud" class="wave-loud hidden" type="button">⚡ Jump to the loudest part</button>
|
||||
<div class="deck-main">
|
||||
<button id="prevBtn" class="ctrl" title="Previous" aria-label="Previous"><svg viewBox="0 0 24 24" aria-hidden="true" class="fill"><rect x="5" y="5" width="2.8" height="14" rx="1.2"/><path d="M19 5.5v13L9.5 12z"/></svg></button>
|
||||
<button id="back10Btn" class="ctrl skip10" type="button" title="Back 10 seconds" aria-label="Back 10 seconds"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12a8 8 0 1 0 3-6.2M4 4v4.5h4.5"/><text x="12" y="15.5" text-anchor="middle" font-size="7.5" font-weight="700" fill="currentColor" stroke="none">10</text></svg></button>
|
||||
<button id="playBtn" class="ctrl play" title="Play/Pause" aria-label="Play or pause"><svg viewBox="0 0 24 24" aria-hidden="true" class="fill"><path d="M9 5.5v13l10-6.5z"/></svg></button>
|
||||
<button id="fwd10Btn" class="ctrl skip10" type="button" title="Forward 10 seconds" aria-label="Forward 10 seconds"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 12a8 8 0 1 1-3-6.2M20 4v4.5h-4.5"/><text x="12" y="15.5" text-anchor="middle" font-size="7.5" font-weight="700" fill="currentColor" stroke="none">10</text></svg></button>
|
||||
<button id="nextBtn" class="ctrl" title="Next" aria-label="Next"><svg viewBox="0 0 24 24" aria-hidden="true" class="fill"><rect x="16.2" y="5" width="2.8" height="14" rx="1.2"/><path d="M5 5.5v13l9.5-6.5z"/></svg></button>
|
||||
<button id="prevBtn" class="ctrl" title="Previous" aria-label="Previous"><svg class="transport-icon" data-transport-icon="previous" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><use href="#transport-previous"/></svg></button>
|
||||
<button id="back10Btn" class="ctrl skip10" type="button" title="Back 10 seconds" aria-label="Back 10 seconds"><svg class="transport-icon" data-transport-icon="back10" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><use href="#transport-back10"/></svg></button>
|
||||
<button id="playBtn" class="ctrl play" title="Play/Pause" aria-label="Play or pause"><svg class="transport-icon" data-transport-icon="play" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><use href="#transport-play"/></svg></button>
|
||||
<button id="fwd10Btn" class="ctrl skip10" type="button" title="Forward 10 seconds" aria-label="Forward 10 seconds"><svg class="transport-icon" data-transport-icon="forward10" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><use href="#transport-forward10"/></svg></button>
|
||||
<button id="nextBtn" class="ctrl" title="Next" aria-label="Next"><svg class="transport-icon" data-transport-icon="next" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><use href="#transport-next"/></svg></button>
|
||||
<div id="dkModeHost" hidden><button id="shuffleBtn" class="ctrl" title="Shuffle upcoming tracks (S)" aria-label="Shuffle"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 3h5v5M4 20 21 3M21 16v5h-5M15 15l6 6M4 4l5 5"/></svg></button><button id="repeatBtn" class="ctrl" title="Repeat list when finished (R)" aria-label="Repeat list"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M17 2l4 4-4 4M3 12v-2a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 12v2a4 4 0 0 1-4 4H3"/></svg></button><button id="loopBtn" class="ctrl" title="Loop this video (L)"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M17 2l4 4-4 4M3 12v-2a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 12v2a4 4 0 0 1-4 4H3M11 10.2l1.6-.9v5.4"/></svg></button></div>
|
||||
</div>
|
||||
<div class="deck-vol vol">
|
||||
@@ -473,9 +473,9 @@
|
||||
<span id="serviceCurTime">0:00</span> / <span id="serviceDurTime">0:00</span>
|
||||
</div>
|
||||
<div class="service-controls">
|
||||
<button id="servicePrevBtn" class="service-btn" type="button" aria-label="Previous track">⏮</button>
|
||||
<button id="servicePlayBtn" class="service-btn service-btn-play" type="button" aria-label="Play or Pause">▶</button>
|
||||
<button id="serviceNextBtn" class="service-btn" type="button" aria-label="Next track">⏭</button>
|
||||
<button id="servicePrevBtn" class="service-btn" type="button" aria-label="Previous track"><svg class="transport-icon" data-transport-icon="previous" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><use href="#transport-previous"/></svg></button>
|
||||
<button id="servicePlayBtn" class="service-btn service-btn-play" type="button" aria-label="Play or Pause"><svg class="transport-icon" data-transport-icon="play" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><use href="#transport-play"/></svg></button>
|
||||
<button id="serviceNextBtn" class="service-btn" type="button" aria-label="Next track"><svg class="transport-icon" data-transport-icon="next" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><use href="#transport-next"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -490,7 +490,7 @@
|
||||
<!-- Mini now-playing bar -->
|
||||
<div id="miniBar" class="mini-bar hidden">
|
||||
<div class="mini-bar-inner">
|
||||
<button id="miniPlayBtn" class="mini-btn">▶</button>
|
||||
<button id="miniPlayBtn" class="mini-btn" aria-label="Play"><svg class="transport-icon" data-transport-icon="play" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><use href="#transport-play"/></svg></button>
|
||||
<div class="mini-info">
|
||||
<div id="miniTitle" class="mini-title"></div>
|
||||
<div id="miniChannel" class="mini-channel"></div>
|
||||
@@ -567,9 +567,9 @@
|
||||
<div class="rv-times"><span id="rvCur">0:00</span><span id="rvDur">0:00</span></div>
|
||||
</div>
|
||||
<div class="rv-transport">
|
||||
<button id="rvPrev" class="rv-tbtn" type="button" aria-label="Previous">⏮</button>
|
||||
<button id="rvToggle" class="rv-tbtn rv-play" type="button" aria-label="Play or pause">▶</button>
|
||||
<button id="rvNext" class="rv-tbtn" type="button" aria-label="Next">⏭</button>
|
||||
<button id="rvPrev" class="rv-tbtn" type="button" aria-label="Previous"><svg class="transport-icon" data-transport-icon="previous" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><use href="#transport-previous"/></svg></button>
|
||||
<button id="rvToggle" class="rv-tbtn rv-play" type="button" aria-label="Play or pause"><svg class="transport-icon" data-transport-icon="play" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><use href="#transport-play"/></svg></button>
|
||||
<button id="rvNext" class="rv-tbtn" type="button" aria-label="Next"><svg class="transport-icon" data-transport-icon="next" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><use href="#transport-next"/></svg></button>
|
||||
</div>
|
||||
<div class="rv-volume">
|
||||
<span>🔊</span><input id="rvVolume" type="range" min="0" max="100" value="100" /><span id="rvVolVal">100%</span>
|
||||
@@ -591,7 +591,7 @@
|
||||
<span class="bottom-nav-label">Search</span>
|
||||
</button>
|
||||
<button class="bottom-nav-btn" data-view="queue" aria-label="Queue">
|
||||
<span class="bottom-nav-icon"><span class="legacy-nav-icon" aria-hidden="true">▶</span><svg class="classic-nav-icon" hidden viewBox="0 0 24 24" aria-hidden="true"><path d="m8 5 11 7-11 7z"/></svg></span>
|
||||
<span class="bottom-nav-icon"><svg class="transport-icon" data-transport-icon="play" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><use href="#transport-play"/></svg></span>
|
||||
<span class="bottom-nav-label">Queue</span>
|
||||
<span id="bnQueueCount" class="bottom-nav-badge hidden">0</span>
|
||||
</button>
|
||||
|
||||
@@ -16,10 +16,19 @@
|
||||
for (const link of root.document.querySelectorAll('link[rel="stylesheet"]')) { const copy = doc.createElement('link'); copy.rel = 'stylesheet'; copy.href = link.href; doc.head.append(copy); }
|
||||
const style = doc.createElement('style'); style.textContent = 'body{margin:0;background:var(--bg,#151719);color:var(--text,#fff);display:flex;flex-direction:column;height:100vh;overflow:hidden}.lyrics-floating-view{position:relative!important;inset:auto!important;flex:1;min-height:0;display:flex!important;flex-direction:column!important;width:100%!important}.lyrics-floating-view .sl-list{flex:1;min-height:0;height:auto!important}.lyrics-floating-controls{display:flex;gap:8px;padding:12px}.lyrics-floating-controls button{flex:1;min-height:48px;background:var(--bg-2,#25292d);color:inherit;border:1px solid var(--line,#555);border-radius:8px}'; doc.head.append(style);
|
||||
const view = adapter.view(), marker = root.document.createComment('floating lyrics'); view.before(marker); view.classList.add('lyrics-floating-view'); doc.body.append(view);
|
||||
doc.body.append(root.document.querySelector('#transport-play').ownerSVGElement.cloneNode(true));
|
||||
const controls = doc.createElement('div'); controls.className = 'lyrics-floating-controls';
|
||||
for (const [label, action] of [['Play / Pause', adapter.toggle], ['Next', adapter.next]]) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = label; b.onclick = action; controls.append(b); } doc.body.append(controls);
|
||||
for (const [kind, label, action] of [['play', 'Play', adapter.toggle], ['next', 'Next', adapter.next]]) { const b = doc.createElement('button'); b.type = 'button'; root.TransportIcons.set(b,kind,label); b.onclick = action; controls.append(b); } doc.body.append(controls);
|
||||
const repaint = () => {
|
||||
doc.documentElement.dataset.layout=root.document.documentElement.dataset.layout; doc.documentElement.dataset.theme=root.document.documentElement.dataset.theme;
|
||||
const kind=root.document.querySelector('#playBtn .transport-icon')?.dataset.transportIcon || 'play';
|
||||
root.TransportIcons.set(controls.firstElementChild,kind);
|
||||
};
|
||||
const transportObserver=new root.MutationObserver(repaint);
|
||||
transportObserver.observe(root.document.getElementById('playBtn'),{childList:true});
|
||||
transportObserver.observe(root.document.documentElement,{attributes:true,attributeFilter:['data-layout','data-theme']}); repaint();
|
||||
const win = pipWindow; let restored = false;
|
||||
cleanup = () => { if (restored) return; restored = true; marker.replaceWith(view); view.classList.remove('lyrics-floating-view'); pipWindow = null; adapter.refresh(); if (!win.closed) win.close(); };
|
||||
cleanup = () => { if (restored) return; restored = true; transportObserver.disconnect(); marker.replaceWith(view); view.classList.remove('lyrics-floating-view'); pipWindow = null; adapter.refresh(); if (!win.closed) win.close(); };
|
||||
win.addEventListener('pagehide', cleanup, { once: true }); win.addEventListener('resize', adapter.refresh); adapter.refresh();
|
||||
} else {
|
||||
const canvas = root.document.createElement('canvas'); canvas.width = 840; canvas.height = 500; const ctx = canvas.getContext('2d');
|
||||
|
||||
@@ -293,7 +293,7 @@
|
||||
<div class="rh-body"></div>
|
||||
<label class="rh-name-row">This screen's name <input class="rh-name" maxlength="40" /></label>
|
||||
<div class="rh-buttons">
|
||||
<button class="np-btn" type="button" data-act="start">▶ Start — control from a phone</button>
|
||||
<button class="np-btn" type="button" data-act="start">${TransportIcons.svg('play')} Start — control from a phone</button>
|
||||
<button class="np-btn" type="button" data-act="code">↻ New code</button>
|
||||
<button class="np-btn" type="button" data-act="unpair">Unpair all phones</button>
|
||||
<button class="np-btn" type="button" data-act="stop">Stop</button>
|
||||
@@ -414,14 +414,14 @@
|
||||
$('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); }
|
||||
if (!directPlay) { directPlay = document.createElement('button'); directPlay.id = 'rvDirectPlay'; directPlay.className = 'np-btn'; directPlay.style.minHeight = '44px'; directPlay.innerHTML = TransportIcons.svg('play') + ' 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');
|
||||
$('rvToggle').textContent = s && !s.paused ? '⏸' : '▶';
|
||||
TransportIcons.set($('rvToggle'), s && !s.paused ? 'pause' : 'play');
|
||||
$('rvDur').textContent = clock((s && s.dur) || 0);
|
||||
if (!rc.volTimer && document.activeElement !== $('rvVolume')) {
|
||||
const pct = Math.round(((s && s.volume) ?? 1) * 100);
|
||||
|
||||
@@ -186,13 +186,13 @@
|
||||
<div class="sm-link"><span>Stream</span><code>${escapeHtml(url)}</code><button class="np-btn" type="button" data-act="copy">Copy</button></div>
|
||||
<div class="ext-buttons">
|
||||
${isAndroid ? `
|
||||
<a class="np-btn" href="${escapeHtml(intent(url, title, 'org.videolan.vlc'))}">▶ VLC</a>
|
||||
<a class="np-btn" href="${escapeHtml(intent(url, title, 'com.mxtech.videoplayer.ad'))}">▶ MX Player</a>
|
||||
<a class="np-btn" href="${escapeHtml(intent(url, title))}">▶ Open with…</a>`
|
||||
<a class="np-btn" href="${escapeHtml(intent(url, title, 'org.videolan.vlc'))}">${TransportIcons.svg('play')} VLC</a>
|
||||
<a class="np-btn" href="${escapeHtml(intent(url, title, 'com.mxtech.videoplayer.ad'))}">${TransportIcons.svg('play')} MX Player</a>
|
||||
<a class="np-btn" href="${escapeHtml(intent(url, title))}">${TransportIcons.svg('play')} Open with…</a>`
|
||||
: isIOS ? `
|
||||
<a class="np-btn" href="vlc-x-callback://x-callback-url/stream?url=${encodeURIComponent(url)}">▶ VLC</a>
|
||||
<a class="np-btn" href="infuse://x-callback-url/play?url=${encodeURIComponent(url)}">▶ Infuse</a>`
|
||||
: `<a class="np-btn" href="vlc://${escapeHtml(url)}">▶ VLC</a>`}
|
||||
<a class="np-btn" href="vlc-x-callback://x-callback-url/stream?url=${encodeURIComponent(url)}">${TransportIcons.svg('play')} VLC</a>
|
||||
<a class="np-btn" href="infuse://x-callback-url/play?url=${encodeURIComponent(url)}">${TransportIcons.svg('play')} Infuse</a>`
|
||||
: `<a class="np-btn" href="vlc://${escapeHtml(url)}">${TransportIcons.svg('play')} VLC</a>`}
|
||||
<button class="np-btn" type="button" data-act="m3u">${MediaIcons.svg('download')} .m3u (this video)</button>
|
||||
${inList ? `<button class="np-btn" type="button" data-act="m3u-list">${MediaIcons.svg('download')} .m3u (whole list)</button>` : ''}
|
||||
</div>
|
||||
|
||||
@@ -154,7 +154,7 @@ function renderHomePlaylists() {
|
||||
${thumb ? `<img src="${escapeHtml(thumb)}" alt="" loading="lazy" decoding="async" />`
|
||||
: '<span class="home-pl-thumb-fallback">🎵</span>'}
|
||||
${offlineHtml}
|
||||
<span class="home-pl-play" title="Play all">▶</span>
|
||||
<span class="home-pl-play" title="Play all">${TransportIcons.svg('play')}</span>
|
||||
</span>
|
||||
<span class="home-pl-name"></span>
|
||||
<span class="home-pl-count">${count} video${count === 1 ? '' : 's'}</span>`;
|
||||
@@ -162,7 +162,7 @@ function renderHomePlaylists() {
|
||||
card.title = pl.name;
|
||||
card.addEventListener('click', () => { view = { type: 'playlist', id: pl.id }; render(); });
|
||||
card.querySelector('.home-pl-play').addEventListener('click', (e) => {
|
||||
e.stopPropagation(); // the ▶ badge plays; the rest of the card opens the list
|
||||
e.stopPropagation(); // the play badge plays; the rest of the card opens the list
|
||||
if (count) playFromList(pl.videos, 0, 'playlist:' + pl.id);
|
||||
else toast('That playlist is empty');
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<div class="st-sub">${escapeHtml(deltaTxt)}${deltaTxt ? ' · ' : ''}${hrs(S.totalSec)} all time</div>
|
||||
</div>
|
||||
<div class="st-tile">
|
||||
<div class="st-label">▶ Track plays</div>
|
||||
<div class="st-label">${TransportIcons.svg('play')} Track plays</div>
|
||||
<div class="st-big">${S.totalPlays}</div>
|
||||
<div class="st-sub">${S.songsPlayed} song${S.songsPlayed === 1 ? '' : 's'} · ${playlistsWith} playlist${playlistsWith === 1 ? '' : 's'}</div>
|
||||
</div>
|
||||
|
||||
@@ -1019,7 +1019,7 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
||||
box-shadow: 0 0 26px -12px var(--accent-glow);
|
||||
}
|
||||
.card.playing::after {
|
||||
content: "▶ NOW PLAYING";
|
||||
content: "NOW PLAYING";
|
||||
position: absolute; top: 9px; right: 44px;
|
||||
font-family: var(--mono); font-size: 8px; font-weight: 700;
|
||||
letter-spacing: 0.1em; color: var(--accent);
|
||||
@@ -4356,3 +4356,25 @@ html[data-layout="bento-hub"] .media-shape-classic { display:none; }
|
||||
html[data-layout="f7-swipe"] .media-shape-f7-swipe,
|
||||
html[data-layout="glass-stage"] .media-shape-glass-stage,
|
||||
html[data-layout="bento-hub"] .media-shape-bento-hub { display:inline; }
|
||||
|
||||
/* Transport drawings and touch targets survive layout/state changes. */
|
||||
.transport-icon { display:inline-block; flex:none; width:24px; height:24px; vertical-align:middle; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; pointer-events:none; }
|
||||
.transport-shape { display:none; }
|
||||
.transport-shape-classic { display:inline; }
|
||||
html:is([data-layout="f7-swipe"],[data-layout="glass-stage"],[data-layout="bento-hub"]) .transport-shape-classic { display:none; }
|
||||
html[data-layout="f7-swipe"] .transport-shape-f7-swipe,
|
||||
html[data-layout="glass-stage"] .transport-shape-glass-stage,
|
||||
html[data-layout="bento-hub"] .transport-shape-bento-hub { display:inline; }
|
||||
html[data-layout="f7-swipe"] .transport-shape-f7-swipe { fill:currentColor; stroke-width:.7; }
|
||||
html[data-layout="f7-swipe"] :is([data-transport-icon="back10"],[data-transport-icon="forward10"],[data-transport-icon="back15"]) .transport-shape { fill:none; stroke-width:2; }
|
||||
html[data-layout="glass-stage"] .transport-icon { stroke-width:1.5; }
|
||||
html[data-layout="bento-hub"] .transport-icon { stroke-width:2; stroke-linejoin:round; }
|
||||
button:has(> .transport-icon), a.np-btn:has(> .transport-icon) { min-width:44px; min-height:44px; }
|
||||
button:has(> .transport-icon) { align-items:center; justify-content:center; }
|
||||
html[data-layout="f7-swipe"] :is(#playBtn,#stgPlay,.service-btn-play,.rv-play) { border-radius:50%; }
|
||||
html[data-layout="f7-swipe"] #playBtn { width:64px; height:64px; }
|
||||
html[data-layout="glass-stage"] :is(#playBtn,.mini-btn,.rv-tbtn):has(> .transport-icon) { background:var(--tile,rgba(255,255,255,.08)); border:1px solid var(--line); backdrop-filter:blur(12px); }
|
||||
html[data-layout="bento-hub"] :is(#playBtn,.mini-btn,.service-btn,.rv-tbtn):has(> .transport-icon) { border-radius:16px; }
|
||||
.car-controls .transport-icon { width:36px; height:36px; margin-right:12px; }
|
||||
.gh-seek .transport-icon, .empty-icon .transport-icon { width:48px; height:48px; }
|
||||
.ph-logo .transport-icon { width:60%; height:60%; }
|
||||
|
||||
40
frontend/transport-icons.test.js
Normal file
40
frontend/transport-icons.test.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const {test}=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const {readFileSync,readdirSync}=require('node:fs');
|
||||
const {join}=require('node:path');
|
||||
const vm=require('node:vm');
|
||||
const {transport}=require('./download-actions');
|
||||
test('transport symbols cover all actions and layouts with monochrome SVG',()=>{
|
||||
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),/stroke="currentColor"/);
|
||||
for(const layout of ['classic','f7-swipe','glass-stage','bento-hub'])assert.ok(transport.variants[layout][kind]);
|
||||
}
|
||||
assert.throws(()=>transport.svg('unknown'),/Unknown transport/);
|
||||
});
|
||||
test('playback UI swaps shared symbols and accessible names without changing handlers',()=>{
|
||||
const buttons=Object.fromEntries(['playBtn','stgPlay','miniPlayBtn','servicePlayBtn'].map(id=>[id,{onclick:()=>id,setAttribute(k,v){this[k]=v;}}]));
|
||||
const handlers=Object.values(buttons).map(b=>b.onclick);let changes=0;
|
||||
const Player={master:{paused:true}},classes=[];
|
||||
const app=readFileSync(join(__dirname,'app.js'),'utf8');
|
||||
const body=app.slice(app.indexOf('function updatePlayBtn() {'),app.indexOf('// "⚠ Broken"'));
|
||||
const sandbox={Player,TransportIcons:transport,Remote:{changed(){changes++;}},$:id=>buttons[id],document:{querySelector:()=>({classList:{toggle:(...args)=>classes.push(args)}})}};
|
||||
vm.runInNewContext(body,sandbox);
|
||||
for(const paused of [true,false,true]){
|
||||
Player.master.paused=paused;sandbox.updatePlayBtn();
|
||||
for(const b of Object.values(buttons)){assert.match(b.innerHTML,new RegExp(`#transport-${paused?'play':'pause'}`));assert.equal(b['aria-label'],paused?'Play':'Pause');}
|
||||
}
|
||||
assert.equal(changes,3);assert.deepEqual(Object.values(buttons).map(b=>b.onclick),handlers);
|
||||
assert.deepEqual(classes,[['is-paused',true],['is-paused',false],['is-paused',true]]);
|
||||
});
|
||||
test('runtime sources never render legacy transport glyphs',()=>{
|
||||
const failures=[];
|
||||
for(const file of readdirSync(__dirname).filter(f=>/\.(js|html|css)$/.test(f)&&!f.endsWith('.test.js'))){
|
||||
readFileSync(join(__dirname,file),'utf8').split('\n').forEach((line,i)=>{
|
||||
// Legacy input decoder accepts old labels; its output uses shared symbols.
|
||||
if(file==='glass-controls.js'&&line.startsWith(' const glyphs = '))return;
|
||||
if(/[▶⏸⏮⏭⏪⏩⏯◀❚]/u.test(line))failures.push(`${file}:${i+1}`);
|
||||
});
|
||||
}
|
||||
assert.deepEqual(failures,[]);
|
||||
});
|
||||
@@ -222,7 +222,7 @@ function renderList() {
|
||||
els.listTitle.textContent = 'Queue';
|
||||
if (list.length) {
|
||||
const playAll = document.createElement('button');
|
||||
playAll.textContent = '▶ Play queue';
|
||||
playAll.innerHTML = TransportIcons.svg('play') + ' Play queue';
|
||||
playAll.onclick = () => playQueue(0);
|
||||
const clear = document.createElement('button');
|
||||
clear.textContent = 'Clear';
|
||||
@@ -234,7 +234,7 @@ function renderList() {
|
||||
els.listTitle.textContent = sp ? sp.label : 'Auto Playlist';
|
||||
if (list.length) {
|
||||
const playAll = document.createElement('button');
|
||||
playAll.textContent = '▶ Play all';
|
||||
playAll.innerHTML = TransportIcons.svg('play') + ' Play all';
|
||||
playAll.onclick = () => playFromList(list.slice(), 0, 'smart:' + view.smartType);
|
||||
const queueAll = document.createElement('button');
|
||||
queueAll.textContent = '+ Queue all';
|
||||
@@ -245,7 +245,7 @@ function renderList() {
|
||||
els.listTitle.textContent = channelData.name || 'Channel';
|
||||
if (list.length) {
|
||||
const playAll = document.createElement('button');
|
||||
playAll.textContent = '▶ Play all';
|
||||
playAll.innerHTML = TransportIcons.svg('play') + ' Play all';
|
||||
playAll.onclick = () => playFromList(channelData.results.slice(), 0, 'channel');
|
||||
const queueAll = document.createElement('button');
|
||||
queueAll.textContent = '+ Queue all';
|
||||
@@ -261,11 +261,11 @@ function renderList() {
|
||||
$('listSub').textContent = `${pl.videos.length} track${pl.videos.length === 1 ? '' : 's'}${secs ? ' · ' + (h ? h + 'h ' : '') + m + 'm' : ''}`; }
|
||||
renderP2pStrip();
|
||||
const playAll = document.createElement('button');
|
||||
playAll.textContent = '▶ Play all';
|
||||
playAll.innerHTML = TransportIcons.svg('play') + ' Play all';
|
||||
playAll.className = 'la-primary';
|
||||
playAll.onclick = () => { if (pl.videos.length) playFromList(pl.videos, 0, 'playlist:' + pl.id); };
|
||||
const playFull = document.createElement('button');
|
||||
playFull.textContent = '▶ Play in full';
|
||||
playFull.innerHTML = TransportIcons.svg('play') + ' Play in full';
|
||||
playFull.className = 'la-main';
|
||||
playFull.dataset.sub = 'Start to finish';
|
||||
playFull.title = 'Play every video start to finish — ignores saved positions and A-B loops, stops after the last one';
|
||||
@@ -360,7 +360,7 @@ function renderList() {
|
||||
addBrowseCta(empty, '🔍 Search videos');
|
||||
} else if (view.type === 'queue') {
|
||||
empty.innerHTML = `
|
||||
<div class="empty-icon">▶</div>
|
||||
<div class="empty-icon">${TransportIcons.svg('play')}</div>
|
||||
<h3 class="empty-title">Your queue is empty</h3>
|
||||
<p class="empty-desc">Use <strong>+ Queue</strong> on any video to line it up. The queue is temporary and plays in order.</p>
|
||||
`;
|
||||
@@ -492,7 +492,7 @@ async function renderP2pList() {
|
||||
refresh.setAttribute('aria-label', 'Refresh');
|
||||
refresh.onclick = () => renderP2pList();
|
||||
const playAll = document.createElement('button');
|
||||
playAll.textContent = '▶ Play all';
|
||||
playAll.innerHTML = TransportIcons.svg('play') + ' Play all';
|
||||
playAll.className = 'la-primary la-inline';
|
||||
playAll.onclick = () => { if (list.length) playFromList(list, 0, 'p2p'); };
|
||||
els.listActions.append(playAll, refresh);
|
||||
|
||||
@@ -347,7 +347,7 @@
|
||||
const s = st.state && st.state.v;
|
||||
panel.querySelector('.pt-body').innerHTML = `
|
||||
${st.code ? `<div class="pt-share"><code>${escapeHtml(link())}</code><button class="np-btn" type="button" data-act="copy">Copy</button>${navigator.share ? '<button class="np-btn" type="button" data-act="share">↗</button>' : ''}</div>` : ''}
|
||||
${!isHost() && s ? `<div class="pt-now">▶ ${escapeHtml(s.title)}</div>` : ''}
|
||||
${!isHost() && s ? `<div class="pt-now">${TransportIcons.svg('play')} ${escapeHtml(s.title)}</div>` : ''}
|
||||
${!isHost() && st.detached ? '<button class="rv-primary" type="button" data-act="resync">↻ Re-sync with the host</button>' : ''}
|
||||
<div class="pt-members">${st.members.map((m) => `<span class="pt-member${m.pid === pid ? ' me' : ''}">${m.host ? '👑 ' : ''}${escapeHtml(m.name)}${m.voice ? ' 🎤' : ''}</span>`).join('')}</div>
|
||||
${isHost() ? `<label class="pt-toggle"><input type="checkbox" data-act="allow" ${st.allowControl ? 'checked' : ''} /> Guests can control playback</label>` : ''}
|
||||
|
||||
Reference in New Issue
Block a user