release: prepare Twitch VOD Manager 1.0.2

Redesign the seven-area desktop workspace with responsive navigation, searchable settings, localized controls, and accessible update states.

Harden Electron release coverage with fully isolated user data, download paths, offline fixtures, concurrency checks, and public-release manifest validation.
This commit is contained in:
Sucukdeluxe
2026-08-10 12:29:56 +02:00
parent dacd66fe1c
commit d599593966
29 changed files with 2388 additions and 725 deletions
+9
View File
@@ -1,5 +1,14 @@
# Changelog # Changelog
## 1.0.2 - 2026-08-10
- Redesigned the desktop workspace with compact top navigation, contextual sidebars and dedicated toolbars for all seven areas.
- Added Light, Dark and System appearance modes with improved contrast and responsive layouts from 1280 to 2048 pixels.
- Added a persistent update control with download, postpone and dismiss actions.
- Added searchable settings, synchronized section navigation and clearer empty, queue and busy states.
- Improved German and English localization, including locale-aware dates and accessibility labels.
- Hardened the release test suite with isolated application data, browser profiles, downloads and offline network fixtures.
## 1.0.1 - 2026-08-05 ## 1.0.1 - 2026-08-05
- New clean public release line based on the complete desktop application. - New clean public release line based on the complete desktop application.
+3 -1
View File
@@ -11,7 +11,9 @@ Twitch VOD Manager is a Windows desktop application for finding, downloading, tr
- Resume interrupted downloads and verify completed files - Resume interrupted downloads and verify completed files
- Manage queues, history, profiles and per-streamer automation - Manage queues, history, profiles and per-streamer automation
- Capture live streams and Twitch chat - Capture live streams and Twitch chat
- Use light and dark themes with German and English localization - Navigate a compact workspace with contextual sidebars and dedicated toolbars
- Use Light, Dark and System themes with German and English localization
- Search settings and jump directly to individual configuration areas
- Receive application updates through GitHub Releases - Receive application updates through GitHub Releases
## Installation ## Installation
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "1.0.1", "version": "1.0.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "1.0.1", "version": "1.0.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.16.1", "axios": "^1.16.1",
+4 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "1.0.1", "version": "1.0.2",
"description": "Twitch VOD Manager - Download Twitch VODs easily", "description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js", "main": "dist/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",
@@ -16,7 +16,9 @@
"test:e2e:guide": "node scripts/smoke-test-template-guide.js", "test:e2e:guide": "node scripts/smoke-test-template-guide.js",
"test:e2e:full": "node scripts/smoke-test-full.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:workspace-ui": "npm run build && node scripts/smoke-test-workspace-ui.js",
"test:e2e:release": "npm run build && npm run test:unit && npm run test:e2e:update-logic && npm run test:e2e:public-release && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full", "test:e2e:isolation": "node scripts/smoke-test-e2e-isolation-contract.js",
"test:e2e:settings-autosave": "node scripts/smoke-test-settings-autosave.js",
"test:e2e:release": "npm run build && npm run test:unit && npm run test:e2e:update-logic && npm run test:merge-split && npm run test:e2e:public-release && npm run test:e2e:workspace-ui && npm run test:e2e:isolation && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full && npm run test:e2e:settings-autosave",
"test:e2e:stress": "npm run test:e2e:release && npm run test:e2e:release && npm run test:e2e:release", "test:e2e:stress": "npm run test:e2e:release && npm run test:e2e:release && npm run test:e2e:release",
"pack": "npm run build && electron-builder --dir", "pack": "npm run build && electron-builder --dir",
"dist": "npm run build && electron-builder", "dist": "npm run build && electron-builder",
+251
View File
@@ -0,0 +1,251 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const OFFLINE_PROXY = 'http://127.0.0.1:1';
function buildSafeConfig(downloadsDir, overrides = {}) {
return {
client_id: '',
client_secret: '',
download_path: downloadsDir,
streamers: [],
theme: 'twitch',
download_mode: 'full',
part_minutes: 120,
language: 'en',
filename_template_vod: '{title}.mp4',
filename_template_parts: '{date}_Part{part_padded}.mp4',
filename_template_clip: '{date}_{part}.mp4',
smart_queue_scheduler: false,
performance_mode: 'balanced',
prevent_duplicate_downloads: true,
persist_queue_on_restart: false,
metadata_cache_minutes: 10,
parallel_downloads: 1,
auto_resume_queue_on_startup: false,
downloaded_vod_ids: [],
streamlink_quality: 'best',
notify_on_each_completion: false,
streamlink_disable_ads: true,
auto_record_streamers: [],
auto_record_poll_seconds: 90,
download_chat_replay: false,
capture_live_chat: false,
discord_webhook_url: '',
discord_notify_live_start: false,
discord_notify_live_end: false,
discord_notify_vod_complete: false,
discord_notify_vod_auto_queued: false,
auto_cleanup_enabled: false,
auto_cleanup_days: 30,
auto_cleanup_target: 'live_only',
auto_cleanup_action: 'archive',
log_stream_events: false,
auto_vod_download_streamers: [],
auto_vod_download_poll_minutes: 15,
auto_vod_max_age_hours: 24,
auto_resume_live_recording: false,
auto_merge_resumed_parts: false,
delete_parts_after_merge: false,
...overrides,
client_id: '',
client_secret: '',
download_path: downloadsDir,
streamers: [],
auto_resume_queue_on_startup: false,
auto_record_streamers: [],
download_chat_replay: false,
capture_live_chat: false,
discord_webhook_url: '',
discord_notify_live_start: false,
discord_notify_live_end: false,
discord_notify_vod_complete: false,
discord_notify_vod_auto_queued: false,
auto_cleanup_enabled: false,
auto_vod_download_streamers: [],
auto_resume_live_recording: false,
auto_merge_resumed_parts: false,
delete_parts_after_merge: false
};
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
function createE2eEnvironment(name, configOverrides = {}) {
const safeName = String(name || 'smoke').replace(/[^a-z0-9_-]+/gi, '-').toLowerCase();
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), `twitch-vod-manager-${safeName}-`));
const programDataDir = path.join(rootDir, 'programdata');
const userDataDir = path.join(rootDir, 'userdata');
const downloadsDir = path.join(rootDir, 'downloads');
const appDataDir = path.join(programDataDir, 'Twitch_VOD_Manager');
const mediaDir = path.join(rootDir, 'media');
const configFile = path.join(appDataDir, 'config.json');
const queueFile = path.join(appDataDir, 'download_queue.json');
for (const directory of [programDataDir, userDataDir, downloadsDir, appDataDir, mediaDir]) {
fs.mkdirSync(directory, { recursive: true });
}
writeJson(configFile, buildSafeConfig(downloadsDir, configOverrides));
writeJson(queueFile, []);
return {
rootDir,
programDataDir,
userDataDir,
downloadsDir,
appDataDir,
mediaDir,
configFile,
queueFile
};
}
function writeE2eConfig(environment, overrides = {}) {
const config = buildSafeConfig(environment.downloadsDir, overrides);
writeJson(environment.configFile, config);
return config;
}
function readE2eConfig(environment) {
return JSON.parse(fs.readFileSync(environment.configFile, 'utf8'));
}
function getElectronLaunchOptions(environment, extraArgs = []) {
return {
executablePath: require('electron'),
args: [
`--user-data-dir=${environment.userDataDir}`,
`--proxy-server=${OFFLINE_PROXY}`,
'--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost',
...extraArgs,
'.'
],
cwd: path.resolve(__dirname, '..'),
env: {
...process.env,
PROGRAMDATA: environment.programDataDir,
HTTP_PROXY: OFFLINE_PROXY,
HTTPS_PROXY: OFFLINE_PROXY,
ALL_PROXY: OFFLINE_PROXY,
NO_PROXY: '',
http_proxy: OFFLINE_PROXY,
https_proxy: OFFLINE_PROXY,
all_proxy: OFFLINE_PROXY,
no_proxy: ''
}
};
}
function isSamePath(left, right) {
return path.resolve(left).toLowerCase() === path.resolve(right).toLowerCase();
}
async function verifyE2eIsolation(app, win, environment) {
const main = await app.evaluate(({ app: electronApp }) => ({
programData: process.env.PROGRAMDATA || '',
userData: electronApp.getPath('userData')
}));
const rendererDownloadPath = await win.evaluate(async () => {
const currentConfig = await window.api.getConfig();
return currentConfig.download_path;
});
const verification = {
rootDir: environment.rootDir,
programData: main.programData,
userData: main.userData,
downloadPath: rendererDownloadPath,
programDataIsolated: isSamePath(main.programData, environment.programDataDir),
userDataIsolated: isSamePath(main.userData, environment.userDataDir),
downloadPathIsolated: isSamePath(rendererDownloadPath, environment.downloadsDir)
};
if (!verification.programDataIsolated || !verification.userDataIsolated || !verification.downloadPathIsolated) {
throw new Error(`E2E isolation verification failed: ${JSON.stringify(verification)}`);
}
return verification;
}
async function installOfflineFixtures(app) {
return app.evaluate(({ app: electronApp, ipcMain }) => {
const offlineState = {
httpProxy: process.env.HTTP_PROXY || '',
httpsProxy: process.env.HTTPS_PROXY || '',
allProxy: process.env.ALL_PROXY || '',
noProxy: process.env.NO_PROXY ?? null,
chromiumProxy: electronApp.commandLine.getSwitchValue('proxy-server')
};
const guardsActive =
offlineState.httpProxy === offlineState.httpsProxy &&
offlineState.httpProxy === offlineState.allProxy &&
offlineState.httpProxy.startsWith('http://127.0.0.1:') &&
offlineState.noProxy === '' &&
offlineState.chromiumProxy === offlineState.httpProxy;
if (!guardsActive) {
throw new Error(`Offline bootstrap verification failed: ${JSON.stringify(offlineState || null)}`);
}
const vods = [{
id: '999999999999999',
title: 'Offline fixture VOD',
created_at: '2026-02-01T00:00:00Z',
duration: '1h0m0s',
thumbnail_url: '',
url: 'https://www.twitch.tv/videos/999999999999999',
view_count: 123,
stream_id: 'offline-fixture-stream'
}];
const replaceHandler = (channel, handler) => {
ipcMain.removeHandler(channel);
ipcMain.handle(channel, handler);
};
replaceHandler('get-user-id', async () => 'offline-fixture-user');
replaceHandler('get-vods', async () => vods);
replaceHandler('get-streamer-profile', async () => null);
replaceHandler('run-preflight', async () => ({
ok: true,
autoFixApplied: false,
checks: {
internet: true,
streamlink: true,
ffmpeg: true,
ffprobe: true,
downloadPathWritable: true
},
messages: ['Offline fixture'],
timestamp: '2026-01-01T00:00:00Z'
}));
replaceHandler('check-update', async () => ({ checking: true, offlineFixture: true }));
return {
network: 'blocked',
twitch: 'fixture',
updater: 'fixture',
guards: offlineState
};
});
}
function cleanupE2eEnvironment(environment) {
if (!environment?.rootDir) {
return;
}
fs.rmSync(environment.rootDir, { recursive: true, force: true });
}
module.exports = {
buildSafeConfig,
createE2eEnvironment,
writeE2eConfig,
readE2eConfig,
getElectronLaunchOptions,
verifyE2eIsolation,
installOfflineFixtures,
cleanupE2eEnvironment
};
+64 -2
View File
@@ -4,19 +4,81 @@
"CHANGELOG.md", "CHANGELOG.md",
"LICENSE", "LICENSE",
"README.md", "README.md",
"build", "build/installer.nsh",
"eslint.config.mjs", "eslint.config.mjs",
"package-lock.json", "package-lock.json",
"package.json", "package.json",
"scripts/e2e-test-environment.js",
"scripts/public-release-files.json", "scripts/public-release-files.json",
"scripts/smoke-test-e2e-isolation-contract.js",
"scripts/smoke-test-full.js", "scripts/smoke-test-full.js",
"scripts/smoke-test-merge-split-logic.js", "scripts/smoke-test-merge-split-logic.js",
"scripts/smoke-test-public-release-config.js", "scripts/smoke-test-public-release-config.js",
"scripts/smoke-test-settings-autosave.js", "scripts/smoke-test-settings-autosave.js",
"scripts/smoke-test-template-guide.js", "scripts/smoke-test-template-guide.js",
"scripts/smoke-test-update-version-logic.js", "scripts/smoke-test-update-version-logic.js",
"scripts/smoke-test-workspace-ui.js",
"scripts/smoke-test.js", "scripts/smoke-test.js",
"src", "src/index.html",
"src/main/domain/archive-files-store.test.ts",
"src/main/domain/archive-files-store.ts",
"src/main/domain/chunk-index-store.test.ts",
"src/main/domain/chunk-index-store.ts",
"src/main/domain/config-normalize.test.ts",
"src/main/domain/config-normalize.ts",
"src/main/domain/i18n-backend.test.ts",
"src/main/domain/i18n-backend.ts",
"src/main/domain/integrity-check.test.ts",
"src/main/domain/integrity-check.ts",
"src/main/domain/migrator.test.ts",
"src/main/domain/migrator.ts",
"src/main/domain/pkce.test.ts",
"src/main/domain/pkce.ts",
"src/main/domain/token-store.test.ts",
"src/main/domain/token-store.ts",
"src/main/domain/top-clips-crawler.test.ts",
"src/main/domain/top-clips-crawler.ts",
"src/main/domain/twitch-oauth.test.ts",
"src/main/domain/twitch-oauth.ts",
"src/main/domain/update-version-utils.test.ts",
"src/main/domain/update-version-utils.ts",
"src/main/index.ts",
"src/main/infra/chunk-hash.test.ts",
"src/main/infra/chunk-hash.ts",
"src/main/infra/db.test.ts",
"src/main/infra/db.ts",
"src/main/infra/duration.test.ts",
"src/main/infra/duration.ts",
"src/main/infra/format-helpers.test.ts",
"src/main/infra/format-helpers.ts",
"src/main/infra/fs-atomic.test.ts",
"src/main/infra/fs-atomic.ts",
"src/main/infra/loopback-server.test.ts",
"src/main/infra/loopback-server.ts",
"src/main/infra/schema-v5.ts",
"src/main/infra/secure-storage.test.ts",
"src/main/infra/secure-storage.ts",
"src/main.ts",
"src/preload.ts",
"src/renderer-archive.ts",
"src/renderer-command-palette.ts",
"src/renderer-globals.d.ts",
"src/renderer-locale-de.ts",
"src/renderer-locale-en.ts",
"src/renderer-profile.ts",
"src/renderer-queue.ts",
"src/renderer-settings.ts",
"src/renderer-shared.ts",
"src/renderer-stats.ts",
"src/renderer-streamers.ts",
"src/renderer-texts.ts",
"src/renderer-updates.ts",
"src/renderer-vod-hover.ts",
"src/renderer.ts",
"src/styles.css",
"src/tools.ts",
"src/types.ts",
"src/workspace.css",
"tsconfig.json", "tsconfig.json",
"vitest.config.ts" "vitest.config.ts"
] ]
@@ -0,0 +1,165 @@
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const HELPER_FILE = path.join(__dirname, 'e2e-test-environment.js');
const SMOKE_FILES = [
'scripts/smoke-test.js',
'scripts/smoke-test-template-guide.js',
'scripts/smoke-test-full.js',
'scripts/smoke-test-settings-autosave.js',
'scripts/smoke-test-workspace-ui.js'
];
function inspectSources() {
const failures = [];
if (!fs.existsSync(HELPER_FILE)) {
failures.push('Missing scripts/e2e-test-environment.js');
}
for (const relativePath of SMOKE_FILES) {
const source = fs.readFileSync(path.join(ROOT, relativePath), 'utf8');
const requiredPatterns = [
["require('./e2e-test-environment')", 'shared isolation helper import'],
['createE2eEnvironment(', 'isolated test root creation'],
['getElectronLaunchOptions(', 'isolated Electron launch options'],
['verifyE2eIsolation(', 'runtime isolation verification'],
['installOfflineFixtures(', 'offline IPC fixtures'],
['cleanupE2eEnvironment(', 'guaranteed isolated root cleanup']
];
for (const [pattern, label] of requiredPatterns) {
if (!source.includes(pattern)) {
failures.push(`${relativePath}: missing ${label}`);
}
}
if (/process\.exit\s*\(/.test(source)) {
failures.push(`${relativePath}: process.exit bypasses cleanup`);
}
if (/electron\.launch\s*\(\s*\{/.test(source)) {
failures.push(`${relativePath}: raw electron.launch options bypass shared isolation`);
}
}
const fullSource = fs.readFileSync(path.join(ROOT, 'scripts/smoke-test-full.js'), 'utf8');
const forbiddenFullPatterns = [
['backupFile(', 'real-file backup path'],
['restoreFile(', 'real-file restore path'],
["path.join(process.cwd(), 'tmp_e2e_full')", 'project-local media path'],
["process.env.PROGRAMDATA || 'C:\\\\ProgramData'", 'ambient ProgramData path']
];
for (const [pattern, label] of forbiddenFullPatterns) {
if (fullSource.includes(pattern)) {
failures.push(`scripts/smoke-test-full.js: contains ${label}`);
}
}
return failures;
}
function inspectHelper() {
if (!fs.existsSync(HELPER_FILE)) {
return [];
}
const failures = [];
const {
createE2eEnvironment,
getElectronLaunchOptions,
cleanupE2eEnvironment
} = require(HELPER_FILE);
const environment = createE2eEnvironment('isolation-contract');
try {
const expectedDirectories = [
environment.rootDir,
environment.programDataDir,
environment.userDataDir,
environment.downloadsDir,
environment.appDataDir
];
for (const directory of expectedDirectories) {
if (!fs.statSync(directory).isDirectory()) {
failures.push(`Helper did not create directory: ${directory}`);
}
}
const config = JSON.parse(fs.readFileSync(environment.configFile, 'utf8'));
const queue = JSON.parse(fs.readFileSync(environment.queueFile, 'utf8'));
const launch = getElectronLaunchOptions(environment);
if (path.resolve(config.download_path) !== path.resolve(environment.downloadsDir)) {
failures.push('Seed config download_path is outside the isolated downloads directory');
}
if (!Array.isArray(config.streamers) || config.streamers.length !== 0) {
failures.push('Seed config contains streamers');
}
if (!Array.isArray(config.auto_record_streamers) || config.auto_record_streamers.length !== 0) {
failures.push('Seed config enables auto recording');
}
if (!Array.isArray(config.auto_vod_download_streamers) || config.auto_vod_download_streamers.length !== 0) {
failures.push('Seed config enables automatic VOD downloads');
}
if (config.auto_resume_queue_on_startup !== false || config.auto_resume_live_recording !== false) {
failures.push('Seed config enables automatic resume behavior');
}
if (config.auto_cleanup_enabled !== false) {
failures.push('Seed config enables automatic cleanup');
}
if (config.discord_webhook_url !== '') {
failures.push('Seed config contains a webhook');
}
if (!Array.isArray(queue) || queue.length !== 0) {
failures.push('Seed queue is not empty');
}
if (path.resolve(launch.env.PROGRAMDATA) !== path.resolve(environment.programDataDir)) {
failures.push('Electron launch options do not isolate PROGRAMDATA');
}
if (launch.args[0] !== `--user-data-dir=${environment.userDataDir}` || launch.args.at(-1) !== '.') {
failures.push('Electron launch options do not place the isolated userData switch before the app path');
}
if (!String(launch.args[1] || '').startsWith('--proxy-server=http://127.0.0.1:')) {
failures.push('Electron launch options do not install the Chromium offline proxy guard');
}
if (launch.env.HTTP_PROXY !== launch.env.HTTPS_PROXY || launch.env.HTTP_PROXY !== launch.env.ALL_PROXY) {
failures.push('Electron launch options do not install consistent Node proxy guards');
}
if (!String(launch.env.HTTP_PROXY || '').startsWith('http://127.0.0.1:')) {
failures.push('Electron launch options do not route Node HTTP clients to an offline loopback proxy');
}
if (launch.env.NO_PROXY !== '') {
failures.push('Electron launch options allow proxy bypasses');
}
} finally {
cleanupE2eEnvironment(environment);
}
if (fs.existsSync(environment.rootDir)) {
failures.push('Helper cleanup left the isolated root on disk');
}
return failures;
}
function run() {
const failures = [...inspectSources(), ...inspectHelper()];
const summary = {
files: SMOKE_FILES,
failures
};
console.log(JSON.stringify(summary, null, 2));
process.exitCode = failures.length === 0 ? 0 : 1;
}
try {
run();
} catch (error) {
console.error(error);
process.exitCode = 1;
}
+48 -144
View File
@@ -2,30 +2,13 @@ const { _electron: electron } = require('playwright');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { spawnSync } = require('child_process'); const { spawnSync } = require('child_process');
const {
const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager'); createE2eEnvironment,
const CONFIG_FILE = path.join(APPDATA_DIR, 'config.json'); getElectronLaunchOptions,
const QUEUE_FILE = path.join(APPDATA_DIR, 'download_queue.json'); verifyE2eIsolation,
const TMP_DIR = path.join(process.cwd(), 'tmp_e2e_full'); installOfflineFixtures,
const MEDIA_A = path.join(TMP_DIR, 'in_a.mp4'); cleanupE2eEnvironment
const MEDIA_B = path.join(TMP_DIR, 'in_b.mp4'); } = require('./e2e-test-environment');
function backupFile(filePath) {
if (!fs.existsSync(filePath)) return null;
return fs.readFileSync(filePath);
}
function restoreFile(filePath, backup) {
if (backup === null) {
if (fs.existsSync(filePath)) {
fs.rmSync(filePath, { force: true });
}
return;
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, backup);
}
function findFileRecursive(rootDir, fileName) { function findFileRecursive(rootDir, fileName) {
if (!fs.existsSync(rootDir)) return null; if (!fs.existsSync(rootDir)) return null;
@@ -46,11 +29,11 @@ function findFileRecursive(rootDir, fileName) {
return null; return null;
} }
function resolveFfmpegBinary() { function resolveFfmpegBinary(environment) {
const direct = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore', windowsHide: true }); const direct = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore', windowsHide: true });
if (direct.status === 0) return 'ffmpeg'; if (direct.status === 0) return 'ffmpeg';
const bundledRoot = path.join(APPDATA_DIR, 'tools', 'ffmpeg'); const bundledRoot = path.join(environment.appDataDir, 'tools', 'ffmpeg');
const bundled = findFileRecursive(bundledRoot, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'); const bundled = findFileRecursive(bundledRoot, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg');
if (bundled) return bundled; if (bundled) return bundled;
@@ -65,9 +48,10 @@ function runFfmpeg(ffmpegPath, args) {
} }
} }
function ensureTestMedia() { function ensureTestMedia(environment) {
fs.mkdirSync(TMP_DIR, { recursive: true }); const mediaA = path.join(environment.mediaDir, 'in_a.mp4');
const ffmpeg = resolveFfmpegBinary(); const mediaB = path.join(environment.mediaDir, 'in_b.mp4');
const ffmpeg = resolveFfmpegBinary(environment);
runFfmpeg(ffmpeg, [ runFfmpeg(ffmpeg, [
'-y', '-y',
@@ -75,7 +59,7 @@ function ensureTestMedia() {
'-i', 'testsrc=size=640x360:rate=30', '-i', 'testsrc=size=640x360:rate=30',
'-t', '4', '-t', '4',
'-pix_fmt', 'yuv420p', '-pix_fmt', 'yuv420p',
MEDIA_A mediaA
]); ]);
runFfmpeg(ffmpeg, [ runFfmpeg(ffmpeg, [
@@ -84,26 +68,23 @@ function ensureTestMedia() {
'-i', 'testsrc=size=640x360:rate=30', '-i', 'testsrc=size=640x360:rate=30',
'-t', '3', '-t', '3',
'-pix_fmt', 'yuv420p', '-pix_fmt', 'yuv420p',
MEDIA_B mediaB
]); ]);
return { mediaA, mediaB };
} }
async function run() { async function run() {
const configBackup = backupFile(CONFIG_FILE); const environment = createE2eEnvironment('full');
const queueBackup = backupFile(QUEUE_FILE); let app = null;
let app;
try { try {
ensureTestMedia(); const { mediaA, mediaB } = ensureTestMedia(environment);
const electronPath = require('electron'); app = await electron.launch(getElectronLaunchOptions(environment));
app = await electron.launch({
executablePath: electronPath,
args: ['.'],
cwd: process.cwd()
});
const win = await app.firstWindow(); const win = await app.firstWindow();
const isolation = await verifyE2eIsolation(app, win, environment);
const fixtures = await installOfflineFixtures(app);
const issues = []; const issues = [];
win.on('pageerror', (err) => { win.on('pageerror', (err) => {
@@ -144,15 +125,9 @@ async function run() {
} }
}; };
const cleanupDownloads = async () => {
await window.api.cancelDownload();
await sleep(400);
};
const initialConfig = await window.api.getConfig(); const initialConfig = await window.api.getConfig();
try { try {
await cleanupDownloads();
await clearQueue(); await clearQueue();
const requiredGlobals = [ const requiredGlobals = [
@@ -227,7 +202,7 @@ async function run() {
await window.api.saveConfig({ client_id: '', client_secret: '', download_path: tmpDir }); await window.api.saveConfig({ client_id: '', client_secret: '', download_path: tmpDir });
window.showTab('vods'); window.showTab('vods');
await window.selectStreamer('xrohat'); await window.selectStreamer('fixture_streamer');
await waitFor(() => document.querySelectorAll('.vod-card').length > 0, 18000, 300); await waitFor(() => document.querySelectorAll('.vod-card').length > 0, 18000, 300);
const vodCards = document.querySelectorAll('.vod-card').length; const vodCards = document.querySelectorAll('.vod-card').length;
@@ -250,17 +225,17 @@ async function run() {
await window.api.saveConfig({ prevent_duplicate_downloads: true }); await window.api.saveConfig({ prevent_duplicate_downloads: true });
await window.api.addToQueue({ await window.api.addToQueue({
url: 'https://www.twitch.tv/videos/2695851503', url: 'https://www.twitch.tv/videos/999999999999999',
title: '__E2E_FULL__dup', title: '__E2E_FULL__dup',
date: '2026-02-01T00:00:00Z', date: '2026-02-01T00:00:00Z',
streamer: 'xrohat', streamer: 'fixture_streamer',
duration_str: '1h0m0s' duration_str: '1h0m0s'
}); });
await window.api.addToQueue({ await window.api.addToQueue({
url: 'https://www.twitch.tv/videos/2695851503', url: 'https://www.twitch.tv/videos/999999999999999',
title: '__E2E_FULL__dup', title: '__E2E_FULL__dup',
date: '2026-02-01T00:00:00Z', date: '2026-02-01T00:00:00Z',
streamer: 'xrohat', streamer: 'fixture_streamer',
duration_str: '1h0m0s' duration_str: '1h0m0s'
}); });
let q = await window.api.getQueue(); let q = await window.api.getQueue();
@@ -290,7 +265,7 @@ async function run() {
const clipInvalidStatus = (document.getElementById('clipStatus')?.textContent || '').trim(); const clipInvalidStatus = (document.getElementById('clipStatus')?.textContent || '').trim();
assert(clipInvalidStatus.includes('Invalid clip URL') || clipInvalidStatus.includes('Ungueltige Clip-URL'), 'Invalid clip URL localization failed'); assert(clipInvalidStatus.includes('Invalid clip URL') || clipInvalidStatus.includes('Ungueltige Clip-URL'), 'Invalid clip URL localization failed');
window.openClipDialog('https://www.twitch.tv/videos/2695851503', '__E2E_FULL__clip', '2026-02-01T00:00:00Z', 'xrohat', '1h0m0s'); window.openClipDialog('https://www.twitch.tv/videos/999999999999999', '__E2E_FULL__clip', '2026-02-01T00:00:00Z', 'fixture_streamer', '1h0m0s');
document.getElementById('clipStartTime').value = '00:00:10'; document.getElementById('clipStartTime').value = '00:00:10';
document.getElementById('clipEndTime').value = '00:00:22'; document.getElementById('clipEndTime').value = '00:00:22';
window.updateFromInput('start'); window.updateFromInput('start');
@@ -304,86 +279,17 @@ async function run() {
await clearQueue(); await clearQueue();
await window.api.addToQueue({ await window.api.addToQueue({
url: 'https://www.twitch.tv/videos/2695851503', url: 'https://www.twitch.tv/videos/999999999999999',
title: '__E2E_FULL__pause',
date: '2026-02-01T00:00:00Z',
streamer: 'xrohat',
duration_str: '4h0m0s'
});
await window.api.startDownload();
await waitFor(async () => {
const list = await window.api.getQueue();
const it = list.find((x) => x.title === '__E2E_FULL__pause');
return it && (it.status === 'downloading' || it.status === 'error');
}, 25000, 400);
await window.api.pauseDownload();
await sleep(1400);
q = await window.api.getQueue();
const paused = q.find((item) => item.title === '__E2E_FULL__pause');
checks.pauseResume = {
pausedStatus: paused?.status || 'none',
buttonText: (document.getElementById('btnStart')?.textContent || '').trim()
};
assert(paused?.status === 'paused', 'Pause did not set item status to paused');
await window.api.startDownload();
await sleep(900);
const resumed = await window.api.isDownloading();
checks.pauseResume.resumed = resumed;
assert(resumed === true, 'Resume did not restart downloading');
await cleanupDownloads();
await clearQueue();
await window.api.addToQueue({
url: 'not-a-valid-url',
title: '__E2E_FULL__retry',
date: '2026-02-01T00:00:00Z',
streamer: 'xrohat',
duration_str: '1h0m0s'
});
await window.api.startDownload();
const reachedError = await waitFor(async () => {
const list = await window.api.getQueue();
const it = list.find((item) => item.title === '__E2E_FULL__retry');
return it && it.status === 'error';
}, 90000, 1000);
q = await window.api.getQueue();
const failed = q.find((item) => item.title === '__E2E_FULL__retry');
checks.retryFlow = {
failedStatus: failed?.status || 'none',
failedReason: failed?.last_error || ''
};
assert(reachedError && failed?.status === 'error', 'Retry item did not reach deterministic error state');
assert(Boolean(failed?.last_error), 'Retry test item missing error reason');
await window.api.retryFailedDownloads();
await sleep(500);
q = await window.api.getQueue();
const afterRetry = q.find((item) => item.title === '__E2E_FULL__retry');
checks.retryFlow.afterRetryStatus = afterRetry?.status || 'none';
const retryAcceptedStatuses = ['pending', 'downloading', 'error'];
assert(retryAcceptedStatuses.includes(afterRetry?.status || ''), 'Retry failed action did not update item state');
await cleanupDownloads();
await clearQueue();
await window.api.addToQueue({
url: 'https://www.twitch.tv/videos/does-not-exist',
title: '__E2E_FULL__orderA', title: '__E2E_FULL__orderA',
date: '2026-02-01T00:00:00Z', date: '2026-02-01T00:00:00Z',
streamer: 'xrohat', streamer: 'fixture_streamer',
duration_str: '1h0m0s' duration_str: '1h0m0s'
}); });
await window.api.addToQueue({ await window.api.addToQueue({
url: 'https://www.twitch.tv/videos/does-not-exist', url: 'https://www.twitch.tv/videos/999999999999998',
title: '__E2E_FULL__orderB', title: '__E2E_FULL__orderB',
date: '2026-02-01T00:00:00Z', date: '2026-02-01T00:00:00Z',
streamer: 'xrohat', streamer: 'fixture_streamer',
duration_str: '1h0m0s' duration_str: '1h0m0s'
}); });
@@ -419,7 +325,6 @@ async function run() {
} catch (e) { } catch (e) {
failures.push(`Unexpected exception: ${String(e)}`); failures.push(`Unexpected exception: ${String(e)}`);
} finally { } finally {
await cleanupDownloads();
await clearQueue(); await clearQueue();
await window.api.saveConfig(initialConfig); await window.api.saveConfig(initialConfig);
config = await window.api.getConfig(); config = await window.api.getConfig();
@@ -428,15 +333,17 @@ async function run() {
return { checks, failures }; return { checks, failures };
}, { }, {
mediaA: MEDIA_A.replace(/\\/g, '/'), mediaA: mediaA.replace(/\\/g, '/'),
mediaB: MEDIA_B.replace(/\\/g, '/'), mediaB: mediaB.replace(/\\/g, '/'),
tmpDir: TMP_DIR.replace(/\\/g, '/') tmpDir: environment.mediaDir.replace(/\\/g, '/')
}); });
await app.close(); await app.close();
app = null; app = null;
const output = { const output = {
isolation,
fixtures,
...summary, ...summary,
runtimeIssues: issues runtimeIssues: issues
}; };
@@ -444,23 +351,20 @@ async function run() {
console.log(JSON.stringify(output, null, 2)); console.log(JSON.stringify(output, null, 2));
const failed = output.failures.length > 0 || output.runtimeIssues.length > 0; const failed = output.failures.length > 0 || output.runtimeIssues.length > 0;
process.exit(failed ? 1 : 0); return failed ? 1 : 0;
} finally { } finally {
if (app) { if (app) {
try { await app.close().catch(() => undefined);
await app.close();
} catch {
// ignore
} }
} cleanupE2eEnvironment(environment);
restoreFile(CONFIG_FILE, configBackup);
restoreFile(QUEUE_FILE, queueBackup);
fs.rmSync(TMP_DIR, { recursive: true, force: true });
} }
} }
run().catch((err) => { run()
.then((exitCode) => {
process.exitCode = exitCode;
})
.catch((err) => {
console.error(err); console.error(err);
process.exit(1); process.exitCode = 1;
}); });
+46 -5
View File
@@ -13,9 +13,9 @@ function check(condition, message) {
if (!condition) failures.push(message); if (!condition) failures.push(message);
} }
check(packageJson.version === '1.0.1', `package version is ${packageJson.version}`); check(packageJson.version === '1.0.2', `package version is ${packageJson.version}`);
check(packageLock.version === '1.0.1', `lockfile version is ${packageLock.version}`); check(packageLock.version === '1.0.2', `lockfile version is ${packageLock.version}`);
check(packageLock.packages?.['']?.version === '1.0.1', `lockfile root package version is ${packageLock.packages?.['']?.version}`); check(packageLock.packages?.['']?.version === '1.0.2', `lockfile root package version is ${packageLock.packages?.['']?.version}`);
check(packageJson.build?.appId === 'io.github.sucukdeluxe.twitch-vod-manager', `appId is ${packageJson.build?.appId}`); check(packageJson.build?.appId === 'io.github.sucukdeluxe.twitch-vod-manager', `appId is ${packageJson.build?.appId}`);
check(packageJson.build?.publish?.provider === 'generic', `publish provider is ${packageJson.build?.publish?.provider}`); check(packageJson.build?.publish?.provider === 'generic', `publish provider is ${packageJson.build?.publish?.provider}`);
check(packageJson.build?.publish?.url === 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest/download/', `publish URL is ${packageJson.build?.publish?.url}`); check(packageJson.build?.publish?.url === 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest/download/', `publish URL is ${packageJson.build?.publish?.url}`);
@@ -24,15 +24,56 @@ check(mainSource.includes('GITHUB_RELEASES_API_LATEST_URL'), 'GitHub releases AP
check(mainSource.includes('GITHUB_RELEASES_DOWNLOAD_BASE_URL'), 'GitHub releases download constant is missing'); check(mainSource.includes('GITHUB_RELEASES_DOWNLOAD_BASE_URL'), 'GitHub releases download constant is missing');
check(mainSource.includes('https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest'), 'GitHub latest release API URL is missing'); check(mainSource.includes('https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest'), 'GitHub latest release API URL is missing');
check(mainSource.includes('https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download'), 'GitHub release download URL is missing'); check(mainSource.includes('https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download'), 'GitHub release download URL is missing');
check(indexSource.includes('Version: v1.0.1'), 'initial version label is not 1.0.1'); check(!/storyboards\/\d{8,12}(?:-|\/)/.test(mainSource), 'numeric Twitch VOD example remains in the public source');
check(indexSource.includes('Version: v1.0.2'), 'initial version label is not 1.0.2');
check(!indexSource.includes('Version: v4.1.13'), 'legacy version label is still present'); check(!indexSource.includes('Version: v4.1.13'), 'legacy version label is still present');
check(fs.existsSync(manifestPath), 'public release manifest is missing'); check(fs.existsSync(manifestPath), 'public release manifest is missing');
if (fs.existsSync(manifestPath)) { if (fs.existsSync(manifestPath)) {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const entries = Array.isArray(manifest.files) ? manifest.files : []; const entries = Array.isArray(manifest.files) ? manifest.files : [];
const normalizedEntries = entries.map((entry) => entry.replace(/\\/g, '/').replace(/\/$/, ''));
const forbiddenReleasePath = /(?:^|\/)(?:\.claude|\.codex|\.superpowers|tasks?|memories?|prompts?|artifacts?|logs?|backups?)(?:\/|$)|(?:^|\/)(?:AGENTS|CLAUDE)\.md$|\.(?:db|sqlite|sqlite3|log|bak|backup|zip|7z|rar|exe|msi|jsonl)$/i;
for (const entry of entries) { for (const entry of entries) {
check(fs.existsSync(path.join(root, entry)), `public release entry does not exist: ${entry}`); const absolutePath = path.join(root, entry);
check(fs.existsSync(absolutePath), `public release entry does not exist: ${entry}`);
if (fs.existsSync(absolutePath)) {
const stat = fs.lstatSync(absolutePath);
check(stat.isFile(), `public release entry is not a file: ${entry}`);
check(!stat.isSymbolicLink(), `public release entry is a symbolic link: ${entry}`);
}
check(!forbiddenReleasePath.test(entry.replace(/\\/g, '/')), `forbidden public release path: ${entry}`);
}
check(new Set(normalizedEntries).size === normalizedEntries.length, 'public release manifest contains duplicate entries');
const collectFiles = (directory) => {
const result = [];
for (const item of fs.readdirSync(directory, { withFileTypes: true })) {
const absolutePath = path.join(directory, item.name);
if (item.isDirectory()) result.push(...collectFiles(absolutePath));
else if (item.isFile()) result.push(path.relative(root, absolutePath).replace(/\\/g, '/'));
}
return result;
};
const publicTreeFiles = [...collectFiles(path.join(root, 'build')), ...collectFiles(path.join(root, 'src'))].sort();
const missingTreeFiles = publicTreeFiles.filter((relativePath) => !normalizedEntries.includes(relativePath));
check(missingTreeFiles.length === 0, `public release manifest is missing ${missingTreeFiles.length} build/src files: ${missingTreeFiles.slice(0, 5).join(', ')}`);
const referencedTestFiles = new Set();
for (const [name, command] of Object.entries(packageJson.scripts || {})) {
if (!name.startsWith('test')) continue;
for (const match of String(command).matchAll(/\b((?:scripts|tests?|src)[\\/][A-Za-z0-9._\\/-]+)\b/g)) {
const relativePath = match[1].replace(/\\/g, '/');
const absolutePath = path.join(root, ...relativePath.split('/'));
if (fs.existsSync(absolutePath) && fs.statSync(absolutePath).isFile()) {
referencedTestFiles.add(relativePath);
}
}
}
for (const relativePath of [...referencedTestFiles].sort()) {
check(normalizedEntries.includes(relativePath), `test script file is missing from the public release manifest: ${relativePath}`);
} }
} }
+38 -83
View File
@@ -1,61 +1,16 @@
const { _electron: electron } = require('playwright'); const { _electron: electron } = require('playwright');
const path = require('path'); const {
const fs = require('fs'); createE2eEnvironment,
writeE2eConfig,
readE2eConfig,
getElectronLaunchOptions,
verifyE2eIsolation,
installOfflineFixtures,
cleanupE2eEnvironment
} = require('./e2e-test-environment');
const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager'); async function launchApp(environment) {
const CONFIG_FILE = path.join(APPDATA_DIR, 'config.json'); return electron.launch(getElectronLaunchOptions(environment));
const DEFAULT_CONFIG = {
client_id: '',
client_secret: '',
download_path: path.join(process.env.USERPROFILE || 'C:\\Users\\ploet', 'Desktop', 'Twitch_VODs'),
streamers: [],
theme: 'twitch',
download_mode: 'full',
part_minutes: 120,
language: 'en',
filename_template_vod: '{title}.mp4',
filename_template_parts: '{date}_Part{part_padded}.mp4',
filename_template_clip: '{date}_{part}.mp4',
smart_queue_scheduler: true,
performance_mode: 'balanced',
prevent_duplicate_downloads: true,
metadata_cache_minutes: 10
};
function backupFile(filePath) {
if (!fs.existsSync(filePath)) return null;
return fs.readFileSync(filePath);
}
function restoreFile(filePath, backup) {
if (backup === null) {
if (fs.existsSync(filePath)) {
fs.rmSync(filePath, { force: true });
}
return;
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, backup);
}
function writeConfig(config) {
fs.mkdirSync(path.dirname(CONFIG_FILE), { recursive: true });
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
}
function readConfig() {
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
}
async function launchApp() {
const electronPath = require('electron');
return electron.launch({
executablePath: electronPath,
args: ['.'],
cwd: process.cwd()
});
} }
async function setSettingsAndBlur(win, mode, partMinutes) { async function setSettingsAndBlur(win, mode, partMinutes) {
@@ -102,53 +57,54 @@ async function readSettingsFromUi(win) {
} }
async function run() { async function run() {
const configBackup = backupFile(CONFIG_FILE); const environment = createE2eEnvironment('settings-autosave');
const baseConfig = configBackup ? { ...DEFAULT_CONFIG, ...JSON.parse(String(configBackup)) } : { ...DEFAULT_CONFIG };
let app = null; let app = null;
try { try {
writeConfig({ writeE2eConfig(environment, {
...baseConfig,
client_id: '',
client_secret: '',
download_mode: 'full', download_mode: 'full',
part_minutes: 120 part_minutes: 120
}); });
app = await launchApp(); const isolations = [];
app = await launchApp(environment);
let win = await app.firstWindow(); let win = await app.firstWindow();
isolations.push(await verifyE2eIsolation(app, win, environment));
await installOfflineFixtures(app);
await win.waitForTimeout(2200); await win.waitForTimeout(2200);
await setSettingsAndBlur(win, 'parts', 60); await setSettingsAndBlur(win, 'parts', 60);
await app.close(); await app.close();
app = null; app = null;
const afterBlurClose = readConfig(); const afterBlurClose = readE2eConfig(environment);
app = await launchApp(); app = await launchApp(environment);
win = await app.firstWindow(); win = await app.firstWindow();
isolations.push(await verifyE2eIsolation(app, win, environment));
await installOfflineFixtures(app);
await win.waitForTimeout(2200); await win.waitForTimeout(2200);
const reopenedAfterBlur = await readSettingsFromUi(win); const reopenedAfterBlur = await readSettingsFromUi(win);
await app.close(); await app.close();
app = null; app = null;
writeConfig({ writeE2eConfig(environment, {
...baseConfig,
client_id: '',
client_secret: '',
download_mode: 'full', download_mode: 'full',
part_minutes: 120 part_minutes: 120
}); });
app = await launchApp(); app = await launchApp(environment);
win = await app.firstWindow(); win = await app.firstWindow();
isolations.push(await verifyE2eIsolation(app, win, environment));
const fixtures = await installOfflineFixtures(app);
await win.waitForTimeout(2200); await win.waitForTimeout(2200);
await setSettingsAndCloseImmediately(win, 'parts', 75); await setSettingsAndCloseImmediately(win, 'parts', 75);
await app.close(); await app.close();
app = null; app = null;
const afterDirectClose = readConfig(); const afterDirectClose = readE2eConfig(environment);
const result = { const result = {
isolation: isolations,
fixtures,
afterBlurClose: { afterBlurClose: {
config: { config: {
download_mode: afterBlurClose.download_mode, download_mode: afterBlurClose.download_mode,
@@ -176,21 +132,20 @@ async function run() {
afterDirectClose.download_mode === 'parts' && afterDirectClose.download_mode === 'parts' &&
afterDirectClose.part_minutes === 75; afterDirectClose.part_minutes === 75;
process.exit(blurCaseOk && directCloseOk ? 0 : 1); return blurCaseOk && directCloseOk ? 0 : 1;
} finally { } finally {
if (app) { if (app) {
try { await app.close().catch(() => undefined);
await app.close();
} catch {
// ignore
} }
} cleanupE2eEnvironment(environment);
restoreFile(CONFIG_FILE, configBackup);
} }
} }
run().catch((err) => { run()
.then((exitCode) => {
process.exitCode = exitCode;
})
.catch((err) => {
console.error(err); console.error(err);
process.exit(1); process.exitCode = 1;
}); });
+37 -28
View File
@@ -1,14 +1,20 @@
const { _electron: electron } = require('playwright'); const { _electron: electron } = require('playwright');
const {
createE2eEnvironment,
getElectronLaunchOptions,
verifyE2eIsolation,
installOfflineFixtures,
cleanupE2eEnvironment
} = require('./e2e-test-environment');
async function run() { async function run() {
const electronPath = require('electron'); const environment = createE2eEnvironment('template-guide');
const app = await electron.launch({ let app = null;
executablePath: electronPath, try {
args: ['.'], app = await electron.launch(getElectronLaunchOptions(environment));
cwd: process.cwd()
});
const win = await app.firstWindow(); const win = await app.firstWindow();
const isolation = await verifyE2eIsolation(app, win, environment);
const fixtures = await installOfflineFixtures(app);
const issues = []; const issues = [];
const failures = []; const failures = [];
@@ -23,13 +29,11 @@ async function run() {
}); });
const fail = (message) => failures.push(message); const fail = (message) => failures.push(message);
let settingsPreview = ''; let settingsPreview = '';
let variableRows = 0; let variableRows = 0;
let clipPreviewBefore = ''; let clipPreviewBefore = '';
let clipPreviewAfter = ''; let clipPreviewAfter = '';
try {
await win.waitForTimeout(2500); await win.waitForTimeout(2500);
await win.evaluate(() => { await win.evaluate(() => {
@@ -74,18 +78,16 @@ async function run() {
await win.click('#templateGuideCloseBtn'); await win.click('#templateGuideCloseBtn');
await win.waitForTimeout(100); await win.waitForTimeout(100);
await win.evaluate(async () => { await win.evaluate(() => {
window.showTab('vods'); window.showTab('vods');
await window.selectStreamer('xrohat'); window.openClipDialog(
'https://www.twitch.tv/videos/999999999999999',
'Offline fixture VOD',
'2026-02-01T00:00:00Z',
'offline_fixture',
'1h0m0s'
);
}); });
await win.waitForTimeout(3200);
const clipButtons = win.locator('.vod-card .vod-btn.secondary');
const clipCount = await clipButtons.count();
if (clipCount < 1) {
fail('No clip buttons found in VOD list');
} else {
await clipButtons.first().click();
await win.waitForTimeout(260); await win.waitForTimeout(260);
await win.locator('input[name="filenameFormat"][value="template"]').check(); await win.locator('input[name="filenameFormat"][value="template"]').check();
@@ -118,12 +120,10 @@ async function run() {
await win.evaluate(() => { await win.evaluate(() => {
window.closeClipDialog(); window.closeClipDialog();
}); });
}
} finally {
await app.close();
}
const summary = { const summary = {
isolation,
fixtures,
failures, failures,
issues, issues,
checks: { checks: {
@@ -136,11 +136,20 @@ async function run() {
console.log(JSON.stringify(summary, null, 2)); console.log(JSON.stringify(summary, null, 2));
const hasFailure = failures.length > 0 || issues.length > 0; return failures.length > 0 || issues.length > 0 ? 1 : 0;
process.exit(hasFailure ? 1 : 0); } finally {
if (app) {
await app.close().catch(() => undefined);
}
cleanupE2eEnvironment(environment);
}
} }
run().catch((err) => { run()
.then((exitCode) => {
process.exitCode = exitCode;
})
.catch((err) => {
console.error(err); console.error(err);
process.exit(1); process.exitCode = 1;
}); });
+756 -106
View File
@@ -1,7 +1,13 @@
const { _electron: electron } = require('playwright'); const { _electron: electron } = require('playwright');
const fs = require('fs'); const fs = require('fs');
const os = require('os');
const path = require('path'); const path = require('path');
const {
createE2eEnvironment,
getElectronLaunchOptions,
verifyE2eIsolation,
installOfflineFixtures,
cleanupE2eEnvironment
} = require('./e2e-test-environment');
const TARGETS = [ const TARGETS = [
{ width: 2048, height: 1094 }, { width: 2048, height: 1094 },
@@ -12,21 +18,10 @@ const TARGETS = [
const TABS = ['vods', 'clips', 'cutter', 'merge', 'stats', 'archive', 'settings']; const TABS = ['vods', 'clips', 'cutter', 'merge', 'stats', 'archive', 'settings'];
async function run() { async function run() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-ui-contract-')); const environment = createE2eEnvironment('workspace-ui', {
const tempProgramData = path.join(tempRoot, 'programdata');
const tempUserData = path.join(tempRoot, 'userdata');
const tempDownloadPath = path.join(tempRoot, 'downloads');
const tempAppData = path.join(tempProgramData, 'Twitch_VOD_Manager');
fs.mkdirSync(tempProgramData, { recursive: true });
fs.mkdirSync(tempUserData, { recursive: true });
fs.mkdirSync(tempDownloadPath, { recursive: true });
fs.mkdirSync(tempAppData, { recursive: true });
fs.writeFileSync(path.join(tempAppData, 'config.json'), JSON.stringify({
download_path: tempDownloadPath,
streamers: [],
language: 'en', language: 'en',
theme: 'twitch' theme: 'twitch'
})); });
const artifactDir = path.join(process.cwd(), 'artifacts', 'ui-overhaul', 'workspace-ui'); const artifactDir = path.join(process.cwd(), 'artifacts', 'ui-overhaul', 'workspace-ui');
fs.mkdirSync(artifactDir, { recursive: true }); fs.mkdirSync(artifactDir, { recursive: true });
@@ -40,24 +35,21 @@ async function run() {
}; };
try { try {
app = await electron.launch({ app = await electron.launch(getElectronLaunchOptions(environment));
executablePath: require('electron'),
args: [`--user-data-dir=${tempUserData}`, '.'],
cwd: process.cwd(),
env: { ...process.env, PROGRAMDATA: tempProgramData }
});
const win = await app.firstWindow(); const win = await app.firstWindow();
const actualUserData = await app.evaluate(({ app: electronApp }) => electronApp.getPath('userData')); const isolation = await verifyE2eIsolation(app, win, environment);
const runtimeConfig = await win.evaluate(() => window.api.getConfig()); const offlineFixtures = await installOfflineFixtures(app);
checks.dataIsolation = { checks.dataIsolation = {
expectedUserData: tempUserData, expectedUserData: environment.userDataDir,
actualUserData, actualUserData: isolation.userData,
expectedDownloadPath: tempDownloadPath, expectedDownloadPath: environment.downloadsDir,
actualDownloadPath: runtimeConfig.download_path actualDownloadPath: isolation.downloadPath
}; };
check(path.resolve(actualUserData) === path.resolve(tempUserData), 'Electron userData is not isolated from the regular application profile'); checks.offlineFixtures = offlineFixtures;
check(path.resolve(runtimeConfig.download_path) === path.resolve(tempDownloadPath), 'Workspace content is not isolated from the regular download folder'); check(isolation.userDataIsolated, 'Electron userData is not isolated from the regular application profile');
check(isolation.downloadPathIsolated, 'Workspace content is not isolated from the regular download folder');
check(offlineFixtures.network === 'blocked' && offlineFixtures.twitch === 'fixture' && offlineFixtures.updater === 'fixture', 'Workspace UI test is not protected by offline fixtures');
win.on('pageerror', (error) => runtimeIssues.push(`pageerror: ${String(error)}`)); win.on('pageerror', (error) => runtimeIssues.push(`pageerror: ${String(error)}`));
win.on('console', (message) => { win.on('console', (message) => {
if (message.type() === 'error') runtimeIssues.push(`console.error: ${message.text()}`); if (message.type() === 'error') runtimeIssues.push(`console.error: ${message.text()}`);
@@ -73,7 +65,8 @@ async function run() {
workspaceMain: Boolean(document.querySelector('.workspace-main')), workspaceMain: Boolean(document.querySelector('.workspace-main')),
toolbar: Boolean(document.querySelector('.workspace-toolbar')), toolbar: Boolean(document.querySelector('.workspace-toolbar')),
updateButton: Boolean(document.getElementById('workspaceUpdateButton')), updateButton: Boolean(document.getElementById('workspaceUpdateButton')),
topNavigationItems: document.querySelectorAll('.top-nav [data-tab]').length topNavigationItems: document.querySelectorAll('.top-nav button[data-tab]').length,
nonButtonNavigationItems: document.querySelectorAll('.top-nav [data-tab]:not(button)').length
})); }));
checks.shell = shell; checks.shell = shell;
@@ -84,7 +77,49 @@ async function run() {
check(shell.workspaceMain, 'The workspace main region is missing'); check(shell.workspaceMain, 'The workspace main region is missing');
check(shell.toolbar, 'The workspace toolbar is missing'); check(shell.toolbar, 'The workspace toolbar is missing');
check(shell.updateButton, 'The persistent update action is missing'); check(shell.updateButton, 'The persistent update action is missing');
check(shell.topNavigationItems === 7, `Expected 7 primary navigation items, found ${shell.topNavigationItems}`); check(shell.topNavigationItems === 7, `Expected 7 native primary navigation buttons, found ${shell.topNavigationItems}`);
check(shell.nonButtonNavigationItems === 0, `Expected only native primary navigation buttons, found ${shell.nonButtonNavigationItems} non-buttons`);
const queueEmptyActions = await win.evaluate(() => ({
count: document.getElementById('queueCount')?.textContent?.trim() || '',
startDisabled: document.getElementById('btnStart')?.disabled === true,
retryDisabled: document.getElementById('btnRetryFailed')?.disabled === true,
clearDisabled: document.getElementById('btnClear')?.disabled === true
}));
checks.queueEmptyActions = queueEmptyActions;
check(queueEmptyActions.count === '0', `Expected an empty isolated queue, found count ${queueEmptyActions.count}`);
check(queueEmptyActions.startDisabled, 'Start queue action is enabled while the queue is empty');
check(queueEmptyActions.retryDisabled, 'Retry queue action is enabled while the queue is empty');
check(queueEmptyActions.clearDisabled, 'Clear queue action is enabled while the queue is empty');
await win.evaluate(() => {
const storagePrototype = Object.getPrototypeOf(localStorage);
const originalSetItem = storagePrototype.setItem;
window.__workspaceTabActivations = [];
storagePrototype.setItem = function setItem(key, value) {
if (key === 'twitch-vod-manager:active-tab') {
window.__workspaceTabActivations.push(value);
}
return originalSetItem.call(this, key, value);
};
});
const keyboardActivations = {};
for (const tab of TABS) {
keyboardActivations[tab] = {};
for (const key of ['Enter', 'Space']) {
const button = win.locator(`.top-nav button[data-tab="${tab}"]`);
await win.evaluate(() => { window.__workspaceTabActivations = []; });
await button.focus();
await button.press(key);
await win.waitForTimeout(30);
const activations = await win.evaluate(() => [...window.__workspaceTabActivations]);
keyboardActivations[tab][key] = activations;
check(activations.length === 1, `${tab} ${key} activation persisted ${activations.length} tab changes instead of exactly one`);
check(activations[0] === tab, `${tab} ${key} activation persisted the wrong tab: ${activations.join(', ')}`);
}
}
checks.keyboardActivations = keyboardActivations;
const updateContract = await win.evaluate(() => ({ const updateContract = await win.evaluate(() => ({
action: typeof window.handleWorkspaceUpdateAction, action: typeof window.handleWorkspaceUpdateAction,
@@ -103,7 +138,8 @@ async function run() {
label: document.getElementById('workspaceUpdateLabel')?.textContent?.trim() || '', label: document.getElementById('workspaceUpdateLabel')?.textContent?.trim() || '',
description: document.getElementById('updateText')?.textContent?.trim() || '', description: document.getElementById('updateText')?.textContent?.trim() || '',
checkLabel: document.getElementById('checkUpdateBtn')?.textContent?.trim() || '', checkLabel: document.getElementById('checkUpdateBtn')?.textContent?.trim() || '',
disabled: document.getElementById('workspaceUpdateButton')?.disabled || false disabled: document.getElementById('workspaceUpdateButton')?.disabled || false,
ariaDisabled: document.getElementById('workspaceUpdateButton')?.getAttribute('aria-disabled') || ''
}); });
window.setUpdateBannerAvailableUi({ version: '9.9.9' }); window.setUpdateBannerAvailableUi({ version: '9.9.9' });
@@ -121,123 +157,737 @@ async function run() {
check(updaterStates.available.state === 'available', 'Available update state is not reflected in the topbar'); check(updaterStates.available.state === 'available', 'Available update state is not reflected in the topbar');
check(updaterStates.available.description.includes('9.9.9'), 'Available update version is not announced'); check(updaterStates.available.description.includes('9.9.9'), 'Available update version is not announced');
check(updaterStates.downloading.state === 'downloading', 'Download state is not reflected in the topbar'); check(updaterStates.downloading.state === 'downloading', 'Download state is not reflected in the topbar');
check(updaterStates.downloading.disabled, 'Update action remains enabled while downloading'); check(!updaterStates.downloading.disabled, 'Downloading update status is not keyboard focusable');
check(updaterStates.downloading.ariaDisabled === 'true', 'Downloading update action does not expose aria-disabled=true');
check(updaterStates.ready.state === 'ready', 'Ready-to-install state is not reflected in the topbar'); check(updaterStates.ready.state === 'ready', 'Ready-to-install state is not reflected in the topbar');
check(updaterStates.ready.description.includes('9.9.9'), 'Ready update version is not announced'); check(updaterStates.ready.description.includes('9.9.9'), 'Ready update version is not announced');
check(updaterStates.idle.state === 'idle', 'Idle update state is not restored in the topbar'); check(updaterStates.idle.state === 'idle', 'Idle update state is not restored in the topbar');
check(!updaterStates.idle.description.includes('9.9.9'), 'Idle update state keeps stale release information'); check(!updaterStates.idle.description.includes('9.9.9'), 'Idle update state keeps stale release information');
check(updaterStates.idle.description === updaterStates.idle.checkLabel, 'Idle update tooltip does not describe the available action'); check(updaterStates.idle.description === updaterStates.idle.checkLabel, 'Idle update tooltip does not describe the available action');
}
if (shell.topNavigation && shell.workspace) { await win.evaluate(() => window.setDownloadPendingUi());
await win.setViewportSize(TARGETS[0]); await win.locator('#workspaceUpdateButton').focus();
const tabChecks = {}; await win.waitForTimeout(80);
for (const tab of TABS) { const downloadingKeyboardState = await win.evaluate(() => {
const button = win.locator(`.top-nav [data-tab="${tab}"]`); const button = document.getElementById('workspaceUpdateButton');
await button.focus(); const popover = document.querySelector('.workspace-update-popover');
await button.press('Enter'); const style = popover ? getComputedStyle(popover) : null;
await win.waitForTimeout(50);
const state = await win.evaluate((tabId) => {
const navItem = document.querySelector(`.top-nav [data-tab="${tabId}"]`);
const content = document.getElementById(`${tabId}Tab`);
const context = document.querySelector(`[data-context-for="${tabId}"]`);
const title = document.getElementById('pageTitle');
return { return {
current: navItem?.getAttribute('aria-current') === 'page', focused: document.activeElement === button,
contentVisible: Boolean(content?.classList.contains('active')), disabled: button?.disabled || false,
contextVisible: Boolean(context && !context.hidden), ariaDisabled: button?.getAttribute('aria-disabled') || '',
title: title?.textContent?.trim() || '', ariaExpanded: button?.getAttribute('aria-expanded') || '',
focused: document.activeElement === navItem visible: Boolean(style && style.visibility === 'visible' && Number(style.opacity) > 0),
state: document.getElementById('updateBanner')?.dataset.updateState || ''
}; };
}, tab);
tabChecks[tab] = state;
check(state.current, `Primary navigation does not mark ${tab} as current`);
check(state.contentVisible, `The ${tab} workspace is not visible after activation`);
check(state.contextVisible, `The ${tab} contextual sidebar is not visible after activation`);
check(Boolean(state.title), `The ${tab} workspace title is empty`);
check(state.focused, `Keyboard focus was lost while activating ${tab}`);
await win.screenshot({
path: path.join(artifactDir, `workspace-${tab}-${TARGETS[0].width}x${TARGETS[0].height}.png`),
fullPage: true
}); });
} await win.keyboard.press('Enter');
checks.tabs = tabChecks; const downloadingStateAfterEnter = await win.evaluate(() => document.getElementById('updateBanner')?.dataset.updateState || '');
checks.downloadingKeyboardState = { ...downloadingKeyboardState, stateAfterEnter: downloadingStateAfterEnter };
const themePicker = win.locator('#workspaceThemePicker [data-theme]'); check(downloadingKeyboardState.focused && !downloadingKeyboardState.disabled, 'Downloading update status cannot receive keyboard focus');
const themeCount = await themePicker.count(); check(downloadingKeyboardState.ariaDisabled === 'true', 'Downloading update trigger does not communicate its unavailable action');
check(themeCount === 3, `Expected 3 workspace theme choices, found ${themeCount}`); check(downloadingKeyboardState.visible && downloadingKeyboardState.ariaExpanded === 'true', 'Downloading progress is hidden from keyboard focus');
if (themeCount === 3) { check(downloadingKeyboardState.state === 'downloading' && downloadingStateAfterEnter === 'downloading', 'Keyboard activation changes the downloading state');
await win.locator('#workspaceThemePicker [data-theme="light"]').click(); await win.evaluate(() => window.hideUpdateBanner());
const lightTheme = await win.evaluate(() => ({
bodyClass: document.body.className,
selected: document.getElementById('themeSelect')?.value || '',
pressed: document.querySelector('#workspaceThemePicker [data-theme="light"]')?.getAttribute('aria-pressed')
}));
check(lightTheme.bodyClass === 'theme-light', 'Light theme choice does not update the application theme');
check(lightTheme.selected === 'light', 'Light theme choice does not update the settings value');
check(lightTheme.pressed === 'true', 'Light theme choice is not exposed as selected');
await win.locator('#workspaceThemePicker [data-theme="system"]').click();
const systemTheme = await win.evaluate(() => ({
bodyClass: document.body.className,
selected: document.getElementById('themeSelect')?.value || '',
pressed: document.querySelector('#workspaceThemePicker [data-theme="system"]')?.getAttribute('aria-pressed')
}));
check(systemTheme.bodyClass === 'theme-system', 'System theme choice does not update the application theme');
check(systemTheme.selected === 'system', 'System theme choice does not update the settings value');
check(systemTheme.pressed === 'true', 'System theme choice is not exposed as selected');
await win.locator('#workspaceThemePicker [data-theme="twitch"]').click();
}
await win.evaluate(() => window.setUpdateBannerAvailableUi({ version: '9.9.9' })); await win.evaluate(() => window.setUpdateBannerAvailableUi({ version: '9.9.9' }));
await win.locator('#workspaceUpdateButton').hover(); await win.locator('#workspaceUpdateButton').hover();
await win.waitForTimeout(100); await win.waitForTimeout(80);
const popoverActions = await win.evaluate(() => {
const popover = document.querySelector('.workspace-update-popover');
const later = document.getElementById('workspaceUpdateLater');
const dismiss = document.getElementById('workspaceUpdateDismiss');
const style = popover ? getComputedStyle(popover) : null;
return {
visible: Boolean(style && style.visibility === 'visible' && Number(style.opacity) > 0),
laterExists: later instanceof HTMLButtonElement,
laterText: later?.textContent?.trim() || '',
dismissExists: dismiss instanceof HTMLButtonElement,
dismissLabel: dismiss?.textContent?.trim() || dismiss?.getAttribute('aria-label')?.trim() || dismiss?.getAttribute('title')?.trim() || '',
expanded: document.getElementById('workspaceUpdateButton')?.getAttribute('aria-expanded') || ''
};
});
checks.popoverActions = popoverActions;
check(popoverActions.visible, 'Available update popover is not visible on hover');
check(popoverActions.laterExists, 'Available update popover has no Later action');
check(/^later$/i.test(popoverActions.laterText), `Available update Later action is labelled "${popoverActions.laterText}"`);
check(popoverActions.dismissExists, 'Available update popover has no Close action');
check(/^close$/i.test(popoverActions.dismissLabel), `Available update Close action is labelled "${popoverActions.dismissLabel}"`);
check(popoverActions.expanded === 'true', 'Available update trigger does not expose aria-expanded=true while the popover is visible');
if (popoverActions.laterExists) {
await win.locator('#workspaceUpdateLater').click();
await win.waitForTimeout(160);
const laterState = await win.evaluate(() => ({
state: document.getElementById('updateBanner')?.dataset.updateState || '',
shown: document.getElementById('updateBanner')?.classList.contains('show') || false,
visibility: getComputedStyle(document.querySelector('.workspace-update-popover')).visibility,
pointerEvents: getComputedStyle(document.querySelector('.workspace-update-popover')).pointerEvents,
opacity: getComputedStyle(document.querySelector('.workspace-update-popover')).opacity,
ariaExpanded: document.getElementById('workspaceUpdateButton')?.getAttribute('aria-expanded') || ''
}));
checks.updateLaterState = laterState;
check(laterState.state === 'available', 'Later action discards the available update state');
check(!laterState.shown, 'Later action does not hide the current update announcement');
check(laterState.visibility === 'hidden' && laterState.pointerEvents === 'none' && Number(laterState.opacity) === 0, 'Later action leaves the update popover visibly interactive');
check(laterState.ariaExpanded === 'false', 'Later action leaves aria-expanded=true on a hidden popover');
await win.evaluate(() => window.setDownloadReadyUi({ version: '9.9.9' }));
const readyAfterPostpone = await win.evaluate(() => ({
state: document.getElementById('updateBanner')?.dataset.updateState || '',
shown: document.getElementById('updateBanner')?.classList.contains('show') || false,
dismissed: document.getElementById('updateBanner')?.classList.contains('popover-dismissed') || false
}));
await win.evaluate(() => {
window.changeLanguage('de');
window.changeLanguage('en');
});
const readyAfterLanguageRefresh = await win.evaluate(() => ({
state: document.getElementById('updateBanner')?.dataset.updateState || '',
shown: document.getElementById('updateBanner')?.classList.contains('show') || false,
dismissed: document.getElementById('updateBanner')?.classList.contains('popover-dismissed') || false
}));
checks.readyAfterPostpone = { immediate: readyAfterPostpone, afterLanguageRefresh: readyAfterLanguageRefresh };
check(readyAfterPostpone.state === 'ready' && readyAfterPostpone.shown && !readyAfterPostpone.dismissed, 'Ready update remains postponed after download completion');
check(readyAfterLanguageRefresh.state === 'ready' && readyAfterLanguageRefresh.shown && !readyAfterLanguageRefresh.dismissed, 'Language refresh hides or postpones a ready update');
}
await win.evaluate(() => window.setUpdateBannerAvailableUi({ version: '9.9.9' }));
if (popoverActions.dismissExists) {
await win.locator('#workspaceUpdateButton').hover();
await win.locator('#workspaceUpdateDismiss').click();
await win.waitForTimeout(160);
const dismissState = await win.evaluate(() => ({
state: document.getElementById('updateBanner')?.dataset.updateState || '',
shown: document.getElementById('updateBanner')?.classList.contains('show') || false,
visibility: getComputedStyle(document.querySelector('.workspace-update-popover')).visibility,
pointerEvents: getComputedStyle(document.querySelector('.workspace-update-popover')).pointerEvents,
opacity: getComputedStyle(document.querySelector('.workspace-update-popover')).opacity,
ariaExpanded: document.getElementById('workspaceUpdateButton')?.getAttribute('aria-expanded') || ''
}));
checks.updateDismissState = dismissState;
check(dismissState.state === 'idle', 'Close action does not dismiss the available update state');
check(!dismissState.shown, 'Close action leaves the update announcement visible');
check(dismissState.visibility === 'hidden' && dismissState.pointerEvents === 'none' && Number(dismissState.opacity) === 0, 'Close action leaves the update popover visibly interactive');
check(dismissState.ariaExpanded === 'false', 'Close action leaves aria-expanded=true on a hidden popover');
}
await win.evaluate(() => window.hideUpdateBanner());
}
await win.evaluate(() => window.showTab('clips'));
const clipContextLinks = win.locator('[data-context-for="clips"] .context-link');
if (await clipContextLinks.count() >= 2) {
await clipContextLinks.nth(1).click();
await win.waitForTimeout(30);
const contextNavigation = await win.evaluate(() => {
const panel = document.querySelector('[data-context-for="clips"]');
const links = [...(panel?.querySelectorAll('.context-link') || [])];
return {
activeCount: links.filter((link) => link.classList.contains('active')).length,
currentCount: links.filter((link) => link.getAttribute('aria-current') === 'page').length,
secondActive: links[1]?.classList.contains('active') || false,
secondCurrent: links[1]?.getAttribute('aria-current') === 'page'
};
});
checks.contextNavigation = contextNavigation;
check(contextNavigation.activeCount === 1, `Context navigation exposes ${contextNavigation.activeCount} active items after a click`);
check(contextNavigation.currentCount === 1, `Context navigation exposes ${contextNavigation.currentCount} aria-current items after a click`);
check(contextNavigation.secondActive && contextNavigation.secondCurrent, 'Clicked context navigation item is not active and current');
} else {
check(false, 'Clip context navigation does not expose at least two items');
}
await win.evaluate(() => window.showTab('settings'));
const settingsSearchExists = await win.locator('#settingsSearchInput').count() === 1;
check(settingsSearchExists, 'Settings search input is missing');
if (settingsSearchExists) {
const beforeCount = await win.locator('#settingsTab .settings-card').evaluateAll((cards) => cards.filter((card) => {
const style = getComputedStyle(card);
return !card.hidden && style.display !== 'none' && style.visibility !== 'hidden';
}).length);
await win.locator('#settingsSearchInput').fill('download');
await win.waitForTimeout(40);
const filtered = await win.locator('#settingsTab .settings-card').evaluateAll((cards) => {
const visible = cards.filter((card) => {
const style = getComputedStyle(card);
return !card.hidden && style.display !== 'none' && style.visibility !== 'hidden';
});
return {
count: visible.length,
headings: visible.map((card) => card.querySelector('h3')?.textContent?.trim() || '')
};
});
await win.locator('#settingsSearchInput').fill('');
await win.waitForTimeout(40);
const restoredCount = await win.locator('#settingsTab .settings-card').evaluateAll((cards) => cards.filter((card) => {
const style = getComputedStyle(card);
return !card.hidden && style.display !== 'none' && style.visibility !== 'hidden';
}).length);
checks.settingsSearch = { beforeCount, filtered, restoredCount };
check(filtered.count > 0, 'Settings search hides every settings card for a matching query');
check(filtered.count < beforeCount, 'Settings search does not filter any settings cards');
check(filtered.headings.some((heading) => /download/i.test(heading)), `Settings search has no matching Downloads card: ${filtered.headings.join(', ')}`);
check(restoredCount === beforeCount, `Clearing settings search restores ${restoredCount} of ${beforeCount} cards`);
}
const dynamicQueue = await win.evaluate(() => {
queue = [
{ id: 'pending-fixture', title: 'Pending fixture', url: 'https://example.invalid/pending', date: '2026-08-10T12:00:00Z', streamer: 'fixture', duration_str: '1h', status: 'pending', progress: 0 },
{ id: 'downloading-fixture', title: 'Downloading fixture', url: 'https://example.invalid/downloading', date: '2026-08-10T12:00:00Z', streamer: 'fixture', duration_str: '1h', status: 'downloading', progress: 42 },
{ id: 'completed-fixture', title: 'Completed fixture', url: 'https://example.invalid/completed', date: '2026-08-10T12:00:00Z', streamer: 'fixture', duration_str: '1h', status: 'completed', progress: 100 },
{ id: 'error-fixture', title: 'Error fixture', url: 'https://example.invalid/error', date: '2026-08-10T12:00:00Z', streamer: 'fixture', duration_str: '1h', status: 'error', progress: 0, last_error: 'Fixture failure' }
];
renderQueue();
return {
rows: document.querySelectorAll('#queueList .queue-item').length,
pending: document.querySelectorAll('#queueList .status.pending').length,
downloading: document.querySelectorAll('#queueList .status.downloading').length,
completed: document.querySelectorAll('#queueList .status.completed').length,
error: document.querySelectorAll('#queueList .status.error').length,
determinateProgress: document.querySelector('#queueList [data-id="downloading-fixture"] .queue-progress-wrap')?.getAttribute('aria-valuenow') || '',
retryEnabled: document.getElementById('btnRetryFailed')?.disabled === false
};
});
checks.dynamicQueue = dynamicQueue;
check(dynamicQueue.rows === 4, `Dynamic queue fixture rendered ${dynamicQueue.rows} of 4 rows`);
check(dynamicQueue.pending === 1 && dynamicQueue.downloading === 1 && dynamicQueue.completed === 1 && dynamicQueue.error === 1, 'Dynamic queue fixture does not expose all representative states');
check(dynamicQueue.determinateProgress === '42', `Dynamic queue progress is ${dynamicQueue.determinateProgress} instead of 42`);
check(dynamicQueue.retryEnabled, 'Dynamic queue error state does not enable Retry');
await win.evaluate(() => {
queue = [];
renderQueue();
renderVODs([{
id: 'locale-fixture',
title: 'Locale fixture',
created_at: '2026-08-10T12:00:00Z',
duration: '1h2m3s',
thumbnail_url: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==',
url: 'https://example.invalid/vod',
view_count: 1234
}], 'fixture');
});
await win.evaluate(() => window.changeLanguage('en'));
await win.keyboard.press('Control+K');
await win.waitForSelector('#commandPaletteModal.show');
const englishPalette = await win.evaluate(() => ({
labels: [...document.querySelectorAll('#commandPaletteList .cp-item-label')].map((item) => item.textContent?.trim() || ''),
hints: [...document.querySelectorAll('#commandPaletteList .cp-item-hint')].map((item) => item.textContent?.trim() || '')
}));
await win.keyboard.press('Escape');
await win.evaluate(() => window.showTab('archive'));
const englishChrome = await win.evaluate(() => ({
navSettings: document.getElementById('navSettingsText')?.textContent?.trim() || '',
contextHeading: document.querySelector('[data-context-for="archive"] [data-context-heading]')?.textContent?.trim() || '',
archiveResults: document.getElementById('archiveResultsNavText')?.textContent?.trim() || '',
selectFolder: document.getElementById('selectFolderBtn')?.textContent?.trim() || '',
later: document.getElementById('workspaceUpdateLater')?.textContent?.trim() || '',
dismiss: document.getElementById('workspaceUpdateDismiss')?.textContent?.trim() || document.getElementById('workspaceUpdateDismiss')?.getAttribute('aria-label')?.trim() || '',
mainNavigation: document.querySelector('.top-nav')?.getAttribute('aria-label') || '',
workspaceContext: document.querySelector('.context-sidebar')?.getAttribute('aria-label') || '',
vodWorkspace: document.querySelector('[data-context-for="vods"] .context-switcher')?.getAttribute('aria-label') || '',
clipSections: document.querySelector('[data-context-for="clips"] .context-list')?.getAttribute('aria-label') || '',
cutterSections: document.querySelector('[data-context-for="cutter"] .context-list')?.getAttribute('aria-label') || '',
mergeSections: document.querySelector('[data-context-for="merge"] .context-list')?.getAttribute('aria-label') || '',
statisticsSections: document.querySelector('[data-context-for="stats"] .context-list')?.getAttribute('aria-label') || '',
archiveSections: document.querySelector('[data-context-for="archive"] .context-list')?.getAttribute('aria-label') || '',
settingsSections: document.querySelector('[data-context-for="settings"] .context-list')?.getAttribute('aria-label') || '',
cutVideo: document.querySelector('button[onclick="startCutting()"]')?.getAttribute('aria-label') || '',
mergeVideos: document.querySelector('button[onclick="startMerging()"]')?.getAttribute('aria-label') || '',
streamerWorkspaceSwitch: document.querySelector('.context-switcher button:first-child')?.textContent?.trim() || '',
queueWorkspaceSwitch: document.querySelector('.context-switcher button:nth-child(2)')?.textContent?.trim() || '',
commandTitle: document.getElementById('commandPaletteTitle')?.textContent?.trim() || '',
commandPalette: document.getElementById('commandPaletteInput')?.getAttribute('aria-label') || '',
commandResults: document.getElementById('commandPaletteList')?.getAttribute('aria-label') || ''
}));
await win.evaluate(() => window.showTab('vods'));
const englishDate = await win.locator('.vod-card .vod-meta span').first().textContent();
const expectedEnglishDate = await win.evaluate(() => new Date('2026-08-10T12:00:00Z').toLocaleDateString('en-US'));
await win.evaluate(() => window.changeLanguage('de'));
await win.keyboard.press('Control+K');
await win.waitForSelector('#commandPaletteModal.show');
const germanPalette = await win.evaluate(() => ({
labels: [...document.querySelectorAll('#commandPaletteList .cp-item-label')].map((item) => item.textContent?.trim() || ''),
hints: [...document.querySelectorAll('#commandPaletteList .cp-item-hint')].map((item) => item.textContent?.trim() || '')
}));
await win.keyboard.press('Escape');
await win.evaluate(() => window.showTab('archive'));
const germanChrome = await win.evaluate(() => ({
navSettings: document.getElementById('navSettingsText')?.textContent?.trim() || '',
contextHeading: document.querySelector('[data-context-for="archive"] [data-context-heading]')?.textContent?.trim() || '',
archiveResults: document.getElementById('archiveResultsNavText')?.textContent?.trim() || '',
selectFolder: document.getElementById('selectFolderBtn')?.textContent?.trim() || '',
later: document.getElementById('workspaceUpdateLater')?.textContent?.trim() || '',
dismiss: document.getElementById('workspaceUpdateDismiss')?.textContent?.trim() || document.getElementById('workspaceUpdateDismiss')?.getAttribute('aria-label')?.trim() || '',
mainNavigation: document.querySelector('.top-nav')?.getAttribute('aria-label') || '',
workspaceContext: document.querySelector('.context-sidebar')?.getAttribute('aria-label') || '',
vodWorkspace: document.querySelector('[data-context-for="vods"] .context-switcher')?.getAttribute('aria-label') || '',
clipSections: document.querySelector('[data-context-for="clips"] .context-list')?.getAttribute('aria-label') || '',
cutterSections: document.querySelector('[data-context-for="cutter"] .context-list')?.getAttribute('aria-label') || '',
mergeSections: document.querySelector('[data-context-for="merge"] .context-list')?.getAttribute('aria-label') || '',
statisticsSections: document.querySelector('[data-context-for="stats"] .context-list')?.getAttribute('aria-label') || '',
archiveSections: document.querySelector('[data-context-for="archive"] .context-list')?.getAttribute('aria-label') || '',
settingsSections: document.querySelector('[data-context-for="settings"] .context-list')?.getAttribute('aria-label') || '',
cutVideo: document.querySelector('button[onclick="startCutting()"]')?.getAttribute('aria-label') || '',
mergeVideos: document.querySelector('button[onclick="startMerging()"]')?.getAttribute('aria-label') || '',
streamerWorkspaceSwitch: document.querySelector('.context-switcher button:first-child')?.textContent?.trim() || '',
queueWorkspaceSwitch: document.querySelector('.context-switcher button:nth-child(2)')?.textContent?.trim() || '',
commandTitle: document.getElementById('commandPaletteTitle')?.textContent?.trim() || '',
commandPalette: document.getElementById('commandPaletteInput')?.getAttribute('aria-label') || '',
commandResults: document.getElementById('commandPaletteList')?.getAttribute('aria-label') || ''
}));
await win.evaluate(() => window.showTab('vods'));
const germanDate = await win.locator('.vod-card .vod-meta span').first().textContent();
const expectedGermanDate = await win.evaluate(() => new Date('2026-08-10T12:00:00Z').toLocaleDateString('de-DE'));
checks.locale = { englishChrome, germanChrome, englishPalette, germanPalette, englishDate, expectedEnglishDate, germanDate, expectedGermanDate };
check(englishChrome.navSettings === 'Settings', `English top navigation says "${englishChrome.navSettings}"`);
check(englishChrome.contextHeading === 'Archive', `English context heading says "${englishChrome.contextHeading}"`);
check(englishChrome.archiveResults === 'Results', `English archive context action says "${englishChrome.archiveResults}"`);
check(/^later$/i.test(englishChrome.later), `English update Later action says "${englishChrome.later}"`);
check(/^close$/i.test(englishChrome.dismiss), `English update Close action says "${englishChrome.dismiss}"`);
check(englishChrome.mainNavigation === 'Main navigation', `English main navigation aria-label says "${englishChrome.mainNavigation}"`);
check(englishChrome.workspaceContext === 'Workspace context', `English workspace context aria-label says "${englishChrome.workspaceContext}"`);
check(englishChrome.vodWorkspace === 'VOD workspace', `English VOD workspace aria-label says "${englishChrome.vodWorkspace}"`);
check(englishChrome.clipSections === 'Clip sections', `English clip sections aria-label says "${englishChrome.clipSections}"`);
check(englishChrome.cutterSections === 'Video cutter sections', `English cutter sections aria-label says "${englishChrome.cutterSections}"`);
check(englishChrome.mergeSections === 'Video merge sections', `English merge sections aria-label says "${englishChrome.mergeSections}"`);
check(englishChrome.statisticsSections === 'Statistics sections', `English statistics sections aria-label says "${englishChrome.statisticsSections}"`);
check(englishChrome.archiveSections === 'Archive sections', `English archive sections aria-label says "${englishChrome.archiveSections}"`);
check(englishChrome.settingsSections === 'Settings sections', `English settings sections aria-label says "${englishChrome.settingsSections}"`);
check(englishChrome.cutVideo === 'Cut video', `English cut action aria-label says "${englishChrome.cutVideo}"`);
check(englishChrome.mergeVideos === 'Merge videos', `English merge action aria-label says "${englishChrome.mergeVideos}"`);
check(englishChrome.streamerWorkspaceSwitch === 'Streamer', `English streamer workspace switch says "${englishChrome.streamerWorkspaceSwitch}"`);
check(englishChrome.queueWorkspaceSwitch === 'Queue', `English queue workspace switch says "${englishChrome.queueWorkspaceSwitch}"`);
check(JSON.stringify(englishPalette.labels) === JSON.stringify(['VODs', 'Clips', 'Video cutter', 'Merge videos', 'Statistics', 'Archive', 'Settings']), `English command palette labels are [${englishPalette.labels.join(', ')}]`);
check(englishPalette.hints.length === 7 && englishPalette.hints.every((hint) => hint === 'Open'), `English command palette hints are [${englishPalette.hints.join(', ')}]`);
check(englishChrome.commandTitle === 'Command palette', `English command palette title says "${englishChrome.commandTitle}"`);
check(englishChrome.commandPalette === 'Command palette', `English command palette aria-label says "${englishChrome.commandPalette}"`);
check(englishChrome.commandResults === 'Command results', `English command results aria-label says "${englishChrome.commandResults}"`);
check(germanChrome.navSettings === 'Einstellungen', `German top navigation says "${germanChrome.navSettings}"`);
check(germanChrome.contextHeading === 'Archiv', `German context heading says "${germanChrome.contextHeading}"`);
check(/^Ergebnisse$/i.test(germanChrome.archiveResults), `German archive context action says "${germanChrome.archiveResults}"`);
check(/^(Später|Spaeter)$/i.test(germanChrome.later), `German update Later action says "${germanChrome.later}"`);
check(/^(Schließen|Schliessen)$/i.test(germanChrome.dismiss), `German update Close action says "${germanChrome.dismiss}"`);
check(Boolean(englishChrome.selectFolder) && Boolean(germanChrome.selectFolder) && englishChrome.selectFolder !== germanChrome.selectFolder, 'Folder chooser action does not switch between English and German');
check(germanChrome.mainNavigation === 'Hauptnavigation', `German main navigation aria-label says "${germanChrome.mainNavigation}"`);
check(germanChrome.workspaceContext === 'Arbeitsbereichskontext', `German workspace context aria-label says "${germanChrome.workspaceContext}"`);
check(germanChrome.vodWorkspace === 'VOD-Arbeitsbereich', `German VOD workspace aria-label says "${germanChrome.vodWorkspace}"`);
check(germanChrome.clipSections === 'Clip-Bereiche', `German clip sections aria-label says "${germanChrome.clipSections}"`);
check(germanChrome.cutterSections === 'Videoschnitt-Bereiche', `German cutter sections aria-label says "${germanChrome.cutterSections}"`);
check(germanChrome.mergeSections === 'Zusammenfügen-Bereiche', `German merge sections aria-label says "${germanChrome.mergeSections}"`);
check(germanChrome.statisticsSections === 'Statistik-Bereiche', `German statistics sections aria-label says "${germanChrome.statisticsSections}"`);
check(germanChrome.archiveSections === 'Archiv-Bereiche', `German archive sections aria-label says "${germanChrome.archiveSections}"`);
check(germanChrome.settingsSections === 'Einstellungsbereiche', `German settings sections aria-label says "${germanChrome.settingsSections}"`);
check(germanChrome.cutVideo === 'Video schneiden', `German cut action aria-label says "${germanChrome.cutVideo}"`);
check(germanChrome.mergeVideos === 'Videos zusammenfügen', `German merge action aria-label says "${germanChrome.mergeVideos}"`);
check(germanChrome.streamerWorkspaceSwitch === 'Streamer', `German streamer workspace switch says "${germanChrome.streamerWorkspaceSwitch}"`);
check(germanChrome.queueWorkspaceSwitch === 'Warteschlange', `German queue workspace switch says "${germanChrome.queueWorkspaceSwitch}"`);
check(JSON.stringify(germanPalette.labels) === JSON.stringify(['VODs', 'Clips', 'Videoschnitt', 'Videos zusammenfügen', 'Statistiken', 'Archiv', 'Einstellungen']), `German command palette labels are [${germanPalette.labels.join(', ')}]`);
check(germanPalette.hints.length === 7 && germanPalette.hints.every((hint) => hint === 'Öffnen'), `German command palette hints are [${germanPalette.hints.join(', ')}]`);
check(germanChrome.commandTitle === 'Befehlspalette', `German command palette title says "${germanChrome.commandTitle}"`);
check(germanChrome.commandPalette === 'Befehlspalette', `German command palette aria-label says "${germanChrome.commandPalette}"`);
check(germanChrome.commandResults === 'Befehlsergebnisse', `German command results aria-label says "${germanChrome.commandResults}"`);
check(englishDate === expectedEnglishDate, `English VOD date is "${englishDate}" instead of "${expectedEnglishDate}"`);
check(germanDate === expectedGermanDate, `German VOD date is "${germanDate}" instead of "${expectedGermanDate}"`);
await win.waitForFunction(() => !document.getElementById('btnStatsRefresh')?.disabled && !document.getElementById('btnArchiveSearch')?.disabled);
await app.evaluate(({ ipcMain }) => {
globalThis.__workspaceLocalizedErrorCalls = { stats: 0, archive: 0 };
ipcMain.removeHandler('get-archive-stats');
ipcMain.handle('get-archive-stats', () => {
globalThis.__workspaceLocalizedErrorCalls.stats += 1;
throw new Error('localized stats fixture');
});
ipcMain.removeHandler('search-archive');
ipcMain.handle('search-archive', () => {
globalThis.__workspaceLocalizedErrorCalls.archive += 1;
throw new Error('localized archive fixture');
});
});
const captureLocalizedErrors = async (language, statsPrefix, archivePrefix, expectedStatsCalls, expectedArchiveCalls) => {
await win.evaluate((nextLanguage) => {
window.changeLanguage(nextLanguage);
window.showTab('stats');
}, language);
await win.waitForFunction((prefix) => document.getElementById('statsSummaryGrid')?.textContent?.trim().startsWith(prefix), statsPrefix);
await win.waitForFunction(() => !document.getElementById('btnStatsRefresh')?.disabled);
const statsCalls = await app.evaluate(() => globalThis.__workspaceLocalizedErrorCalls.stats);
check(statsCalls === expectedStatsCalls, `${language} statistics error path made ${statsCalls} IPC calls instead of ${expectedStatsCalls}`);
const stats = await win.locator('#statsSummaryGrid').textContent();
await win.evaluate(() => window.showTab('archive'));
await win.waitForFunction((prefix) => document.getElementById('archiveSearchSummary')?.textContent?.trim().startsWith(prefix), archivePrefix);
await win.waitForFunction(() => !document.getElementById('btnArchiveSearch')?.disabled);
const archiveCalls = await app.evaluate(() => globalThis.__workspaceLocalizedErrorCalls.archive);
check(archiveCalls === expectedArchiveCalls, `${language} archive error path made ${archiveCalls} IPC calls instead of ${expectedArchiveCalls}`);
const archive = await win.locator('#archiveSearchSummary').textContent();
return { stats: stats?.trim() || '', archive: archive?.trim() || '' };
};
const localizedErrors = {
english: await captureLocalizedErrors('en', 'Error:', 'Error:', 1, 1),
german: await captureLocalizedErrors('de', 'Fehler:', 'Fehler:', 2, 2)
};
checks.localizedErrors = localizedErrors;
check(localizedErrors.english.stats.startsWith('Error:'), `English statistics error says "${localizedErrors.english.stats}"`);
check(localizedErrors.english.archive.startsWith('Error:'), `English archive error says "${localizedErrors.english.archive}"`);
check(localizedErrors.german.stats.startsWith('Fehler:'), `German statistics error says "${localizedErrors.german.stats}"`);
check(localizedErrors.german.archive.startsWith('Fehler:'), `German archive error says "${localizedErrors.german.archive}"`);
await app.evaluate(({ ipcMain }) => {
ipcMain.removeHandler('get-archive-stats');
ipcMain.handle('get-archive-stats', () => ({
totalFiles: 0,
totalBytes: 0,
liveCount: 0,
liveBytes: 0,
vodCount: 0,
vodBytes: 0,
chatCount: 0,
chatBytes: 0,
eventsCount: 0,
streamerCount: 0,
avgRecordingSizeBytes: 0,
topStreamers: [],
dailyActivity: [],
sizeBuckets: [],
scannedAt: '2026-08-10T12:00:00Z',
downloadPath: '',
rootExists: true
}));
ipcMain.removeHandler('search-archive');
ipcMain.handle('search-archive', () => ({
rootExists: true,
totalScanned: 0,
matchCount: 0,
truncated: false,
hits: []
}));
});
await win.evaluate(() => window.changeLanguage('en'));
await win.setViewportSize(TARGETS[0]);
await win.evaluate(() => window.showTab('vods'));
await win.waitForTimeout(160);
await win.screenshot({
path: path.join(artifactDir, `workspace-vods-fixture-${TARGETS[0].width}x${TARGETS[0].height}.png`),
fullPage: true
});
await win.evaluate(() => {
vodRenderTaskId += 1;
lastLoadedVods = [];
lastLoadedStreamer = null;
setVodGridEmptyState(document.getElementById('vodGrid'), UI_TEXT.vods.noneTitle, UI_TEXT.vods.noneText);
updateVodFilterCount(0, 0);
});
await win.evaluate(() => window.showTab('settings'));
const captureTheme = async () => win.evaluate(() => {
const parse = (value) => {
const parts = value.match(/[\d.]+/g)?.map(Number) || [];
return { r: parts[0] || 0, g: parts[1] || 0, b: parts[2] || 0, a: parts.length > 3 ? parts[3] : 1 };
};
const linear = (channel) => {
const value = channel / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
};
const luminance = (color) => 0.2126 * linear(color.r) + 0.7152 * linear(color.g) + 0.0722 * linear(color.b);
const contrast = (foreground, background) => {
const lighter = Math.max(luminance(foreground), luminance(background));
const darker = Math.min(luminance(foreground), luminance(background));
return (lighter + 0.05) / (darker + 0.05);
};
const effectiveBackground = (element) => {
let current = element;
while (current) {
const value = getComputedStyle(current).backgroundColor;
if (parse(value).a > 0) return value;
current = current.parentElement;
}
return 'rgb(255, 255, 255)';
};
const pair = (selector) => {
const element = document.querySelector(selector);
if (!element) return null;
const foreground = getComputedStyle(element).color;
const background = effectiveBackground(element);
return { foreground, background, contrast: contrast(parse(foreground), parse(background)) };
};
return {
bodyClass: document.body.className,
bodyBackground: getComputedStyle(document.body).backgroundColor,
bodyColor: getComputedStyle(document.body).color,
title: pair('#pageTitle'),
contextHeading: pair('[data-context-for="settings"] [data-context-heading]'),
settingsSearch: pair('#settingsSearchInput'),
toolbarAction: pair('#toolbarCheckUpdateBtn')
};
});
await win.emulateMedia({ colorScheme: 'dark' });
await win.locator('#workspaceThemePicker [data-theme="twitch"]').click();
await win.waitForTimeout(160);
const darkTheme = await captureTheme();
await win.locator('#workspaceThemePicker [data-theme="light"]').click();
await win.waitForTimeout(160);
const lightTheme = await captureTheme();
await win.emulateMedia({ colorScheme: 'light' });
await win.locator('#workspaceThemePicker [data-theme="system"]').click();
await win.waitForTimeout(160);
const systemLightTheme = await captureTheme();
checks.themes = { darkTheme, lightTheme, systemLightTheme };
for (const [name, theme] of Object.entries({ dark: darkTheme, light: lightTheme, systemLight: systemLightTheme })) {
check(Boolean(theme.title && theme.contextHeading && theme.settingsSearch && theme.toolbarAction), `${name} theme is missing a representative computed-style target`);
for (const [pairName, pair] of Object.entries({ title: theme.title, contextHeading: theme.contextHeading, settingsSearch: theme.settingsSearch, toolbarAction: theme.toolbarAction })) {
if (pair) check(pair.contrast >= 4.5, `${name} ${pairName} contrast is ${pair.contrast.toFixed(2)}:1`);
}
}
check(darkTheme.bodyClass === 'theme-twitch', `Dark theme body class is ${darkTheme.bodyClass}`);
check(lightTheme.bodyClass === 'theme-light', `Light theme body class is ${lightTheme.bodyClass}`);
check(systemLightTheme.bodyClass === 'theme-system', `System theme body class is ${systemLightTheme.bodyClass}`);
check(darkTheme.bodyBackground !== lightTheme.bodyBackground, 'Explicit Dark and Light themes compute the same body background');
check(systemLightTheme.bodyBackground === lightTheme.bodyBackground, `System-Light background ${systemLightTheme.bodyBackground} does not match Light ${lightTheme.bodyBackground}`);
check(systemLightTheme.bodyColor === lightTheme.bodyColor, `System-Light text ${systemLightTheme.bodyColor} does not match Light ${lightTheme.bodyColor}`);
await win.screenshot({
path: path.join(artifactDir, `workspace-settings-system-light-${TARGETS[0].width}x${TARGETS[0].height}.png`),
fullPage: true
});
await win.locator('#workspaceThemePicker [data-theme="light"]').click();
await win.waitForTimeout(160);
await win.screenshot({
path: path.join(artifactDir, `workspace-settings-light-${TARGETS[0].width}x${TARGETS[0].height}.png`),
fullPage: true
});
await win.emulateMedia({ colorScheme: 'dark' });
await win.locator('#workspaceThemePicker [data-theme="twitch"]').click();
const finalScreenshotTheme = await win.evaluate(() => ({
bodyClass: document.body.className,
bodyBackground: getComputedStyle(document.body).backgroundColor,
vodFixtureCards: document.querySelectorAll('#vodGrid .vod-card').length
}));
checks.finalScreenshotTheme = finalScreenshotTheme;
check(finalScreenshotTheme.bodyClass === 'theme-twitch', `Canonical screenshots use ${finalScreenshotTheme.bodyClass} instead of Dark`);
check(finalScreenshotTheme.bodyBackground === darkTheme.bodyBackground, `Canonical screenshot background ${finalScreenshotTheme.bodyBackground} does not match Dark ${darkTheme.bodyBackground}`);
check(finalScreenshotTheme.vodFixtureCards === 0, `Canonical screenshots retain ${finalScreenshotTheme.vodFixtureCards} VOD fixture cards`);
await win.evaluate(() => window.setUpdateBannerAvailableUi({ version: '9.9.9' }));
await win.locator('#workspaceUpdateButton').hover();
await win.waitForFunction(() => {
const popover = document.getElementById('workspaceUpdatePopover');
const style = popover ? getComputedStyle(popover) : null;
return Boolean(style && style.visibility === 'visible' && Number(style.opacity) > 0);
});
const updateScreenshotState = await win.evaluate(() => ({
bodyClass: document.body.className,
state: document.getElementById('updateBanner')?.dataset.updateState || '',
laterVisible: document.getElementById('workspaceUpdateLater')?.hidden === false,
dismissVisible: document.getElementById('workspaceUpdateDismiss')?.hidden === false
}));
checks.updateScreenshotState = updateScreenshotState;
check(updateScreenshotState.bodyClass === 'theme-twitch', `Update screenshot uses ${updateScreenshotState.bodyClass} instead of Dark`);
check(updateScreenshotState.state === 'available', `Update screenshot uses ${updateScreenshotState.state} instead of available state`);
check(updateScreenshotState.laterVisible && updateScreenshotState.dismissVisible, 'Update screenshot does not expose both Later and Close');
await win.waitForTimeout(160);
await win.screenshot({ await win.screenshot({
path: path.join(artifactDir, `workspace-update-${TARGETS[0].width}x${TARGETS[0].height}.png`), path: path.join(artifactDir, `workspace-update-${TARGETS[0].width}x${TARGETS[0].height}.png`),
fullPage: true fullPage: true
}); });
await win.mouse.move(Math.floor(TARGETS[0].width / 2), Math.floor(TARGETS[0].height / 2));
await win.evaluate(() => window.hideUpdateBanner()); await win.evaluate(() => window.hideUpdateBanner());
}
const targetChecks = []; const responsiveQueueFixtures = [
{ id: 'responsive-pending', title: 'Responsive pending fixture with a deliberately long title that must remain contained inside the queue row', url: 'https://example.invalid/responsive-pending', date: '2026-08-10T12:00:00Z', streamer: 'fixture_streamer', duration_str: '12h34m56s', status: 'pending', progress: 0 },
{ id: 'responsive-downloading', title: 'Responsive downloading fixture with a deliberately long title that must not widen the workspace', url: 'https://example.invalid/responsive-downloading', date: '2026-08-10T12:00:00Z', streamer: 'fixture_streamer', duration_str: '12h34m56s', status: 'downloading', progress: 67 },
{ id: 'responsive-completed', title: 'Responsive completed fixture with a deliberately long title for overflow coverage', url: 'https://example.invalid/responsive-completed', date: '2026-08-10T12:00:00Z', streamer: 'fixture_streamer', duration_str: '12h34m56s', status: 'completed', progress: 100 },
{ id: 'responsive-error', title: 'Responsive error fixture with a deliberately long title and representative failure state', url: 'https://example.invalid/responsive-error', date: '2026-08-10T12:00:00Z', streamer: 'fixture_streamer', duration_str: '12h34m56s', status: 'error', progress: 0, last_error: 'Offline responsive fixture failure' }
];
await app.evaluate(({ ipcMain }, queueFixtures) => {
globalThis.__workspaceResponsiveQueueSyncCalls = 0;
ipcMain.removeHandler('get-queue');
ipcMain.handle('get-queue', () => {
globalThis.__workspaceResponsiveQueueSyncCalls += 1;
return queueFixtures.map((item) => ({ ...item }));
});
}, responsiveQueueFixtures);
await win.evaluate((queueFixtures) => {
const vodFixtures = [
{ id: 'responsive-vod-one', title: 'Responsive VOD fixture with a deliberately long title that must stay inside its card at every supported width', created_at: '2026-08-10T12:00:00Z', duration: '12h34m56s', thumbnail_url: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==', url: 'https://example.invalid/responsive-vod-one', view_count: 987654 },
{ id: 'responsive-vod-two', title: 'Second responsive VOD fixture covering multi-card layout and long metadata without horizontal overflow', created_at: '2026-08-09T12:00:00Z', duration: '9h8m7s', thumbnail_url: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==', url: 'https://example.invalid/responsive-vod-two', view_count: 123456 }
];
window.__restoreResponsiveFixtures = () => {
queue = queueFixtures.map((item) => ({ ...item }));
renderQueue();
renderVODs(vodFixtures.map((item) => ({ ...item })), 'fixture_streamer');
};
window.__restoreResponsiveFixtures();
}, responsiveQueueFixtures);
await win.evaluate(() => syncQueueAndDownloadState());
const responsiveQueueSyncCalls = await app.evaluate(() => globalThis.__workspaceResponsiveQueueSyncCalls);
const responsiveFixtures = await win.evaluate(() => {
return {
queueRows: document.querySelectorAll('#queueList .queue-item').length,
vodCards: document.querySelectorAll('#vodGrid .vod-card').length
};
});
checks.responsiveFixtures = { ...responsiveFixtures, syncCalls: responsiveQueueSyncCalls };
check(responsiveQueueSyncCalls >= 1, 'Responsive queue fixtures were not observed through the queue sync IPC path');
check(responsiveFixtures.queueRows === 4, `Responsive fixture rendered ${responsiveFixtures.queueRows} queue rows instead of 4`);
check(responsiveFixtures.vodCards === 2, `Responsive fixture rendered ${responsiveFixtures.vodCards} VOD cards instead of 2`);
const responsiveTabs = {};
for (const target of TARGETS) { for (const target of TARGETS) {
await win.setViewportSize(target); await win.setViewportSize(target);
await win.waitForTimeout(100); await win.evaluate(() => window.__restoreResponsiveFixtures());
await win.waitForTimeout(50);
responsiveTabs[`${target.width}x${target.height}`] = {};
for (const tab of TABS) {
await win.evaluate((tabId) => window.showTab(tabId), tab);
await win.waitForTimeout(160);
const state = await win.evaluate((tabId) => {
const isVisible = (element) => {
if (!element || element.hidden) return false;
const style = getComputedStyle(element);
return style.display !== 'none' && style.visibility !== 'hidden';
};
const navItem = document.querySelector(`.top-nav button[data-tab="${tabId}"]`);
const contextPanels = [...document.querySelectorAll('[data-context-for]')].filter(isVisible);
const toolbars = [...document.querySelectorAll('[data-toolbar-for]')].filter(isVisible);
const tabContents = [...document.querySelectorAll('.tab-content')].filter(isVisible);
return {
current: navItem?.getAttribute('aria-current') === 'page',
contextPanels: contextPanels.map((panel) => panel.dataset.contextFor || ''),
toolbars: toolbars.map((toolbar) => toolbar.dataset.toolbarFor || ''),
tabContents: tabContents.map((content) => content.id || ''),
viewportWidth: document.documentElement.clientWidth,
documentScrollWidth: document.documentElement.scrollWidth,
bodyScrollWidth: document.body.scrollWidth,
workspaceScrollWidth: document.querySelector('.workspace-shell')?.scrollWidth || 0,
workspaceClientWidth: document.querySelector('.workspace-shell')?.clientWidth || 0,
queueRows: document.querySelectorAll('#queueList .queue-item').length,
vodCards: document.querySelectorAll('#vodGrid .vod-card').length
};
}, tab);
responsiveTabs[`${target.width}x${target.height}`][tab] = state;
check(state.current, `${tab} is not current at ${target.width}x${target.height}`);
check(state.contextPanels.length === 1 && state.contextPanels[0] === tab, `${tab} exposes context panels [${state.contextPanels.join(', ')}] at ${target.width}x${target.height}`);
check(state.toolbars.length === 1 && state.toolbars[0] === tab, `${tab} exposes toolbars [${state.toolbars.join(', ')}] at ${target.width}x${target.height}`);
check(state.tabContents.length === 1 && state.tabContents[0] === `${tab}Tab`, `${tab} exposes tab contents [${state.tabContents.join(', ')}] at ${target.width}x${target.height}`);
check(state.documentScrollWidth <= state.viewportWidth + 1, `${tab} document overflows horizontally at ${target.width}x${target.height}`);
check(state.bodyScrollWidth <= state.viewportWidth + 1, `${tab} body overflows horizontally at ${target.width}x${target.height}`);
check(state.workspaceScrollWidth <= state.workspaceClientWidth + 1, `${tab} workspace overflows horizontally at ${target.width}x${target.height}`);
if (tab === 'vods') {
check(state.queueRows === 4, `VOD workspace lost responsive queue fixtures at ${target.width}x${target.height}`);
check(state.vodCards === 2, `VOD workspace lost responsive VOD fixtures at ${target.width}x${target.height}`);
}
if (target.width === TARGETS[0].width) {
await win.screenshot({
path: path.join(artifactDir, `workspace-${tab}-${target.width}x${target.height}.png`),
fullPage: true
});
}
}
const geometry = await win.evaluate(() => { const geometry = await win.evaluate(() => {
const topbar = document.querySelector('.app-topbar')?.getBoundingClientRect(); const topbar = document.querySelector('.app-topbar')?.getBoundingClientRect();
const sidebar = document.querySelector('.context-sidebar')?.getBoundingClientRect(); const sidebar = document.querySelector('.context-sidebar')?.getBoundingClientRect();
const toolbar = document.querySelector('.workspace-toolbar')?.getBoundingClientRect(); const toolbar = document.querySelector('.workspace-toolbar')?.getBoundingClientRect();
const updateButton = document.getElementById('workspaceUpdateButton')?.getBoundingClientRect(); const updateButton = document.getElementById('workspaceUpdateButton')?.getBoundingClientRect();
return { return {
viewportWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
topbarHeight: topbar?.height || 0, topbarHeight: topbar?.height || 0,
sidebarWidth: sidebar?.width || 0, sidebarWidth: sidebar?.width || 0,
toolbarHeight: toolbar?.height || 0, toolbarHeight: toolbar?.height || 0,
updateVisible: Boolean(updateButton && updateButton.width > 0 && updateButton.height > 0) updateVisible: Boolean(updateButton && updateButton.width > 0 && updateButton.height > 0)
}; };
}); });
responsiveTabs[`${target.width}x${target.height}`].geometry = geometry;
targetChecks.push({ ...target, ...geometry });
check(geometry.scrollWidth <= geometry.viewportWidth + 1, `Horizontal overflow at ${target.width}x${target.height}`);
check(geometry.topbarHeight >= 39 && geometry.topbarHeight <= 41, `Topbar height is ${geometry.topbarHeight}px at ${target.width}x${target.height}`); check(geometry.topbarHeight >= 39 && geometry.topbarHeight <= 41, `Topbar height is ${geometry.topbarHeight}px at ${target.width}x${target.height}`);
check(geometry.sidebarWidth >= 260 && geometry.sidebarWidth <= 272, `Context sidebar width is ${geometry.sidebarWidth}px at ${target.width}x${target.height}`); check(geometry.sidebarWidth >= 260 && geometry.sidebarWidth <= 272, `Context sidebar width is ${geometry.sidebarWidth}px at ${target.width}x${target.height}`);
check(geometry.toolbarHeight >= 59 && geometry.toolbarHeight <= 61, `Workspace toolbar height is ${geometry.toolbarHeight}px at ${target.width}x${target.height}`); check(geometry.toolbarHeight >= 59 && geometry.toolbarHeight <= 61, `Workspace toolbar height is ${geometry.toolbarHeight}px at ${target.width}x${target.height}`);
check(geometry.updateVisible, `Update action is not visible at ${target.width}x${target.height}`); check(geometry.updateVisible, `Update action is not visible at ${target.width}x${target.height}`);
await win.screenshot({ await win.screenshot({
path: path.join(artifactDir, `workspace-${target.width}x${target.height}.png`), path: path.join(artifactDir, `workspace-${target.width}x${target.height}.png`),
fullPage: true fullPage: true
}); });
} }
checks.targets = targetChecks; checks.responsiveTabs = responsiveTabs;
await app.evaluate(({ ipcMain }) => {
globalThis.__workspaceClipIpcState = { calls: 0, resolve: null };
globalThis.__workspaceStatsIpcState = { calls: 0, resolve: null };
ipcMain.removeHandler('download-clip');
ipcMain.handle('download-clip', () => {
globalThis.__workspaceClipIpcState.calls += 1;
return new Promise((resolve) => {
globalThis.__workspaceClipIpcState.resolve = resolve;
});
});
ipcMain.removeHandler('get-archive-stats');
ipcMain.handle('get-archive-stats', () => {
globalThis.__workspaceStatsIpcState.calls += 1;
return new Promise((resolve) => {
globalThis.__workspaceStatsIpcState.resolve = resolve;
});
});
});
await win.evaluate(() => {
document.getElementById('clipUrl').value = 'https://clips.twitch.tv/ContractFixture';
document.getElementById('btnClip').click();
void window.downloadClip();
document.getElementById('toolbarClipDownloadBtn').click();
});
await win.waitForFunction(() => document.getElementById('btnClip')?.disabled && document.getElementById('toolbarClipDownloadBtn')?.disabled);
const clipPending = await win.evaluate(() => ({
mainDisabled: document.getElementById('btnClip')?.disabled === true,
toolbarDisabled: document.getElementById('toolbarClipDownloadBtn')?.disabled === true
}));
const clipIpcCalls = await app.evaluate(() => globalThis.__workspaceClipIpcState.calls);
checks.clipConcurrency = { ...clipPending, ipcCalls: clipIpcCalls };
check(clipIpcCalls === 1, `Parallel downloadClip activation produced ${clipIpcCalls} IPC calls instead of one`);
check(clipPending.mainDisabled && clipPending.toolbarDisabled, 'Clip main and toolbar triggers are not both disabled while the IPC call is pending');
await app.evaluate(() => globalThis.__workspaceClipIpcState.resolve({ success: true }));
await win.waitForFunction(() => !document.getElementById('btnClip')?.disabled && !document.getElementById('toolbarClipDownloadBtn')?.disabled);
await win.evaluate(() => {
document.getElementById('btnStatsRefresh').click();
void window.refreshArchiveStats();
document.getElementById('toolbarStatsRefreshBtn').click();
});
await win.waitForFunction(() => document.getElementById('btnStatsRefresh')?.disabled && document.getElementById('toolbarStatsRefreshBtn')?.disabled);
const statsPending = await win.evaluate(() => ({
mainDisabled: document.getElementById('btnStatsRefresh')?.disabled === true,
toolbarDisabled: document.getElementById('toolbarStatsRefreshBtn')?.disabled === true
}));
const statsIpcCalls = await app.evaluate(() => globalThis.__workspaceStatsIpcState.calls);
checks.statsConcurrency = { ...statsPending, ipcCalls: statsIpcCalls };
check(statsIpcCalls === 1, `Parallel refreshArchiveStats activation produced ${statsIpcCalls} IPC calls instead of one`);
check(statsPending.mainDisabled && statsPending.toolbarDisabled, 'Statistics main and toolbar triggers are not both disabled while the IPC call is pending');
await app.evaluate(() => globalThis.__workspaceStatsIpcState.resolve({
totalFiles: 0,
totalBytes: 0,
liveCount: 0,
liveBytes: 0,
vodCount: 0,
vodBytes: 0,
chatCount: 0,
chatBytes: 0,
eventsCount: 0,
streamerCount: 0,
avgRecordingSizeBytes: 0,
topStreamers: [],
dailyActivity: [],
sizeBuckets: [],
scannedAt: new Date().toISOString(),
downloadPath: '',
rootExists: true
}));
await win.waitForFunction(() => !document.getElementById('btnStatsRefresh')?.disabled && !document.getElementById('toolbarStatsRefreshBtn')?.disabled);
} finally { } finally {
if (app) await app.close(); if (app) await app.close();
fs.rmSync(tempRoot, { recursive: true, force: true }); cleanupE2eEnvironment(environment);
} }
const result = { checks, failures, runtimeIssues }; const result = { checks, failures, runtimeIssues };
@@ -247,5 +897,5 @@ async function run() {
run().catch((error) => { run().catch((error) => {
console.error(error); console.error(error);
process.exit(1); process.exitCode = 1;
}); });
+31 -16
View File
@@ -1,14 +1,21 @@
const { _electron: electron } = require('playwright'); const { _electron: electron } = require('playwright');
const {
createE2eEnvironment,
getElectronLaunchOptions,
verifyE2eIsolation,
installOfflineFixtures,
cleanupE2eEnvironment
} = require('./e2e-test-environment');
async function run() { async function run() {
const electronPath = require('electron'); const environment = createE2eEnvironment('smoke');
const app = await electron.launch({ let app = null;
executablePath: electronPath,
args: ['.'],
cwd: process.cwd()
});
try {
app = await electron.launch(getElectronLaunchOptions(environment));
const win = await app.firstWindow(); const win = await app.firstWindow();
const isolation = await verifyE2eIsolation(app, win, environment);
const fixtures = await installOfflineFixtures(app);
const issues = []; const issues = [];
win.on('pageerror', (err) => { win.on('pageerror', (err) => {
@@ -69,10 +76,10 @@ async function run() {
}, randomName); }, randomName);
await win.evaluate(async () => { await win.evaluate(async () => {
await window.selectStreamer('xrohat'); await window.selectStreamer('fixture_streamer');
}); });
await win.waitForTimeout(3500); await win.waitForTimeout(500);
const vodCount = await win.locator('.vod-card').count(); const vodCount = await win.locator('.vod-card').count();
@@ -109,14 +116,12 @@ async function run() {
const mergeButtonDisabled = await win.locator('#btnMerge').isDisabled(); const mergeButtonDisabled = await win.locator('#btnMerge').isDisabled();
const preflightText = await win.locator('#preflightResult').innerText(); const preflightText = await win.locator('#preflightResult').innerText();
const healthBadge = await win.locator('#healthBadge').innerText(); const healthBadge = await win.locator('#healthBadge').innerText();
await app.close();
const failedGlobals = Object.entries(globals) const failedGlobals = Object.entries(globals)
.filter(([, type]) => type !== 'function') .filter(([, type]) => type !== 'function')
.map(([name, type]) => `${name}=${type}`); .map(([name, type]) => `${name}=${type}`);
const summary = { const summary = {
isolation,
fixtures,
failedGlobals, failedGlobals,
hasTempStreamer: hasTempStreamer.includes(randomName), hasTempStreamer: hasTempStreamer.includes(randomName),
vodCount, vodCount,
@@ -140,10 +145,20 @@ async function run() {
!summary.healthBadge || !summary.healthBadge ||
summary.issues.length > 0; summary.issues.length > 0;
process.exit(hasFailure ? 1 : 0); return hasFailure ? 1 : 0;
} finally {
if (app) {
await app.close().catch(() => undefined);
}
cleanupE2eEnvironment(environment);
}
} }
run().catch((err) => { run()
.then((exitCode) => {
process.exitCode = exitCode;
})
.catch((err) => {
console.error(err); console.error(err);
process.exit(1); process.exitCode = 1;
}); });
+59 -47
View File
@@ -212,24 +212,33 @@
</div> </div>
<div class="topbar-actions"> <div class="topbar-actions">
<div class="update-banner workspace-update" id="updateBanner" data-update-state="idle"> <div class="update-banner workspace-update" id="updateBanner" data-update-state="idle">
<button type="button" class="workspace-update-button" id="workspaceUpdateButton" onclick="handleWorkspaceUpdateAction()" aria-describedby="updateText"> <button type="button" class="workspace-update-button" id="workspaceUpdateButton" onclick="handleWorkspaceUpdateAction()" aria-describedby="updateText" aria-controls="workspaceUpdatePopover" aria-expanded="false">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"></path><path d="m8 11 4 4 4-4"></path><path d="M4 19h16"></path></svg> <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"></path><path d="m8 11 4 4 4-4"></path><path d="M4 19h16"></path></svg>
<span id="workspaceUpdateLabel">Update</span> <span id="workspaceUpdateLabel">Update</span>
</button> </button>
<div class="workspace-update-popover" role="status" aria-live="polite"> <div class="workspace-update-popover" id="workspaceUpdatePopover">
<span id="updateText">Nach Updates suchen.</span> <div class="workspace-update-popover-header">
<span id="updateText" role="status" aria-live="polite">Nach Updates suchen.</span>
<button type="button" id="workspaceUpdateDismiss" onclick="dismissWorkspaceUpdatePopover()" aria-label="Update notification dismiss">
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="m6 6 12 12M18 6 6 18"></path></svg>
<span class="sr-only" id="workspaceUpdateDismissText">Close</span>
</button>
</div>
<div id="updateProgress" class="update-banner-progress-wrap is-hidden"> <div id="updateProgress" class="update-banner-progress-wrap is-hidden">
<div class="update-banner-progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Update download" id="updateProgressGauge"> <div class="update-banner-progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Update download" id="updateProgressGauge">
<div id="updateProgressBar" class="update-banner-progress-bar"></div> <div id="updateProgressBar" class="update-banner-progress-bar"></div>
</div> </div>
</div> </div>
<div class="workspace-update-popover-actions">
<button type="button" id="updateButton" onclick="downloadUpdate()">Jetzt herunterladen</button> <button type="button" id="updateButton" onclick="downloadUpdate()">Jetzt herunterladen</button>
<button type="button" id="workspaceUpdateLater" onclick="postponeWorkspaceUpdatePopover()">Spater</button>
</div> </div>
</div> </div>
<button type="button" class="topbar-icon-button" onclick="showTab('settings')" aria-label="System status" title="System status"> </div>
<button type="button" class="topbar-icon-button" id="systemStatusButton" onclick="showTab('settings')" aria-label="System status" title="System status">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l2-5 4 10 2-5h6"></path></svg> <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l2-5 4 10 2-5h6"></path></svg>
</button> </button>
<button type="button" class="topbar-account-button" onclick="showTab('settings')" aria-label="Open settings" title="Open settings"> <button type="button" class="topbar-account-button" id="openSettingsButton" onclick="showTab('settings')" aria-label="Open settings" title="Open settings">
<span>TV</span> <span>TV</span>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m8 10 4 4 4-4"></path></svg> <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m8 10 4 4 4-4"></path></svg>
</button> </button>
@@ -240,8 +249,8 @@
<aside class="sidebar context-sidebar" aria-label="Workspace context"> <aside class="sidebar context-sidebar" aria-label="Workspace context">
<section class="context-panel" data-context-for="vods"> <section class="context-panel" data-context-for="vods">
<div class="context-switcher" role="group" aria-label="VOD workspace"> <div class="context-switcher" role="group" aria-label="VOD workspace">
<button type="button" class="active" onclick="focusWorkspaceTarget('streamerList')">Streamer</button> <button type="button" id="streamerWorkspaceSwitch" class="active" aria-pressed="true" onclick="focusWorkspaceTarget('streamerList', this)">Streamer</button>
<button type="button" onclick="focusWorkspaceTarget('queueList')">Queue</button> <button type="button" id="queueWorkspaceSwitch" aria-pressed="false" onclick="focusWorkspaceTarget('queueList', this)">Warteschlange</button>
</div> </div>
<div class="section-title" id="streamerSectionTitle"> <div class="section-title" id="streamerSectionTitle">
@@ -261,10 +270,10 @@
</div> </div>
<div class="queue-list" id="queueList"></div> <div class="queue-list" id="queueList"></div>
<div class="queue-actions"> <div class="queue-actions">
<button type="button" class="btn btn-start" id="btnStart" onclick="toggleDownload()">Start</button> <button type="button" class="btn btn-start" id="btnStart" onclick="toggleDownload()" disabled>Start</button>
<button type="button" class="btn btn-merge-group is-hidden" id="btnMergeGroup" onclick="createMergeGroupFromSelection()">Merge &amp; Split</button> <button type="button" class="btn btn-merge-group is-hidden" id="btnMergeGroup" onclick="createMergeGroupFromSelection()">Merge &amp; Split</button>
<button type="button" class="btn btn-retry" id="btnRetryFailed" onclick="retryFailedDownloads()" title="Nur fehlgeschlagene Downloads erneut starten">Wiederholen</button> <button type="button" class="btn btn-retry" id="btnRetryFailed" onclick="retryFailedDownloads()" title="Nur fehlgeschlagene Downloads erneut starten">Wiederholen</button>
<button type="button" class="btn btn-clear" id="btnClear" onclick="clearCompleted()">Leeren</button> <button type="button" class="btn btn-clear" id="btnClear" onclick="clearCompleted()" disabled>Leeren</button>
</div> </div>
</div> </div>
<div class="stats-bar" id="statsBar"></div> <div class="stats-bar" id="statsBar"></div>
@@ -273,58 +282,58 @@
<section class="context-panel" data-context-for="clips" hidden> <section class="context-panel" data-context-for="clips" hidden>
<div class="context-panel-heading" data-context-heading>Clips</div> <div class="context-panel-heading" data-context-heading>Clips</div>
<nav class="context-list" aria-label="Clip sections"> <nav class="context-list" aria-label="Clip sections">
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('clipUrl')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 6h11a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4zM17 10l4-2v8l-4-2"></path></svg><span data-label-source="clipsHeading">Clip-Download</span></button> <button type="button" class="context-link active" onclick="focusWorkspaceTarget('clipUrl', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 6h11a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4zM17 10l4-2v8l-4-2"></path></svg><span data-label-source="clipsHeading">Clip-Download</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('clipsInfoTitle')"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"></circle><path d="M12 11v6M12 7h.01"></path></svg><span data-label-source="clipsInfoTitle">Info</span></button> <button type="button" class="context-link" onclick="focusWorkspaceTarget('clipsInfoTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"></circle><path d="M12 11v6M12 7h.01"></path></svg><span data-label-source="clipsInfoTitle">Info</span></button>
</nav> </nav>
</section> </section>
<section class="context-panel" data-context-for="cutter" hidden> <section class="context-panel" data-context-for="cutter" hidden>
<div class="context-panel-heading" data-context-heading>Video schneiden</div> <div class="context-panel-heading" data-context-heading>Video schneiden</div>
<nav class="context-list" aria-label="Cutter sections"> <nav class="context-list" aria-label="Cutter sections">
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('cutterBrowseBtn')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h7l2 2h9v11H3z"></path></svg><span data-label-source="cutterSelectTitle">Video auswahlen</span></button> <button type="button" class="context-link active" onclick="focusWorkspaceTarget('cutterBrowseBtn', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h7l2 2h9v11H3z"></path></svg><span data-label-source="cutterSelectTitle">Video auswahlen</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('timelineContainer')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 12h16M7 8v8M17 8v8"></path></svg><span data-label-source="cutterInfoSelectionLabel">Auswahl</span></button> <button type="button" class="context-link" onclick="focusWorkspaceTarget('timelineContainer', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 12h16M7 8v8M17 8v8"></path></svg><span data-label-source="cutterInfoSelectionLabel">Auswahl</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('btnCut')"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="6" cy="7" r="3"></circle><circle cx="6" cy="17" r="3"></circle><path d="m8.5 8.5 11 7M8.5 15.5 20 8"></path></svg><span data-label-source="btnCut">Schneiden</span></button> <button type="button" class="context-link" onclick="focusWorkspaceTarget('btnCut', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="6" cy="7" r="3"></circle><circle cx="6" cy="17" r="3"></circle><path d="m8.5 8.5 11 7M8.5 15.5 20 8"></path></svg><span data-label-source="btnCut">Schneiden</span></button>
</nav> </nav>
</section> </section>
<section class="context-panel" data-context-for="merge" hidden> <section class="context-panel" data-context-for="merge" hidden>
<div class="context-panel-heading" data-context-heading>Zusammenfugen</div> <div class="context-panel-heading" data-context-heading>Zusammenfugen</div>
<nav class="context-list" aria-label="Merge sections"> <nav class="context-list" aria-label="Merge sections">
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('mergeAddBtn')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 6h16v12H4zM12 9v6M9 12h6"></path></svg><span data-label-source="mergeAddBtn">Videos hinzufugen</span></button> <button type="button" class="context-link active" onclick="focusWorkspaceTarget('mergeAddBtn', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 6h16v12H4zM12 9v6M9 12h6"></path></svg><span data-label-source="mergeAddBtn">Videos hinzufugen</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('mergeFileList')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 6h14M5 12h14M5 18h14"></path></svg><span data-label-source="mergeTitle">Videos zusammenfugen</span></button> <button type="button" class="context-link" onclick="focusWorkspaceTarget('mergeFileList', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 6h14M5 12h14M5 18h14"></path></svg><span data-label-source="mergeTitle">Videos zusammenfugen</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('btnMerge')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 5h4a3 3 0 0 1 3 3v8a3 3 0 0 0 3 3h4M16 16l3 3-3 3"></path></svg><span data-label-source="btnMerge">Zusammenfugen</span></button> <button type="button" class="context-link" onclick="focusWorkspaceTarget('btnMerge', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 5h4a3 3 0 0 1 3 3v8a3 3 0 0 0 3 3h4M16 16l3 3-3 3"></path></svg><span data-label-source="btnMerge">Zusammenfugen</span></button>
</nav> </nav>
</section> </section>
<section class="context-panel" data-context-for="stats" hidden> <section class="context-panel" data-context-for="stats" hidden>
<div class="context-panel-heading" data-context-heading>Statistik</div> <div class="context-panel-heading" data-context-heading>Statistik</div>
<nav class="context-list" aria-label="Statistics sections"> <nav class="context-list" aria-label="Statistics sections">
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('statsSummaryTitle')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 19V9M10 19V5M16 19v-7M22 19V3"></path></svg><span data-label-source="statsSummaryTitle">Uebersicht</span></button> <button type="button" class="context-link active" onclick="focusWorkspaceTarget('statsSummaryTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 19V9M10 19V5M16 19v-7M22 19V3"></path></svg><span data-label-source="statsSummaryTitle">Uebersicht</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('statsTopStreamersTitle')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 7h16M4 12h12M4 17h8"></path></svg><span data-label-source="statsTopStreamersTitle">Top Streamer</span></button> <button type="button" class="context-link" onclick="focusWorkspaceTarget('statsTopStreamersTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 7h16M4 12h12M4 17h8"></path></svg><span data-label-source="statsTopStreamersTitle">Top Streamer</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('statsActivityTitle')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m3 17 5-5 4 3 7-8 2 2"></path></svg><span data-label-source="statsActivityTitle">Aktivitaet</span></button> <button type="button" class="context-link" onclick="focusWorkspaceTarget('statsActivityTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m3 17 5-5 4 3 7-8 2 2"></path></svg><span data-label-source="statsActivityTitle">Aktivitaet</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('statsSizeBucketsTitle')"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"></circle><path d="M12 3v9h9"></path></svg><span data-label-source="statsSizeBucketsTitle">Groessen-Verteilung</span></button> <button type="button" class="context-link" onclick="focusWorkspaceTarget('statsSizeBucketsTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"></circle><path d="M12 3v9h9"></path></svg><span data-label-source="statsSizeBucketsTitle">Groessen-Verteilung</span></button>
</nav> </nav>
</section> </section>
<section class="context-panel" data-context-for="archive" hidden> <section class="context-panel" data-context-for="archive" hidden>
<div class="context-panel-heading" data-context-heading>Archiv</div> <div class="context-panel-heading" data-context-heading>Archiv</div>
<nav class="context-list" aria-label="Archive sections"> <nav class="context-list" aria-label="Archive sections">
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('archiveSearchQuery')"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6"></circle><path d="m15 15 5 5"></path></svg><span data-label-source="archiveTitle">Archiv durchsuchen</span></button> <button type="button" class="context-link active" onclick="focusWorkspaceTarget('archiveSearchQuery', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6"></circle><path d="m15 15 5 5"></path></svg><span data-label-source="archiveTitle">Archiv durchsuchen</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('archiveSearchType')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 5h16M7 12h10M10 19h4"></path></svg><span data-label-source="archiveSearchType">Filter</span></button> <button type="button" class="context-link" onclick="focusWorkspaceTarget('archiveSearchType', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 5h16M7 12h10M10 19h4"></path></svg><span data-label-source="archiveSearchType">Filter</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('archiveSearchResults')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 4h16v16H4zM8 8h8M8 12h8M8 16h5"></path></svg><span>Ergebnisse</span></button> <button type="button" class="context-link" onclick="focusWorkspaceTarget('archiveSearchResults', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 4h16v16H4zM8 8h8M8 12h8M8 16h5"></path></svg><span id="archiveResultsNavText">Ergebnisse</span></button>
</nav> </nav>
</section> </section>
<section class="context-panel" data-context-for="settings" hidden> <section class="context-panel" data-context-for="settings" hidden>
<div class="context-panel-heading" data-context-heading>Einstellungen</div> <div class="context-panel-heading" data-context-heading>Einstellungen</div>
<nav class="context-list" aria-label="Settings sections"> <nav class="context-list" aria-label="Settings sections">
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('designTitle')"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"></circle><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1"></path></svg><span data-label-source="designTitle">Design</span></button> <button type="button" class="context-link active" onclick="focusWorkspaceTarget('designTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"></circle><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1"></path></svg><span data-label-source="designTitle">Design</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('apiTitle')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M8 12h8M12 8v8"></path><circle cx="12" cy="12" r="9"></circle></svg><span data-label-source="apiTitle">Twitch API</span></button> <button type="button" class="context-link" onclick="focusWorkspaceTarget('apiTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M8 12h8M12 8v8"></path><circle cx="12" cy="12" r="9"></circle></svg><span data-label-source="apiTitle">Twitch API</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('downloadSettingsTitle')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3v12M8 11l4 4 4-4M4 20h16"></path></svg><span data-label-source="downloadSettingsTitle">Downloads</span></button> <button type="button" class="context-link" onclick="focusWorkspaceTarget('downloadSettingsTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3v12M8 11l4 4 4-4M4 20h16"></path></svg><span data-label-source="downloadSettingsTitle">Downloads</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('updateTitle')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3v12M8 11l4 4 4-4M4 19h16"></path></svg><span data-label-source="updateTitle">Updates</span></button> <button type="button" class="context-link" onclick="focusWorkspaceTarget('updateTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3v12M8 11l4 4 4-4M4 19h16"></path></svg><span data-label-source="updateTitle">Updates</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('preflightTitle')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3 4 7v5c0 5 3.4 8 8 9 4.6-1 8-4 8-9V7z"></path><path d="m9 12 2 2 4-4"></path></svg><span data-label-source="preflightTitle">System-Check</span></button> <button type="button" class="context-link" onclick="focusWorkspaceTarget('preflightTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3 4 7v5c0 5 3.4 8 8 9 4.6-1 8-4 8-9V7z"></path><path d="m9 12 2 2 4-4"></path></svg><span data-label-source="preflightTitle">System-Check</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('debugLogTitle')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 5h14v14H5zM8 9l2 2-2 2M12 15h4"></path></svg><span data-label-source="debugLogTitle">Debug-Log</span></button> <button type="button" class="context-link" onclick="focusWorkspaceTarget('debugLogTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 5h14v14H5zM8 9l2 2-2 2M12 15h4"></path></svg><span data-label-source="debugLogTitle">Debug-Log</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('storageCardTitle')"><svg aria-hidden="true" viewBox="0 0 24 24"><ellipse cx="12" cy="6" rx="8" ry="3"></ellipse><path d="M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"></path></svg><span data-label-source="storageCardTitle">Storage</span></button> <button type="button" class="context-link" onclick="focusWorkspaceTarget('storageCardTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><ellipse cx="12" cy="6" rx="8" ry="3"></ellipse><path d="M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"></path></svg><span data-label-source="storageCardTitle">Storage</span></button>
</nav> </nav>
</section> </section>
</aside> </aside>
@@ -333,34 +342,37 @@
<header class="header workspace-toolbar"> <header class="header workspace-toolbar">
<div class="toolbar-context" data-toolbar-for="vods"> <div class="toolbar-context" data-toolbar-for="vods">
<button type="button" class="toolbar-primary" onclick="focusWorkspaceTarget('newStreamer')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"></path></svg><span>Streamer</span></button> <button type="button" class="toolbar-primary" onclick="focusWorkspaceTarget('newStreamer')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"></path></svg><span>Streamer</span></button>
<button type="button" class="toolbar-icon-button" onclick="refreshVODs()" aria-label="Refresh VODs" title="Refresh VODs"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M20 6v5h-5M4 18v-5h5M18.5 9A7 7 0 0 0 6 7M5.5 15A7 7 0 0 0 18 17"></path></svg></button> <button type="button" class="toolbar-icon-button" id="toolbarRefreshVodsBtn" onclick="refreshVODs()" aria-label="Refresh VODs" title="Refresh VODs"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M20 6v5h-5M4 18v-5h5M18.5 9A7 7 0 0 0 6 7M5.5 15A7 7 0 0 0 18 17"></path></svg></button>
</div> </div>
<div class="toolbar-context" data-toolbar-for="clips" hidden> <div class="toolbar-context" data-toolbar-for="clips" hidden>
<button type="button" class="toolbar-primary" onclick="focusWorkspaceTarget('clipUrl')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"></path></svg><span data-label-source="clipsHeading">Clip</span></button> <button type="button" class="toolbar-primary" onclick="focusWorkspaceTarget('clipUrl')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"></path></svg><span data-label-source="clipsHeading">Clip</span></button>
<button type="button" class="toolbar-icon-button" onclick="downloadClip()" aria-label="Download clip" title="Download clip"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3v12M8 11l4 4 4-4M4 20h16"></path></svg></button> <button type="button" class="toolbar-icon-button" id="toolbarClipDownloadBtn" onclick="downloadClip()" aria-label="Download clip" title="Download clip"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3v12M8 11l4 4 4-4M4 20h16"></path></svg></button>
</div> </div>
<div class="toolbar-context" data-toolbar-for="cutter" hidden> <div class="toolbar-context" data-toolbar-for="cutter" hidden>
<button type="button" class="toolbar-primary" onclick="selectCutterVideo()"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h7l2 2h9v11H3z"></path></svg><span data-label-source="cutterBrowseBtn">Durchsuchen</span></button> <button type="button" class="toolbar-primary" onclick="selectCutterVideo()"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h7l2 2h9v11H3z"></path></svg><span data-label-source="cutterBrowseBtn">Durchsuchen</span></button>
<button type="button" class="toolbar-icon-button" onclick="startCutting()" aria-label="Cut video" title="Cut video"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="6" cy="7" r="3"></circle><circle cx="6" cy="17" r="3"></circle><path d="m8.5 8.5 11 7M8.5 15.5 20 8"></path></svg></button> <button type="button" class="toolbar-icon-button" id="toolbarCutBtn" onclick="startCutting()" aria-label="Cut video" title="Cut video"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="6" cy="7" r="3"></circle><circle cx="6" cy="17" r="3"></circle><path d="m8.5 8.5 11 7M8.5 15.5 20 8"></path></svg></button>
</div> </div>
<div class="toolbar-context" data-toolbar-for="merge" hidden> <div class="toolbar-context" data-toolbar-for="merge" hidden>
<button type="button" class="toolbar-primary" onclick="addMergeFiles()"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"></path></svg><span data-label-source="mergeAddBtn">Videos hinzufugen</span></button> <button type="button" class="toolbar-primary" onclick="addMergeFiles()"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"></path></svg><span data-label-source="mergeAddBtn">Videos hinzufugen</span></button>
<button type="button" class="toolbar-icon-button" onclick="startMerging()" aria-label="Merge videos" title="Merge videos"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 5h4a3 3 0 0 1 3 3v8a3 3 0 0 0 3 3h4M16 16l3 3-3 3"></path></svg></button> <button type="button" class="toolbar-icon-button" id="toolbarMergeBtn" onclick="startMerging()" aria-label="Merge videos" title="Merge videos"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 5h4a3 3 0 0 1 3 3v8a3 3 0 0 0 3 3h4M16 16l3 3-3 3"></path></svg></button>
</div> </div>
<div class="toolbar-context" data-toolbar-for="stats" hidden> <div class="toolbar-context" data-toolbar-for="stats" hidden>
<button type="button" class="toolbar-primary" onclick="refreshArchiveStats()"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M20 6v5h-5M4 18v-5h5M18.5 9A7 7 0 0 0 6 7M5.5 15A7 7 0 0 0 18 17"></path></svg><span data-label-source="btnStatsRefresh">Aktualisieren</span></button> <button type="button" class="toolbar-primary" id="toolbarStatsRefreshBtn" onclick="refreshArchiveStats()"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M20 6v5h-5M4 18v-5h5M18.5 9A7 7 0 0 0 6 7M5.5 15A7 7 0 0 0 18 17"></path></svg><span data-label-source="btnStatsRefresh">Aktualisieren</span></button>
</div> </div>
<div class="toolbar-context" data-toolbar-for="archive" hidden> <div class="toolbar-context" data-toolbar-for="archive" hidden>
<button type="button" class="toolbar-primary" onclick="performArchiveSearch()"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6"></circle><path d="m15 15 5 5"></path></svg><span data-label-source="btnArchiveSearch">Suchen</span></button> <button type="button" class="toolbar-primary" onclick="performArchiveSearch()"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6"></circle><path d="m15 15 5 5"></path></svg><span data-label-source="btnArchiveSearch">Suchen</span></button>
</div> </div>
<div class="toolbar-context" data-toolbar-for="settings" hidden> <div class="toolbar-context" data-toolbar-for="settings" hidden>
<button type="button" class="toolbar-primary" onclick="saveSettings()"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 4h12l2 2v14H5zM8 4v6h8V4M8 20v-6h8v6"></path></svg><span data-label-source="saveSettingsBtn">Speichern</span></button> <label class="workspace-settings-search" for="settingsSearchInput">
<button type="button" class="toolbar-icon-button" onclick="checkUpdate()" aria-label="Check for updates" title="Check for updates"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M20 6v5h-5M4 18v-5h5M18.5 9A7 7 0 0 0 6 7M5.5 15A7 7 0 0 0 18 17"></path></svg></button> <svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6"></circle><path d="m15 15 5 5"></path></svg>
<input type="search" id="settingsSearchInput" placeholder="Search settings…" oninput="filterSettings(this.value)" autocomplete="off">
</label>
<button type="button" class="toolbar-icon-button" id="toolbarCheckUpdateBtn" onclick="checkUpdate()" aria-label="Check for updates" title="Check for updates"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M20 6v5h-5M4 18v-5h5M18.5 9A7 7 0 0 0 6 7M5.5 15A7 7 0 0 0 18 17"></path></svg></button>
</div> </div>
<h1 class="workspace-title" id="pageTitle">VODs</h1> <h1 class="workspace-title" id="pageTitle">VODs</h1>
<div class="header-actions workspace-toolbar-actions" data-toolbar-search-for="vods"> <div class="header-actions workspace-toolbar-actions" data-toolbar-search-for="vods">
<div class="header-search workspace-search"> <div class="header-search workspace-search">
<input type="text" id="newStreamer" placeholder="Streamer hinzufugen..." onkeypress="if(event.key==='Enter')addStreamer()"> <input type="text" id="newStreamer" placeholder="Streamer hinzufugen" onkeypress="if(event.key==='Enter')addStreamer()">
<button id="btnAddStreamer" type="button" onclick="addStreamer()" aria-label="Add streamer" title="Add streamer">+</button> <button id="btnAddStreamer" type="button" onclick="addStreamer()" aria-label="Add streamer" title="Add streamer">+</button>
</div> </div>
<span id="refreshText" class="sr-only">Aktualisieren</span> <span id="refreshText" class="sr-only">Aktualisieren</span>
@@ -398,7 +410,7 @@
</div> </div>
<div class="vod-grid" id="vodGrid"> <div class="vod-grid" id="vodGrid">
<div class="empty-state"> <div class="empty-state">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg> <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2h8l4 4v16H6z"></path><path d="M14 2v5h5M10 11l5 3-5 3z"></path></svg>
<h3 id="vodGridEmptyTitle">Keine VODs</h3> <h3 id="vodGridEmptyTitle">Keine VODs</h3>
<p id="vodGridEmptyText">Wahle einen Streamer aus der Liste oder fuge einen neuen hinzu.</p> <p id="vodGridEmptyText">Wahle einen Streamer aus der Liste oder fuge einen neuen hinzu.</p>
</div> </div>
@@ -604,11 +616,11 @@
</button> </button>
<button type="button" class="workspace-theme-choice active" data-theme="twitch" aria-pressed="true" onclick="selectWorkspaceTheme('twitch')"> <button type="button" class="workspace-theme-choice active" data-theme="twitch" aria-pressed="true" onclick="selectWorkspaceTheme('twitch')">
<span class="workspace-theme-preview theme-preview-dark" aria-hidden="true"><span></span><span></span><span></span></span> <span class="workspace-theme-preview theme-preview-dark" aria-hidden="true"><span></span><span></span><span></span></span>
<span>Dark</span> <span id="themeDarkOption">Dark</span>
</button> </button>
<button type="button" class="workspace-theme-choice" data-theme="system" aria-pressed="false" onclick="selectWorkspaceTheme('system')"> <button type="button" class="workspace-theme-choice" data-theme="system" aria-pressed="false" onclick="selectWorkspaceTheme('system')">
<span class="workspace-theme-preview theme-preview-system" aria-hidden="true"><span></span><span></span><span></span></span> <span class="workspace-theme-preview theme-preview-system" aria-hidden="true"><span></span><span></span><span></span></span>
<span>System</span> <span id="themeSystemOption">System</span>
</button> </button>
</div> </div>
<select id="themeSelect" class="theme-select-fallback" onchange="changeTheme(this.value)"> <select id="themeSelect" class="theme-select-fallback" onchange="changeTheme(this.value)">
@@ -662,7 +674,7 @@
<label id="storageLabel" for="downloadPath">Speicherort</label> <label id="storageLabel" for="downloadPath">Speicherort</label>
<div class="form-row"> <div class="form-row">
<input type="text" id="downloadPath" readonly> <input type="text" id="downloadPath" readonly>
<button type="button" class="btn-secondary" onclick="selectFolder()">Ordner</button> <button type="button" class="btn-secondary" id="selectFolderBtn" onclick="selectFolder()">Ordner</button>
<button type="button" class="btn-secondary" id="openFolderBtn" onclick="openFolder()">Offnen</button> <button type="button" class="btn-secondary" id="openFolderBtn" onclick="openFolder()">Offnen</button>
</div> </div>
</div> </div>
@@ -785,7 +797,7 @@
<div class="settings-card"> <div class="settings-card">
<h3 id="updateTitle">Updates</h3> <h3 id="updateTitle">Updates</h3>
<p id="versionInfo" class="card-intro">Version: v1.0.1</p> <p id="versionInfo" class="card-intro">Version: v1.0.2</p>
<button type="button" class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button> <button type="button" class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
</div> </div>
@@ -938,7 +950,7 @@
<div class="modal-overlay" id="commandPaletteModal" role="dialog" aria-modal="true" aria-labelledby="commandPaletteTitle"> <div class="modal-overlay" id="commandPaletteModal" role="dialog" aria-modal="true" aria-labelledby="commandPaletteTitle">
<div class="modal command-palette"> <div class="modal command-palette">
<h2 id="commandPaletteTitle" class="cp-title">Command Palette</h2> <h2 id="commandPaletteTitle" class="cp-title">Befehlspalette</h2>
<input <input
type="text" type="text"
id="commandPaletteInput" id="commandPaletteInput"
@@ -946,9 +958,9 @@
placeholder="Suche Befehl..." placeholder="Suche Befehl..."
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
aria-label="Command Palette" aria-label="Befehlspalette"
/> />
<ul id="commandPaletteList" class="cp-list" role="listbox" aria-label="Command results"></ul> <ul id="commandPaletteList" class="cp-list" role="listbox" aria-label="Befehlsergebnisse"></ul>
<p class="cp-hint" id="commandPaletteHint">Up/Down zum Navigieren, Enter zum Ausfuehren, Esc zum Schliessen</p> <p class="cp-hint" id="commandPaletteHint">Up/Down zum Navigieren, Enter zum Ausfuehren, Esc zum Schliessen</p>
</div> </div>
</div> </div>
+2 -2
View File
@@ -2470,8 +2470,8 @@ async function getVodStoryboard(vodId: string): Promise<VodStoryboard | null> {
return null; return null;
} }
// The manifest URL points at e.g. .../storyboards/2767872722-info.json // The manifest URL points at e.g. .../storyboards/{vodId}-info.json
// and sprite filenames are relative (e.g. "2767872722-high-0.jpg"). // and sprite filenames are relative (e.g. "{vodId}-high-0.jpg").
// Strip the JSON filename to get the base, then append the sprite. // Strip the JSON filename to get the base, then append the sprite.
const baseUrl = manifestUrl.replace(/\/[^/]+$/, '/'); const baseUrl = manifestUrl.replace(/\/[^/]+$/, '/');
const firstSpriteUrl = baseUrl + entry.images[0]; const firstSpriteUrl = baseUrl + entry.images[0];
View File
View File
+2 -2
View File
@@ -56,7 +56,7 @@ async function performArchiveSearch(): Promise<void> {
const result = await window.api.searchArchive(filter); const result = await window.api.searchArchive(filter);
renderArchiveSearchResults(result); renderArchiveSearchResults(result);
} catch (e) { } catch (e) {
if (summaryEl) summaryEl.textContent = `Fehler: ${String(e)}`; if (summaryEl) summaryEl.textContent = `${UI_TEXT.static.errorPrefix}: ${String(e)}`;
applyHtml(resultsEl, ''); applyHtml(resultsEl, '');
} finally { } finally {
archiveSearchInFlight = false; archiveSearchInFlight = false;
@@ -91,7 +91,7 @@ function renderArchiveSearchResults(result: ArchiveSearchResult): void {
} }
const rows = result.hits.map((hit) => { const rows = result.hits.map((hit) => {
const date = new Date(hit.mtimeMs).toLocaleString(); const date = formatUiDateTime(new Date(hit.mtimeMs));
const typeBadge = `<span class="archive-type-badge ${hit.type === 'live' ? 'live' : 'vod'}">${hit.type === 'live' ? 'LIVE' : 'VOD'}</span>`; const typeBadge = `<span class="archive-type-badge ${hit.type === 'live' ? 'live' : 'vod'}">${hit.type === 'live' ? 'LIVE' : 'VOD'}</span>`;
const safeFullAttr = hit.fullPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); const safeFullAttr = hit.fullPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const chatBtn = hit.chatPath const chatBtn = hit.chatPath
+13 -11
View File
@@ -30,20 +30,22 @@ interface PaletteCommand {
// hint 'Open' statt 'Tab' — 'Tab' las sich wie eine Tastatur-Taste // hint 'Open' statt 'Tab' — 'Tab' las sich wie eine Tastatur-Taste
// ('druecke Tab') statt 'oeffnet diesen Tab'. // ('druecke Tab') statt 'oeffnet diesen Tab'.
const tabs: Array<{ id: string; labels: string[]; hint: string }> = [ const commandText = UI_TEXT.static.commandPaletteCommands;
{ id: 'vods', labels: ['VODs', 'videos', 'streams'], hint: 'Open' }, const tabs: Array<{ id: string; label: string; keywords: string }> = [
{ id: 'queue', labels: ['Queue', 'downloads', 'warteschlange'], hint: 'Open' }, { id: 'vods', ...commandText.vods },
{ id: 'streamers', labels: ['Streamers', 'channels'], hint: 'Open' }, { id: 'clips', ...commandText.clips },
{ id: 'stats', labels: ['Stats', 'statistiken', 'dashboard'], hint: 'Open' }, { id: 'cutter', ...commandText.cutter },
{ id: 'archive', labels: ['Archive', 'archiv'], hint: 'Open' }, { id: 'merge', ...commandText.merge },
{ id: 'settings', labels: ['Settings', 'einstellungen', 'config'], hint: 'Open' }, { id: 'stats', ...commandText.stats },
{ id: 'archive', ...commandText.archive },
{ id: 'settings', ...commandText.settings },
]; ];
const tabCommands: PaletteCommand[] = tabs.map(t => ({ const tabCommands: PaletteCommand[] = tabs.map(t => ({
id: 'tab:' + t.id, id: 'tab:' + t.id,
label: t.labels[0], label: t.label,
hint: t.hint, hint: UI_TEXT.static.commandPaletteOpenHint,
keywords: t.labels.join(' ').toLowerCase(), keywords: `${t.label} ${t.keywords}`.toLowerCase(),
action: () => showTab(t.id), action: () => showTab(t.id),
})); }));
@@ -58,7 +60,7 @@ interface PaletteCommand {
streamerCommands.push({ streamerCommands.push({
id: 'streamer:' + name.toLowerCase(), id: 'streamer:' + name.toLowerCase(),
label: name, label: name,
hint: 'Streamer', hint: UI_TEXT.static.commandPaletteStreamerHint,
keywords: ('@' + name + ' ' + name).toLowerCase(), keywords: ('@' + name + ' ' + name).toLowerCase(),
action: () => { action: () => {
showTab('vods'); showTab('vods');
+40 -3
View File
@@ -13,15 +13,46 @@ const UI_TEXT_DE = {
healthGood: 'System: Stabil', healthGood: 'System: Stabil',
healthWarn: 'System: Warnung', healthWarn: 'System: Warnung',
healthBad: 'System: Problem', healthBad: 'System: Problem',
systemStatus: 'Systemstatus',
openSettings: 'Einstellungen offnen',
refreshVods: 'VODs aktualisieren',
downloadClip: 'Clip herunterladen',
mainNavigationAria: 'Hauptnavigation',
workspaceContextAria: 'Arbeitsbereichskontext',
vodWorkspaceAria: 'VOD-Arbeitsbereich',
clipSectionsAria: 'Clip-Bereiche',
cutterSectionsAria: 'Videoschnitt-Bereiche',
mergeSectionsAria: 'Zusammenfügen-Bereiche',
statisticsSectionsAria: 'Statistik-Bereiche',
archiveSectionsAria: 'Archiv-Bereiche',
settingsSectionsAria: 'Einstellungsbereiche',
cutVideoAction: 'Video schneiden',
mergeVideosAction: 'Videos zusammenfügen',
errorPrefix: 'Fehler',
settingsSearchPlaceholder: 'Einstellungen durchsuchen…',
clearQueue: 'Leeren', clearQueue: 'Leeren',
refresh: 'Aktualisieren', refresh: 'Aktualisieren',
streamerPlaceholder: 'Streamer hinzufugen...', streamerPlaceholder: 'Streamer hinzufugen',
clipsHeading: 'Twitch Clip-Download', clipsHeading: 'Twitch Clip-Download',
clipsInfoTitle: 'Info', clipsInfoTitle: 'Info',
clipsInfoText: 'Unterstutzte Formate:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert.', clipsInfoText: 'Unterstutzte Formate:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert.',
cutterSelectTitle: 'Video auswahlen', cutterSelectTitle: 'Video auswahlen',
cutterPreviewPlaceholder: 'Video auswahlen um Vorschau zu sehen', cutterPreviewPlaceholder: 'Video auswahlen um Vorschau zu sehen',
cutterBrowse: 'Durchsuchen', cutterBrowse: 'Durchsuchen',
commandPaletteTitle: 'Befehlspalette',
commandPaletteAria: 'Befehlspalette',
commandPaletteResultsAria: 'Befehlsergebnisse',
commandPaletteOpenHint: 'Öffnen',
commandPaletteStreamerHint: 'Streamer',
commandPaletteCommands: {
vods: { label: 'VODs', keywords: 'vod videos streams' },
clips: { label: 'Clips', keywords: 'clips clip-download' },
cutter: { label: 'Videoschnitt', keywords: 'videoschnitt schneiden cutter' },
merge: { label: 'Videos zusammenfügen', keywords: 'zusammenfügen videos merge' },
stats: { label: 'Statistiken', keywords: 'statistiken stats dashboard' },
archive: { label: 'Archiv', keywords: 'archiv archive' },
settings: { label: 'Einstellungen', keywords: 'einstellungen settings config' }
},
commandPaletteSearchPlaceholder: 'Befehl suchen...', commandPaletteSearchPlaceholder: 'Befehl suchen...',
commandPaletteHint: 'Up/Down zum Navigieren, Enter zum Ausfuehren, Esc zum Schliessen', commandPaletteHint: 'Up/Down zum Navigieren, Enter zum Ausfuehren, Esc zum Schliessen',
mergeTitle: 'Videos zusammenfugen', mergeTitle: 'Videos zusammenfugen',
@@ -30,6 +61,8 @@ const UI_TEXT_DE = {
designTitle: 'Design', designTitle: 'Design',
themeLabel: 'Theme', themeLabel: 'Theme',
themeLight: 'Hell', themeLight: 'Hell',
themeDark: 'Dunkel',
themeSystem: 'System',
languageLabel: 'Sprache', languageLabel: 'Sprache',
languageDe: 'Deutsch', languageDe: 'Deutsch',
languageEn: 'Englisch', languageEn: 'Englisch',
@@ -39,6 +72,7 @@ const UI_TEXT_DE = {
saveSettings: 'Speichern & Verbinden', saveSettings: 'Speichern & Verbinden',
downloadSettingsTitle: 'Download-Einstellungen', downloadSettingsTitle: 'Download-Einstellungen',
storageLabel: 'Speicherort', storageLabel: 'Speicherort',
selectFolder: 'Ordner',
openFolder: 'Offnen', openFolder: 'Offnen',
modeLabel: 'Download-Modus', modeLabel: 'Download-Modus',
modeFull: 'Ganzes VOD', modeFull: 'Ganzes VOD',
@@ -141,8 +175,9 @@ const UI_TEXT_DE = {
archiveSummaryTruncated: '{matchCount} Treffer (gescannt: {scanned} Dateien, gezeigt: {shown} - verfeinere die Suche fuer mehr)', archiveSummaryTruncated: '{matchCount} Treffer (gescannt: {scanned} Dateien, gezeigt: {shown} - verfeinere die Suche fuer mehr)',
archiveNoMatches: 'Keine Treffer.', archiveNoMatches: 'Keine Treffer.',
archiveNoRoot: 'Download-Ordner nicht gefunden. Setze zuerst einen Download-Pfad in den Einstellungen.', archiveNoRoot: 'Download-Ordner nicht gefunden. Setze zuerst einen Download-Pfad in den Einstellungen.',
archiveSearchPlaceholder: 'Suche...', archiveSearchPlaceholder: 'Suche',
archiveSearchAria: 'Archiv durchsuchen', archiveSearchAria: 'Archiv durchsuchen',
archiveResults: 'Ergebnisse',
archiveOpen: 'Oeffnen', archiveOpen: 'Oeffnen',
archiveShowInFolder: 'Ordner', archiveShowInFolder: 'Ordner',
archiveViewChat: 'Chat', archiveViewChat: 'Chat',
@@ -461,7 +496,7 @@ const UI_TEXT_DE = {
infoSelection: 'Auswahl', infoSelection: 'Auswahl',
startLabel: 'Start:', startLabel: 'Start:',
endLabel: 'Ende:', endLabel: 'Ende:',
filePathPlaceholder: 'Keine Datei ausgewaehlt...' filePathPlaceholder: 'Keine Datei ausgewaehlt'
}, },
merge: { merge: {
empty: 'Keine Videos ausgewahlt', empty: 'Keine Videos ausgewahlt',
@@ -504,6 +539,8 @@ const UI_TEXT_DE = {
modalReadyTitle: 'Update bereit', modalReadyTitle: 'Update bereit',
modalReadyMessage: 'Version {version} wurde heruntergeladen. Jetzt installieren und neu starten?', modalReadyMessage: 'Version {version} wurde heruntergeladen. Jetzt installieren und neu starten?',
modalDismiss: 'Nein', modalDismiss: 'Nein',
later: 'Später',
dismissAria: 'Schließen',
modalDownloadConfirm: 'Ja, herunterladen', modalDownloadConfirm: 'Ja, herunterladen',
modalInstallConfirm: 'Ja, installieren', modalInstallConfirm: 'Ja, installieren',
modalSkipVersion: 'Diese Version ueberspringen', modalSkipVersion: 'Diese Version ueberspringen',
+40 -3
View File
@@ -13,15 +13,46 @@ const UI_TEXT_EN = {
healthGood: 'System: Stable', healthGood: 'System: Stable',
healthWarn: 'System: Warning', healthWarn: 'System: Warning',
healthBad: 'System: Problem', healthBad: 'System: Problem',
systemStatus: 'System status',
openSettings: 'Open settings',
refreshVods: 'Refresh VODs',
downloadClip: 'Download clip',
mainNavigationAria: 'Main navigation',
workspaceContextAria: 'Workspace context',
vodWorkspaceAria: 'VOD workspace',
clipSectionsAria: 'Clip sections',
cutterSectionsAria: 'Video cutter sections',
mergeSectionsAria: 'Video merge sections',
statisticsSectionsAria: 'Statistics sections',
archiveSectionsAria: 'Archive sections',
settingsSectionsAria: 'Settings sections',
cutVideoAction: 'Cut video',
mergeVideosAction: 'Merge videos',
errorPrefix: 'Error',
settingsSearchPlaceholder: 'Search settings…',
clearQueue: 'Clear', clearQueue: 'Clear',
refresh: 'Refresh', refresh: 'Refresh',
streamerPlaceholder: 'Add streamer...', streamerPlaceholder: 'Add streamer',
clipsHeading: 'Twitch Clip Download', clipsHeading: 'Twitch Clip Download',
clipsInfoTitle: 'Info', clipsInfoTitle: 'Info',
clipsInfoText: 'Supported formats:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips are saved in your download folder under "Clips/StreamerName/".', clipsInfoText: 'Supported formats:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips are saved in your download folder under "Clips/StreamerName/".',
cutterSelectTitle: 'Select video', cutterSelectTitle: 'Select video',
cutterPreviewPlaceholder: 'Select a video to see a preview', cutterPreviewPlaceholder: 'Select a video to see a preview',
cutterBrowse: 'Browse', cutterBrowse: 'Browse',
commandPaletteTitle: 'Command palette',
commandPaletteAria: 'Command palette',
commandPaletteResultsAria: 'Command results',
commandPaletteOpenHint: 'Open',
commandPaletteStreamerHint: 'Streamer',
commandPaletteCommands: {
vods: { label: 'VODs', keywords: 'vod videos streams' },
clips: { label: 'Clips', keywords: 'clips clip download' },
cutter: { label: 'Video cutter', keywords: 'video cutter trim schneiden' },
merge: { label: 'Merge videos', keywords: 'merge videos zusammenfügen' },
stats: { label: 'Statistics', keywords: 'statistics stats dashboard' },
archive: { label: 'Archive', keywords: 'archive archiv' },
settings: { label: 'Settings', keywords: 'settings configuration einstellungen' }
},
commandPaletteSearchPlaceholder: 'Search command...', commandPaletteSearchPlaceholder: 'Search command...',
commandPaletteHint: 'Up/Down to navigate, Enter to run, Esc to close', commandPaletteHint: 'Up/Down to navigate, Enter to run, Esc to close',
mergeTitle: 'Merge videos', mergeTitle: 'Merge videos',
@@ -30,6 +61,8 @@ const UI_TEXT_EN = {
designTitle: 'Design', designTitle: 'Design',
themeLabel: 'Theme', themeLabel: 'Theme',
themeLight: 'Light', themeLight: 'Light',
themeDark: 'Dark',
themeSystem: 'System',
languageLabel: 'Language', languageLabel: 'Language',
languageDe: 'German', languageDe: 'German',
languageEn: 'English', languageEn: 'English',
@@ -39,6 +72,7 @@ const UI_TEXT_EN = {
saveSettings: 'Save & Connect', saveSettings: 'Save & Connect',
downloadSettingsTitle: 'Download Settings', downloadSettingsTitle: 'Download Settings',
storageLabel: 'Storage Path', storageLabel: 'Storage Path',
selectFolder: 'Folder',
openFolder: 'Open', openFolder: 'Open',
modeLabel: 'Download Mode', modeLabel: 'Download Mode',
modeFull: 'Full VOD', modeFull: 'Full VOD',
@@ -142,8 +176,9 @@ const UI_TEXT_EN = {
archiveSummaryTruncated: '{matchCount} matches (scanned {scanned} files, showing {shown} - tighten the query for more)', archiveSummaryTruncated: '{matchCount} matches (scanned {scanned} files, showing {shown} - tighten the query for more)',
archiveNoMatches: 'No matches.', archiveNoMatches: 'No matches.',
archiveNoRoot: 'Download folder not found. Set a download path in Settings first.', archiveNoRoot: 'Download folder not found. Set a download path in Settings first.',
archiveSearchPlaceholder: 'Search...', archiveSearchPlaceholder: 'Search',
archiveSearchAria: 'Search archive', archiveSearchAria: 'Search archive',
archiveResults: 'Results',
archiveOpen: 'Open', archiveOpen: 'Open',
archiveShowInFolder: 'Folder', archiveShowInFolder: 'Folder',
archiveViewChat: 'Chat', archiveViewChat: 'Chat',
@@ -461,7 +496,7 @@ const UI_TEXT_EN = {
infoSelection: 'Selection', infoSelection: 'Selection',
startLabel: 'Start:', startLabel: 'Start:',
endLabel: 'End:', endLabel: 'End:',
filePathPlaceholder: 'No file selected...' filePathPlaceholder: 'No file selected'
}, },
merge: { merge: {
empty: 'No videos selected', empty: 'No videos selected',
@@ -504,6 +539,8 @@ const UI_TEXT_EN = {
modalReadyTitle: 'Update ready', modalReadyTitle: 'Update ready',
modalReadyMessage: 'Version {version} has been downloaded. Install and restart now?', modalReadyMessage: 'Version {version} has been downloaded. Install and restart now?',
modalDismiss: 'No', modalDismiss: 'No',
later: 'Later',
dismissAria: 'Close',
modalDownloadConfirm: 'Yes, download', modalDownloadConfirm: 'Yes, download',
modalInstallConfirm: 'Yes, install', modalInstallConfirm: 'Yes, install',
modalSkipVersion: 'Skip this version', modalSkipVersion: 'Skip this version',
+5 -1
View File
@@ -484,8 +484,12 @@ function renderQueue(): void {
const list = byId('queueList'); const list = byId('queueList');
byId('queueCount').textContent = String(queue.length); byId('queueCount').textContent = String(queue.length);
const retryBtn = byId<HTMLButtonElement>('btnRetryFailed'); const retryBtn = byId<HTMLButtonElement>('btnRetryFailed');
const clearBtn = byId<HTMLButtonElement>('btnClear');
const hasFailed = queue.some((item) => item.status === 'error'); const hasFailed = queue.some((item) => item.status === 'error');
const hasCompleted = queue.some((item) => item.status === 'completed');
retryBtn.disabled = !hasFailed; retryBtn.disabled = !hasFailed;
clearBtn.disabled = !hasCompleted;
updateDownloadButtonState();
const renderFingerprint = getQueueRenderFingerprint(queue); const renderFingerprint = getQueueRenderFingerprint(queue);
if (renderFingerprint === lastQueueRenderFingerprint) { if (renderFingerprint === lastQueueRenderFingerprint) {
@@ -556,7 +560,7 @@ function renderQueue(): void {
<div><span class="queue-detail-label">URL:</span> ${escapeHtml(item.url)}</div> <div><span class="queue-detail-label">URL:</span> ${escapeHtml(item.url)}</div>
<div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailStreamer)}</span> ${escapeHtml(item.streamer)}</div> <div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailStreamer)}</span> ${escapeHtml(item.streamer)}</div>
<div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailDuration)}</span> ${escapeHtml(item.duration_str)}</div> <div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailDuration)}</span> ${escapeHtml(item.duration_str)}</div>
<div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailDate)}</span> ${escapeHtml(new Date(item.date).toLocaleString())}</div> <div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailDate)}</span> ${escapeHtml(formatUiDateTime(item.date))}</div>
${renderQueueItemFileActions(item)} ${renderQueueItemFileActions(item)}
</div> </div>
</div> </div>
+9
View File
@@ -179,6 +179,14 @@ function updateStatus(text: string, connected: boolean): void {
dot.classList.add(connected ? 'connected' : 'error'); dot.classList.add(connected ? 'connected' : 'error');
} }
function filterSettings(query: string): void {
const normalizedQuery = query.trim().toLocaleLowerCase(getIntlLocale());
document.querySelectorAll<HTMLElement>('#settingsTab .settings-card').forEach((card) => {
const searchableText = (card.textContent || '').toLocaleLowerCase(getIntlLocale());
card.hidden = normalizedQuery.length > 0 && !searchableText.includes(normalizedQuery);
});
}
function changeLanguage(lang: string): void { function changeLanguage(lang: string): void {
const normalized = setLanguage(lang); const normalized = setLanguage(lang);
byId<HTMLSelectElement>('languageSelect').value = normalized; byId<HTMLSelectElement>('languageSelect').value = normalized;
@@ -208,6 +216,7 @@ function changeLanguage(lang: string): void {
void refreshRuntimeMetrics(); void refreshRuntimeMetrics();
void refreshAutomationStatusLine(); void refreshAutomationStatusLine();
validateFilenameTemplates(); validateFilenameTemplates();
filterSettings(byId<HTMLInputElement>('settingsSearchInput').value);
} }
function updateLanguagePicker(lang: string): void { function updateLanguagePicker(lang: string): void {
+11 -2
View File
@@ -1,6 +1,13 @@
let archiveStatsRefreshInFlight = false;
async function refreshArchiveStats(): Promise<void> { async function refreshArchiveStats(): Promise<void> {
if (archiveStatsRefreshInFlight) return;
const btn = document.getElementById('btnStatsRefresh') as HTMLButtonElement | null; const btn = document.getElementById('btnStatsRefresh') as HTMLButtonElement | null;
const toolbarBtn = document.getElementById('toolbarStatsRefreshBtn') as HTMLButtonElement | null;
archiveStatsRefreshInFlight = true;
if (btn) btn.disabled = true; if (btn) btn.disabled = true;
if (toolbarBtn) toolbarBtn.disabled = true;
const lastLabel = document.getElementById('statsLastScannedLabel'); const lastLabel = document.getElementById('statsLastScannedLabel');
if (lastLabel) lastLabel.textContent = (UI_TEXT.static.statsScanning as string) || 'Scanning...'; if (lastLabel) lastLabel.textContent = (UI_TEXT.static.statsScanning as string) || 'Scanning...';
@@ -9,9 +16,11 @@ async function refreshArchiveStats(): Promise<void> {
renderArchiveStats(stats); renderArchiveStats(stats);
} catch (e) { } catch (e) {
const summary = document.getElementById('statsSummaryGrid'); const summary = document.getElementById('statsSummaryGrid');
if (summary) summary.textContent = `Fehler: ${String(e)}`; if (summary) summary.textContent = `${UI_TEXT.static.errorPrefix}: ${String(e)}`;
} finally { } finally {
archiveStatsRefreshInFlight = false;
if (btn) btn.disabled = false; if (btn) btn.disabled = false;
if (toolbarBtn) toolbarBtn.disabled = false;
} }
} }
@@ -19,7 +28,7 @@ function renderArchiveStats(stats: ArchiveStats): void {
const lastLabel = document.getElementById('statsLastScannedLabel'); const lastLabel = document.getElementById('statsLastScannedLabel');
if (lastLabel) { if (lastLabel) {
const dt = new Date(stats.scannedAt); const dt = new Date(stats.scannedAt);
lastLabel.textContent = `${UI_TEXT.static.statsScannedAt}: ${dt.toLocaleString()}`; lastLabel.textContent = `${UI_TEXT.static.statsScannedAt}: ${formatUiDateTime(dt)}`;
} }
renderStatsSummary(stats); renderStatsSummary(stats);
+20 -9
View File
@@ -726,13 +726,7 @@ async function removeStreamer(name: string): Promise<void> {
currentStreamer = null; currentStreamer = null;
const hide = (window as unknown as { hideStreamerProfileHeader?: () => void }).hideStreamerProfileHeader; const hide = (window as unknown as { hideStreamerProfileHeader?: () => void }).hideStreamerProfileHeader;
if (typeof hide === 'function') hide(); if (typeof hide === 'function') hide();
byId('vodGrid').innerHTML = ` setVodGridEmptyState(byId('vodGrid'), UI_TEXT.vods.noneTitle, UI_TEXT.vods.noneText);
<div class="empty-state">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
<h3>${UI_TEXT.vods.noneTitle}</h3>
<p>${UI_TEXT.vods.noneText}</p>
</div>
`;
} }
async function selectStreamer(name: string, forceRefresh = false): Promise<void> { async function selectStreamer(name: string, forceRefresh = false): Promise<void> {
@@ -803,6 +797,24 @@ async function selectStreamer(name: string, forceRefresh = false): Promise<void>
renderVODs(vods, name); renderVODs(vods, name);
} }
function createVodEmptyStateIcon(): SVGSVGElement {
const namespace = 'http://www.w3.org/2000/svg';
const icon = document.createElementNS(namespace, 'svg');
icon.setAttribute('aria-hidden', 'true');
icon.setAttribute('viewBox', '0 0 24 24');
icon.setAttribute('fill', 'none');
icon.setAttribute('stroke', 'currentColor');
icon.setAttribute('stroke-width', '1.5');
icon.setAttribute('stroke-linecap', 'round');
icon.setAttribute('stroke-linejoin', 'round');
const page = document.createElementNS(namespace, 'path');
page.setAttribute('d', 'M6 2h8l4 4v16H6zM14 2v5h5');
const play = document.createElementNS(namespace, 'path');
play.setAttribute('d', 'M10 11l5 3-5 3z');
icon.append(page, play);
return icon;
}
function setVodGridEmptyState(grid: HTMLElement, title: string, text: string): void { function setVodGridEmptyState(grid: HTMLElement, title: string, text: string): void {
// Build via DOM API so the (locale-only) strings can never escape into HTML. // Build via DOM API so the (locale-only) strings can never escape into HTML.
const wrap = document.createElement('div'); const wrap = document.createElement('div');
@@ -811,8 +823,7 @@ function setVodGridEmptyState(grid: HTMLElement, title: string, text: string): v
h3.textContent = title; h3.textContent = title;
const p = document.createElement('p'); const p = document.createElement('p');
p.textContent = text; p.textContent = text;
wrap.appendChild(h3); wrap.append(createVodEmptyStateIcon(), h3, p);
wrap.appendChild(p);
grid.replaceChildren(wrap); grid.replaceChildren(wrap);
} }
+56
View File
@@ -17,6 +17,11 @@ function formatUiDate(input: string | Date): string {
return date.toLocaleDateString(getIntlLocale()); return date.toLocaleDateString(getIntlLocale());
} }
function formatUiDateTime(input: string | Date): string {
const date = input instanceof Date ? input : new Date(input);
return date.toLocaleString(getIntlLocale());
}
function formatUiNumber(value: number): string { function formatUiNumber(value: number): string {
return value.toLocaleString(getIntlLocale()); return value.toLocaleString(getIntlLocale());
} }
@@ -62,6 +67,33 @@ function applyLanguageToStaticUI(): void {
setText('navMergeText', UI_TEXT.static.navMerge); setText('navMergeText', UI_TEXT.static.navMerge);
setText('navStatsText', UI_TEXT.static.navStats); setText('navStatsText', UI_TEXT.static.navStats);
setText('navArchiveText', UI_TEXT.static.navArchive); setText('navArchiveText', UI_TEXT.static.navArchive);
setAriaLabelAll('.top-nav', UI_TEXT.static.mainNavigationAria);
setAriaLabelAll('.context-sidebar', UI_TEXT.static.workspaceContextAria);
setAriaLabelAll('[data-context-for="vods"] .context-switcher', UI_TEXT.static.vodWorkspaceAria);
setAriaLabelAll('[data-context-for="clips"] .context-list', UI_TEXT.static.clipSectionsAria);
setAriaLabelAll('[data-context-for="cutter"] .context-list', UI_TEXT.static.cutterSectionsAria);
setAriaLabelAll('[data-context-for="merge"] .context-list', UI_TEXT.static.mergeSectionsAria);
setAriaLabelAll('[data-context-for="stats"] .context-list', UI_TEXT.static.statisticsSectionsAria);
setAriaLabelAll('[data-context-for="archive"] .context-list', UI_TEXT.static.archiveSectionsAria);
setAriaLabelAll('[data-context-for="settings"] .context-list', UI_TEXT.static.settingsSectionsAria);
setAriaLabel('toolbarCutBtn', UI_TEXT.static.cutVideoAction);
setTitle('toolbarCutBtn', UI_TEXT.static.cutVideoAction);
setAriaLabel('toolbarMergeBtn', UI_TEXT.static.mergeVideosAction);
setTitle('toolbarMergeBtn', UI_TEXT.static.mergeVideosAction);
document.querySelectorAll<HTMLButtonElement>('.top-nav [data-tab]').forEach((button) => {
const tab = button.dataset.tab;
const titles: Record<string, string> = {
vods: UI_TEXT.static.navVods,
clips: UI_TEXT.static.navClips,
cutter: UI_TEXT.static.navCutter,
merge: UI_TEXT.static.navMerge,
stats: UI_TEXT.static.navStats,
archive: UI_TEXT.static.navArchive,
settings: UI_TEXT.static.navSettings
};
button.title = tab ? titles[tab] || '' : '';
});
setText('archiveResultsNavText', UI_TEXT.static.archiveResults);
setText('archiveTitle', UI_TEXT.static.archiveTitle); setText('archiveTitle', UI_TEXT.static.archiveTitle);
setText('archiveIntro', UI_TEXT.static.archiveIntro); setText('archiveIntro', UI_TEXT.static.archiveIntro);
setText('btnArchiveSearch', UI_TEXT.static.archiveSearchBtn); setText('btnArchiveSearch', UI_TEXT.static.archiveSearchBtn);
@@ -94,6 +126,8 @@ function applyLanguageToStaticUI(): void {
setText('statsSizeBucketsTitle', UI_TEXT.static.statsSizeBucketsTitle); setText('statsSizeBucketsTitle', UI_TEXT.static.statsSizeBucketsTitle);
setText('btnStatsRefresh', UI_TEXT.static.statsRefresh); setText('btnStatsRefresh', UI_TEXT.static.statsRefresh);
setText('queueTitleText', UI_TEXT.static.queueTitle); setText('queueTitleText', UI_TEXT.static.queueTitle);
setText('streamerWorkspaceSwitch', UI_TEXT.static.streamerSectionTitle);
setText('queueWorkspaceSwitch', UI_TEXT.static.queueTitle);
setText('healthBadge', UI_TEXT.static.healthUnknown); setText('healthBadge', UI_TEXT.static.healthUnknown);
setText('btnRetryFailed', UI_TEXT.static.retryFailed); setText('btnRetryFailed', UI_TEXT.static.retryFailed);
setTitle('btnRetryFailed', UI_TEXT.static.retryFailedHint); setTitle('btnRetryFailed', UI_TEXT.static.retryFailedHint);
@@ -120,6 +154,9 @@ function applyLanguageToStaticUI(): void {
setText('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle); setText('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle);
setText('cutterPreviewPlaceholder', UI_TEXT.static.cutterPreviewPlaceholder); setText('cutterPreviewPlaceholder', UI_TEXT.static.cutterPreviewPlaceholder);
setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse); setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse);
setText('commandPaletteTitle', UI_TEXT.static.commandPaletteTitle);
setAriaLabel('commandPaletteInput', UI_TEXT.static.commandPaletteAria);
setAriaLabel('commandPaletteList', UI_TEXT.static.commandPaletteResultsAria);
setPlaceholder('commandPaletteInput', UI_TEXT.static.commandPaletteSearchPlaceholder); setPlaceholder('commandPaletteInput', UI_TEXT.static.commandPaletteSearchPlaceholder);
setText('commandPaletteHint', UI_TEXT.static.commandPaletteHint); setText('commandPaletteHint', UI_TEXT.static.commandPaletteHint);
setText('cutterInfoDurationLabel', UI_TEXT.cutter.infoDuration); setText('cutterInfoDurationLabel', UI_TEXT.cutter.infoDuration);
@@ -136,6 +173,8 @@ function applyLanguageToStaticUI(): void {
setText('designTitle', UI_TEXT.static.designTitle); setText('designTitle', UI_TEXT.static.designTitle);
setText('themeLabel', UI_TEXT.static.themeLabel); setText('themeLabel', UI_TEXT.static.themeLabel);
setText('themeLightOption', UI_TEXT.static.themeLight); setText('themeLightOption', UI_TEXT.static.themeLight);
setText('themeDarkOption', UI_TEXT.static.themeDark);
setText('themeSystemOption', UI_TEXT.static.themeSystem);
setText('languageLabel', UI_TEXT.static.languageLabel); setText('languageLabel', UI_TEXT.static.languageLabel);
setText('languageDeText', UI_TEXT.static.languageDe); setText('languageDeText', UI_TEXT.static.languageDe);
setText('languageEnText', UI_TEXT.static.languageEn); setText('languageEnText', UI_TEXT.static.languageEn);
@@ -147,6 +186,7 @@ function applyLanguageToStaticUI(): void {
setText('saveSettingsBtn', UI_TEXT.static.saveSettings); setText('saveSettingsBtn', UI_TEXT.static.saveSettings);
setText('downloadSettingsTitle', UI_TEXT.static.downloadSettingsTitle); setText('downloadSettingsTitle', UI_TEXT.static.downloadSettingsTitle);
setText('storageLabel', UI_TEXT.static.storageLabel); setText('storageLabel', UI_TEXT.static.storageLabel);
setText('selectFolderBtn', UI_TEXT.static.selectFolder);
setText('openFolderBtn', UI_TEXT.static.openFolder); setText('openFolderBtn', UI_TEXT.static.openFolder);
setText('modeLabel', UI_TEXT.static.modeLabel); setText('modeLabel', UI_TEXT.static.modeLabel);
setText('modeFullText', UI_TEXT.static.modeFull); setText('modeFullText', UI_TEXT.static.modeFull);
@@ -290,6 +330,10 @@ function applyLanguageToStaticUI(): void {
setText('runtimeMetricsOutput', UI_TEXT.static.runtimeMetricsLoading); setText('runtimeMetricsOutput', UI_TEXT.static.runtimeMetricsLoading);
setText('updateText', UI_TEXT.static.checkUpdates); setText('updateText', UI_TEXT.static.checkUpdates);
setText('updateButton', UI_TEXT.updates.downloadNow); setText('updateButton', UI_TEXT.updates.downloadNow);
setText('workspaceUpdateLater', UI_TEXT.updates.later);
setText('workspaceUpdateDismissText', UI_TEXT.updates.dismissAria);
setAriaLabel('workspaceUpdateDismiss', UI_TEXT.updates.dismissAria);
setTitle('workspaceUpdateDismiss', UI_TEXT.updates.dismissAria);
setText('updateModalEyebrow', UI_TEXT.static.updateTitle); setText('updateModalEyebrow', UI_TEXT.static.updateTitle);
setText('updateModalTitle', UI_TEXT.updates.modalAvailableTitle); setText('updateModalTitle', UI_TEXT.updates.modalAvailableTitle);
setText('updateModalDismissBtn', UI_TEXT.updates.modalDismiss); setText('updateModalDismissBtn', UI_TEXT.updates.modalDismiss);
@@ -306,6 +350,18 @@ function applyLanguageToStaticUI(): void {
setAriaLabel('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle); setAriaLabel('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle);
setPlaceholder('chatViewerFilter', UI_TEXT.queue.chatViewerFilterPlaceholder); setPlaceholder('chatViewerFilter', UI_TEXT.queue.chatViewerFilterPlaceholder);
setAriaLabel('chatViewerFilter', UI_TEXT.queue.chatViewerFilterAria); setAriaLabel('chatViewerFilter', UI_TEXT.queue.chatViewerFilterAria);
setPlaceholder('settingsSearchInput', UI_TEXT.static.settingsSearchPlaceholder);
setAriaLabel('settingsSearchInput', UI_TEXT.static.settingsSearchPlaceholder);
setAriaLabel('systemStatusButton', UI_TEXT.static.systemStatus);
setTitle('systemStatusButton', UI_TEXT.static.systemStatus);
setAriaLabel('openSettingsButton', UI_TEXT.static.openSettings);
setTitle('openSettingsButton', UI_TEXT.static.openSettings);
setAriaLabel('toolbarRefreshVodsBtn', UI_TEXT.static.refreshVods);
setTitle('toolbarRefreshVodsBtn', UI_TEXT.static.refreshVods);
setAriaLabel('toolbarClipDownloadBtn', UI_TEXT.static.downloadClip);
setTitle('toolbarClipDownloadBtn', UI_TEXT.static.downloadClip);
setAriaLabel('toolbarCheckUpdateBtn', UI_TEXT.static.checkUpdates);
setTitle('toolbarCheckUpdateBtn', UI_TEXT.static.checkUpdates);
setText('vodSortLabel', UI_TEXT.vods.sortLabel); setText('vodSortLabel', UI_TEXT.vods.sortLabel);
if (typeof refreshVodSortSelectLabels === 'function') { if (typeof refreshVodSortSelectLabels === 'function') {
refreshVodSortSelectLabels(); refreshVodSortSelectLabels();
+53 -5
View File
@@ -8,6 +8,7 @@ let latestDownloadProgress: UpdateDownloadProgress | null = null;
let updateBannerState: 'idle' | 'available' | 'downloading' | 'ready' = 'idle'; let updateBannerState: 'idle' | 'available' | 'downloading' | 'ready' = 'idle';
let updateChangelogExpanded = false; let updateChangelogExpanded = false;
let shouldOpenUpdateModalOnAvailable = false; let shouldOpenUpdateModalOnAvailable = false;
let workspaceUpdatePopoverPostponed = false;
const SKIPPED_UPDATE_VERSION_KEY = 'twitch-vod-manager:skipped-update-version'; const SKIPPED_UPDATE_VERSION_KEY = 'twitch-vod-manager:skipped-update-version';
@@ -98,6 +99,15 @@ function setCheckButtonCheckingState(enabled: boolean): void {
} }
} }
function syncWorkspaceUpdateExpansion(): void {
const banner = byId('updateBanner');
const expanded = banner.classList.contains('show')
&& !banner.classList.contains('popover-dismissed')
&& !workspaceUpdatePopoverPostponed
&& (banner.matches(':hover') || banner.matches(':focus-within'));
byId<HTMLButtonElement>('workspaceUpdateButton').setAttribute('aria-expanded', String(expanded));
}
function syncWorkspaceUpdateState(state: 'idle' | 'available' | 'downloading' | 'ready'): void { function syncWorkspaceUpdateState(state: 'idle' | 'available' | 'downloading' | 'ready'): void {
const banner = byId('updateBanner'); const banner = byId('updateBanner');
const button = byId<HTMLButtonElement>('workspaceUpdateButton'); const button = byId<HTMLButtonElement>('workspaceUpdateButton');
@@ -106,25 +116,35 @@ function syncWorkspaceUpdateState(state: 'idle' | 'available' | 'downloading' |
banner.dataset.updateState = state; banner.dataset.updateState = state;
button.dataset.updateState = state; button.dataset.updateState = state;
button.disabled = state === 'downloading'; button.disabled = false;
if (state === 'downloading') {
button.setAttribute('aria-disabled', 'true');
} else {
button.removeAttribute('aria-disabled');
}
label.textContent = state === 'ready' ? UI_TEXT.updates.installNow : 'Update'; label.textContent = state === 'ready' ? UI_TEXT.updates.installNow : 'Update';
button.title = state === 'idle' ? UI_TEXT.static.checkUpdates : description; button.title = state === 'idle' ? UI_TEXT.static.checkUpdates : description;
button.setAttribute('aria-label', button.title); button.setAttribute('aria-label', button.title);
syncWorkspaceUpdateExpansion();
byId<HTMLButtonElement>('workspaceUpdateLater').hidden = state === 'idle' || state === 'downloading';
byId<HTMLButtonElement>('workspaceUpdateDismiss').hidden = state === 'idle';
} }
function showUpdateBanner(): void { function showUpdateBanner(): void {
byId('updateBanner').classList.add('show'); byId('updateBanner').classList.toggle('show', !workspaceUpdatePopoverPostponed);
syncWorkspaceUpdateState(updateBannerState); syncWorkspaceUpdateState(updateBannerState);
} }
function hideUpdateBanner(): void { function hideUpdateBanner(): void {
updateBannerState = 'idle'; updateBannerState = 'idle';
workspaceUpdatePopoverPostponed = false;
const banner = byId('updateBanner'); const banner = byId('updateBanner');
const progress = byId('updateProgress'); const progress = byId('updateProgress');
const bar = byId('updateProgressBar'); const bar = byId('updateProgressBar');
const action = byId<HTMLButtonElement>('updateButton'); const action = byId<HTMLButtonElement>('updateButton');
banner.classList.remove('show'); banner.classList.remove('show');
banner.classList.remove('popover-dismissed');
progress.classList.add('is-hidden'); progress.classList.add('is-hidden');
bar.classList.remove('downloading'); bar.classList.remove('downloading');
bar.style.width = '0%'; bar.style.width = '0%';
@@ -135,6 +155,26 @@ function hideUpdateBanner(): void {
syncWorkspaceUpdateState('idle'); syncWorkspaceUpdateState('idle');
} }
function postponeWorkspaceUpdatePopover(): void {
workspaceUpdatePopoverPostponed = true;
const banner = byId('updateBanner');
banner.classList.remove('show');
banner.classList.add('popover-dismissed');
byId<HTMLButtonElement>('workspaceUpdateButton').setAttribute('aria-expanded', 'false');
byId<HTMLButtonElement>('workspaceUpdateButton').focus();
}
function dismissWorkspaceUpdatePopover(): void {
hideUpdateBanner();
byId<HTMLButtonElement>('workspaceUpdateButton').focus();
}
for (const eventName of ['mouseenter', 'mouseleave', 'focusin', 'focusout']) {
byId('updateBanner').addEventListener(eventName, () => {
window.requestAnimationFrame(syncWorkspaceUpdateExpansion);
});
}
function handleWorkspaceUpdateAction(): void { function handleWorkspaceUpdateAction(): void {
if (updateBannerState === 'downloading' || updateCheckInProgress) { if (updateBannerState === 'downloading' || updateCheckInProgress) {
return; return;
@@ -148,12 +188,16 @@ function handleWorkspaceUpdateAction(): void {
void checkUpdate(); void checkUpdate();
} }
function setUpdateBannerAvailableUi(info: UpdateInfo): void { function setUpdateBannerAvailableUi(info: UpdateInfo, reveal = true): void {
const activeInfo = rememberUpdateInfo(info); const activeInfo = rememberUpdateInfo(info);
updateReady = false; updateReady = false;
updateDownloadInProgress = false; updateDownloadInProgress = false;
latestDownloadProgress = null; latestDownloadProgress = null;
updateBannerState = 'available'; updateBannerState = 'available';
if (reveal) {
workspaceUpdatePopoverPostponed = false;
byId('updateBanner').classList.remove('popover-dismissed');
}
showUpdateBanner(); showUpdateBanner();
byId('updateProgress').classList.add('is-hidden'); byId('updateProgress').classList.add('is-hidden');
@@ -172,6 +216,8 @@ function setUpdateBannerAvailableUi(info: UpdateInfo): void {
function setDownloadPendingUi(): void { function setDownloadPendingUi(): void {
updateReady = false; updateReady = false;
updateBannerState = 'downloading'; updateBannerState = 'downloading';
workspaceUpdatePopoverPostponed = false;
byId('updateBanner').classList.remove('popover-dismissed');
showUpdateBanner(); showUpdateBanner();
const button = byId<HTMLButtonElement>('updateButton'); const button = byId<HTMLButtonElement>('updateButton');
@@ -193,11 +239,13 @@ function setDownloadPendingUi(): void {
function setDownloadReadyUi(info?: UpdateInfo): void { function setDownloadReadyUi(info?: UpdateInfo): void {
const activeInfo = rememberUpdateInfo(info); const activeInfo = rememberUpdateInfo(info);
showUpdateBanner();
updateReady = true; updateReady = true;
updateDownloadInProgress = false; updateDownloadInProgress = false;
updateBannerState = 'ready'; updateBannerState = 'ready';
workspaceUpdatePopoverPostponed = false;
byId('updateBanner').classList.remove('popover-dismissed');
latestDownloadProgress = null; latestDownloadProgress = null;
showUpdateBanner();
const bar = byId('updateProgressBar'); const bar = byId('updateProgressBar');
bar.classList.remove('downloading'); bar.classList.remove('downloading');
@@ -426,7 +474,7 @@ function refreshUpdateUiTexts(): void {
const bar = byId('updateProgressBar'); const bar = byId('updateProgressBar');
if (updateBannerState === 'available' && latestUpdateInfo) { if (updateBannerState === 'available' && latestUpdateInfo) {
setUpdateBannerAvailableUi(latestUpdateInfo); setUpdateBannerAvailableUi(latestUpdateInfo, false);
} else if (updateBannerState === 'downloading') { } else if (updateBannerState === 'downloading') {
button.textContent = UI_TEXT.updates.downloading; button.textContent = UI_TEXT.updates.downloading;
button.disabled = true; button.disabled = true;
+57 -29
View File
@@ -44,26 +44,6 @@ async function init(): Promise<void> {
renderStreamers(); renderStreamers();
renderQueue(); renderQueue();
// Keyboard activation for nav-items (Enter / Space). The items are
// div[role="button"][tabindex="0"], so browsers won't synthesise a
// click on Enter/Space natively — we wire it here once via event
// delegation so the listener doesn't need re-binding per tab switch.
const nav = document.querySelector('.nav');
if (nav && !nav.hasAttribute('data-keynav-bound')) {
nav.setAttribute('data-keynav-bound', '1');
nav.addEventListener('keydown', (event) => {
const ev = event as KeyboardEvent;
if (ev.key !== 'Enter' && ev.key !== ' ') return;
const target = ev.target as HTMLElement | null;
const item = target?.closest('.nav-item') as HTMLElement | null;
if (!item) return;
const tab = item.dataset.tab;
if (!tab) return;
ev.preventDefault();
showTab(tab);
});
}
// Kick off live-status subscription so the sidebar dots populate. // Kick off live-status subscription so the sidebar dots populate.
const liveStatusInit = (window as unknown as { initLiveStatusSubscription?: () => Promise<void> }).initLiveStatusSubscription; const liveStatusInit = (window as unknown as { initLiveStatusSubscription?: () => Promise<void> }).initLiveStatusSubscription;
if (typeof liveStatusInit === 'function') void liveStatusInit(); if (typeof liveStatusInit === 'function') void liveStatusInit();
@@ -807,10 +787,12 @@ function getQueueStateFingerprint(items: QueueItem[]): string {
} }
function updateDownloadButtonState(): void { function updateDownloadButtonState(): void {
const btn = byId('btnStart'); const btn = byId<HTMLButtonElement>('btnStart');
const hasPaused = queue.some((item) => item.status === 'paused'); const hasPaused = queue.some((item) => item.status === 'paused');
const hasRunnable = queue.some((item) => item.status === 'pending' || item.status === 'paused');
btn.textContent = downloading ? UI_TEXT.queue.stop : (hasPaused ? UI_TEXT.queue.resume : UI_TEXT.queue.start); btn.textContent = downloading ? UI_TEXT.queue.stop : (hasPaused ? UI_TEXT.queue.resume : UI_TEXT.queue.start);
btn.classList.toggle('downloading', downloading); btn.classList.toggle('downloading', downloading);
btn.disabled = !downloading && !hasRunnable;
} }
async function syncQueueAndDownloadState(): Promise<void> { async function syncQueueAndDownloadState(): Promise<void> {
@@ -888,15 +870,50 @@ function syncWorkspaceChrome(tab: string): void {
const activeContext = document.querySelector<HTMLElement>(`[data-context-for="${tab}"]`); const activeContext = document.querySelector<HTMLElement>(`[data-context-for="${tab}"]`);
const contextHeading = activeContext?.querySelector<HTMLElement>('[data-context-heading]'); const contextHeading = activeContext?.querySelector<HTMLElement>('[data-context-heading]');
if (contextHeading && navLabel) contextHeading.textContent = navLabel; if (contextHeading && navLabel) contextHeading.textContent = navLabel;
if (activeContext) syncWorkspaceContextSelection(activeContext);
const workspace = document.querySelector<HTMLElement>('.workspace-shell'); const workspace = document.querySelector<HTMLElement>('.workspace-shell');
if (workspace) workspace.dataset.activeWorkspace = tab; if (workspace) workspace.dataset.activeWorkspace = tab;
} }
function focusWorkspaceTarget(id: string): void { function syncWorkspaceContextSelection(panel: HTMLElement): void {
const links = Array.from(panel.querySelectorAll<HTMLButtonElement>('.context-list .context-link'));
const activeLink = links.find((link) => link.classList.contains('active')) || links[0];
links.forEach((link) => {
const active = link === activeLink;
link.classList.toggle('active', active);
if (active) link.setAttribute('aria-current', 'page');
else link.removeAttribute('aria-current');
});
const switchers = Array.from(panel.querySelectorAll<HTMLButtonElement>('.context-switcher button'));
const activeSwitcher = switchers.find((button) => button.classList.contains('active')) || switchers[0];
switchers.forEach((button) => {
const active = button === activeSwitcher;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', String(active));
});
}
function focusWorkspaceTarget(id: string, source?: HTMLElement): void {
const target = document.getElementById(id) as HTMLElement | null; const target = document.getElementById(id) as HTMLElement | null;
if (!target) return; if (!target) return;
const group = source?.closest<HTMLElement>('.context-list, .context-switcher');
if (group && source) {
const buttons = Array.from(group.querySelectorAll<HTMLButtonElement>('button'));
buttons.forEach((button) => {
const active = button === source;
button.classList.toggle('active', active);
if (group.classList.contains('context-list')) {
if (active) button.setAttribute('aria-current', 'page');
else button.removeAttribute('aria-current');
} else {
button.setAttribute('aria-pressed', String(active));
}
});
}
target.scrollIntoView({ behavior: 'smooth', block: 'start' }); target.scrollIntoView({ behavior: 'smooth', block: 'start' });
const focusTarget = target.matches('button, input, select, textarea, [tabindex]') const focusTarget = target.matches('button, input, select, textarea, [tabindex]')
? target ? target
@@ -1487,10 +1504,15 @@ async function confirmClipDialog(): Promise<void> {
closeClipDialog(); closeClipDialog();
} }
let clipDownloadInFlight = false;
async function downloadClip(): Promise<void> { async function downloadClip(): Promise<void> {
if (clipDownloadInFlight) return;
const url = byId<HTMLInputElement>('clipUrl').value.trim(); const url = byId<HTMLInputElement>('clipUrl').value.trim();
const status = byId('clipStatus'); const status = byId('clipStatus');
const btn = byId('btnClip'); const btn = byId<HTMLButtonElement>('btnClip');
const toolbarBtn = byId<HTMLButtonElement>('toolbarClipDownloadBtn');
if (!url) { if (!url) {
status.textContent = UI_TEXT.clips.enterUrl; status.textContent = UI_TEXT.clips.enterUrl;
@@ -1498,27 +1520,33 @@ async function downloadClip(): Promise<void> {
return; return;
} }
clipDownloadInFlight = true;
btn.disabled = true; btn.disabled = true;
toolbarBtn.disabled = true;
btn.textContent = UI_TEXT.clips.loadingButton; btn.textContent = UI_TEXT.clips.loadingButton;
status.textContent = UI_TEXT.clips.loadingStatus; status.textContent = UI_TEXT.clips.loadingStatus;
status.className = 'clip-status loading'; status.className = 'clip-status loading';
try {
const result = await window.api.downloadClip(url); const result = await window.api.downloadClip(url);
btn.disabled = false;
btn.textContent = UI_TEXT.clips.downloadButton;
if (result.success) { if (result.success) {
status.textContent = UI_TEXT.clips.success; status.textContent = UI_TEXT.clips.success;
status.className = 'clip-status success'; status.className = 'clip-status success';
return; return;
} }
// Backend now produces locale-aware error strings via tBackend(),
// so we no longer need a renderer-side translation table here.
const backendError = (result.error || '').trim(); const backendError = (result.error || '').trim();
status.textContent = UI_TEXT.clips.errorPrefix + (backendError || UI_TEXT.clips.unknownError); status.textContent = UI_TEXT.clips.errorPrefix + (backendError || UI_TEXT.clips.unknownError);
status.className = 'clip-status error'; status.className = 'clip-status error';
} catch {
status.textContent = UI_TEXT.clips.errorPrefix + UI_TEXT.clips.unknownError;
status.className = 'clip-status error';
} finally {
clipDownloadInFlight = false;
btn.disabled = false;
toolbarBtn.disabled = false;
btn.textContent = UI_TEXT.clips.downloadButton;
}
} }
async function loadCutterFromPath(filePath: string): Promise<void> { async function loadCutterFromPath(filePath: string): Promise<void> {
+376 -31
View File
@@ -17,6 +17,12 @@
--workspace-warning: #f2c66d; --workspace-warning: #f2c66d;
--workspace-danger: #ef7d7d; --workspace-danger: #ef7d7d;
--workspace-info: #8fb5ff; --workspace-info: #8fb5ff;
--workspace-empty-icon: #f3f3f3;
--workspace-popover-bg: #4f4d4d;
--workspace-popover-border: #5b5959;
--workspace-popover-text: #ffffff;
--workspace-popover-muted: #e4e2e2;
--workspace-focus-ring: #ffffff;
--workspace-radius: 6px; --workspace-radius: 6px;
--workspace-radius-small: 4px; --workspace-radius-small: 4px;
--workspace-topbar-height: 40px; --workspace-topbar-height: 40px;
@@ -61,6 +67,12 @@ body.theme-system {
--workspace-warning: #f2c66d; --workspace-warning: #f2c66d;
--workspace-danger: #ef7d7d; --workspace-danger: #ef7d7d;
--workspace-info: #8fb5ff; --workspace-info: #8fb5ff;
--workspace-empty-icon: #f3f3f3;
--workspace-popover-bg: #4f4d4d;
--workspace-popover-border: #5b5959;
--workspace-popover-text: #ffffff;
--workspace-popover-muted: #e4e2e2;
--workspace-focus-ring: #ffffff;
--bg-main: var(--workspace-client); --bg-main: var(--workspace-client);
--bg-sidebar: var(--workspace-panel); --bg-sidebar: var(--workspace-panel);
--bg-card: var(--workspace-panel-raised); --bg-card: var(--workspace-panel-raised);
@@ -95,6 +107,12 @@ body.theme-light {
--workspace-warning: #8f5d12; --workspace-warning: #8f5d12;
--workspace-danger: #a33d3d; --workspace-danger: #a33d3d;
--workspace-info: #315ea8; --workspace-info: #315ea8;
--workspace-empty-icon: #596475;
--workspace-popover-bg: #4f4d4d;
--workspace-popover-border: #5b5959;
--workspace-popover-text: #ffffff;
--workspace-popover-muted: #e4e2e2;
--workspace-focus-ring: #172033;
--bg-main: var(--workspace-client); --bg-main: var(--workspace-client);
--bg-sidebar: var(--workspace-panel); --bg-sidebar: var(--workspace-panel);
--bg-card: var(--workspace-panel-raised); --bg-card: var(--workspace-panel-raised);
@@ -217,7 +235,7 @@ textarea:disabled,
height: 19px; height: 19px;
flex: 0 0 19px; flex: 0 0 19px;
color: var(--workspace-primary); color: var(--workspace-primary);
fill: currentColor; fill: none;
} }
.topbar-brand-name { .topbar-brand-name {
@@ -266,29 +284,31 @@ textarea:disabled,
} }
.top-nav-item.nav-item.active { .top-nav-item.nav-item.active {
color: var(--workspace-primary); color: var(--workspace-primary-text);
background: var(--workspace-control); background: var(--workspace-primary);
border-color: var(--workspace-border-strong); border-color: var(--workspace-primary);
} }
.top-nav-item.nav-item svg { .top-nav-item.nav-item svg {
width: 17px; width: 17px;
height: 17px; height: 17px;
flex: 0 0 17px; flex: 0 0 17px;
fill: currentColor; fill: none;
stroke: currentColor; stroke: currentColor;
} }
.top-nav-item .top-nav-label, .top-nav-item .top-nav-label,
.top-nav-item.nav-item > span { .top-nav-item.nav-item > span {
position: absolute; position: static;
width: 1px; width: auto;
height: 1px; min-width: 0;
height: auto;
padding: 0; padding: 0;
margin: -1px; margin: 0;
overflow: hidden; overflow: hidden;
clip: rect(0, 0, 0, 0); clip: auto;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis;
border: 0; border: 0;
} }
@@ -385,9 +405,9 @@ textarea:disabled,
width: max-content; width: max-content;
max-width: 250px; max-width: 250px;
padding: 7px 9px; padding: 7px 9px;
color: var(--workspace-text); color: var(--workspace-popover-text);
background: #505050; background: var(--workspace-popover-bg);
border: 1px solid #5c5c5c; border: 1px solid var(--workspace-popover-border);
border-radius: var(--workspace-radius-small); border-radius: var(--workspace-radius-small);
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
@@ -444,7 +464,7 @@ textarea:disabled,
} }
.context-panel { .context-panel {
display: none; display: flex;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
@@ -453,10 +473,6 @@ textarea:disabled,
overflow: hidden; overflow: hidden;
} }
.context-panel.active {
display: flex;
}
.context-sidebar-header { .context-sidebar-header {
display: flex; display: flex;
flex: 0 0 auto; flex: 0 0 auto;
@@ -1612,7 +1628,7 @@ input[type="range"] {
width: 56px; width: 56px;
height: 56px; height: 56px;
margin-bottom: 6px; margin-bottom: 6px;
color: #f3f3f3; color: var(--workspace-empty-icon);
opacity: 0.92; opacity: 0.92;
} }
@@ -2128,6 +2144,12 @@ input[type="range"] {
--workspace-warning: #8f5d12; --workspace-warning: #8f5d12;
--workspace-danger: #a33d3d; --workspace-danger: #a33d3d;
--workspace-info: #315ea8; --workspace-info: #315ea8;
--workspace-empty-icon: #596475;
--workspace-popover-bg: #4f4d4d;
--workspace-popover-border: #5b5959;
--workspace-popover-text: #ffffff;
--workspace-popover-muted: #e4e2e2;
--workspace-focus-ring: #172033;
--bg-main: var(--workspace-client); --bg-main: var(--workspace-client);
--bg-sidebar: var(--workspace-panel); --bg-sidebar: var(--workspace-panel);
--bg-card: var(--workspace-panel-raised); --bg-card: var(--workspace-panel-raised);
@@ -2333,9 +2355,9 @@ input[type="range"] {
align-items: stretch; align-items: stretch;
gap: 7px; gap: 7px;
padding: 8px; padding: 8px;
color: var(--workspace-text); color: var(--workspace-popover-text);
background: #4f4d4d; background: var(--workspace-popover-bg);
border: 1px solid #5b5959; border: 1px solid var(--workspace-popover-border);
border-radius: 5px; border-radius: 5px;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
@@ -2351,15 +2373,15 @@ input[type="range"] {
right: 39px; right: 39px;
width: 9px; width: 9px;
height: 9px; height: 9px;
background: #4f4d4d; background: var(--workspace-popover-bg);
border-top: 1px solid #5b5959; border-top: 1px solid var(--workspace-popover-border);
border-left: 1px solid #5b5959; border-left: 1px solid var(--workspace-popover-border);
content: ""; content: "";
transform: rotate(45deg); transform: rotate(45deg);
} }
.workspace-update:hover .workspace-update-popover, .workspace-update.show:not(.popover-dismissed):hover .workspace-update-popover,
.workspace-update:focus-within .workspace-update-popover { .workspace-update.show:not(.popover-dismissed):focus-within .workspace-update-popover {
visibility: visible; visibility: visible;
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
@@ -2398,10 +2420,6 @@ input[type="range"] {
height: 5px; height: 5px;
} }
.context-panel {
display: flex;
}
.context-switcher { .context-switcher {
display: flex; display: flex;
flex: 0 0 auto; flex: 0 0 auto;
@@ -2859,3 +2877,330 @@ input[type="range"] {
flex-basis: 190px; flex-basis: 190px;
} }
} }
.top-nav-item.nav-item.active:hover {
color: var(--workspace-primary-text);
background: var(--workspace-primary-hover);
border-color: var(--workspace-primary-hover);
}
.top-nav .top-nav-item:focus-visible {
outline: 2px solid var(--workspace-focus-ring);
outline-offset: 2px;
box-shadow: none;
}
#settingsTab .settings-card:has(#downloadPath) {
width: min(720px, 100%);
}
#settingsTab .form-row:has(#downloadPath) {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 8px;
}
#settingsTab #downloadPath {
width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#clipsTab.active {
display: grid;
grid-template-columns: minmax(420px, 1.35fr) minmax(280px, 0.65fr);
align-content: start;
align-items: start;
gap: 16px;
}
#clipsTab .clip-input {
display: grid;
width: 100%;
max-width: none;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
margin: 0;
padding: 16px;
text-align: left;
background: var(--workspace-panel-raised);
border: 1px solid var(--workspace-border);
border-radius: var(--workspace-radius);
}
#clipsTab .clip-input h2 {
grid-column: 1 / -1;
margin: 0 0 2px;
font-size: 16px;
line-height: 22px;
}
#clipsTab .clip-input #clipUrl {
width: 100%;
min-width: 0;
min-height: 38px;
margin: 0;
}
#clipsTab .clip-input #btnClip {
min-height: 38px;
white-space: nowrap;
}
#clipsTab .clip-input #clipStatus {
grid-column: 1 / -1;
min-height: 20px;
margin: 0;
font-size: 12px;
line-height: 20px;
}
#clipsTab > .settings-card.centered {
width: 100%;
max-width: none;
align-self: start;
margin: 0;
}
.workspace-settings-search {
position: relative;
display: flex;
flex: 0 1 550px;
min-width: 180px;
height: 36px;
align-items: center;
margin-left: auto;
}
.workspace-toolbar .toolbar-context[data-toolbar-for="settings"] {
flex: 1 1 auto;
width: 100%;
}
.workspace-settings-search input {
width: 100%;
min-width: 0;
height: 36px;
padding: 0 36px 0 34px;
color: var(--workspace-text);
background: var(--workspace-control);
border: 1px solid var(--workspace-border);
border-radius: var(--workspace-radius-small);
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.workspace-settings-search > svg {
position: absolute;
left: 11px;
z-index: 1;
width: 14px;
height: 14px;
color: var(--workspace-text-muted);
fill: none;
stroke: currentColor;
pointer-events: none;
}
.workspace-settings-search > button {
position: absolute;
right: 4px;
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
padding: 0;
color: var(--workspace-text-muted);
background: transparent;
border: 0;
border-radius: var(--workspace-radius-small);
cursor: pointer;
}
.workspace-settings-search > button:hover {
color: var(--workspace-text);
background: var(--workspace-control-hover);
}
.workspace-update-popover {
width: 260px;
}
.workspace-update-popover-header {
position: relative;
z-index: 1;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
color: var(--workspace-popover-text);
}
.workspace-update-popover-header #updateText {
flex: 1 1 auto;
min-width: 0;
}
#workspaceUpdateDismiss {
display: inline-flex;
width: 24px;
min-width: 24px;
height: 24px;
align-items: center;
justify-content: center;
padding: 0;
color: var(--workspace-popover-muted);
background: transparent;
border: 1px solid transparent;
border-radius: var(--workspace-radius-small);
cursor: pointer;
}
#workspaceUpdateDismiss:hover {
color: var(--workspace-popover-text);
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.18);
}
#workspaceUpdateDismiss::before {
content: "\00d7";
font-size: 17px;
font-weight: 500;
line-height: 1;
}
.workspace-update-popover-actions {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
.workspace-update-popover-actions #updateButton {
flex: 1 1 auto;
}
#workspaceUpdateLater {
display: inline-flex;
min-height: 30px;
align-items: center;
justify-content: center;
padding: 0 9px;
color: var(--workspace-popover-text);
background: transparent;
border: 1px solid var(--workspace-popover-muted);
border-radius: var(--workspace-radius-small);
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
#workspaceUpdateLater:hover {
background: rgba(255, 255, 255, 0.12);
}
.workspace-update-popover .update-banner-progress-track {
background: rgba(255, 255, 255, 0.22);
}
@media (min-width: 1180px) {
.topbar-navigation-cluster {
flex: 0 1 1000px;
width: 1000px;
max-width: calc(100vw - 254px);
}
.topbar-navigation-cluster .topbar-brand {
flex-basis: 190px;
min-width: 190px;
padding: 0 12px;
}
.topbar-navigation-cluster .topbar-brand #logoText {
position: static;
width: auto;
height: auto;
padding: 0;
margin: 0;
overflow: hidden;
clip: auto;
white-space: nowrap;
text-overflow: ellipsis;
border: 0;
}
.topbar-navigation-cluster .top-nav {
min-width: 0;
overflow: hidden;
}
.topbar-navigation-cluster .top-nav-item.nav-item {
flex: 1 1 auto;
width: auto;
min-width: 44px;
max-width: none;
padding: 0 8px;
gap: 6px;
}
.topbar-navigation-cluster .top-nav-item .top-nav-label,
.topbar-navigation-cluster .top-nav-item.nav-item > span {
position: static;
display: block;
width: auto;
min-width: 0;
height: auto;
padding: 0;
margin: 0;
overflow: hidden;
clip: auto;
white-space: nowrap;
text-overflow: ellipsis;
border: 0;
font-size: 12px;
}
}
@media (max-width: 1179px) {
.topbar-navigation-cluster .top-nav-item .top-nav-label,
.topbar-navigation-cluster .top-nav-item.nav-item > span {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.topbar-navigation-cluster .top-nav-item.nav-item {
width: 44px;
min-width: 44px;
padding: 0;
gap: 0;
}
}
@media (max-width: 1350px) {
.workspace-settings-search {
flex-basis: 280px;
}
}
@media (max-width: 820px) {
#clipsTab.active {
grid-template-columns: minmax(0, 1fr);
}
#settingsTab .form-row:has(#downloadPath) {
grid-template-columns: minmax(0, 1fr);
}
}