feat: improve queue card readability and detail controls
Windows CI / verify (push) Canceled after 0s
Windows CI / verify (push) Canceled after 0s
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Rework queue cards with two-line titles, clearer status badges, larger dates below the progress bar on the right, and 32-pixel remove and retry buttons. Keep paused progress visible and wrap long download errors.
|
||||
- Expand or collapse queue details by double-clicking the card surface or using the dedicated keyboard-accessible toggle. Add isolated queue-card checks for both languages and themes.
|
||||
- Resolve the repository icon during Windows hot development even when the branded Electron executable reports itself as packaged, preventing a startup crash.
|
||||
|
||||
## 1.0.19 - 2026-08-14
|
||||
|
||||
@@ -18,6 +18,8 @@ Am 6. September 2026 nach einem Festplatten-Reset aus den vorhandenen Remote-Rep
|
||||
|
||||
## Letzte Änderungen
|
||||
|
||||
- 6. September 2026: Queue-Cards überarbeitet. Titel mit bis zu zwei Zeilen in 13 px; Datum von 10 auf 12 px vergrößert und rechts unter den Fortschrittsbalken gesetzt. Status als beschriftetes Badge, Entfernen/Wiederholen mit 32 × 32 px Klickfläche, Details und Fortschritt in 12 px, keine Kartenschrift unter 10 px. Wartende Einträge ohne doppelte Statuszeile, pausierte Downloads mit erhaltenem Prozentstand, längere Fehler umbrechend.
|
||||
- Details lassen sich per Doppelklick auf die freie Kartenfläche einschließlich Titel, Datum und Balken umschalten. Ein eigener Pfeil unterstützt Einzelklick, Enter und Leertaste; Schaltflächen lösen keine zusätzliche Kartenaktion aus. Aufklappen aktualisiert die vorhandenen Elemente und erhält den Tastaturfokus.
|
||||
- 6. September 2026: Windows-Hot-Dev-Start repariert. Die umbenannte Electron-EXE meldet `app.isPackaged` auch im Entwicklungsbetrieb als wahr; die Symbolauflösung berücksichtigt nun ausdrücklich `TWITCH_VOD_MANAGER_DEV` und verwendet das vorhandene `build/icon.ico`.
|
||||
- 1.0.19: Streamlink und FFmpeg werden nach der Installation automatisch im Hintergrund eingerichtet; die Queue zeigt die Vorbereitung der Download-Werkzeuge an. Außerdem wurden instabile Electron-Smoke-Tests korrigiert.
|
||||
- 1.0.18: Reparaturen bei Werkzeuginstallation und Windows-Kurzpfaden, robustere Download- und Wiederherstellungsabläufe, Verbesserungen an Updates, Diagnosen, Queue, Streamer-Auswahl und Video-Cutter sowie zusätzliche isolierte Prüfungen.
|
||||
@@ -43,6 +45,7 @@ npm run dev
|
||||
- `npm run dev`: Entwicklungsstart mit Hot Reload; `npm start`: einmaliger Entwicklungsstart.
|
||||
- Entwicklungsdaten liegen isoliert in `.dev-program-data/` und `.dev-user-data/`; beide Ordner sind ignoriert.
|
||||
- Basisprüfungen: `npm run build`, `npm run lint`, `npm run security:check`, `npm run test:unit`.
|
||||
- Queue-UI-Prüfung: `npm run test:e2e:queue-cards`; prüft sechs Zustände in Deutsch/Englisch und Hell/Dunkel sowie Doppelklick, Tastatur, Fortschrittsaktualisierung und Entfernen. Screenshots liegen ignoriert in `tmp_queue-card-artifacts/`. Auch in `test:e2e:focused` und dessen CI-Vertrag eingebunden.
|
||||
- Isolierter Anwendungstest: `npm run test:e2e`.
|
||||
- Umfassende Release-Prüfung: `npm run test:e2e:release`; Windows-Paket: `npm run dist:win`.
|
||||
- Relevante Struktur: `src/main.ts`, `src/main/`, `src/renderer-*.ts`, `src/index.html`, `src/styles*.css`, `src/workspace*.css`, `scripts/` und `build/`.
|
||||
@@ -77,3 +80,11 @@ npm run dev
|
||||
- Streamlink 8.4.0 im Entwicklungsverzeichnis eingerichtet und per `--version` geprüft. Der vorhandene Managed-Tool-Installer hat Archiv- und EXE-Prüfsummen verifiziert; zum Entpacken wurde lokal PowerShell 7 mit `Expand-Archive -LiteralPath` und über Umgebungsvariablen übergebenen Pfaden verwendet.
|
||||
- Der automatische Installationsversuch mit Windows PowerShell meldete zuvor `required-executable-missing`. Die lokale Einrichtung ist behoben; die Ursache im allgemeinen Entpackablauf ist noch offen.
|
||||
- Anschließender Hot-Reload-Neustart tatsächlich ausgelöst und geprüft: Das neue Anwendungsfenster reagiert, und der reale Preflight meldet Internet, Streamlink, FFmpeg, FFprobe und beschreibbares Download-Verzeichnis als erfolgreich. Keine Live-Downloads gestartet.
|
||||
|
||||
6. September 2026, Queue-Card-Rework (unveröffentlicht, Version weiterhin 1.0.19):
|
||||
|
||||
- Build, alle 630 Unit-Tests, isolierte Queue-Card-Prüfung und umfassender Workspace-UI-Test erfolgreich; keine gemeldeten Laufzeitfehler.
|
||||
- Queue-Screenshots in Hell und Dunkel visuell geprüft; Datum rechts unter dem Balken, ausreichend große Aktionsflächen und keine horizontale Überläufe.
|
||||
- Lint ohne Fehler bei den bekannten 15 Warnungen. Lokale Entwicklungsdaten und installierte Drittanbieter-Werkzeuge sind nun ausdrücklich von ESLint ausgeschlossen; vier Lint-Konfigurationstests erfolgreich.
|
||||
- Security-/Public-Manifest-, CI- und E2E-Isolationsprüfungen erfolgreich. Der neue UI-Test steht in der öffentlichen Datei-Allowlist; Entwicklungsdaten und Screenshots bleiben außerhalb von Git.
|
||||
- Hot-Dev bleibt aktiv. Keine Veröffentlichung und kein Live-Download für diese UI-Änderung.
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import globals from 'globals';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['dist/**', 'release/**', 'node_modules/**', 'tmp_*/**', 'docs/**']
|
||||
ignores: ['dist/**', 'release/**', 'node_modules/**', '.dev-program-data/**', '.dev-user-data/**', 'tmp_*/**', 'docs/**']
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
+2
-1
@@ -22,12 +22,13 @@
|
||||
"test:e2e:guide": "node scripts/smoke-test-template-guide.js",
|
||||
"test:e2e:full": "node scripts/smoke-test-full.js",
|
||||
"test:e2e:workspace-ui": "npm run build && node scripts/smoke-test-workspace-ui.js",
|
||||
"test:e2e:queue-cards": "npm run build && node scripts/smoke-test-queue-cards.js",
|
||||
"test:e2e:cutter": "npm run build && node scripts/smoke-test-cutter.js",
|
||||
"test:e2e:cutter-matrix": "npm run build && node scripts/smoke-test-cutter-media-matrix.js",
|
||||
"test:e2e:isolation": "node scripts/smoke-test-e2e-isolation-contract.js",
|
||||
"test:capability-contract": "node scripts/smoke-test-file-capability-contract.js",
|
||||
"test:e2e:settings-autosave": "node scripts/smoke-test-settings-autosave.js",
|
||||
"test:e2e:focused": "npm run test:e2e:isolation && npm run test:e2e:workspace-ui",
|
||||
"test:e2e:focused": "npm run test:e2e:isolation && npm run test:e2e:workspace-ui && npm run test:e2e:queue-cards",
|
||||
"test:security": "node --test scripts/security-check.test.js",
|
||||
"test:lint-config": "node --test scripts/lint-config.test.mjs",
|
||||
"test:ci-contract": "node scripts/smoke-test-ci-contract.js",
|
||||
|
||||
@@ -7,6 +7,12 @@ import { ESLint } from 'eslint';
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const eslint = new ESLint({ cwd: root, overrideConfigFile: path.join(root, 'eslint.config.mjs') });
|
||||
|
||||
test('ignores local development data and bundled third-party tools', async () => {
|
||||
for (const file of ['.dev-program-data/tools/worker.js', '.dev-user-data/cache/script.js']) {
|
||||
assert.equal(await eslint.isPathIgnored(path.join(root, file)), true);
|
||||
}
|
||||
});
|
||||
|
||||
async function messagesFor(source, filePath) {
|
||||
const [result] = await eslint.lintText(source, { filePath: path.join(root, filePath) });
|
||||
return result.messages;
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"scripts/smoke-test-template-guide.js",
|
||||
"scripts/smoke-test-update-version-logic.js",
|
||||
"scripts/smoke-test-workspace-ui.js",
|
||||
"scripts/smoke-test-queue-cards.js",
|
||||
"scripts/smoke-test.js",
|
||||
"src/index.html",
|
||||
"src/main/domain/app-state-store.test.ts",
|
||||
|
||||
@@ -553,7 +553,8 @@ const requiredScripts = {
|
||||
'test:live:twitch': 'node scripts/smoke-test-live-integration.js twitch',
|
||||
'test:live:updater-postpublish': 'node scripts/smoke-test-live-integration.js updater',
|
||||
'test:e2e:cutter-matrix': 'npm run build && node scripts/smoke-test-cutter-media-matrix.js',
|
||||
'test:e2e:focused': 'npm run test:e2e:isolation && npm run test:e2e:workspace-ui',
|
||||
'test:e2e:focused': 'npm run test:e2e:isolation && npm run test:e2e:workspace-ui && npm run test:e2e:queue-cards',
|
||||
'test:e2e:queue-cards': 'npm run build && node scripts/smoke-test-queue-cards.js',
|
||||
'test:packaged-launch': 'node scripts/smoke-test-packaged-launch.js',
|
||||
'test:installer': 'node scripts/smoke-test-installer.js',
|
||||
'dist:ci': 'electron-builder --win nsis'
|
||||
|
||||
@@ -8,7 +8,8 @@ const SMOKE_FILES = [
|
||||
'scripts/smoke-test-template-guide.js',
|
||||
'scripts/smoke-test-full.js',
|
||||
'scripts/smoke-test-settings-autosave.js',
|
||||
'scripts/smoke-test-workspace-ui.js'
|
||||
'scripts/smoke-test-workspace-ui.js',
|
||||
'scripts/smoke-test-queue-cards.js'
|
||||
];
|
||||
|
||||
function inspectSources() {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { _electron: electron } = require('playwright');
|
||||
const { createE2eEnvironment, getElectronLaunchOptions, verifyE2eIsolation, installOfflineFixtures, cleanupE2eEnvironment } = require('./e2e-test-environment');
|
||||
|
||||
async function main() {
|
||||
const environment = createE2eEnvironment('queue-cards');
|
||||
const artifacts = path.resolve(__dirname, '../tmp_queue-card-artifacts');
|
||||
fs.mkdirSync(artifacts, { recursive: true });
|
||||
let app;
|
||||
try {
|
||||
app = await electron.launch(getElectronLaunchOptions(environment));
|
||||
const win = await app.firstWindow();
|
||||
await verifyE2eIsolation(app, win, environment);
|
||||
await installOfflineFixtures(app);
|
||||
const errors = [];
|
||||
win.on('pageerror', (error) => errors.push(String(error)));
|
||||
await win.waitForFunction(() => typeof window.showTab === 'function' && typeof window.changeLanguage === 'function');
|
||||
await win.evaluate(() => {
|
||||
showTab('vods');
|
||||
const panel = document.querySelector('[data-context-for="vods"]');
|
||||
panel.dataset.vodsLayout = 'tabs';
|
||||
panel.dataset.vodsWorkspace = 'queue';
|
||||
queue = [
|
||||
{ id: 'pending', status: 'pending', title: 'Ein langer Streamtitel mit gut lesbaren Download-Details und einer zweiten Zeile', progress: 0 },
|
||||
{ id: 'running', status: 'downloading', title: 'Sommerstream am See – gemeinsam unterwegs', progress: 42, progressStatus: 'Video wird heruntergeladen', speed: '12.5 MB/s', eta: '08:24' },
|
||||
{ id: 'paused', status: 'paused', title: 'Community-Abend mit Freunden', progress: 23 },
|
||||
{ id: 'error', status: 'error', title: 'Ein weiterer langer Streamtitel mit einer Fehlermeldung', progress: 10, last_error: 'Die Verbindung wurde unterbrochen. Bitte erneut versuchen.' },
|
||||
{ id: 'completed', status: 'completed', title: 'Highlights vom Wochenende', progress: 100, outputFiles: ['C:\\fixture\\highlights.mp4'] },
|
||||
{ id: 'live', status: 'downloading', title: 'Live aus dem Studio', progress: 0, isLive: true, recordingHealth: 'ok', progressStatus: 'Live-Aufnahme läuft' }
|
||||
].map((item) => ({ url: `https://example.invalid/${item.id}`, date: '2026-09-06T10:00:00Z', streamer: 'Beispielkanal', duration_str: '2h 34m', ...item }));
|
||||
renderQueue();
|
||||
});
|
||||
for (const theme of ['twitch', 'light']) {
|
||||
for (const language of ['de', 'en']) {
|
||||
await win.setViewportSize({ width: 1280, height: 900 });
|
||||
await win.evaluate(({ theme, language }) => {
|
||||
document.body.className = `theme-${theme}`;
|
||||
changeLanguage(language);
|
||||
}, { theme, language });
|
||||
const layout = await win.locator('#queueList').evaluate((list) => [...list.querySelectorAll('.queue-item')].map((item) => {
|
||||
const rect = (selector) => item.querySelector(selector).getBoundingClientRect();
|
||||
const date = rect('.queue-date');
|
||||
const bar = rect('.queue-progress-wrap');
|
||||
const remove = rect('.remove');
|
||||
return {
|
||||
id: item.dataset.id,
|
||||
dateBelow: date.top >= bar.bottom,
|
||||
dateRight: Math.abs(date.right - bar.right) <= 1,
|
||||
dateSize: parseFloat(getComputedStyle(item.querySelector('.queue-date')).fontSize),
|
||||
removeSize: Math.min(remove.width, remove.height),
|
||||
overflow: item.scrollWidth - item.clientWidth,
|
||||
tinyText: [...item.querySelectorAll('*')].filter((element) => element.textContent.trim() && element.getBoundingClientRect().height > 0 && parseFloat(getComputedStyle(element).fontSize) < 10).length,
|
||||
};
|
||||
}));
|
||||
for (const card of layout) {
|
||||
assert(card.dateBelow && card.dateRight, `Date placement: ${JSON.stringify(card)}`);
|
||||
assert(card.dateSize >= 12 && card.removeSize >= 32 && card.tinyText === 0, `Readability: ${JSON.stringify(card)}`);
|
||||
assert(card.overflow <= 1, `Card overflow: ${JSON.stringify(card)}`);
|
||||
}
|
||||
await win.locator('.queue-section').screenshot({ path: path.join(artifacts, `queue-${theme}-${language}.png`) });
|
||||
}
|
||||
}
|
||||
const card = win.locator('.queue-item[data-id="pending"]');
|
||||
const toggle = card.locator('[data-queue-action="details"]');
|
||||
for (const selector of ['.title', '.queue-date', '.queue-progress-wrap', '.queue-status-label']) {
|
||||
await card.locator(selector).dblclick();
|
||||
assert.equal(await toggle.getAttribute('aria-expanded'), 'true', `${selector} expands`);
|
||||
await card.locator(selector).dblclick();
|
||||
assert.equal(await toggle.getAttribute('aria-expanded'), 'false', `${selector} collapses`);
|
||||
}
|
||||
await card.dblclick({ position: { x: 4, y: 4 } });
|
||||
assert.equal(await toggle.getAttribute('aria-expanded'), 'true', 'Card padding expands');
|
||||
await toggle.click();
|
||||
assert.equal(await toggle.getAttribute('aria-expanded'), 'false', 'Toggle collapses');
|
||||
await toggle.dblclick();
|
||||
assert.equal(await toggle.getAttribute('aria-expanded'), 'true', 'Double-clicking toggle changes state only once');
|
||||
await toggle.focus();
|
||||
await win.keyboard.press('Space');
|
||||
assert.equal(await toggle.getAttribute('aria-expanded'), 'false', 'Space collapses');
|
||||
await win.keyboard.press('Enter');
|
||||
assert.equal(await toggle.getAttribute('aria-expanded'), 'true', 'Enter expands');
|
||||
assert(await toggle.evaluate((button) => document.activeElement === button), 'Toggle retains keyboard focus');
|
||||
await win.evaluate(() => {
|
||||
queue.find((item) => item.id === 'running').progress = 61;
|
||||
renderQueue();
|
||||
});
|
||||
assert.equal(await toggle.getAttribute('aria-expanded'), 'true', 'Expansion survives queue rerender');
|
||||
assert.equal(await win.locator('[data-id="running"] .queue-progress-wrap').getAttribute('aria-valuenow'), '61');
|
||||
await app.evaluate(({ ipcMain }) => {
|
||||
ipcMain.removeHandler('remove-from-queue');
|
||||
ipcMain.handle('remove-from-queue', async (_event, id) => {
|
||||
if (id !== 'pending') throw new Error('Unexpected removal target');
|
||||
return [];
|
||||
});
|
||||
});
|
||||
await card.locator('.remove svg path').click();
|
||||
await win.waitForFunction(() => document.querySelectorAll('#queueList .queue-item').length === 0);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(JSON.stringify({ failures: [], themes: 2, languages: 2, states: 6, interactions: 'passed', artifacts }));
|
||||
} finally {
|
||||
if (app) await app.close();
|
||||
cleanupE2eEnvironment(environment);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
@@ -1265,7 +1265,8 @@ async function run() {
|
||||
progressInfoText: progressInfo?.textContent || '',
|
||||
progressInfoBelowBar: Boolean(progressWrap && progressInfo && progressInfo.getBoundingClientRect().top >= progressWrap.getBoundingClientRect().bottom),
|
||||
progressIsGreen,
|
||||
statusRemoveCenterDelta: statusRect && removeRect ? Math.abs((statusRect.top + statusRect.bottom) / 2 - (removeRect.top + removeRect.bottom) / 2) : null,
|
||||
statusBelowBar: Boolean(statusRect && progressWrap && statusRect.top >= progressWrap.getBoundingClientRect().bottom),
|
||||
removeSize: removeRect ? Math.min(removeRect.width, removeRect.height) : 0,
|
||||
reservedSelectionControls: document.querySelectorAll('#queueList .queue-selector, #queueList .queue-selector-placeholder').length,
|
||||
mergeSelectActionVisible,
|
||||
selectionOrderText: selectionOrder?.textContent?.trim() || '',
|
||||
@@ -1290,7 +1291,8 @@ async function run() {
|
||||
check(dynamicQueue.progressInfoText.includes('42.0%') && dynamicQueue.progressInfoText.includes('55.8 MB/s') && dynamicQueue.progressInfoText.includes('1/1'), `Queue progress details are incomplete: ${dynamicQueue.progressInfoText}`);
|
||||
check(dynamicQueue.progressInfoBelowBar, 'Queue progress details are not positioned below the progress bar');
|
||||
check(dynamicQueue.progressIsGreen, 'Running queue progress is not green');
|
||||
check(dynamicQueue.statusRemoveCenterDelta !== null && dynamicQueue.statusRemoveCenterDelta <= 1, `Queue status and remove action are not vertically aligned: ${dynamicQueue.statusRemoveCenterDelta}`);
|
||||
check(dynamicQueue.statusBelowBar, 'Queue status is not positioned below the progress bar');
|
||||
check(dynamicQueue.removeSize >= 32, `Queue remove target is too small: ${dynamicQueue.removeSize}`);
|
||||
check(dynamicQueue.reservedSelectionControls === 0, `Queue still reserves ${dynamicQueue.reservedSelectionControls} in-flow selection controls`);
|
||||
check(dynamicQueue.mergeSelectActionVisible, 'Pending Queue item has no merge selection action in its context menu');
|
||||
check(dynamicQueue.selectionOrderText === '1' && dynamicQueue.selectionOrderPosition === 'absolute', `Merge selection order is not a floating badge: ${dynamicQueue.selectionOrderText}/${dynamicQueue.selectionOrderPosition}`);
|
||||
|
||||
@@ -378,6 +378,8 @@ const UI_TEXT_DE = {
|
||||
statusRunning: 'Läuft',
|
||||
statusPaused: 'Pausiert',
|
||||
statusWaiting: 'Wartet',
|
||||
toggleDetails: 'Download-Details ein-/ausklappen',
|
||||
removeItem: 'Aus der Queue entfernen',
|
||||
progressError: 'Fehler',
|
||||
progressReady: 'Bereit',
|
||||
progressLoading: 'Lade...',
|
||||
|
||||
@@ -378,6 +378,8 @@ const UI_TEXT_EN = {
|
||||
statusRunning: 'Running',
|
||||
statusPaused: 'Paused',
|
||||
statusWaiting: 'Waiting',
|
||||
toggleDetails: 'Expand or collapse download details',
|
||||
removeItem: 'Remove from queue',
|
||||
progressError: 'Error',
|
||||
progressReady: 'Ready',
|
||||
progressLoading: 'Loading...',
|
||||
|
||||
@@ -408,7 +408,7 @@ describe('renderer queue production paths', () => {
|
||||
|
||||
expect(metrics({ status: 'downloading', progress: 12.34, speed: '4 MB/s', eta: '2m' })).toBe('12.3% | 4 MB/s | 2m');
|
||||
expect(metrics({ status: 'pending', progress: 12.34, speed: '4 MB/s', eta: '2m' })).toBe('');
|
||||
expect(metrics({ status: 'paused', progress: 12.34, speed: '4 MB/s', eta: '2m' })).toBe('');
|
||||
expect(metrics({ status: 'paused', progress: 12.34, speed: '4 MB/s', eta: '2m' })).toBe('12.3%');
|
||||
expect(metrics({ status: 'error', progress: 12.34, speed: '4 MB/s', eta: '2m' })).toBe('');
|
||||
expect(metrics({ status: 'completed', progress: 100, speed: '4 MB/s', eta: '2m' })).toBe('100%');
|
||||
});
|
||||
|
||||
+29
-12
@@ -135,8 +135,17 @@ function initQueueActions(): void {
|
||||
list.addEventListener('click', (event: MouseEvent) => {
|
||||
const control = resolveQueueControl(event.target);
|
||||
if (!control || !list.contains(control)) return;
|
||||
if (control.dataset.queueAction === 'details' && event.detail > 1) return;
|
||||
void activateQueueControl(control);
|
||||
});
|
||||
list.addEventListener('dblclick', (event: MouseEvent) => {
|
||||
if (!(event.target instanceof Element)) return;
|
||||
if (event.target.closest('button, a, input, select, textarea, [role="button"], [data-queue-action], [data-queue-file-action]')) return;
|
||||
const item = event.target.closest<HTMLElement>('.queue-item');
|
||||
if (!item?.dataset.id || !list.contains(item)) return;
|
||||
event.preventDefault();
|
||||
toggleQueueDetails(item.dataset.id);
|
||||
});
|
||||
list.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
const control = resolveQueueControl(event.target);
|
||||
@@ -457,7 +466,7 @@ function getQueueProgressStatusText(item: QueueItem): string {
|
||||
function getQueueProgressMetricsText(item: QueueItem): string {
|
||||
const parts: string[] = [];
|
||||
if (item.status === 'completed') parts.push('100%');
|
||||
if (item.status === 'downloading' && item.progress > 0) {
|
||||
if ((item.status === 'downloading' || item.status === 'paused') && item.progress > 0) {
|
||||
parts.push(`${Math.max(0, Math.min(100, item.progress)).toFixed(1)}%`);
|
||||
}
|
||||
if (item.status === 'downloading' && item.speed) parts.push(item.speed);
|
||||
@@ -564,7 +573,10 @@ function toggleQueueDetails(id: string): void {
|
||||
} else {
|
||||
expandedQueueIds.add(id);
|
||||
}
|
||||
renderQueue();
|
||||
const item = Array.from(byId<HTMLElement>('queueList').querySelectorAll<HTMLElement>('.queue-item'))
|
||||
.find((candidate) => candidate.dataset.id === id);
|
||||
item?.querySelector('.queue-details')?.classList.toggle('expanded', expandedQueueIds.has(id));
|
||||
item?.querySelector('[data-queue-action="details"]')?.setAttribute('aria-expanded', String(expandedQueueIds.has(id)));
|
||||
}
|
||||
|
||||
function initQueueDragDrop(): void {
|
||||
@@ -627,7 +639,7 @@ function renderQueue(): void {
|
||||
|
||||
const list = byId('queueList');
|
||||
initQueueActions();
|
||||
byId('queueCount').textContent = String(queue.length);
|
||||
byId('queueCount').textContent = formatUiNumber(queue.length);
|
||||
const retryBtn = byId<HTMLButtonElement>('btnRetryFailed');
|
||||
const clearBtn = byId<HTMLButtonElement>('btnClear');
|
||||
const hasFailed = queue.some((item) => item.status === 'error' && !item.mergeRecoveryBlocked);
|
||||
@@ -683,26 +695,32 @@ function renderQueue(): void {
|
||||
? renderRecordingHealthBadge(item.recordingHealth)
|
||||
: '';
|
||||
const mergeMetaExtra = isMergeGroup
|
||||
? ` (${UI_TEXT.mergeGroup.metaLabel.replace('{count}', String(item.mergeGroup!.items.length))})`
|
||||
? escapeHtml(UI_TEXT.mergeGroup.metaLabel.replace('{count}', formatUiNumber(item.mergeGroup!.items.length)))
|
||||
: '';
|
||||
const detailsId = `queue-details-${itemIndex}`;
|
||||
|
||||
return `
|
||||
<div class="queue-item${isMergeGroup ? ' merge-group' : ''}${isSelected ? ' merge-selected' : ''}" draggable="${item.status === 'pending' ? 'true' : 'false'}" data-id="${escapeHtml(item.id)}">
|
||||
${isSelected ? `<span class="queue-selection-order" title="${selectionTitle}" aria-label="${selectionTitle}">${selectionPosition}</span>` : ''}
|
||||
<div class="status ${item.status}"></div>
|
||||
<div class="queue-main">
|
||||
<div class="queue-title-row">
|
||||
<div class="title" title="${safeTitle}" role="button" tabindex="0" aria-expanded="${expandedQueueIds.has(item.id) ? 'true' : 'false'}" aria-controls="${detailsId}" data-queue-action="details">${liveBadge}${healthBadge}${mergeIcon}${isClip}${safeTitle}</div>
|
||||
<div class="queue-status-label">${safeStatusLabel}</div>
|
||||
<span class="remove" role="button" tabindex="0" aria-label="${escapeHtml(UI_TEXT.streamers.removeAria)}" data-queue-action="remove">x</span>
|
||||
<div class="title" title="${safeTitle}">${liveBadge}${healthBadge}${mergeIcon}${isClip}${safeTitle}</div>
|
||||
${item.status === 'error' && !item.mergeRecoveryBlocked ? `<button class="queue-retry-btn" type="button" title="${escapeHtml(UI_TEXT.queue.retryItem)}" aria-label="${escapeHtml(UI_TEXT.queue.retryItem)}" data-queue-action="retry">↻</button>` : ''}
|
||||
<button class="remove" type="button" title="${escapeHtml(UI_TEXT.queue.removeItem)}" aria-label="${escapeHtml(UI_TEXT.queue.removeItem)}" data-queue-action="remove"><svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v5M14 11v5"/></svg></button>
|
||||
</div>
|
||||
<div class="queue-meta"><span class="queue-date">${safeDate}</span>${mergeMetaExtra}</div>
|
||||
${mergeMetaExtra ? `<div class="queue-meta">${mergeMetaExtra}</div>` : ''}
|
||||
<div class="queue-progress-wrap" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(progressValue)}" aria-label="${escapeHtml(safeStatusLabel)}">
|
||||
<div class="queue-progress-bar" style="width: ${progressValue}%;"></div>
|
||||
</div>
|
||||
<div class="queue-progress-info">
|
||||
<span class="queue-progress-status${progressStatusClass}">${safeProgressStatus}</span>
|
||||
<div class="queue-footer">
|
||||
<div class="queue-summary">
|
||||
<button class="queue-details-toggle" type="button" aria-label="${escapeHtml(UI_TEXT.queue.toggleDetails)}" title="${escapeHtml(UI_TEXT.queue.toggleDetails)}" aria-expanded="${expandedQueueIds.has(item.id) ? 'true' : 'false'}" aria-controls="${detailsId}" data-queue-action="details"><svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m7 10 5 5 5-5"/></svg></button>
|
||||
<span class="queue-status-badge" title="${safeProgressStatus}"><span class="status ${item.status}" aria-hidden="true"></span><span class="queue-status-label">${safeStatusLabel}</span></span>
|
||||
</div>
|
||||
<span class="queue-date">${safeDate}</span>
|
||||
</div>
|
||||
<div class="queue-progress-info${item.status === 'pending' || item.status === 'completed' ? ' is-hidden' : ''}">
|
||||
<span class="queue-progress-status${progressStatusClass}${item.status === 'paused' ? ' is-hidden' : ''}">${safeProgressStatus}</span>
|
||||
<span class="queue-progress-metrics">${safeProgressMetrics}</span>
|
||||
</div>
|
||||
<div class="queue-details${expandedQueueIds.has(item.id) ? ' expanded' : ''}" id="${detailsId}">
|
||||
@@ -713,7 +731,6 @@ function renderQueue(): void {
|
||||
${renderQueueItemFileActions(item)}
|
||||
</div>
|
||||
</div>
|
||||
${item.status === 'error' && !item.mergeRecoveryBlocked ? `<button class="queue-retry-btn" type="button" title="${escapeHtml(UI_TEXT.queue.retryItem)}" aria-label="${escapeHtml(UI_TEXT.queue.retryItem)}" data-queue-action="retry">↻</button>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('production style modules', () => {
|
||||
.replace(/\r\n/g, '\n'));
|
||||
const digest = createHash('sha256').update(content).digest('hex');
|
||||
|
||||
expect(digest).toBe('143fb0cc3e6c2ca3f04ded7e3b83175db424b34b6ca9ed4ef32e337b4a60a898');
|
||||
expect(digest).toBe('2ed5d8cce6dca293fba4f83a0347950ee887c01cafb9febfb345842a6c569cd5');
|
||||
});
|
||||
|
||||
test('derives the Windows hot-development executable version from package metadata', () => {
|
||||
@@ -57,7 +57,7 @@ describe('production style modules', () => {
|
||||
test('animates queue progress only while downloading and visibly marks paused items', () => {
|
||||
const styles = readFileSync(join(__dirname, 'styles-workflows.css'), 'utf8');
|
||||
const baseShimmer = styles.match(/\.queue-progress-bar::after\s*\{([\s\S]*?)\}/)?.[1] ?? '';
|
||||
const activeShimmer = styles.match(/\.status\.downloading\s*~\s*\.queue-main\s+\.queue-progress-bar::after\s*\{([\s\S]*?)\}/)?.[1] ?? '';
|
||||
const activeShimmer = styles.match(/\.queue-item:has\(\.status\.downloading\)\s+\.queue-progress-bar::after\s*\{([\s\S]*?)\}/)?.[1] ?? '';
|
||||
const pausedStatus = styles.match(/\.queue-item\s+\.status\.paused\s*\{([\s\S]*?)\}/)?.[1] ?? '';
|
||||
|
||||
expect(baseShimmer).toMatch(/display:\s*none/);
|
||||
|
||||
+108
-38
@@ -93,7 +93,6 @@
|
||||
|
||||
.queue-item:has(.status.completed) {
|
||||
border-left-color: var(--success);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.queue-item .status {
|
||||
@@ -102,7 +101,7 @@
|
||||
border-radius: 50%;
|
||||
background: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
margin-top: 4px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.queue-item .status.pending { background: var(--warning); box-shadow: 0 0 6px rgba(255, 167, 38, 0.5); }
|
||||
@@ -118,10 +117,15 @@
|
||||
|
||||
.queue-item .title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.queue-detail-label {
|
||||
@@ -167,19 +171,21 @@
|
||||
.queue-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 16px;
|
||||
gap: 6px;
|
||||
min-height: 32px;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.queue-status-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.queue-meta {
|
||||
font-size: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
margin-bottom: 4px;
|
||||
@@ -188,11 +194,50 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.queue-footer,
|
||||
.queue-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.queue-footer {
|
||||
justify-content: space-between;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.queue-date {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.queue-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--text-secondary) 8%, transparent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.queue-status-badge:has(.status.pending) {
|
||||
background: color-mix(in srgb, var(--warning) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--warning) 25%, transparent);
|
||||
}
|
||||
|
||||
.queue-progress-wrap {
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: rgba(255,255,255,0.10);
|
||||
background: color-mix(in srgb, var(--text-secondary) 18%, transparent);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -215,7 +260,7 @@
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.status.downloading ~ .queue-main .queue-progress-bar::after {
|
||||
.queue-item:has(.status.downloading) .queue-progress-bar::after {
|
||||
display: block;
|
||||
animation: queue-progress-shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
@@ -227,21 +272,20 @@
|
||||
|
||||
.queue-progress-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 3px;
|
||||
font-size: 10px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 14px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.queue-progress-status,
|
||||
.queue-progress-metrics {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.queue-progress-status.is-starting {
|
||||
@@ -259,22 +303,51 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.queue-item .remove {
|
||||
.queue-item .remove,
|
||||
.queue-item .queue-retry-btn,
|
||||
.queue-details-toggle {
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 16px;
|
||||
flex: 0 0 32px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--error);
|
||||
opacity: 0.7;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 7px;
|
||||
font-size: 18px;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.queue-item .remove:hover {
|
||||
opacity: 1;
|
||||
color: var(--error);
|
||||
background: color-mix(in srgb, var(--error) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--error) 45%, transparent);
|
||||
}
|
||||
|
||||
.queue-details-toggle {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-basis: 28px;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.queue-details-toggle:hover,
|
||||
.queue-item .queue-retry-btn:hover {
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--text-secondary) 12%, transparent);
|
||||
}
|
||||
|
||||
.queue-details-toggle[aria-expanded="true"] svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.queue-details-toggle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.queue-item[draggable="true"] {
|
||||
@@ -291,10 +364,13 @@
|
||||
|
||||
.queue-details {
|
||||
display: none;
|
||||
font-size: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 0;
|
||||
word-break: break-all;
|
||||
margin-top: 8px;
|
||||
padding: 8px 0 2px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.queue-details.expanded {
|
||||
@@ -330,12 +406,6 @@
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 200, 83, 0.45);
|
||||
}
|
||||
|
||||
.queue-item .title:focus-visible {
|
||||
outline: none;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.45);
|
||||
}
|
||||
|
||||
.queue-item.merge-group {
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
@@ -780,7 +850,7 @@ input[type="checkbox"].vod-select-checkbox {
|
||||
display: inline-block;
|
||||
background: #ff4444;
|
||||
color: white;
|
||||
font-size: 9px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 1px 5px;
|
||||
|
||||
+2
-2
@@ -956,8 +956,8 @@ textarea:disabled,
|
||||
}
|
||||
|
||||
.context-sidebar .queue-item {
|
||||
margin: 3px 0;
|
||||
padding: 7px;
|
||||
margin: 0 0 8px;
|
||||
padding: 10px;
|
||||
background: var(--workspace-panel-raised);
|
||||
border: 1px solid var(--workspace-border);
|
||||
border-radius: var(--workspace-radius-small);
|
||||
|
||||
Reference in New Issue
Block a user