Use theme-aware SVGs for download and offline actions

This commit is contained in:
Jonathan Sykes
2026-10-09 14:28:23 +08:00
parent 66bbda51c7
commit 2001e77cee
13 changed files with 122 additions and 62 deletions

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

@@ -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

@@ -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

@@ -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 {
@@ -4331,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;
@@ -903,7 +903,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" />
@@ -975,7 +975,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>
@@ -1336,7 +1336,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">