Use theme-aware SVGs for download and offline actions
This commit is contained in:
@@ -312,6 +312,8 @@
|
||||
main { padding-top: 16px; }
|
||||
}
|
||||
@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>
|
||||
</head>
|
||||
<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>
|
||||
<button class="btn sm" id="shiftBtn" type="button">Apply</button>
|
||||
<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>
|
||||
<label><input type="checkbox" id="follow" checked /> Follow playback</label>
|
||||
</div>
|
||||
@@ -508,7 +510,7 @@
|
||||
<div class="card">
|
||||
<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>
|
||||
<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>
|
||||
<pre id="revView" class="hidden" style="margin-top:12px"></pre>
|
||||
</div>
|
||||
@@ -569,6 +571,7 @@
|
||||
<nav class="nav" id="tabsBottom" role="tablist"></nav>
|
||||
</div>
|
||||
|
||||
<script src="download-actions.js"></script>
|
||||
<script src="lyrics-core.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
|
||||
@@ -1241,7 +1241,7 @@ async function adoptSharedPlaylistByCode(code, { fromName = '' } = {}) {
|
||||
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.
|
||||
</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: '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,
|
||||
and future changes here sync to that profile.
|
||||
</p>`;
|
||||
showModal('⬇ Open shared profile', body, [
|
||||
showModal('Open shared profile', body, [
|
||||
{ label: 'Cancel', onClick: () => { closeModal(); resolve(false); } },
|
||||
{ label: 'Load profile', primary: true, onClick: () => { closeModal(); resolve(true); } },
|
||||
]);
|
||||
@@ -1567,7 +1567,7 @@ function loadProfileFlow() {
|
||||
changes here sync back to that profile.
|
||||
</p>
|
||||
<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: 'Load', primary: true, onClick: async () => {
|
||||
const name = ($('profileNameInput').value || '').trim();
|
||||
@@ -2209,7 +2209,7 @@ async function openVideoEditor(source) {
|
||||
{
|
||||
label: 'Save edited copy', primary: true, onClick: () => {
|
||||
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;
|
||||
}
|
||||
const keep = VideoEdit.invertCuts(cuts, duration);
|
||||
@@ -3559,7 +3559,7 @@ const Notes = (() => {
|
||||
<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="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 class="notes-empty-hint">Lyrics and chapters are shared with everyone who plays this song.</div>
|
||||
</div>`;
|
||||
@@ -3801,7 +3801,7 @@ const Notes = (() => {
|
||||
${note ? `<p class="lyr-editor-note">${escapeHtml(note)}</p>` : ''}
|
||||
<textarea class="lyr-editor-text" spellcheck="false" placeholder="Paste or type the lyrics…"></textarea>
|
||||
<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="stamp">⏱ Stamp current time on this line</button>
|
||||
</div>`;
|
||||
@@ -4181,7 +4181,7 @@ const Notes = (() => {
|
||||
const refresh = () => { ta.value = build(inc.checked); };
|
||||
inc.addEventListener('change', refresh);
|
||||
refresh();
|
||||
showModal('⤓ Export chapters', body, [
|
||||
showModal('Export chapters', body, [
|
||||
{ label: 'Download .txt', onClick: () => {
|
||||
const blob = new Blob([`${title}\nhttps://youtu.be/${st.videoId}\n\n${ta.value}\n`], { type: 'text/plain' });
|
||||
const a = document.createElement('a');
|
||||
@@ -6920,7 +6920,7 @@ function openImportYouTubeModal() {
|
||||
</p>
|
||||
`;
|
||||
|
||||
showModal('⬇ Import from YouTube', successBody, [
|
||||
showModal('Import from YouTube', successBody, [
|
||||
{ label: 'Cancel', onClick: closeModal },
|
||||
{
|
||||
label: 'Create playlist',
|
||||
@@ -6959,7 +6959,7 @@ function openImportYouTubeModal() {
|
||||
}
|
||||
}
|
||||
|
||||
showModal('⬇ Import from YouTube', body, [
|
||||
showModal('Import from YouTube', body, [
|
||||
{ label: 'Cancel', onClick: closeModal },
|
||||
{ label: 'Import', primary: true, onClick: handleImport },
|
||||
]);
|
||||
@@ -6991,7 +6991,7 @@ function openCardMenu(video) {
|
||||
const isCustom = !!video.custom;
|
||||
|
||||
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.onclick = () => { closeModal(); exportToDevice(video); };
|
||||
body.appendChild(exportBtn);
|
||||
@@ -7005,7 +7005,7 @@ function openCardMenu(video) {
|
||||
saveBtn.className = 'danger';
|
||||
saveBtn.onclick = () => { closeModal(); deleteCustomVideo(video.id); };
|
||||
} 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 () => {
|
||||
closeModal();
|
||||
if (cachedIds.has(video.id)) {
|
||||
@@ -7232,17 +7232,17 @@ function updatePlaylistOfflineButton(btn, pl) {
|
||||
let label;
|
||||
btn.classList.remove('done', 'busy');
|
||||
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.classList.add('disabled');
|
||||
label = 'All videos in this playlist are available offline';
|
||||
} 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.classList.add('disabled');
|
||||
label = `${stats.cached} of ${stats.total} videos saved offline`;
|
||||
} 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.classList.toggle('disabled', stats.total === 0);
|
||||
label = stats.total === 0 ? 'Playlist is empty' : 'Make available offline';
|
||||
@@ -7259,7 +7259,7 @@ async function makePlaylistOffline(pl) {
|
||||
const btn = $('plOfflineBtn');
|
||||
if (btn) {
|
||||
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.disabled = true;
|
||||
btn.classList.add('disabled');
|
||||
|
||||
@@ -1,6 +1,41 @@
|
||||
/* One action control for the Downloads page and Settings download manager. */
|
||||
(function(root) {
|
||||
'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 = {
|
||||
retry: '<path d="M3 11a9 9 0 1 1 2.6 7M3 3v8h8"/>',
|
||||
cancel: '<path d="m6 6 12 12M6 18 18 6"/>',
|
||||
@@ -19,6 +54,6 @@
|
||||
const text = doc.createElement('span'); text.textContent = label;
|
||||
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;
|
||||
})(globalThis);
|
||||
|
||||
@@ -119,11 +119,7 @@
|
||||
empty.className = 'empty-state dl-empty-state';
|
||||
empty.innerHTML = `
|
||||
<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">
|
||||
<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>
|
||||
<svg class="dl-empty-svg media-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-download"/></svg>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
let adapter, total = 0;
|
||||
function configure(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 heading = doc.createElement('h3'); heading.textContent = 'Downloads & storage';
|
||||
const jobs = doc.createElement('div'); jobs.className = 'download-manager'; jobs.setAttribute('aria-label', 'Download manager');
|
||||
|
||||
@@ -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"/>',
|
||||
add:'<path d="M12 5v14M5 12h14"/>', close:'<path d="m6 6 12 12M18 6 6 18"/>',
|
||||
search:'<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
|
||||
save:'<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"/>',
|
||||
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"/>',
|
||||
@@ -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"/>',
|
||||
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) {
|
||||
const mapping=bento?{...glyphs,'📺':'television','⏱':'clock','⇅':'settings','📌':'pin','🎧':'music','📚':'folder'}:glyphs;
|
||||
const prefix = text.match(/^\s*/)[0], rest = text.slice(prefix.length);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const test=require('node:test'),assert=require('node:assert/strict');
|
||||
const {leadingGlyph}=require('./glass-controls.js');
|
||||
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.equal(leadingGlyph('A song with 🔍 in its title'),null);
|
||||
assert.equal(leadingGlyph('Clear all'),null);
|
||||
|
||||
@@ -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" 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="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="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="saved"><svg class="media-icon media-nav-icon" data-media-icon="offline" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-offline"/></svg> Saved</button>
|
||||
<button class="nav-item" data-view="downloads"><svg class="media-icon media-nav-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-download"/></svg> Downloads <span id="navDlCount" class="nav-badge hidden">0</span></button>
|
||||
<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="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 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="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="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="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 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>
|
||||
<div id="npMoreHost" class="np-more-host" hidden>
|
||||
<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>
|
||||
<span class="notes-spacer"></span>
|
||||
<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 class="markers-section">
|
||||
<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>
|
||||
</button>
|
||||
<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>
|
||||
</button>
|
||||
<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 id="bnDlCount" class="bottom-nav-badge hidden">0</span>
|
||||
</button>
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
'use strict';
|
||||
const matches = (video, query) => !query || `${video.title || video.id} ${video.channel || ''}`.toLowerCase().includes(query.toLowerCase());
|
||||
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"/>'
|
||||
};
|
||||
function button(doc, kind, label, action) {
|
||||
const b = doc.createElement('button'); b.type = 'button';
|
||||
b.className = `saved-file-action${kind === 'delete' ? ' danger' : ''}`;
|
||||
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;
|
||||
}
|
||||
function actions(row, video, exportFile, deleteFile) {
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
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`;
|
||||
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>`;
|
||||
out.dataset.name = name;
|
||||
} catch (err) {
|
||||
@@ -98,7 +98,7 @@
|
||||
out.dataset.name = name;
|
||||
out.dataset.type = fmt === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
|
||||
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>
|
||||
${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) {
|
||||
@@ -176,7 +176,7 @@
|
||||
showModal('📺 Open in another player', body, [{ label: 'Close', primary: true, onClick: closeModal }]);
|
||||
const ok = await cached(meta.id);
|
||||
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;
|
||||
}
|
||||
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="infuse://x-callback-url/play?url=${encodeURIComponent(url)}">▶ Infuse</a>`
|
||||
: `<a class="np-btn" href="vlc://${escapeHtml(url)}">▶ VLC</a>`}
|
||||
<button class="np-btn" type="button" data-act="m3u">⤓ .m3u (this video)</button>
|
||||
${inList ? '<button class="np-btn" type="button" data-act="m3u-list">⤓ .m3u (whole list)</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">${MediaIcons.svg('download')} .m3u (whole list)</button>` : ''}
|
||||
</div>
|
||||
<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.'
|
||||
|
||||
@@ -91,7 +91,7 @@ function renderSidebar() {
|
||||
let offlineBadge = '';
|
||||
if (stats.total > 0) {
|
||||
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) {
|
||||
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 = '';
|
||||
if (stats.total > 0) {
|
||||
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) {
|
||||
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 btn = els.saveBtn;
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('saving', downloading.has(id));
|
||||
if (removing.has(id)) {
|
||||
setTile(btn, 'Removing…');
|
||||
btn.classList.remove('done');
|
||||
|
||||
@@ -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: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 {
|
||||
font-family: var(--display);
|
||||
font-weight: 700;
|
||||
@@ -1097,19 +1097,20 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
||||
display: none;
|
||||
align-items: center; justify-content: center;
|
||||
width: 18px; height: 18px;
|
||||
color: #7ee0a8;
|
||||
color: var(--accent-bright);
|
||||
font-size: 12px; font-weight: 700;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.85);
|
||||
}
|
||||
.card.cached .saved-badge { display: flex; }
|
||||
.card.downloading .saved-badge {
|
||||
display: flex;
|
||||
color: #ffc850;
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
color: var(--accent-bright);
|
||||
}
|
||||
.card.downloading .saved-badge::before { content: "⏳"; }
|
||||
.card.downloading .saved-badge { font-size: 0; }
|
||||
.card.downloading .saved-badge::before { font-size: 12px; }
|
||||
/* State changes reveal SVGs without rewriting badges or their handlers. */
|
||||
.saved-badge .badge-progress { display:none; }
|
||||
.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 */
|
||||
.dl-progress {
|
||||
@@ -4331,3 +4332,27 @@ html.ios-nozoom, html.ios-nozoom body { touch-action: manipulation; }
|
||||
|
||||
.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; }
|
||||
|
||||
/* 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; }
|
||||
|
||||
@@ -44,7 +44,7 @@ function renderNotesView() {
|
||||
// Most recently played first (history order), the rest after.
|
||||
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));
|
||||
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;
|
||||
if (!groups.length) {
|
||||
c.innerHTML = `
|
||||
@@ -130,8 +130,8 @@ function setListPaneHidden(hidden) {
|
||||
const LIST_ICONS = {
|
||||
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"/>',
|
||||
offline: '<path d="M12 4v11M7 10l5 5 5-5M5 20h14"/>',
|
||||
offlineDone: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
|
||||
offline: '<use href="#media-download"/>',
|
||||
offlineDone: '<use href="#media-offline"/>',
|
||||
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"/>',
|
||||
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 row = document.createElement('div');
|
||||
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('small').textContent = `${fmtBytes(it.size)}${it.height ? ' · ' + it.height + 'p' : ''} · ${it.online ? 'Online' : 'Offline'}`;
|
||||
row.querySelector('.ps-dl').onclick = () => exportToDevice(v);
|
||||
@@ -517,7 +517,7 @@ async function renderP2pList() {
|
||||
<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>
|
||||
<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>`;
|
||||
row.querySelector('.card-title').textContent = v.title;
|
||||
row.addEventListener('click', (e) => {
|
||||
@@ -550,7 +550,7 @@ async function renderSaved() {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'empty-state';
|
||||
empty.innerHTML = `
|
||||
<div class="empty-icon">💾</div>
|
||||
<div class="empty-icon">${MediaIcons.svg('offline')}</div>
|
||||
<h3 class="empty-title">Offline cache unavailable</h3>
|
||||
<p class="empty-desc">This build doesn't support saving videos for offline playback.</p>`;
|
||||
c.appendChild(empty);
|
||||
@@ -566,9 +566,9 @@ async function renderSaved() {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'empty-state';
|
||||
empty.innerHTML = `
|
||||
<div class="empty-icon">💾</div>
|
||||
<div class="empty-icon">${MediaIcons.svg('offline')}</div>
|
||||
<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');
|
||||
c.appendChild(empty);
|
||||
return;
|
||||
@@ -903,7 +903,7 @@ async function renderSettings() {
|
||||
<div class="set-group">
|
||||
<div class="set-group-title">Playlists</div>
|
||||
<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="importBtn" class="btn">Import playlists</button>
|
||||
<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="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="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>
|
||||
<input id="profileCredFileInput" type="file" accept=".json,application/json" hidden />
|
||||
</div>
|
||||
@@ -1336,7 +1336,7 @@ function renderCard(v, index, list) {
|
||||
<div class="thumb">
|
||||
<img loading="lazy" decoding="async" src="${escapeHtml(v.thumbnail || '')}" alt="" />
|
||||
${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>' : ''}
|
||||
</div>
|
||||
<div class="card-info">
|
||||
|
||||
Reference in New Issue
Block a user