Polish download action buttons in Classic and Glass Stage
This commit is contained in:
11
frontend/download-actions.css
Normal file
11
frontend/download-actions.css
Normal 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); }
|
||||||
24
frontend/download-actions.js
Normal file
24
frontend/download-actions.js
Normal 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);
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
2
playwright.download-actions.config.js
Normal file
2
playwright.download-actions.config.js
Normal 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 } });
|
||||||
41
tests/download-actions.spec.js
Normal file
41
tests/download-actions.spec.js
Normal 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']);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user