Merge branch 'codex/ui-fixes'

This commit is contained in:
Jonathan Sykes
2026-10-09 14:37:51 +08:00
23 changed files with 511 additions and 67 deletions

3
.gitignore vendored
View File

@@ -44,3 +44,6 @@ plans/codex-report*.md
# performance harness artifacts and temp files # performance harness artifacts and temp files
perf/fixtures/ perf/fixtures/
perf/.tmp/ perf/.tmp/
# UI regression screenshots and local measurements
plans/ui-shots/

View File

@@ -312,6 +312,8 @@
main { padding-top: 16px; } main { padding-top: 16px; }
} }
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } } @media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
.media-shape { display:none; } .media-shape-classic { display:inline; }
.media-icon { width:18px; height:18px; vertical-align:middle; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; }
</style> </style>
</head> </head>
<body> <body>
@@ -407,7 +409,7 @@
<label>Shift every timed line by <input class="field" id="shiftBy" type="text" value="0.0" style="flex:0 0 84px;height:36px;text-align:center" /> s</label> <label>Shift every timed line by <input class="field" id="shiftBy" type="text" value="0.0" style="flex:0 0 84px;height:36px;text-align:center" /> s</label>
<button class="btn sm" id="shiftBtn" type="button">Apply</button> <button class="btn sm" id="shiftBtn" type="button">Apply</button>
<span class="grow"></span> <span class="grow"></span>
<button class="btn sm" id="webBtn" type="button" title="Fetch lyrics from LRCLIB">⤓ Pull from LRCLIB</button> <button class="btn sm" id="webBtn" type="button" title="Fetch lyrics from LRCLIB"><svg class="media-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-download"/></svg> Pull from LRCLIB</button>
<button class="btn sm" id="rawBtn" type="button">✎ Edit as text</button> <button class="btn sm" id="rawBtn" type="button">✎ Edit as text</button>
<label><input type="checkbox" id="follow" checked /> Follow playback</label> <label><input type="checkbox" id="follow" checked /> Follow playback</label>
</div> </div>
@@ -508,7 +510,7 @@
<div class="card"> <div class="card">
<div class="row" style="margin-bottom:8px"><h2 style="margin:0">Recent edits</h2><span class="grow"></span> <div class="row" style="margin-bottom:8px"><h2 style="margin:0">Recent edits</h2><span class="grow"></span>
<button class="btn sm" id="refreshBtn" type="button">↻ Refresh</button> <button class="btn sm" id="refreshBtn" type="button">↻ Refresh</button>
<a class="btn sm" href="/api/admin/notes/export">⤓ Export all (JSON)</a></div> <a class="btn sm" href="/api/admin/notes/export"><svg class="media-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-download"/></svg> Export all (JSON)</a></div>
<div class="scroll"><table class="tbl" id="revTable"></table></div> <div class="scroll"><table class="tbl" id="revTable"></table></div>
<pre id="revView" class="hidden" style="margin-top:12px"></pre> <pre id="revView" class="hidden" style="margin-top:12px"></pre>
</div> </div>
@@ -569,6 +571,7 @@
<nav class="nav" id="tabsBottom" role="tablist"></nav> <nav class="nav" id="tabsBottom" role="tablist"></nav>
</div> </div>
<script src="download-actions.js"></script>
<script src="lyrics-core.js"></script> <script src="lyrics-core.js"></script>
<script> <script>
(function () { (function () {

View File

@@ -1241,7 +1241,7 @@ async function adoptSharedPlaylistByCode(code, { fromName = '' } = {}) {
Add “<strong>${escapeHtml(shared.name)}</strong>” (${count} video${count === 1 ? '' : 's'}) to your playlists? Add “<strong>${escapeHtml(shared.name)}</strong>” (${count} video${count === 1 ? '' : 's'}) to your playlists?
This playlist is added alongside your existing playlists and nothing is replaced. This playlist is added alongside your existing playlists and nothing is replaced.
</p>`; </p>`;
showModal(fromName ? '🎵 Playlist sent to you' : '⬇ Add shared playlist', body, [ showModal(fromName ? '🎵 Playlist sent to you' : 'Add shared playlist', body, [
{ label: fromName ? 'No thanks' : 'Cancel', onClick: () => { closeModal(); resolve(false); } }, { label: fromName ? 'No thanks' : 'Cancel', onClick: () => { closeModal(); resolve(false); } },
{ label: 'Add playlist', primary: true, onClick: () => { closeModal(); resolve(true); } }, { label: 'Add playlist', primary: true, onClick: () => { closeModal(); resolve(true); } },
]); ]);
@@ -1361,7 +1361,7 @@ async function adoptProfileFromUrl() {
Loading it replaces the playlists, history and settings on this device, Loading it replaces the playlists, history and settings on this device,
and future changes here sync to that profile. and future changes here sync to that profile.
</p>`; </p>`;
showModal('⬇ Open shared profile', body, [ showModal('Open shared profile', body, [
{ label: 'Cancel', onClick: () => { closeModal(); resolve(false); } }, { label: 'Cancel', onClick: () => { closeModal(); resolve(false); } },
{ label: 'Load profile', primary: true, onClick: () => { closeModal(); resolve(true); } }, { label: 'Load profile', primary: true, onClick: () => { closeModal(); resolve(true); } },
]); ]);
@@ -1567,7 +1567,7 @@ function loadProfileFlow() {
changes here sync back to that profile. changes here sync back to that profile.
</p> </p>
<input id="profileNameInput" type="text" placeholder="profile name" autocomplete="off" />`; <input id="profileNameInput" type="text" placeholder="profile name" autocomplete="off" />`;
showModal('⬇ Load online profile', body, [ showModal('Load online profile', body, [
{ label: 'Cancel', onClick: closeModal }, { label: 'Cancel', onClick: closeModal },
{ label: 'Load', primary: true, onClick: async () => { { label: 'Load', primary: true, onClick: async () => {
const name = ($('profileNameInput').value || '').trim(); const name = ($('profileNameInput').value || '').trim();
@@ -2209,7 +2209,7 @@ async function openVideoEditor(source) {
{ {
label: 'Save edited copy', primary: true, onClick: () => { label: 'Save edited copy', primary: true, onClick: () => {
if (!VideoEdit.hasEdits(cuts, duration)) { if (!VideoEdit.hasEdits(cuts, duration)) {
showErr('Add at least one cut, or use ⬇ Save for the full video.'); showErr('Add at least one cut, or use Save for the full video.');
return; return;
} }
const keep = VideoEdit.invertCuts(cuts, duration); const keep = VideoEdit.invertCuts(cuts, duration);
@@ -3559,7 +3559,7 @@ const Notes = (() => {
<div class="notes-empty-actions"> <div class="notes-empty-actions">
<button class="np-btn" type="button" data-act="web">🌐 Find lyrics online</button> <button class="np-btn" type="button" data-act="web">🌐 Find lyrics online</button>
<button class="np-btn" type="button" data-act="paste">✎ Add lyrics</button> <button class="np-btn" type="button" data-act="paste">✎ Add lyrics</button>
${isUploadId(st.videoId) ? '' : '<button class="np-btn" type="button" data-act="captions">⤓ Import YouTube captions</button>'} ${isUploadId(st.videoId) ? '' : `<button class="np-btn" type="button" data-act="captions">${MediaIcons.svg('download')} Import YouTube captions</button>`}
</div> </div>
<div class="notes-empty-hint">Lyrics and chapters are shared with everyone who plays this song.</div> <div class="notes-empty-hint">Lyrics and chapters are shared with everyone who plays this song.</div>
</div>`; </div>`;
@@ -3801,7 +3801,7 @@ const Notes = (() => {
${note ? `<p class="lyr-editor-note">${escapeHtml(note)}</p>` : ''} ${note ? `<p class="lyr-editor-note">${escapeHtml(note)}</p>` : ''}
<textarea class="lyr-editor-text" spellcheck="false" placeholder="Paste or type the lyrics…"></textarea> <textarea class="lyr-editor-text" spellcheck="false" placeholder="Paste or type the lyrics…"></textarea>
<div class="lyr-editor-tools"> <div class="lyr-editor-tools">
<button type="button" class="np-btn" data-act="captions">⤓ Import YouTube captions</button> <button type="button" class="np-btn" data-act="captions">${MediaIcons.svg('download')} Import YouTube captions</button>
<button type="button" class="np-btn" data-act="break">↵ Line break (same time)</button> <button type="button" class="np-btn" data-act="break">↵ Line break (same time)</button>
<button type="button" class="np-btn" data-act="stamp">⏱ Stamp current time on this line</button> <button type="button" class="np-btn" data-act="stamp">⏱ Stamp current time on this line</button>
</div>`; </div>`;
@@ -4181,7 +4181,7 @@ const Notes = (() => {
const refresh = () => { ta.value = build(inc.checked); }; const refresh = () => { ta.value = build(inc.checked); };
inc.addEventListener('change', refresh); inc.addEventListener('change', refresh);
refresh(); refresh();
showModal('⤓ Export chapters', body, [ showModal('Export chapters', body, [
{ label: 'Download .txt', onClick: () => { { label: 'Download .txt', onClick: () => {
const blob = new Blob([`${title}\nhttps://youtu.be/${st.videoId}\n\n${ta.value}\n`], { type: 'text/plain' }); const blob = new Blob([`${title}\nhttps://youtu.be/${st.videoId}\n\n${ta.value}\n`], { type: 'text/plain' });
const a = document.createElement('a'); const a = document.createElement('a');
@@ -6920,7 +6920,7 @@ function openImportYouTubeModal() {
</p> </p>
`; `;
showModal('⬇ Import from YouTube', successBody, [ showModal('Import from YouTube', successBody, [
{ label: 'Cancel', onClick: closeModal }, { label: 'Cancel', onClick: closeModal },
{ {
label: 'Create playlist', label: 'Create playlist',
@@ -6959,7 +6959,7 @@ function openImportYouTubeModal() {
} }
} }
showModal('⬇ Import from YouTube', body, [ showModal('Import from YouTube', body, [
{ label: 'Cancel', onClick: closeModal }, { label: 'Cancel', onClick: closeModal },
{ label: 'Import', primary: true, onClick: handleImport }, { label: 'Import', primary: true, onClick: handleImport },
]); ]);
@@ -6991,7 +6991,7 @@ function openCardMenu(video) {
const isCustom = !!video.custom; const isCustom = !!video.custom;
const exportBtn = document.createElement('button'); const exportBtn = document.createElement('button');
exportBtn.textContent = '🖼 Save to gallery'; exportBtn.innerHTML = MediaIcons.svg('download') + ' Save to gallery';
exportBtn.title = 'Put the video file in your gallery, Files app or Downloads folder'; exportBtn.title = 'Put the video file in your gallery, Files app or Downloads folder';
exportBtn.onclick = () => { closeModal(); exportToDevice(video); }; exportBtn.onclick = () => { closeModal(); exportToDevice(video); };
body.appendChild(exportBtn); body.appendChild(exportBtn);
@@ -7005,7 +7005,7 @@ function openCardMenu(video) {
saveBtn.className = 'danger'; saveBtn.className = 'danger';
saveBtn.onclick = () => { closeModal(); deleteCustomVideo(video.id); }; saveBtn.onclick = () => { closeModal(); deleteCustomVideo(video.id); };
} else { } else {
saveBtn.textContent = isSaved ? '✓ Saved offline — remove' : '⬇ Save for offline'; saveBtn.innerHTML = MediaIcons.svg(isSaved ? 'offline' : 'download') + (isSaved ? ' Saved offline — remove' : ' Save for offline');
saveBtn.onclick = async () => { saveBtn.onclick = async () => {
closeModal(); closeModal();
if (cachedIds.has(video.id)) { if (cachedIds.has(video.id)) {
@@ -7232,17 +7232,17 @@ function updatePlaylistOfflineButton(btn, pl) {
let label; let label;
btn.classList.remove('done', 'busy'); btn.classList.remove('done', 'busy');
if (stats.total > 0 && stats.cached === stats.total) { if (stats.total > 0 && stats.cached === stats.total) {
if (iconOnly) { setListIcon(btn, 'offlineDone'); btn.classList.add('done'); } else btn.textContent = '✓ Available offline'; if (iconOnly) { setListIcon(btn, 'offlineDone'); btn.classList.add('done'); } else btn.innerHTML = MediaIcons.svg('offline') + ' Available offline';
btn.disabled = true; btn.disabled = true;
btn.classList.add('disabled'); btn.classList.add('disabled');
label = 'All videos in this playlist are available offline'; label = 'All videos in this playlist are available offline';
} else if (isDownloading) { } else if (isDownloading) {
if (iconOnly) { btn.innerHTML = `<span class="lib-progress">${stats.cached}/${stats.total}</span>`; btn.classList.add('busy'); } else btn.textContent = `Saving ${stats.cached}/${stats.total}…`; if (iconOnly) { btn.innerHTML = MediaIcons.svg('progress') + `<span class="lib-progress">${stats.cached}/${stats.total}</span>`; btn.classList.add('busy'); } else btn.innerHTML = MediaIcons.svg('progress') + ` Saving ${stats.cached}/${stats.total}…`;
btn.disabled = true; btn.disabled = true;
btn.classList.add('disabled'); btn.classList.add('disabled');
label = `${stats.cached} of ${stats.total} videos saved offline`; label = `${stats.cached} of ${stats.total} videos saved offline`;
} else { } else {
if (iconOnly) setListIcon(btn, 'offline'); else btn.textContent = '⬇ Make available offline'; if (iconOnly) setListIcon(btn, 'offline'); else btn.innerHTML = MediaIcons.svg('download') + ' Make available offline';
btn.disabled = stats.total === 0; btn.disabled = stats.total === 0;
btn.classList.toggle('disabled', stats.total === 0); btn.classList.toggle('disabled', stats.total === 0);
label = stats.total === 0 ? 'Playlist is empty' : 'Make available offline'; label = stats.total === 0 ? 'Playlist is empty' : 'Make available offline';
@@ -7259,7 +7259,7 @@ async function makePlaylistOffline(pl) {
const btn = $('plOfflineBtn'); const btn = $('plOfflineBtn');
if (btn) { if (btn) {
const stats = playlistOfflineStats(pl); const stats = playlistOfflineStats(pl);
btn.innerHTML = `<span class="lib-progress">${stats.cached}/${stats.total}</span>`; btn.innerHTML = MediaIcons.svg('progress') + `<span class="lib-progress">${stats.cached}/${stats.total}</span>`;
btn.classList.add('busy'); btn.classList.add('busy');
btn.disabled = true; btn.disabled = true;
btn.classList.add('disabled'); btn.classList.add('disabled');

View File

@@ -4,4 +4,3 @@
.car-controls button { min-height: 110px; font: 650 clamp(22px, 3vw, 36px)/1.2 system-ui; background: #fff; color: #050607; border: 3px solid #fff; border-radius: 16px; } .car-controls button { min-height: 110px; font: 650 clamp(22px, 3vw, 36px)/1.2 system-ui; background: #fff; color: #050607; border: 3px solid #fff; border-radius: 16px; }
.car-exit { align-self: flex-start; min-height: 52px; padding: 12px 20px; background: #070809; color: #fff; border: 2px solid #fff; border-radius: 8px; } .car-exit { align-self: flex-start; min-height: 52px; padding: 12px 20px; background: #070809; color: #fff; border: 2px solid #fff; border-radius: 8px; }
.car-mode button:focus-visible { outline: 4px solid #61caff; outline-offset: 4px; } .car-mode button:focus-visible { outline: 4px solid #61caff; outline-offset: 4px; }
.voice-search { flex-shrink: 0; min-width: 44px; min-height: 44px; }

View File

@@ -1,6 +1,41 @@
/* One action control for the Downloads page and Settings download manager. */ /* One action control for the Downloads page and Settings download manager. */
(function(root) { (function(root) {
'use strict'; 'use strict';
// One SVG definition per media concept; every instance inherits its control colour.
const mediaPaths = {
download: '<path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4"/>',
offline: '<circle cx="12" cy="12" r="9"/><path d="m7.5 12 3 3 6-6"/>',
progress: '<path d="M12 3a9 9 0 1 1-9 9"/>'
};
const variants = {
'classic': mediaPaths,
'f7-swipe': {
download: '<path d="M12 3v12m-5-5 5 5 5-5M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/>',
offline: '<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>',
progress: '<path d="M12 3a9 9 0 1 1-9 9"/>'
},
'glass-stage': {
download: '<path d="M12 4v11m-4-4 4 4 4-4M5 20h14"/>',
offline: '<circle cx="12" cy="12" r="8"/><path d="m8.5 12 2.5 2.5 4.5-5"/>',
progress: '<path d="M12 4a8 8 0 1 1-8 8"/>'
},
'bento-hub': {
download: '<path d="M12 4v11m-4-4 4 4 4-4M5 17v3h14v-3"/>',
offline: '<rect x="4" y="4" width="16" height="16" rx="4"/><path d="m8 12 3 3 5-6"/>',
progress: '<path d="M12 4a8 8 0 1 1-8 8"/>'
}
};
const use = kind => `<use href="#media-${kind}"/>`;
const svg = (kind, extra = '') => `<svg class="media-icon ${extra}" data-media-icon="${kind}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${use(kind)}</svg>`;
const media = { paths: mediaPaths, use, svg };
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(mediaPaths).map(kind => `<g id="media-${kind}">${Object.entries(variants).map(([layout, paths]) => `<g class="media-shape media-shape-${layout}">${paths[kind]}</g>`).join('')}</g>`).join('') + '</defs>';
root.document.body.appendChild(defs);
root.MediaIcons = media;
}
const paths = { const paths = {
retry: '<path d="M3 11a9 9 0 1 1 2.6 7M3 3v8h8"/>', retry: '<path d="M3 11a9 9 0 1 1 2.6 7M3 3v8h8"/>',
cancel: '<path d="m6 6 12 12M6 18 18 6"/>', cancel: '<path d="m6 6 12 12M6 18 18 6"/>',
@@ -19,6 +54,6 @@
const text = doc.createElement('span'); text.textContent = label; const text = doc.createElement('span'); text.textContent = label;
button.append(icon, text); return button; button.append(icon, text); return button;
} }
const api = { create }; const api = { create, media };
if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.DownloadActions = api; if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.DownloadActions = api;
})(globalThis); })(globalThis);

View File

@@ -119,11 +119,7 @@
empty.className = 'empty-state dl-empty-state'; empty.className = 'empty-state dl-empty-state';
empty.innerHTML = ` empty.innerHTML = `
<div class="empty-icon dl-empty-icon-wrapper" aria-hidden="true"> <div class="empty-icon dl-empty-icon-wrapper" aria-hidden="true">
<svg class="dl-empty-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> <svg class="dl-empty-svg media-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-download"/></svg>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</div> </div>
<h3 class="empty-title">No active downloads</h3> <h3 class="empty-title">No active downloads</h3>
<p class="empty-desc">Saves in progress appear here with live status. Videos you save land in <strong>Saved</strong>.</p> <p class="empty-desc">Saves in progress appear here with live status. Videos you save land in <strong>Saved</strong>.</p>

View File

@@ -12,7 +12,7 @@
let adapter, total = 0; let adapter, total = 0;
function configure(options) { function configure(options) {
adapter = options; adapter = options;
root.SettingsSections.register({ id: 'downloads-storage', title: 'Downloads & storage', cluster: 'Library & storage', description: 'Active queued paused failed saves, disk usage and playlist sizes', icon: '<path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4"/>', summary: () => `${adapter.slots()} at once · ${bytes(total)}`, render(container) { root.SettingsSections.register({ id: 'downloads-storage', title: 'Downloads & storage', cluster: 'Library & storage', description: 'Active queued paused failed saves, disk usage and playlist sizes', icon: '<use href="#media-download"/>', summary: () => `${adapter.slots()} at once · ${bytes(total)}`, render(container) {
const doc = container.ownerDocument; const doc = container.ownerDocument;
const heading = doc.createElement('h3'); heading.textContent = 'Downloads & storage'; const heading = doc.createElement('h3'); heading.textContent = 'Downloads & storage';
const jobs = doc.createElement('div'); jobs.className = 'download-manager'; jobs.setAttribute('aria-label', 'Download manager'); const jobs = doc.createElement('div'); jobs.className = 'download-manager'; jobs.setAttribute('aria-label', 'Download manager');

View File

@@ -6,7 +6,7 @@
music:'<path d="M9 18V5l11-2v13M9 8l11-2"/><ellipse cx="6" cy="18" rx="3" ry="2"/><ellipse cx="17" cy="16" rx="3" ry="2"/>', music:'<path d="M9 18V5l11-2v13M9 8l11-2"/><ellipse cx="6" cy="18" rx="3" ry="2"/><ellipse cx="17" cy="16" rx="3" ry="2"/>',
add:'<path d="M12 5v14M5 12h14"/>', close:'<path d="m6 6 12 12M18 6 6 18"/>', add:'<path d="M12 5v14M5 12h14"/>', close:'<path d="m6 6 12 12M18 6 6 18"/>',
search:'<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>', search:'<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
save:'<path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4"/>', save:'<use href="#media-download"/>',
play:'<path d="m8 5 11 7-11 7z"/>', pause:'<path d="M8 5v14M16 5v14"/>', 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"/>', next:'<path d="m5 5 10 7-10 7zM19 5v14"/>', previous:'<path d="m19 5-10 7 10 7zM5 5v14"/>',
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"/>', 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"/>',
@@ -23,7 +23,7 @@
television:'<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4"/>', pin:'<path d="M9 3h6l-1 6 4 4H6l4-4zM12 13v8"/>', television:'<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4"/>', pin:'<path d="M9 3h6l-1 6 4 4H6l4-4zM12 13v8"/>',
devices:'<rect x="3" y="4" width="14" height="12" rx="2"/><path d="M7 20h6M10 16v4"/><rect x="17" y="10" width="5" height="11" rx="1"/>' devices:'<rect x="3" y="4" width="14" height="12" rx="2"/><path d="M7 20h6M10 16v4"/><rect x="17" y="10" width="5" height="11" rx="1"/>'
}; };
const glyphs = {'+':'add','✕':'close','×':'close','✖':'close','🔍':'search','🔎':'search','⬇':'save','⤓':'save','💾':'save','▶':'play','⏸':'pause','⏭':'next','⏮':'previous','↻':'repeat','↺':'repeat','✎':'edit','✏':'edit','📝':'edit','🎤':'lyrics','↗':'share','⧉':'copy','✓':'check','📁':'folder','⚙':'settings','📋':'chapters','🕘':'clock','🔀':'shuffle','📡':'devices','▾':'down','▸':'right','←':'back','⋯':'more','⋮':'more','🗑':'trash','🎵':'music'}; const glyphs = {'+':'add','✕':'close','×':'close','✖':'close','🔍':'search','🔎':'search','▶':'play','⏸':'pause','⏭':'next','⏮':'previous','↻':'repeat','↺':'repeat','✎':'edit','✏':'edit','📝':'edit','🎤':'lyrics','↗':'share','⧉':'copy','✓':'check','📁':'folder','⚙':'settings','📋':'chapters','🕘':'clock','🔀':'shuffle','📡':'devices','▾':'down','▸':'right','←':'back','⋯':'more','⋮':'more','🗑':'trash','🎵':'music'};
function leadingGlyph(text,bento=false) { function leadingGlyph(text,bento=false) {
const mapping=bento?{...glyphs,'📺':'television','⏱':'clock','⇅':'settings','📌':'pin','🎧':'music','📚':'folder'}:glyphs; const mapping=bento?{...glyphs,'📺':'television','⏱':'clock','⇅':'settings','📌':'pin','🎧':'music','📚':'folder'}:glyphs;
const prefix = text.match(/^\s*/)[0], rest = text.slice(prefix.length); const prefix = text.match(/^\s*/)[0], rest = text.slice(prefix.length);

View File

@@ -1,7 +1,7 @@
const test=require('node:test'),assert=require('node:assert/strict'); const test=require('node:test'),assert=require('node:assert/strict');
const {leadingGlyph}=require('./glass-controls.js'); const {leadingGlyph}=require('./glass-controls.js');
test('glass icon substitution preserves leading whitespace and remaining labels',()=>{ test('glass icon substitution preserves leading whitespace and remaining labels',()=>{
assert.deepEqual(leadingGlyph(' ⬇ Save'),{kind:'save',original:' ⬇',rest:' Save'}); assert.equal(leadingGlyph('Save'),null);
assert.deepEqual(leadingGlyph('⚙️ Settings'),{kind:'settings',original:'⚙️',rest:' Settings'}); assert.deepEqual(leadingGlyph('⚙️ Settings'),{kind:'settings',original:'⚙️',rest:' Settings'});
assert.equal(leadingGlyph('A song with 🔍 in its title'),null); assert.equal(leadingGlyph('A song with 🔍 in its title'),null);
assert.equal(leadingGlyph('Clear all'),null); assert.equal(leadingGlyph('Clear all'),null);

View File

@@ -47,8 +47,8 @@
<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 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"><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="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="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"><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="M5 3h12l4 4v14H3V3h2zm2 0v6h10V3M7 21v-8h10v8"/></svg> Saved</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"><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="M12 3v12m-5-5 5 5 5-5M4 16v5h16v-5"/></svg> Downloads <span id="navDlCount" class="nav-badge hidden">0</span></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>
<button class="nav-item" data-view="library"><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="M3 6h7l2 3h9v11H3z"/></svg> Library</button> <button class="nav-item" data-view="library"><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="M3 6h7l2 3h9v11H3z"/></svg> Library</button>
<button class="nav-item" data-view="notes"><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="M14 3H4v18h16V11M9 15l2-5 7-7 3 3-7 7z"/></svg> Notes</button> <button class="nav-item" data-view="notes"><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="M14 3H4v18h16V11M9 15l2-5 7-7 3 3-7 7z"/></svg> Notes</button>
<button class="nav-item" data-view="stats"><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="M4 21V11h4v10M10 21V3h4v18M16 21V7h4v14"/></svg> Stats</button> <button class="nav-item" data-view="stats"><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="M4 21V11h4v10M10 21V3h4v18M16 21V7h4v14"/></svg> Stats</button>
@@ -268,8 +268,8 @@
</div> </div>
<div class="np-actions"> <div class="np-actions">
<button id="addBtn" class="np-btn" title="Add to the queue or to a playlist"><svg class="ti" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h10M4 11h10M4 16h6M17 13v8M13 17h8"/></svg><span class="tl">Add</span></button> <button id="addBtn" class="np-btn" title="Add to the queue or to a playlist"><svg class="ti" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h10M4 11h10M4 16h6M17 13v8M13 17h8"/></svg><span class="tl">Add</span></button>
<button id="saveBtn" class="np-btn" title="Save this video for offline playback"><svg class="ti" viewBox="0 0 24 24" aria-hidden="true"><path class="i-save" d="M12 4v11M7 11l5 5 5-5M5 20h14"/><path class="i-done" d="M20 12a8 8 0 1 1-16 0 8 8 0 0 1 16 0zM8.5 12.2l2.4 2.4 4.6-4.8"/></svg><span class="tl">Save</span></button> <button id="saveBtn" class="np-btn" title="Save this video for offline playback"><svg class="ti media-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use class="i-save" href="#media-download"/><use class="i-done" href="#media-offline"/><use class="i-progress" href="#media-progress"/></svg><span class="tl">Save</span></button>
<button id="exportBtn" class="np-btn" title="Save this video to your gallery, Files app or Downloads folder — works while streaming too"><svg class="ti" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="3"/><circle cx="9" cy="10.5" r="1.6"/><path d="M4 17l5-4.5 4 3.5 3-2.5 4 3.5"/></svg><span class="tl">Gallery</span></button> <button id="exportBtn" class="np-btn" title="Save this video to your gallery, Files app or Downloads folder — works while streaming too"><svg class="ti media-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-download"/></svg><span class="tl">Gallery</span></button>
<button id="serviceBtn" class="np-btn" title="Service mode — set-list view with the screen kept awake"><svg class="ti" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21"/></svg><span class="tl">Service</span></button> <button id="serviceBtn" class="np-btn" title="Service mode — set-list view with the screen kept awake"><svg class="ti" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21"/></svg><span class="tl">Service</span></button>
<div id="npMoreHost" class="np-more-host" hidden> <div id="npMoreHost" class="np-more-host" hidden>
<button id="queueBtn" class="np-btn" title="Add to the temporary queue">+ Queue</button> <button id="queueBtn" class="np-btn" title="Add to the temporary queue">+ Queue</button>
@@ -338,7 +338,7 @@
<button id="addNoteBtn" class="np-btn" type="button" title="Write a note tied to this moment [N]">📝 Note</button> <button id="addNoteBtn" class="np-btn" type="button" title="Write a note tied to this moment [N]">📝 Note</button>
<span class="notes-spacer"></span> <span class="notes-spacer"></span>
<button id="chaptersHistoryBtn" class="np-btn" type="button" title="Earlier versions of the shared chapters">🕘</button> <button id="chaptersHistoryBtn" class="np-btn" type="button" title="Earlier versions of the shared chapters">🕘</button>
<button id="exportMarkersBtn" class="np-btn" type="button">⤓ Export</button> <button id="exportMarkersBtn" class="np-btn" type="button"><svg class="media-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-download"/></svg> Export</button>
</div> </div>
<div class="markers-section"> <div class="markers-section">
<div class="markers-heading">Chapters <span class="markers-scope">shared with everyone</span></div> <div class="markers-heading">Chapters <span class="markers-scope">shared with everyone</span></div>
@@ -598,11 +598,11 @@
<span class="bottom-nav-label">History</span> <span class="bottom-nav-label">History</span>
</button> </button>
<button class="bottom-nav-btn" data-view="saved" aria-label="Saved"> <button class="bottom-nav-btn" data-view="saved" aria-label="Saved">
<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="M5 3h12l4 4v14H3V3h2zm2 0v6h10V3M7 21v-8h10v8"/></svg></span> <span class="bottom-nav-icon"><svg class="media-icon media-nav-icon" data-media-icon="offline" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-offline"/></svg></span>
<span class="bottom-nav-label">Saved</span> <span class="bottom-nav-label">Saved</span>
</button> </button>
<button class="bottom-nav-btn" data-view="downloads" aria-label="Downloads"> <button class="bottom-nav-btn" data-view="downloads" aria-label="Downloads">
<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="M12 3v12m-5-5 5 5 5-5M4 16v5h16v-5"/></svg></span> <span class="bottom-nav-icon"><svg class="media-icon media-nav-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-download"/></svg></span>
<span class="bottom-nav-label">Downloads</span> <span class="bottom-nav-label">Downloads</span>
<span id="bnDlCount" class="bottom-nav-badge hidden">0</span> <span id="bnDlCount" class="bottom-nav-badge hidden">0</span>
</button> </button>

View File

@@ -0,0 +1,19 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { readdirSync, readFileSync } = require('node:fs');
const { join } = require('node:path');
test('download and offline icons never use emoji or text glyphs', () => {
// Arrow keys are prose, not action icons. Exact lines prevent new exceptions.
const allow = new Map([
['index.html', new Set(['<div class="shortcut-row"><kbd>↑</kbd><kbd>↓</kbd><span>Volume up / down</span></div>'])],
['admin.html', new Set(['<span class="muted" style="font-size:12.5px">Shortcuts: <code>Space</code> stamp (Tap-sync) · <code>S</code> stamp selected · <code>K</code> play · <code>↑↓</code> select · <code>⌘/Ctrl S</code> save</span>'])],
]);
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)=>{
if (/[⬇⤓⇩💾📥↓]/u.test(line) && !allow.get(file)?.has(line.trim())) failures.push(`${file}:${i+1}`);
});
}
assert.deepEqual(failures,[]);
});

View File

@@ -2,9 +2,10 @@
.saved-storage-text { min-width:0; color:var(--text-2); font-size:14px; font-variant-numeric:tabular-nums; } .saved-storage-text { min-width:0; color:var(--text-2); font-size:14px; font-variant-numeric:tabular-nums; }
.saved-clear { flex:none; min-height:44px; padding:0 10px; border:0; background:transparent; color:var(--accent); font:inherit; cursor:pointer; } .saved-clear { flex:none; min-height:44px; padding:0 10px; border:0; background:transparent; color:var(--accent); font:inherit; cursor:pointer; }
html[data-layout] .card.saved-card { display:grid; grid-template-columns:72px minmax(0,1fr) 96px; align-items:center; gap:12px; min-height:88px; } html[data-layout] .card.saved-card { display:grid; grid-template-columns:72px minmax(0,1fr) 96px; align-items:center; gap:12px; min-height:88px; }
html[data-layout] .saved-card .thumb { grid-column:1; grid-row:1; width:72px; } html[data-layout] #cards .saved-card .thumb { grid-column:1; grid-row:1; width:100%; min-width:0; max-width:100%; flex:none; overflow:hidden; }
html[data-layout] #cards .saved-card .thumb img { width:100%; max-width:100%; height:100%; object-fit:cover; }
html[data-layout] .saved-card .card-info { grid-column:2; grid-row:1; min-width:0; } html[data-layout] .saved-card .card-info { grid-column:2; grid-row:1; min-width:0; }
html[data-layout] .saved-card .card-title { display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2; overflow:hidden; overflow-wrap:anywhere; } html[data-layout] .saved-card .card-title { display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2; overflow:hidden; overflow-wrap:anywhere; text-overflow:ellipsis; }
html[data-layout] .saved-file-actions { grid-column:3; grid-row:1; display:flex; align-items:center; gap:8px; } html[data-layout] .saved-file-actions { grid-column:3; grid-row:1; display:flex; align-items:center; gap:8px; }
.saved-file-action { width:44px; height:44px; min-width:44px; padding:10px; border:1px solid var(--line); border-radius:var(--radius-sm,8px); background:var(--bg-2); color:var(--text-2); cursor:pointer; } .saved-file-action { width:44px; height:44px; min-width:44px; padding:10px; border:1px solid var(--line); border-radius:var(--radius-sm,8px); background:var(--bg-2); color:var(--text-2); cursor:pointer; }
.saved-file-action svg { display:block; width:22px; height:22px; } .saved-file-action svg { display:block; width:22px; height:22px; }
@@ -13,4 +14,4 @@ html[data-layout] .saved-file-actions { grid-column:3; grid-row:1; display:flex;
.saved-file-action.danger:hover, .saved-clear:hover { color:var(--accent); } .saved-file-action.danger:hover, .saved-clear:hover { color:var(--accent); }
.saved-file-action:focus-visible, .saved-clear:focus-visible { outline:2px solid var(--accent); outline-offset:3px; } .saved-file-action:focus-visible, .saved-clear:focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
.saved-file-action:disabled, .saved-clear:disabled { opacity:.5; cursor:wait; } .saved-file-action:disabled, .saved-clear:disabled { opacity:.5; cursor:wait; }
@media(min-width:900px) { html[data-layout] .card.saved-card { grid-template-columns:88px minmax(0,1fr) 96px; } html[data-layout] .saved-card .thumb { width:88px; } } @media(min-width:900px) { html[data-layout] .card.saved-card { grid-template-columns:88px minmax(0,1fr) 96px; } }

View File

@@ -3,14 +3,14 @@
'use strict'; 'use strict';
const matches = (video, query) => !query || `${video.title || video.id} ${video.channel || ''}`.toLowerCase().includes(query.toLowerCase()); const matches = (video, query) => !query || `${video.title || video.id} ${video.channel || ''}`.toLowerCase().includes(query.toLowerCase());
const paths = { const paths = {
export: '<path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4"/>', export: '<use href="#media-download"/>',
delete: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6"/>' delete: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6"/>'
}; };
function button(doc, kind, label, action) { function button(doc, kind, label, action) {
const b = doc.createElement('button'); b.type = 'button'; const b = doc.createElement('button'); b.type = 'button';
b.className = `saved-file-action${kind === 'delete' ? ' danger' : ''}`; b.className = `saved-file-action${kind === 'delete' ? ' danger' : ''}`;
b.setAttribute('aria-label', label); b.title = label; b.setAttribute('aria-label', label); b.title = label;
b.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[kind]}</svg>`; b.innerHTML = `<svg class="media-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[kind]}</svg>`;
b.onclick = e => { e.stopPropagation(); action(); }; return b; b.onclick = e => { e.stopPropagation(); action(); }; return b;
} }
function actions(row, video, exportFile, deleteFile) { function actions(row, video, exportFile, deleteFile) {

View File

@@ -71,7 +71,7 @@
const url = URL.createObjectURL(gifBlob); const url = URL.createObjectURL(gifBlob);
const name = `${(meta.title || meta.id).replace(/[^\w\- ]+/g, '').trim().slice(0, 40) || meta.id}-${Math.floor(cur())}s.gif`; const name = `${(meta.title || meta.id).replace(/[^\w\- ]+/g, '').trim().slice(0, 40) || meta.id}-${Math.floor(cur())}s.gif`;
out.innerHTML = `<img class="sm-preview" alt="GIF preview" src="${url}" /> out.innerHTML = `<img class="sm-preview" alt="GIF preview" src="${url}" />
<div class="sm-gif-actions"><a class="np-btn" href="${url}" download="${escapeHtml(name)}">⤓ Download</a> <div class="sm-gif-actions"><a class="np-btn" href="${url}" download="${escapeHtml(name)}">${MediaIcons.svg('download')} Download</a>
${navigator.canShare && navigator.canShare({ files: [new File([gifBlob], name, { type: 'image/gif' })] }) ? '<button class="np-btn" type="button" data-act="sharegif">↗ Share GIF</button>' : ''}</div>`; ${navigator.canShare && navigator.canShare({ files: [new File([gifBlob], name, { type: 'image/gif' })] }) ? '<button class="np-btn" type="button" data-act="sharegif">↗ Share GIF</button>' : ''}</div>`;
out.dataset.name = name; out.dataset.name = name;
} catch (err) { } catch (err) {
@@ -98,7 +98,7 @@
out.dataset.name = name; out.dataset.name = name;
out.dataset.type = fmt === 'mp3' ? 'audio/mpeg' : 'audio/mp4'; out.dataset.type = fmt === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
out.innerHTML = `<audio controls src="${url}" class="sm-audio"></audio> out.innerHTML = `<audio controls src="${url}" class="sm-audio"></audio>
<div class="sm-gif-actions"><a class="np-btn" href="${url}" download="${escapeHtml(name)}">⤓ Download</a> <div class="sm-gif-actions"><a class="np-btn" href="${url}" download="${escapeHtml(name)}">${MediaIcons.svg('download')} Download</a>
${navigator.canShare && navigator.canShare({ files: [new File([biteBlob], name, { type: out.dataset.type })] }) ? '<button class="np-btn" type="button" data-act="sharebite">↗ Share</button>' : ''}</div> ${navigator.canShare && navigator.canShare({ files: [new File([biteBlob], name, { type: out.dataset.type })] }) ? '<button class="np-btn" type="button" data-act="sharebite">↗ Share</button>' : ''}</div>
${fmt === 'm4r' ? '<p class="sp-note">iPhone: share it to GarageBand (or drag into Finder with the phone connected) to set it as a ringtone.</p>' : ''}`; ${fmt === 'm4r' ? '<p class="sp-note">iPhone: share it to GarageBand (or drag into Finder with the phone connected) to set it as a ringtone.</p>' : ''}`;
} catch (err) { } catch (err) {
@@ -176,7 +176,7 @@
showModal('📺 Open in another player', body, [{ label: 'Close', primary: true, onClick: closeModal }]); showModal('📺 Open in another player', body, [{ label: 'Close', primary: true, onClick: closeModal }]);
const ok = await cached(meta.id); const ok = await cached(meta.id);
if (!ok) { if (!ok) {
body.innerHTML = `<p class="modal-text">The server hasn't finished caching this video yet, and other players need that single-file copy. Keep it playing for a minute (or tap ⬇ Save), then try again.</p>`; body.innerHTML = `<p class="modal-text">The server hasn't finished caching this video yet, and other players need that single-file copy. Keep it playing for a minute (or tap ${MediaIcons.svg('download')} Save), then try again.</p>`;
return; return;
} }
const url = streamUrl(meta.id); const url = streamUrl(meta.id);
@@ -193,8 +193,8 @@
<a class="np-btn" href="vlc-x-callback://x-callback-url/stream?url=${encodeURIComponent(url)}">▶ VLC</a> <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="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://${escapeHtml(url)}">▶ VLC</a>`}
<button class="np-btn" type="button" data-act="m3u">⤓ .m3u (this video)</button> <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">⤓ .m3u (whole list)</button>' : ''} ${inList ? `<button class="np-btn" type="button" data-act="m3u-list">${MediaIcons.svg('download')} .m3u (whole list)</button>` : ''}
</div> </div>
<p class="sp-note">${isAndroid ? 'VLC / MX Player must be installed; “Open with…” shows every video app.' <p class="sp-note">${isAndroid ? 'VLC / MX Player must be installed; “Open with…” shows every video app.'
: isIOS ? 'Needs the VLC (or Infuse) app. The link streams from your server; nothing is downloaded first.' : isIOS ? 'Needs the VLC (or Infuse) app. The link streams from your server; nothing is downloaded first.'

View File

@@ -91,7 +91,7 @@ function renderSidebar() {
let offlineBadge = ''; let offlineBadge = '';
if (stats.total > 0) { if (stats.total > 0) {
if (stats.cached === stats.total) { if (stats.cached === stats.total) {
offlineBadge = `<span class="pl-offline-badge all" title="Available offline">⬇</span>`; offlineBadge = `<span class="pl-offline-badge all" title="Available offline">${MediaIcons.svg('offline')}</span>`;
} else if (stats.cached > 0) { } else if (stats.cached > 0) {
offlineBadge = `<span class="pl-offline-badge partial" title="${stats.cached} of ${stats.total} videos available offline">${stats.cached}/${stats.total}</span>`; offlineBadge = `<span class="pl-offline-badge partial" title="${stats.cached} of ${stats.total} videos available offline">${stats.cached}/${stats.total}</span>`;
} }
@@ -141,7 +141,7 @@ function renderHomePlaylists() {
let offlineHtml = ''; let offlineHtml = '';
if (stats.total > 0) { if (stats.total > 0) {
if (stats.cached === stats.total) { if (stats.cached === stats.total) {
offlineHtml = `<span class="home-pl-offline all" title="Available offline">⬇</span>`; offlineHtml = `<span class="home-pl-offline all" title="Available offline">${MediaIcons.svg('offline')}</span>`;
} else if (stats.cached > 0) { } else if (stats.cached > 0) {
offlineHtml = `<span class="home-pl-offline partial" title="${stats.cached} of ${stats.total} videos available offline">${stats.cached}/${stats.total}</span>`; offlineHtml = `<span class="home-pl-offline partial" title="${stats.cached} of ${stats.total} videos available offline">${stats.cached}/${stats.total}</span>`;
} }
@@ -261,6 +261,7 @@ function updateNowPlayingActions() {
{ const ch = $('npChannel'); if (ch) { const ok = isUploadId(id) || cachedIds.has(id); ch.classList.toggle('verified', ok); ch.title = ok ? 'Available on this device or the server' : ''; } } { const ch = $('npChannel'); if (ch) { const ok = isUploadId(id) || cachedIds.has(id); ch.classList.toggle('verified', ok); ch.title = ok ? 'Available on this device or the server' : ''; } }
const btn = els.saveBtn; const btn = els.saveBtn;
if (!btn) return; if (!btn) return;
btn.classList.toggle('saving', downloading.has(id));
if (removing.has(id)) { if (removing.has(id)) {
setTile(btn, 'Removing…'); setTile(btn, 'Removing…');
btn.classList.remove('done'); btn.classList.remove('done');

View File

@@ -273,7 +273,7 @@ body::after {
background: linear-gradient(180deg, rgba(16,16,19,0.7), transparent); background: linear-gradient(180deg, rgba(16,16,19,0.7), transparent);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
} }
.search-form { flex: 1; display: flex; gap: 10px; max-width: 760px; position: relative; } .search-form { flex: 1; display: flex; align-items: center; gap: 10px; max-width: 760px; position: relative; }
/* Recent-searches dropdown — device-local, kept in localStorage (RecentSearches in app.js) */ /* Recent-searches dropdown — device-local, kept in localStorage (RecentSearches in app.js) */
.search-recent { .search-recent {
@@ -752,7 +752,7 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
} }
.np-btn:hover { color: var(--text); border-color: var(--accent); background: var(--bg-2); transform: translateY(-1px); } .np-btn:hover { color: var(--text); border-color: var(--accent); background: var(--bg-2); transform: translateY(-1px); }
.np-btn:disabled { opacity: 0.6; cursor: default; transform: none; } .np-btn:disabled { opacity: 0.6; cursor: default; transform: none; }
.np-btn.done { color: #7ee0a8; border-color: rgba(126,224,168,0.35); } .np-btn.done { color: var(--accent-bright); border-color: var(--accent); }
.np-title { .np-title {
font-family: var(--display); font-family: var(--display);
font-weight: 700; font-weight: 700;
@@ -1097,19 +1097,20 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
display: none; display: none;
align-items: center; justify-content: center; align-items: center; justify-content: center;
width: 18px; height: 18px; width: 18px; height: 18px;
color: #7ee0a8; color: var(--accent-bright);
font-size: 12px; font-weight: 700; font-size: 12px; font-weight: 700;
text-shadow: 0 1px 3px rgba(0,0,0,0.85); text-shadow: 0 1px 3px rgba(0,0,0,0.85);
} }
.card.cached .saved-badge { display: flex; } .card.cached .saved-badge { display: flex; }
.card.downloading .saved-badge { .card.downloading .saved-badge {
display: flex; display: flex;
color: #ffc850; color: var(--accent-bright);
animation: pulse 1s ease-in-out infinite;
} }
.card.downloading .saved-badge::before { content: "⏳"; } /* State changes reveal SVGs without rewriting badges or their handlers. */
.card.downloading .saved-badge { font-size: 0; } .saved-badge .badge-progress { display:none; }
.card.downloading .saved-badge::before { font-size: 12px; } .card.downloading .saved-badge .badge-offline { display:none; }
.card.downloading .saved-badge .badge-progress { display:block; }
.saved-badge .media-icon { width:16px; height:16px; filter:drop-shadow(0 1px 2px #000); }
/* Download progress bar on card thumb */ /* Download progress bar on card thumb */
.dl-progress { .dl-progress {
@@ -4257,6 +4258,30 @@ body.landscape-fs .player-stage { touch-action: none; } /* fullscreen (real or t
.search-form { min-width: 0; } .search-form { min-width: 0; }
.search-form #searchInput { flex: 1 1 auto; min-width: 0; } .search-form #searchInput { flex: 1 1 auto; min-width: 0; }
/* Eager toolbar geometry: these buttons exist before Car mode is loaded. */
.topbar :is(#voiceSearchBtn, #carModeBtn, #updateIconBtn) {
flex: none;
width: 44px; height: 44px; min-width: 44px; min-height: 44px;
padding: 0;
align-items: center; justify-content: center;
display: inline-flex;
}
.topbar #updateIconBtn.hidden { display: none; }
.topbar :is(#voiceSearchBtn, #carModeBtn) svg { width: 22px; height: 22px; flex: none; }
.topbar :is(#voiceSearchBtn, #carModeBtn):hover { transform: none; }
@media (max-width: 430px) {
html[data-layout] .topbar, html[data-layout] #searchForm { gap: 4px; }
html[data-layout] #searchForm .search-btn { flex: none; padding-inline: 8px; }
html[data-layout="f7-swipe"] #searchInput { padding-right: 6px; }
}
@media (max-width: 860px) {
html[data-layout="bento-hub"] .topbar {
padding-top: calc(12px + env(safe-area-inset-top, 0px));
padding-left: max(12px, env(safe-area-inset-left, 0px));
padding-right: max(12px, env(safe-area-inset-right, 0px));
}
}
/* iPhone/iPad: no double-tap page zoom (scrolling unaffected) */ /* iPhone/iPad: no double-tap page zoom (scrolling unaffected) */
html.ios-nozoom, html.ios-nozoom body { touch-action: manipulation; } html.ios-nozoom, html.ios-nozoom body { touch-action: manipulation; }
@@ -4307,3 +4332,27 @@ html.ios-nozoom, html.ios-nozoom body { touch-action: manipulation; }
.lyric-primary { white-space: pre-line; } .lyric-primary { white-space: pre-line; }
.lyric-secondary { display: block; white-space: pre-line; font-size: .62em; font-weight: 450; line-height: 1.2; opacity: .68; margin-top: .15em; } .lyric-secondary { display: block; white-space: pre-line; font-size: .62em; font-weight: 450; line-height: 1.2; opacity: .68; margin-top: .15em; }
/* Media icons share definitions and follow the active layout's stroke family. */
.media-icon { display:inline-block; width:20px; height:20px; flex:none; vertical-align:middle; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; pointer-events:none; }
.media-nav-icon { width:20px; height:20px; }
.empty-icon .media-icon { width:48px; height:48px; }
:is(.pl-offline-badge,.home-pl-offline) .media-icon { width:14px; height:14px; }
html[data-layout="classic"] .media-icon { stroke-width:1.8; }
html[data-layout="f7-swipe"] .media-icon { stroke-width:2; }
html[data-layout="glass-stage"] .media-icon { stroke-width:1.9; }
html[data-layout="bento-hub"] .media-icon { stroke-width:1.8; }
.np-btn .i-progress { display:none; }
.np-btn.saving :is(.i-save,.i-done) { display:none; }
.np-btn.saving .i-progress { display:inline; }
:is([data-media-icon="progress"], .np-btn.saving .media-icon) { animation:mediaSpin 1s linear infinite; transform-origin:center; }
@keyframes mediaSpin { to { transform:rotate(360deg); } }
@media(prefers-reduced-motion:reduce) { :is([data-media-icon="progress"], .np-btn.saving .media-icon) { animation:none; } }
.media-shape { display:none; }
.media-shape-classic { display:inline; }
html[data-layout="f7-swipe"] .media-shape-classic,
html[data-layout="glass-stage"] .media-shape-classic,
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; }

View File

@@ -44,7 +44,7 @@ function renderNotesView() {
// Most recently played first (history order), the rest after. // Most recently played first (history order), the rest after.
const order = new Map(data.history.map((v, i) => [v.id, i])); const order = new Map(data.history.map((v, i) => [v.id, i]));
groups.sort((a, b) => (order.has(a.id) ? order.get(a.id) : 1e9) - (order.has(b.id) ? order.get(b.id) : 1e9)); groups.sort((a, b) => (order.has(a.id) ? order.get(a.id) : 1e9) - (order.has(b.id) ? order.get(b.id) : 1e9));
els.listActions.innerHTML = groups.length ? '<button class="np-btn" id="notesExportBtn" type="button">⤓ Export Markdown</button>' : ''; els.listActions.innerHTML = groups.length ? `<button class="np-btn" id="notesExportBtn" type="button">${MediaIcons.svg('download')} Export Markdown</button>` : '';
const c = els.cards; const c = els.cards;
if (!groups.length) { if (!groups.length) {
c.innerHTML = ` c.innerHTML = `
@@ -130,8 +130,8 @@ function setListPaneHidden(hidden) {
const LIST_ICONS = { const LIST_ICONS = {
rename: '<path d="M4 20h4L19 9l-4-4L4 16v4zM14 6l4 4"/>', rename: '<path d="M4 20h4L19 9l-4-4L4 16v4zM14 6l4 4"/>',
share: '<path d="M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1"/><path d="M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1"/>', share: '<path d="M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1"/><path d="M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1"/>',
offline: '<path d="M12 4v11M7 10l5 5 5-5M5 20h14"/>', offline: '<use href="#media-download"/>',
offlineDone: '<path d="M5 12.5l4.5 4.5L19 7.5"/>', offlineDone: '<use href="#media-offline"/>',
pin: '<path d="M9 3h6l-1 6 4 4H6l4-4zM12 13v8"/>', pin: '<path d="M9 3h6l-1 6 4 4H6l4-4zM12 13v8"/>',
select: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8 12l3 3 5-6"/>', select: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8 12l3 3 5-6"/>',
del: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6"/>', del: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6"/>',
@@ -416,7 +416,7 @@ async function renderP2pStrip() {
const v = { id: it.id, title: it.title || videoTitleById(it.id) || it.id, channel: it.channel || '', duration: it.duration || 0, thumbnail: `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg` }; const v = { id: it.id, title: it.title || videoTitleById(it.id) || it.id, channel: it.channel || '', duration: it.duration || 0, thumbnail: `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg` };
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'ps-row'; row.className = 'ps-row';
row.innerHTML = '<span class="ps-ic">📡</span><span class="ps-txt"><b></b><small></small></span><button type="button" class="ps-dl" aria-label="Save to this device">⤓</button>'; row.innerHTML = `<span class="ps-ic">📡</span><span class="ps-txt"><b></b><small></small></span><button type="button" class="ps-dl" aria-label="Save to this device">${MediaIcons.svg('download')}</button>`;
row.querySelector('b').textContent = v.title; row.querySelector('b').textContent = v.title;
row.querySelector('small').textContent = `${fmtBytes(it.size)}${it.height ? ' · ' + it.height + 'p' : ''} · ${it.online ? 'Online' : 'Offline'}`; row.querySelector('small').textContent = `${fmtBytes(it.size)}${it.height ? ' · ' + it.height + 'p' : ''} · ${it.online ? 'Online' : 'Offline'}`;
row.querySelector('.ps-dl').onclick = () => exportToDevice(v); row.querySelector('.ps-dl').onclick = () => exportToDevice(v);
@@ -517,7 +517,7 @@ async function renderP2pList() {
<div class="card-title"></div> <div class="card-title"></div>
<div class="card-channel">${state} · ${it.holders} device${it.holders === 1 ? '' : 's'} · ${fmtBytes(it.size)}${it.height ? ' · ' + it.height + 'p' : ''}${it.stale ? ' · <i>not checked lately</i>' : ''}</div> <div class="card-channel">${state} · ${it.holders} device${it.holders === 1 ? '' : 's'} · ${fmtBytes(it.size)}${it.height ? ' · ' + it.height + 'p' : ''}${it.stale ? ' · <i>not checked lately</i>' : ''}</div>
</div> </div>
<button class="card-act" data-act="export" title="Save to this device" aria-label="Save to this device">⤓</button> <button class="card-act" data-act="export" title="Save to this device" aria-label="Save to this device">${MediaIcons.svg('download')}</button>
<button class="card-act" data-act="add" title="Add to a playlist" aria-label="Add to a playlist">+</button>`; <button class="card-act" data-act="add" title="Add to a playlist" aria-label="Add to a playlist">+</button>`;
row.querySelector('.card-title').textContent = v.title; row.querySelector('.card-title').textContent = v.title;
row.addEventListener('click', (e) => { row.addEventListener('click', (e) => {
@@ -550,7 +550,7 @@ async function renderSaved() {
const empty = document.createElement('div'); const empty = document.createElement('div');
empty.className = 'empty-state'; empty.className = 'empty-state';
empty.innerHTML = ` empty.innerHTML = `
<div class="empty-icon">💾</div> <div class="empty-icon">${MediaIcons.svg('offline')}</div>
<h3 class="empty-title">Offline cache unavailable</h3> <h3 class="empty-title">Offline cache unavailable</h3>
<p class="empty-desc">This build doesn't support saving videos for offline playback.</p>`; <p class="empty-desc">This build doesn't support saving videos for offline playback.</p>`;
c.appendChild(empty); c.appendChild(empty);
@@ -566,9 +566,9 @@ async function renderSaved() {
const empty = document.createElement('div'); const empty = document.createElement('div');
empty.className = 'empty-state'; empty.className = 'empty-state';
empty.innerHTML = ` empty.innerHTML = `
<div class="empty-icon">💾</div> <div class="empty-icon">${MediaIcons.svg('offline')}</div>
<h3 class="empty-title">Nothing saved yet</h3> <h3 class="empty-title">Nothing saved yet</h3>
<p class="empty-desc">Use <strong>⬇ Save</strong> while playing, or add videos to a playlist to keep them offline.</p>`; <p class="empty-desc">Use <strong>${MediaIcons.svg('download')} Save</strong> while playing, or add videos to a playlist to keep them offline.</p>`;
addBrowseCta(empty, '🔍 Find videos'); addBrowseCta(empty, '🔍 Find videos');
c.appendChild(empty); c.appendChild(empty);
return; return;
@@ -904,7 +904,7 @@ async function renderSettings() {
<div class="set-group"> <div class="set-group">
<div class="set-group-title">Playlists</div> <div class="set-group-title">Playlists</div>
<div class="set-row" style="border:none;gap:8px;flex-wrap:wrap"> <div class="set-row" style="border:none;gap:8px;flex-wrap:wrap">
<button id="importYtBtn" class="btn">⬇ Import from YouTube</button> <button id="importYtBtn" class="btn">${MediaIcons.svg('download')} Import from YouTube</button>
<button id="exportBtn" class="btn">Export playlists</button> <button id="exportBtn" class="btn">Export playlists</button>
<button id="importBtn" class="btn">Import playlists</button> <button id="importBtn" class="btn">Import playlists</button>
<input id="fileInput" type="file" accept=".json" style="display:none" /> <input id="fileInput" type="file" accept=".json" style="display:none" />
@@ -976,7 +976,7 @@ async function renderSettings() {
<button id="profileShareBtn" class="btn"${profileShareUrl() ? '' : ' style="display:none"'}>↗ Share</button> <button id="profileShareBtn" class="btn"${profileShareUrl() ? '' : ' style="display:none"'}>↗ Share</button>
<button id="profileUnlinkBtn" class="btn danger"${data.profile && data.profile.name ? '' : ' style="display:none"'}>Unlink</button> <button id="profileUnlinkBtn" class="btn danger"${data.profile && data.profile.name ? '' : ' style="display:none"'}>Unlink</button>
<button id="profileProtectBtn" class="btn"${data.profile && data.profile.name ? '' : ' style="display:none"'}>🔒 Protect…</button> <button id="profileProtectBtn" class="btn"${data.profile && data.profile.name ? '' : ' style="display:none"'}>🔒 Protect…</button>
<button id="profileExportCredBtn" class="btn" style="display:none">⤓ Credentials file</button> <button id="profileExportCredBtn" class="btn" style="display:none">${MediaIcons.svg('download')} Credentials file</button>
<button id="profileImportCredBtn" class="btn">⤒ Import credentials</button> <button id="profileImportCredBtn" class="btn">⤒ Import credentials</button>
<input id="profileCredFileInput" type="file" accept=".json,application/json" hidden /> <input id="profileCredFileInput" type="file" accept=".json,application/json" hidden />
</div> </div>
@@ -1339,7 +1339,7 @@ function renderCard(v, index, list) {
<div class="thumb"> <div class="thumb">
<img loading="lazy" decoding="async" src="${escapeHtml(v.thumbnail || '')}" alt="" /> <img loading="lazy" decoding="async" src="${escapeHtml(v.thumbnail || '')}" alt="" />
${v.duration ? `<span class="dur">${fmtTime(v.duration)}</span>` : ''} ${v.duration ? `<span class="dur">${fmtTime(v.duration)}</span>` : ''}
<span class="saved-badge" title="${isDownloading ? 'Downloading…' : 'Saved offline'}">${isDownloading ? '' : '⬇'}</span> <span class="saved-badge" title="${isDownloading ? 'Downloading…' : 'Saved offline'}">${MediaIcons.svg('offline', 'badge-offline')}${MediaIcons.svg('progress', 'badge-progress')}</span>
${isDownloading ? '<div class="dl-progress"><div class="dl-bar"></div></div>' : ''} ${isDownloading ? '<div class="dl-progress"><div class="dl-bar"></div></div>' : ''}
</div> </div>
<div class="card-info"> <div class="card-info">

View File

@@ -335,3 +335,15 @@ No product behavior changes are involved in these measurement corrections.
Keep browser/build/test processes idle during full timing measurements. WebKit's Keep browser/build/test processes idle during full timing measurements. WebKit's
zero long-task field means unavailable, not zero work. Autoplay-blocked/media-ready zero long-task field means unavailable, not zero work. Autoplay-blocked/media-ready
null results do not establish playback performance or iPhone audio continuity. null results do not establish playback performance or iPhone audio continuity.
## UI geometry and media icons
```sh
node perf/ui-geometry.mjs
node perf/ui-icons.mjs
node --test frontend/media-icons.test.js
```
Both harnesses run Chromium and WebKit at mobile dimensions with stubbed SpeechRecognition and local Saved/Queue metadata. They serve only the frontend, block service workers and external requests, and never download media. Geometry tests cold and switched layouts, six widths (320–430), mic visible/hidden and update icon visible/hidden: center spread ≤1px, touch targets ≥40px, Saved image/text gap ≥8px, no horizontal overflow or clipped car control. `--quick` limits widths and speech cases; `--browser` selects an engine; `--frontend` selects historical fixtures. `--measure-only --label before` records known broken geometry without exiting nonzero.
Icon checks switch the same page through four layouts × dark/light/contrast, assert SVG definitions, inherited colour, stroke family and save/progress/offline state, and save Queue/Now Playing/navigation shots. Glass Stage intentionally hides bottom navigation, so its full page is recorded instead. Any assertion failure exits nonzero. Shots and raw measurements live in git-ignored plans/ui-shots/. Full production inventory: plans/ui-icon-inventory.md. Native speech recognition, installed-PWA safe-area insets and iPhone rendering still need device verification.

36
perf/ui-fixture.mjs Normal file
View File

@@ -0,0 +1,36 @@
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { resolve, extname } from 'node:path';
export const layouts = ['classic', 'f7-swipe', 'glass-stage', 'bento-hub'];
export const themes = ['dark', 'light', 'contrast'];
export async function serve(frontend = 'frontend') {
const root = resolve(frontend);
const mime = { '.html':'text/html', '.js':'text/javascript', '.mjs':'text/javascript', '.css':'text/css', '.json':'application/json', '.woff2':'font/woff2', '.svg':'image/svg+xml', '.png':'image/png' };
const server = createServer(async (req, res) => {
const pathname = new URL(req.url, 'http://localhost').pathname;
const path = resolve(root, '.' + (pathname === '/' ? '/index.html' : pathname));
if (!path.startsWith(root + '/')) { res.writeHead(403).end(); return; }
try { const body = await readFile(path); res.writeHead(200, { 'Content-Type':mime[extname(path)] || 'application/octet-stream' }).end(body); }
catch { res.writeHead(404).end(); }
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
return { origin:`http://127.0.0.1:${server.address().port}`, close:() => new Promise(r => server.close(r)) };
}
export async function contextFor(browser, origin, layout = 'classic', theme = 'dark') {
const context = await browser.newContext({ viewport:{width:414,height:896}, deviceScaleFactor:2, isMobile:true, hasTouch:true, serviceWorkers:'block', reducedMotion:'reduce' });
await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.abort());
await context.addInitScript(({layout,theme}) => {
localStorage.setItem('_ytpdata', JSON.stringify({ playlists:[], history:[], queue:[], settings:{layout,theme,reduceMotion:true} }));
window.SpeechRecognition = window.webkitSpeechRecognition = function() {};
Object.defineProperty(navigator.connection || navigator, 'saveData', {value:true,configurable:true});
}, {layout,theme});
return context;
}
export async function appearance(page, layout, theme = 'dark') {
await page.evaluate(async ({layout,theme}) => {
if (window.Lazy) await Lazy.load('layout:' + layout);
data.settings.layout = layout; data.settings.theme = theme; applyAppearance();
await document.fonts.ready;
}, {layout,theme});
}

98
perf/ui-geometry.mjs Normal file
View File

@@ -0,0 +1,98 @@
#!/usr/bin/env node
// Local static shell, real renderSaved(), deterministic offline-file metadata.
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { readFile, mkdir, writeFile } from 'node:fs/promises';
import { resolve, extname } from 'node:path';
import { chromium, webkit } from 'playwright';
const args = process.argv.slice(2);
const option = (key, fallback) => args.includes(key) ? args[args.indexOf(key) + 1] : fallback;
const root = resolve(option('--frontend', 'frontend'));
const label = option('--label', 'after');
const measureOnly = args.includes('--measure-only');
const shotDir = resolve('plans/ui-shots');
await mkdir(shotDir, { recursive: true });
const mime = { '.html':'text/html', '.js':'text/javascript', '.mjs':'text/javascript', '.css':'text/css', '.json':'application/json', '.woff2':'font/woff2', '.svg':'image/svg+xml', '.png':'image/png' };
const server = createServer(async (req, res) => {
const path = resolve(root, '.' + (new URL(req.url, 'http://localhost').pathname === '/' ? '/index.html' : new URL(req.url, 'http://localhost').pathname));
if (!path.startsWith(root + '/')) { res.writeHead(403).end(); return; }
try { const body = await readFile(path); res.writeHead(200, { 'Content-Type':mime[extname(path)] || 'application/octet-stream' }).end(body); }
catch { res.writeHead(404).end(); }
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
const origin = `http://127.0.0.1:${server.address().port}`;
const widths = args.includes('--quick') ? [414] : [320, 360, 375, 390, 414, 430];
const layouts = ['classic', 'f7-swipe', 'glass-stage', 'bento-hub'];
const results = [], failures = [];
const check = (ok, message) => { if (!ok) failures.push(message); };
try {
for (const [name, engine] of Object.entries({ chromium, webkit }).filter(([name])=>option('--browser','all')==='all'||option('--browser','all')===name)) {
const browser = await engine.launch();
try {
for (const layout of layouts) for (const speech of (args.includes('--quick') ? [true] : [true, false])) for (const entry of ['cold', 'switched']) {
const context = await browser.newContext({ viewport:{width:414,height:896}, deviceScaleFactor:2, isMobile:true, hasTouch:true, serviceWorkers:'block' });
await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.abort());
await context.addInitScript(({ layout, speech }) => {
localStorage.setItem('_ytpdata', JSON.stringify({ playlists:[], history:[], queue:[], settings:{ layout } }));
window.SpeechRecognition = window.webkitSpeechRecognition = speech ? function() {} : undefined;
window.addEventListener('unhandledrejection', e=>console.error('rejection:', String(e.reason)));
// Prevent idle warming from concealing the eager/lazy CSS regression.
Object.defineProperty(navigator.connection || navigator, 'saveData', { value:true, configurable:true });
}, { layout:entry==='cold'?layout:(layout==='classic'?'f7-swipe':'classic'), speech });
const page = await context.newPage();
page.on("pageerror", e => console.error(name, layout, e.message));
page.on("console", m=>{if(m.text().startsWith("rejection:")) console.error(m.text());});
await page.goto(origin);
await page.waitForFunction(() => document.activeElement.id === 'searchInput');
// Historical shell's optional integrations can delay toolbar setup on
// a static server; run its original initializer with inert callbacks.
await page.evaluate(() => { if (!document.querySelector('[aria-label="Car mode"]')) CarMode.configure({ input:document.getElementById('searchInput'), search(){}, toast(){}, state:()=>({paused:true}), action(){} }); });
await page.evaluate(() => { document.querySelector('[aria-label="Car mode"]').id='carModeBtn'; const mic=document.querySelector('[aria-label="Voice search"]'); if(mic)mic.id='voiceSearchBtn'; });
await page.waitForSelector('#carModeBtn');
if (speech) await page.waitForSelector('#voiceSearchBtn');
await page.evaluate(async layout => { if(window.Lazy) await Lazy.load('layout:' + layout); data.settings.layout=layout; applyAppearance(); await document.fonts.ready; }, layout);
// Seed the actual Saved renderer, without media downloads or server access.
await page.evaluate(async () => {
const videos = ['Revealing Lilith (origins of abortion, ancient goddesses, & the final chapter)', 'How my Trouble Has Always Been a Divine Setup | Perry Stone', 'Official live video ' + 'UnbrokenLongTitle'.repeat(12)].map((title,i) => ({id:'geometry00'+i,title,thumbnail:'/icons/icon-192.png',channel:'Geometry fixture'}));
data.history = videos;
API.cacheList = async () => ({ok:true,items:videos.map((v,i)=>({id:v.id,size:(3-i)*100000000})),total:600000000});
view = {type:'saved'}; render();
});
await page.waitForSelector('.saved-card .card-title');
await page.waitForTimeout(550); // card entrance animation
for (const width of widths) {
await page.setViewportSize({width,height:896});
for (const update of [false, true]) {
await page.evaluate(show => document.querySelector('#updateIconBtn')?.classList.toggle('hidden', !show), update);
const geometry = await page.evaluate(() => {
const rect = n => { const r=n.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,cy:r.y+r.height/2}; };
const ids=['#sidebarToggle','#searchInput','#voiceSearchBtn','.search-btn','#carModeBtn','#updateIconBtn'];
const top = Object.fromEntries(ids.map(id=>[id,document.querySelector(id)]).filter(([,n])=>n&&n.getBoundingClientRect().width).map(([id,n])=>[id,rect(n)]));
const rows=[...document.querySelectorAll('.saved-card')].map(n=>({thumb:rect(n.querySelector('.thumb')),image:rect(n.querySelector('.thumb img')),info:rect(n.querySelector('.card-info')),title:rect(n.querySelector('.card-title'))}));
return {top,rows,scrollWidth:document.documentElement.scrollWidth};
});
const key=`${name}/${layout}/${entry}/${width}/speech=${speech}/update=${update}`;
const centers=Object.values(geometry.top).map(r=>r.cy);
geometry.centerSpread=Math.max(...centers)-Math.min(...centers);
geometry.gaps=geometry.rows.map(r=>r.info.x-Math.max(r.thumb.right,r.image.right));
check(geometry.centerSpread<=1,`${key}: center spread ${geometry.centerSpread}`);
for (const id of ['#voiceSearchBtn','#carModeBtn']) if(geometry.top[id]) check(geometry.top[id].width>=40&&geometry.top[id].height>=40,`${key}: cramped ${id}`);
for (const gap of geometry.gaps) check(gap>=8,`${key}: thumbnail/title gap ${gap}`);
check(geometry.top['#searchInput'].width>=24,`${key}: search input collapsed`);
check(geometry.top['#carModeBtn'].right<=width-8,`${key}: car clipped at edge`);
check(geometry.scrollWidth<=width,`${key}: horizontal page overflow`);
results.push({browser:name,layout,entry,width,speech,update,...geometry});
if(width===414&&speech&&!update) await page.screenshot({path:resolve(shotDir,`${label}-${name}-${layout}-${entry}.png`)});
}
}
// Loading the optional feature must not resize or move the toolbar.
await context.close();
}
} finally { await browser.close(); }
}
} finally { await new Promise(r=>server.close(r)); }
await writeFile(resolve(shotDir,`${label}-measurements.json`),JSON.stringify({results,failures},null,2));
console.log(`${label}: ${results.length} cases; ${failures.length} failed checks; measurements/screenshots in plans/ui-shots/`);
if(failures.length) console.log(failures.slice(0,12).join('\n'));
if(!measureOnly) assert.equal(failures.length,0,'UI geometry checks failed');

96
perf/ui-icons.mjs Normal file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { chromium, webkit } from 'playwright';
import { serve, layouts, themes, contextFor, appearance } from './ui-fixture.mjs';
const args = process.argv.slice(2);
const option = (key, fallback) => args.includes(key) ? args[args.indexOf(key)+1] : fallback;
const shots = resolve('plans/ui-shots');
await mkdir(shots, {recursive:true});
const host = await serve(option('--frontend','frontend'));
const results = [];
try {
for (const [name,engine] of Object.entries({chromium,webkit}).filter(([name]) => option('--browser','all') === 'all' || name === option('--browser','all'))) {
const browser = await engine.launch();
try {
const context = await contextFor(browser,host.origin);
const page = await context.newPage();
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto(host.origin);
await page.waitForFunction(() => document.activeElement.id === 'searchInput');
await page.evaluate(() => {
CarMode.configure({input:els.searchInput,search(){},toast(){},state:()=>({paused:true}),action(){}});
const videos = ['Saved offline fixture','Downloading fixture','Streaming fixture'].map((title,i) => ({id:'iconcheck0'+i,title,channel:'Icon fixture',thumbnail:'/icons/icon-192.png',duration:240}));
data.queue = videos; data.history = videos;
cachedIds.add(videos[0].id); downloading.add(videos[1].id);
current = {meta:videos[0]}; view = {type:'queue'}; render();
els.playerPane.classList.remove('empty','home-over');
els.placeholder.classList.add('hidden'); els.controls.classList.remove('hidden');
document.querySelector('#nowPlayingMeta').classList.remove('hidden');
updateNowPlayingActions();
});
await page.waitForSelector('.card.cached .saved-badge');
// Reuse this live page: layout changes must repaint the same SVG instances.
for (const layout of layouts) for (const theme of themes) {
await appearance(page,layout,theme);
await page.addStyleTag({content:'*,*::before,*::after { animation:none !important; transition:none !important; }'});
const measurements = await page.evaluate(() => {
const glyph = /[⬇⤓⇩💾📥↓]/u;
const bad = [...document.querySelectorAll('button,a,.saved-badge,.empty-icon,.pl-offline-badge,.home-pl-offline')].filter(n => glyph.test(n.textContent)).map(n=>n.outerHTML);
const selectors = ['.card.cached .saved-badge','.card.downloading .saved-badge','#saveBtn','#exportBtn','.bottom-nav-btn[data-view="saved"]','.bottom-nav-btn[data-view="downloads"]','.nav-item[data-view="saved"]','.nav-item[data-view="downloads"]'];
const icons = selectors.map(selector => {
const el=document.querySelector(selector), svg=el.querySelector('svg');
const active=[...el.querySelectorAll('svg')].find(n=>getComputedStyle(n).display!=='none') || svg;
const style=getComputedStyle(active), parent=getComputedStyle(el);
const uses=[...active.querySelectorAll('use')].filter(n=>getComputedStyle(n).display!=='none');
return {selector,hasSvg:!!svg,text:active.textContent,stroke:style.stroke,color:style.color,parentColor:parent.color,weight:style.strokeWidth,uses:uses.map(n=>n.getAttribute('href')),resolved:uses.every(n=>!!document.querySelector(n.getAttribute('href')))};
});
const shapes = [...document.querySelectorAll('#media-download .media-shape')].filter(n=>getComputedStyle(n).display!=='none').map(n=>n.classList[1]);
const probe=document.createElement('span');probe.style.color='var(--accent-bright)';document.body.appendChild(probe);
const accent=getComputedStyle(probe).color;probe.remove();
return {bad,icons,shapes,accent};
});
assert.deepEqual(measurements.bad,[],`${name}/${layout}/${theme}: glyph icon`);
assert.deepEqual(measurements.shapes,[`media-shape-${layout}`],`${layout}: wrong layout drawing`);
for (const icon of measurements.icons) {
assert(icon.hasSvg && icon.resolved && !icon.text.trim(),`${icon.selector}: invalid SVG`);
assert.equal(icon.stroke,icon.color,`${icon.selector}: ignores currentColor`);
assert.equal(icon.color,icon.parentColor,`${icon.selector}: ignores control colour`);
assert.equal(parseFloat(icon.weight),({'classic':1.8,'f7-swipe':2,'glass-stage':1.9,'bento-hub':1.8})[layout],`${icon.selector}: wrong stroke family`);
}
if(layout!=='bento-hub') assert.equal(measurements.icons[0].color,measurements.accent,'badge ignores theme token');
assert.deepEqual(measurements.icons[0].uses,['#media-offline']);
assert.deepEqual(measurements.icons[1].uses,['#media-progress']);
const label=`icons-${name}-${layout}-${theme}`;
await page.locator('#listPane').screenshot({path:resolve(shots,`${label}-queue.png`)});
// Bento Hub shows either the list or player on phones.
await page.evaluate(() => document.querySelector('#listPane').classList.add('hidden'));
await page.locator('.np-actions').screenshot({path:resolve(shots,`${label}-playing.png`)});
// Glass Stage replaces bottom navigation with its floating chrome.
const nav = page.locator('.bottom-nav');
if (await nav.isVisible()) await nav.screenshot({path:resolve(shots,`${label}-nav.png`)});
else await page.screenshot({path:resolve(shots,`${label}-nav-hidden.png`)});
// State updates must keep the SVG and reveal the corresponding concept.
const states=await page.evaluate(() => {
const id=current.meta.id;
const visible=()=>{ els.saveBtn.getBoundingClientRect(); return [...els.saveBtn.querySelectorAll('use')].filter(n=>getComputedStyle(n).display!=='none').map(n=>n.getAttribute('href')); };
cachedIds.delete(id);updateNowPlayingActions();const normal=visible();
downloading.add(id);updateNowPlayingActions();const progress=visible();
downloading.delete(id);cachedIds.add(id);updateNowPlayingActions();const done=visible();
markCardCacheState('iconcheck02','downloading');markCardCacheState('iconcheck02','cached');
return {normal,progress,done};
});
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'));
results.push({browser:name,layout,theme,...measurements});
}
assert.deepEqual(errors,[],'script errors');
await context.close();
} finally { await browser.close(); }
}
} finally { await host.close(); }
await writeFile(resolve(shots,'icon-measurements.json'),JSON.stringify(results,null,2));
console.log(`Media icons: ${results.length} layout/theme cases passed; Queue, Now Playing and navigation screenshots in plans/ui-shots/`);

25
plans/ui-fixes-report.md Normal file
View File

@@ -0,0 +1,25 @@
# iPhone UI fixes — 2026-10-09
Work confined to wt-ui/codex/ui-fixes. Retained interrupted commits 3de4cba (A) and 66bbda5 (B). No loading, service-worker, boot or media behavior changed.
## A: toolbar
Phase 2 measurements have ≤1px center spread. Phase 3 measurements rise to 10px (classic), 8px (F7), 9px (Glass), while Bento stays aligned. Commit 5d6a154 moved car-mode.css out of the eager path, including the mic's min-height/min-width rule; absent search-form align-items:center left it at the top. The 26px car target was already small in the old shell. Shared eager styles now provide align-items:center, 44px targets, 22px icons, mobile gaps and safe-area padding. Relevant rules: frontend/styles.css:276 and :4260; obsolete feature-only mic sizing removed from frontend/car-mode.css.
## B: Saved
The existing 72px grid track could contain a theme-widened thumbnail/image; later-loaded layout rules made that visible. Phase 2 minimum measured image/text gap was 12px; Phase 3 or subsequent layout switching produced -4px. The original weak rule comes from ca836e0; Phase 3 dynamic stylesheet ordering exposed it. frontend/saved-page.css:5 now bounds thumbnail and image to the grid track with specific selectors, overflow containment and fixed participation; the text track remains minmax(0,1fr) with min-width:0 and clamped/ellipsized long titles. Final gap is 12px across all tested layouts and widths.
## C: all media icons
Legacy text/emoji download/save icons predate Phases 3–5. frontend/download-actions.js:4 supplies shared SVG concepts (download, offline check, progress), with layout-specific shapes switched by CSS, currentColor and theme tokens. Consumers include Queue/card badges, Now Playing save/export, Saved actions, Downloads/empty states, sidebar/home offline badges, bottom navigation, playlist offline actions, Settings imports, P2P export, Share/External exports and admin LRCLIB/JSON export. Accessible text, IDs and handlers remain intact. Hard-coded done/download badge colours now use accent tokens. Full file:line inventory: [ui-icon-inventory.md](ui-icon-inventory.md). Keyboard-arrow prose is the only narrow glyph allowlist; manifest/app logos have no media-action glyphs.
## Verification
Commands: node perf/ui-geometry.mjs; node perf/ui-icons.mjs; node --test frontend/*.test.js; (cd server && bun install && bun run test).
Icons: 24 layout/theme cases passed across both engines, including runtime shape/colour switching and save/downloading/saved state changes. The glyph guard passed. Frontend suite: 199 passed. Server: 124 passed; only the two permitted worker failures (missing Python yt_dlp).
Geometry: 384 cases, zero failed checks on Chromium and WebKit; four layouts, widths 320/360/375/390/414/430, mic on/off, update on/off, cold and switched layouts. Final center spread 0px, mic/car targets 44×44, image/text gaps 12px, no clipped car or horizontal overflow. Recorded screenshots and raw phase comparisons are git-ignored under plans/ui-shots/. Inspected old and new classic plus the owner's F7 screenshot. Classic Saved-heading and title-region pixel comparisons are identical; full application-wide pixel identity was not tested.
Native iPhone follow-up: installed-PWA safe-area/keyboard sizing, real speech permission and voice search, touch targets at 320px, runtime layout switching, and light/contrast appearance. Browser fixtures stub speech capability and media metadata, not native speech or OPFS.

View File

@@ -0,0 +1,71 @@
# Download and offline icon inventory
All production references after conversion, including SVG definitions, consumers and CSS. Generated from the final frontend; tests excluded.
| File:line | Reference |
| --- | --- |
| `frontend/admin.html:315` | `.media-shape { display:none; } .media-shape-classic { display:inline; } ` |
| `frontend/admin.html:316` | `.media-icon { width:18px; height:18px; vertical-align:middle; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; } ` |
| `frontend/admin.html:412` | `<button class="btn sm" id="webBtn" type="button" title="Fetch lyrics from LRCLIB"><svg class="media-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-download"/></svg> Pull from LRCLIB</button> ` |
| `frontend/admin.html:513` | `<a class="btn sm" href="/api/admin/notes/export"><svg class="media-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-download"/></svg> Export all (JSON)</a></div> ` |
| `frontend/app.js:3562` | `${isUploadId(st.videoId) ? '' : <button class="np-btn" type="button" data-act="captions">${MediaIcons.svg('download')} Import YouTube captions</button>} ` |
| `frontend/app.js:3804` | `<button type="button" class="np-btn" data-act="captions">${MediaIcons.svg('download')} Import YouTube captions</button> ` |
| `frontend/app.js:6994` | `exportBtn.innerHTML = MediaIcons.svg('download') + ' Save to gallery'; ` |
| `frontend/app.js:7008` | `saveBtn.innerHTML = MediaIcons.svg(isSaved ? 'offline' : 'download') + (isSaved ? ' Saved offline — remove' : ' Save for offline'); ` |
| `frontend/app.js:7235` | `if (iconOnly) { setListIcon(btn, 'offlineDone'); btn.classList.add('done'); } else btn.innerHTML = MediaIcons.svg('offline') + ' Available offline'; ` |
| `frontend/app.js:7240` | `if (iconOnly) { btn.innerHTML = MediaIcons.svg('progress') + <span class="lib-progress">${stats.cached}/${stats.total}</span>; btn.classList.add('busy'); } else btn.innerHTML = MediaIcons.svg('progress') + Saving ${stats.cached}/${stats.total}…; ` |
| `frontend/app.js:7245` | `if (iconOnly) setListIcon(btn, 'offline'); else btn.innerHTML = MediaIcons.svg('download') + ' Make available offline'; ` |
| `frontend/app.js:7262` | `btn.innerHTML = MediaIcons.svg('progress') + <span class="lib-progress">${stats.cached}/${stats.total}</span>; ` |
| `frontend/download-actions.js:29` | `const svg = (kind, extra = '') => <svg class="media-icon ${extra}" data-media-icon="${kind}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${use(kind)}</svg>; ` |
| `frontend/download-actions.js:35` | `defs.innerHTML = '<defs>' + Object.keys(mediaPaths).map(kind => <g id="media-${kind}">${Object.entries(variants).map(([layout, paths]) => <g class="media-shape media-shape-${layout}">${paths[kind]}</g>).join('')}</g>).join('') + '</defs>'; ` |
| `frontend/download-actions.js:37` | `root.MediaIcons = media; ` |
| `frontend/downloads-page.js:122` | `<svg class="dl-empty-svg media-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-download"/></svg> ` |
| `frontend/downloads.js:15` | `root.SettingsSections.register({ id: 'downloads-storage', title: 'Downloads & storage', cluster: 'Library & storage', description: 'Active queued paused failed saves, disk usage and playlist sizes', icon: '<use href="#media-download"/>', summary: () => ${adapter.slots()} at once · ${bytes(total)}, render(container) { ` |
| `frontend/glass-controls.js:9` | `save:'<use href="#media-download"/>', ` |
| `frontend/index.html:50` | `<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> ` |
| `frontend/index.html:51` | `<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> ` |
| `frontend/index.html:271` | `<button id="saveBtn" class="np-btn" title="Save this video for offline playback"><svg class="ti media-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use class="i-save" href="#media-download"/><use class="i-done" href="#media-offline"/><use class="i-progress" href="#media-progress"/></svg><span class="tl">Save</span></button> ` |
| `frontend/index.html:272` | `<button id="exportBtn" class="np-btn" title="Save this video to your gallery, Files app or Downloads folder — works while streaming too"><svg class="ti media-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-download"/></svg><span class="tl">Gallery</span></button> ` |
| `frontend/index.html:341` | `<button id="exportMarkersBtn" class="np-btn" type="button"><svg class="media-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-download"/></svg> Export</button> ` |
| `frontend/index.html:601` | `<span class="bottom-nav-icon"><svg class="media-icon media-nav-icon" data-media-icon="offline" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-offline"/></svg></span> ` |
| `frontend/index.html:605` | `<span class="bottom-nav-icon"><svg class="media-icon media-nav-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-download"/></svg></span> ` |
| `frontend/saved-page.js:6` | `export: '<use href="#media-download"/>', ` |
| `frontend/saved-page.js:13` | `b.innerHTML = <svg class="media-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[kind]}</svg>; ` |
| `frontend/share-external.js:74` | `<div class="sm-gif-actions"><a class="np-btn" href="${url}" download="${escapeHtml(name)}">${MediaIcons.svg('download')} Download</a> ` |
| `frontend/share-external.js:101` | `<div class="sm-gif-actions"><a class="np-btn" href="${url}" download="${escapeHtml(name)}">${MediaIcons.svg('download')} Download</a> ` |
| `frontend/share-external.js:179` | `body.innerHTML = <p class="modal-text">The server hasn't finished caching this video yet, and other players need that single-file copy. Keep it playing for a minute (or tap ${MediaIcons.svg('download')} Save), then try again.</p>; ` |
| `frontend/share-external.js:196` | `<button class="np-btn" type="button" data-act="m3u">${MediaIcons.svg('download')} .m3u (this video)</button> ` |
| `frontend/share-external.js:197` | `${inList ? <button class="np-btn" type="button" data-act="m3u-list">${MediaIcons.svg('download')} .m3u (whole list)</button> : ''} ` |
| `frontend/shell-core.js:94` | `offlineBadge = <span class="pl-offline-badge all" title="Available offline">${MediaIcons.svg('offline')}</span>; ` |
| `frontend/shell-core.js:144` | `offlineHtml = <span class="home-pl-offline all" title="Available offline">${MediaIcons.svg('offline')}</span>; ` |
| `frontend/styles.css:1113` | `.saved-badge .media-icon { width:16px; height:16px; filter:drop-shadow(0 1px 2px #000); } ` |
| `frontend/styles.css:4337` | `.media-icon { display:inline-block; width:20px; height:20px; flex:none; vertical-align:middle; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; pointer-events:none; } ` |
| `frontend/styles.css:4339` | `.empty-icon .media-icon { width:48px; height:48px; } ` |
| `frontend/styles.css:4340` | `:is(.pl-offline-badge,.home-pl-offline) .media-icon { width:14px; height:14px; } ` |
| `frontend/styles.css:4341` | `html[data-layout="classic"] .media-icon { stroke-width:1.8; } ` |
| `frontend/styles.css:4342` | `html[data-layout="f7-swipe"] .media-icon { stroke-width:2; } ` |
| `frontend/styles.css:4343` | `html[data-layout="glass-stage"] .media-icon { stroke-width:1.9; } ` |
| `frontend/styles.css:4344` | `html[data-layout="bento-hub"] .media-icon { stroke-width:1.8; } ` |
| `frontend/styles.css:4348` | `:is([data-media-icon="progress"], .np-btn.saving .media-icon) { animation:mediaSpin 1s linear infinite; transform-origin:center; } ` |
| `frontend/styles.css:4350` | `@media(prefers-reduced-motion:reduce) { :is([data-media-icon="progress"], .np-btn.saving .media-icon) { animation:none; } } ` |
| `frontend/styles.css:4351` | `.media-shape { display:none; } ` |
| `frontend/styles.css:4352` | `.media-shape-classic { display:inline; } ` |
| `frontend/styles.css:4353` | `html[data-layout="f7-swipe"] .media-shape-classic, ` |
| `frontend/styles.css:4354` | `html[data-layout="glass-stage"] .media-shape-classic, ` |
| `frontend/styles.css:4355` | `html[data-layout="bento-hub"] .media-shape-classic { display:none; } ` |
| `frontend/styles.css:4356` | `html[data-layout="f7-swipe"] .media-shape-f7-swipe, ` |
| `frontend/styles.css:4357` | `html[data-layout="glass-stage"] .media-shape-glass-stage, ` |
| `frontend/styles.css:4358` | `html[data-layout="bento-hub"] .media-shape-bento-hub { display:inline; } ` |
| `frontend/views-core.js:47` | `els.listActions.innerHTML = groups.length ? <button class="np-btn" id="notesExportBtn" type="button">${MediaIcons.svg('download')} Export Markdown</button> : ''; ` |
| `frontend/views-core.js:133` | `offline: '<use href="#media-download"/>', ` |
| `frontend/views-core.js:134` | `offlineDone: '<use href="#media-offline"/>', ` |
| `frontend/views-core.js:419` | `row.innerHTML = <span class="ps-ic">📡</span><span class="ps-txt"><b></b><small></small></span><button type="button" class="ps-dl" aria-label="Save to this device">${MediaIcons.svg('download')}</button>; ` |
| `frontend/views-core.js:520` | `<button class="card-act" data-act="export" title="Save to this device" aria-label="Save to this device">${MediaIcons.svg('download')}</button> ` |
| `frontend/views-core.js:553` | `<div class="empty-icon">${MediaIcons.svg('offline')}</div> ` |
| `frontend/views-core.js:569` | `<div class="empty-icon">${MediaIcons.svg('offline')}</div> ` |
| `frontend/views-core.js:571` | `<p class="empty-desc">Use <strong>${MediaIcons.svg('download')} Save</strong> while playing, or add videos to a playlist to keep them offline.</p>; ` |
| `frontend/views-core.js:906` | `<button id="importYtBtn" class="btn">${MediaIcons.svg('download')} Import from YouTube</button> ` |
| `frontend/views-core.js:978` | `<button id="profileExportCredBtn" class="btn" style="display:none">${MediaIcons.svg('download')} Credentials file</button> ` |
| `frontend/views-core.js:1339` | `<span class="saved-badge" title="${isDownloading ? 'Downloading…' : 'Saved offline'}">${MediaIcons.svg('offline', 'badge-offline')}${MediaIcons.svg('progress', 'badge-progress')}</span> ` |
The only remaining requested arrow glyphs are keyboard shortcut prose in index.html and admin.html, explicitly allowlisted by media-icons.test.js. Webmanifest/shortcut/icon images contain no download/save action glyph; generic app logos stay unchanged. Modal titles that previously prefixed a glyph now retain their accessible plain-text name.