Polish download action buttons in Classic and Glass Stage

This commit is contained in:
Jonathan Sykes
2026-10-04 02:48:08 +08:00
parent 2042c9b6fc
commit 04b620e3c7
8 changed files with 89 additions and 17 deletions

View File

@@ -0,0 +1,11 @@
/* Shared quiet controls; use each layout's existing surface and focus tokens. */
:is(.dl-row,.download-job) .download-action { display:inline-flex; align-items:center; justify-content:center; flex:none; gap:8px; height:auto; min-height:44px; min-width:44px; padding:8px 14px; border:1px solid var(--line); border-radius:var(--radius-sm); background:var(--bg-2); color:var(--text); font:600 13px var(--ui); line-height:1.4; cursor:pointer; white-space:nowrap; }
:is(.dl-row,.download-job) .download-action:hover { background:var(--bg-3); border-color:var(--accent); }
:is(.dl-row,.download-job) .download-action.danger { color:var(--accent); }
:is(.dl-row,.download-job) .download-action:focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
:is(.dl-row,.download-job) .download-action:disabled { opacity:.5; cursor:not-allowed; }
.download-action-icon { width:20px; height:20px; flex:none; fill:none; stroke:currentColor; stroke-width:1.9; stroke-linecap:round; stroke-linejoin:round; pointer-events:none; }
html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action { border-radius:18px; border-color:var(--glass-line); background:var(--tile); color:var(--text-2); box-shadow:none; }
html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action:hover { border-color:var(--glass-line); background:var(--tile-hi); color:var(--text); }
html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action.danger { color:var(--accent-bright); }
html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action:focus-visible { outline-color:var(--accent-bright); }

View File

@@ -0,0 +1,24 @@
/* One action control for the Downloads page and Settings download manager. */
(function(root) {
'use strict';
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"/>',
pause: '<path d="M8 5v14M16 5v14"/>',
resume: '<path d="m9 5 10 7-10 7z"/>',
};
function create(doc, action, title) {
if (!paths[action]) throw new Error('Unknown download action');
const label = action[0].toUpperCase() + action.slice(1);
const button = doc.createElement('button'); button.type = 'button';
button.className = `dl-act download-action${action === 'cancel' ? ' danger' : ''}`;
button.setAttribute('aria-label', `${label} ${title}`);
const icon = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
icon.setAttribute('viewBox', '0 0 24 24'); icon.setAttribute('aria-hidden', 'true'); icon.setAttribute('focusable', 'false');
icon.classList.add('download-action-icon'); icon.innerHTML = paths[action];
const text = doc.createElement('span'); text.textContent = label;
button.append(icon, text); return button;
}
const api = { create };
if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.DownloadActions = api;
})(globalThis);

View File

@@ -1,6 +1,7 @@
/* Downloads page UI module. Grouped jobs, storage info, confirmed cancellation. */ /* Downloads page UI module. Grouped jobs, storage info, confirmed cancellation. */
(function (root) { (function (root) {
'use strict'; 'use strict';
const actionButtons = typeof module !== 'undefined' ? require('./download-actions.js') : root.DownloadActions;
const errors = typeof module !== 'undefined' ? require('./download-errors.js') : root.DownloadErrors; const errors = typeof module !== 'undefined' ? require('./download-errors.js') : root.DownloadErrors;
const bytes = n => { const bytes = n => {
@@ -222,10 +223,7 @@
acts.className = 'dl-actions'; acts.className = 'dl-actions';
if (isActive) { if (isActive) {
const pauseBtn = doc.createElement('button'); const pauseBtn = actionButtons.create(doc, 'pause', job.title || job.id);
pauseBtn.type = 'button';
pauseBtn.className = 'dl-act';
pauseBtn.textContent = 'Pause';
pauseBtn.setAttribute('aria-label', `Pause ${job.title || job.id}`); pauseBtn.setAttribute('aria-label', `Pause ${job.title || job.id}`);
pauseBtn.onclick = (e) => { pauseBtn.onclick = (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -234,10 +232,7 @@
}; };
acts.appendChild(pauseBtn); acts.appendChild(pauseBtn);
} else if (isPaused) { } else if (isPaused) {
const resumeBtn = doc.createElement('button'); const resumeBtn = actionButtons.create(doc, 'resume', job.title || job.id);
resumeBtn.type = 'button';
resumeBtn.className = 'dl-act';
resumeBtn.textContent = 'Resume';
resumeBtn.setAttribute('aria-label', `Resume ${job.title || job.id}`); resumeBtn.setAttribute('aria-label', `Resume ${job.title || job.id}`);
resumeBtn.onclick = (e) => { resumeBtn.onclick = (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -246,10 +241,7 @@
}; };
acts.appendChild(resumeBtn); acts.appendChild(resumeBtn);
} else if (isFailed) { } else if (isFailed) {
const retryBtn = doc.createElement('button'); const retryBtn = actionButtons.create(doc, 'retry', job.title || job.id);
retryBtn.type = 'button';
retryBtn.className = 'dl-act';
retryBtn.textContent = 'Retry';
retryBtn.setAttribute('aria-label', `Retry ${job.title || job.id}`); retryBtn.setAttribute('aria-label', `Retry ${job.title || job.id}`);
retryBtn.onclick = (e) => { retryBtn.onclick = (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -259,10 +251,7 @@
acts.appendChild(retryBtn); acts.appendChild(retryBtn);
} }
const cancelBtn = doc.createElement('button'); const cancelBtn = actionButtons.create(doc, 'cancel', job.title || job.id);
cancelBtn.type = 'button';
cancelBtn.className = 'dl-act danger';
cancelBtn.textContent = 'Cancel';
cancelBtn.setAttribute('aria-label', `Cancel ${job.title || job.id}`); cancelBtn.setAttribute('aria-label', `Cancel ${job.title || job.id}`);
cancelBtn.onclick = (e) => { cancelBtn.onclick = (e) => {
e.stopPropagation(); e.stopPropagation();

View File

@@ -1,6 +1,7 @@
/* Download controls and device storage accounting. */ /* Download controls and device storage accounting. */
(function(root) { (function(root) {
'use strict'; 'use strict';
const actionButtons = typeof module !== 'undefined' ? require('./download-actions.js') : root.DownloadActions;
const errors = typeof module !== 'undefined' ? require('./download-errors.js') : root.DownloadErrors; const errors = typeof module !== 'undefined' ? require('./download-errors.js') : root.DownloadErrors;
const bytes = n => n >= 1073741824 ? `${(n / 1073741824).toFixed(1)} GB` : `${(n / 1048576).toFixed(1)} MB`; const bytes = n => n >= 1073741824 ? `${(n / 1073741824).toFixed(1)} GB` : `${(n / 1048576).toFixed(1)} MB`;
function breakdown(files, playlists) { function breakdown(files, playlists) {
@@ -29,7 +30,7 @@
const progress = doc.createElement('progress'); progress.max = job.progress?.total || 1; if (job.progress?.total) progress.value = job.progress.received || 0; progress.setAttribute('aria-label', `Saving ${job.title || job.id}`); const progress = doc.createElement('progress'); progress.max = job.progress?.total || 1; if (job.progress?.total) progress.value = job.progress.received || 0; progress.setAttribute('aria-label', `Saving ${job.title || job.id}`);
const actions = doc.createElement('div'); const actions = doc.createElement('div');
for (const action of [...(['active', 'queued'].includes(job.status) ? ['pause'] : [job.status === 'failed' ? 'retry' : 'resume']), 'cancel']) { for (const action of [...(['active', 'queued'].includes(job.status) ? ['pause'] : [job.status === 'failed' ? 'retry' : 'resume']), 'cancel']) {
const button = doc.createElement('button'); button.type = 'button'; button.textContent = action[0].toUpperCase() + action.slice(1); button.setAttribute('aria-label', `${button.textContent} ${job.title || job.id}`); button.onclick = () => { adapter.action(job.id, action); signature = ''; paint(); }; actions.append(button); const button = actionButtons.create(doc, action, job.title || job.id); button.onclick = () => { adapter.action(job.id, action); signature = ''; paint(); }; actions.append(button);
} }
row.append(title, status, progress, actions); row.append(title, status, progress, actions);
if (job.error) errors.appendDetails(row, job.error); if (job.error) errors.appendDetails(row, job.error);

View File

@@ -37,6 +37,7 @@
<link rel="stylesheet" href="theme-glass-panel.css" /> <link rel="stylesheet" href="theme-glass-panel.css" />
<link rel="stylesheet" href="fullscreen-orientation.css" /> <link rel="stylesheet" href="fullscreen-orientation.css" />
<link rel="stylesheet" href="related-videos.css" /> <link rel="stylesheet" href="related-videos.css" />
<link rel="stylesheet" href="download-actions.css" />
</head> </head>
<body> <body>
<div class="app"> <div class="app">
@@ -670,6 +671,7 @@
<script src="direct-protocol.js"></script> <script src="direct-protocol.js"></script>
<script src="direct-stream.js"></script> <script src="direct-stream.js"></script>
<script src="direct-media.js"></script> <script src="direct-media.js"></script>
<script src="download-actions.js"></script>
<script src="download-errors.js"></script> <script src="download-errors.js"></script>
<script src="downloads.js"></script> <script src="downloads.js"></script>
<script src="downloads-page.js"></script> <script src="downloads-page.js"></script>

View File

@@ -96,6 +96,8 @@ const SHELL = [
'/audio-continuity.js', '/audio-continuity.js',
'/fullscreen-orientation.js', '/fullscreen-orientation.js',
'/fullscreen-orientation.css', '/fullscreen-orientation.css',
'/download-actions.js',
'/download-actions.css',
'/download-errors.js', '/download-errors.js',
'/downloads.js', '/downloads.js',
'/downloads-page.js', '/downloads-page.js',

View File

@@ -0,0 +1,2 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({ testDir: './tests', testMatch: /download-actions\.spec\.js/, timeout: 30000, workers: 1, projects: [{ name: 'chromium', use: { browserName: 'chromium' } }, { name: 'webkit', use: { browserName: 'webkit' } }], use: { baseURL: 'http://localhost:8104', serviceWorkers: 'block' }, webServer: { command: 'npx serve frontend -l 8104 --no-clipboard', port: 8104 } });

View File

@@ -0,0 +1,41 @@
const { test, expect } = require('@playwright/test');
const { openClassic } = require('./helpers/classic-fixture');
const { mkdirSync } = require('node:fs');
const before = process.env.CAPTURE_BEFORE === '1';
for (const layout of ['classic', 'glass-stage']) for (const width of [390, 1440]) for (const screen of ['downloads', 'settings']) test(`${layout} ${width} ${screen}: failed download actions`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 844 });
await openClassic(page);
await page.evaluate(({ layout, screen }) => {
data.settings.layout = layout; applyAppearance();
window.actionCalls = [];
Downloads.configure({ slots: () => 4, files: async () => [], playlists: () => [], jobs: () => [{ id: 'aaaaaaaaaaa', title: 'A long worship recording with piano, strings and choir — evening session', channel: 'Studio', thumbnail: '/fixture-cover.svg', status: 'failed', error: 'ERROR: [youtube] Video unavailable' }], action: (id, action) => actionCalls.push([id, action]) });
API.cacheList = async () => ({ ok: true, items: [], total: 0 });
view = { type: screen }; render();
}, { layout, screen });
if (screen === 'settings') await page.getByRole('button', { name: /Downloads & storage/ }).click();
const retry = page.getByRole('button', { name: /^Retry A long/ });
const cancel = page.getByRole('button', { name: /^Cancel A long/ });
await expect(retry).toBeVisible(); await expect(cancel).toBeVisible();
await retry.scrollIntoViewIfNeeded();
if (before) {
mkdirSync('/home/josh/deliverables/ytplayer-done12', { recursive: true });
await page.screenshot({ path: `/home/josh/deliverables/ytplayer-done12/before-${layout}-${width}-${screen}-${info.project.name}.png` }); return;
}
for (const button of [retry, cancel]) {
await expect(button.locator('svg')).toHaveAttribute('aria-hidden', 'true');
const rect = await button.boundingBox(); expect(rect.height).toBeGreaterThanOrEqual(44); expect(rect.width).toBeGreaterThanOrEqual(44);
await expect(button.locator('span')).toHaveText(button === retry ? 'Retry' : 'Cancel');
}
const a = await retry.boundingBox(), b = await cancel.boundingBox();
expect(a.x + a.width <= b.x || a.y + a.height <= b.y).toBe(true);
const visual = await retry.evaluate(el => { const s = getComputedStyle(el), probe = document.createElement('span'); probe.style.backgroundColor = getComputedStyle(document.documentElement).getPropertyValue(document.documentElement.dataset.layout === 'glass-stage' ? '--tile' : '--bg-2').trim(); document.body.append(probe); const expected = getComputedStyle(probe).backgroundColor; probe.remove(); return { radius: s.borderRadius, background: s.backgroundColor, expected }; });
expect(visual.radius).toBe(layout === 'glass-stage' ? '18px' : '8px');
expect(visual.background).toBe(visual.expected);
await page.keyboard.press('Tab'); await retry.focus(); expect(await retry.evaluate(el => getComputedStyle(el).outlineStyle)).not.toBe('none');
mkdirSync('/home/josh/deliverables/ytplayer-done12', { recursive: true });
await page.screenshot({ path: `/home/josh/deliverables/ytplayer-done12/after-${layout}-${width}-${screen}-${info.project.name}.png` });
await retry.click(); expect(await page.evaluate(() => actionCalls)).toContainEqual(['aaaaaaaaaaa', 'retry']);
await cancel.click();
if (screen === 'downloads') await page.locator('.modal').getByRole('button', { name: 'Cancel save', exact: true }).click();
expect(await page.evaluate(() => actionCalls)).toContainEqual(['aaaaaaaaaaa', 'cancel']);
});