release: publish Twitch VOD Manager 1.0.3

Polish navigation, settings, streamer and queue workflows; add safe pause and partial-file lifecycle handling; apply the product identity across Windows surfaces; and replace the public README with a complete English product guide and isolated screenshot.
This commit is contained in:
Sucukdeluxe
2026-08-10 19:25:08 +02:00
parent f9e415da88
commit b3c2a8b9fd
48 changed files with 4035 additions and 826 deletions
+2
View File
@@ -8,4 +8,6 @@ tmp_*/
.env .env
.env.* .env.*
!.env.example !.env.example
.dev-program-data/
.dev-user-data/
.DS_Store .DS_Store
+14
View File
@@ -1,5 +1,19 @@
# Changelog # Changelog
## 1.0.3 - 2026-08-10
- Added animated selection markers across the main navigation, sidebar modes, language control, streamer list and settings pages.
- Reorganized Settings into dedicated pages with a responsive two-column download configuration and clearer public-mode guidance.
- Added startup preloading and silent five-minute background refreshes for configured streamers and their VOD libraries.
- Improved VOD cards with stable one-line titles, localized dates, clearer view counts, persistent duration badges and high-resolution hover previews.
- Added an optional split Streamer and Queue sidebar, compact streamer context actions and space-efficient merge-order selection.
- Improved queue accuracy with real zero-percent starts, stable progress metadata and pause or continue support without restarting the download.
- Added safe partial-file handling so incomplete downloads are removed after cancellation, normal shutdown or crash recovery and final names only appear after verification.
- Replaced Electron branding across the window, taskbar, installer, uninstaller and notifications with the Twitch VOD Manager identity.
- Improved German localization, date formatting, text-selection behavior, responsive navigation labels and update-dialog layout.
- Added a Windows hot-reload development workflow for renderer and main-process changes.
- Updated the public documentation with a complete feature overview, privacy details, setup guidance and an isolated product screenshot.
## 1.0.2 - 2026-08-10 ## 1.0.2 - 2026-08-10
- Redesigned the desktop workspace with compact top navigation, contextual sidebars and dedicated toolbars for all seven areas. - Redesigned the desktop workspace with compact top navigation, contextual sidebars and dedicated toolbars for all seven areas.
+109 -19
View File
@@ -1,40 +1,130 @@
# Twitch VOD Manager <div align="center">
<img src="build/icon.png" alt="Twitch VOD Manager icon" width="128" height="128">
Twitch VOD Manager is a Windows desktop application for finding, downloading, trimming, splitting, merging and organizing Twitch VODs and clips. # Twitch VOD Manager
## Features A focused Windows desktop app for browsing, downloading, trimming, merging and organizing Twitch VODs and clips.
- Search streamers and browse available VODs [![Latest release](https://img.shields.io/github/v/release/Sucukdeluxe/Twitch-VOD-Manager?display_name=tag&sort=semver)](https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest)
- Download complete VODs or precise time ranges [![Windows](https://img.shields.io/badge/platform-Windows-0078D4?logo=windows)](https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest)
- Split long recordings into configurable parts [![License](https://img.shields.io/github/license/Sucukdeluxe/Twitch-VOD-Manager)](LICENSE)
- Merge related downloads and track group progress
- Resume interrupted downloads and verify completed files [Download for Windows](https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest)
- Manage queues, history, profiles and per-streamer automation </div>
- Capture live streams and Twitch chat
- Navigate a compact workspace with contextual sidebars and dedicated toolbars ![Twitch VOD Manager workspace](docs/images/twitch-vod-manager-overview.png)
- Use Light, Dark and System themes with German and English localization
- Search settings and jump directly to individual configuration areas ## Overview
- Receive application updates through GitHub Releases
Twitch VOD Manager brings the complete VOD workflow into one desktop workspace. Add streamers, browse their public broadcasts, queue complete videos or precise ranges, and keep downloaded files organized without switching between separate tools.
The application works in public mode without a Twitch login. Connecting a Twitch account is optional and enables access to content available to that account, including eligible subscriber-only or channel-management VODs.
## Highlights
### Browse and organize
- Add multiple streamers and preload their profiles and VOD libraries at startup
- Refresh content quietly in the background every five minutes
- Search, sort and filter VODs with stable cards and localized metadata
- Preview high-resolution frames without leaving the application
- Track completed downloads in the archive and review aggregate statistics
### Download and process
- Download complete VODs or selected time ranges
- Trim, split and merge recordings with dedicated tools
- Queue multiple jobs and follow real progress, speed and remaining time
- Pause and continue an active download without restarting it
- Save optional chat replays and stream events alongside recordings
- Record live streams automatically with retry and merge controls
### Desktop experience
- Compact navigation with animated selection states
- Separate settings pages for appearance, Twitch, downloads, automation, storage, maintenance, updates and diagnostics
- Light, Dark and System themes
- English and German interface languages
- Optional split Streamer and Queue sidebar
- Command palette and keyboard-friendly controls
- Integrated update checks through verified GitHub release assets
### Reliable file handling
- Active downloads are written to temporary partial files
- Final filenames appear only after a successful integrity check
- Cancelled jobs and normal application shutdowns remove incomplete files
- Stale partial files left by a crash are cleaned up on the next launch
- Application data, settings and download history remain local
## Installation ## Installation
Download the current Windows installer from [GitHub Releases](https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest). 1. Open the [latest GitHub release](https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest).
2. Download `Twitch-VOD-Manager-Setup-1.0.3.exe`.
3. Run the installer and choose the installation directory.
4. Start Twitch VOD Manager and add a streamer.
The application stores its settings and local database on the computer where it is installed. No Twitch credentials, user settings, download history or personal data are included in this repository or its release files. The current Windows installer is not code-signed, so Microsoft Defender SmartScreen may ask for confirmation before the first installation.
## Getting started
1. Select **Twitch VODs** and choose **Add streamer**.
2. Enter a Twitch channel name.
3. Select a VOD card with a click, or right-click it to open the broadcast on Twitch.
4. Choose **+ Queue** for a complete download or **Trim VOD** for a time range.
5. Review the Queue in the sidebar and start the download.
Public VODs are available immediately. Twitch authentication can be configured under **Settings → Twitch API** when account-specific access is needed.
## Data and privacy
Twitch VOD Manager stores its configuration, local database, queue state and history on the computer where it runs. OAuth credentials are handled through the local application flow and are never included in this repository or in release files.
Public mode does not require a Twitch login. It supports public VOD discovery and downloads, while authenticated access depends on the permissions of the connected Twitch account.
## Updates
The application checks GitHub Releases for newer versions. The Update control only appears when an update is actually available. Every release includes `latest.yml`, the Windows installer and its blockmap for the desktop updater.
## Development ## Development
Requirements: ### Requirements
- Node.js 22.13 or newer - Node.js 22.13 or newer
- Windows for building the NSIS installer - npm
- Windows for NSIS installer builds
### Run with hot reload
```powershell ```powershell
npm ci npm ci
npm run dev
```
Renderer changes reload automatically. Main-process changes restart the development application.
### Verify and build
```powershell
npm run test:e2e:release npm run test:e2e:release
npm run dist:win npm run dist:win
``` ```
The Windows installer and updater metadata are written to `release/`.
## Project structure
| Path | Purpose |
| --- | --- |
| `src/main.ts` | Electron main process and desktop integrations |
| `src/main/` | Domain logic, persistence and infrastructure |
| `src/renderer-*.ts` | Workspace features and renderer behavior |
| `src/index.html` | Application shell and settings pages |
| `src/styles.css` | Shared component styles |
| `src/workspace.css` | Desktop workspace layout and motion |
| `scripts/` | Development, test and release checks |
| `build/` | Installer resources and application icons |
## License ## License
Twitch VOD Manager is available under the [MIT License](LICENSE). Twitch VOD Manager is released under the [MIT License](LICENSE).
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "1.0.2", "version": "1.0.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "1.0.2", "version": "1.0.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.16.1", "axios": "^1.16.1",
+7 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "1.0.2", "version": "1.0.3",
"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",
@@ -11,6 +11,7 @@
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"start": "npm run build && electron .", "start": "npm run build && electron .",
"dev": "node scripts/dev.mjs",
"test:unit": "vitest run --passWithNoTests", "test:unit": "vitest run --passWithNoTests",
"test:unit:watch": "vitest", "test:unit:watch": "vitest",
"test:e2e:update-logic": "node scripts/smoke-test-update-version-logic.js", "test:e2e:update-logic": "node scripts/smoke-test-update-version-logic.js",
@@ -54,6 +55,7 @@
"src/index.html", "src/index.html",
"src/styles.css", "src/styles.css",
"src/workspace.css", "src/workspace.css",
"build/icon.png",
"package.json" "package.json"
], ],
"directories": { "directories": {
@@ -61,7 +63,8 @@
}, },
"win": { "win": {
"target": "nsis", "target": "nsis",
"signAndEditExecutable": false, "icon": "build/icon.ico",
"signExecutable": false,
"artifactName": "Twitch-VOD-Manager-Setup-${version}.${ext}" "artifactName": "Twitch-VOD-Manager-Setup-${version}.${ext}"
}, },
"nsis": { "nsis": {
@@ -71,6 +74,8 @@
"createDesktopShortcut": true, "createDesktopShortcut": true,
"createStartMenuShortcut": true, "createStartMenuShortcut": true,
"shortcutName": "Twitch VOD Manager v${version}", "shortcutName": "Twitch VOD Manager v${version}",
"installerIcon": "build/icon.ico",
"uninstallerIcon": "build/icon.ico",
"include": "build/installer.nsh" "include": "build/installer.nsh"
}, },
"publish": { "publish": {
+101
View File
@@ -0,0 +1,101 @@
const { _electron: electron } = require('playwright');
const fs = require('fs');
const path = require('path');
const {
createE2eEnvironment,
getElectronLaunchOptions,
verifyE2eIsolation,
installOfflineFixtures,
cleanupE2eEnvironment
} = require('./e2e-test-environment');
async function run() {
const environment = createE2eEnvironment('readme-screenshot', {
language: 'en',
theme: 'twitch',
sidebar_split_view: true
});
const outputPath = path.resolve('docs', 'images', 'twitch-vod-manager-overview.png');
let app;
try {
app = await electron.launch(getElectronLaunchOptions(environment));
const win = await app.firstWindow();
await verifyE2eIsolation(app, win, environment);
await installOfflineFixtures(app);
await app.evaluate(({ ipcMain }) => {
const image = (label, from, to) => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="${from}"/><stop offset="1" stop-color="${to}"/></linearGradient></defs><rect width="640" height="360" fill="url(#g)"/><circle cx="520" cy="80" r="110" fill="rgba(255,255,255,.09)"/><circle cx="100" cy="320" r="170" fill="rgba(0,0,0,.12)"/><text x="34" y="300" fill="white" font-family="Segoe UI,Arial" font-size="34" font-weight="700">${label}</text></svg>`;
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
};
const vods = [
['Ranked highlights and community games', '5h13m20s', 155331, '#6441a5', '#1f7ae0'],
['Late night challenge run', '7h39m0s', 241394, '#172554', '#7c3aed'],
['Tournament watch party', '5h59m10s', 183557, '#0f766e', '#2563eb'],
['Creative stream and Q&A', '3h40m3s', 94821, '#7c2d12', '#db2777'],
['Weekend co-op session', '4h50m0s', 127644, '#1e3a8a', '#0891b2'],
['Best moments from the week', '2h54m23s', 119663, '#581c87', '#be123c']
].map(([title, duration, viewCount, from, to], index) => ({
id: `demo-vod-${index + 1}`,
title,
created_at: `2026-08-${String(9 - index).padStart(2, '0')}T18:00:00Z`,
duration,
thumbnail_url: image(`DEMO VOD ${index + 1}`, from, to),
url: `https://www.twitch.tv/videos/demo-${index + 1}`,
view_count: viewCount,
stream_id: `demo-stream-${index + 1}`
}));
ipcMain.removeHandler('get-user-id');
ipcMain.handle('get-user-id', async () => 'demo-user');
ipcMain.removeHandler('get-vods');
ipcMain.handle('get-vods', async () => vods);
ipcMain.removeHandler('get-streamer-profile');
ipcMain.handle('get-streamer-profile', async () => ({
login: 'demo_channel',
displayName: 'DemoChannel',
avatarUrl: image('TVM', '#7c3aed', '#0ea5e9'),
bannerUrl: '',
description: 'Example profile for the public product screenshot',
broadcasterType: 'partner',
followerCount: 1250000,
vodCount: vods.length,
lastStreamAt: '2026-08-09T18:00:00Z',
isLive: false,
currentTitle: null,
currentGame: null,
currentStreamPreviewUrl: '',
currentStreamViewers: null,
twitchUrl: 'https://www.twitch.tv/demo_channel',
fetchedAt: Date.now()
}));
ipcMain.removeHandler('get-streamer-display-names');
ipcMain.handle('get-streamer-display-names', async () => ({ demo_channel: 'DemoChannel' }));
});
await win.waitForFunction(() => typeof window.showTab === 'function');
await win.setViewportSize({ width: 1600, height: 900 });
await win.evaluate(async () => {
window.changeLanguage('en');
showTab('vods');
isConnected = true;
config.streamers = ['demo_channel'];
config.streamer_display_names = { demo_channel: 'DemoChannel' };
config.sidebar_split_view = true;
streamerVodCache.clear();
streamerProfileCache.clear();
await window.hydrateStreamerDisplayNames();
await selectStreamer('demo_channel');
});
await win.waitForTimeout(700);
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
await win.screenshot({ path: outputPath, type: 'png' });
process.stdout.write(`${outputPath}\n`);
} finally {
if (app) await app.close();
cleanupE2eEnvironment(environment);
}
}
run().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+102
View File
@@ -0,0 +1,102 @@
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { pathToFileURL } from 'node:url';
import { watch } from 'node:fs';
import { dirname, resolve } from 'node:path';
const rootDirectory = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const typescriptCli = resolve(rootDirectory, 'node_modules', 'typescript', 'bin', 'tsc');
const electronSourceExecutable = process.platform === 'win32'
? resolve(rootDirectory, 'node_modules', 'electron', 'dist', 'electron.exe')
: resolve(rootDirectory, 'node_modules', '.bin', 'electron');
let electronExecutable = electronSourceExecutable;
const outputDirectory = resolve(rootDirectory, 'dist');
const developmentProgramData = resolve(rootDirectory, '.dev-program-data');
const developmentUserData = resolve(rootDirectory, '.dev-user-data');
let electronProcess;
let restarting = false;
let restartTimer;
function run(command, args, options = {}) {
return spawn(command, args, { cwd: rootDirectory, stdio: 'inherit', ...options });
}
function waitForExit(child) {
return new Promise((resolveExit, reject) => {
child.once('error', reject);
child.once('exit', (code) => resolveExit(code ?? 1));
});
}
function startElectron() {
electronProcess = run(electronExecutable, [`--user-data-dir=${developmentUserData}`, '.'], {
env: {
...process.env,
PROGRAMDATA: developmentProgramData,
TWITCH_VOD_MANAGER_DEV: '1',
},
});
electronProcess.once('exit', () => {
electronProcess = undefined;
});
}
function restartElectron() {
if (restarting) return;
restarting = true;
if (electronProcess) {
electronProcess.once('exit', () => {
restarting = false;
startElectron();
});
electronProcess.kill();
return;
}
restarting = false;
startElectron();
}
function isElectronRestartTarget(fileName) {
const baseName = fileName.replaceAll('\\', '/').split('/').at(-1) ?? '';
return !/^renderer(?:[-.].+)?\.js$/.test(baseName);
}
function scheduleRestart(fileName) {
if (!fileName || !isElectronRestartTarget(fileName.toString())) return;
clearTimeout(restartTimer);
restartTimer = setTimeout(restartElectron, 200);
}
function stop(child) {
if (child && !child.killed) child.kill();
}
const initialCompile = run(process.execPath, [typescriptCli]);
const initialExitCode = await waitForExit(initialCompile);
if (initialExitCode !== 0) process.exit(initialExitCode);
if (process.platform === 'win32') {
const helperPath = pathToFileURL(resolve(outputDirectory, 'main', 'dev-executable.js')).href;
const { prepareWindowsDevExecutable } = await import(helperPath);
electronExecutable = await prepareWindowsDevExecutable({
sourcePath: electronSourceExecutable,
destinationPath: resolve(rootDirectory, 'node_modules', 'electron', 'dist', 'Twitch VOD Manager.exe'),
iconPath: resolve(rootDirectory, 'build', 'icon.ico'),
version: '1.0.3',
});
}
const compiler = run(process.execPath, [typescriptCli, '--watch', '--preserveWatchOutput']);
const outputWatcher = watch(outputDirectory, { recursive: true }, (_, fileName) => scheduleRestart(fileName));
startElectron();
for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, () => {
outputWatcher.close();
clearTimeout(restartTimer);
stop(compiler);
stop(electronProcess);
process.exit();
});
}
+17
View File
@@ -5,10 +5,15 @@
"LICENSE", "LICENSE",
"README.md", "README.md",
"build/installer.nsh", "build/installer.nsh",
"build/icon.ico",
"build/icon.png",
"docs/images/twitch-vod-manager-overview.png",
"eslint.config.mjs", "eslint.config.mjs",
"package-lock.json", "package-lock.json",
"package.json", "package.json",
"scripts/e2e-test-environment.js", "scripts/e2e-test-environment.js",
"scripts/capture-readme-screenshot.js",
"scripts/dev.mjs",
"scripts/public-release-files.json", "scripts/public-release-files.json",
"scripts/smoke-test-e2e-isolation-contract.js", "scripts/smoke-test-e2e-isolation-contract.js",
"scripts/smoke-test-full.js", "scripts/smoke-test-full.js",
@@ -26,10 +31,18 @@
"src/main/domain/chunk-index-store.ts", "src/main/domain/chunk-index-store.ts",
"src/main/domain/config-normalize.test.ts", "src/main/domain/config-normalize.test.ts",
"src/main/domain/config-normalize.ts", "src/main/domain/config-normalize.ts",
"src/main/domain/app-identity.test.ts",
"src/main/domain/app-identity.ts",
"src/main/domain/i18n-backend.test.ts", "src/main/domain/i18n-backend.test.ts",
"src/main/domain/i18n-backend.ts", "src/main/domain/i18n-backend.ts",
"src/main/domain/partial-download.test.ts",
"src/main/domain/partial-download.ts",
"src/main/domain/pausable-output.test.ts",
"src/main/domain/pausable-output.ts",
"src/main/domain/integrity-check.test.ts", "src/main/domain/integrity-check.test.ts",
"src/main/domain/integrity-check.ts", "src/main/domain/integrity-check.ts",
"src/main/domain/vod-preview.test.ts",
"src/main/domain/vod-preview.ts",
"src/main/domain/migrator.test.ts", "src/main/domain/migrator.test.ts",
"src/main/domain/migrator.ts", "src/main/domain/migrator.ts",
"src/main/domain/pkce.test.ts", "src/main/domain/pkce.test.ts",
@@ -59,6 +72,10 @@
"src/main/infra/secure-storage.test.ts", "src/main/infra/secure-storage.test.ts",
"src/main/infra/secure-storage.ts", "src/main/infra/secure-storage.ts",
"src/main.ts", "src/main.ts",
"src/main/dev-executable.test.ts",
"src/main/dev-executable.ts",
"src/main/dev-reload.test.ts",
"src/main/dev-reload.ts",
"src/preload.ts", "src/preload.ts",
"src/renderer-archive.ts", "src/renderer-archive.ts",
"src/renderer-command-palette.ts", "src/renderer-command-palette.ts",
+6
View File
@@ -189,6 +189,9 @@ async function run() {
retry: (document.getElementById('btnRetryFailed')?.textContent || '').trim(), retry: (document.getElementById('btnRetryFailed')?.textContent || '').trim(),
enText: (document.getElementById('languageEnText')?.textContent || '').trim(), enText: (document.getElementById('languageEnText')?.textContent || '').trim(),
enIcon: !!document.querySelector('#langOptionEn .flag-icon.flag-en'), enIcon: !!document.querySelector('#langOptionEn .flag-icon.flag-en'),
enIconTag: document.querySelector('#langOptionEn .flag-icon.flag-en')?.tagName || '',
enIconOpacity: getComputedStyle(document.querySelector('#langOptionEn .flag-icon.flag-en')).opacity,
enIconSize: document.querySelector('#langOptionEn .flag-icon.flag-en')?.getBoundingClientRect().toJSON() || null,
enActive: !!document.getElementById('langOptionEn')?.classList.contains('active') enActive: !!document.getElementById('langOptionEn')?.classList.contains('active')
}; };
@@ -197,6 +200,9 @@ async function run() {
assert(enState.nav.includes('Settings'), 'English language switch failed'); assert(enState.nav.includes('Settings'), 'English language switch failed');
assert(deState.deIcon, 'German flag icon missing'); assert(deState.deIcon, 'German flag icon missing');
assert(enState.enIcon, 'English flag icon missing'); assert(enState.enIcon, 'English flag icon missing');
assert(enState.enIconTag === 'svg', 'English flag must use crisp vector geometry');
assert(enState.enIconOpacity === '1', 'English flag must render fully opaque');
assert(enState.enIconSize?.width === 18 && enState.enIconSize?.height === 12, 'English flag must render at 18x12 pixels');
assert(deState.deActive, 'German language button did not activate'); assert(deState.deActive, 'German language button did not activate');
assert(enState.enActive, 'English language button did not activate'); assert(enState.enActive, 'English language button did not activate');
+16 -5
View File
@@ -13,19 +13,30 @@ function check(condition, message) {
if (!condition) failures.push(message); if (!condition) failures.push(message);
} }
check(packageJson.version === '1.0.2', `package version is ${packageJson.version}`); check(packageJson.version === '1.0.3', `package version is ${packageJson.version}`);
check(packageLock.version === '1.0.2', `lockfile version is ${packageLock.version}`); check(packageLock.version === '1.0.3', `lockfile version is ${packageLock.version}`);
check(packageLock.packages?.['']?.version === '1.0.2', `lockfile root package version is ${packageLock.packages?.['']?.version}`); check(packageLock.packages?.['']?.version === '1.0.3', `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}`);
check(JSON.stringify(packageJson.build?.files) === JSON.stringify(['dist/**/*', 'src/index.html', 'src/styles.css', 'src/workspace.css', 'package.json']), 'packaged file list is not restricted'); check(JSON.stringify(packageJson.build?.files) === JSON.stringify(['dist/**/*', 'src/index.html', 'src/styles.css', 'src/workspace.css', 'build/icon.png', 'package.json']), 'packaged file list is not restricted');
check(packageJson.build?.win?.icon === 'build/icon.ico', `Windows icon is ${packageJson.build?.win?.icon}`);
check(packageJson.build?.nsis?.installerIcon === 'build/icon.ico', `installer icon is ${packageJson.build?.nsis?.installerIcon}`);
check(packageJson.build?.nsis?.uninstallerIcon === 'build/icon.ico', `uninstaller icon is ${packageJson.build?.nsis?.uninstallerIcon}`);
check(packageJson.build?.win?.signAndEditExecutable !== false, 'Windows executable resource editing is enabled');
check(packageJson.build?.win?.signExecutable === false, 'Windows code signing remains disabled without suppressing icon resources');
check(fs.existsSync(path.join(root, 'build', 'icon.png')), 'application PNG icon is missing');
check(fs.existsSync(path.join(root, 'build', 'icon.ico')), 'application ICO icon is missing');
check(mainSource.includes('app.setAppUserModelId(WINDOWS_APP_IDENTITY.appUserModelId)'), 'Windows AppUserModelID is not applied from the centralized identity');
check(mainSource.includes('app.setName(WINDOWS_APP_IDENTITY.name)'), 'Windows application name is not applied before startup');
check(mainSource.includes("icon: path.join(__dirname, process.platform === 'win32' ? '../build/icon.ico' : '../build/icon.png')"), 'BrowserWindow does not use the platform application icon');
check(indexSource.includes('class="topbar-brand-mark" src="../build/icon.png"'), 'topbar does not use the application icon');
check(mainSource.includes('GITHUB_RELEASES_API_LATEST_URL'), 'GitHub releases API constant is missing'); check(mainSource.includes('GITHUB_RELEASES_API_LATEST_URL'), 'GitHub releases API constant is missing');
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(!/storyboards\/\d{8,12}(?:-|\/)/.test(mainSource), 'numeric Twitch VOD example remains in the public source'); 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: v1.0.3'), 'initial version label is not 1.0.3');
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');
+3
View File
@@ -41,6 +41,9 @@ async function run() {
}); });
await win.waitForTimeout(200); await win.waitForTimeout(200);
await win.click('[data-context-for="settings"] [data-settings-pane="downloads"]');
await win.waitForTimeout(180);
await win.click('#settingsTemplateGuideBtn'); await win.click('#settingsTemplateGuideBtn');
await win.waitForTimeout(180); await win.waitForTimeout(180);
File diff suppressed because it is too large Load Diff
+200 -214
View File
@@ -13,8 +13,8 @@
<div class="modal update-modal"> <div class="modal update-modal">
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="dismissUpdateModal()">x</button> <button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="dismissUpdateModal()">x</button>
<div class="update-modal-eyebrow" id="updateModalEyebrow">Updates</div> <div class="update-modal-eyebrow" id="updateModalEyebrow">Updates</div>
<h2 id="updateModalTitle">Update verfugbar</h2> <h2 id="updateModalTitle">Update verfügbar</h2>
<p class="update-modal-message" id="updateModalMessage">Version 0.0.0 ist verfugbar. Jetzt herunterladen?</p> <p class="update-modal-message" id="updateModalMessage">Version 0.0.0 ist verfügbar. Jetzt herunterladen?</p>
<div class="update-modal-meta is-hidden" id="updateModalMeta"></div> <div class="update-modal-meta is-hidden" id="updateModalMeta"></div>
<div class="update-changelog-card is-hidden" id="updateChangelogCard"> <div class="update-changelog-card is-hidden" id="updateChangelogCard">
@@ -24,13 +24,13 @@
</div> </div>
<div class="update-changelog-panel" id="updateChangelogPanel" hidden> <div class="update-changelog-panel" id="updateChangelogPanel" hidden>
<div class="update-changelog-content" id="updateChangelogContent"></div> <div class="update-changelog-content" id="updateChangelogContent"></div>
<p class="update-changelog-empty" id="updateChangelogEmpty" hidden>Kein Changelog verfugbar.</p> <p class="update-changelog-empty" id="updateChangelogEmpty" hidden>Kein Changelog verfügbar.</p>
</div> </div>
</div> </div>
<div class="modal-actions update-modal-actions"> <div class="modal-actions update-modal-actions">
<button class="btn-secondary" id="updateModalDismissBtn" type="button" onclick="dismissUpdateModal()">Nein</button> <button class="btn-secondary" id="updateModalDismissBtn" type="button" onclick="dismissUpdateModal()">Nein</button>
<button class="btn-secondary" id="updateModalSkipBtn" type="button" onclick="skipUpdateVersion()">Diese Version ueberspringen</button> <button class="btn-secondary" id="updateModalSkipBtn" type="button" onclick="skipUpdateVersion()">Diese Version überspringen</button>
<button class="btn-primary" id="updateModalConfirmBtn" type="button" onclick="confirmUpdateModal()">Ja, herunterladen</button> <button class="btn-primary" id="updateModalConfirmBtn" type="button" onclick="confirmUpdateModal()">Ja, herunterladen</button>
</div> </div>
</div> </div>
@@ -66,7 +66,7 @@
</div> </div>
<div class="clip-modal-field"> <div class="clip-modal-field">
<label class="clip-modal-label" id="clipDialogPartLabel" for="clipStartPart">Start Part-Nummer (optional, fur Fortsetzung):</label> <label class="clip-modal-label" id="clipDialogPartLabel" for="clipStartPart">Start Part-Nummer (optional, für Fortsetzung):</label>
<input type="text" id="clipStartPart" placeholder="z.B. 42" class="clip-modal-part-input" oninput="updateFilenameExamples()"> <input type="text" id="clipStartPart" placeholder="z.B. 42" class="clip-modal-part-input" oninput="updateFilenameExamples()">
<div id="clipDialogPartHint" class="clip-modal-hint">Leer lassen = Teil 1</div> <div id="clipDialogPartHint" class="clip-modal-hint">Leer lassen = Teil 1</div>
</div> </div>
@@ -99,7 +99,7 @@
</div> </div>
<div class="clip-modal-actions"> <div class="clip-modal-actions">
<button type="button" class="btn-pill success" id="clipDialogConfirmBtn" style="padding: 12px 30px;" onclick="confirmClipDialog()">Zur Queue hinzufugen</button> <button type="button" class="btn-pill success" id="clipDialogConfirmBtn" style="padding: 12px 30px;" onclick="confirmClipDialog()">Zur Queue hinzufügen</button>
</div> </div>
</div> </div>
</div> </div>
@@ -132,7 +132,7 @@
<div class="modal template-guide-modal"> <div class="modal template-guide-modal">
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeTemplateGuide()">x</button> <button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeTemplateGuide()">x</button>
<h2 id="templateGuideTitle">Template Guide</h2> <h2 id="templateGuideTitle">Template Guide</h2>
<p id="templateGuideIntro" class="template-guide-intro">Nutze Variablen fur Dateinamen und prufe das Ergebnis als Live-Vorschau.</p> <p id="templateGuideIntro" class="template-guide-intro">Nutze Variablen für Dateinamen und prüfe das Ergebnis als Live-Vorschau.</p>
<div class="template-guide-actions"> <div class="template-guide-actions">
<button type="button" class="btn-secondary" id="templateGuideUseVod" onclick="setTemplateGuidePreset('vod')">VOD Template</button> <button type="button" class="btn-secondary" id="templateGuideUseVod" onclick="setTemplateGuidePreset('vod')">VOD Template</button>
@@ -149,7 +149,7 @@
<div id="templateGuideContext" class="template-guide-context"></div> <div id="templateGuideContext" class="template-guide-context"></div>
</div> </div>
<h3 id="templateGuideVarsTitle" class="template-guide-vars-title">Verfugbare Variablen</h3> <h3 id="templateGuideVarsTitle" class="template-guide-vars-title">Verfügbare Variablen</h3>
<div class="template-guide-table-wrap"> <div class="template-guide-table-wrap">
<table class="template-guide-table" aria-labelledby="templateGuideVarsTitle"> <table class="template-guide-table" aria-labelledby="templateGuideVarsTitle">
<thead> <thead>
@@ -164,7 +164,7 @@
</div> </div>
<div class="template-guide-footer"> <div class="template-guide-footer">
<button type="button" class="btn-secondary" id="templateGuideCloseBtn" onclick="closeTemplateGuide()">Schliessen</button> <button type="button" class="btn-secondary" id="templateGuideCloseBtn" onclick="closeTemplateGuide()">Schließen</button>
</div> </div>
</div> </div>
</div> </div>
@@ -172,11 +172,7 @@
<header class="app-topbar"> <header class="app-topbar">
<div class="topbar-navigation-cluster"> <div class="topbar-navigation-cluster">
<div class="topbar-brand"> <div class="topbar-brand">
<svg class="topbar-brand-mark" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> <img class="topbar-brand-mark" src="../build/icon.png" alt="" aria-hidden="true">
<rect x="2.5" y="3.5" width="19" height="15" rx="3"></rect>
<path d="M9.5 8.25 15 11l-5.5 2.75z"></path>
<path d="M7 18.5V21l3.5-2.5"></path>
</svg>
<span id="logoText">Twitch VOD Manager</span> <span id="logoText">Twitch VOD Manager</span>
</div> </div>
<nav class="nav top-nav" aria-label="Main navigation"> <nav class="nav top-nav" aria-label="Main navigation">
@@ -192,9 +188,9 @@
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="7" r="3"></circle><circle cx="6" cy="17" r="3"></circle><path d="m8.6 8.5 11 7M8.6 15.5 20 8"></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"><circle cx="6" cy="7" r="3"></circle><circle cx="6" cy="17" r="3"></circle><path d="m8.6 8.5 11 7M8.6 15.5 20 8"></path></svg>
<span class="top-nav-label" id="navCutterText">Video schneiden</span> <span class="top-nav-label" id="navCutterText">Video schneiden</span>
</button> </button>
<button class="nav-item top-nav-item" type="button" data-tab="merge" onclick="showTab('merge')" title="Videos zusammenfugen"> <button class="nav-item top-nav-item" type="button" data-tab="merge" onclick="showTab('merge')" title="Videos zusammenfügen">
<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="M5 5h4a3 3 0 0 1 3 3v8a3 3 0 0 0 3 3h4"></path><path d="m16 16 3 3-3 3M5 19h4a3 3 0 0 0 3-3V8a3 3 0 0 1 3-3h4"></path><path d="m16 2 3 3-3 3"></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="M5 5h4a3 3 0 0 1 3 3v8a3 3 0 0 0 3 3h4"></path><path d="m16 16 3 3-3 3M5 19h4a3 3 0 0 0 3-3V8a3 3 0 0 1 3-3h4"></path><path d="m16 2 3 3-3 3"></path></svg>
<span class="top-nav-label" id="navMergeText">Videos zusammenfugen</span> <span class="top-nav-label" id="navMergeText">Videos zusammenfügen</span>
</button> </button>
<button class="nav-item top-nav-item" type="button" data-tab="stats" onclick="showTab('stats')" title="Statistik"> <button class="nav-item top-nav-item" type="button" data-tab="stats" onclick="showTab('stats')" title="Statistik">
<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="M4 19V9M10 19V5M16 19v-7M22 19V3"></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="M4 19V9M10 19V5M16 19v-7M22 19V3"></path></svg>
@@ -211,7 +207,7 @@
</nav> </nav>
</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" hidden>
<button type="button" class="workspace-update-button" id="workspaceUpdateButton" onclick="handleWorkspaceUpdateAction()" aria-describedby="updateText" aria-controls="workspaceUpdatePopover" aria-expanded="false"> <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>
@@ -235,33 +231,28 @@
</div> </div>
</div> </div>
</div> </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>
</button>
<button type="button" class="topbar-account-button" id="openSettingsButton" onclick="showTab('settings')" aria-label="Open settings" title="Open settings">
<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>
</button>
</div> </div>
</header> </header>
<div class="app workspace-shell"> <div class="app workspace-shell">
<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" data-vods-workspace="streamers" data-vods-layout="split">
<div class="context-switcher" role="group" aria-label="VOD workspace"> <div class="context-switcher" role="group" aria-label="VOD workspace">
<button type="button" id="streamerWorkspaceSwitch" class="active" aria-pressed="true" onclick="focusWorkspaceTarget('streamerList', this)">Streamer</button> <button type="button" id="streamerWorkspaceSwitch" class="active" aria-pressed="true" onclick="setVodsWorkspace('streamers', this)">Streamer</button>
<button type="button" id="queueWorkspaceSwitch" aria-pressed="false" onclick="focusWorkspaceTarget('queueList', this)">Warteschlange</button> <button type="button" id="queueWorkspaceSwitch" aria-pressed="false" onclick="setVodsWorkspace('queue', this)">Warteschlange</button>
</div> </div>
<div class="section-title" id="streamerSectionTitle"> <div class="streamer-section">
<span class="section-title-label"> <div class="section-title" id="streamerSectionTitle">
<span id="streamerSectionTitleText">Streamer</span> <span class="section-title-label">
<span id="streamerSectionTitleText">Streamer</span>
</span>
<span id="streamerSectionCounter" class="streamer-section-counter"></span> <span id="streamerSectionCounter" class="streamer-section-counter"></span>
</span> <button id="btnStreamerBulkRemove" class="btn-close is-hidden" type="button" onclick="bulkRemoveStreamers()" title="Bulk remove">x</button>
<button id="btnStreamerBulkRemove" class="btn-close is-hidden" type="button" onclick="bulkRemoveStreamers()" title="Bulk remove">x</button> </div>
<input type="text" id="streamerListFilter" class="filter-input compact is-hidden" placeholder="Filter..." oninput="onStreamerListFilterChange()">
<div class="streamers" id="streamerList"></div>
</div> </div>
<input type="text" id="streamerListFilter" class="filter-input compact is-hidden" placeholder="Filter..." oninput="onStreamerListFilterChange()">
<div class="streamers" id="streamerList"></div>
<div class="queue-section"> <div class="queue-section">
<div class="queue-header"> <div class="queue-header">
@@ -290,28 +281,28 @@
<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', 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 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 auswählen</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('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', 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> <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>Zusammenfügen</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', 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 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 hinzufügen</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('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 zusammenfügen</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> <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">Zusammenfügen</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', 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 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">Übersicht</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('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', 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('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">Aktivität</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> <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">Größen-Verteilung</span></button>
</nav> </nav>
</section> </section>
@@ -327,13 +318,17 @@
<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', 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 active" data-settings-pane="design" onclick="setSettingsPane('design', 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', 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" data-settings-pane="api" onclick="setSettingsPane('api', 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', 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" data-settings-pane="downloads" onclick="setSettingsPane('downloads', 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', 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" data-settings-pane="automation" onclick="setSettingsPane('automation', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 12a8 8 0 1 0 2.3-5.7M4 4v6h6"></path></svg><span data-label-source="autoVodCardTitle">Automatisierung</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" data-settings-pane="notifications" onclick="setSettingsPane('notifications', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"></path></svg><span data-label-source="discordCardTitle">Benachrichtigungen</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" data-settings-pane="storage" onclick="setSettingsPane('storage', 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>
<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> <button type="button" class="context-link" data-settings-pane="maintenance" onclick="setSettingsPane('maintenance', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 7h16v13H4zM8 7V4h8v3M9 12h6"></path></svg><span data-label-source="backupCardTitle">Sicherung &amp; Wartung</span></button>
<button type="button" class="context-link" data-settings-pane="updates" onclick="setSettingsPane('updates', 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" data-settings-pane="system" onclick="setSettingsPane('system', 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" data-settings-pane="debug" onclick="setSettingsPane('debug', 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" data-settings-pane="metrics" onclick="setSettingsPane('metrics', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 19V9M10 19V5M16 19v-7M22 19V3"></path></svg><span data-label-source="runtimeMetricsTitle">Runtime Metrics</span></button>
</nav> </nav>
</section> </section>
</aside> </aside>
@@ -341,8 +336,8 @@
<main class="main workspace-main"> <main class="main workspace-main">
<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-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> <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>
<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>
</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>
@@ -353,7 +348,7 @@
<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> <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 hinzufügen</span></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> <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>
@@ -372,7 +367,7 @@
<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 hinzufügen…" 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>
@@ -386,6 +381,11 @@
<div class="vod-filter-row"> <div class="vod-filter-row">
<input type="text" id="vodFilterInput" class="filter-input" placeholder="Filter VODs..." oninput="onVodFilterInput()"> <input type="text" id="vodFilterInput" class="filter-input" placeholder="Filter VODs..." oninput="onVodFilterInput()">
<button type="button" id="vodFilterClearBtn" class="btn-close is-hidden" onclick="clearVodFilter()" title="Clear filter">x</button> <button type="button" id="vodFilterClearBtn" class="btn-close is-hidden" onclick="clearVodFilter()" title="Clear filter">x</button>
<span id="vodFilterCount" class="form-sublabel vod-filter-count"></span>
<label id="vodHideDownloadedLabel" class="inline-toggle vod-hide-downloaded" title="">
<input type="checkbox" id="vodHideDownloadedToggle" onchange="onVodHideDownloadedChange()">
<span id="vodHideDownloadedText">Hide downloaded</span>
</label>
<label id="vodSortLabel" for="vodSortSelect" class="form-sublabel vod-sort-label">Sort:</label> <label id="vodSortLabel" for="vodSortSelect" class="form-sublabel vod-sort-label">Sort:</label>
<select id="vodSortSelect" class="select-compact" onchange="onVodSortChange()"> <select id="vodSortSelect" class="select-compact" onchange="onVodSortChange()">
<option value="date_desc">Newest first</option> <option value="date_desc">Newest first</option>
@@ -394,11 +394,6 @@
<option value="duration_desc">Longest first</option> <option value="duration_desc">Longest first</option>
<option value="duration_asc">Shortest first</option> <option value="duration_asc">Shortest first</option>
</select> </select>
<span id="vodFilterCount" class="form-sublabel vod-filter-count"></span>
<label id="vodHideDownloadedLabel" class="inline-toggle" title="">
<input type="checkbox" id="vodHideDownloadedToggle" onchange="onVodHideDownloadedChange()">
<span id="vodHideDownloadedText">Hide downloaded</span>
</label>
</div> </div>
<div id="vodBulkBar" class="vod-bulk-bar is-hidden"> <div id="vodBulkBar" class="vod-bulk-bar is-hidden">
<span id="vodBulkCount" class="vod-bulk-count">0 selected</span> <span id="vodBulkCount" class="vod-bulk-count">0 selected</span>
@@ -412,7 +407,7 @@
<div class="empty-state"> <div class="empty-state">
<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> <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">Wähle einen Streamer aus der Liste oder füge einen neuen hinzu.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -442,7 +437,7 @@
<div class="tab-content" id="cutterTab"> <div class="tab-content" id="cutterTab">
<div class="cutter-container"> <div class="cutter-container">
<div class="settings-card"> <div class="settings-card">
<h3 id="cutterSelectTitle">Video auswahlen</h3> <h3 id="cutterSelectTitle">Video auswählen</h3>
<div class="form-row"> <div class="form-row">
<input type="text" id="cutterFilePath" readonly placeholder="Keine Datei ausgewahlt..."> <input type="text" id="cutterFilePath" readonly placeholder="Keine Datei ausgewahlt...">
<button type="button" class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button> <button type="button" class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button>
@@ -452,7 +447,7 @@
<div class="video-preview" id="cutterPreview"> <div class="video-preview" id="cutterPreview">
<div class="placeholder"> <div class="placeholder">
<svg aria-hidden="true" width="64" height="64" 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-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg> <svg aria-hidden="true" width="64" height="64" 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-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
<p id="cutterPreviewPlaceholder">Video auswaehlen um Vorschau zu sehen</p> <p id="cutterPreviewPlaceholder">Video auswählen um Vorschau zu sehen</p>
</div> </div>
</div> </div>
@@ -462,7 +457,7 @@
<span class="cutter-info-value" id="infoDuration">--:--:--</span> <span class="cutter-info-value" id="infoDuration">--:--:--</span>
</div> </div>
<div class="cutter-info-item"> <div class="cutter-info-item">
<span class="cutter-info-label" id="cutterInfoResolutionLabel">Aufloesung</span> <span class="cutter-info-label" id="cutterInfoResolutionLabel">Auflösung</span>
<span class="cutter-info-value" id="infoResolution">----x----</span> <span class="cutter-info-value" id="infoResolution">----x----</span>
</div> </div>
<div class="cutter-info-item"> <div class="cutter-info-item">
@@ -510,12 +505,12 @@
<div class="tab-content" id="mergeTab"> <div class="tab-content" id="mergeTab">
<div class="merge-container"> <div class="merge-container">
<div class="settings-card"> <div class="settings-card">
<h3 id="mergeTitle">Videos zusammenfugen</h3> <h3 id="mergeTitle">Videos zusammenfügen</h3>
<p id="mergeDesc" class="card-intro"> <p id="mergeDesc" class="card-intro">
Wahle mehrere Videos aus um sie zu einem Video zusammenzufugen. Wähle mehrere Videos aus um sie zu einem Video zusammenzufügen.
Die Reihenfolge kann per Drag & Drop geandert werden. Die Reihenfolge kann per Drag & Drop geändert werden.
</p> </p>
<button type="button" class="btn-secondary" id="mergeAddBtn" onclick="addMergeFiles()">+ Videos hinzufugen</button> <button type="button" class="btn-secondary" id="mergeAddBtn" onclick="addMergeFiles()">+ Videos hinzufügen</button>
</div> </div>
<div class="file-list" id="mergeFileList"> <div class="file-list" id="mergeFileList">
@@ -533,7 +528,7 @@
</div> </div>
<div class="merge-actions"> <div class="merge-actions">
<button type="button" class="btn-primary" id="btnMerge" onclick="startMerging()" disabled>Zusammenfugen</button> <button type="button" class="btn-primary" id="btnMerge" onclick="startMerging()" disabled>Zusammenfügen</button>
</div> </div>
</div> </div>
</div> </div>
@@ -548,26 +543,26 @@
<button type="button" class="btn-secondary" id="btnStatsRefresh" onclick="refreshArchiveStats()">Aktualisieren</button> <button type="button" class="btn-secondary" id="btnStatsRefresh" onclick="refreshArchiveStats()">Aktualisieren</button>
</div> </div>
</div> </div>
<p id="statsIntro" class="card-intro flush">Aggregiert ueber den Download-Ordner. Live-Aufnahmen liegen unter <code>{streamer}/live/</code>, VOD-Downloads direkt unter <code>{streamer}/</code>. Lade-Zeit skaliert mit der Anzahl Dateien.</p> <p id="statsIntro" class="card-intro flush">Aggregiert über den Download-Ordner. Live-Aufnahmen liegen unter <code>{streamer}/live/</code>, VOD-Downloads direkt unter <code>{streamer}/</code>. Lade-Zeit skaliert mit der Anzahl Dateien.</p>
</div> </div>
<div class="settings-card"> <div class="settings-card">
<h3 id="statsSummaryTitle">Uebersicht</h3> <h3 id="statsSummaryTitle">Übersicht</h3>
<div id="statsSummaryGrid" class="stats-summary-grid"></div> <div id="statsSummaryGrid" class="stats-summary-grid"></div>
</div> </div>
<div class="settings-card"> <div class="settings-card">
<h3 id="statsTopStreamersTitle">Top Streamer (nach Groesse)</h3> <h3 id="statsTopStreamersTitle">Top Streamer (nach Größe)</h3>
<div id="statsTopStreamers"></div> <div id="statsTopStreamers"></div>
</div> </div>
<div class="settings-card"> <div class="settings-card">
<h3 id="statsActivityTitle">Aktivitaet (letzte 30 Tage)</h3> <h3 id="statsActivityTitle">Aktivität (letzte 30 Tage)</h3>
<div id="statsActivity"></div> <div id="statsActivity"></div>
</div> </div>
<div class="settings-card"> <div class="settings-card">
<h3 id="statsSizeBucketsTitle">Aufnahme-Groessen-Verteilung</h3> <h3 id="statsSizeBucketsTitle">Aufnahme-Größen-Verteilung</h3>
<div id="statsSizeBuckets"></div> <div id="statsSizeBuckets"></div>
</div> </div>
</div> </div>
@@ -576,7 +571,7 @@
<div class="tab-content" id="archiveTab"> <div class="tab-content" id="archiveTab">
<div class="settings-card"> <div class="settings-card">
<h3 id="archiveTitle">Archiv durchsuchen</h3> <h3 id="archiveTitle">Archiv durchsuchen</h3>
<p id="archiveIntro" class="card-intro">Suche nach Dateinamen, Streamern oder Datum-Strings. Treffer zeigen Recordings (Live + VOD); zugehoerige Chat- und Events-Dateien werden als Companion-Buttons angeboten.</p> <p id="archiveIntro" class="card-intro">Suche nach Dateinamen, Streamern oder Datumsangaben. Treffer zeigen Aufnahmen (Live + VOD); zugehörige Chat- und Event-Dateien werden als zusätzliche Schaltflächen angeboten.</p>
<div class="form-row search-bar"> <div class="form-row search-bar">
<input type="text" id="archiveSearchQuery" class="filter-input flex-1-1-240" placeholder="Suche..."> <input type="text" id="archiveSearchQuery" class="filter-input flex-1-1-240" placeholder="Suche...">
<select id="archiveSearchType" class="select-compact"> <select id="archiveSearchType" class="select-compact">
@@ -589,8 +584,8 @@
</select> </select>
<select id="archiveSearchSort" class="select-compact"> <select id="archiveSearchSort" class="select-compact">
<option value="date_desc">Neueste zuerst</option> <option value="date_desc">Neueste zuerst</option>
<option value="date_asc">Aelteste zuerst</option> <option value="date_asc">Älteste zuerst</option>
<option value="size_desc">Groesste zuerst</option> <option value="size_desc">Größte zuerst</option>
<option value="size_asc">Kleinste zuerst</option> <option value="size_asc">Kleinste zuerst</option>
<option value="name_asc">Name (A-Z)</option> <option value="name_asc">Name (A-Z)</option>
</select> </select>
@@ -604,8 +599,8 @@
</div> </div>
<!-- Settings Tab --> <!-- Settings Tab -->
<div class="tab-content" id="settingsTab"> <div class="tab-content" id="settingsTab" data-settings-pane="design">
<div class="settings-card"> <div class="settings-card" data-settings-pane="design">
<h3 id="designTitle">Design</h3> <h3 id="designTitle">Design</h3>
<div class="form-group"> <div class="form-group">
<label id="themeLabel" for="themeSelect">Theme</label> <label id="themeLabel" for="themeSelect">Theme</label>
@@ -635,23 +630,35 @@
<div class="form-group"> <div class="form-group">
<label id="languageLabel">Sprache</label> <label id="languageLabel">Sprache</label>
<div class="language-picker" id="languagePicker" role="group" aria-labelledby="languageLabel"> <div class="language-picker" id="languagePicker" role="group" aria-labelledby="languageLabel">
<button type="button" class="lang-option" id="langOptionEn" onclick="selectLanguageOption('en')" aria-pressed="false">
<svg class="flag-icon flag-en" viewBox="0 0 18 12" aria-hidden="true" focusable="false" shape-rendering="crispEdges">
<rect width="18" height="12" fill="#b31942"></rect>
<path d="M0 2h18v2H0zm0 4h18v2H0zm0 4h18v2H0z" fill="#fff"></path>
<path d="M0 0h8v6H0z" fill="#0a3161"></path>
<path d="M1 1h1v1H1zm3 0h1v1H4zm3 0h1v1H7zM2 3h1v1H2zm3 0h1v1H5zM1 5h1v1H1zm3 0h1v1H4zm3 0h1v1H7z" fill="#fff"></path>
</svg>
<span id="languageEnText">English</span>
</button>
<button type="button" class="lang-option" id="langOptionDe" onclick="selectLanguageOption('de')" aria-pressed="false"> <button type="button" class="lang-option" id="langOptionDe" onclick="selectLanguageOption('de')" aria-pressed="false">
<span class="flag-icon flag-de" aria-hidden="true"></span> <span class="flag-icon flag-de" aria-hidden="true"></span>
<span id="languageDeText">Deutsch</span> <span id="languageDeText">Deutsch</span>
</button> </button>
<button type="button" class="lang-option" id="langOptionEn" onclick="selectLanguageOption('en')" aria-pressed="false">
<span class="flag-icon flag-en" aria-hidden="true"></span>
<span id="languageEnText">English</span>
</button>
</div> </div>
<select id="languageSelect" onchange="changeLanguage(this.value)" style="display:none"> <select id="languageSelect" onchange="changeLanguage(this.value)" style="display:none">
<option value="de">de</option> <option value="de">de</option>
<option value="en">en</option> <option value="en">en</option>
</select> </select>
</div> </div>
<div class="form-group sidebar-layout-setting">
<label class="toggle-row">
<input type="checkbox" id="sidebarSplitViewToggle" checked onchange="applySidebarLayoutPreference(this.checked)">
<span id="sidebarSplitViewLabel">Streamer und Queue gleichzeitig anzeigen</span>
</label>
<p id="sidebarSplitViewHint" class="form-note">Teilt die linke Seitenleiste gleichmäßig auf. Deaktiviert erscheinen wieder die Umschaltflächen.</p>
</div>
</div> </div>
<div class="settings-card"> <div class="settings-card" data-settings-pane="api" hidden>
<h3 id="apiTitle">Twitch API</h3> <h3 id="apiTitle">Twitch API</h3>
<p id="apiHelpText" class="card-intro"> <p id="apiHelpText" class="card-intro">
<span id="apiHelpIntro">Du brauchst eine Client-ID und ein Client-Secret von Twitch.</span> <span id="apiHelpIntro">Du brauchst eine Client-ID und ein Client-Secret von Twitch.</span>
@@ -668,9 +675,9 @@
<button type="button" class="btn-primary" id="saveSettingsBtn" onclick="saveSettings()">Speichern & Verbinden</button> <button type="button" class="btn-primary" id="saveSettingsBtn" onclick="saveSettings()">Speichern & Verbinden</button>
</div> </div>
<div class="settings-card"> <div class="settings-card" data-settings-pane="downloads" hidden>
<h3 id="downloadSettingsTitle">Download-Einstellungen</h3> <h3 id="downloadSettingsTitle">Download-Einstellungen</h3>
<div class="form-group"> <div class="form-group download-settings-storage">
<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>
@@ -678,130 +685,109 @@
<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>
<div class="form-group"> <div class="download-settings-layout">
<label id="modeLabel" for="downloadMode">Download-Modus</label> <div class="download-settings-column">
<select id="downloadMode"> <section class="download-settings-section">
<option value="full" id="modeFullText">Ganzes VOD</option> <h4 id="downloadBasicsTitle">Download und Qualität</h4>
<option value="parts" id="modePartsText">In Teile splitten</option> <div class="form-group">
</select> <label id="modeLabel" for="downloadMode">Download-Modus</label>
</div> <select id="downloadMode">
<div class="form-group"> <option value="full" id="modeFullText">Ganzes VOD</option>
<label id="partMinutesLabel" for="partMinutes">Teil-Lange (Minuten)</label> <option value="parts" id="modePartsText">In Teile splitten</option>
<input type="number" id="partMinutes" value="120" min="10" max="480"> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label id="parallelDownloadsLabel" for="parallelDownloads">Parallele Downloads</label> <label id="partMinutesLabel" for="partMinutes">Teil-Lange (Minuten)</label>
<select id="parallelDownloads"> <input type="number" id="partMinutes" value="120" min="10" max="480">
<option value="1" id="parallelDownloads1">1 (Standard)</option> </div>
<option value="2" id="parallelDownloads2">2 (Parallel)</option> <div class="form-group">
</select> <label id="parallelDownloadsLabel" for="parallelDownloads">Parallele Downloads</label>
</div> <select id="parallelDownloads">
<div class="form-group"> <option value="1" id="parallelDownloads1">1 (Standard)</option>
<label id="streamlinkQualityLabel" for="streamlinkQuality">Stream-Qualitaet</label> <option value="2" id="parallelDownloads2">2 (Parallel)</option>
<select id="streamlinkQuality"> </select>
<option value="best" id="streamlinkQualityBest">Best (Standard)</option> </div>
<option value="source" id="streamlinkQualitySource">Source (Original)</option> <div class="form-group">
<option value="1080p60" id="streamlinkQuality1080p60">1080p60</option> <label id="streamlinkQualityLabel" for="streamlinkQuality">Stream-Qualität</label>
<option value="720p60" id="streamlinkQuality720p60">720p60</option> <select id="streamlinkQuality">
<option value="720p" id="streamlinkQuality720p">720p</option> <option value="best" id="streamlinkQualityBest">Best (Standard)</option>
<option value="480p" id="streamlinkQuality480p">480p</option> <option value="source" id="streamlinkQualitySource">Source (Original)</option>
<option value="audio_only" id="streamlinkQualityAudio">Audio only</option> <option value="1080p60" id="streamlinkQuality1080p60">1080p60</option>
</select> <option value="720p60" id="streamlinkQuality720p60">720p60</option>
</div> <option value="720p" id="streamlinkQuality720p">720p</option>
<div class="form-group"> <option value="480p" id="streamlinkQuality480p">480p</option>
<label id="performanceModeLabel" for="performanceMode">Performance-Profil</label> <option value="audio_only" id="streamlinkQualityAudio">Audio only</option>
<select id="performanceMode"> </select>
<option value="stability" id="performanceModeStability">Max Stabilitat</option> </div>
<option value="balanced" id="performanceModeBalanced">Ausgewogen</option> <div class="form-group">
<option value="speed" id="performanceModeSpeed">Max Geschwindigkeit</option> <label id="performanceModeLabel" for="performanceMode">Performance-Profil</label>
</select> <select id="performanceMode">
</div> <option value="stability" id="performanceModeStability">Max Stabilitat</option>
<div class="form-group"> <option value="balanced" id="performanceModeBalanced">Ausgewogen</option>
<label class="toggle-row"> <option value="speed" id="performanceModeSpeed">Max Geschwindigkeit</option>
<input type="checkbox" id="smartSchedulerToggle" checked> </select>
<span id="smartSchedulerLabel">Smart Queue Scheduler aktivieren</span> </div>
</label> </section>
<label class="toggle-row"> <section class="download-settings-section">
<input type="checkbox" id="duplicatePreventionToggle" checked> <div class="download-settings-section-heading">
<span id="duplicatePreventionLabel">Duplikate in Queue verhindern</span> <h4 id="filenameTemplatesTitle">Dateinamen-Templates</h4>
</label> <button class="btn-secondary" id="settingsTemplateGuideBtn" type="button" onclick="openTemplateGuide('vod')">Template Guide</button>
<label class="toggle-row"> </div>
<input type="checkbox" id="persistQueueToggle" checked> <div class="form-row template-presets">
<span id="persistQueueLabel">Queue zwischen App-Starts speichern</span> <button class="btn-secondary" id="templatePresetDefault" type="button" onclick="applyTemplatePreset('default')">Preset: Default</button>
</label> <button class="btn-secondary" id="templatePresetArchive" type="button" onclick="applyTemplatePreset('archive')">Preset: Archive</button>
<label class="toggle-row"> <button class="btn-secondary" id="templatePresetClipper" type="button" onclick="applyTemplatePreset('clipper')">Preset: Clipper</button>
<input type="checkbox" id="autoResumeQueueToggle"> </div>
<span id="autoResumeQueueLabel">Queue beim Start automatisch fortsetzen</span> <div class="filename-template-grid">
</label> <label id="vodTemplateLabel" for="vodFilenameTemplate">VOD Template</label>
<label class="toggle-row"> <input type="text" id="vodFilenameTemplate" class="input-monospace" placeholder="{title}.mp4" oninput="validateFilenameTemplates()">
<input type="checkbox" id="notifyEachCompletionToggle"> <label id="partsTemplateLabel" for="partsFilenameTemplate">VOD Part Template</label>
<span id="notifyEachCompletionLabel">Benachrichtigung bei jedem fertigen Download</span> <input type="text" id="partsFilenameTemplate" class="input-monospace" placeholder="{date}_Part{part_padded}.mp4" oninput="validateFilenameTemplates()">
</label> <label id="defaultClipTemplateLabel" for="defaultClipFilenameTemplate">Clip Template</label>
<label class="toggle-row"> <input type="text" id="defaultClipFilenameTemplate" class="input-monospace" placeholder="{date}_{part}.mp4" oninput="validateFilenameTemplates()">
<input type="checkbox" id="streamlinkDisableAdsToggle" checked> </div>
<span id="streamlinkDisableAdsLabel">Twitch-Ads beim Download ueberspringen</span> <div id="filenameTemplateHint" class="form-note">Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
</label> <div id="filenameTemplateLint" class="template-lint ok">Template-Check: OK</div>
<label class="toggle-row"> </section>
<input type="checkbox" id="downloadChatReplayToggle">
<span id="downloadChatReplayLabel">Chat-Replay parallel zum VOD speichern (.chat.json)</span>
</label>
<label class="toggle-row">
<input type="checkbox" id="captureLiveChatToggle">
<span id="captureLiveChatLabel">Live-Chat waehrend der Aufnahme mitschneiden (.chat.jsonl)</span>
</label>
<label class="toggle-row">
<input type="checkbox" id="logStreamEventsToggle" checked>
<span id="logStreamEventsLabel">Stream-Events bei Live-Aufnahmen mitloggen (.events.jsonl)</span>
</label>
<label class="toggle-row">
<input type="checkbox" id="autoResumeLiveRecordingToggle" checked>
<span id="autoResumeLiveRecordingLabel">Live-Aufnahme automatisch fortsetzen wenn Streamlink abbricht (max. 5 Versuche)</span>
</label>
<label class="toggle-row">
<input type="checkbox" id="autoMergeResumedPartsToggle">
<span id="autoMergeResumedPartsLabel">Fortgesetzte Aufnahme-Parts automatisch zu einer Datei zusammenfuegen (ffmpeg concat)</span>
</label>
<label class="toggle-row indented">
<input type="checkbox" id="deletePartsAfterMergeToggle">
<span id="deletePartsAfterMergeLabel">Einzelne Parts nach erfolgreichem Merge loeschen</span>
</label>
</div>
<div class="form-group">
<label id="metadataCacheMinutesLabel" for="metadataCacheMinutes">Metadata-Cache (Minuten)</label>
<input type="number" id="metadataCacheMinutes" value="10" min="1" max="120">
</div>
<div class="form-group">
<div class="form-row" style="align-items:center; margin-bottom: 4px;">
<label id="filenameTemplatesTitle">Dateinamen-Templates</label>
<button class="btn-secondary" id="settingsTemplateGuideBtn" type="button" onclick="openTemplateGuide('vod')">Template Guide</button>
</div> </div>
<div class="form-row" style="gap: 8px; margin: 8px 0 6px;"> <div class="download-settings-column">
<button class="btn-secondary" id="templatePresetDefault" type="button" onclick="applyTemplatePreset('default')">Preset: Default</button> <section class="download-settings-section">
<button class="btn-secondary" id="templatePresetArchive" type="button" onclick="applyTemplatePreset('archive')">Preset: Archive</button> <h4 id="queueAutomationTitle">Queue und Automatisierung</h4>
<button class="btn-secondary" id="templatePresetClipper" type="button" onclick="applyTemplatePreset('clipper')">Preset: Clipper</button> <div class="download-settings-toggles">
<label class="toggle-row"><input type="checkbox" id="smartSchedulerToggle" checked><span id="smartSchedulerLabel">Smart Queue Scheduler aktivieren</span></label>
<label class="toggle-row"><input type="checkbox" id="duplicatePreventionToggle" checked><span id="duplicatePreventionLabel">Duplikate in Queue verhindern</span></label>
<label class="toggle-row"><input type="checkbox" id="persistQueueToggle" checked><span id="persistQueueLabel">Queue zwischen App-Starts speichern</span></label>
<label class="toggle-row"><input type="checkbox" id="autoResumeQueueToggle"><span id="autoResumeQueueLabel">Queue beim Start automatisch fortsetzen</span></label>
<label class="toggle-row"><input type="checkbox" id="notifyEachCompletionToggle"><span id="notifyEachCompletionLabel">Benachrichtigung bei jedem fertigen Download</span></label>
</div>
</section>
<section class="download-settings-section">
<h4 id="recordingMetadataTitle">Aufnahmen und Metadaten</h4>
<div class="download-settings-toggles">
<label class="toggle-row"><input type="checkbox" id="streamlinkDisableAdsToggle" checked><span id="streamlinkDisableAdsLabel">Twitch-Ads beim Download überspringen</span></label>
<label class="toggle-row"><input type="checkbox" id="downloadChatReplayToggle"><span id="downloadChatReplayLabel">Chat-Replay parallel zum VOD speichern (.chat.json)</span></label>
<label class="toggle-row"><input type="checkbox" id="captureLiveChatToggle"><span id="captureLiveChatLabel">Live-Chat während der Aufnahme mitschneiden (.chat.jsonl)</span></label>
<label class="toggle-row"><input type="checkbox" id="logStreamEventsToggle" checked><span id="logStreamEventsLabel">Stream-Events bei Live-Aufnahmen mitloggen (.events.jsonl)</span></label>
<label class="toggle-row"><input type="checkbox" id="autoResumeLiveRecordingToggle" checked><span id="autoResumeLiveRecordingLabel">Live-Aufnahme automatisch fortsetzen wenn Streamlink abbricht (max. 5 Versuche)</span></label>
<label class="toggle-row"><input type="checkbox" id="autoMergeResumedPartsToggle"><span id="autoMergeResumedPartsLabel">Fortgesetzte Aufnahme-Parts automatisch zu einer Datei zusammenfügen (ffmpeg concat)</span></label>
<label class="toggle-row indented"><input type="checkbox" id="deletePartsAfterMergeToggle"><span id="deletePartsAfterMergeLabel">Einzelne Parts nach erfolgreichem Merge löschen</span></label>
</div>
<div class="form-group metadata-cache-setting">
<label id="metadataCacheMinutesLabel" for="metadataCacheMinutes">Metadata-Cache (Minuten)</label>
<input type="number" id="metadataCacheMinutes" value="10" min="1" max="120">
</div>
</section>
</div> </div>
<div class="filename-template-grid">
<label id="vodTemplateLabel" for="vodFilenameTemplate">VOD Template</label>
<input type="text" id="vodFilenameTemplate" class="input-monospace" placeholder="{title}.mp4" oninput="validateFilenameTemplates()">
<label id="partsTemplateLabel" for="partsFilenameTemplate">VOD Part Template</label>
<input type="text" id="partsFilenameTemplate" class="input-monospace" placeholder="{date}_Part{part_padded}.mp4" oninput="validateFilenameTemplates()">
<label id="defaultClipTemplateLabel" for="defaultClipFilenameTemplate">Clip Template</label>
<input type="text" id="defaultClipFilenameTemplate" class="input-monospace" placeholder="{date}_{part}.mp4" oninput="validateFilenameTemplates()">
</div>
<div id="filenameTemplateHint" class="form-note" style="margin-top: 8px;">Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
<div id="filenameTemplateLint" class="template-lint ok">Template-Check: OK</div>
</div> </div>
</div> </div>
<div class="settings-card"> <div class="settings-card" data-settings-pane="updates" hidden>
<h3 id="updateTitle">Updates</h3> <h3 id="updateTitle">Updates</h3>
<p id="versionInfo" class="card-intro">Version: v1.0.2</p> <p id="versionInfo" class="card-intro">Version: v1.0.3</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>
<div class="settings-card"> <div class="settings-card" data-settings-pane="system" hidden>
<div class="form-row section-header"> <div class="form-row section-header">
<h3 id="preflightTitle">System-Check</h3> <h3 id="preflightTitle">System-Check</h3>
<span class="health-badge unknown" id="healthBadge">System: Unbekannt</span> <span class="health-badge unknown" id="healthBadge">System: Unbekannt</span>
@@ -813,11 +799,11 @@
<pre id="preflightResult" class="log-panel">Noch kein Check ausgefuhrt.</pre> <pre id="preflightResult" class="log-panel">Noch kein Check ausgefuhrt.</pre>
</div> </div>
<div class="settings-card"> <div class="settings-card" data-settings-pane="debug" hidden>
<h3 id="debugLogTitle">Live Debug-Log</h3> <h3 id="debugLogTitle">Live Debug-Log</h3>
<div class="form-row aligned"> <div class="form-row aligned">
<button type="button" class="btn-secondary" id="btnRefreshLog" onclick="refreshDebugLog()">Aktualisieren</button> <button type="button" class="btn-secondary" id="btnRefreshLog" onclick="refreshDebugLog()">Aktualisieren</button>
<button type="button" class="btn-secondary" id="btnOpenDebugLogFile" onclick="openDebugLogFile()">Log-Datei oeffnen</button> <button type="button" class="btn-secondary" id="btnOpenDebugLogFile" onclick="openDebugLogFile()">Log-Datei öffnen</button>
<label class="inline-toggle"> <label class="inline-toggle">
<input type="checkbox" id="debugAutoRefresh" onchange="toggleDebugAutoRefresh(this.checked)"> <input type="checkbox" id="debugAutoRefresh" onchange="toggleDebugAutoRefresh(this.checked)">
<span id="autoRefreshText">Auto-Refresh</span> <span id="autoRefreshText">Auto-Refresh</span>
@@ -826,7 +812,7 @@
<pre id="debugLogOutput" class="log-panel">Lade...</pre> <pre id="debugLogOutput" class="log-panel">Lade...</pre>
</div> </div>
<div class="settings-card"> <div class="settings-card" data-settings-pane="storage" hidden>
<div class="form-row section-header"> <div class="form-row section-header">
<h3 id="storageCardTitle">Storage</h3> <h3 id="storageCardTitle">Storage</h3>
<button type="button" class="btn-secondary" id="btnRefreshStorage" onclick="refreshStorageStats()">Aktualisieren</button> <button type="button" class="btn-secondary" id="btnRefreshStorage" onclick="refreshStorageStats()">Aktualisieren</button>
@@ -837,7 +823,7 @@
<hr> <hr>
<h4 id="cleanupTitle">Auto-Cleanup</h4> <h4 id="cleanupTitle">Auto-Cleanup</h4>
<p id="cleanupIntro" class="card-intro">Aufnahmen aelter als X Tage automatisch archivieren oder loeschen. Schiebt Sidecar-Chat-Dateien (.chat.json/.chat.jsonl) mit der Aufnahme.</p> <p id="cleanupIntro" class="card-intro">Aufnahmen älter als X Tage automatisch archivieren oder löschen. Schiebt Sidecar-Chat-Dateien (.chat.json/.chat.jsonl) mit der Aufnahme.</p>
<label class="toggle-row" style="margin-bottom: 8px;"> <label class="toggle-row" style="margin-bottom: 8px;">
<input type="checkbox" id="autoCleanupEnabledToggle"> <input type="checkbox" id="autoCleanupEnabledToggle">
<span id="autoCleanupEnabledLabel">Auto-Cleanup aktivieren</span> <span id="autoCleanupEnabledLabel">Auto-Cleanup aktivieren</span>
@@ -858,20 +844,20 @@
<span id="autoCleanupActionLabel" class="form-sublabel">Aktion</span> <span id="autoCleanupActionLabel" class="form-sublabel">Aktion</span>
<select id="autoCleanupAction"> <select id="autoCleanupAction">
<option value="archive" id="autoCleanupActionArchive">In Archiv verschieben</option> <option value="archive" id="autoCleanupActionArchive">In Archiv verschieben</option>
<option value="delete" id="autoCleanupActionDelete">Loeschen</option> <option value="delete" id="autoCleanupActionDelete">Löschen</option>
</select> </select>
</label> </label>
</div> </div>
<div class="form-row" style="margin-bottom: 8px; gap: 8px;"> <div class="form-row" style="margin-bottom: 8px; gap: 8px;">
<button type="button" class="btn-secondary" id="btnCleanupDryRun" onclick="runCleanupDryRun()">Vorschau</button> <button type="button" class="btn-secondary" id="btnCleanupDryRun" onclick="runCleanupDryRun()">Vorschau</button>
<button type="button" class="btn-secondary" id="btnCleanupRunNow" onclick="runCleanupNow()">Jetzt ausfuehren</button> <button type="button" class="btn-secondary" id="btnCleanupRunNow" onclick="runCleanupNow()">Jetzt ausführen</button>
</div> </div>
<div id="cleanupReport" class="form-note" role="status" aria-live="polite"></div> <div id="cleanupReport" class="form-note" role="status" aria-live="polite"></div>
</div> </div>
<div class="settings-card"> <div class="settings-card" data-settings-pane="notifications" hidden>
<h3 id="discordCardTitle">Discord-Webhook</h3> <h3 id="discordCardTitle">Discord-Webhook</h3>
<p id="discordCardIntro" class="card-intro">Sende Benachrichtigungen an einen Discord-Channel via Webhook — nuetzlich fuer Multi-Device-Setups oder eine dedizierte Archiv-Maschine.</p> <p id="discordCardIntro" class="card-intro">Sende Benachrichtigungen an einen Discord-Channel via Webhook — nützlich für Multi-Device-Setups oder eine dedizierte Archiv-Maschine.</p>
<div class="form-group"> <div class="form-group">
<label id="discordWebhookUrlLabel" for="discordWebhookUrl">Webhook-URL</label> <label id="discordWebhookUrlLabel" for="discordWebhookUrl">Webhook-URL</label>
<input type="text" id="discordWebhookUrl" placeholder="https://discord.com/api/webhooks/..."> <input type="text" id="discordWebhookUrl" placeholder="https://discord.com/api/webhooks/...">
@@ -896,9 +882,9 @@
</div> </div>
</div> </div>
<div class="settings-card"> <div class="settings-card" data-settings-pane="automation" hidden>
<h3 id="autoVodCardTitle">Auto-VOD-Download</h3> <h3 id="autoVodCardTitle">Auto-VOD-Download</h3>
<p id="autoVodCardIntro" class="card-intro">Streamer mit aktiviertem VOD-Toggle werden in dem hier festgelegten Intervall auf neue Twitch-VODs geprueft. Neue VODs innerhalb des Alters-Fensters werden automatisch zur Download-Queue hinzugefuegt.</p> <p id="autoVodCardIntro" class="card-intro">Streamer mit aktiviertem VOD-Toggle werden in dem hier festgelegten Intervall auf neue Twitch-VODs geprüft. Neue VODs innerhalb des Alters-Fensters werden automatisch zur Download-Queue hinzugefügt.</p>
<div class="form-row aligned"> <div class="form-row aligned">
<label id="autoVodPollMinutesLabel" class="form-sublabel" for="autoVodPollMinutes">Poll-Intervall (Minuten)</label> <label id="autoVodPollMinutesLabel" class="form-sublabel" for="autoVodPollMinutes">Poll-Intervall (Minuten)</label>
<input type="number" id="autoVodPollMinutes" min="5" max="360" value="15" class="input-narrow"> <input type="number" id="autoVodPollMinutes" min="5" max="360" value="15" class="input-narrow">
@@ -907,22 +893,22 @@
</div> </div>
<div class="form-row" style="align-items: center; gap: 12px; flex-wrap: wrap;"> <div class="form-row" style="align-items: center; gap: 12px; flex-wrap: wrap;">
<button type="button" class="btn-secondary" id="btnAutoVodScanNow" onclick="triggerManualAutoVodScan()">Jetzt scannen</button> <button type="button" class="btn-secondary" id="btnAutoVodScanNow" onclick="triggerManualAutoVodScan()">Jetzt scannen</button>
<button type="button" class="btn-secondary" id="btnAutoRecordScanNow" onclick="triggerManualAutoRecordScan()">Live-Status pruefen</button> <button type="button" class="btn-secondary" id="btnAutoRecordScanNow" onclick="triggerManualAutoRecordScan()">Live-Status prüfen</button>
<span id="autoVodStatusLine" class="form-sublabel"></span> <span id="autoVodStatusLine" class="form-sublabel"></span>
</div> </div>
</div> </div>
<div class="settings-card"> <div class="settings-card" data-settings-pane="maintenance" hidden>
<h3 id="backupCardTitle">Sicherung &amp; Wartung</h3> <h3 id="backupCardTitle">Sicherung &amp; Wartung</h3>
<p id="backupCardIntro" class="card-intro">Konfiguration sichern, auf einem anderen Geraet wiederherstellen, oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.</p> <p id="backupCardIntro" class="card-intro">Konfiguration sichern, auf einem anderen Gerät wiederherstellen, oder die Liste der bereits heruntergeladenen VODs zurücksetzen.</p>
<div class="form-row" style="margin-bottom: 10px; flex-wrap: wrap;"> <div class="form-row" style="margin-bottom: 10px; flex-wrap: wrap;">
<button type="button" class="btn-secondary" id="btnExportConfig" onclick="exportConfigToFile()">Konfiguration exportieren</button> <button type="button" class="btn-secondary" id="btnExportConfig" onclick="exportConfigToFile()">Konfiguration exportieren</button>
<button type="button" class="btn-secondary" id="btnImportConfig" onclick="importConfigFromFile()">Konfiguration importieren</button> <button type="button" class="btn-secondary" id="btnImportConfig" onclick="importConfigFromFile()">Konfiguration importieren</button>
<button type="button" class="btn-secondary" id="btnResetDownloadedIds" onclick="resetDownloadedIds()">Downloaded-VODs zuruecksetzen</button> <button type="button" class="btn-secondary" id="btnResetDownloadedIds" onclick="resetDownloadedIds()">Downloaded-VODs zurücksetzen</button>
</div> </div>
</div> </div>
<div class="settings-card"> <div class="settings-card" data-settings-pane="metrics" hidden>
<h3 id="runtimeMetricsTitle">Runtime Metrics</h3> <h3 id="runtimeMetricsTitle">Runtime Metrics</h3>
<div class="form-row aligned"> <div class="form-row aligned">
<button type="button" class="btn-secondary" id="btnRefreshMetrics" onclick="refreshRuntimeMetrics()">Aktualisieren</button> <button type="button" class="btn-secondary" id="btnRefreshMetrics" onclick="refreshRuntimeMetrics()">Aktualisieren</button>
@@ -961,7 +947,7 @@
aria-label="Befehlspalette" aria-label="Befehlspalette"
/> />
<ul id="commandPaletteList" class="cp-list" role="listbox" aria-label="Befehlsergebnisse"></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 Ausführen, Esc zum Schließen</p>
</div> </div>
</div> </div>
+308 -113
View File
@@ -15,6 +15,9 @@ import {
getMergeGroupPhaseText as getMergeGroupPhaseTextCore, getMergeGroupPhaseText as getMergeGroupPhaseTextCore,
} from './main/infra/format-helpers'; } from './main/infra/format-helpers';
import { tBackend as tBackendCore, type BackendMessageKey } from './main/domain/i18n-backend'; import { tBackend as tBackendCore, type BackendMessageKey } from './main/domain/i18n-backend';
import { watchRendererChanges } from './main/dev-reload';
import { createPausableOutput, type PausableOutput } from './main/domain/pausable-output';
import { PartialDownloadRegistry } from './main/domain/partial-download';
import type { DbHandle } from './main/infra/db'; import type { DbHandle } from './main/infra/db';
import { import {
normalizeLogin, normalizeLogin,
@@ -31,6 +34,8 @@ import {
type PerformanceMode, type PerformanceMode,
} from './main/domain/config-normalize'; } from './main/domain/config-normalize';
import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, DownloadResult } from './types'; import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, DownloadResult } from './types';
import { buildVodPreviewFrameUrls } from './main/domain/vod-preview';
import { getWindowsAppIdentity } from './main/domain/app-identity';
import { import {
setDebugLogFn, initToolDirs, setDebugLogFn, initToolDirs,
getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath, getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath,
@@ -45,6 +50,9 @@ import {
// CONFIG & CONSTANTS // CONFIG & CONSTANTS
// ========================================== // ==========================================
const APP_VERSION = app.getVersion(); const APP_VERSION = app.getVersion();
const WINDOWS_APP_IDENTITY = getWindowsAppIdentity(process.env.TWITCH_VOD_MANAGER_DEV === '1');
app.setName(WINDOWS_APP_IDENTITY.name);
app.setAppUserModelId(WINDOWS_APP_IDENTITY.appUserModelId);
const GITHUB_REPO_OWNER = 'Sucukdeluxe'; const GITHUB_REPO_OWNER = 'Sucukdeluxe';
const GITHUB_REPO_NAME = 'Twitch-VOD-Manager'; const GITHUB_REPO_NAME = 'Twitch-VOD-Manager';
const GITHUB_RELEASES_API_LATEST_URL = 'https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest'; const GITHUB_RELEASES_API_LATEST_URL = 'https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest';
@@ -55,6 +63,7 @@ const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twi
const CONFIG_FILE = path.join(APPDATA_DIR, 'config.json'); const CONFIG_FILE = path.join(APPDATA_DIR, 'config.json');
const QUEUE_FILE = path.join(APPDATA_DIR, 'download_queue.json'); const QUEUE_FILE = path.join(APPDATA_DIR, 'download_queue.json');
const DEBUG_LOG_FILE = path.join(APPDATA_DIR, 'debug.log'); const DEBUG_LOG_FILE = path.join(APPDATA_DIR, 'debug.log');
const PARTIAL_DOWNLOADS_FILE = path.join(APPDATA_DIR, 'partial-downloads.json');
const TOOLS_DIR = path.join(APPDATA_DIR, 'tools'); const TOOLS_DIR = path.join(APPDATA_DIR, 'tools');
const TOOLS_STREAMLINK_DIR = path.join(TOOLS_DIR, 'streamlink'); const TOOLS_STREAMLINK_DIR = path.join(TOOLS_DIR, 'streamlink');
const TOOLS_FFMPEG_DIR = path.join(TOOLS_DIR, 'ffmpeg'); const TOOLS_FFMPEG_DIR = path.join(TOOLS_DIR, 'ffmpeg');
@@ -110,6 +119,7 @@ function tBackend(key: BackendMessageKey, params?: Record<string, string | numbe
if (!fs.existsSync(APPDATA_DIR)) { if (!fs.existsSync(APPDATA_DIR)) {
fs.mkdirSync(APPDATA_DIR, { recursive: true }); fs.mkdirSync(APPDATA_DIR, { recursive: true });
} }
const partialDownloadRegistry = new PartialDownloadRegistry(PARTIAL_DOWNLOADS_FILE);
// ========================================== // ==========================================
// INTERFACES // INTERFACES
@@ -119,10 +129,12 @@ interface Config {
client_secret: string; client_secret: string;
download_path: string; download_path: string;
streamers: string[]; streamers: string[];
streamer_display_names: Record<string, string>;
theme: string; theme: string;
download_mode: 'parts' | 'full'; download_mode: 'parts' | 'full';
part_minutes: number; part_minutes: number;
language: 'de' | 'en'; language: 'de' | 'en';
sidebar_split_view: boolean;
filename_template_vod: string; filename_template_vod: string;
filename_template_parts: string; filename_template_parts: string;
filename_template_clip: string; filename_template_clip: string;
@@ -256,10 +268,12 @@ const defaultConfig: Config = {
client_secret: '', client_secret: '',
download_path: DEFAULT_DOWNLOAD_PATH, download_path: DEFAULT_DOWNLOAD_PATH,
streamers: [], streamers: [],
streamer_display_names: {},
theme: 'twitch', theme: 'twitch',
download_mode: 'full', download_mode: 'full',
part_minutes: 120, part_minutes: 120,
language: 'en', language: 'en',
sidebar_split_view: true,
filename_template_vod: DEFAULT_FILENAME_TEMPLATE_VOD, filename_template_vod: DEFAULT_FILENAME_TEMPLATE_VOD,
filename_template_parts: DEFAULT_FILENAME_TEMPLATE_PARTS, filename_template_parts: DEFAULT_FILENAME_TEMPLATE_PARTS,
filename_template_clip: DEFAULT_FILENAME_TEMPLATE_CLIP, filename_template_clip: DEFAULT_FILENAME_TEMPLATE_CLIP,
@@ -314,12 +328,22 @@ function normalizeConfigTemplates(input: Config): Config {
const trimmedIds = cleanIds.length > DOWNLOADED_IDS_MAX const trimmedIds = cleanIds.length > DOWNLOADED_IDS_MAX
? cleanIds.slice(cleanIds.length - DOWNLOADED_IDS_MAX) ? cleanIds.slice(cleanIds.length - DOWNLOADED_IDS_MAX)
: cleanIds; : cleanIds;
const displayNames: Record<string, string> = {};
if (isPlainObject(input.streamer_display_names)) {
for (const [login, displayName] of Object.entries(input.streamer_display_names)) {
const normalizedLogin = normalizeLogin(login);
const normalizedDisplayName = typeof displayName === 'string' ? displayName.trim() : '';
if (normalizedLogin && normalizedDisplayName) displayNames[normalizedLogin] = normalizedDisplayName;
}
}
return { return {
...input, ...input,
streamer_display_names: displayNames,
filename_template_vod: normalizeFilenameTemplate(input.filename_template_vod, DEFAULT_FILENAME_TEMPLATE_VOD), filename_template_vod: normalizeFilenameTemplate(input.filename_template_vod, DEFAULT_FILENAME_TEMPLATE_VOD),
filename_template_parts: normalizeFilenameTemplate(input.filename_template_parts, DEFAULT_FILENAME_TEMPLATE_PARTS), filename_template_parts: normalizeFilenameTemplate(input.filename_template_parts, DEFAULT_FILENAME_TEMPLATE_PARTS),
filename_template_clip: normalizeFilenameTemplate(input.filename_template_clip, DEFAULT_FILENAME_TEMPLATE_CLIP), filename_template_clip: normalizeFilenameTemplate(input.filename_template_clip, DEFAULT_FILENAME_TEMPLATE_CLIP),
sidebar_split_view: input.sidebar_split_view !== false,
smart_queue_scheduler: input.smart_queue_scheduler !== false, smart_queue_scheduler: input.smart_queue_scheduler !== false,
performance_mode: normalizePerformanceMode(input.performance_mode), performance_mode: normalizePerformanceMode(input.performance_mode),
prevent_duplicate_downloads: input.prevent_duplicate_downloads !== false, prevent_duplicate_downloads: input.prevent_duplicate_downloads !== false,
@@ -505,6 +529,7 @@ function sanitizeQueueItem(raw: unknown): QueueItem | null {
if (typeof raw.totalParts === 'number' && Number.isFinite(raw.totalParts)) item.totalParts = raw.totalParts; if (typeof raw.totalParts === 'number' && Number.isFinite(raw.totalParts)) item.totalParts = raw.totalParts;
if (typeof raw.speed === 'string') item.speed = raw.speed; if (typeof raw.speed === 'string') item.speed = raw.speed;
if (typeof raw.eta === 'string') item.eta = raw.eta; if (typeof raw.eta === 'string') item.eta = raw.eta;
if (typeof raw.progressStatus === 'string') item.progressStatus = raw.progressStatus;
if (typeof raw.last_error === 'string') item.last_error = raw.last_error; if (typeof raw.last_error === 'string') item.last_error = raw.last_error;
if (typeof raw.downloadedBytes === 'number' && Number.isFinite(raw.downloadedBytes)) item.downloadedBytes = raw.downloadedBytes; if (typeof raw.downloadedBytes === 'number' && Number.isFinite(raw.downloadedBytes)) item.downloadedBytes = raw.downloadedBytes;
if (typeof raw.totalBytes === 'number' && Number.isFinite(raw.totalBytes)) item.totalBytes = raw.totalBytes; if (typeof raw.totalBytes === 'number' && Number.isFinite(raw.totalBytes)) item.totalBytes = raw.totalBytes;
@@ -634,12 +659,27 @@ function flushQueueSave(): void {
// GLOBAL STATE // GLOBAL STATE
// ========================================== // ==========================================
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let stopDevelopmentReload: (() => void) | null = null;
function startDevelopmentReload(): void {
if (process.env.TWITCH_VOD_MANAGER_DEV !== '1' || stopDevelopmentReload) return;
stopDevelopmentReload = watchRendererChanges(
__dirname,
path.join(__dirname, '../src'),
() => mainWindow?.webContents.reloadIgnoringCache(),
);
app.once('before-quit', () => {
stopDevelopmentReload?.();
stopDevelopmentReload = null;
});
}
let config = loadConfig(); let config = loadConfig();
let accessToken: string | null = null; let accessToken: string | null = null;
let downloadQueue: QueueItem[] = loadQueue(); let downloadQueue: QueueItem[] = loadQueue();
let queueIdCounter = 0; let queueIdCounter = 0;
let lastQueueBroadcastFingerprint = ''; let lastQueueBroadcastFingerprint = '';
let isDownloading = false; let isDownloading = false;
let queuePaused = false;
// Process handle for the standalone video editor pipeline (cutter / merger / // Process handle for the standalone video editor pipeline (cutter / merger /
// splitter). Queue downloads track their own children via activeDownloads, // splitter). Queue downloads track their own children via activeDownloads,
// and clip downloads via activeClipProcesses. Keeping these separate // and clip downloads via activeClipProcesses. Keeping these separate
@@ -649,12 +689,19 @@ let currentEditorProcess: ChildProcess | null = null;
// `currentDownloadCancelled` flag was redundant once pause/cancel/remove // `currentDownloadCancelled` flag was redundant once pause/cancel/remove
// started iterating activeDownloads and adding each item to that Set; it // started iterating activeDownloads and adding each item to that Set; it
// was removed in the 4.5.27 cleanup. // was removed in the 4.5.27 cleanup.
let pauseRequested = false;
let activeQueueItemId: string | null = null; let activeQueueItemId: string | null = null;
let downloadStartTime = 0; let downloadStartTime = 0;
let downloadedBytes = 0; let downloadedBytes = 0;
// Per-item tracking for parallel downloads // Per-item tracking for parallel downloads
const activeDownloads = new Map<string, { process: ChildProcess | null; cancelled: boolean; startTime: number; bytes: number }>(); interface ActiveDownloadTracking {
process: ChildProcess | null;
cancelled: boolean;
startTime: number;
bytes: number;
output: PausableOutput;
partialFilename: string;
}
const activeDownloads = new Map<string, ActiveDownloadTracking>();
const cancelledItemIds = new Set<string>(); const cancelledItemIds = new Set<string>();
// userId -> login reverse map. Bounded via Map insertion-order eviction so // userId -> login reverse map. Bounded via Map insertion-order eviction so
// a long-running session doesn't grow it unbounded across thousands of // a long-running session doesn't grow it unbounded across thousands of
@@ -1337,12 +1384,12 @@ function classifyDownloadError(errorMessage: string): RetryErrorClass {
const text = (errorMessage || '').toLowerCase(); const text = (errorMessage || '').toLowerCase();
if (!text) return 'unknown'; if (!text) return 'unknown';
if (text.includes('ungueltige vod-url') || text.includes('invalid vod url')) return 'validation'; if (text.includes('ungültige vod-url') || text.includes('ungueltige vod-url') || text.includes('invalid vod url')) return 'validation';
if (text.includes('429') || text.includes('rate limit') || text.includes('too many requests')) return 'rate_limit'; if (text.includes('429') || text.includes('rate limit') || text.includes('too many requests')) return 'rate_limit';
if (text.includes('401') || text.includes('403') || text.includes('unauthorized') || text.includes('forbidden') || text.includes('subscriber only') || text.includes('sub-only') || text.includes('not subscribed')) return 'auth'; if (text.includes('401') || text.includes('403') || text.includes('unauthorized') || text.includes('forbidden') || text.includes('subscriber only') || text.includes('sub-only') || text.includes('not subscribed')) return 'auth';
if (text.includes('timed out') || text.includes('timeout') || text.includes('network') || text.includes('connection') || text.includes('dns') || text.includes('http error') || text.includes('connectionerror') || text.includes('readerror')) return 'network'; if (text.includes('timed out') || text.includes('timeout') || text.includes('network') || text.includes('connection') || text.includes('dns') || text.includes('http error') || text.includes('connectionerror') || text.includes('readerror')) return 'network';
if (text.includes('streamlink nicht gefunden') || text.includes('streamlink not found') || text.includes('streamlink is missing') || text.includes('ffmpeg') || text.includes('ffprobe') || text.includes('enoent')) return 'tooling'; if (text.includes('streamlink nicht gefunden') || text.includes('streamlink not found') || text.includes('streamlink is missing') || text.includes('ffmpeg') || text.includes('ffprobe') || text.includes('enoent')) return 'tooling';
if (text.includes('integritaet') || text.includes('integrity') || text.includes('kein videostream') || text.includes('no video stream')) return 'integrity'; if (text.includes('integrität') || text.includes('integritaet') || text.includes('integrity') || text.includes('kein videostream') || text.includes('no video stream')) return 'integrity';
if (text.includes('access denied') || text.includes('permission') || text.includes('disk') || text.includes('file') || text.includes('ordner') || text.includes('folder')) return 'io'; if (text.includes('access denied') || text.includes('permission') || text.includes('disk') || text.includes('file') || text.includes('ordner') || text.includes('folder')) return 'io';
// Twitch-spezifische streamlink errors: // Twitch-spezifische streamlink errors:
// "error: No playable streams found on this URL" — VOD weg / private / sub-only // "error: No playable streams found on this URL" — VOD weg / private / sub-only
@@ -1436,6 +1483,17 @@ const activeDownloadProgress = new Map<string, number>();
function recordDownloadProgress(progress: DownloadProgress): void { function recordDownloadProgress(progress: DownloadProgress): void {
const p = Number(progress.progress); const p = Number(progress.progress);
const item = downloadQueue.find((candidate) => candidate.id === progress.id);
if (item) {
if (Number.isFinite(p) && p > 0 && p <= 100) item.progress = Math.max(item.progress, p);
item.speed = progress.speed || '';
item.eta = progress.eta || '';
item.progressStatus = progress.status;
if (typeof progress.currentPart === 'number') item.currentPart = progress.currentPart;
if (typeof progress.totalParts === 'number') item.totalParts = progress.totalParts;
if (typeof progress.downloadedBytes === 'number') item.downloadedBytes = progress.downloadedBytes;
if (typeof progress.totalBytes === 'number') item.totalBytes = progress.totalBytes;
}
const fraction = Number.isFinite(p) && p > 0 && p <= 100 ? p / 100 : 0.3; const fraction = Number.isFinite(p) && p > 0 && p <= 100 ? p / 100 : 0.3;
activeDownloadProgress.set(progress.id, fraction); activeDownloadProgress.set(progress.id, fraction);
updateTaskbarProgress(); updateTaskbarProgress();
@@ -2189,6 +2247,50 @@ interface PublicStreamerProfileResult {
stream: PublicStreamInfo | null; stream: PublicStreamInfo | null;
} }
interface PublicDisplayNameQueryResult {
user: {
login: string;
displayName: string;
} | null;
}
async function getStreamerDisplayNames(logins: string[]): Promise<Record<string, string>> {
const normalizedLogins = [...new Set(logins.map((login) => normalizeLogin(login)).filter(Boolean))];
const displayNames = { ...config.streamer_display_names };
const missing = normalizedLogins;
let changed = false;
let nextIndex = 0;
const resolveNext = async (): Promise<void> => {
while (nextIndex < missing.length) {
const login = missing[nextIndex++];
const data = await fetchPublicTwitchGql<PublicDisplayNameQueryResult>(
'query($login: String!) { user(login: $login) { login displayName } }',
{ login }
);
const result = data?.user;
const resolvedLogin = result?.login ? normalizeLogin(result.login) : login;
const displayName = result?.displayName?.trim();
if (displayName && displayNames[resolvedLogin] !== displayName) {
displayNames[resolvedLogin] = displayName;
changed = true;
}
}
};
await Promise.all(Array.from({ length: Math.min(4, missing.length) }, () => resolveNext()));
if (changed) {
config.streamer_display_names = displayNames;
saveConfig(config);
}
return normalizedLogins.reduce<Record<string, string>>((resolved, login) => {
const displayName = displayNames[login];
if (displayName) resolved[login] = displayName;
return resolved;
}, {});
}
interface PublicStreamInfo { interface PublicStreamInfo {
previewUrl: string; previewUrl: string;
viewers: number | null; viewers: number | null;
@@ -2392,6 +2494,9 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise<
interface VodStoryboard { interface VodStoryboard {
vodId: string; vodId: string;
spriteDataUrl: string; spriteDataUrl: string;
frameDataUrls: string[];
frameWidth: number;
frameHeight: number;
cols: number; cols: number;
rows: number; rows: number;
cellWidth: number; cellWidth: number;
@@ -2399,7 +2504,7 @@ interface VodStoryboard {
framesInSprite: number; framesInSprite: number;
} }
const MAX_VOD_STORYBOARD_CACHE_ENTRIES = 1024; const MAX_VOD_STORYBOARD_CACHE_ENTRIES = 12;
const vodStoryboardCache = new Map<string, CacheEntry<VodStoryboard | null>>(); const vodStoryboardCache = new Map<string, CacheEntry<VodStoryboard | null>>();
const inFlightStoryboardRequests = new Map<string, Promise<VodStoryboard | null>>(); const inFlightStoryboardRequests = new Map<string, Promise<VodStoryboard | null>>();
@@ -2430,8 +2535,8 @@ async function getVodStoryboard(vodId: string): Promise<VodStoryboard | null> {
// manifest. The manifest lists sprite images at multiple quality // manifest. The manifest lists sprite images at multiple quality
// levels; we pick the high-quality first sprite (covers the // levels; we pick the high-quality first sprite (covers the
// beginning of the VOD with the most detail). // beginning of the VOD with the most detail).
const data = await fetchPublicTwitchGql<{ video: { seekPreviewsURL: string | null } | null }>( const data = await fetchPublicTwitchGql<{ video: { seekPreviewsURL: string | null; previewThumbnailURL: string | null } | null }>(
`query($id: ID!) { video(id: $id) { seekPreviewsURL } }`, `query($id: ID!) { video(id: $id) { seekPreviewsURL previewThumbnailURL(width: 1920, height: 1080) } }`,
{ id: vodId } { id: vodId }
); );
const manifestUrl = data?.video?.seekPreviewsURL; const manifestUrl = data?.video?.seekPreviewsURL;
@@ -2474,10 +2579,23 @@ async function getVodStoryboard(vodId: string): Promise<VodStoryboard | null> {
// and sprite filenames are relative (e.g. "{vodId}-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 frameUrls = buildVodPreviewFrameUrls(data?.video?.previewThumbnailURL || '');
const frameDataUrls = (await Promise.all(frameUrls.map(async (url) => {
try {
const response = await axios.get<ArrayBuffer>(url, {
responseType: 'arraybuffer',
timeout: 8000,
headers: { 'User-Agent': 'TwitchVODManager/1.0' }
});
const contentType = String(response.headers['content-type'] || 'image/jpeg').split(';')[0];
return `data:${contentType};base64,${Buffer.from(response.data).toString('base64')}`;
} catch {
return '';
}
}))).filter((url) => url.length > 0);
const firstSpriteUrl = baseUrl + entry.images[0]; const firstSpriteUrl = baseUrl + entry.images[0];
const spriteDataUrl = frameDataUrls.length >= 2 ? '' : await fetchAvatarAsDataUrl(firstSpriteUrl);
const spriteDataUrl = await fetchAvatarAsDataUrl(firstSpriteUrl); if (frameDataUrls.length < 2 && !spriteDataUrl) {
if (!spriteDataUrl) {
setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES); setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
return null; return null;
} }
@@ -2485,6 +2603,9 @@ async function getVodStoryboard(vodId: string): Promise<VodStoryboard | null> {
const storyboard: VodStoryboard = { const storyboard: VodStoryboard = {
vodId, vodId,
spriteDataUrl, spriteDataUrl,
frameDataUrls: frameDataUrls.length >= 2 ? frameDataUrls : [],
frameWidth: frameDataUrls.length >= 2 ? 1920 : 0,
frameHeight: frameDataUrls.length >= 2 ? 1080 : 0,
cols: entry.cols, cols: entry.cols,
rows: entry.rows, rows: entry.rows,
cellWidth: entry.width, cellWidth: entry.width,
@@ -3050,7 +3171,7 @@ function downloadVODPart(
): Promise<DownloadResult> { ): Promise<DownloadResult> {
return new Promise((resolve) => { return new Promise((resolve) => {
const streamlinkCmd = getStreamlinkCommand(); const streamlinkCmd = getStreamlinkCommand();
const args = [...streamlinkCmd.prefixArgs, url, getStreamlinkStreamArg(), '-o', filename, '--force']; const args = [...streamlinkCmd.prefixArgs, url, getStreamlinkStreamArg(), '--stdout'];
if (config.streamlink_disable_ads !== false) { if (config.streamlink_disable_ads !== false) {
// Skips Twitch mid-roll ads which would otherwise be embedded // Skips Twitch mid-roll ads which would otherwise be embedded
// in the VOD output. Off only if the user explicitly disabled it. // in the VOD output. Off only if the user explicitly disabled it.
@@ -3083,12 +3204,23 @@ function downloadVODPart(
// for support / forensics — no need to flood stdout too. // for support / forensics — no need to flood stdout too.
appendDebugLog('download-part-start', { itemId, command: streamlinkCmd.command, filename, args }); appendDebugLog('download-part-start', { itemId, command: streamlinkCmd.command, filename, args });
const partialFilename = partialDownloadRegistry.begin(filename);
const proc = spawn(streamlinkCmd.command, args, { windowsHide: true }); const proc = spawn(streamlinkCmd.command, args, { windowsHide: true });
const outputStream = fs.createWriteStream(partialFilename, { flags: 'w' });
if (!proc.stdout) {
outputStream.destroy();
partialDownloadRegistry.discard(partialFilename);
resolve({ success: false, error: tBackend('unknownDownloadError') });
return;
}
const output = createPausableOutput(proc.stdout, outputStream);
const outputFinished = output.finished.then(() => null, (error) => error);
// Register in per-item tracking map for parallel downloads // Register in per-item tracking map for parallel downloads
// (no longer mirrored on a global — currentEditorProcess is editor-only) // (no longer mirrored on a global — currentEditorProcess is editor-only)
const itemTracking = { process: proc, cancelled: false, startTime: Date.now(), bytes: 0 }; const itemTracking: ActiveDownloadTracking = { process: proc, cancelled: false, startTime: Date.now(), bytes: 0, output, partialFilename };
activeDownloads.set(itemId, itemTracking); activeDownloads.set(itemId, itemTracking);
if (queuePaused) output.pause();
downloadStartTime = itemTracking.startTime; downloadStartTime = itemTracking.startTime;
downloadedBytes = 0; downloadedBytes = 0;
@@ -3097,9 +3229,9 @@ function downloadVODPart(
// Monitor file size for progress // Monitor file size for progress
const progressInterval = setInterval(() => { const progressInterval = setInterval(() => {
if (fs.existsSync(filename)) { if (fs.existsSync(partialFilename)) {
try { try {
const stats = fs.statSync(filename); const stats = fs.statSync(partialFilename);
downloadedBytes = stats.size; downloadedBytes = stats.size;
itemTracking.bytes = stats.size; itemTracking.bytes = stats.size;
@@ -3167,46 +3299,24 @@ function downloadVODPart(
} }
}, 1000); }, 1000);
const stdoutBuffer: string[] = [];
proc.stdout?.on('data', (data: Buffer) => {
const line = data.toString();
// Capture non-progress lines auch fuer Diagnose — streamlink-Windows-
// Builds schreiben einige Errors auf stdout statt stderr (z.B. "error:
// No playable streams found on this URL"). Wenn stderr leer bleibt,
// greift der close-handler auf stdout zurueck.
stdoutBuffer.push(line);
if (stdoutBuffer.length > 200) stdoutBuffer.shift();
const lower = line.toLowerCase();
if (lower.includes('error:') || lower.includes('warning:')) {
appendDebugLog('download-part-stdout-err', { itemId, message: line.trim() });
if (lower.includes('error:')) {
// Letzte echte streamlink-Errorzeile, auch wenn auf stdout
const errLine = line.split('\n').map(l => l.trim()).filter(l => l.toLowerCase().includes('error:')).pop();
if (errLine) lastErrorLine = errLine;
}
}
// Parse progress
const match = line.match(/(\d+\.\d+)%/);
if (match) {
const percent = parseFloat(match[1]);
lastStreamlinkPercent = percent;
onProgress({
id: itemId,
progress: percent,
speed: '',
eta: '',
status: `${percent.toFixed(1)}%`,
currentPart: partNum,
totalParts: totalParts
});
}
});
proc.stderr?.on('data', (data: Buffer) => { proc.stderr?.on('data', (data: Buffer) => {
const message = data.toString(); const message = data.toString();
if (message.trim()) { if (message.trim()) {
stderrBuffer.push(message); stderrBuffer.push(message);
const match = message.match(/(\d+\.\d+)%/);
if (match) {
const percent = parseFloat(match[1]);
lastStreamlinkPercent = percent;
onProgress({
id: itemId,
progress: percent,
speed: '',
eta: '',
status: `${percent.toFixed(1)}%`,
currentPart: partNum,
totalParts: totalParts
});
}
// Bounded buffer — wir wollen nicht 100MB stderr in RAM bei einem // Bounded buffer — wir wollen nicht 100MB stderr in RAM bei einem
// streamlink-loop. 200 chunks reichen fuer normale Diagnose. // streamlink-loop. 200 chunks reichen fuer normale Diagnose.
if (stderrBuffer.length > 200) stderrBuffer.shift(); if (stderrBuffer.length > 200) stderrBuffer.shift();
@@ -3230,26 +3340,36 @@ function downloadVODPart(
proc.on('close', async (code) => { proc.on('close', async (code) => {
clearInterval(progressInterval); clearInterval(progressInterval);
const outputError = await outputFinished;
activeDownloads.delete(itemId); activeDownloads.delete(itemId);
if (outputError) {
partialDownloadRegistry.discard(partialFilename);
resolve({ success: false, error: String(outputError) });
return;
}
if (cancelledItemIds.has(itemId)) { if (cancelledItemIds.has(itemId)) {
cancelledItemIds.delete(itemId); cancelledItemIds.delete(itemId);
partialDownloadRegistry.discard(partialFilename);
appendDebugLog('download-part-cancelled', { itemId, filename }); appendDebugLog('download-part-cancelled', { itemId, filename });
resolve({ success: false, error: tBackend('downloadCancelled') }); resolve({ success: false, error: tBackend('downloadCancelled') });
return; return;
} }
if (code === 0 && fs.existsSync(filename)) { if (code === 0 && fs.existsSync(partialFilename)) {
const stats = fs.statSync(filename); const stats = fs.statSync(partialFilename);
if (stats.size <= MIN_FILE_BYTES) { if (stats.size <= MIN_FILE_BYTES) {
const tooSmall = tBackend('fileTooSmall', { bytes: String(stats.size) }); const tooSmall = tBackend('fileTooSmall', { bytes: String(stats.size) });
partialDownloadRegistry.discard(partialFilename);
appendDebugLog('download-part-failed-small-file', { itemId, filename, bytes: stats.size }); appendDebugLog('download-part-failed-small-file', { itemId, filename, bytes: stats.size });
resolve({ success: false, error: tooSmall }); resolve({ success: false, error: tooSmall });
return; return;
} }
const integrityResult = validateDownloadedFileIntegrity(filename, expectedDurationSeconds); const integrityResult = validateDownloadedFileIntegrity(partialFilename, expectedDurationSeconds);
if (!integrityResult.success) { if (!integrityResult.success) {
partialDownloadRegistry.discard(partialFilename);
appendDebugLog('download-part-failed-integrity', { appendDebugLog('download-part-failed-integrity', {
itemId, itemId,
filename, filename,
@@ -3260,6 +3380,13 @@ function downloadVODPart(
return; return;
} }
try {
partialDownloadRegistry.commit(partialFilename, filename);
} catch (error) {
partialDownloadRegistry.discard(partialFilename);
resolve({ success: false, error: String(error) });
return;
}
runtimeMetrics.downloadedBytesTotal += stats.size; runtimeMetrics.downloadedBytesTotal += stats.size;
appendDebugLog('download-part-success', { itemId, filename, bytes: stats.size }); appendDebugLog('download-part-success', { itemId, filename, bytes: stats.size });
resolve({ success: true }); resolve({ success: true });
@@ -3272,13 +3399,12 @@ function downloadVODPart(
// war historisch ein stdout-Error). Wir mergen beide Streams // war historisch ein stdout-Error). Wir mergen beide Streams
// damit immer SICHTBAR ist was passiert. // damit immer SICHTBAR ist was passiert.
const fullStderr = stderrBuffer.join('').trim(); const fullStderr = stderrBuffer.join('').trim();
const fullStdout = stdoutBuffer.join('').trim();
// Letzte Error-/Warning-Zeile aus beiden Streams suchen, falls // Letzte Error-/Warning-Zeile aus beiden Streams suchen, falls
// lastErrorLine noch leer ist (z.B. weil streamlink ohne Output // lastErrorLine noch leer ist (z.B. weil streamlink ohne Output
// mit Code 1 exited — was bei pre-flight-Auth-Fails passiert). // mit Code 1 exited — was bei pre-flight-Auth-Fails passiert).
let userFacingError = lastErrorLine; let userFacingError = lastErrorLine;
if (!userFacingError) { if (!userFacingError) {
const combined = (fullStderr + '\n' + fullStdout).split('\n').map(l => l.trim()).filter(Boolean); const combined = fullStderr.split('\n').map(l => l.trim()).filter(Boolean);
userFacingError = combined.filter(l => l.toLowerCase().includes('error:')).pop() userFacingError = combined.filter(l => l.toLowerCase().includes('error:')).pop()
|| combined.filter(l => !l.startsWith('[')).pop() || combined.filter(l => !l.startsWith('[')).pop()
|| ''; || '';
@@ -3288,14 +3414,16 @@ function downloadVODPart(
} }
appendDebugLog('download-part-failed', { appendDebugLog('download-part-failed', {
itemId, filename, code, error: userFacingError, itemId, filename, code, error: userFacingError,
stderrTail: fullStderr.slice(-2000), stderrTail: fullStderr.slice(-2000)
stdoutTail: fullStdout.slice(-2000),
}); });
partialDownloadRegistry.discard(partialFilename);
resolve({ success: false, error: userFacingError }); resolve({ success: false, error: userFacingError });
}); });
proc.on('error', (err) => { proc.on('error', async (err) => {
clearInterval(progressInterval); clearInterval(progressInterval);
await output.cancel();
partialDownloadRegistry.discard(partialFilename);
console.error('Process error:', err); console.error('Process error:', err);
activeDownloads.delete(itemId); activeDownloads.delete(itemId);
const rawError = String(err); const rawError = String(err);
@@ -5046,8 +5174,8 @@ async function downloadLiveStream(
} }
// Resume decision tree. // Resume decision tree.
if (cancelledItemIds.has(item.id) || !isDownloading || pauseRequested) { if (cancelledItemIds.has(item.id) || !isDownloading) {
appendDebugLog('recording-resume-cancelled', { itemId: item.id, partNumber, reason: pauseRequested ? 'pause' : 'cancel' }); appendDebugLog('recording-resume-cancelled', { itemId: item.id, partNumber, reason: 'cancel' });
break; break;
} }
if (!config.auto_resume_live_recording) { if (!config.auto_resume_live_recording) {
@@ -5721,12 +5849,12 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
const result = item.mergeGroup const result = item.mergeGroup
? await processDownloadMergeGroup(item, (progress) => { ? await processDownloadMergeGroup(item, (progress) => {
mainWindow?.webContents.send('download-progress', progress);
recordDownloadProgress(progress); recordDownloadProgress(progress);
if (!queuePaused) mainWindow?.webContents.send('download-progress', progress);
}) })
: await downloadVOD(item, (progress) => { : await downloadVOD(item, (progress) => {
mainWindow?.webContents.send('download-progress', progress);
recordDownloadProgress(progress); recordDownloadProgress(progress);
if (!queuePaused) mainWindow?.webContents.send('download-progress', progress);
}); });
if (result.success) { if (result.success) {
@@ -5736,8 +5864,8 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
finalResult = result; finalResult = result;
if (!isDownloading || cancelledItemIds.has(item.id) || pauseRequested) { if (!isDownloading || cancelledItemIds.has(item.id)) {
finalResult = { success: false, error: pauseRequested ? tBackend('downloadPaused') : tBackend('downloadCancelled') }; finalResult = { success: false, error: tBackend('downloadCancelled') };
break; break;
} }
@@ -5781,10 +5909,9 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
return; return;
} }
const wasPaused = pauseRequested || (finalResult.error || '').includes('pausiert'); item.status = finalResult.success ? 'completed' : 'error';
item.status = finalResult.success ? 'completed' : (wasPaused ? 'paused' : 'error');
item.progress = finalResult.success ? 100 : item.progress; item.progress = finalResult.success ? 100 : item.progress;
item.last_error = finalResult.success || wasPaused ? '' : (finalResult.error || tBackend('unknownDownloadError')); item.last_error = finalResult.success ? '' : (finalResult.error || tBackend('unknownDownloadError'));
if (finalResult.success && Array.isArray(finalResult.outputFiles) && finalResult.outputFiles.length > 0) { if (finalResult.success && Array.isArray(finalResult.outputFiles) && finalResult.outputFiles.length > 0) {
// Attach the produced file paths so the renderer can offer // Attach the produced file paths so the renderer can offer
@@ -5818,7 +5945,8 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
if (Notification.isSupported()) { if (Notification.isSupported()) {
const itemNotification = new Notification({ const itemNotification = new Notification({
title: 'Twitch VOD Manager', title: 'Twitch VOD Manager',
body: `${item.title || item.url}` body: `${item.title || item.url}`,
icon: path.join(__dirname, '../build/icon.png')
}); });
const firstFile = item.outputFiles?.[0]; const firstFile = item.outputFiles?.[0];
itemNotification.on('click', () => { itemNotification.on('click', () => {
@@ -5924,7 +6052,7 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
if (finalResult.success) { if (finalResult.success) {
runtimeMetrics.downloadsCompleted += 1; runtimeMetrics.downloadsCompleted += 1;
} else if (!wasPaused) { } else {
runtimeMetrics.downloadsFailed += 1; runtimeMetrics.downloadsFailed += 1;
} }
@@ -5956,7 +6084,7 @@ async function processQueue(): Promise<void> {
}); });
isDownloading = true; isDownloading = true;
pauseRequested = false; queuePaused = false;
cancelledItemIds.clear(); cancelledItemIds.clear();
mainWindow?.webContents.send('download-started'); mainWindow?.webContents.send('download-started');
emitQueueUpdated(); emitQueueUpdated();
@@ -5964,17 +6092,17 @@ async function processQueue(): Promise<void> {
const maxSlots = Math.min(Math.max(1, config.parallel_downloads || 1), 2); const maxSlots = Math.min(Math.max(1, config.parallel_downloads || 1), 2);
const activePromises = new Map<string, Promise<void>>(); const activePromises = new Map<string, Promise<void>>();
while (isDownloading && !pauseRequested) { while (isDownloading) {
// Clean up finished promises // Clean up finished promises
for (const [id] of activePromises) { for (const [id] of activePromises) {
const queueItem = downloadQueue.find(i => i.id === id); const queueItem = downloadQueue.find(i => i.id === id);
if (!queueItem || queueItem.status !== 'downloading') { if (!queueItem || (queueItem.status !== 'downloading' && queueItem.status !== 'paused')) {
activePromises.delete(id); activePromises.delete(id);
} }
} }
// Fill available slots // Fill available slots
while (activePromises.size < maxSlots && !pauseRequested) { while (activePromises.size < maxSlots && !queuePaused) {
const item = pickNextPendingQueueItem(); const item = pickNextPendingQueueItem();
if (!item) break; if (!item) break;
@@ -5994,7 +6122,7 @@ async function processQueue(): Promise<void> {
} }
isDownloading = false; isDownloading = false;
pauseRequested = false; queuePaused = false;
runtimeMetrics.activeItemId = null; runtimeMetrics.activeItemId = null;
runtimeMetrics.activeItemTitle = null; runtimeMetrics.activeItemTitle = null;
activeQueueItemId = null; activeQueueItemId = null;
@@ -6012,7 +6140,8 @@ async function processQueue(): Promise<void> {
title: 'Twitch VOD Manager', title: 'Twitch VOD Manager',
body: failed > 0 body: failed > 0
? `${completed} Downloads fertig, ${failed} fehlgeschlagen` ? `${completed} Downloads fertig, ${failed} fehlgeschlagen`
: `${completed} Downloads abgeschlossen` : `${completed} Downloads abgeschlossen`,
icon: path.join(__dirname, '../build/icon.png')
}); });
// Click brings the app to the foreground AND opens the download // Click brings the app to the foreground AND opens the download
// folder so the user can immediately see the output files. // folder so the user can immediately see the output files.
@@ -6048,6 +6177,7 @@ function createWindow(): void {
minHeight: 700, minHeight: 700,
title: `Twitch VOD Manager [v${APP_VERSION}]`, title: `Twitch VOD Manager [v${APP_VERSION}]`,
backgroundColor: '#0e0e10', backgroundColor: '#0e0e10',
icon: path.join(__dirname, process.platform === 'win32' ? '../build/icon.ico' : '../build/icon.png'),
autoHideMenuBar: true, autoHideMenuBar: true,
webPreferences: { webPreferences: {
nodeIntegration: false, nodeIntegration: false,
@@ -6582,7 +6712,7 @@ ipcMain.handle('add-to-queue', (_, item: Omit<QueueItem, 'id' | 'status' | 'prog
return downloadQueue; return downloadQueue;
}); });
ipcMain.handle('remove-from-queue', (_, id: string) => { ipcMain.handle('remove-from-queue', async (_, id: string) => {
const wasActiveItem = activeQueueItemId === id || activeDownloads.has(id); const wasActiveItem = activeQueueItemId === id || activeDownloads.has(id);
if (wasActiveItem) { if (wasActiveItem) {
@@ -6590,6 +6720,8 @@ ipcMain.handle('remove-from-queue', (_, id: string) => {
const tracking = activeDownloads.get(id); const tracking = activeDownloads.get(id);
if (tracking?.process) { if (tracking?.process) {
tracking.process.kill(); tracking.process.kill();
await tracking.output.cancel();
partialDownloadRegistry.discard(tracking.partialFilename);
} }
activeDownloads.delete(id); activeDownloads.delete(id);
activeQueueItemId = null; activeQueueItemId = null;
@@ -6762,6 +6894,20 @@ ipcMain.handle('create-merge-group', (_, itemIds: string[]) => {
}); });
ipcMain.handle('start-download', async () => { ipcMain.handle('start-download', async () => {
if (isDownloading && queuePaused) {
queuePaused = false;
for (const item of downloadQueue) {
if (item.status === 'paused') item.status = 'downloading';
}
for (const [id, tracking] of activeDownloads) {
tracking.output.resume();
}
saveQueue(downloadQueue);
emitQueueUpdated(true);
mainWindow?.webContents.send('download-started');
return true;
}
downloadQueue = downloadQueue.map((item) => item.status === 'paused' ? { ...item, status: 'pending' } : item); downloadQueue = downloadQueue.map((item) => item.status === 'paused' ? { ...item, status: 'pending' } : item);
const hasPendingItems = downloadQueue.some(item => item.status === 'pending'); const hasPendingItems = downloadQueue.some(item => item.status === 'pending');
@@ -6780,31 +6926,37 @@ ipcMain.handle('start-download', async () => {
}); });
ipcMain.handle('pause-download', () => { ipcMain.handle('pause-download', () => {
if (!isDownloading) return false; if (!isDownloading || queuePaused) return false;
pauseRequested = true; queuePaused = true;
// Kill queue downloads only — cutter/merger/splitter use currentEditorProcess for (const [, tracking] of activeDownloads) {
// and aren't affected by pause-download. Per-item cancel state lives in tracking.output.pause();
// cancelledItemIds — every active item gets added below. }
for (const [id, tracking] of activeDownloads) { for (const item of downloadQueue) {
cancelledItemIds.add(id); if (item.status === 'downloading') {
if (tracking.process) { item.status = 'paused';
tracking.process.kill(); item.speed = '';
item.eta = '';
item.progressStatus = tBackend('downloadPaused');
} }
} }
saveQueue(downloadQueue);
emitQueueUpdated(true);
mainWindow?.webContents.send('download-paused');
return true; return true;
}); });
ipcMain.handle('cancel-download', () => { ipcMain.handle('cancel-download', async () => {
isDownloading = false; isDownloading = false;
pauseRequested = false; queuePaused = false;
// Kill queue downloads only — see pause-download note above. await Promise.all([...activeDownloads].map(async ([id, tracking]) => {
for (const [id, tracking] of activeDownloads) {
cancelledItemIds.add(id); cancelledItemIds.add(id);
if (tracking.process) { if (tracking.process) {
tracking.process.kill(); tracking.process.kill();
await tracking.output.cancel();
partialDownloadRegistry.discard(tracking.partialFilename);
} }
} }));
return true; return true;
}); });
@@ -6921,7 +7073,12 @@ ipcMain.handle('open-external', async (_, url: string) => {
// Tracks active standalone clip downloads so cancel-download / window-all-closed // Tracks active standalone clip downloads so cancel-download / window-all-closed
// can kill them. Separate from activeDownloads (queue) because clip downloads // can kill them. Separate from activeDownloads (queue) because clip downloads
// don't go through the queue scheduler. // don't go through the queue scheduler.
const activeClipProcesses = new Map<string, ChildProcess>(); interface ActiveClipDownloadTracking {
process: ChildProcess;
output: PausableOutput;
partialFilename: string;
}
const activeClipProcesses = new Map<string, ActiveClipDownloadTracking>();
ipcMain.handle('download-clip', async (_, clipUrl: string) => { ipcMain.handle('download-clip', async (_, clipUrl: string) => {
let clipId = ''; let clipId = '';
@@ -6959,51 +7116,70 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => {
return new Promise<{ success: boolean; error?: string; filename?: string }>((resolve) => { return new Promise<{ success: boolean; error?: string; filename?: string }>((resolve) => {
const streamlinkCmd = getStreamlinkCommand(); const streamlinkCmd = getStreamlinkCommand();
const partialFilename = partialDownloadRegistry.begin(filename);
const proc = spawn(streamlinkCmd.command, [ const proc = spawn(streamlinkCmd.command, [
...streamlinkCmd.prefixArgs, ...streamlinkCmd.prefixArgs,
`https://clips.twitch.tv/${clipId}`, `https://clips.twitch.tv/${clipId}`,
getStreamlinkStreamArg(), getStreamlinkStreamArg(),
'-o', filename, '--stdout'
'--force'
], { windowsHide: true }); ], { windowsHide: true });
if (!proc.stdout) {
partialDownloadRegistry.discard(partialFilename);
releaseClaimedFilenamesForItem(clipId);
resolve({ success: false, error: tBackend('unknownDownloadError') });
return;
}
const output = createPausableOutput(proc.stdout, fs.createWriteStream(partialFilename, { flags: 'w' }));
const outputFinished = output.finished.then(() => null, (error) => error);
activeClipProcesses.set(clipId, proc); activeClipProcesses.set(clipId, { process: proc, output, partialFilename });
appendDebugLog('clip-download-start', { clipId, broadcaster: safeBroadcaster, filename }); appendDebugLog('clip-download-start', { clipId, broadcaster: safeBroadcaster, filename });
proc.on('close', (code) => { proc.on('close', async (code) => {
activeClipProcesses.delete(clipId); activeClipProcesses.delete(clipId);
releaseClaimedFilenamesForItem(clipId); releaseClaimedFilenamesForItem(clipId);
const outputError = await outputFinished;
if (code !== 0 || !fs.existsSync(filename)) { if (outputError || code !== 0 || !fs.existsSync(partialFilename)) {
partialDownloadRegistry.discard(partialFilename);
appendDebugLog('clip-download-failed', { clipId, code }); appendDebugLog('clip-download-failed', { clipId, code });
resolve({ success: false, error: tBackend('downloadFailedExitCode', { code: String(code ?? -1) }) }); resolve({ success: false, error: outputError ? String(outputError) : tBackend('downloadFailedExitCode', { code: String(code ?? -1) }) });
return; return;
} }
// Integrity: clips are short but should still be at least a few KB // Integrity: clips are short but should still be at least a few KB
// and parse as a video stream via ffprobe. Empty/zero-byte files // and parse as a video stream via ffprobe. Empty/zero-byte files
// were previously reported as "success" because exit code was 0. // were previously reported as "success" because exit code was 0.
const stats = fs.statSync(filename); const stats = fs.statSync(partialFilename);
if (stats.size < 16 * 1024) { if (stats.size < 16 * 1024) {
try { fs.unlinkSync(filename); } catch { } partialDownloadRegistry.discard(partialFilename);
appendDebugLog('clip-download-too-small', { clipId, bytes: stats.size }); appendDebugLog('clip-download-too-small', { clipId, bytes: stats.size });
resolve({ success: false, error: tBackend('clipFileTooSmall', { bytes: String(stats.size) }) }); resolve({ success: false, error: tBackend('clipFileTooSmall', { bytes: String(stats.size) }) });
return; return;
} }
const integrity = validateDownloadedFileIntegrity(filename, null); const integrity = validateDownloadedFileIntegrity(partialFilename, null);
if (!integrity.success) { if (!integrity.success) {
try { fs.unlinkSync(filename); } catch { } partialDownloadRegistry.discard(partialFilename);
appendDebugLog('clip-download-integrity-failed', { clipId, error: integrity.error }); appendDebugLog('clip-download-integrity-failed', { clipId, error: integrity.error });
resolve({ success: false, error: integrity.error || tBackend('integrityFailedGeneric') }); resolve({ success: false, error: integrity.error || tBackend('integrityFailedGeneric') });
return; return;
} }
try {
partialDownloadRegistry.commit(partialFilename, filename);
} catch (error) {
partialDownloadRegistry.discard(partialFilename);
resolve({ success: false, error: String(error) });
return;
}
appendDebugLog('clip-download-success', { clipId, bytes: stats.size, filename }); appendDebugLog('clip-download-success', { clipId, bytes: stats.size, filename });
resolve({ success: true, filename }); resolve({ success: true, filename });
}); });
proc.on('error', () => { proc.on('error', async () => {
await output.cancel();
partialDownloadRegistry.discard(partialFilename);
activeClipProcesses.delete(clipId); activeClipProcesses.delete(clipId);
releaseClaimedFilenamesForItem(clipId); releaseClaimedFilenamesForItem(clipId);
resolve({ success: false, error: tBackend('streamlinkNotFound') }); resolve({ success: false, error: tBackend('streamlinkNotFound') });
@@ -7036,6 +7212,10 @@ ipcMain.handle('get-streamer-profile', async (_, login: string, forceRefresh?: b
return await getStreamerProfile(login, forceRefresh === true); return await getStreamerProfile(login, forceRefresh === true);
}); });
ipcMain.handle('get-streamer-display-names', async (_, logins: string[]): Promise<Record<string, string>> => {
return await getStreamerDisplayNames(Array.isArray(logins) ? logins : []);
});
ipcMain.handle('get-vod-storyboard', async (_, vodId: string): Promise<VodStoryboard | null> => { ipcMain.handle('get-vod-storyboard', async (_, vodId: string): Promise<VodStoryboard | null> => {
return await getVodStoryboard(vodId); return await getVodStoryboard(vodId);
}); });
@@ -7128,7 +7308,7 @@ ipcMain.handle('check-folder-writable', (_, folderPath: string): boolean => {
return isDownloadPathWritable(folderPath); return isDownloadPathWritable(folderPath);
}); });
ipcMain.handle('is-downloading', () => isDownloading); ipcMain.handle('is-downloading', () => isDownloading && !queuePaused);
ipcMain.handle('get-runtime-metrics', () => getRuntimeMetricsSnapshot()); ipcMain.handle('get-runtime-metrics', () => getRuntimeMetricsSnapshot());
@@ -7316,7 +7496,10 @@ let appDb: DbHandle | null = null;
export function getAppDb(): DbHandle | null { return appDb; } export function getAppDb(): DbHandle | null { return appDb; }
app.whenReady().then(() => { app.whenReady().then(() => {
app.setAppUserModelId('com.twitch.vodmanager'); const removedPartialDownloads = partialDownloadRegistry.cleanup();
if (removedPartialDownloads.length > 0) {
appendDebugLog('partial-downloads-cleaned-on-startup', { count: removedPartialDownloads.length });
}
refreshBundledToolPaths(true); refreshBundledToolPaths(true);
startMetadataCacheCleanup(); startMetadataCacheCleanup();
startDebugLogFlushTimer(); startDebugLogFlushTimer();
@@ -7342,6 +7525,7 @@ app.whenReady().then(() => {
restartLiveStatusPoller(); restartLiveStatusPoller();
restartAutoCleanupTimer(); restartAutoCleanupTimer();
createWindow(); createWindow();
startDevelopmentReload();
appendDebugLog('startup-tools-check-skipped', 'Deferred to first use'); appendDebugLog('startup-tools-check-skipped', 'Deferred to first use');
app.on('activate', () => { app.on('activate', () => {
@@ -7357,8 +7541,10 @@ app.whenReady().then(() => {
// a single idempotent helper means any future tweak (e.g. flushing a new // a single idempotent helper means any future tweak (e.g. flushing a new
// debug stream) lands once and applies on every quit path. // debug stream) lands once and applies on every quit path.
let shutdownCleanupDone = false; let shutdownCleanupDone = false;
let quitAfterCleanup = false;
let shutdownPromise: Promise<void> | null = null;
function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): void { async function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): Promise<void> {
if (shutdownCleanupDone) return; if (shutdownCleanupDone) return;
shutdownCleanupDone = true; shutdownCleanupDone = true;
@@ -7376,16 +7562,20 @@ function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): void {
// and any in-flight cutter/merger/splitter ffmpeg. before-quit used to // and any in-flight cutter/merger/splitter ffmpeg. before-quit used to
// skip this entirely; window-all-closed did it but only via direct // skip this entirely; window-all-closed did it but only via direct
// kill() (no try/catch around the queue process kill). // kill() (no try/catch around the queue process kill).
for (const [, tracking] of activeDownloads) { await Promise.all([...activeDownloads.values()].map(async (tracking) => {
if (tracking.process) { if (tracking.process) {
try { tracking.process.kill(); } catch { /* already exited */ } try { tracking.process.kill(); } catch { /* already exited */ }
} }
} try { await tracking.output.cancel(); } catch { }
try { partialDownloadRegistry.discard(tracking.partialFilename); } catch { }
}));
activeDownloads.clear(); activeDownloads.clear();
for (const [, proc] of activeClipProcesses) { await Promise.all([...activeClipProcesses.values()].map(async (tracking) => {
try { proc.kill(); } catch { /* already exited */ } try { tracking.process.kill(); } catch { }
} try { await tracking.output.cancel(); } catch { }
try { partialDownloadRegistry.discard(tracking.partialFilename); } catch { }
}));
activeClipProcesses.clear(); activeClipProcesses.clear();
if (currentEditorProcess) { if (currentEditorProcess) {
@@ -7409,12 +7599,17 @@ function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): void {
} }
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
shutdownCleanup('window-all-closed');
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
app.quit(); app.quit();
} }
}); });
app.on('before-quit', () => { app.on('before-quit', (event) => {
shutdownCleanup('before-quit'); if (quitAfterCleanup) return;
event.preventDefault();
if (shutdownPromise) return;
shutdownPromise = shutdownCleanup('before-quit').finally(() => {
quitAfterCleanup = true;
app.quit();
});
}); });
+41
View File
@@ -0,0 +1,41 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as ResEdit from 'resedit';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { prepareWindowsDevExecutable } from './dev-executable';
let tempDirectory: string;
beforeEach(() => {
tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-dev-exe-'));
});
afterEach(() => {
fs.rmSync(tempDirectory, { recursive: true, force: true });
});
describe.runIf(process.platform === 'win32')('prepareWindowsDevExecutable', () => {
it('bettet App-Icon und Produktnamen in die gestartete Entwicklungs-EXE ein', async () => {
const sourcePath = path.join(process.env.WINDIR || 'C:\\Windows', 'System32', 'where.exe');
const destinationPath = path.join(tempDirectory, 'Twitch VOD Manager.exe');
const iconPath = path.resolve('build/icon.ico');
await prepareWindowsDevExecutable({
sourcePath,
destinationPath,
iconPath,
version: '1.0.3'
});
const executable = ResEdit.NtExecutable.from(fs.readFileSync(destinationPath));
const resources = ResEdit.NtExecutableResource.from(executable);
const iconGroups = ResEdit.Resource.IconGroupEntry.fromEntries(resources.entries);
const versionInfo = ResEdit.Resource.VersionInfo.fromEntries(resources.entries)[0];
const strings = versionInfo.getStringValues(versionInfo.getAllLanguagesForStringValues()[0]);
expect(iconGroups[0].icons).toHaveLength(6);
expect(strings.ProductName).toBe('Twitch VOD Manager');
expect(strings.FileDescription).toBe('Twitch VOD Manager');
});
});
+71
View File
@@ -0,0 +1,71 @@
import * as fs from 'fs';
import * as path from 'path';
import * as ResEdit from 'resedit';
import { writeFileAtomicSync } from './infra/fs-atomic';
export interface WindowsDevExecutableOptions {
sourcePath: string;
destinationPath: string;
iconPath: string;
version: string;
}
export async function prepareWindowsDevExecutable(options: WindowsDevExecutableOptions): Promise<string> {
const source = path.resolve(options.sourcePath);
const destination = path.resolve(options.destinationPath);
const icon = path.resolve(options.iconPath);
const stampPath = `${destination}.json`;
const sourceStats = fs.statSync(source);
const iconStats = fs.statSync(icon);
const fingerprint = JSON.stringify({
sourceSize: sourceStats.size,
sourceModified: sourceStats.mtimeMs,
iconSize: iconStats.size,
iconModified: iconStats.mtimeMs,
version: options.version
});
if (fs.existsSync(destination) && fs.existsSync(stampPath) && fs.readFileSync(stampPath, 'utf8') === fingerprint) {
return destination;
}
fs.mkdirSync(path.dirname(destination), { recursive: true });
const temporaryDestination = `${destination}.tmp`;
fs.copyFileSync(source, temporaryDestination);
try {
const executable = ResEdit.NtExecutable.from(fs.readFileSync(temporaryDestination), { ignoreCert: true });
const resources = ResEdit.NtExecutableResource.from(executable);
const versionEntries = ResEdit.Resource.VersionInfo.fromEntries(resources.entries);
const versionInfo = versionEntries[0] || ResEdit.Resource.VersionInfo.createEmpty();
const languages = versionInfo.getAllLanguagesForStringValues();
const language = languages[0] || { lang: 0x0409, codepage: 1200 };
versionInfo.setStringValues(language, {
FileDescription: 'Twitch VOD Manager',
ProductName: 'Twitch VOD Manager',
InternalName: 'Twitch VOD Manager',
OriginalFilename: 'Twitch VOD Manager.exe'
});
versionInfo.setFileVersion(options.version);
versionInfo.setProductVersion(options.version);
versionInfo.outputToResourceEntries(resources.entries);
const iconFile = ResEdit.Data.IconFile.from(fs.readFileSync(icon));
ResEdit.Resource.IconGroupEntry.replaceIconsForResource(
resources.entries,
1,
language.lang,
iconFile.icons.map((entry) => entry.data)
);
resources.outputResource(executable);
fs.writeFileSync(temporaryDestination, Buffer.from(executable.generate()));
fs.rmSync(destination, { force: true });
fs.renameSync(temporaryDestination, destination);
writeFileAtomicSync(stampPath, fingerprint);
return destination;
} catch (error) {
fs.rmSync(temporaryDestination, { force: true });
throw error;
}
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, test } from 'vitest';
import { isRendererReloadTarget } from './dev-reload';
describe('isRendererReloadTarget', () => {
test('reloads renderer output and static renderer assets', () => {
expect(isRendererReloadTarget('renderer.js')).toBe(true);
expect(isRendererReloadTarget('renderer-settings.js')).toBe(true);
expect(isRendererReloadTarget('index.html')).toBe(true);
expect(isRendererReloadTarget('styles.css')).toBe(true);
});
test('does not reload for main-process output', () => {
expect(isRendererReloadTarget('main.js')).toBe(false);
expect(isRendererReloadTarget('preload.js')).toBe(false);
expect(isRendererReloadTarget('main/domain/config.js')).toBe(false);
});
});
+31
View File
@@ -0,0 +1,31 @@
import { watch, type FSWatcher } from 'node:fs';
const staticRendererAssets = new Set(['index.html', 'styles.css', 'workspace.css']);
export function isRendererReloadTarget(fileName: string): boolean {
const normalized = fileName.replaceAll('\\', '/');
const baseName = normalized.split('/').at(-1) ?? '';
return staticRendererAssets.has(baseName) || /^renderer(?:[-.].+)?\.js$/.test(baseName);
}
export function watchRendererChanges(
outputDirectory: string,
sourceDirectory: string,
reload: () => void,
): () => void {
let reloadTimer: NodeJS.Timeout | undefined;
const scheduleReload = (fileName: string | Buffer | null): void => {
if (!fileName || !isRendererReloadTarget(fileName.toString())) return;
if (reloadTimer) clearTimeout(reloadTimer);
reloadTimer = setTimeout(reload, 125);
};
const watchers: FSWatcher[] = [
watch(outputDirectory, { recursive: true }, (_, fileName) => scheduleReload(fileName)),
watch(sourceDirectory, { recursive: true }, (_, fileName) => scheduleReload(fileName)),
];
return () => {
if (reloadTimer) clearTimeout(reloadTimer);
for (const watcher of watchers) watcher.close();
};
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, test } from 'vitest';
import { getWindowsAppIdentity } from './app-identity';
describe('getWindowsAppIdentity', () => {
test('trennt Hot-Dev von der veröffentlichten Windows-Identität', () => {
expect(getWindowsAppIdentity(true)).toEqual({
name: 'Twitch VOD Manager',
appUserModelId: 'io.github.sucukdeluxe.twitch-vod-manager.development'
});
expect(getWindowsAppIdentity(false)).toEqual({
name: 'Twitch VOD Manager',
appUserModelId: 'io.github.sucukdeluxe.twitch-vod-manager'
});
});
});
+13
View File
@@ -0,0 +1,13 @@
export interface WindowsAppIdentity {
name: string;
appUserModelId: string;
}
export function getWindowsAppIdentity(isDevelopment: boolean): WindowsAppIdentity {
return {
name: 'Twitch VOD Manager',
appUserModelId: isDevelopment
? 'io.github.sucukdeluxe.twitch-vod-manager.development'
: 'io.github.sucukdeluxe.twitch-vod-manager'
};
}
+8
View File
@@ -41,6 +41,14 @@ describe('tBackend', () => {
} }
}); });
test('German backend messages use native umlauts', () => {
const text = Object.values(BACKEND_MESSAGES.de).join('\n').toLocaleLowerCase('de-DE');
const forbidden = ['ungueltig', 'integritaetspruefung', 'fur ', 'benoetigt', 'prufe '];
for (const token of forbidden) {
expect(text).not.toContain(token);
}
});
test('no template literal left after substitution for typical params', () => { test('no template literal left after substitution for typical params', () => {
// attemptFailed has {attempt}, {max}, {errorClass}, {error} // attemptFailed has {attempt}, {max}, {errorClass}, {error}
const result = tBackend('attemptFailed', { attempt: 1, max: 3, errorClass: 'network', error: 'ETIMEDOUT' }, 'en'); const result = tBackend('attemptFailed', { attempt: 1, max: 3, errorClass: 'network', error: 'ETIMEDOUT' }, 'en');
+11 -11
View File
@@ -1,10 +1,10 @@
// Backend-Messages (User-visible aus main.ts produziert). Pure: Sprache wird // Backend-Messages (User-visible aus main.ts produziert). Pure: Sprache wird
// als Parameter uebergeben statt aus globalem config geholt. // als Parameter übergeben statt aus globalem config geholt.
export const BACKEND_MESSAGES = { export const BACKEND_MESSAGES = {
de: { de: {
invalidVodUrl: 'Ungueltige VOD-URL', invalidVodUrl: 'Ungültige VOD-URL',
invalidClipUrl: 'Ungueltige Clip-URL', invalidClipUrl: 'Ungültige Clip-URL',
clipNotFound: 'Clip nicht gefunden', clipNotFound: 'Clip nicht gefunden',
streamlinkAutoInstallFailed: 'Streamlink fehlt und konnte nicht automatisch installiert werden. Siehe debug.log.', streamlinkAutoInstallFailed: 'Streamlink fehlt und konnte nicht automatisch installiert werden. Siehe debug.log.',
streamlinkMissing: 'Streamlink fehlt.', streamlinkMissing: 'Streamlink fehlt.',
@@ -15,10 +15,10 @@ export const BACKEND_MESSAGES = {
ffmpegSplitFailed: 'FFmpeg Split fehlgeschlagen.', ffmpegSplitFailed: 'FFmpeg Split fehlgeschlagen.',
fileTooSmall: 'Datei zu klein ({bytes} Bytes)', fileTooSmall: 'Datei zu klein ({bytes} Bytes)',
clipFileTooSmall: 'Clip-Datei zu klein ({bytes} Bytes) - Twitch hat den Stream evtl. nicht ausgeliefert.', clipFileTooSmall: 'Clip-Datei zu klein ({bytes} Bytes) - Twitch hat den Stream evtl. nicht ausgeliefert.',
integrityNoVideo: 'Integritaetspruefung fehlgeschlagen: Kein Videostream gefunden.', integrityNoVideo: 'Integritätsprüfung fehlgeschlagen: Kein Videostream gefunden.',
integrityTooShort: 'Integritaetspruefung fehlgeschlagen: Dauer zu kurz ({duration}s).', integrityTooShort: 'Integritätsprüfung fehlgeschlagen: Dauer zu kurz ({duration}s).',
integrityDurationMismatch: 'Integritaetspruefung fehlgeschlagen: {actual}s statt erwarteter ~{expected}s.', integrityDurationMismatch: 'Integritätsprüfung fehlgeschlagen: {actual}s statt erwarteter ~{expected}s.',
integrityFailedGeneric: 'Integritaetspruefung fehlgeschlagen.', integrityFailedGeneric: 'Integritätsprüfung fehlgeschlagen.',
downloadCancelled: 'Download wurde abgebrochen.', downloadCancelled: 'Download wurde abgebrochen.',
downloadPaused: 'Download wurde pausiert.', downloadPaused: 'Download wurde pausiert.',
downloadFailedExitCode: 'Download fehlgeschlagen (Exit-Code {code})', downloadFailedExitCode: 'Download fehlgeschlagen (Exit-Code {code})',
@@ -26,12 +26,12 @@ export const BACKEND_MESSAGES = {
notAllClipPartsDownloaded: 'Nicht alle Clip-Teile konnten heruntergeladen werden.', notAllClipPartsDownloaded: 'Nicht alle Clip-Teile konnten heruntergeladen werden.',
notAllPartsDownloaded: 'Nicht alle Teile konnten heruntergeladen werden.', notAllPartsDownloaded: 'Nicht alle Teile konnten heruntergeladen werden.',
mergeGroupFileMissing: 'Heruntergeladene Datei {index} fehlt.', mergeGroupFileMissing: 'Heruntergeladene Datei {index} fehlt.',
diskSpaceShortFor: 'Zu wenig Speicherplatz fur {context}: frei {free}, benoetigt ~{required}.', diskSpaceShortFor: 'Zu wenig Speicherplatz für {context}: frei {free}, benötigt ~{required}.',
diskSpaceShortGeneric: 'Zu wenig Speicherplatz.', diskSpaceShortGeneric: 'Zu wenig Speicherplatz.',
attemptFailed: 'Versuch {attempt}/{max} fehlgeschlagen ({errorClass}): {error}', attemptFailed: 'Versuch {attempt}/{max} fehlgeschlagen ({errorClass}): {error}',
retryingIn: 'Neuer Versuch in {seconds}s ({errorClass})...', retryingIn: 'Neuer Versuch in {seconds}s ({errorClass})...',
statusCheckingTools: 'Prufe Download-Tools...', statusCheckingTools: 'Prüfe Download-Tools...',
statusDownloadStarted: 'Download gestartet', statusDownloadStarted: 'Download wird gestartet',
statusBytesDownloaded: '{bytes} heruntergeladen', statusBytesDownloaded: '{bytes} heruntergeladen',
statusFetchingChatReplay: 'Chat-Replay wird heruntergeladen...', statusFetchingChatReplay: 'Chat-Replay wird heruntergeladen...',
statusChatMessagesFetched: 'Chat-Nachrichten geladen: {count}', statusChatMessagesFetched: 'Chat-Nachrichten geladen: {count}',
@@ -70,7 +70,7 @@ export const BACKEND_MESSAGES = {
attemptFailed: 'Attempt {attempt}/{max} failed ({errorClass}): {error}', attemptFailed: 'Attempt {attempt}/{max} failed ({errorClass}): {error}',
retryingIn: 'Retrying in {seconds}s ({errorClass})...', retryingIn: 'Retrying in {seconds}s ({errorClass})...',
statusCheckingTools: 'Checking download tools...', statusCheckingTools: 'Checking download tools...',
statusDownloadStarted: 'Download started', statusDownloadStarted: 'Starting download ',
statusBytesDownloaded: '{bytes} downloaded', statusBytesDownloaded: '{bytes} downloaded',
statusFetchingChatReplay: 'Fetching chat replay...', statusFetchingChatReplay: 'Fetching chat replay...',
statusChatMessagesFetched: 'Chat messages fetched: {count}', statusChatMessagesFetched: 'Chat messages fetched: {count}',
+1 -1
View File
@@ -36,7 +36,7 @@ const CONFIG_KV_KEYS = [
'auto_record_poll_seconds', 'filename_template_vod', 'filename_template_parts', 'auto_record_poll_seconds', 'filename_template_vod', 'filename_template_parts',
'filename_template_clip', 'smart_queue_scheduler', 'prevent_duplicate_downloads', 'filename_template_clip', 'smart_queue_scheduler', 'prevent_duplicate_downloads',
'persist_queue_on_restart', 'auto_resume_queue_on_startup', 'persist_queue_on_restart', 'auto_resume_queue_on_startup',
'notify_on_each_completion', 'notify_on_each_completion', 'sidebar_split_view',
] as const; ] as const;
function backupOnce(srcPath: string): void { function backupOnce(srcPath: string): void {
+64
View File
@@ -0,0 +1,64 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { PartialDownloadRegistry } from './partial-download';
let tempDirectory: string;
let registryPath: string;
beforeEach(() => {
tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-partial-'));
registryPath = path.join(tempDirectory, 'partial-downloads.json');
});
afterEach(() => {
fs.rmSync(tempDirectory, { recursive: true, force: true });
});
describe('PartialDownloadRegistry', () => {
it('veröffentlicht erst nach erfolgreichem Commit den endgültigen Dateinamen', () => {
const registry = new PartialDownloadRegistry(registryPath);
const finalPath = path.join(tempDirectory, 'video.mp4');
const partialPath = registry.begin(finalPath);
fs.writeFileSync(partialPath, 'vollständig');
expect(partialPath).toBe(`${finalPath}.tvm-part`);
expect(fs.existsSync(finalPath)).toBe(false);
registry.commit(partialPath, finalPath);
expect(fs.readFileSync(finalPath, 'utf8')).toBe('vollständig');
expect(fs.existsSync(partialPath)).toBe(false);
expect(fs.existsSync(registryPath)).toBe(false);
});
it('entfernt eine abgebrochene Teil-Datei', () => {
const registry = new PartialDownloadRegistry(registryPath);
const finalPath = path.join(tempDirectory, 'abbruch.mp4');
const partialPath = registry.begin(finalPath);
fs.writeFileSync(partialPath, 'unvollständig');
registry.discard(partialPath);
expect(fs.existsSync(partialPath)).toBe(false);
expect(fs.existsSync(finalPath)).toBe(false);
expect(fs.existsSync(registryPath)).toBe(false);
});
it('räumt nach einem simulierten Crash registrierte Teil-Dateien beim nächsten Start auf', () => {
const firstRun = new PartialDownloadRegistry(registryPath);
const finalPath = path.join(tempDirectory, 'crash.mp4');
const partialPath = firstRun.begin(finalPath);
fs.writeFileSync(partialPath, 'unvollständig');
const secondRun = new PartialDownloadRegistry(registryPath);
const removed = secondRun.cleanup();
expect(removed).toEqual([partialPath]);
expect(fs.existsSync(partialPath)).toBe(false);
expect(fs.existsSync(finalPath)).toBe(false);
expect(fs.existsSync(registryPath)).toBe(false);
});
});
+83
View File
@@ -0,0 +1,83 @@
import * as fs from 'fs';
import * as path from 'path';
import { writeFileAtomicSync } from '../infra/fs-atomic';
const PARTIAL_SUFFIX = '.tvm-part';
export class PartialDownloadRegistry {
private readonly paths = new Set<string>();
constructor(private readonly registryPath: string) {
this.load(this.registryPath);
this.load(`${this.registryPath}.tmp`);
}
begin(finalPath: string): string {
const partialPath = path.resolve(`${finalPath}${PARTIAL_SUFFIX}`);
if (fs.existsSync(partialPath)) fs.rmSync(partialPath, { force: true });
this.paths.add(partialPath);
this.persist();
return partialPath;
}
commit(partialPath: string, finalPath: string): void {
const normalizedPartialPath = this.normalize(partialPath);
const normalizedFinalPath = path.resolve(finalPath);
if (!fs.existsSync(normalizedPartialPath)) throw new Error(`Teil-Datei fehlt: ${normalizedPartialPath}`);
if (fs.existsSync(normalizedFinalPath)) throw new Error(`Zieldatei existiert bereits: ${normalizedFinalPath}`);
fs.renameSync(normalizedPartialPath, normalizedFinalPath);
this.paths.delete(normalizedPartialPath);
this.persist();
}
discard(partialPath: string): void {
const normalizedPartialPath = this.normalize(partialPath);
if (fs.existsSync(normalizedPartialPath)) fs.rmSync(normalizedPartialPath, { force: true });
this.paths.delete(normalizedPartialPath);
this.persist();
}
cleanup(): string[] {
const removed: string[] = [];
for (const partialPath of this.paths) {
if (fs.existsSync(partialPath)) {
fs.rmSync(partialPath, { force: true });
removed.push(partialPath);
}
}
this.paths.clear();
this.persist();
return removed;
}
private normalize(filePath: string): string {
const normalizedPath = path.resolve(filePath);
if (!normalizedPath.endsWith(PARTIAL_SUFFIX)) throw new Error(`Ungültiger Teil-Dateipfad: ${normalizedPath}`);
return normalizedPath;
}
private load(filePath: string): void {
try {
if (!fs.existsSync(filePath)) return;
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
const values = Array.isArray(parsed) ? parsed : parsed?.paths;
if (!Array.isArray(values)) return;
for (const value of values) {
if (typeof value !== 'string') continue;
try {
this.paths.add(this.normalize(value));
} catch { }
}
} catch { }
}
private persist(): void {
if (this.paths.size === 0) {
fs.rmSync(this.registryPath, { force: true });
fs.rmSync(`${this.registryPath}.tmp`, { force: true });
return;
}
fs.mkdirSync(path.dirname(this.registryPath), { recursive: true });
writeFileAtomicSync(this.registryPath, JSON.stringify({ paths: [...this.paths] }, null, 2));
}
}
+89
View File
@@ -0,0 +1,89 @@
import { PassThrough, Writable } from 'stream';
import { describe, expect, it } from 'vitest';
import { createPausableOutput } from './pausable-output';
function waitForTurn(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve));
}
describe('createPausableOutput', () => {
it('setzt denselben Ausgabestrom ohne Datenverlust fort', async () => {
const source = new PassThrough();
const chunks: Buffer[] = [];
const target = new Writable({
write(chunk, _encoding, callback) {
chunks.push(Buffer.from(chunk));
callback();
}
});
const output = createPausableOutput(source, target);
source.write('erster-');
await waitForTurn();
output.pause();
source.write('zweiter-');
await waitForTurn();
expect(Buffer.concat(chunks).toString()).toBe('erster-');
output.resume();
source.end('dritter');
await output.finished;
expect(Buffer.concat(chunks).toString()).toBe('erster-zweiter-dritter');
expect(output.isPaused()).toBe(false);
});
it('schließt einen während der Pause beendeten Quellstrom erst nach dem Fortsetzen ab', async () => {
const source = new PassThrough();
const chunks: Buffer[] = [];
const target = new Writable({
write(chunk, _encoding, callback) {
chunks.push(Buffer.from(chunk));
callback();
}
});
const output = createPausableOutput(source, target);
let finished = false;
void output.finished.then(() => {
finished = true;
});
source.write('vorher-');
await waitForTurn();
output.pause();
source.end('nachher');
await waitForTurn();
expect(finished).toBe(false);
expect(Buffer.concat(chunks).toString()).toBe('vorher-');
output.resume();
await output.finished;
expect(Buffer.concat(chunks).toString()).toBe('vorher-nachher');
});
it('schließt Quell- und Zielstrom bei einem Abbruch zuverlässig', async () => {
const source = new PassThrough();
const chunks: Buffer[] = [];
const target = new Writable({
write(chunk, _encoding, callback) {
chunks.push(Buffer.from(chunk));
callback();
}
});
const output = createPausableOutput(source, target);
source.write('behalten');
await waitForTurn();
output.pause();
await output.cancel();
await output.finished;
expect(source.destroyed).toBe(true);
expect(target.destroyed).toBe(true);
expect(Buffer.concat(chunks).toString()).toBe('behalten');
});
});
+74
View File
@@ -0,0 +1,74 @@
import { Readable, Writable } from 'stream';
export interface PausableOutput {
pause(): void;
resume(): void;
cancel(): Promise<void>;
isPaused(): boolean;
finished: Promise<void>;
}
export function createPausableOutput(source: Readable, target: Writable): PausableOutput {
let paused = false;
let settled = false;
let resolveFinished: () => void = () => {};
let rejectFinished: (error: Error) => void = () => {};
let resolveClosed: () => void = () => {};
const finished = new Promise<void>((resolve, reject) => {
resolveFinished = resolve;
rejectFinished = reject;
});
const closed = new Promise<void>((resolve) => {
resolveClosed = resolve;
});
const attach = () => source.pipe(target, { end: false });
const finish = () => {
if (!settled) target.end();
};
target.once('finish', () => {
settled = true;
resolveFinished();
});
target.once('close', () => {
resolveClosed();
if (!settled) {
settled = true;
resolveFinished();
}
});
target.once('error', (error) => {
settled = true;
source.destroy(error);
rejectFinished(error);
});
source.once('end', finish);
source.once('error', (error) => target.destroy(error));
attach();
return {
pause() {
if (paused || settled) return;
paused = true;
source.unpipe(target);
source.pause();
},
resume() {
if (!paused || settled) return;
paused = false;
attach();
source.resume();
},
async cancel() {
if (!settled) {
paused = false;
source.unpipe(target);
source.destroy();
target.destroy();
}
await closed;
},
isPaused: () => paused,
finished
};
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, test } from 'vitest';
import { buildVodPreviewFrameUrls } from './vod-preview';
describe('buildVodPreviewFrameUrls', () => {
test('builds four full-HD frame URLs from a Twitch thumb0 URL', () => {
const input = 'https://static-cdn.jtvnw.net/cf_vods/example/thumb/thumb0-1920x1080.jpg';
expect(buildVodPreviewFrameUrls(input)).toEqual([
'https://static-cdn.jtvnw.net/cf_vods/example/thumb/thumb0-1920x1080.jpg',
'https://static-cdn.jtvnw.net/cf_vods/example/thumb/thumb1-1920x1080.jpg',
'https://static-cdn.jtvnw.net/cf_vods/example/thumb/thumb2-1920x1080.jpg',
'https://static-cdn.jtvnw.net/cf_vods/example/thumb/thumb3-1920x1080.jpg'
]);
});
test('rejects URLs that are not Twitch full-HD VOD thumbnails', () => {
expect(buildVodPreviewFrameUrls('https://example.invalid/thumb0-320x180.jpg')).toEqual([]);
});
});
+20
View File
@@ -0,0 +1,20 @@
const TWITCH_VOD_THUMBNAIL_PATTERN = /thumb0-1920x1080\.jpg$/;
export function buildVodPreviewFrameUrls(thumbnailUrl: string): string[] {
let parsed: URL;
try {
parsed = new URL(thumbnailUrl);
} catch {
return [];
}
if (parsed.protocol !== 'https:' || parsed.hostname !== 'static-cdn.jtvnw.net' || !TWITCH_VOD_THUMBNAIL_PATTERN.test(parsed.pathname)) {
return [];
}
return [0, 1, 2, 3].map((index) => {
const frameUrl = new URL(parsed.toString());
frameUrl.pathname = frameUrl.pathname.replace('thumb0-1920x1080.jpg', `thumb${index}-1920x1080.jpg`);
return frameUrl.toString();
});
}
+1 -1
View File
@@ -32,7 +32,7 @@ const DEFAULT_SUCCESS = `<!doctype html><html><head><meta charset="utf-8"><title
<style>body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0e0e10;color:#efeff1;display:flex;align-items:center;justify-content:center;height:100vh;margin:0} <style>body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0e0e10;color:#efeff1;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
.box{text-align:center;padding:2rem 3rem;background:#1f1f23;border-radius:8px} .box{text-align:center;padding:2rem 3rem;background:#1f1f23;border-radius:8px}
h1{color:#9146FF;margin:0 0 0.5rem}</style></head> h1{color:#9146FF;margin:0 0 0.5rem}</style></head>
<body><div class="box"><h1>Login erfolgreich</h1><p>Du kannst dieses Fenster jetzt schliessen.</p></div></body></html>`; <body><div class="box"><h1>Login erfolgreich</h1><p>Du kannst dieses Fenster jetzt schließen.</p></div></body></html>`;
const DEFAULT_ERROR = `<!doctype html><html><head><meta charset="utf-8"><title>Fehler</title> const DEFAULT_ERROR = `<!doctype html><html><head><meta charset="utf-8"><title>Fehler</title>
<style>body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0e0e10;color:#efeff1;display:flex;align-items:center;justify-content:center;height:100vh;margin:0} <style>body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0e0e10;color:#efeff1;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
+4
View File
@@ -93,6 +93,7 @@ contextBridge.exposeInMainWorld('api', {
getStorageStats: () => ipcRenderer.invoke('get-storage-stats'), getStorageStats: () => ipcRenderer.invoke('get-storage-stats'),
getArchiveStats: () => ipcRenderer.invoke('get-archive-stats'), getArchiveStats: () => ipcRenderer.invoke('get-archive-stats'),
getStreamerProfile: (login: string, forceRefresh?: boolean) => ipcRenderer.invoke('get-streamer-profile', login, forceRefresh), getStreamerProfile: (login: string, forceRefresh?: boolean) => ipcRenderer.invoke('get-streamer-profile', login, forceRefresh),
getStreamerDisplayNames: (logins: string[]) => ipcRenderer.invoke('get-streamer-display-names', logins),
getVodStoryboard: (vodId: string) => ipcRenderer.invoke('get-vod-storyboard', vodId), getVodStoryboard: (vodId: string) => ipcRenderer.invoke('get-vod-storyboard', vodId),
getLiveStatusSnapshot: () => ipcRenderer.invoke('get-live-status-snapshot'), getLiveStatusSnapshot: () => ipcRenderer.invoke('get-live-status-snapshot'),
onLiveStatusBatchUpdate: (callback: (info: { changes: Array<{ login: string; isLive: boolean }> }) => void) => { onLiveStatusBatchUpdate: (callback: (info: { changes: Array<{ login: string; isLive: boolean }> }) => void) => {
@@ -151,6 +152,9 @@ contextBridge.exposeInMainWorld('api', {
onDownloadStarted: (callback: () => void) => { onDownloadStarted: (callback: () => void) => {
ipcRenderer.on('download-started', () => callback()); ipcRenderer.on('download-started', () => callback());
}, },
onDownloadPaused: (callback: () => void) => {
ipcRenderer.on('download-paused', () => callback());
},
onDownloadFinished: (callback: () => void) => { onDownloadFinished: (callback: () => void) => {
ipcRenderer.on('download-finished', () => callback()); ipcRenderer.on('download-finished', () => callback());
}, },
+7
View File
@@ -3,10 +3,12 @@ interface AppConfig {
client_secret?: string; client_secret?: string;
download_path?: string; download_path?: string;
streamers?: string[]; streamers?: string[];
streamer_display_names?: Record<string, string>;
theme?: string; theme?: string;
download_mode?: 'parts' | 'full'; download_mode?: 'parts' | 'full';
part_minutes?: number; part_minutes?: number;
language?: 'de' | 'en'; language?: 'de' | 'en';
sidebar_split_view?: boolean;
filename_template_vod?: string; filename_template_vod?: string;
filename_template_parts?: string; filename_template_parts?: string;
filename_template_clip?: string; filename_template_clip?: string;
@@ -257,6 +259,9 @@ interface StreamerProfile {
interface VodStoryboard { interface VodStoryboard {
vodId: string; vodId: string;
spriteDataUrl: string; spriteDataUrl: string;
frameDataUrls: string[];
frameWidth: number;
frameHeight: number;
cols: number; cols: number;
rows: number; rows: number;
cellWidth: number; cellWidth: number;
@@ -346,6 +351,7 @@ interface ApiBridge {
getStorageStats(): Promise<StorageStatsResult>; getStorageStats(): Promise<StorageStatsResult>;
getArchiveStats(): Promise<ArchiveStats>; getArchiveStats(): Promise<ArchiveStats>;
getStreamerProfile(login: string, forceRefresh?: boolean): Promise<StreamerProfile | null>; getStreamerProfile(login: string, forceRefresh?: boolean): Promise<StreamerProfile | null>;
getStreamerDisplayNames(logins: string[]): Promise<Record<string, string>>;
getVodStoryboard(vodId: string): Promise<VodStoryboard | null>; getVodStoryboard(vodId: string): Promise<VodStoryboard | null>;
getLiveStatusSnapshot(): Promise<Record<string, boolean>>; getLiveStatusSnapshot(): Promise<Record<string, boolean>>;
onLiveStatusBatchUpdate(callback: (info: { changes: Array<{ login: string; isLive: boolean }> }) => void): void; onLiveStatusBatchUpdate(callback: (info: { changes: Array<{ login: string; isLive: boolean }> }) => void): void;
@@ -388,6 +394,7 @@ interface ApiBridge {
onQueueUpdated(callback: (queue: QueueItem[]) => void): void; onQueueUpdated(callback: (queue: QueueItem[]) => void): void;
onQueueDuplicateSkipped(callback: (payload: { title: string; streamer: string; url: string }) => void): void; onQueueDuplicateSkipped(callback: (payload: { title: string; streamer: string; url: string }) => void): void;
onDownloadStarted(callback: () => void): void; onDownloadStarted(callback: () => void): void;
onDownloadPaused(callback: () => void): void;
onDownloadFinished(callback: () => void): void; onDownloadFinished(callback: () => void): void;
onCutProgress(callback: (percent: number) => void): void; onCutProgress(callback: (percent: number) => void): void;
onMergeProgress(callback: (percent: number) => void): void; onMergeProgress(callback: (percent: number) => void): void;
+130 -117
View File
@@ -4,7 +4,7 @@ const UI_TEXT_DE = {
navVods: 'Twitch VODs', navVods: 'Twitch VODs',
navClips: 'Twitch Clips', navClips: 'Twitch Clips',
navCutter: 'Video schneiden', navCutter: 'Video schneiden',
navMerge: 'Videos zusammenfugen', navMerge: 'Videos zusammenfügen',
navSettings: 'Einstellungen', navSettings: 'Einstellungen',
queueTitle: 'Warteschlange', queueTitle: 'Warteschlange',
retryFailed: 'Wiederholen', retryFailed: 'Wiederholen',
@@ -14,7 +14,7 @@ const UI_TEXT_DE = {
healthWarn: 'System: Warnung', healthWarn: 'System: Warnung',
healthBad: 'System: Problem', healthBad: 'System: Problem',
systemStatus: 'Systemstatus', systemStatus: 'Systemstatus',
openSettings: 'Einstellungen offnen', openSettings: 'Einstellungen öffnen',
refreshVods: 'VODs aktualisieren', refreshVods: 'VODs aktualisieren',
downloadClip: 'Clip herunterladen', downloadClip: 'Clip herunterladen',
mainNavigationAria: 'Hauptnavigation', mainNavigationAria: 'Hauptnavigation',
@@ -32,12 +32,12 @@ const UI_TEXT_DE = {
settingsSearchPlaceholder: 'Einstellungen durchsuchen…', settingsSearchPlaceholder: 'Einstellungen durchsuchen…',
clearQueue: 'Leeren', clearQueue: 'Leeren',
refresh: 'Aktualisieren', refresh: 'Aktualisieren',
streamerPlaceholder: 'Streamer hinzufugen…', streamerPlaceholder: 'Streamer hinzufügen…',
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 auswählen',
cutterPreviewPlaceholder: 'Video auswahlen um Vorschau zu sehen', cutterPreviewPlaceholder: 'Video auswählen, um eine Vorschau zu sehen',
cutterBrowse: 'Durchsuchen', cutterBrowse: 'Durchsuchen',
commandPaletteTitle: 'Befehlspalette', commandPaletteTitle: 'Befehlspalette',
commandPaletteAria: 'Befehlspalette', commandPaletteAria: 'Befehlspalette',
@@ -54,10 +54,10 @@ const UI_TEXT_DE = {
settings: { label: 'Einstellungen', keywords: 'einstellungen settings config' } 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: 'Auf/Ab zum Navigieren, Enter zum Ausführen, Esc zum Schließen',
mergeTitle: 'Videos zusammenfugen', mergeTitle: 'Videos zusammenfügen',
mergeDesc: 'Wahle mehrere Videos aus, um sie zu einem Video zusammenzufugen. Die Reihenfolge kann geandert werden.', mergeDesc: 'Wähle mehrere Videos aus, um sie zu einem Video zusammenzufügen. Die Reihenfolge kann geändert werden.',
mergeAdd: '+ Videos hinzufugen', mergeAdd: '+ Videos hinzufügen',
designTitle: 'Design', designTitle: 'Design',
themeLabel: 'Theme', themeLabel: 'Theme',
themeLight: 'Hell', themeLight: 'Hell',
@@ -65,12 +65,15 @@ const UI_TEXT_DE = {
themeSystem: 'System', themeSystem: 'System',
languageLabel: 'Sprache', languageLabel: 'Sprache',
languageDe: 'Deutsch', languageDe: 'Deutsch',
languageEn: 'Englisch', languageEn: 'English',
apiTitle: 'Twitch API', apiTitle: 'Twitch API',
clientIdLabel: 'Client ID', clientIdLabel: 'Client ID',
clientSecretLabel: 'Client Secret', clientSecretLabel: 'Client Secret',
saveSettings: 'Speichern & Verbinden', saveSettings: 'Speichern & Verbinden',
downloadSettingsTitle: 'Download-Einstellungen', downloadSettingsTitle: 'Download-Einstellungen',
downloadBasicsTitle: 'Download und Qualität',
queueAutomationTitle: 'Queue und Automatisierung',
recordingMetadataTitle: 'Aufnahmen und Metadaten',
storageLabel: 'Speicherort', storageLabel: 'Speicherort',
selectFolder: 'Ordner', selectFolder: 'Ordner',
openFolder: 'Offnen', openFolder: 'Offnen',
@@ -86,11 +89,13 @@ const UI_TEXT_DE = {
performanceModeBalanced: 'Ausgewogen', performanceModeBalanced: 'Ausgewogen',
performanceModeSpeed: 'Max Geschwindigkeit', performanceModeSpeed: 'Max Geschwindigkeit',
smartSchedulerLabel: 'Smart Queue Scheduler aktivieren', smartSchedulerLabel: 'Smart Queue Scheduler aktivieren',
smartSchedulerHint: 'Bevorzugt kuerzere VODs und aeltere Queue-Eintraege zuerst, damit der Durchsatz gleichmaessig bleibt. Deaktivieren = strikte Einfuegereihenfolge.', smartSchedulerHint: 'Bevorzugt kürzere VODs und ältere Queue-Einträge zuerst, damit der Durchsatz gleichmäßig bleibt. Deaktivieren = strikte Einfügereihenfolge.',
streamerInvalid: 'Twitch-Username ungueltig (4-25 Zeichen, Buchstaben/Zahlen/Unterstrich).', sidebarSplitViewLabel: 'Streamer und Queue gleichzeitig anzeigen',
sidebarSplitViewHint: 'Teilt die linke Seitenleiste gleichmäßig auf. Deaktiviert erscheinen wieder die Umschaltflächen.',
streamerInvalid: 'Twitch-Username ungültig (4-25 Zeichen, Buchstaben/Zahlen/Unterstrich).',
apiHelpIntro: 'Du brauchst eine Client-ID und ein Client-Secret von Twitch.', apiHelpIntro: 'Du brauchst eine Client-ID und ein Client-Secret von Twitch.',
apiHelpLinkText: 'dev.twitch.tv/console/apps', apiHelpLinkText: 'dev.twitch.tv/console/apps',
openDebugLogFile: 'Log-Datei oeffnen', openDebugLogFile: 'Log-Datei öffnen',
storageCardTitle: 'Speicher', storageCardTitle: 'Speicher',
storageCardIntro: 'Disk-Verbrauch pro Streamer im aktuellen Download-Ordner. Live-Aufnahmen werden separat ausgewiesen.', storageCardIntro: 'Disk-Verbrauch pro Streamer im aktuellen Download-Ordner. Live-Aufnahmen werden separat ausgewiesen.',
storageRefresh: 'Aktualisieren', storageRefresh: 'Aktualisieren',
@@ -103,10 +108,10 @@ const UI_TEXT_DE = {
storageColumnLive: 'Live', storageColumnLive: 'Live',
storageColumnChat: 'Chat', storageColumnChat: 'Chat',
storageColumnActionsAria: 'Aktionen', storageColumnActionsAria: 'Aktionen',
storageOpen: 'Oeffnen', storageOpen: 'Öffnen',
storageOtherFolders: 'Andere Ordner im Download-Pfad', storageOtherFolders: 'Andere Ordner im Download-Pfad',
cleanupTitle: 'Auto-Cleanup', cleanupTitle: 'Auto-Cleanup',
cleanupIntro: 'Aufnahmen aelter als X Tage in einen Archiv-Ordner verschieben oder loeschen. Sidecar-Chat-Dateien (.chat.json/.chat.jsonl) werden mit der Aufnahme bewegt.', cleanupIntro: 'Aufnahmen älter als X Tage in einen Archiv-Ordner verschieben oder löschen. Sidecar-Chat-Dateien (.chat.json/.chat.jsonl) werden mit der Aufnahme bewegt.',
cleanupEnabledLabel: 'Auto-Cleanup aktivieren', cleanupEnabledLabel: 'Auto-Cleanup aktivieren',
cleanupDaysLabel: 'Tage-Schwelle', cleanupDaysLabel: 'Tage-Schwelle',
cleanupTargetLabel: 'Bereich', cleanupTargetLabel: 'Bereich',
@@ -114,42 +119,42 @@ const UI_TEXT_DE = {
cleanupTargetAll: 'Alle Aufnahmen', cleanupTargetAll: 'Alle Aufnahmen',
cleanupActionLabel: 'Aktion', cleanupActionLabel: 'Aktion',
cleanupActionArchive: 'In Archiv verschieben', cleanupActionArchive: 'In Archiv verschieben',
cleanupActionDelete: 'Loeschen', cleanupActionDelete: 'Löschen',
cleanupDryRun: 'Vorschau', cleanupDryRun: 'Vorschau',
cleanupRunNow: 'Jetzt ausfuehren', cleanupRunNow: 'Jetzt ausführen',
cleanupReportPreview: 'Wuerde {count} Dateien betreffen (~{size}). Es wurden keine Dateien verschoben oder geloescht.', cleanupReportPreview: 'Würde {count} Dateien betreffen (~{size}). Es wurden keine Dateien verschoben oder gelöscht.',
cleanupReportDone: '{count} Dateien verarbeitet, ~{size} frei.{failed}', cleanupReportDone: '{count} Dateien verarbeitet, ~{size} frei.{failed}',
cleanupReportFailedSuffix: ' {failed} fehlgeschlagen.', cleanupReportFailedSuffix: ' {failed} fehlgeschlagen.',
cleanupReportEmpty: 'Keine Aufnahmen aelter als {days} Tage gefunden.', cleanupReportEmpty: 'Keine Aufnahmen älter als {days} Tage gefunden.',
discordCardTitle: 'Discord-Webhook', discordCardTitle: 'Discord-Webhook',
discordCardIntro: 'Sende Benachrichtigungen an einen Discord-Channel via Webhook - nuetzlich fuer Multi-Device-Setups oder eine dedizierte Archiv-Maschine.', discordCardIntro: 'Sende Benachrichtigungen an einen Discord-Channel via Webhook - nützlich für Multi-Device-Setups oder eine dedizierte Archiv-Maschine.',
discordWebhookUrlLabel: 'Webhook-URL', discordWebhookUrlLabel: 'Webhook-URL',
discordNotifyLiveStartLabel: 'Bei Live-Aufnahme-Start benachrichtigen', discordNotifyLiveStartLabel: 'Bei Live-Aufnahme-Start benachrichtigen',
discordNotifyLiveEndLabel: 'Bei Live-Aufnahme-Ende benachrichtigen', discordNotifyLiveEndLabel: 'Bei Live-Aufnahme-Ende benachrichtigen',
discordNotifyVodAutoQueuedLabel: 'Bei automatisch eingereihten VODs benachrichtigen', discordNotifyVodAutoQueuedLabel: 'Bei automatisch eingereihten VODs benachrichtigen',
autoResumeLiveRecordingLabel: 'Live-Aufnahme automatisch fortsetzen wenn Streamlink abbricht (max. 5 Versuche)', autoResumeLiveRecordingLabel: 'Live-Aufnahme automatisch fortsetzen wenn Streamlink abbricht (max. 5 Versuche)',
autoMergeResumedPartsLabel: 'Fortgesetzte Aufnahme-Parts automatisch zu einer Datei zusammenfuegen (ffmpeg concat, kein Re-Encode)', autoMergeResumedPartsLabel: 'Fortgesetzte Aufnahme-Parts automatisch zu einer Datei zusammenfügen (ffmpeg concat, kein Re-Encode)',
deletePartsAfterMergeLabel: 'Einzelne Parts nach erfolgreichem Merge loeschen', deletePartsAfterMergeLabel: 'Einzelne Parts nach erfolgreichem Merge löschen',
autoVodCardTitle: 'Auto-VOD-Download', autoVodCardTitle: 'Auto-VOD-Download',
autoVodCardIntro: 'Streamer mit aktiviertem VOD-Toggle werden in dem hier festgelegten Intervall auf neue Twitch-VODs geprueft. Neue VODs innerhalb des Alters-Fensters werden automatisch zur Download-Queue hinzugefuegt.', autoVodCardIntro: 'Streamer mit aktiviertem VOD-Toggle werden in dem hier festgelegten Intervall auf neue Twitch-VODs geprüft. Neue VODs innerhalb des Altersfensters werden automatisch zur Download-Queue hinzugefügt.',
autoVodPollMinutesLabel: 'Poll-Intervall (Minuten)', autoVodPollMinutesLabel: 'Poll-Intervall (Minuten)',
autoVodMaxAgeHoursLabel: 'Max. Alter (Stunden)', autoVodMaxAgeHoursLabel: 'Max. Alter (Stunden)',
autoVodScanNow: 'Jetzt scannen', autoVodScanNow: 'Jetzt scannen',
autoRecordScanNow: 'Live-Status pruefen', autoRecordScanNow: 'Live-Status prüfen',
statsTitle: 'Archiv-Statistik', statsTitle: 'Archiv-Statistik',
statsIntro: 'Aggregiert ueber den Download-Ordner. Live-Aufnahmen liegen unter <code>{streamer}/live/</code>, VOD-Downloads direkt unter <code>{streamer}/</code>. Lade-Zeit skaliert mit der Anzahl Dateien.', statsIntro: 'Aggregiert über den Download-Ordner. Live-Aufnahmen liegen unter <code>{streamer}/live/</code>, VOD-Downloads direkt unter <code>{streamer}/</code>. Lade-Zeit skaliert mit der Anzahl Dateien.',
statsRefresh: 'Aktualisieren', statsRefresh: 'Aktualisieren',
statsScanning: 'Scanne...', statsScanning: 'Scanne...',
statsScannedAt: 'Letzter Scan', statsScannedAt: 'Letzter Scan',
statsSummaryTitle: 'Uebersicht', statsSummaryTitle: 'Übersicht',
statsTopStreamersTitle: 'Top Streamer (nach Groesse)', statsTopStreamersTitle: 'Top Streamer (nach Größe)',
statsActivityTitle: 'Aktivitaet (letzte 30 Tage)', statsActivityTitle: 'Aktivität (letzte 30 Tage)',
statsSizeBucketsTitle: 'Aufnahme-Groessen-Verteilung', statsSizeBucketsTitle: 'Aufnahme-Größen-Verteilung',
statsTotalRecordings: 'Aufnahmen gesamt', statsTotalRecordings: 'Aufnahmen gesamt',
statsLiveRecordings: 'Live-Aufnahmen', statsLiveRecordings: 'Live-Aufnahmen',
statsVodRecordings: 'VOD-Downloads', statsVodRecordings: 'VOD-Downloads',
statsStreamers: 'Streamer', statsStreamers: 'Streamer',
statsAvgSize: 'Durchschn. Groesse', statsAvgSize: 'Durchschn. Größe',
statsChatFiles: 'Chat-Dateien', statsChatFiles: 'Chat-Dateien',
statsFiles: 'Dateien', statsFiles: 'Dateien',
statsActivityEmpty: 'Keine Aufnahmen in den letzten 30 Tagen.', statsActivityEmpty: 'Keine Aufnahmen in den letzten 30 Tagen.',
@@ -159,65 +164,65 @@ const UI_TEXT_DE = {
navStats: 'Statistik', navStats: 'Statistik',
navArchive: 'Archiv', navArchive: 'Archiv',
archiveTitle: 'Archiv durchsuchen', archiveTitle: 'Archiv durchsuchen',
archiveIntro: 'Suche nach Dateinamen, Streamern oder Datum-Strings. Treffer zeigen Recordings (Live + VOD); zugehoerige Chat- und Events-Dateien werden als Companion-Buttons angeboten.', archiveIntro: 'Suche nach Dateinamen, Streamern oder Datumsangaben. Treffer zeigen Aufnahmen (Live + VOD); zugehörige Chat- und Event-Dateien werden als zusätzliche Schaltflächen angeboten.',
archiveAllTypes: 'Alle Typen', archiveAllTypes: 'Alle Typen',
archiveTypeLive: 'Live-Aufnahmen', archiveTypeLive: 'Live-Aufnahmen',
archiveTypeVod: 'VOD-Downloads', archiveTypeVod: 'VOD-Downloads',
archiveAllStreamers: 'Alle Streamer', archiveAllStreamers: 'Alle Streamer',
archiveSortDateDesc: 'Neueste zuerst', archiveSortDateDesc: 'Neueste zuerst',
archiveSortDateAsc: 'Aelteste zuerst', archiveSortDateAsc: 'Älteste zuerst',
archiveSortSizeDesc: 'Groesste zuerst', archiveSortSizeDesc: 'Größte zuerst',
archiveSortSizeAsc: 'Kleinste zuerst', archiveSortSizeAsc: 'Kleinste zuerst',
archiveSortNameAsc: 'Name (A-Z)', archiveSortNameAsc: 'Name (A-Z)',
archiveSearchBtn: 'Suchen', archiveSearchBtn: 'Suchen',
archiveSearching: 'Scanne...', archiveSearching: 'Scanne...',
archiveSummary: '{matchCount} Treffer (gescannt: {scanned} Dateien)', archiveSummary: '{matchCount} Treffer (gescannt: {scanned} Dateien)',
archiveSummaryTruncated: '{matchCount} Treffer (gescannt: {scanned} Dateien, gezeigt: {shown} - verfeinere die Suche fuer mehr)', archiveSummaryTruncated: '{matchCount} Treffer (gescannt: {scanned} Dateien, gezeigt: {shown} - verfeinere die Suche für 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', archiveResults: 'Ergebnisse',
archiveOpen: 'Oeffnen', archiveOpen: 'Öffnen',
archiveShowInFolder: 'Ordner', archiveShowInFolder: 'Ordner',
archiveViewChat: 'Chat', archiveViewChat: 'Chat',
archiveViewEvents: 'Events', archiveViewEvents: 'Events',
discordNotifyVodCompleteLabel: 'Bei abgeschlossenem VOD-Download benachrichtigen', discordNotifyVodCompleteLabel: 'Bei abgeschlossenem VOD-Download benachrichtigen',
backupCardTitle: 'Sicherung & Wartung', backupCardTitle: 'Sicherung & Wartung',
backupCardIntro: 'Konfiguration sichern, auf einem anderen Geraet wiederherstellen oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.', backupCardIntro: 'Konfiguration sichern, auf einem anderen Gerät wiederherstellen oder die Liste der bereits heruntergeladenen VODs zurücksetzen.',
exportConfig: 'Konfiguration exportieren', exportConfig: 'Konfiguration exportieren',
importConfig: 'Konfiguration importieren', importConfig: 'Konfiguration importieren',
resetDownloadedIds: 'Downloaded-VODs zuruecksetzen', resetDownloadedIds: 'Downloaded-VODs zurücksetzen',
configExported: 'Konfiguration exportiert.', configExported: 'Konfiguration exportiert.',
configExportFailed: 'Export der Konfiguration fehlgeschlagen.', configExportFailed: 'Export der Konfiguration fehlgeschlagen.',
configImported: 'Konfiguration importiert. Einige Aenderungen erfordern evtl. einen Neustart.', configImported: 'Konfiguration importiert. Einige Aenderungen erfordern evtl. einen Neustart.',
configImportFailed: 'Import der Konfiguration fehlgeschlagen.', configImportFailed: 'Import der Konfiguration fehlgeschlagen.',
resetDownloadedConfirm: 'Liste der heruntergeladenen VODs zuruecksetzen? Karten verlieren das gruene Haekchen, es werden aber keine Dateien geloescht.', resetDownloadedConfirm: 'Liste der heruntergeladenen VODs zurücksetzen? Karten verlieren das grüne Häkchen, es werden aber keine Dateien gelöscht.',
resetDownloadedDone: '{count} Eintraege aus der Downloaded-Liste entfernt.', resetDownloadedDone: '{count} Einträge aus der Downloaded-Liste entfernt.',
duplicatePreventionLabel: 'Duplikate in Queue verhindern', duplicatePreventionLabel: 'Duplikate in Queue verhindern',
persistQueueLabel: 'Queue zwischen App-Starts speichern', persistQueueLabel: 'Queue zwischen App-Starts speichern',
autoResumeQueueLabel: 'Queue beim Start automatisch fortsetzen', autoResumeQueueLabel: 'Queue beim Start automatisch fortsetzen',
autoResumeQueueHint: 'Wenn aktiv und die gespeicherte Queue noch ausstehende Eintraege hat, starten Downloads ~5 Sekunden nach dem Fensteroeffnen. Deaktivieren = Start-Klick noetig.', autoResumeQueueHint: 'Wenn aktiv und die gespeicherte Queue noch ausstehende Einträge hat, starten Downloads ~5 Sekunden nach dem Fensteröffnen. Deaktivieren = Start-Klick nötig.',
notifyEachCompletionLabel: 'Benachrichtigung bei jedem fertigen Download', notifyEachCompletionLabel: 'Benachrichtigung bei jedem fertigen Download',
notifyEachCompletionHint: 'Standardmaessig aus — bei langen Queues wuerde das System-Notifications-Panel sonst zugespammt. Die Queue-End-Zusammenfassung erscheint trotzdem.', notifyEachCompletionHint: 'Standardmäßig aus — bei langen Queues würde das System-Notifications-Panel sonst zugespammt. Die Queue-End-Zusammenfassung erscheint trotzdem.',
streamlinkDisableAdsLabel: 'Twitch-Ads beim Download ueberspringen', streamlinkDisableAdsLabel: 'Twitch-Ads beim Download überspringen',
streamlinkDisableAdsHint: 'Gibt --twitch-disable-ads an streamlink weiter, damit Mid-Roll-Ads nicht ins VOD eingebettet werden. Empfohlen aktiv lassen.', streamlinkDisableAdsHint: 'Gibt --twitch-disable-ads an streamlink weiter, damit Mid-Roll-Ads nicht ins VOD eingebettet werden. Empfohlen aktiv lassen.',
downloadChatReplayLabel: 'Chat-Replay parallel zum VOD speichern (.chat.json)', downloadChatReplayLabel: 'Chat-Replay parallel zum VOD speichern (.chat.json)',
downloadChatReplayHint: 'Nach erfolgreichem VOD-Download wird der oeffentliche Chat-Replay via Twitch GQL geholt und als JSON neben dem Video gespeichert. Twitch behaelt Chat-Replays nur solange wie das VOD selbst.', downloadChatReplayHint: 'Nach erfolgreichem VOD-Download wird der öffentliche Chat-Replay via Twitch GQL geholt und als JSON neben dem Video gespeichert. Twitch behält Chat-Replays nur solange wie das VOD selbst.',
captureLiveChatLabel: 'Live-Chat waehrend der Aufnahme mitschneiden (.chat.jsonl)', captureLiveChatLabel: 'Live-Chat während der Aufnahme mitschneiden (.chat.jsonl)',
captureLiveChatHint: 'Oeffnet waehrend einer Live-Aufnahme eine anonyme IRC-Verbindung zum Twitch-Chat und schreibt jede Nachricht in eine .chat.jsonl-Datei neben dem Video (JSON Lines, eine Nachricht pro Zeile, damit ein Mid-Stream-Abbruch frueheren Inhalt nicht korrumpiert).', captureLiveChatHint: 'Oeffnet während einer Live-Aufnahme eine anonyme IRC-Verbindung zum Twitch-Chat und schreibt jede Nachricht in eine .chat.jsonl-Datei neben dem Video (JSON Lines, eine Nachricht pro Zeile, damit ein Mid-Stream-Abbruch früheren Inhalt nicht korrumpiert).',
logStreamEventsLabel: 'Stream-Events bei Live-Aufnahmen mitloggen (.events.jsonl)', logStreamEventsLabel: 'Stream-Events bei Live-Aufnahmen mitloggen (.events.jsonl)',
logStreamEventsHint: 'Pollt den Streamer einmal pro Minute und schreibt Title-/Game-Wechsel in eine .events.jsonl-Datei neben dem Video. Hilfreich beim Suchen in langen archivierten Streams ("wann hat er auf CS:GO gewechselt?"). Sehr guenstig — ein zusaetzlicher Helix/GQL-Call pro Minute pro aktiver Aufnahme.', logStreamEventsHint: 'Pollt den Streamer einmal pro Minute und schreibt Title-/Game-Wechsel in eine .events.jsonl-Datei neben dem Video. Hilfreich beim Suchen in langen archivierten Streams ("wann hat er auf CS:GO gewechselt?"). Sehr günstig — ein zusätzlicher Helix/GQL-Call pro Minute pro aktiver Aufnahme.',
streamlinkQualityLabel: 'Stream-Qualitaet', streamlinkQualityLabel: 'Stream-Qualität',
streamlinkQualityHint: 'Streamlink versucht erst diese Qualitaet; falls das VOD sie nicht anbietet, faellt es auf "best" zurueck.', streamlinkQualityHint: 'Streamlink versucht erst diese Qualität; falls das VOD sie nicht anbietet, fällt es auf "best" zurück.',
streamlinkQualityBest: 'Best (Standard)', streamlinkQualityBest: 'Best (Standard)',
streamlinkQualitySource: 'Source (Original)', streamlinkQualitySource: 'Source (Original)',
streamlinkQualityAudio: 'Nur Audio', streamlinkQualityAudio: 'Nur Audio',
downloadPathNotWritable: 'Download-Ordner ist nicht beschreibbar. Waehle einen anderen Ordner oder pruefe die Schreibrechte.', downloadPathNotWritable: 'Download-Ordner ist nicht beschreibbar. Wähle einen anderen Ordner oder prüfe die Schreibrechte.',
streamerSectionTitle: 'Streamer', streamerSectionTitle: 'Streamer',
streamerListFilterPlaceholder: 'Filtern...', streamerListFilterPlaceholder: 'Filtern...',
streamerListFilterAria: 'Streamer-Liste filtern', streamerListFilterAria: 'Streamer-Liste filtern',
streamerAddAriaLabel: 'Streamer hinzufuegen', streamerAddAriaLabel: 'Streamer hinzufügen',
streamerBulkRemoveTitle: 'Alle entfernen (oder gefilterte)', streamerBulkRemoveTitle: 'Alle entfernen (oder gefilterte)',
streamerBulkRemoveAll: 'Alle {count} Streamer aus der Liste entfernen?', streamerBulkRemoveAll: 'Alle {count} Streamer aus der Liste entfernen?',
streamerBulkRemoveFiltered: 'Die {count} passenden Streamer aus der Liste entfernen?', streamerBulkRemoveFiltered: 'Die {count} passenden Streamer aus der Liste entfernen?',
@@ -234,20 +239,20 @@ const UI_TEXT_DE = {
templateLintWarn: 'Unbekannte Platzhalter', templateLintWarn: 'Unbekannte Platzhalter',
templateGuideButton: 'Template Guide', templateGuideButton: 'Template Guide',
templateGuideTitle: 'Dateinamen-Template Guide', templateGuideTitle: 'Dateinamen-Template Guide',
templateGuideIntro: 'Nutze Platzhalter fur Dateinamen und teste dein Muster mit einer Live-Vorschau.', templateGuideIntro: 'Nutze Platzhalter für Dateinamen und teste dein Muster mit einer Live-Vorschau.',
templateGuideTemplateLabel: 'Template', templateGuideTemplateLabel: 'Template',
templateGuideOutputLabel: 'Live-Vorschau', templateGuideOutputLabel: 'Live-Vorschau',
templateGuideVarsTitle: 'Verfugbare Platzhalter', templateGuideVarsTitle: 'Verfügbare Platzhalter',
templateGuideVarCol: 'Platzhalter', templateGuideVarCol: 'Platzhalter',
templateGuideDescCol: 'Beschreibung', templateGuideDescCol: 'Beschreibung',
templateGuideExampleCol: 'Beispiel', templateGuideExampleCol: 'Beispiel',
templateGuideUseVod: 'VOD-Template nutzen', templateGuideUseVod: 'VOD-Template nutzen',
templateGuideUseParts: 'Teile-Template nutzen', templateGuideUseParts: 'Teile-Template nutzen',
templateGuideUseClip: 'Clip-Template nutzen', templateGuideUseClip: 'Clip-Template nutzen',
templateGuideClose: 'Schliessen', templateGuideClose: 'Schließen',
templateGuideContextVod: 'Kontext: Beispiel fur kompletten VOD-Download', templateGuideContextVod: 'Kontext: Beispiel für kompletten VOD-Download',
templateGuideContextParts: 'Kontext: Beispiel fur VOD-Teil', templateGuideContextParts: 'Kontext: Beispiel für VOD-Teil',
templateGuideContextClip: 'Kontext: Beispiel fur Clip-Zuschnitt', templateGuideContextClip: 'Kontext: Beispiel für Clip-Zuschnitt',
templateGuideContextClipLive: 'Kontext: Aktuelle Auswahl im Clip-Dialog', templateGuideContextClipLive: 'Kontext: Aktuelle Auswahl im Clip-Dialog',
runtimeMetricsTitle: 'Runtime Metrics', runtimeMetricsTitle: 'Runtime Metrics',
runtimeMetricsRefresh: 'Aktualisieren', runtimeMetricsRefresh: 'Aktualisieren',
@@ -274,7 +279,7 @@ const UI_TEXT_DE = {
preflightRun: 'Check ausfuhren', preflightRun: 'Check ausfuhren',
preflightFix: 'Auto-Fix Tools', preflightFix: 'Auto-Fix Tools',
preflightEmpty: 'Noch kein Check ausgefuhrt.', preflightEmpty: 'Noch kein Check ausgefuhrt.',
preflightChecking: 'Prufe...', preflightChecking: 'Prüfe...',
preflightFixing: 'Fixe...', preflightFixing: 'Fixe...',
preflightReady: 'Alles bereit.', preflightReady: 'Alles bereit.',
preflightInternet: 'Internet', preflightInternet: 'Internet',
@@ -288,16 +293,18 @@ const UI_TEXT_DE = {
notConnected: 'Nicht verbunden' notConnected: 'Nicht verbunden'
}, },
status: { status: {
noLogin: 'Ohne Login (Public Modus)', noLogin: 'Public-Modus · öffentliche VODs verfügbar',
publicHint: 'Ohne Twitch-API-Verbindung funktionieren öffentliche VODs und Downloads. Mit API-Verbindung stehen vollständigere Metadaten, mehr Verlauf und zuverlässigere Abfragen bereit.',
connecting: 'Verbinde...', connecting: 'Verbinde...',
connected: 'Verbunden', connected: 'Twitch-API verbunden',
connectFailedPublic: 'Verbindung fehlgeschlagen - Public Modus aktiv' connectedHint: 'Die Twitch-API ist verbunden. Vollständige Metadaten und der erweiterte VOD-Verlauf sind verfügbar.',
connectFailedPublic: 'API nicht erreichbar · Public-Modus aktiv'
}, },
tabs: { tabs: {
vods: 'VODs', vods: 'VODs',
clips: 'Clips', clips: 'Clips',
cutter: 'Video schneiden', cutter: 'Video schneiden',
merge: 'Videos zusammenfugen', merge: 'Videos zusammenfügen',
stats: 'Statistik', stats: 'Statistik',
archive: 'Archiv', archive: 'Archiv',
settings: 'Einstellungen' settings: 'Einstellungen'
@@ -312,24 +319,24 @@ const UI_TEXT_DE = {
resume: 'Fortsetzen', resume: 'Fortsetzen',
statusDone: 'Abgeschlossen', statusDone: 'Abgeschlossen',
statusFailed: 'Fehlgeschlagen', statusFailed: 'Fehlgeschlagen',
statusRunning: 'Laeuft', statusRunning: 'Läuft',
statusPaused: 'Pausiert', statusPaused: 'Pausiert',
statusWaiting: 'Wartet', statusWaiting: 'Wartet',
progressError: 'Fehler', progressError: 'Fehler',
progressReady: 'Bereit', progressReady: 'Bereit',
progressLoading: 'Lade...', progressLoading: 'Lade...',
readyToDownload: 'Bereit zum Download', readyToDownload: 'Bereit zum Download',
started: 'Download gestartet', started: 'Download wird gestartet',
done: 'Fertig', done: 'Fertig',
failed: 'Download fehlgeschlagen', failed: 'Download fehlgeschlagen',
speed: 'Geschwindigkeit', speed: 'Geschwindigkeit',
eta: 'Restzeit', eta: 'Restzeit',
part: 'Teil', part: 'Teil',
emptyAlert: 'Die Warteschlange ist leer. Fuge zuerst ein VOD oder einen Clip hinzu.', emptyAlert: 'Die Warteschlange ist leer. Füge zuerst ein VOD oder einen Clip hinzu.',
duplicateSkipped: 'Dieser Eintrag ist bereits aktiv in der Warteschlange.', duplicateSkipped: 'Dieser Eintrag ist bereits aktiv in der Warteschlange.',
openFile: 'Datei oeffnen', openFile: 'Datei öffnen',
showInFolder: 'Im Ordner zeigen', showInFolder: 'Im Ordner zeigen',
openFileFailed: 'Datei konnte nicht geoeffnet werden (evtl. verschoben oder geloescht).', openFileFailed: 'Datei konnte nicht geöffnet werden (evtl. verschoben oder gelöscht).',
outputFilesLabel: '{count} Ausgabedateien', outputFilesLabel: '{count} Ausgabedateien',
retryItem: 'Diesen Eintrag erneut versuchen', retryItem: 'Diesen Eintrag erneut versuchen',
viewChat: 'Chat ansehen', viewChat: 'Chat ansehen',
@@ -338,7 +345,7 @@ const UI_TEXT_DE = {
chatViewerFilterPlaceholder: 'Chat filtern...', chatViewerFilterPlaceholder: 'Chat filtern...',
chatViewerFilterAria: 'Chatnachrichten filtern', chatViewerFilterAria: 'Chatnachrichten filtern',
viewChatCount: '{count} Nachrichten', viewChatCount: '{count} Nachrichten',
viewChatTruncatedSuffix: ' (gekuerzt)', viewChatTruncatedSuffix: ' (gekürzt)',
viewEvents: 'Events ansehen', viewEvents: 'Events ansehen',
viewEventsCount: '{count} Events', viewEventsCount: '{count} Events',
viewEventsEmpty: 'Keine Events aufgezeichnet.', viewEventsEmpty: 'Keine Events aufgezeichnet.',
@@ -349,13 +356,16 @@ const UI_TEXT_DE = {
statusBarSummary: '{downloading} aktiv, {pending} wartet', statusBarSummary: '{downloading} aktiv, {pending} wartet',
ctxMoveTop: 'Nach oben verschieben', ctxMoveTop: 'Nach oben verschieben',
ctxMoveBottom: 'Nach unten verschieben', ctxMoveBottom: 'Nach unten verschieben',
ctxSelectForMerge: 'Für Zusammenfügen auswählen',
ctxRemoveFromMerge: 'Aus Zusammenfügen-Auswahl entfernen',
mergeSelectionPosition: 'Zusammenfügen: Position {position}',
ctxCopyUrl: 'URL kopieren', ctxCopyUrl: 'URL kopieren',
ctxOpenOnTwitch: 'Auf Twitch oeffnen', ctxOpenOnTwitch: 'Auf Twitch öffnen',
ctxRemove: 'Aus Queue entfernen', ctxRemove: 'Aus Queue entfernen',
ctxCopiedUrl: 'URL in Zwischenablage kopiert.', ctxCopiedUrl: 'URL in Zwischenablage kopiert.',
liveRecordingTitle: 'Live-Aufnahme - laeuft bis der Stream endet', liveRecordingTitle: 'Live-Aufnahme - läuft bis der Stream endet',
recordingHealth: { recordingHealth: {
ok: 'Gesund - Bytes fliessen', ok: 'Gesund - Bytes fließen',
stale: 'Stillstand - keine Bytes mehr (Netz-Hickser oder Stream endet)', stale: 'Stillstand - keine Bytes mehr (Netz-Hickser oder Stream endet)',
unknown: 'Warte auf ersten Segment' unknown: 'Warte auf ersten Segment'
}, },
@@ -367,48 +377,51 @@ const UI_TEXT_DE = {
affiliate: 'Affiliate', affiliate: 'Affiliate',
followers: 'Follower', followers: 'Follower',
vods: 'VODs', vods: 'VODs',
vodsTooltip: 'Ueber die Twitch-API sichtbare VODs dieses Kanals', vodsTooltip: 'Über die Twitch-API sichtbare VODs dieses Kanals',
lastStream: 'Letzter Stream', lastStream: 'Letzter Stream',
openTwitch: 'Auf Twitch oeffnen', openTwitch: 'Auf Twitch öffnen',
openTwitchTooltip: 'Diesen Kanal auf twitch.tv oeffnen', openTwitchTooltip: 'Diesen Kanal auf twitch.tv öffnen',
liveCardTooltip: 'Klick um sofort eine Live-Aufnahme zu starten', liveCardTooltip: 'Klick um sofort eine Live-Aufnahme zu starten',
liveThumbAlt: 'Live-Vorschau', liveThumbAlt: 'Live-Vorschau',
recordNow: 'Jetzt aufnehmen', recordNow: 'Jetzt aufnehmen',
refresh: 'Aktualisieren', refresh: 'Aktualisieren',
agoMinutes: 'vor {n} Min', agoMinutes: 'vor {n} Min',
agoHours: 'vor {n} h', agoHours: 'vor {n} h',
agoDay: 'vor 1 Tag',
agoDays: 'vor {n} Tagen', agoDays: 'vor {n} Tagen',
agoMonth: 'vor 1 Monat',
agoMonths: 'vor {n} Monaten', agoMonths: 'vor {n} Monaten',
agoYear: 'vor 1 Jahr',
agoYears: 'vor {n} Jahren' agoYears: 'vor {n} Jahren'
}, },
streamers: { streamers: {
recordLiveTitle: 'Diesen Streamer live aufnehmen (laeuft bis der Stream endet)', recordLiveTitle: 'Diesen Streamer live aufnehmen (läuft bis der Stream endet)',
liveRecordingStarted: 'Live-Aufnahme fuer {streamer} gestartet.', liveRecordingStarted: 'Live-Aufnahme für {streamer} gestartet.',
liveRecordingOffline: '{streamer} ist gerade offline.', liveRecordingOffline: '{streamer} ist gerade offline.',
liveRecordingAlreadyActive: 'Aufnahme von {streamer} laeuft bereits.', liveRecordingAlreadyActive: 'Aufnahme von {streamer} läuft bereits.',
liveRecordingFailed: 'Live-Aufnahme konnte nicht gestartet werden', liveRecordingFailed: 'Live-Aufnahme konnte nicht gestartet werden',
autoRecordTitle: 'Auto-Aufnahme: wenn dieser Streamer live geht, nimmt die App automatisch auf', autoRecordTitle: 'Auto-Aufnahme: wenn dieser Streamer live geht, nimmt die App automatisch auf',
autoRecordEnabled: 'Auto-Aufnahme aktiviert fuer {streamer}. Live-Status wird geprueft...', autoRecordEnabled: 'Auto-Aufnahme aktiviert für {streamer}. Live-Status wird geprüft...',
autoRecordDisabled: 'Auto-Aufnahme fuer {streamer} deaktiviert.', autoRecordDisabled: 'Auto-Aufnahme für {streamer} deaktiviert.',
autoVodTitle: 'Neue VODs (kuerzlich veroeffentlicht) automatisch herunterladen', autoVodTitle: 'Neue VODs (kürzlich veröffentlicht) automatisch herunterladen',
autoVodEnabled: 'Auto-VOD aktiviert fuer {streamer}. Neue VODs werden automatisch geladen.', autoVodEnabled: 'Auto-VOD aktiviert für {streamer}. Neue VODs werden automatisch geladen.',
autoVodDisabled: 'Auto-VOD fuer {streamer} deaktiviert.', autoVodDisabled: 'Auto-VOD für {streamer} deaktiviert.',
autoVodScanQueued: '{count} neue VOD(s) automatisch eingereiht.', autoVodScanQueued: '{count} neue VOD(s) automatisch eingereiht.',
autoVodScanEmpty: 'Keine neuen VODs gefunden.', autoVodScanEmpty: 'Keine neuen VODs gefunden.',
autoRecordScanTriggered: 'Manueller Scan: {count} Live-Aufnahme(n) gestartet.', autoRecordScanTriggered: 'Manueller Scan: {count} Live-Aufnahme(n) gestartet.',
autoRecordScanEmpty: 'Manueller Scan: kein Streamer ist gerade live.', autoRecordScanEmpty: 'Manueller Scan: kein Streamer ist gerade live.',
liveNowTooltip: 'Aktuell live auf Twitch', liveNowTooltip: 'Aktuell live auf Twitch',
modalCloseAria: 'Dialog schliessen', modalCloseAria: 'Dialog schließen',
sidebarEmpty: 'Noch keine Streamer. Fuege oben rechts einen hinzu.', sidebarEmpty: 'Noch keine Streamer. Füge oben rechts einen hinzu.',
removeAria: 'Entfernen', removeAria: 'Entfernen',
cutProgressAria: 'Schnitt-Fortschritt', cutProgressAria: 'Schnitt-Fortschritt',
mergeProgressAria: 'Merge-Fortschritt', mergeProgressAria: 'Merge-Fortschritt',
updateProgressAria: 'Update-Download-Fortschritt' updateProgressAria: 'Update-Download-Fortschritt'
}, },
vods: { vods: {
selectAriaLabel: 'VOD fuer Bulk-Aktion auswaehlen', selectAriaLabel: 'VOD für Bulk-Aktion auswählen',
noneTitle: 'Keine VODs', noneTitle: 'Keine VODs',
noneText: 'Wahle einen Streamer aus der Liste.', noneText: 'Wähle einen Streamer aus der Liste.',
loading: 'Lade VODs...', loading: 'Lade VODs...',
notFound: 'Streamer nicht gefunden', notFound: 'Streamer nicht gefunden',
noResultsTitle: 'Keine VODs gefunden', noResultsTitle: 'Keine VODs gefunden',
@@ -419,22 +432,22 @@ const UI_TEXT_DE = {
trimButton: 'VOD zuschneiden', trimButton: 'VOD zuschneiden',
filterPlaceholder: 'Nach Titel filtern... (Strg+F)', filterPlaceholder: 'Nach Titel filtern... (Strg+F)',
filterAria: 'VOD-Titel filtern', filterAria: 'VOD-Titel filtern',
filterClearTitle: 'Filter loeschen (Esc)', filterClearTitle: 'Filter löschen (Esc)',
filterNoMatchTitle: 'Keine Treffer', filterNoMatchTitle: 'Keine Treffer',
filterNoMatchText: 'Keine VODs entsprechen dem aktuellen Filter.', filterNoMatchText: 'Keine VODs entsprechen dem aktuellen Filter.',
filterMatchCount: '{shown} von {total} VODs', filterMatchCount: '{shown} von {total} VODs',
sortLabel: 'Sortierung:', sortLabel: 'Sortierung:',
sortDateDesc: 'Neueste zuerst', sortDateDesc: 'Neueste zuerst',
sortDateAsc: 'Aelteste zuerst', sortDateAsc: 'Älteste zuerst',
sortViewsDesc: 'Meiste Aufrufe', sortViewsDesc: 'Meiste Aufrufe',
sortDurationDesc: 'Laengste zuerst', sortDurationDesc: 'Längste zuerst',
sortDurationAsc: 'Kuerzeste zuerst', sortDurationAsc: 'Kürzeste zuerst',
bulkSelectedCount: '{count} ausgewaehlt', bulkSelectedCount: '{count} ausgewählt',
bulkAddToQueue: '+ Warteschlange', bulkAddToQueue: '+ Warteschlange',
bulkAdding: 'Fuege hinzu...', bulkAdding: 'Füge hinzu...',
bulkClear: 'Loeschen', bulkClear: 'Löschen',
bulkAddedToQueue: '{count} VODs zur Warteschlange hinzugefuegt.', bulkAddedToQueue: '{count} VODs zur Warteschlange hinzugefügt.',
bulkAddSkipped: 'Keine VODs hinzugefuegt (bereits in Queue oder ungueltig).', bulkAddSkipped: 'Keine VODs hinzugefügt (bereits in Queue oder ungültig).',
bulkMarkDownloaded: 'Als heruntergeladen markieren', bulkMarkDownloaded: 'Als heruntergeladen markieren',
bulkUnmark: 'Markierung entfernen', bulkUnmark: 'Markierung entfernen',
bulkMarkedDownloaded: '{count} VODs als heruntergeladen markiert.', bulkMarkedDownloaded: '{count} VODs als heruntergeladen markiert.',
@@ -442,8 +455,8 @@ const UI_TEXT_DE = {
alreadyDownloaded: 'Bereits heruntergeladen', alreadyDownloaded: 'Bereits heruntergeladen',
hideDownloaded: 'Bereits geladene ausblenden', hideDownloaded: 'Bereits geladene ausblenden',
hideDownloadedTitle: 'VODs ausblenden, die als bereits heruntergeladen markiert sind', hideDownloadedTitle: 'VODs ausblenden, die als bereits heruntergeladen markiert sind',
openOnTwitch: 'Auf Twitch oeffnen', openOnTwitch: 'Auf Twitch öffnen',
ctxOpenOnTwitch: 'Auf Twitch oeffnen', ctxOpenOnTwitch: 'Auf Twitch öffnen',
ctxCopyUrl: 'VOD-URL kopieren', ctxCopyUrl: 'VOD-URL kopieren',
ctxCopiedUrl: 'URL in Zwischenablage kopiert.', ctxCopiedUrl: 'URL in Zwischenablage kopiert.',
ctxMarkDownloaded: 'Als heruntergeladen markieren', ctxMarkDownloaded: 'Als heruntergeladen markieren',
@@ -456,17 +469,17 @@ const UI_TEXT_DE = {
dialogEnd: 'Ende:', dialogEnd: 'Ende:',
dialogEndTime: 'Endzeit (HH:MM:SS):', dialogEndTime: 'Endzeit (HH:MM:SS):',
dialogDuration: 'Dauer: ', dialogDuration: 'Dauer: ',
dialogPartLabel: 'Start Part-Nummer (optional, fur Fortsetzung):', dialogPartLabel: 'Start Part-Nummer (optional, für Fortsetzung):',
dialogPartHint: 'Leer lassen = Teil 1', dialogPartHint: 'Leer lassen = Teil 1',
dialogFormatLabel: 'Dateinamen-Format:', dialogFormatLabel: 'Dateinamen-Format:',
dialogConfirm: 'Zur Queue hinzufuegen', dialogConfirm: 'Zur Queue hinzufügen',
invalidDuration: 'Ungultig!', invalidDuration: 'Ungultig!',
invalidTime: 'Ungueltige Zeitangaben', invalidTime: 'Ungültige Zeitangaben',
endBeforeStart: 'Endzeit muss grosser als Startzeit sein!', endBeforeStart: 'Endzeit muss größer als Startzeit sein!',
outOfRange: 'Zeit ausserhalb des VOD-Bereichs!', outOfRange: 'Zeit außerhalb des VOD-Bereichs!',
enterUrl: 'Bitte URL eingeben', enterUrl: 'Bitte URL eingeben',
loadingButton: 'Lade...', loadingButton: 'Lade...',
loadingStatus: 'Download laeuft...', loadingStatus: 'Download läuft...',
downloadButton: 'Clip herunterladen', downloadButton: 'Clip herunterladen',
success: 'Download erfolgreich!', success: 'Download erfolgreich!',
errorPrefix: 'Fehler: ', errorPrefix: 'Fehler: ',
@@ -484,43 +497,43 @@ const UI_TEXT_DE = {
cutter: { cutter: {
videoInfoFailed: 'Konnte Video-Informationen nicht lesen. FFprobe installiert?', videoInfoFailed: 'Konnte Video-Informationen nicht lesen. FFprobe installiert?',
previewLoading: 'Lade Vorschau...', previewLoading: 'Lade Vorschau...',
previewUnavailable: 'Vorschau nicht verfugbar', previewUnavailable: 'Vorschau nicht verfügbar',
previewAlt: 'Vorschau', previewAlt: 'Vorschau',
cutting: 'Schneidet...', cutting: 'Schneidet...',
cut: 'Schneiden', cut: 'Schneiden',
cutSuccess: 'Video erfolgreich geschnitten!', cutSuccess: 'Video erfolgreich geschnitten!',
cutFailed: 'Fehler beim Schneiden des Videos.', cutFailed: 'Fehler beim Schneiden des Videos.',
infoDuration: 'Dauer', infoDuration: 'Dauer',
infoResolution: 'Aufloesung', infoResolution: 'Auflösung',
infoFps: 'FPS', infoFps: 'FPS',
infoSelection: 'Auswahl', infoSelection: 'Auswahl',
startLabel: 'Start:', startLabel: 'Start:',
endLabel: 'Ende:', endLabel: 'Ende:',
filePathPlaceholder: 'Keine Datei ausgewaehlt…' filePathPlaceholder: 'Keine Datei ausgewählt…'
}, },
merge: { merge: {
empty: 'Keine Videos ausgewahlt', empty: 'Keine Videos ausgewahlt',
merging: 'Zusammenfugen...', merging: 'Zusammenfügen...',
merge: 'Zusammenfugen', merge: 'Zusammenfügen',
success: 'Videos erfolgreich zusammengefugt!', success: 'Videos erfolgreich zusammengefugt!',
failed: 'Fehler beim Zusammenfugen der Videos.', failed: 'Fehler beim Zusammenfügen der Videos.',
moveUpAria: 'Nach oben verschieben', moveUpAria: 'Nach oben verschieben',
moveDownAria: 'Nach unten verschieben', moveDownAria: 'Nach unten verschieben',
removeAria: 'Aus Liste entfernen' removeAria: 'Aus Liste entfernen'
}, },
mergeGroup: { mergeGroup: {
btn: 'Zusammenfugen & Splitten', btn: 'Zusammenfügen & Splitten',
phaseDownloading: 'VOD wird heruntergeladen', phaseDownloading: 'VOD wird heruntergeladen',
phaseMerging: 'Zusammenfugen...', phaseMerging: 'Zusammenfügen...',
phaseSplitting: 'Part wird erstellt', phaseSplitting: 'Part wird erstellt',
phaseCleanup: 'Aufraumen...', phaseCleanup: 'Aufraumen...',
needMinTwo: 'Mindestens 2 VODs auswahlen', needMinTwo: 'Mindestens 2 VODs auswählen',
titleTwo: 'Merge: {title1} + {title2}', titleTwo: 'Merge: {title1} + {title2}',
titleMany: 'Merge: {title1} + {count} weitere', titleMany: 'Merge: {title1} + {count} weitere',
metaLabel: '{count} VODs', metaLabel: '{count} VODs',
}, },
updates: { updates: {
bannerDefault: 'Neue Version verfugbar!', bannerDefault: 'Neue Version verfügbar!',
latest: 'Du hast die neueste Version!', latest: 'Du hast die neueste Version!',
checking: 'Suche nach Updates...', checking: 'Suche nach Updates...',
checkInProgress: 'Update-Prufung lauft bereits.', checkInProgress: 'Update-Prufung lauft bereits.',
@@ -529,13 +542,13 @@ const UI_TEXT_DE = {
downloading: 'Wird heruntergeladen...', downloading: 'Wird heruntergeladen...',
downloadInProgress: 'Update-Download lauft bereits.', downloadInProgress: 'Update-Download lauft bereits.',
downloadFailed: 'Update-Download fehlgeschlagen.', downloadFailed: 'Update-Download fehlgeschlagen.',
available: 'verfugbar!', available: 'verfügbar!',
downloadNow: 'Jetzt herunterladen', downloadNow: 'Jetzt herunterladen',
downloadLabel: 'Download', downloadLabel: 'Download',
ready: 'bereit zur Installation!', ready: 'bereit zur Installation!',
installNow: 'Jetzt installieren & neu starten', installNow: 'Jetzt installieren & neu starten',
modalAvailableTitle: 'Update verfugbar', modalAvailableTitle: 'Update verfügbar',
modalAvailableMessage: 'Version {version} ist verfugbar. Jetzt herunterladen?', modalAvailableMessage: 'Version {version} ist verfügbar. Jetzt herunterladen?',
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',
@@ -543,11 +556,11 @@ const UI_TEXT_DE = {
dismissAria: 'Schließen', dismissAria: 'Schließen',
modalDownloadConfirm: 'Ja, herunterladen', modalDownloadConfirm: 'Ja, herunterladen',
modalInstallConfirm: 'Ja, installieren', modalInstallConfirm: 'Ja, installieren',
modalSkipVersion: 'Diese Version ueberspringen', modalSkipVersion: 'Diese Version überspringen',
changelogLabel: 'Changelog', changelogLabel: 'Changelog',
showChangelog: 'Changelog anzeigen', showChangelog: 'Changelog anzeigen',
hideChangelog: 'Changelog ausblenden', hideChangelog: 'Changelog ausblenden',
noChangelog: 'Kein Changelog verfugbar.', noChangelog: 'Kein Changelog verfügbar.',
releasedLabel: 'Release' releasedLabel: 'Release'
} }
} as const; } as const;
+18 -5
View File
@@ -64,13 +64,16 @@ const UI_TEXT_EN = {
themeDark: 'Dark', themeDark: 'Dark',
themeSystem: 'System', themeSystem: 'System',
languageLabel: 'Language', languageLabel: 'Language',
languageDe: 'German', languageDe: 'Deutsch',
languageEn: 'English', languageEn: 'English',
apiTitle: 'Twitch API', apiTitle: 'Twitch API',
clientIdLabel: 'Client ID', clientIdLabel: 'Client ID',
clientSecretLabel: 'Client Secret', clientSecretLabel: 'Client Secret',
saveSettings: 'Save & Connect', saveSettings: 'Save & Connect',
downloadSettingsTitle: 'Download Settings', downloadSettingsTitle: 'Download Settings',
downloadBasicsTitle: 'Download and quality',
queueAutomationTitle: 'Queue and automation',
recordingMetadataTitle: 'Recordings and metadata',
storageLabel: 'Storage Path', storageLabel: 'Storage Path',
selectFolder: 'Folder', selectFolder: 'Folder',
openFolder: 'Open', openFolder: 'Open',
@@ -87,6 +90,8 @@ const UI_TEXT_EN = {
performanceModeSpeed: 'Max Speed', performanceModeSpeed: 'Max Speed',
smartSchedulerLabel: 'Enable smart queue scheduler', smartSchedulerLabel: 'Enable smart queue scheduler',
smartSchedulerHint: 'Prefers shorter VODs and older queue entries first so the queue throughput stays steady. Disable to drain in strict insertion order.', smartSchedulerHint: 'Prefers shorter VODs and older queue entries first so the queue throughput stays steady. Disable to drain in strict insertion order.',
sidebarSplitViewLabel: 'Show streamers and queue together',
sidebarSplitViewHint: 'Splits the left sidebar evenly. Disable it to restore the Streamer and Queue switches.',
streamerInvalid: 'Invalid Twitch username (4-25 chars, letters/digits/underscore).', streamerInvalid: 'Invalid Twitch username (4-25 chars, letters/digits/underscore).',
apiHelpIntro: 'You need a Client ID and Client Secret from Twitch.', apiHelpIntro: 'You need a Client ID and Client Secret from Twitch.',
apiHelpLinkText: 'dev.twitch.tv/console/apps', apiHelpLinkText: 'dev.twitch.tv/console/apps',
@@ -288,10 +293,12 @@ const UI_TEXT_EN = {
notConnected: 'Not connected' notConnected: 'Not connected'
}, },
status: { status: {
noLogin: 'No login (public mode)', noLogin: 'Public mode · public VODs available',
publicHint: 'Public VODs and downloads work without a Twitch API connection. Connecting the API provides more complete metadata, more history, and more reliable requests.',
connecting: 'Connecting...', connecting: 'Connecting...',
connected: 'Connected', connected: 'Twitch API connected',
connectFailedPublic: 'Connection failed - public mode active' connectedHint: 'The Twitch API is connected. Complete metadata and the extended VOD history are available.',
connectFailedPublic: 'API unavailable · public mode active'
}, },
tabs: { tabs: {
vods: 'VODs', vods: 'VODs',
@@ -319,7 +326,7 @@ const UI_TEXT_EN = {
progressReady: 'Ready', progressReady: 'Ready',
progressLoading: 'Loading...', progressLoading: 'Loading...',
readyToDownload: 'Ready to download', readyToDownload: 'Ready to download',
started: 'Download started', started: 'Starting download ',
done: 'Done', done: 'Done',
failed: 'Download failed', failed: 'Download failed',
speed: 'Speed', speed: 'Speed',
@@ -349,6 +356,9 @@ const UI_TEXT_EN = {
statusBarSummary: '{downloading} dl, {pending} queued', statusBarSummary: '{downloading} dl, {pending} queued',
ctxMoveTop: 'Move to top', ctxMoveTop: 'Move to top',
ctxMoveBottom: 'Move to bottom', ctxMoveBottom: 'Move to bottom',
ctxSelectForMerge: 'Select for merge',
ctxRemoveFromMerge: 'Remove from merge selection',
mergeSelectionPosition: 'Merge position {position}',
ctxCopyUrl: 'Copy URL', ctxCopyUrl: 'Copy URL',
ctxOpenOnTwitch: 'Open on Twitch', ctxOpenOnTwitch: 'Open on Twitch',
ctxRemove: 'Remove from queue', ctxRemove: 'Remove from queue',
@@ -377,8 +387,11 @@ const UI_TEXT_EN = {
refresh: 'Refresh', refresh: 'Refresh',
agoMinutes: '{n} min ago', agoMinutes: '{n} min ago',
agoHours: '{n} h ago', agoHours: '{n} h ago',
agoDay: '1 d ago',
agoDays: '{n} d ago', agoDays: '{n} d ago',
agoMonth: '1 mo ago',
agoMonths: '{n} mo ago', agoMonths: '{n} mo ago',
agoYear: '1 y ago',
agoYears: '{n} y ago' agoYears: '{n} y ago'
}, },
streamers: { streamers: {
+66 -3
View File
@@ -3,6 +3,9 @@
// full card once profile data is back. Smooth fade-in is in CSS. // full card once profile data is back. Smooth fade-in is in CSS.
let activeProfileRequestId = 0; let activeProfileRequestId = 0;
let activeProfileLogin = '';
const streamerProfileCache = new Map<string, StreamerProfile>();
const streamerProfileLoads = new Map<string, Promise<StreamerProfile | null>>();
function formatProfileFollowers(count: number | null): string { function formatProfileFollowers(count: number | null): string {
if (count == null) return ''; if (count == null) return '';
@@ -20,14 +23,36 @@ function formatLastStreamAgo(iso: string | null): string {
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
if (hours < 24) return UI_TEXT.profile.agoHours.replace('{n}', String(hours)); if (hours < 24) return UI_TEXT.profile.agoHours.replace('{n}', String(hours));
const days = Math.floor(hours / 24); const days = Math.floor(hours / 24);
if (days === 1) return UI_TEXT.profile.agoDay;
if (days < 30) return UI_TEXT.profile.agoDays.replace('{n}', String(days)); if (days < 30) return UI_TEXT.profile.agoDays.replace('{n}', String(days));
const months = Math.floor(days / 30); const months = Math.floor(days / 30);
if (months === 1) return UI_TEXT.profile.agoMonth;
if (months < 12) return UI_TEXT.profile.agoMonths.replace('{n}', String(months)); if (months < 12) return UI_TEXT.profile.agoMonths.replace('{n}', String(months));
const years = Math.floor(days / 365); const years = Math.floor(days / 365);
if (years === 1) return UI_TEXT.profile.agoYear;
return UI_TEXT.profile.agoYears.replace('{n}', String(years)); return UI_TEXT.profile.agoYears.replace('{n}', String(years));
} }
function streamerProfilesMatch(left: StreamerProfile, right: StreamerProfile): boolean {
return left.login === right.login
&& left.displayName === right.displayName
&& left.avatarUrl === right.avatarUrl
&& left.bannerUrl === right.bannerUrl
&& left.description === right.description
&& left.broadcasterType === right.broadcasterType
&& left.followerCount === right.followerCount
&& left.vodCount === right.vodCount
&& left.lastStreamAt === right.lastStreamAt
&& left.isLive === right.isLive
&& left.currentTitle === right.currentTitle
&& left.currentGame === right.currentGame
&& left.currentStreamPreviewUrl === right.currentStreamPreviewUrl
&& left.currentStreamViewers === right.currentStreamViewers
&& left.twitchUrl === right.twitchUrl;
}
function hideStreamerProfileHeader(): void { function hideStreamerProfileHeader(): void {
activeProfileLogin = '';
const el = document.getElementById('streamerProfileHeader'); const el = document.getElementById('streamerProfileHeader');
if (!el) return; if (!el) return;
el.classList.add('is-hidden'); el.classList.add('is-hidden');
@@ -166,10 +191,14 @@ async function loadStreamerProfile(login: string, forceRefresh = false): Promise
hideStreamerProfileHeader(); hideStreamerProfileHeader();
return; return;
} }
const key = login.trim().toLowerCase();
activeProfileLogin = key;
const reqId = ++activeProfileRequestId; const reqId = ++activeProfileRequestId;
renderStreamerProfileSkeleton(login); const cached = streamerProfileCache.get(key);
if (cached) renderStreamerProfileCard(cached);
else renderStreamerProfileSkeleton(login);
try { try {
const profile = await window.api.getStreamerProfile(login, forceRefresh); const profile = await fetchStreamerProfile(login, forceRefresh);
// Stale-request guard — user may have clicked another streamer // Stale-request guard — user may have clicked another streamer
// while we were waiting on the API. // while we were waiting on the API.
if (reqId !== activeProfileRequestId) return; if (reqId !== activeProfileRequestId) return;
@@ -177,12 +206,44 @@ async function loadStreamerProfile(login: string, forceRefresh = false): Promise
hideStreamerProfileHeader(); hideStreamerProfileHeader();
return; return;
} }
renderStreamerProfileCard(profile); const rememberDisplayName = (window as unknown as { rememberStreamerDisplayName?: (login: string, displayName: string) => void }).rememberStreamerDisplayName;
if (typeof rememberDisplayName === 'function') rememberDisplayName(profile.login, profile.displayName);
if (!cached || !streamerProfilesMatch(cached, profile)) renderStreamerProfileCard(profile);
} catch (_) { } catch (_) {
if (reqId === activeProfileRequestId) hideStreamerProfileHeader(); if (reqId === activeProfileRequestId) hideStreamerProfileHeader();
} }
} }
async function fetchStreamerProfile(login: string, forceRefresh = false): Promise<StreamerProfile | null> {
const key = login.trim().toLowerCase();
const cached = streamerProfileCache.get(key);
if (!forceRefresh && cached) return cached;
const pending = streamerProfileLoads.get(key);
if (pending) return pending;
const task = window.api.getStreamerProfile(login, forceRefresh).then((profile) => {
if (profile) streamerProfileCache.set(key, profile);
return profile;
});
streamerProfileLoads.set(key, task);
try {
return await task;
} finally {
if (streamerProfileLoads.get(key) === task) streamerProfileLoads.delete(key);
}
}
async function preloadStreamerProfiles(logins: string[]): Promise<void> {
await Promise.allSettled(logins.map((login) => fetchStreamerProfile(login)));
}
async function refreshStreamerProfilesInBackground(logins: string[]): Promise<void> {
await Promise.allSettled(logins.map(async (login) => {
const previous = streamerProfileCache.get(login.trim().toLowerCase());
const profile = await fetchStreamerProfile(login, true);
if (profile && activeProfileLogin === login.trim().toLowerCase() && (!previous || !streamerProfilesMatch(previous, profile))) renderStreamerProfileCard(profile);
}));
}
function refreshStreamerProfile(login: string): void { function refreshStreamerProfile(login: string): void {
void loadStreamerProfile(login, true); void loadStreamerProfile(login, true);
} }
@@ -216,3 +277,5 @@ function onProfileAvatarError(img: HTMLImageElement): void {
(window as unknown as { onProfileAvatarError: typeof onProfileAvatarError }).onProfileAvatarError = onProfileAvatarError; (window as unknown as { onProfileAvatarError: typeof onProfileAvatarError }).onProfileAvatarError = onProfileAvatarError;
(window as unknown as { onProfileLivePreviewError: typeof onProfileLivePreviewError }).onProfileLivePreviewError = onProfileLivePreviewError; (window as unknown as { onProfileLivePreviewError: typeof onProfileLivePreviewError }).onProfileLivePreviewError = onProfileLivePreviewError;
(window as unknown as { triggerLiveRecordingFromProfile: typeof triggerLiveRecordingFromProfile }).triggerLiveRecordingFromProfile = triggerLiveRecordingFromProfile; (window as unknown as { triggerLiveRecordingFromProfile: typeof triggerLiveRecordingFromProfile }).triggerLiveRecordingFromProfile = triggerLiveRecordingFromProfile;
(window as unknown as { preloadStreamerProfiles: typeof preloadStreamerProfiles }).preloadStreamerProfiles = preloadStreamerProfiles;
(window as unknown as { refreshStreamerProfilesInBackground: typeof refreshStreamerProfilesInBackground }).refreshStreamerProfilesInBackground = refreshStreamerProfilesInBackground;
+49 -59
View File
@@ -212,6 +212,18 @@ function showQueueContextMenu(x: number, y: number, item: QueueItem): void {
const isPending = item.status === 'pending' || item.status === 'paused'; const isPending = item.status === 'pending' || item.status === 'paused';
const isFailed = item.status === 'error'; const isFailed = item.status === 'error';
const isCompleted = item.status === 'completed'; const isCompleted = item.status === 'completed';
const canSelectForMerge = item.status === 'pending' && !item.mergeGroup && !item.isLive;
if (canSelectForMerge) {
const isSelectedForMerge = selectedQueueIds.includes(item.id);
const mergeSelectionItem = makeItem(
isSelectedForMerge ? UI_TEXT.queue.ctxRemoveFromMerge : UI_TEXT.queue.ctxSelectForMerge,
() => toggleQueueSelection(item.id)
);
mergeSelectionItem.dataset.queueAction = 'merge-select';
menu.appendChild(mergeSelectionItem);
menu.appendChild(makeSeparator());
}
if (isPending) { if (isPending) {
menu.appendChild(makeItem(UI_TEXT.queue.ctxMoveTop, () => { void moveQueueItemTo(item.id, 'top'); })); menu.appendChild(makeItem(UI_TEXT.queue.ctxMoveTop, () => { void moveQueueItemTo(item.id, 'top'); }));
@@ -297,54 +309,30 @@ function getQueueStatusLabel(item: QueueItem): string {
return UI_TEXT.queue.statusWaiting; return UI_TEXT.queue.statusWaiting;
} }
function getQueueProgressText(item: QueueItem): string { function getQueueProgressStatusText(item: QueueItem): string {
if (item.status === 'completed') return '100%';
if (item.status === 'error') return UI_TEXT.queue.progressError;
if (item.status === 'paused') return UI_TEXT.queue.progressReady;
if (item.status === 'pending') return UI_TEXT.queue.progressReady;
if (item.progress > 0) return `${Math.max(0, Math.min(100, item.progress)).toFixed(1)}%`;
return item.progressStatus || UI_TEXT.queue.progressLoading;
}
function getQueueMetaText(item: QueueItem): string {
if (item.status === 'error' && item.last_error) { if (item.status === 'error' && item.last_error) {
return item.last_error; return item.last_error;
} }
const parts: string[] = [];
if (item.currentPart && item.totalParts) { if (item.currentPart && item.totalParts) {
parts.push(`${UI_TEXT.queue.part} ${item.currentPart}/${item.totalParts}`); return `${UI_TEXT.queue.part} ${item.currentPart}/${item.totalParts}`;
} }
if (item.speed) { if (item.status === 'pending') return UI_TEXT.queue.readyToDownload;
parts.push(`${UI_TEXT.queue.speed}: ${item.speed}`); if (item.status === 'paused') return UI_TEXT.queue.statusPaused;
} if (item.status === 'downloading') return item.progressStatus || UI_TEXT.queue.started;
if (item.status === 'completed') return UI_TEXT.queue.done;
return UI_TEXT.queue.failed;
}
if (item.eta) { function getQueueProgressMetricsText(item: QueueItem): string {
parts.push(`${UI_TEXT.queue.eta}: ${item.eta}`); const parts: string[] = [];
if (item.status === 'completed') parts.push('100%');
if (item.status === 'downloading' && item.progress > 0) {
parts.push(`${Math.max(0, Math.min(100, item.progress)).toFixed(1)}%`);
} }
if (item.speed) parts.push(item.speed);
if (!parts.length && item.status === 'pending') { if (item.eta) parts.push(item.eta);
parts.push(UI_TEXT.queue.readyToDownload);
}
if (!parts.length && item.status === 'paused') {
parts.push(UI_TEXT.queue.statusPaused);
}
if (!parts.length && item.status === 'downloading') {
parts.push(item.progressStatus || UI_TEXT.queue.started);
}
if (!parts.length && item.status === 'completed') {
parts.push(UI_TEXT.queue.done);
}
if (!parts.length && item.status === 'error') {
parts.push(UI_TEXT.queue.failed);
}
return parts.join(' | '); return parts.join(' | ');
} }
@@ -400,18 +388,18 @@ function updateQueueItemProgress(progress: DownloadProgress): void {
const bar = el.querySelector('.queue-progress-bar') as HTMLElement | null; const bar = el.querySelector('.queue-progress-bar') as HTMLElement | null;
const wrap = el.querySelector('.queue-progress-wrap') as HTMLElement | null; const wrap = el.querySelector('.queue-progress-wrap') as HTMLElement | null;
const text = el.querySelector('.queue-progress-text') as HTMLElement | null; const status = el.querySelector('.queue-progress-status') as HTMLElement | null;
const meta = el.querySelector('.queue-meta') as HTMLElement | null; const metrics = el.querySelector('.queue-progress-metrics') as HTMLElement | null;
if (bar) { if (bar) {
const isDeterminate = progress.progress > 0 && progress.progress <= 100; const isDeterminate = item.progress > 0 && item.progress <= 100;
const pct = isDeterminate ? Math.min(100, progress.progress) : 0; const pct = isDeterminate ? Math.min(100, item.progress) : 0;
bar.style.width = `${pct}%`; bar.style.width = `${pct}%`;
bar.className = `queue-progress-bar${isDeterminate ? '' : ' indeterminate'}`; bar.className = 'queue-progress-bar';
if (wrap) wrap.setAttribute('aria-valuenow', String(Math.round(pct))); if (wrap) wrap.setAttribute('aria-valuenow', String(Math.round(pct)));
} }
if (text) text.textContent = getQueueProgressText(item); if (status) status.textContent = getQueueProgressStatusText(item);
if (meta) meta.textContent = getQueueMetaText(item); if (metrics) metrics.textContent = getQueueProgressMetricsText(item);
} }
function toggleQueueDetails(id: string): void { function toggleQueueDetails(id: string): void {
@@ -513,19 +501,21 @@ function renderQueue(): void {
list.innerHTML = queue.map((item: QueueItem) => { list.innerHTML = queue.map((item: QueueItem) => {
const safeTitle = escapeHtml(item.title || UI_TEXT.vods.untitled); const safeTitle = escapeHtml(item.title || UI_TEXT.vods.untitled);
const safeStatusLabel = escapeHtml(getQueueStatusLabel(item)); const safeStatusLabel = escapeHtml(getQueueStatusLabel(item));
const safeProgressText = escapeHtml(getQueueProgressText(item)); const safeProgressStatus = escapeHtml(getQueueProgressStatusText(item));
const safeMeta = escapeHtml(getQueueMetaText(item)); const safeProgressMetrics = escapeHtml(getQueueProgressMetricsText(item));
const safeDate = escapeHtml(formatUiDate(item.date));
const progressStatusClass = item.status === 'downloading' && item.progress <= 0 ? ' is-starting' : '';
const isClip = item.customClip ? '* ' : ''; const isClip = item.customClip ? '* ' : '';
const hasDeterminateProgress = item.progress > 0 && item.progress <= 100; const hasDeterminateProgress = item.progress > 0 && item.progress <= 100;
const progressValue = item.status === 'completed' const progressValue = item.status === 'completed'
? 100 ? 100
: (hasDeterminateProgress ? Math.max(0, Math.min(100, item.progress)) : 0); : (hasDeterminateProgress ? Math.max(0, Math.min(100, item.progress)) : 0);
const progressClass = item.status === 'downloading' && !hasDeterminateProgress ? ' indeterminate' : '';
const isMergeGroup = !!item.mergeGroup; const isMergeGroup = !!item.mergeGroup;
const showSelector = item.status === 'pending' && !isMergeGroup && !item.isLive;
const selectionIndex = selectedQueueIds.indexOf(item.id); const selectionIndex = selectedQueueIds.indexOf(item.id);
const isSelected = selectionIndex >= 0; const isSelected = selectionIndex >= 0;
const selectionPosition = selectionIndex + 1;
const selectionTitle = escapeHtml(UI_TEXT.queue.mergeSelectionPosition.replace('{position}', String(selectionPosition)));
const mergeIcon = isMergeGroup const mergeIcon = isMergeGroup
? '<svg class="merge-group-icon" aria-hidden="true" viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg> ' ? '<svg class="merge-group-icon" aria-hidden="true" viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg> '
: ''; : '';
@@ -540,22 +530,23 @@ function renderQueue(): void {
: ''; : '';
return ` return `
<div class="queue-item${isMergeGroup ? ' merge-group' : ''}" draggable="${item.status === 'pending' ? 'true' : 'false'}" data-id="${item.id}"> <div class="queue-item${isMergeGroup ? ' merge-group' : ''}${isSelected ? ' merge-selected' : ''}" draggable="${item.status === 'pending' ? 'true' : 'false'}" data-id="${item.id}">
${showSelector ${isSelected ? `<span class="queue-selection-order" title="${selectionTitle}" aria-label="${selectionTitle}">${selectionPosition}</span>` : ''}
? `<div class="queue-selector${isSelected ? ' selected' : ''}" role="checkbox" tabindex="0" aria-checked="${isSelected ? 'true' : 'false'}" onclick="toggleQueueSelection('${item.id}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleQueueSelection('${item.id}');}">${isSelected ? selectionIndex + 1 : ''}</div>`
: ''
}
<div class="status ${item.status}"></div> <div class="status ${item.status}"></div>
<div class="queue-main"> <div class="queue-main">
<div class="queue-title-row"> <div class="queue-title-row">
<div class="title" title="${safeTitle}" role="button" tabindex="0" aria-expanded="${expandedQueueIds.has(item.id) ? 'true' : 'false'}" aria-controls="details-${item.id}" onclick="toggleQueueDetails('${item.id}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleQueueDetails('${item.id}');}">${liveBadge}${healthBadge}${mergeIcon}${isClip}${safeTitle}</div> <div class="title" title="${safeTitle}" role="button" tabindex="0" aria-expanded="${expandedQueueIds.has(item.id) ? 'true' : 'false'}" aria-controls="details-${item.id}" onclick="toggleQueueDetails('${item.id}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleQueueDetails('${item.id}');}">${liveBadge}${healthBadge}${mergeIcon}${isClip}${safeTitle}</div>
<div class="queue-status-label">${safeStatusLabel}</div> <div class="queue-status-label">${safeStatusLabel}</div>
<span class="remove" role="button" tabindex="0" aria-label="${escapeHtml(UI_TEXT.streamers.removeAria)}" onclick="removeFromQueue('${item.id}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();removeFromQueue('${item.id}');}">x</span>
</div> </div>
<div class="queue-meta">${safeMeta}${mergeMetaExtra}</div> <div class="queue-meta"><span class="queue-date">${safeDate}</span>${mergeMetaExtra}</div>
<div class="queue-progress-wrap" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(progressValue)}" aria-label="${escapeHtml(safeStatusLabel)}"> <div class="queue-progress-wrap" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(progressValue)}" aria-label="${escapeHtml(safeStatusLabel)}">
<div class="queue-progress-bar${progressClass}" style="width: ${progressValue}%;"></div> <div class="queue-progress-bar" style="width: ${progressValue}%;"></div>
</div>
<div class="queue-progress-info">
<span class="queue-progress-status${progressStatusClass}">${safeProgressStatus}</span>
<span class="queue-progress-metrics">${safeProgressMetrics}</span>
</div> </div>
<div class="queue-progress-text">${safeProgressText}</div>
<div class="queue-details${expandedQueueIds.has(item.id) ? ' expanded' : ''}" id="details-${item.id}"> <div class="queue-details${expandedQueueIds.has(item.id) ? ' expanded' : ''}" id="details-${item.id}">
<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>
@@ -565,7 +556,6 @@ function renderQueue(): void {
</div> </div>
</div> </div>
${item.status === 'error' ? `<button class="queue-retry-btn" type="button" title="${escapeHtml(UI_TEXT.queue.retryItem)}" aria-label="${escapeHtml(UI_TEXT.queue.retryItem)}" onclick="retryQueueItem('${item.id}')">&#x21bb;</button>` : ''} ${item.status === 'error' ? `<button class="queue-retry-btn" type="button" title="${escapeHtml(UI_TEXT.queue.retryItem)}" aria-label="${escapeHtml(UI_TEXT.queue.retryItem)}" onclick="retryQueueItem('${item.id}')">&#x21bb;</button>` : ''}
<span class="remove" role="button" tabindex="0" aria-label="${escapeHtml(UI_TEXT.streamers.removeAria)}" onclick="removeFromQueue('${item.id}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();removeFromQueue('${item.id}');}">x</span>
</div> </div>
`; `;
}).join(''); }).join('');
+62 -11
View File
@@ -19,14 +19,14 @@ async function connect(): Promise<void> {
const hasCredentials = Boolean((config.client_id ?? '').toString().trim() && (config.client_secret ?? '').toString().trim()); const hasCredentials = Boolean((config.client_id ?? '').toString().trim() && (config.client_secret ?? '').toString().trim());
if (!hasCredentials) { if (!hasCredentials) {
isConnected = false; isConnected = false;
updateStatus(UI_TEXT.status.noLogin, false); updateStatus(UI_TEXT.status.noLogin, false, 'public');
return; return;
} }
updateStatus(UI_TEXT.status.connecting, false); updateStatus(UI_TEXT.status.connecting, false, 'pending');
const success = await window.api.login(); const success = await window.api.login();
isConnected = success; isConnected = success;
updateStatus(success ? UI_TEXT.status.connected : UI_TEXT.status.connectFailedPublic, success); updateStatus(success ? UI_TEXT.status.connected : UI_TEXT.status.connectFailedPublic, success, success ? 'connected' : 'public');
} }
function formatBytesForMetrics(bytes: number): string { function formatBytesForMetrics(bytes: number): string {
@@ -172,19 +172,58 @@ function toggleRuntimeMetricsAutoRefresh(enabled: boolean): void {
} }
} }
function updateStatus(text: string, connected: boolean): void { type ConnectionStatusTone = 'connected' | 'public' | 'pending';
byId('statusText').textContent = text;
function updateStatus(text: string, connected: boolean, tone: ConnectionStatusTone = connected ? 'connected' : 'public'): void {
const statusText = byId('statusText');
statusText.textContent = text;
statusText.title = tone === 'connected'
? UI_TEXT.status.connectedHint
: tone === 'public'
? UI_TEXT.status.publicHint
: '';
const dot = byId('statusDot'); const dot = byId('statusDot');
dot.classList.remove('connected', 'error'); dot.classList.remove('connected', 'error', 'public');
dot.classList.add(connected ? 'connected' : 'error'); if (tone === 'connected') dot.classList.add('connected');
if (tone === 'public') dot.classList.add('public');
} }
function filterSettings(query: string): void { function filterSettings(query: string): void {
const normalizedQuery = query.trim().toLocaleLowerCase(getIntlLocale()); const normalizedQuery = query.trim().toLocaleLowerCase(getIntlLocale());
document.querySelectorAll<HTMLElement>('#settingsTab .settings-card').forEach((card) => { if (!normalizedQuery) return;
const match = Array.from(document.querySelectorAll<HTMLElement>('#settingsTab .settings-card[data-settings-pane]')).find((card) => {
const searchableText = (card.textContent || '').toLocaleLowerCase(getIntlLocale()); const searchableText = (card.textContent || '').toLocaleLowerCase(getIntlLocale());
card.hidden = normalizedQuery.length > 0 && !searchableText.includes(normalizedQuery); return searchableText.includes(normalizedQuery);
}); });
const pane = match?.dataset.settingsPane;
if (pane) setSettingsPane(pane);
}
function setSettingsPane(pane: string, source?: HTMLElement): void {
const tab = byId<HTMLElement>('settingsTab');
const cards = Array.from(tab.querySelectorAll<HTMLElement>('.settings-card[data-settings-pane]'));
const targetCard = cards.find((card) => card.dataset.settingsPane === pane);
if (!targetCard) return;
tab.dataset.settingsPane = pane;
cards.forEach((card) => {
card.hidden = card !== targetCard;
});
const panel = document.querySelector<HTMLElement>('[data-context-for="settings"]');
const buttons = Array.from(panel?.querySelectorAll<HTMLButtonElement>('.context-link[data-settings-pane]') || []);
const targetButton = source || buttons.find((button) => button.dataset.settingsPane === pane);
buttons.forEach((button) => {
const active = button === targetButton;
button.classList.toggle('active', active);
if (active) button.setAttribute('aria-current', 'page');
else button.removeAttribute('aria-current');
});
if (source) byId<HTMLInputElement>('settingsSearchInput').value = '';
tab.scrollTop = 0;
const list = panel?.querySelector<HTMLElement>('.context-list');
if (list) scheduleSegmentedIndicatorSync(list);
} }
function changeLanguage(lang: string): void { function changeLanguage(lang: string): void {
@@ -195,7 +234,12 @@ function changeLanguage(lang: string): void {
void window.api.saveConfig({ language: normalized }); void window.api.saveConfig({ language: normalized });
const currentStatus = byId('statusText').textContent?.trim() || ''; const currentStatus = byId('statusText').textContent?.trim() || '';
updateStatus(localizeCurrentStatusText(currentStatus), isConnected); const statusTone: ConnectionStatusTone = isConnected
? 'connected'
: byId('statusDot').classList.contains('public')
? 'public'
: 'pending';
updateStatus(localizeCurrentStatusText(currentStatus), isConnected, statusTone);
renderQueue(); renderQueue();
renderStreamers(); renderStreamers();
@@ -228,6 +272,7 @@ function updateLanguagePicker(lang: string): void {
en.classList.toggle('active', !isDe); en.classList.toggle('active', !isDe);
de.setAttribute('aria-pressed', String(isDe)); de.setAttribute('aria-pressed', String(isDe));
en.setAttribute('aria-pressed', String(!isDe)); en.setAttribute('aria-pressed', String(!isDe));
scheduleSegmentedIndicatorSync(byId<HTMLElement>('languagePicker'));
} }
function selectLanguageOption(lang: string): void { function selectLanguageOption(lang: string): void {
@@ -549,6 +594,7 @@ function syncPartMinutesFieldState(): void {
function collectDownloadSettingsPayload(): Partial<AppConfig> { function collectDownloadSettingsPayload(): Partial<AppConfig> {
return { return {
sidebar_split_view: byId<HTMLInputElement>('sidebarSplitViewToggle').checked,
download_mode: byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full', download_mode: byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full',
part_minutes: parseInt(byId<HTMLInputElement>('partMinutes').value, 10) || 120, part_minutes: parseInt(byId<HTMLInputElement>('partMinutes').value, 10) || 120,
parallel_downloads: parseInt(byId<HTMLSelectElement>('parallelDownloads').value, 10) || 1, parallel_downloads: parseInt(byId<HTMLSelectElement>('parallelDownloads').value, 10) || 1,
@@ -596,7 +642,8 @@ function collectFilenameTemplatePayload(showAlert = false): Partial<AppConfig> |
function collectAutoSavePayload(): Partial<AppConfig> { function collectAutoSavePayload(): Partial<AppConfig> {
const payload: Partial<AppConfig> = { const payload: Partial<AppConfig> = {
...collectCredentialsPayload(), ...collectCredentialsPayload(),
...collectDownloadSettingsPayload() ...collectDownloadSettingsPayload(),
sidebar_split_view: byId<HTMLInputElement>('sidebarSplitViewToggle').checked
}; };
const templatePayload = collectFilenameTemplatePayload(false); const templatePayload = collectFilenameTemplatePayload(false);
@@ -612,6 +659,7 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
return JSON.stringify([ return JSON.stringify([
effective.client_id ?? '', effective.client_id ?? '',
effective.client_secret ?? '', effective.client_secret ?? '',
effective.sidebar_split_view !== false,
effective.download_mode ?? 'full', effective.download_mode ?? 'full',
effective.part_minutes ?? 120, effective.part_minutes ?? 120,
effective.parallel_downloads ?? 1, effective.parallel_downloads ?? 1,
@@ -650,6 +698,8 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
function syncSettingsFormFromConfig(): void { function syncSettingsFormFromConfig(): void {
byId<HTMLInputElement>('clientId').value = config.client_id ?? ''; byId<HTMLInputElement>('clientId').value = config.client_id ?? '';
byId<HTMLInputElement>('clientSecret').value = config.client_secret ?? ''; byId<HTMLInputElement>('clientSecret').value = config.client_secret ?? '';
byId<HTMLInputElement>('sidebarSplitViewToggle').checked = config.sidebar_split_view !== false;
applySidebarLayoutPreference(config.sidebar_split_view !== false);
byId<HTMLSelectElement>('downloadMode').value = (config.download_mode as 'parts' | 'full') ?? 'full'; byId<HTMLSelectElement>('downloadMode').value = (config.download_mode as 'parts' | 'full') ?? 'full';
byId<HTMLInputElement>('partMinutes').value = String((config.part_minutes as number) || 120); byId<HTMLInputElement>('partMinutes').value = String((config.part_minutes as number) || 120);
byId<HTMLSelectElement>('parallelDownloads').value = String((config.parallel_downloads as number) || 1); byId<HTMLSelectElement>('parallelDownloads').value = String((config.parallel_downloads as number) || 1);
@@ -784,6 +834,7 @@ function initSettingsAutoSave(): void {
const immediateSaveIds = [ const immediateSaveIds = [
'downloadMode', 'downloadMode',
'sidebarSplitViewToggle',
'parallelDownloads', 'parallelDownloads',
'performanceMode', 'performanceMode',
'smartSchedulerToggle', 'smartSchedulerToggle',
+352 -131
View File
@@ -1,12 +1,90 @@
let selectStreamerRequestId = 0; let selectStreamerRequestId = 0;
let vodRenderTaskId = 0; let vodRenderTaskId = 0;
let streamerIndicatorFrame: number | null = null;
const VOD_RENDER_CHUNK_SIZE = 64; const VOD_RENDER_CHUNK_SIZE = 64;
function scheduleStreamerActiveIndicatorSync(): void {
if (streamerIndicatorFrame !== null) return;
streamerIndicatorFrame = requestAnimationFrame(() => {
streamerIndicatorFrame = null;
const list = document.getElementById('streamerList');
const activeItem = list?.querySelector<HTMLElement>('.streamer-item.active');
if (!list || !activeItem) {
list?.classList.remove('streamer-indicator-visible');
return;
}
list.style.setProperty('--streamer-active-x', `${activeItem.offsetLeft}px`);
list.style.setProperty('--streamer-active-y', `${activeItem.offsetTop}px`);
list.style.setProperty('--streamer-active-width', `${activeItem.offsetWidth}px`);
list.style.setProperty('--streamer-active-height', `${activeItem.offsetHeight}px`);
list.classList.add('streamer-indicator-visible');
requestAnimationFrame(() => list.classList.add('streamer-indicator-ready'));
});
}
// Live status snapshot — updated by the main process via the // Live status snapshot — updated by the main process via the
// 'live-status-batch-update' IPC event. Keys are lowercase logins so // 'live-status-batch-update' IPC event. Keys are lowercase logins so
// the lookup is case-insensitive regardless of how the streamer's // the lookup is case-insensitive regardless of how the streamer's
// name was added (display-cased vs login-cased). // name was added (display-cased vs login-cased).
const liveStatusByLogin = new Map<string, boolean>(); const liveStatusByLogin = new Map<string, boolean>();
const streamerDisplayNames = new Map<string, string>();
function rememberStreamerDisplayName(login: string, displayName: string): void {
const normalizedLogin = login.trim().toLowerCase();
const normalizedDisplayName = displayName.trim();
if (!normalizedLogin || !normalizedDisplayName) return;
const changed = streamerDisplayNames.get(normalizedLogin) !== normalizedDisplayName;
if (changed) streamerDisplayNames.set(normalizedLogin, normalizedDisplayName);
if (currentStreamer?.toLowerCase() === normalizedLogin) {
const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle;
if (typeof setTitle === 'function') setTitle(normalizedDisplayName);
}
if (changed) renderStreamers();
}
(window as unknown as { rememberStreamerDisplayName: typeof rememberStreamerDisplayName }).rememberStreamerDisplayName = rememberStreamerDisplayName;
async function hydrateStreamerDisplayNames(): Promise<void> {
const configuredNames = config.streamer_display_names || {};
let changed = false;
for (const [login, displayName] of Object.entries(configuredNames)) {
const normalizedLogin = login.trim().toLowerCase();
const normalizedDisplayName = displayName.trim();
if (normalizedLogin && normalizedDisplayName && streamerDisplayNames.get(normalizedLogin) !== normalizedDisplayName) {
streamerDisplayNames.set(normalizedLogin, normalizedDisplayName);
changed = true;
}
}
const streamers = (config.streamers ?? []) as string[];
if (streamers.length === 0) {
if (changed) renderStreamers();
return;
}
try {
const resolvedNames = await window.api.getStreamerDisplayNames(streamers);
for (const [login, displayName] of Object.entries(resolvedNames)) {
const normalizedLogin = login.trim().toLowerCase();
const normalizedDisplayName = displayName.trim();
if (normalizedLogin && normalizedDisplayName && streamerDisplayNames.get(normalizedLogin) !== normalizedDisplayName) {
streamerDisplayNames.set(normalizedLogin, normalizedDisplayName);
changed = true;
}
}
} catch { }
if (changed) {
if (currentStreamer) {
const displayName = streamerDisplayNames.get(currentStreamer.toLowerCase());
const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle;
if (displayName && typeof setTitle === 'function') setTitle(displayName);
}
renderStreamers();
}
}
(window as unknown as { hydrateStreamerDisplayNames: typeof hydrateStreamerDisplayNames }).hydrateStreamerDisplayNames = hydrateStreamerDisplayNames;
async function initLiveStatusSubscription(): Promise<void> { async function initLiveStatusSubscription(): Promise<void> {
try { try {
@@ -37,6 +115,15 @@ async function initLiveStatusSubscription(): Promise<void> {
// the clear button. Shared across streamers (acts like a search bar). // the clear button. Shared across streamers (acts like a search bar).
let lastLoadedVods: VOD[] = []; let lastLoadedVods: VOD[] = [];
let lastLoadedStreamer: string | null = null; let lastLoadedStreamer: string | null = null;
interface CachedStreamerVods {
userId: string;
vods: VOD[];
updatedAt: number;
}
const streamerVodCache = new Map<string, CachedStreamerVods>();
const streamerVodLoads = new Map<string, Promise<CachedStreamerVods | null>>();
let streamerBackgroundRefreshTimer: number | null = null;
const STREAMER_BACKGROUND_REFRESH_MS = 5 * 60 * 1000;
let vodFilterQuery = ''; let vodFilterQuery = '';
const VOD_FILTER_STORAGE_KEY = 'twitch-vod-manager:vod-filter'; const VOD_FILTER_STORAGE_KEY = 'twitch-vod-manager:vod-filter';
@@ -222,7 +309,7 @@ function focusVodFilter(): void {
function buildVodCardHtml(vod: VOD, streamer: string, downloadedIds?: Set<string>): string { function buildVodCardHtml(vod: VOD, streamer: string, downloadedIds?: Set<string>): string {
const thumb = vod.thumbnail_url.replace('%{width}', '320').replace('%{height}', '180'); const thumb = vod.thumbnail_url.replace('%{width}', '320').replace('%{height}', '180');
const date = formatUiDate(vod.created_at); const date = formatVodCardDate(vod.created_at);
const safeDisplayTitle = escapeHtml(vod.title || UI_TEXT.vods.untitled); const safeDisplayTitle = escapeHtml(vod.title || UI_TEXT.vods.untitled);
const safeUrlAttr = escapeHtml(vod.url); const safeUrlAttr = escapeHtml(vod.url);
const safeTitleAttr = escapeHtml(vod.title || ''); const safeTitleAttr = escapeHtml(vod.title || '');
@@ -254,15 +341,14 @@ function buildVodCardHtml(vod: VOD, streamer: string, downloadedIds?: Set<string
<input type="checkbox" class="vod-select-checkbox" data-vod-url="${safeUrlAttr}" ${isChecked ? 'checked' : ''} aria-label="${escapeHtml(UI_TEXT.vods.selectAriaLabel)}"> <input type="checkbox" class="vod-select-checkbox" data-vod-url="${safeUrlAttr}" ${isChecked ? 'checked' : ''} aria-label="${escapeHtml(UI_TEXT.vods.selectAriaLabel)}">
${downloadedBadge} ${downloadedBadge}
<div class="vod-thumb-wrap"> <div class="vod-thumb-wrap">
<img class="vod-thumbnail" loading="lazy" decoding="async" src="${thumb}" alt="" title="${escapeHtml(UI_TEXT.vods.openOnTwitch)}" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 180%22><rect fill=%22%23333%22 width=%22320%22 height=%22180%22/></svg>'"> <img class="vod-thumbnail" loading="lazy" decoding="async" src="${thumb}" alt="" title="${escapeHtml(UI_TEXT.vods.selectAriaLabel)}" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 180%22><rect fill=%22%23333%22 width=%22320%22 height=%22180%22/></svg>'">
<div class="vod-duration-badge">${escapeHtml(vod.duration)}</div> <div class="vod-duration-badge">${escapeHtml(vod.duration)}</div>
</div> </div>
<div class="vod-info"> <div class="vod-info">
<div class="vod-title" title="${escapeHtml(vod.title || '')}">${safeDisplayTitle}</div> <div class="vod-title" title="${escapeHtml(vod.title || '')}">${safeDisplayTitle}</div>
<div class="vod-meta"> <div class="vod-meta">
<span>${date}</span> <span class="vod-date"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M7 3v3M17 3v3M4 9h16M5 5h14a1 1 0 0 1 1 1v14H4V6a1 1 0 0 1 1-1z"></path></svg>${date}</span>
<span>${escapeHtml(vod.duration)}</span> <span class="vod-views"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12z"></path><circle cx="12" cy="12" r="2.5"></circle></svg>${formatUiNumber(vod.view_count)} ${escapeHtml(UI_TEXT.vods.views)}</span>
<span>${formatUiNumber(vod.view_count)} ${escapeHtml(UI_TEXT.vods.views)}</span>
</div> </div>
</div> </div>
<div class="vod-actions"> <div class="vod-actions">
@@ -412,6 +498,76 @@ function initCutterDragDrop(): void {
}); });
} }
let streamerContextMenu: HTMLDivElement | null = null;
let streamerContextMenuCleanup: (() => void) | null = null;
function dismissStreamerContextMenu(): void {
streamerContextMenuCleanup?.();
streamerContextMenuCleanup = null;
streamerContextMenu = null;
}
function showStreamerContextMenu(event: MouseEvent, streamer: string): void {
event.preventDefault();
event.stopPropagation();
dismissStreamerContextMenu();
const autoList = (config.auto_record_streamers as string[] | undefined) || [];
const vodList = (config.auto_vod_download_streamers as string[] | undefined) || [];
const menu = document.createElement('div');
menu.className = 'streamer-context-menu';
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-label', streamerDisplayNames.get(streamer.toLowerCase()) || streamer);
const appendAction = (action: 'auto' | 'vod' | 'record', label: string, active: boolean, handler: () => void): void => {
const button = document.createElement('button');
button.type = 'button';
button.dataset.streamerAction = action;
button.className = `streamer-context-action streamer-context-${action}${active ? ' is-active' : ''}`;
button.textContent = label;
button.setAttribute('role', 'menuitem');
button.setAttribute('aria-label', action === 'auto'
? UI_TEXT.streamers?.autoRecordTitle || 'Auto-record'
: action === 'vod'
? UI_TEXT.streamers?.autoVodTitle || 'Auto-download VODs'
: UI_TEXT.streamers?.recordLiveTitle || 'Record live now');
button.addEventListener('click', () => {
dismissStreamerContextMenu();
handler();
});
menu.appendChild(button);
};
appendAction('auto', 'AUTO', autoList.includes(streamer), () => { void toggleAutoRecord(streamer); });
appendAction('vod', 'VOD', vodList.includes(streamer), () => { void toggleAutoVodDownload(streamer); });
appendAction('record', 'REC', false, () => { void triggerLiveRecording(streamer); });
document.body.appendChild(menu);
const bounds = menu.getBoundingClientRect();
const margin = 8;
menu.style.left = `${Math.max(margin, Math.min(event.clientX, window.innerWidth - bounds.width - margin))}px`;
menu.style.top = `${Math.max(margin, Math.min(event.clientY, window.innerHeight - bounds.height - margin))}px`;
const onPointerDown = (pointerEvent: PointerEvent): void => {
if (!menu.contains(pointerEvent.target as Node)) dismissStreamerContextMenu();
};
const onKeyDown = (keyEvent: KeyboardEvent): void => {
if (keyEvent.key === 'Escape') dismissStreamerContextMenu();
};
const onResize = (): void => dismissStreamerContextMenu();
streamerContextMenu = menu;
streamerContextMenuCleanup = () => {
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('keydown', onKeyDown, true);
window.removeEventListener('resize', onResize);
menu.remove();
};
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('keydown', onKeyDown, true);
window.addEventListener('resize', onResize);
menu.querySelector<HTMLButtonElement>('button')?.focus();
}
function renderStreamers(): void { function renderStreamers(): void {
const list = byId('streamerList'); const list = byId('streamerList');
list.replaceChildren(); list.replaceChildren();
@@ -436,6 +592,7 @@ function renderStreamers(): void {
if (counter) counter.textContent = ''; if (counter) counter.textContent = '';
const bulkBtn = document.getElementById('btnStreamerBulkRemove') as HTMLButtonElement | null; const bulkBtn = document.getElementById('btnStreamerBulkRemove') as HTMLButtonElement | null;
if (bulkBtn) bulkBtn.classList.add('is-hidden'); if (bulkBtn) bulkBtn.classList.add('is-hidden');
scheduleStreamerActiveIndicatorSync();
return; return;
} }
@@ -443,14 +600,7 @@ function renderStreamers(): void {
// stays accurate after add/remove/live-status changes. // stays accurate after add/remove/live-status changes.
const counter = document.getElementById('streamerSectionCounter'); const counter = document.getElementById('streamerSectionCounter');
if (counter) { if (counter) {
const liveCount = all.reduce((n, s) => n + (liveStatusByLogin.get(s.toLowerCase()) === true ? 1 : 0), 0); counter.textContent = all.length === 0 ? '' : String(all.length);
if (all.length === 0) {
counter.textContent = '';
} else if (liveCount > 0) {
counter.innerHTML = `${all.length} <span class="streamer-section-counter-divider" aria-hidden="true">·</span> <span class="streamer-section-counter-live">${liveCount} live</span>`;
} else {
counter.textContent = String(all.length);
}
} }
const q = (streamerListFilterQuery || '').trim().toLowerCase(); const q = (streamerListFilterQuery || '').trim().toLowerCase();
@@ -487,75 +637,7 @@ function renderStreamers(): void {
const nameSpan = document.createElement('span'); const nameSpan = document.createElement('span');
nameSpan.className = 'streamer-name' + (isLive ? ' is-live' : ''); nameSpan.className = 'streamer-name' + (isLive ? ' is-live' : '');
nameSpan.textContent = streamer; nameSpan.textContent = streamerDisplayNames.get(streamer.toLowerCase()) || streamer;
// Three streamer-row action chips (AUTO toggle / VOD toggle / REC
// one-shot). All share the same accessibility wiring:
// role="button", tabindex="0", aria-pressed for the toggles +
// aria-label for screen readers, plus Enter/Space keydown
// activation. wireChipButton centralises that so each chip only
// declares its own visual class + label + handler.
const wireChipButton = (el: HTMLElement, opts: {
handler: () => void;
ariaLabel: string;
pressed?: boolean;
}): void => {
el.setAttribute('role', 'button');
el.setAttribute('tabindex', '0');
el.setAttribute('aria-label', opts.ariaLabel);
if (opts.pressed !== undefined) el.setAttribute('aria-pressed', String(opts.pressed));
el.addEventListener('click', (e) => {
e.stopPropagation();
opts.handler();
});
el.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
opts.handler();
}
});
};
// AUTO toggle — when enabled, the main-process auto-record poller
// watches this channel for offline->live transitions and queues a
// live recording automatically.
const autoList = (config.auto_record_streamers as string[] | undefined) || [];
const isAutoOn = autoList.includes(streamer);
const autoBtn = document.createElement('span');
autoBtn.className = 'streamer-auto' + (isAutoOn ? ' active' : '');
autoBtn.textContent = 'AUTO';
autoBtn.title = UI_TEXT.streamers?.autoRecordTitle || 'Auto-record when this streamer goes live';
wireChipButton(autoBtn, {
handler: () => { void toggleAutoRecord(streamer); },
ariaLabel: UI_TEXT.streamers?.autoRecordTitle || 'Auto-record',
pressed: isAutoOn
});
// VOD-auto-download toggle — periodic scan of this streamer's
// VOD list, auto-queues anything new within the age window.
const vodList = (config.auto_vod_download_streamers as string[] | undefined) || [];
const isVodOn = vodList.includes(streamer);
const vodBtn = document.createElement('span');
vodBtn.className = 'streamer-vod' + (isVodOn ? ' active' : '');
vodBtn.textContent = 'VOD';
vodBtn.title = UI_TEXT.streamers?.autoVodTitle || 'Auto-download new VODs';
wireChipButton(vodBtn, {
handler: () => { void toggleAutoVodDownload(streamer); },
ariaLabel: UI_TEXT.streamers?.autoVodTitle || 'Auto-download new VODs',
pressed: isVodOn
});
// Live-record one-shot — triggers a recording immediately (server
// verifies the streamer is online before honoring the request).
const recBtn = document.createElement('span');
recBtn.className = 'streamer-rec';
recBtn.textContent = 'REC';
recBtn.title = UI_TEXT.streamers?.recordLiveTitle || 'Record live now';
wireChipButton(recBtn, {
handler: () => { void triggerLiveRecording(streamer); },
ariaLabel: UI_TEXT.streamers?.recordLiveTitle || 'Record live now'
});
const removeSpan = document.createElement('span'); const removeSpan = document.createElement('span');
removeSpan.className = 'remove'; removeSpan.className = 'remove';
removeSpan.textContent = 'x'; removeSpan.textContent = 'x';
@@ -573,7 +655,22 @@ function renderStreamers(): void {
void removeStreamer(streamer); void removeStreamer(streamer);
} }
}); });
item.append(nameSpan, autoBtn, vodBtn, recBtn, removeSpan); item.append(nameSpan, removeSpan);
item.addEventListener('contextmenu', (contextEvent) => {
showStreamerContextMenu(contextEvent, streamer);
});
item.addEventListener('keydown', (keyEvent) => {
if (keyEvent.key !== 'ContextMenu' && !(keyEvent.shiftKey && keyEvent.key === 'F10')) return;
keyEvent.preventDefault();
const rect = item.getBoundingClientRect();
showStreamerContextMenu(new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
clientX: rect.left + 12,
clientY: rect.top + 12
}), streamer);
});
item.addEventListener('click', () => { item.addEventListener('click', () => {
// Skip click if drag was just released — drop fires after dragend // Skip click if drag was just released — drop fires after dragend
@@ -598,6 +695,7 @@ function renderStreamers(): void {
if (bulkBtn) bulkBtn.classList.toggle('is-hidden', all.length < STREAMER_FILTER_THRESHOLD); if (bulkBtn) bulkBtn.classList.toggle('is-hidden', all.length < STREAMER_FILTER_THRESHOLD);
initStreamerDragDrop(); initStreamerDragDrop();
scheduleStreamerActiveIndicatorSync();
} }
function onStreamerListFilterChange(): void { function onStreamerListFilterChange(): void {
@@ -710,6 +808,7 @@ async function addStreamer(): Promise<void> {
config = await window.api.saveConfig({ streamers: config.streamers }); config = await window.api.saveConfig({ streamers: config.streamers });
input.value = ''; input.value = '';
renderStreamers(); renderStreamers();
void hydrateStreamerDisplayNames();
await selectStreamer(name); await selectStreamer(name);
} }
@@ -728,6 +827,77 @@ async function removeStreamer(name: string): Promise<void> {
setVodGridEmptyState(byId('vodGrid'), UI_TEXT.vods.noneTitle, UI_TEXT.vods.noneText); setVodGridEmptyState(byId('vodGrid'), UI_TEXT.vods.noneTitle, UI_TEXT.vods.noneText);
} }
function normalizeStreamerCacheKey(name: string): string {
return name.trim().toLowerCase();
}
function vodCollectionsMatch(left: VOD[], right: VOD[]): boolean {
if (left.length !== right.length) return false;
return left.every((vod, index) => {
const other = right[index];
return Boolean(other)
&& vod.id === other.id
&& vod.title === other.title
&& vod.created_at === other.created_at
&& vod.duration === other.duration
&& vod.thumbnail_url === other.thumbnail_url
&& vod.url === other.url
&& vod.view_count === other.view_count;
});
}
async function loadStreamerVods(name: string, forceRefresh = false): Promise<CachedStreamerVods | null> {
const key = normalizeStreamerCacheKey(name);
const cached = streamerVodCache.get(key);
if (!forceRefresh && cached) return cached;
const pending = streamerVodLoads.get(key);
if (pending) return pending;
const task = (async () => {
const userId = await window.api.getUserId(name);
if (!userId) return null;
const vods = await window.api.getVODs(userId, forceRefresh);
const entry = { userId, vods: Array.isArray(vods) ? vods : [], updatedAt: Date.now() };
streamerVodCache.set(key, entry);
return entry;
})();
streamerVodLoads.set(key, task);
try {
return await task;
} finally {
if (streamerVodLoads.get(key) === task) streamerVodLoads.delete(key);
}
}
async function preloadConfiguredStreamerData(streamers: string[] = (config.streamers ?? []) as string[]): Promise<void> {
const profilePreloader = (window as unknown as { preloadStreamerProfiles?: (logins: string[]) => Promise<void> }).preloadStreamerProfiles;
const jobs: Promise<unknown>[] = streamers.map((name) => loadStreamerVods(name));
if (typeof profilePreloader === 'function') jobs.push(profilePreloader(streamers));
await Promise.allSettled(jobs);
}
async function refreshConfiguredStreamersInBackground(): Promise<void> {
const streamers = [...((config.streamers ?? []) as string[])];
const profileRefresher = (window as unknown as { refreshStreamerProfilesInBackground?: (logins: string[]) => Promise<void> }).refreshStreamerProfilesInBackground;
const activeKey = currentStreamer ? normalizeStreamerCacheKey(currentStreamer) : '';
const previousActiveVods = activeKey ? streamerVodCache.get(activeKey)?.vods ?? [] : [];
const jobs = streamers.map((name) => loadStreamerVods(name, true));
await Promise.allSettled([
...jobs,
...(typeof profileRefresher === 'function' ? [profileRefresher(streamers)] : [])
]);
if (!currentStreamer || normalizeStreamerCacheKey(currentStreamer) !== activeKey) return;
const updated = streamerVodCache.get(activeKey)?.vods;
if (updated && !vodCollectionsMatch(previousActiveVods, updated)) renderVODs(updated, currentStreamer, true);
}
function startStreamerBackgroundRefresh(): void {
if (streamerBackgroundRefreshTimer !== null) window.clearInterval(streamerBackgroundRefreshTimer);
streamerBackgroundRefreshTimer = window.setInterval(() => {
void refreshConfiguredStreamersInBackground();
}, STREAMER_BACKGROUND_REFRESH_MS);
}
async function selectStreamer(name: string, forceRefresh = false): Promise<void> { async function selectStreamer(name: string, forceRefresh = false): Promise<void> {
// Save where we were on the OLD streamer before navigating away. // Save where we were on the OLD streamer before navigating away.
rememberCurrentVodScroll(); rememberCurrentVodScroll();
@@ -742,15 +912,16 @@ async function selectStreamer(name: string, forceRefresh = false): Promise<void>
pendingScrollRestore = (typeof savedY === 'number' && savedY > 0) ? { streamer: name, y: savedY } : null; pendingScrollRestore = (typeof savedY === 'number' && savedY > 0) ? { streamer: name, y: savedY } : null;
renderStreamers(); renderStreamers();
const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle; const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle;
if (typeof setTitle === 'function') setTitle(name); const displayName = streamerDisplayNames.get(name.toLowerCase()) || name;
else byId('pageTitle').textContent = name; if (typeof setTitle === 'function') setTitle(displayName);
else byId('pageTitle').textContent = displayName;
// Kick off the profile header load in parallel with VOD fetching. // Kick off the profile header load in parallel with VOD fetching.
// It's a separate request stream and not strictly needed for the VOD // It's a separate request stream and not strictly needed for the VOD
// grid, so we don't await it here — the skeleton appears immediately. // grid, so we don't await it here — the skeleton appears immediately.
const profileLoader = (window as unknown as { loadStreamerProfile?: (login: string) => Promise<void> }).loadStreamerProfile; const profileLoader = (window as unknown as { loadStreamerProfile?: (login: string, forceRefresh?: boolean) => Promise<void> }).loadStreamerProfile;
if (typeof profileLoader === 'function') { if (typeof profileLoader === 'function') {
void profileLoader(name); void profileLoader(name, forceRefresh);
} }
if (!isConnected) { if (!isConnected) {
@@ -764,36 +935,32 @@ async function selectStreamer(name: string, forceRefresh = false): Promise<void>
updateStatus(UI_TEXT.status.noLogin, false); updateStatus(UI_TEXT.status.noLogin, false);
} }
// Skeleton loader — six placeholder cards while VODs come in. Much const key = normalizeStreamerCacheKey(name);
// less jarring than a "Loading..." text block in an otherwise blank const cached = streamerVodCache.get(key);
// grid. Shimmer animation is in CSS. if (cached) {
byId('vodGrid').innerHTML = Array.from({ length: 6 }, () => ` renderVODs(cached.vods, name);
<div class="vod-card vod-card-skeleton"> } else {
<div class="vod-skel-thumb"></div> byId('vodGrid').innerHTML = Array.from({ length: 6 }, () => `
<div class="vod-info"> <div class="vod-card vod-card-skeleton">
<div class="vod-skel-line title"></div> <div class="vod-skel-thumb"></div>
<div class="vod-skel-line meta-1"></div> <div class="vod-info">
<div class="vod-skel-line meta-2"></div> <div class="vod-skel-line title"></div>
<div class="vod-skel-line meta-1"></div>
<div class="vod-skel-line meta-2"></div>
</div>
</div> </div>
</div> `).join('');
`).join('');
const userId = await window.api.getUserId(name);
if (isStaleRequest()) {
return;
} }
if (!userId) { const loaded = await loadStreamerVods(name, forceRefresh);
if (isStaleRequest()) return;
if (!loaded) {
byId('vodGrid').innerHTML = `<div class="empty-state"><h3>${UI_TEXT.vods.notFound}</h3></div>`; byId('vodGrid').innerHTML = `<div class="empty-state"><h3>${UI_TEXT.vods.notFound}</h3></div>`;
return; return;
} }
const vods = await window.api.getVODs(userId, forceRefresh); if (!cached || !vodCollectionsMatch(cached.vods, loaded.vods)) renderVODs(loaded.vods, name, Boolean(cached));
if (isStaleRequest()) {
return;
}
renderVODs(vods, name);
} }
function createVodEmptyStateIcon(): SVGSVGElement { function createVodEmptyStateIcon(): SVGSVGElement {
@@ -826,16 +993,17 @@ function setVodGridEmptyState(grid: HTMLElement, title: string, text: string): v
grid.replaceChildren(wrap); grid.replaceChildren(wrap);
} }
function renderVODs(vods: VOD[] | null | undefined, streamer: string): void { function renderVODs(vods: VOD[] | null | undefined, streamer: string, animateChanges = false): void {
// Clear bulk-selection on streamer switch — selection is per-streamer // Clear bulk-selection on streamer switch — selection is per-streamer
if (lastLoadedStreamer && lastLoadedStreamer !== streamer && selectedVodUrls.size > 0) { if (lastLoadedStreamer && lastLoadedStreamer !== streamer && selectedVodUrls.size > 0) {
selectedVodUrls.clear(); selectedVodUrls.clear();
updateVodBulkBar(); updateVodBulkBar();
} }
const motion = animateChanges ? captureVodGridMotion() : undefined;
lastLoadedVods = Array.isArray(vods) ? vods : []; lastLoadedVods = Array.isArray(vods) ? vods : [];
lastLoadedStreamer = streamer; lastLoadedStreamer = streamer;
initVodGridSelectionDelegation(); initVodGridSelectionDelegation();
renderVodGridFromCurrentState(); renderVodGridFromCurrentState(motion);
// After the first chunk lands the grid has size, so scroll-restore can // After the first chunk lands the grid has size, so scroll-restore can
// succeed. Use a small delay to let chunked rendering paint. // succeed. Use a small delay to let chunked rendering paint.
@@ -862,13 +1030,8 @@ function initVodGridSelectionDelegation(): void {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
// 1) Checkbox toggles (bulk-select) // 1) Checkbox toggles (bulk-select)
if (target instanceof HTMLInputElement && target.classList.contains('vod-select-checkbox')) { if (target instanceof HTMLInputElement && target.classList.contains('vod-select-checkbox')) {
const url = target.dataset.vodUrl || '';
if (!url) return;
if (target.checked) selectedVodUrls.add(url);
else selectedVodUrls.delete(url);
const card = target.closest('.vod-card') as HTMLElement | null; const card = target.closest('.vod-card') as HTMLElement | null;
if (card) card.classList.toggle('selected', target.checked); if (card) setVodCardSelection(card, target.checked);
updateVodBulkBar();
return; return;
} }
@@ -886,14 +1049,10 @@ function initVodGridSelectionDelegation(): void {
return; return;
} }
// 3) Click on thumbnail / title / meta -> open VOD on Twitch in the
// OS default browser. Convenient + non-destructive.
const card = target.closest('.vod-card') as HTMLElement | null; const card = target.closest('.vod-card') as HTMLElement | null;
if (!card) return; if (!card) return;
if (target.closest('.vod-actions') || target.classList.contains('vod-select-checkbox')) return; if (target.closest('.vod-actions') || target.classList.contains('vod-select-checkbox')) return;
const ctx = readVodCardContext(card); toggleVodCardSelection(card);
if (!ctx) return;
void window.api.openExternal(ctx.url);
}); });
grid.addEventListener('contextmenu', (e) => { grid.addEventListener('contextmenu', (e) => {
@@ -905,23 +1064,34 @@ function initVodGridSelectionDelegation(): void {
showVodContextMenu(e.clientX, e.clientY, ctx); showVodContextMenu(e.clientX, e.clientY, ctx);
}); });
// Enter / Space on a focused VOD card opens the VOD on Twitch — same
// outcome as a mouse click on the thumbnail. Skip when focus is on a
// child (action button, checkbox) because those have their own
// keyboard handlers (native button + checkbox semantics).
grid.addEventListener('keydown', (e) => { grid.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return; if (e.key !== 'Enter' && e.key !== ' ') return;
const target = e.target as HTMLElement | null; const target = e.target as HTMLElement | null;
if (!target) return; if (!target) return;
const card = target.closest('.vod-card') as HTMLElement | null; const card = target.closest('.vod-card') as HTMLElement | null;
if (!card || card !== target) return; if (!card || card !== target) return;
const ctx = readVodCardContext(card);
if (!ctx) return;
e.preventDefault(); e.preventDefault();
void window.api.openExternal(ctx.url); toggleVodCardSelection(card);
}); });
} }
function setVodCardSelection(card: HTMLElement, selected: boolean): void {
const checkbox = card.querySelector<HTMLInputElement>('.vod-select-checkbox');
const url = checkbox?.dataset.vodUrl || '';
if (!checkbox || !url) return;
checkbox.checked = selected;
card.classList.toggle('selected', selected);
if (selected) selectedVodUrls.add(url);
else selectedVodUrls.delete(url);
updateVodBulkBar();
}
function toggleVodCardSelection(card: HTMLElement): void {
const checkbox = card.querySelector<HTMLInputElement>('.vod-select-checkbox');
if (!checkbox) return;
setVodCardSelection(card, !checkbox.checked);
}
let activeVodContextMenu: HTMLElement | null = null; let activeVodContextMenu: HTMLElement | null = null;
function closeVodContextMenu(): void { function closeVodContextMenu(): void {
@@ -1175,7 +1345,48 @@ async function bulkAddSelectedVodsToQueue(): Promise<void> {
} }
} }
function renderVodGridFromCurrentState(): void { interface VodGridMotion {
rects: Map<string, { left: number; top: number }>;
ids: Set<string>;
}
function captureVodGridMotion(): VodGridMotion {
const rects = new Map<string, { left: number; top: number }>();
const ids = new Set<string>();
document.querySelectorAll<HTMLElement>('#vodGrid .vod-card[data-vod-id]').forEach((card) => {
const id = card.dataset.vodId;
if (!id) return;
const rect = card.getBoundingClientRect();
rects.set(id, { left: rect.left, top: rect.top });
ids.add(id);
});
return { rects, ids };
}
function animateVodGridMotion(motion: VodGridMotion): void {
document.querySelectorAll<HTMLElement>('#vodGrid .vod-card[data-vod-id]').forEach((card, index) => {
const id = card.dataset.vodId || '';
const previous = motion.rects.get(id);
if (previous) {
const rect = card.getBoundingClientRect();
const x = previous.left - rect.left;
const y = previous.top - rect.top;
if (Math.abs(x) > 0.5 || Math.abs(y) > 0.5) {
card.animate([
{ transform: `translate(${x}px, ${y}px)` },
{ transform: 'translate(0, 0)' }
], { duration: 420, easing: 'cubic-bezier(.22, 1, .36, 1)' });
}
return;
}
card.animate([
{ opacity: 0, transform: 'translateY(16px) scale(.98)' },
{ opacity: 1, transform: 'translateY(0) scale(1)' }
], { duration: 360, delay: Math.min(index * 24, 144), easing: 'cubic-bezier(.22, 1, .36, 1)', fill: 'backwards' });
});
}
function renderVodGridFromCurrentState(motion?: VodGridMotion): void {
if (!lastLoadedStreamer) return; if (!lastLoadedStreamer) return;
const grid = byId('vodGrid'); const grid = byId('vodGrid');
@@ -1237,6 +1448,8 @@ function renderVodGridFromCurrentState(): void {
if (startIndex + chunk.length < filtered.length) { if (startIndex + chunk.length < filtered.length) {
scheduleNextChunk(startIndex + chunk.length); scheduleNextChunk(startIndex + chunk.length);
} else if (motion) {
requestAnimationFrame(() => animateVodGridMotion(motion));
} }
}; };
@@ -1250,3 +1463,11 @@ async function refreshVODs(): Promise<void> {
await selectStreamer(currentStreamer, true); await selectStreamer(currentStreamer, true);
} }
(window as unknown as {
preloadConfiguredStreamerData: typeof preloadConfiguredStreamerData;
refreshConfiguredStreamersInBackground: typeof refreshConfiguredStreamersInBackground;
startStreamerBackgroundRefresh: typeof startStreamerBackgroundRefresh;
}).preloadConfiguredStreamerData = preloadConfiguredStreamerData;
(window as unknown as { refreshConfiguredStreamersInBackground: typeof refreshConfiguredStreamersInBackground }).refreshConfiguredStreamersInBackground = refreshConfiguredStreamersInBackground;
(window as unknown as { startStreamerBackgroundRefresh: typeof startStreamerBackgroundRefresh }).startStreamerBackgroundRefresh = startStreamerBackgroundRefresh;
+20 -2
View File
@@ -13,13 +13,26 @@ function getIntlLocale(): string {
} }
function formatUiDate(input: string | Date): string { function formatUiDate(input: string | Date): string {
return formatVodCardDate(input);
}
function formatVodCardDate(input: string | Date): string {
const date = input instanceof Date ? input : new Date(input); const date = input instanceof Date ? input : new Date(input);
return date.toLocaleDateString(getIntlLocale()); if (Number.isNaN(date.getTime())) return '';
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
return `${day}.${month}.${date.getFullYear()}`;
} }
function formatUiDateTime(input: string | Date): string { function formatUiDateTime(input: string | Date): string {
const date = input instanceof Date ? input : new Date(input); const date = input instanceof Date ? input : new Date(input);
return date.toLocaleString(getIntlLocale()); if (Number.isNaN(date.getTime())) return '';
const time = date.toLocaleTimeString(getIntlLocale(), {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
return `${formatVodCardDate(date)}, ${time}`;
} }
function formatUiNumber(value: number): string { function formatUiNumber(value: number): string {
@@ -185,6 +198,11 @@ function applyLanguageToStaticUI(): void {
setText('clientSecretLabel', UI_TEXT.static.clientSecretLabel); setText('clientSecretLabel', UI_TEXT.static.clientSecretLabel);
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('downloadBasicsTitle', UI_TEXT.static.downloadBasicsTitle);
setText('queueAutomationTitle', UI_TEXT.static.queueAutomationTitle);
setText('recordingMetadataTitle', UI_TEXT.static.recordingMetadataTitle);
setText('sidebarSplitViewLabel', UI_TEXT.static.sidebarSplitViewLabel);
setText('sidebarSplitViewHint', UI_TEXT.static.sidebarSplitViewHint);
setText('storageLabel', UI_TEXT.static.storageLabel); setText('storageLabel', UI_TEXT.static.storageLabel);
setText('selectFolderBtn', UI_TEXT.static.selectFolder); setText('selectFolderBtn', UI_TEXT.static.selectFolder);
setText('openFolderBtn', UI_TEXT.static.openFolder); setText('openFolderBtn', UI_TEXT.static.openFolder);
+4
View File
@@ -115,6 +115,7 @@ function syncWorkspaceUpdateState(state: 'idle' | 'available' | 'downloading' |
const description = byId('updateText').textContent?.trim() || UI_TEXT.static.checkUpdates; const description = byId('updateText').textContent?.trim() || UI_TEXT.static.checkUpdates;
banner.dataset.updateState = state; banner.dataset.updateState = state;
banner.hidden = state === 'idle';
button.dataset.updateState = state; button.dataset.updateState = state;
button.disabled = false; button.disabled = false;
if (state === 'downloading') { if (state === 'downloading') {
@@ -671,6 +672,9 @@ window.api.onUpdateNotAvailable(() => {
shouldOpenUpdateModalOnAvailable = false; shouldOpenUpdateModalOnAvailable = false;
manualUpdateCheckPending = false; manualUpdateCheckPending = false;
if (!updateReady) {
hideUpdateBanner();
}
}); });
window.api.onUpdateDownloadProgress((progress: UpdateDownloadProgress) => { window.api.onUpdateDownloadProgress((progress: UpdateDownloadProgress) => {
+23 -10
View File
@@ -26,7 +26,7 @@ const FRAMES_TO_CYCLE = 4;
// unbounded cache could balloon to hundreds of MB on a long browsing // unbounded cache could balloon to hundreds of MB on a long browsing
// session through a streamer with thousands of VODs. FIFO eviction // session through a streamer with thousands of VODs. FIFO eviction
// keeps the working set fresh without manual cleanup. // keeps the working set fresh without manual cleanup.
const MAX_CLIENT_STORYBOARD_CACHE = 100; const MAX_CLIENT_STORYBOARD_CACHE = 12;
function rememberStoryboard(vodId: string, sb: VodStoryboard | null): void { function rememberStoryboard(vodId: string, sb: VodStoryboard | null): void {
vodStoryboardClientCache.set(vodId, sb); vodStoryboardClientCache.set(vodId, sb);
@@ -133,7 +133,8 @@ async function activateHoverPreview(card: HTMLElement, vodId: string): Promise<v
const height = hostRect.height; const height = hostRect.height;
if (width <= 0 || height <= 0) return; if (width <= 0 || height <= 0) return;
if (storyboard.cellWidth <= 0 || storyboard.cellHeight <= 0) return; const frameDataUrls = Array.isArray(storyboard.frameDataUrls) ? storyboard.frameDataUrls.filter((url) => typeof url === 'string' && url.length > 0) : [];
if (frameDataUrls.length === 0 && (storyboard.cellWidth <= 0 || storyboard.cellHeight <= 0)) return;
// Position + Size voll inline gesetzt — kein CSS aspect-ratio mehr, das // Position + Size voll inline gesetzt — kein CSS aspect-ratio mehr, das
// sich mit JS-Dimensionen streiten koennte (siehe styles.css, die Klasse // sich mit JS-Dimensionen streiten koennte (siehe styles.css, die Klasse
@@ -145,13 +146,26 @@ async function activateHoverPreview(card: HTMLElement, vodId: string): Promise<v
// Skaliere X und Y unabhaengig, damit eine Cell die Overlay-Box exakt // Skaliere X und Y unabhaengig, damit eine Cell die Overlay-Box exakt
// fuellt — Twitch-Cell-Aspect kann von 16:9 minimal abweichen. // fuellt — Twitch-Cell-Aspect kann von 16:9 minimal abweichen.
const scaleX = width / storyboard.cellWidth;
const scaleY = height / storyboard.cellHeight;
overlay.style.backgroundImage = `url("${storyboard.spriteDataUrl.replace(/"/g, '%22')}")`;
overlay.style.backgroundSize = `${storyboard.cols * storyboard.cellWidth * scaleX}px ${storyboard.rows * storyboard.cellHeight * scaleY}px`;
overlay.style.backgroundRepeat = 'no-repeat'; overlay.style.backgroundRepeat = 'no-repeat';
const first = cellsToShow[0]; let advanceFrame: (index: number) => void;
overlay.style.backgroundPosition = `-${first.col * storyboard.cellWidth * scaleX}px -${first.row * storyboard.cellHeight * scaleY}px`; if (frameDataUrls.length > 0) {
overlay.style.backgroundSize = 'cover';
overlay.style.backgroundPosition = 'center';
advanceFrame = (index) => {
const frameUrl = frameDataUrls[index % frameDataUrls.length];
overlay.style.backgroundImage = `url("${frameUrl.replace(/"/g, '%22')}")`;
};
} else {
const scaleX = width / storyboard.cellWidth;
const scaleY = height / storyboard.cellHeight;
overlay.style.backgroundImage = `url("${storyboard.spriteDataUrl.replace(/"/g, '%22')}")`;
overlay.style.backgroundSize = `${storyboard.cols * storyboard.cellWidth * scaleX}px ${storyboard.rows * storyboard.cellHeight * scaleY}px`;
advanceFrame = (index) => {
const cell = cellsToShow[index % cellsToShow.length];
overlay.style.backgroundPosition = `-${cell.col * storyboard.cellWidth * scaleX}px -${cell.row * storyboard.cellHeight * scaleY}px`;
};
}
advanceFrame(0);
host.appendChild(overlay); host.appendChild(overlay);
// Trigger CSS transition to opacity:1 on the next frame. // Trigger CSS transition to opacity:1 on the next frame.
@@ -159,8 +173,7 @@ async function activateHoverPreview(card: HTMLElement, vodId: string): Promise<v
let frameIdx = 1; let frameIdx = 1;
const intervalId = window.setInterval(() => { const intervalId = window.setInterval(() => {
const cell = cellsToShow[frameIdx % cellsToShow.length]; advanceFrame(frameIdx);
overlay.style.backgroundPosition = `-${cell.col * storyboard.cellWidth * scaleX}px -${cell.row * storyboard.cellHeight * scaleY}px`;
frameIdx++; frameIdx++;
}, FRAME_INTERVAL_MS); }, FRAME_INTERVAL_MS);
+123 -1
View File
@@ -29,6 +29,7 @@ async function init(): Promise<void> {
byId<HTMLSelectElement>('themeSelect').value = config.theme ?? 'twitch'; byId<HTMLSelectElement>('themeSelect').value = config.theme ?? 'twitch';
byId<HTMLSelectElement>('languageSelect').value = config.language ?? 'en'; byId<HTMLSelectElement>('languageSelect').value = config.language ?? 'en';
updateLanguagePicker(config.language ?? 'en'); updateLanguagePicker(config.language ?? 'en');
setSettingsPane(byId<HTMLElement>('settingsTab').dataset.settingsPane || 'design');
byId<HTMLSelectElement>('downloadMode').value = config.download_mode ?? 'full'; byId<HTMLSelectElement>('downloadMode').value = config.download_mode ?? 'full';
byId<HTMLInputElement>('partMinutes').value = String(config.part_minutes ?? 120); byId<HTMLInputElement>('partMinutes').value = String(config.part_minutes ?? 120);
byId<HTMLSelectElement>('performanceMode').value = (config.performance_mode as string) || 'balanced'; byId<HTMLSelectElement>('performanceMode').value = (config.performance_mode as string) || 'balanced';
@@ -42,6 +43,7 @@ async function init(): Promise<void> {
changeTheme(config.theme ?? 'twitch'); changeTheme(config.theme ?? 'twitch');
renderStreamers(); renderStreamers();
void hydrateStreamerDisplayNames();
renderQueue(); renderQueue();
// Kick off live-status subscription so the sidebar dots populate. // Kick off live-status subscription so the sidebar dots populate.
@@ -75,6 +77,8 @@ async function init(): Promise<void> {
initCutterDragDrop(); initCutterDragDrop();
// Restore last active tab from previous session (default 'vods') // Restore last active tab from previous session (default 'vods')
initTopNavActiveIndicator();
initSegmentedIndicators();
showTab(loadPersistedActiveTab()); showTab(loadPersistedActiveTab());
window.api.onQueueUpdated(async (q: QueueItem[]) => { window.api.onQueueUpdated(async (q: QueueItem[]) => {
@@ -113,7 +117,7 @@ async function init(): Promise<void> {
} }
item.status = 'downloading'; item.status = 'downloading';
item.progress = progress.progress; if (progress.progress > 0) item.progress = Math.max(item.progress, progress.progress);
item.speed = progress.speed; item.speed = progress.speed;
item.eta = progress.eta; item.eta = progress.eta;
item.currentPart = progress.currentPart; item.currentPart = progress.currentPart;
@@ -142,6 +146,12 @@ async function init(): Promise<void> {
markQueueActivity(); markQueueActivity();
}); });
window.api.onDownloadPaused(() => {
downloading = false;
updateDownloadButtonState();
markQueueActivity();
});
window.api.onDownloadFinished(() => { window.api.onDownloadFinished(() => {
downloading = false; downloading = false;
updateDownloadButtonState(); updateDownloadButtonState();
@@ -178,9 +188,14 @@ async function init(): Promise<void> {
} }
if (config.streamers && config.streamers.length > 0) { if (config.streamers && config.streamers.length > 0) {
const preloader = (window as unknown as { preloadConfiguredStreamerData?: (streamers: string[]) => Promise<void> }).preloadConfiguredStreamerData;
if (typeof preloader === 'function') void preloader(config.streamers as string[]);
await selectStreamer(config.streamers[0]); await selectStreamer(config.streamers[0]);
} }
const startBackgroundRefresh = (window as unknown as { startStreamerBackgroundRefresh?: () => void }).startStreamerBackgroundRefresh;
if (typeof startBackgroundRefresh === 'function') startBackgroundRefresh();
setTimeout(() => { setTimeout(() => {
void checkUpdateSilent(); void checkUpdateSilent();
}, 3000); }, 3000);
@@ -895,6 +910,33 @@ function syncWorkspaceContextSelection(panel: HTMLElement): void {
}); });
} }
function setVodsWorkspace(view: 'streamers' | 'queue', source?: HTMLElement): void {
const panel = document.querySelector<HTMLElement>('[data-context-for="vods"]');
if (!panel) return;
panel.dataset.vodsWorkspace = view;
const buttons = Array.from(panel.querySelectorAll<HTMLButtonElement>('.context-switcher button'));
buttons.forEach((button) => {
const active = source ? button === source : button.id === `${view === 'queue' ? 'queue' : 'streamer'}WorkspaceSwitch`;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', String(active));
});
const switcher = panel.querySelector<HTMLElement>('.context-switcher');
if (switcher) scheduleSegmentedIndicatorSync(switcher);
}
function applySidebarLayoutPreference(enabled: boolean): void {
const panel = document.querySelector<HTMLElement>('[data-context-for="vods"]');
if (!panel) return;
panel.dataset.vodsLayout = enabled ? 'split' : 'tabs';
const toggle = document.getElementById('sidebarSplitViewToggle') as HTMLInputElement | null;
if (toggle) toggle.checked = enabled;
if (!enabled) {
setVodsWorkspace(panel.dataset.vodsWorkspace === 'queue' ? 'queue' : 'streamers');
}
}
function focusWorkspaceTarget(id: string, source?: HTMLElement): void { 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;
@@ -912,6 +954,9 @@ function focusWorkspaceTarget(id: string, source?: HTMLElement): void {
button.setAttribute('aria-pressed', String(active)); button.setAttribute('aria-pressed', String(active));
} }
}); });
if (group.matches('[data-context-for="settings"] .context-list')) {
scheduleSegmentedIndicatorSync(group);
}
} }
target.scrollIntoView({ behavior: 'smooth', block: 'start' }); target.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -943,8 +988,10 @@ function showTab(tab: string): void {
} }
navItem.classList.add('active'); navItem.classList.add('active');
navItem.setAttribute('aria-current', 'page'); navItem.setAttribute('aria-current', 'page');
syncTopNavActiveIndicator();
byId(tab + 'Tab').classList.add('active'); byId(tab + 'Tab').classList.add('active');
syncWorkspaceChrome(tab); syncWorkspaceChrome(tab);
scheduleSegmentedIndicatorsSync();
const titles: Record<string, string> = UI_TEXT.tabs; const titles: Record<string, string> = UI_TEXT.tabs;
@@ -1549,6 +1596,81 @@ async function downloadClip(): Promise<void> {
} }
} }
let topNavIndicatorFrame: number | null = null;
function syncTopNavActiveIndicator(): void {
const topNav = document.querySelector<HTMLElement>('.top-nav');
const activeItem = topNav?.querySelector<HTMLElement>('.top-nav-item.active');
if (!topNav || !activeItem) return;
const navRect = topNav.getBoundingClientRect();
const itemRect = activeItem.getBoundingClientRect();
topNav.style.setProperty('--top-nav-active-x', `${itemRect.left - navRect.left}px`);
topNav.style.setProperty('--top-nav-active-y', `${itemRect.top - navRect.top}px`);
topNav.style.setProperty('--top-nav-active-width', `${itemRect.width}px`);
topNav.style.setProperty('--top-nav-active-height', `${itemRect.height}px`);
}
function scheduleTopNavActiveIndicatorSync(): void {
if (topNavIndicatorFrame !== null) return;
topNavIndicatorFrame = requestAnimationFrame(() => {
topNavIndicatorFrame = null;
syncTopNavActiveIndicator();
});
}
function initTopNavActiveIndicator(): void {
const topNav = document.querySelector<HTMLElement>('.top-nav');
if (!topNav) return;
const observer = new ResizeObserver(scheduleTopNavActiveIndicatorSync);
observer.observe(topNav);
topNav.querySelectorAll<HTMLElement>('.top-nav-item').forEach((item) => observer.observe(item));
window.addEventListener('resize', scheduleTopNavActiveIndicatorSync);
}
const segmentedIndicatorFrames = new WeakMap<HTMLElement, number>();
function syncSegmentedIndicator(control: HTMLElement): void {
const activeButton = control.querySelector<HTMLElement>('button.active');
if (!activeButton) {
control.classList.remove('segmented-indicator-visible');
return;
}
control.style.setProperty('--segment-active-x', `${activeButton.offsetLeft}px`);
control.style.setProperty('--segment-active-y', `${activeButton.offsetTop}px`);
control.style.setProperty('--segment-active-width', `${activeButton.offsetWidth}px`);
control.style.setProperty('--segment-active-height', `${activeButton.offsetHeight}px`);
control.classList.add('segmented-indicator-visible');
requestAnimationFrame(() => control.classList.add('segmented-indicator-ready'));
}
function scheduleSegmentedIndicatorSync(control: HTMLElement): void {
if (segmentedIndicatorFrames.has(control)) return;
const frame = requestAnimationFrame(() => {
segmentedIndicatorFrames.delete(control);
syncSegmentedIndicator(control);
});
segmentedIndicatorFrames.set(control, frame);
}
function scheduleSegmentedIndicatorsSync(): void {
document.querySelectorAll<HTMLElement>('.context-switcher, .language-picker, [data-context-for="settings"] .context-list').forEach(scheduleSegmentedIndicatorSync);
}
function initSegmentedIndicators(): void {
const observer = new ResizeObserver((entries) => {
entries.forEach((entry) => {
const control = entry.target.closest<HTMLElement>('.context-switcher, .language-picker, [data-context-for="settings"] .context-list');
if (control) scheduleSegmentedIndicatorSync(control);
});
});
document.querySelectorAll<HTMLElement>('.context-switcher, .language-picker, [data-context-for="settings"] .context-list').forEach((control) => {
observer.observe(control);
control.querySelectorAll<HTMLElement>('button').forEach((button) => observer.observe(button));
syncSegmentedIndicator(control);
});
window.addEventListener('resize', scheduleSegmentedIndicatorsSync);
}
async function loadCutterFromPath(filePath: string): Promise<void> { async function loadCutterFromPath(filePath: string): Promise<void> {
if (!filePath) return; if (!filePath) return;
+62 -62
View File
@@ -973,6 +973,7 @@ select option {
} }
.queue-item { .queue-item {
position: relative;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 10px; gap: 10px;
@@ -1070,12 +1071,14 @@ select option {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
min-height: 16px;
} }
.queue-status-label { .queue-status-label {
flex-shrink: 0; flex-shrink: 0;
font-size: 10px; font-size: 10px;
color: var(--text-secondary); color: var(--text-secondary);
line-height: 16px;
} }
.queue-meta { .queue-meta {
@@ -1099,14 +1102,12 @@ select option {
.queue-progress-bar { .queue-progress-bar {
height: 100%; height: 100%;
width: 0; width: 0;
background: linear-gradient(90deg, var(--accent) 0%, #b97aff 100%); background: linear-gradient(90deg, #168f4a 0%, var(--success) 100%);
transition: width 0.3s ease; transition: width 0.3s ease;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
/* Moving shimmer overlay — implies "active" without needing a different
border or pulse rule. Only visible because the bar is purple. */
.queue-progress-bar::after { .queue-progress-bar::after {
content: ''; content: '';
position: absolute; position: absolute;
@@ -1121,26 +1122,52 @@ select option {
100% { transform: translateX(100%); } 100% { transform: translateX(100%); }
} }
.queue-progress-bar.indeterminate { .queue-progress-info {
width: 35% !important; display: flex;
animation: queue-indeterminate 1.3s ease-in-out infinite; align-items: center;
} justify-content: space-between;
gap: 8px;
.queue-progress-text {
margin-top: 3px; margin-top: 3px;
font-size: 10px; font-size: 10px;
color: var(--text-secondary); color: var(--text-secondary);
line-height: 14px;
} }
@keyframes queue-indeterminate { .queue-progress-status,
0% { transform: translateX(-100%); } .queue-progress-metrics {
100% { transform: translateX(280%); } min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.queue-progress-status.is-starting {
animation: queue-starting-pulse 1.1s ease-in-out infinite;
}
@keyframes queue-starting-pulse {
0%, 100% { opacity: 0.62; }
50% { opacity: 1; }
}
.queue-progress-metrics {
margin-left: auto;
text-align: right;
font-variant-numeric: tabular-nums;
} }
.queue-item .remove { .queue-item .remove {
display: inline-flex;
width: 16px;
height: 16px;
align-items: center;
justify-content: center;
flex: 0 0 16px;
cursor: pointer; cursor: pointer;
color: var(--error); color: var(--error);
opacity: 0.7; opacity: 0.7;
font-size: 11px;
line-height: 16px;
} }
.queue-item .remove:hover { .queue-item .remove:hover {
@@ -1175,27 +1202,29 @@ select option {
margin-bottom: 2px; margin-bottom: 2px;
} }
.queue-selector { .queue-selection-order {
min-width: 22px; position: absolute;
height: 22px; z-index: 2;
padding: 0 3px; top: -5px;
border: 2px solid var(--text-secondary); left: -5px;
border-radius: 4px; display: inline-flex;
cursor: pointer; width: 18px;
flex-shrink: 0; height: 18px;
display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 11px; border: 2px solid var(--bg-panel);
border-radius: 50%;
background: var(--success);
color: #fff;
font-size: 10px;
font-weight: 700; font-weight: 700;
color: var(--bg-primary); line-height: 1;
font-variant-numeric: tabular-nums;
user-select: none; user-select: none;
transition: all 0.15s;
} }
.queue-selector:focus-visible { .queue-item.merge-selected {
outline: none; box-shadow: inset 0 0 0 1px rgba(0, 200, 83, 0.45);
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.55);
} }
.queue-item .title:focus-visible { .queue-item .title:focus-visible {
@@ -1204,15 +1233,6 @@ select option {
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.45); box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.45);
} }
.queue-selector.selected {
background: var(--accent);
border-color: var(--accent);
}
.queue-selector:hover {
border-color: var(--accent);
}
.queue-item.merge-group { .queue-item.merge-group {
border-left: 3px solid var(--accent); border-left: 3px solid var(--accent);
} }
@@ -1721,12 +1741,12 @@ input[type="checkbox"].vod-select-checkbox {
} }
.vod-btn.primary { .vod-btn.primary {
background: var(--accent); background: #1f7a43;
color: white; color: white;
} }
.vod-btn.primary:hover { .vod-btn.primary:hover {
background: var(--accent-hover); background: #186638;
} }
.vod-btn.secondary { .vod-btn.secondary {
@@ -1748,7 +1768,7 @@ input[type="checkbox"].vod-select-checkbox {
} }
.vod-btn.primary:focus-visible { .vod-btn.primary:focus-visible {
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 0 4px rgba(145, 70, 255, 0.55); box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 0 4px rgba(31, 122, 67, 0.65);
} }
/* Settings */ /* Settings */
@@ -1905,17 +1925,9 @@ input[type="checkbox"].vod-select-checkbox {
} }
.flag-en { .flag-en {
background: repeating-linear-gradient(to bottom, #b22234 0 7.7%, #ffffff 7.7% 15.4%); width: 18px;
} background: none;
opacity: 1;
.flag-en::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 45%;
height: 56%;
background: #3c3b6e;
} }
.form-row { .form-row {
@@ -3884,16 +3896,6 @@ input[type="number"]::-webkit-outer-spin-button {
letter-spacing: 0.2px; letter-spacing: 0.2px;
} }
.streamer-section-counter-divider {
opacity: 0.5;
margin: 0 1px;
}
.streamer-section-counter-live {
color: #e91916;
font-weight: 600;
}
/* Empty-state hint inside the sidebar streamer list (no streamers /* Empty-state hint inside the sidebar streamer list (no streamers
added yet). Subtler than the full-page .empty-state — fits the added yet). Subtler than the full-page .empty-state — fits the
narrow sidebar context. */ narrow sidebar context. */
@@ -3945,8 +3947,6 @@ input[type="number"]::-webkit-outer-spin-button {
background: rgba(0, 0, 0, 0.88); background: rgba(0, 0, 0, 0.88);
} }
.vod-card.preview-active .vod-duration-badge,
.vod-card.preview-active .vod-select-checkbox,
.vod-card.preview-active .vod-downloaded-badge { .vod-card.preview-active .vod-downloaded-badge {
opacity: 0; opacity: 0;
transition: opacity 0.2s; transition: opacity 0.2s;
+1
View File
@@ -39,6 +39,7 @@ export interface QueueItem {
eta?: string; eta?: string;
downloadedBytes?: number; downloadedBytes?: number;
totalBytes?: number; totalBytes?: number;
progressStatus?: string;
last_error?: string; last_error?: string;
customClip?: CustomClip; customClip?: CustomClip;
mergeGroup?: MergeGroup; mergeGroup?: MergeGroup;
+692 -27
View File
@@ -43,6 +43,25 @@
--border-soft: var(--workspace-border); --border-soft: var(--workspace-border);
} }
body {
-webkit-user-select: none;
user-select: none;
}
input:not([type]),
input[type="text"],
input[type="search"],
input[type="password"],
input[type="email"],
input[type="url"],
input[type="tel"],
input[type="number"],
textarea,
[contenteditable="true"] {
-webkit-user-select: text;
user-select: text;
}
body.theme-twitch, body.theme-twitch,
body.theme-dark, body.theme-dark,
body.theme-discord, body.theme-discord,
@@ -238,6 +257,11 @@ textarea:disabled,
fill: none; fill: none;
} }
.topbar-brand img.topbar-brand-mark {
object-fit: cover;
border-radius: 5px;
}
.topbar-brand-name { .topbar-brand-name {
overflow: hidden; overflow: hidden;
color: var(--workspace-text); color: var(--workspace-text);
@@ -249,6 +273,7 @@ textarea:disabled,
.top-nav { .top-nav {
display: flex; display: flex;
position: relative;
min-width: 0; min-width: 0;
height: 40px; height: 40px;
align-items: center; align-items: center;
@@ -260,8 +285,25 @@ textarea:disabled,
border-radius: var(--workspace-radius); border-radius: var(--workspace-radius);
} }
.top-nav::before {
content: '';
position: absolute;
z-index: 0;
top: 0;
left: 0;
width: var(--top-nav-active-width, 0px);
height: var(--top-nav-active-height, 0px);
pointer-events: none;
background: var(--workspace-primary);
border-radius: var(--workspace-radius-small);
transform: translate3d(var(--top-nav-active-x, 0px), var(--top-nav-active-y, 0px), 0);
transition: transform 420ms cubic-bezier(0.22, 0.76, 0.22, 1), width 420ms cubic-bezier(0.22, 0.76, 0.22, 1), height 420ms cubic-bezier(0.22, 0.76, 0.22, 1);
}
.top-nav-item.nav-item { .top-nav-item.nav-item {
display: inline-flex; display: inline-flex;
position: relative;
z-index: 1;
width: 34px; width: 34px;
min-width: 34px; min-width: 34px;
height: 32px; height: 32px;
@@ -285,8 +327,12 @@ textarea:disabled,
.top-nav-item.nav-item.active { .top-nav-item.nav-item.active {
color: var(--workspace-primary-text); color: var(--workspace-primary-text);
background: var(--workspace-primary); background: transparent;
border-color: var(--workspace-primary); border-color: transparent;
}
.top-nav-item.nav-item.active:hover {
background: transparent;
} }
.top-nav-item.nav-item svg { .top-nav-item.nav-item svg {
@@ -656,6 +702,7 @@ textarea:disabled,
} }
.context-sidebar .streamers { .context-sidebar .streamers {
position: relative;
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
padding: 0 10px; padding: 0 10px;
@@ -663,7 +710,32 @@ textarea:disabled,
overflow-y: auto; overflow-y: auto;
} }
.context-sidebar .streamers::before {
position: absolute;
z-index: 0;
top: 0;
left: 0;
width: var(--streamer-active-width, 0px);
height: var(--streamer-active-height, 0px);
pointer-events: none;
background: var(--workspace-control);
border-radius: var(--workspace-radius-small);
content: '';
opacity: 0;
transform: translate3d(var(--streamer-active-x, 0px), var(--streamer-active-y, 0px), 0);
}
.context-sidebar .streamers.streamer-indicator-visible::before {
opacity: 1;
}
.context-sidebar .streamers.streamer-indicator-ready::before {
transition: transform 360ms cubic-bezier(0.22, 0.76, 0.22, 1), width 360ms cubic-bezier(0.22, 0.76, 0.22, 1), height 360ms cubic-bezier(0.22, 0.76, 0.22, 1);
}
.context-sidebar .streamer-item { .context-sidebar .streamer-item {
position: relative;
z-index: 1;
min-height: 36px; min-height: 36px;
gap: 8px; gap: 8px;
margin: 1px 0; margin: 1px 0;
@@ -683,15 +755,95 @@ textarea:disabled,
.context-sidebar .streamer-item.active { .context-sidebar .streamer-item.active {
color: var(--workspace-primary); color: var(--workspace-primary);
background: var(--workspace-control); background: transparent;
border-color: transparent; border-color: transparent;
box-shadow: none; box-shadow: none;
} }
.context-sidebar .streamer-item.active:hover {
background: transparent;
}
.context-sidebar .streamer-item.active::after { .context-sidebar .streamer-item.active::after {
display: none; display: none;
} }
.context-sidebar .streamer-item .remove {
display: inline-flex;
flex: 0 0 16px;
align-items: center;
justify-content: center;
margin-left: auto;
opacity: 0.75;
font-size: 14px;
font-weight: 600;
}
.streamer-context-menu {
position: fixed;
z-index: 1000;
display: grid;
min-width: 122px;
padding: 4px;
background: var(--workspace-panel-raised);
border: 1px solid var(--workspace-border);
border-radius: var(--workspace-radius-small);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35);
}
.streamer-context-action {
display: inline-flex;
min-height: 30px;
align-items: center;
justify-content: space-between;
padding: 0 9px;
color: var(--workspace-text);
background: transparent;
border: 0;
border-radius: 5px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
cursor: pointer;
}
.streamer-context-action:hover,
.streamer-context-action:focus-visible {
color: var(--workspace-text);
background: var(--workspace-control-hover);
outline: none;
}
.streamer-context-action.is-active::after {
margin-left: 16px;
color: #55d68a;
content: "✓";
}
.streamer-context-record {
color: #ff7770;
}
.context-sidebar .streamer-section-counter {
display: inline-flex;
min-height: 20px;
align-items: center;
margin-left: auto;
margin-right: 6px;
padding: 0 7px;
color: var(--workspace-text-muted);
background: var(--workspace-control);
border: 1px solid var(--workspace-border);
border-radius: 999px;
font-size: 11px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.context-sidebar .streamer-section-counter:empty {
display: none;
}
.context-sidebar .streamer-avatar, .context-sidebar .streamer-avatar,
.context-sidebar .streamer-avatar-fallback { .context-sidebar .streamer-avatar-fallback {
width: 24px; width: 24px;
@@ -709,6 +861,19 @@ textarea:disabled,
border-top: 1px solid var(--workspace-border); border-top: 1px solid var(--workspace-border);
} }
.context-sidebar .streamer-section {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
}
.context-sidebar .streamer-section .streamers {
min-height: 0;
flex: 1 1 auto;
overflow-y: auto;
}
.context-sidebar .queue-header { .context-sidebar .queue-header {
min-height: 30px; min-height: 30px;
padding: 0 2px; padding: 0 2px;
@@ -727,6 +892,59 @@ textarea:disabled,
overflow-y: auto; overflow-y: auto;
} }
.context-sidebar .queue-count {
display: inline-flex;
min-width: 20px;
min-height: 20px;
align-items: center;
justify-content: center;
padding: 0 7px;
color: #ffffff;
background: #1f7a43;
border: 1px solid #1f7a43;
border-radius: 999px;
font-weight: 700;
}
.context-panel[data-context-for="vods"][data-vods-layout="tabs"][data-vods-workspace="streamers"] .queue-section {
display: none;
}
.context-panel[data-context-for="vods"][data-vods-layout="tabs"][data-vods-workspace="queue"] .streamer-section {
display: none;
}
.context-panel[data-context-for="vods"][data-vods-layout="tabs"][data-vods-workspace="queue"] .queue-section {
flex: 1 1 auto;
max-height: none;
}
.context-panel[data-context-for="vods"][data-vods-layout="tabs"][data-vods-workspace="queue"] .queue-list {
max-height: none;
}
.context-panel[data-context-for="vods"][data-vods-layout="split"] .context-switcher {
display: none;
}
.context-panel[data-context-for="vods"][data-vods-layout="split"] .streamer-section,
.context-panel[data-context-for="vods"][data-vods-layout="split"] .queue-section {
min-height: 0;
flex: 1 1 50%;
max-height: none;
}
.context-panel[data-context-for="vods"][data-vods-layout="split"] .queue-section {
display: flex;
flex-direction: column;
}
.context-panel[data-context-for="vods"][data-vods-layout="split"] .queue-list {
min-height: 0;
max-height: none;
flex: 1 1 auto;
}
.context-sidebar .queue-item { .context-sidebar .queue-item {
margin: 3px 0; margin: 3px 0;
padding: 7px; padding: 7px;
@@ -951,6 +1169,7 @@ textarea:disabled,
height: 100%; height: 100%;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
scrollbar-gutter: stable;
padding: 16px; padding: 16px;
background: var(--workspace-panel); background: var(--workspace-panel);
} }
@@ -1202,13 +1421,13 @@ textarea:focus {
input[type="checkbox"] { input[type="checkbox"] {
width: 15px; width: 15px;
height: 15px; height: 15px;
background: var(--workspace-panel-raised); background-color: var(--workspace-panel-raised);
border: 1px solid var(--workspace-border-strong); border: 1px solid var(--workspace-border-strong);
border-radius: 3px; border-radius: 3px;
} }
input[type="checkbox"]:checked { input[type="checkbox"]:checked {
background: var(--workspace-primary); background-color: var(--workspace-primary);
border-color: var(--workspace-primary); border-color: var(--workspace-primary);
} }
@@ -1318,14 +1537,101 @@ input[type="range"] {
margin-bottom: 10px; margin-bottom: 10px;
} }
.vod-filter-row {
flex-wrap: nowrap;
}
.vod-filter-row .vod-filter-count {
flex: 0 0 auto;
min-width: 0;
}
.vod-filter-row .vod-hide-downloaded {
flex: 0 0 auto;
margin-left: auto;
white-space: nowrap;
}
.vod-filter-row .vod-sort-label {
flex: 0 0 auto;
margin-left: 4px;
}
.vod-filter-row #vodSortSelect {
flex: 0 0 auto;
}
.vod-bulk-bar { .vod-bulk-bar {
padding: 8px 10px; position: fixed;
z-index: 30;
bottom: 38px;
left: calc((100vw + var(--workspace-sidebar-width) + var(--workspace-content-gap)) / 2);
width: max-content;
max-width: calc(100vw - var(--workspace-sidebar-width) - 56px);
margin: 0;
padding: 9px 10px;
flex-wrap: nowrap;
color: var(--workspace-text); color: var(--workspace-text);
background: var(--workspace-panel-raised); background: rgba(31, 31, 31, 0.96);
border: 1px solid var(--workspace-primary); border: 1px solid var(--workspace-border-strong);
border-radius: var(--workspace-radius-small); border-radius: 10px;
box-shadow: none; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.48), 0 0 0 1px rgba(255, 255, 255, 0.04);
animation: none; backdrop-filter: blur(12px);
transform: translateX(-50%);
animation: vod-bulk-dock-in 240ms cubic-bezier(0.22, 0.76, 0.22, 1);
}
.vod-bulk-bar .vod-bulk-count {
width: 112px;
flex: 0 0 112px;
padding: 0 8px 0 4px;
box-sizing: border-box;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.streamer-profile-stat {
min-height: 18px;
line-height: 18px;
}
.streamer-profile-stat strong {
line-height: inherit;
}
.streamer-profile-stat svg {
display: block;
flex: 0 0 14px;
}
.vod-bulk-bar .vod-bulk-spacer {
display: none;
}
.vod-bulk-bar .btn-pill {
white-space: nowrap;
}
.vod-bulk-bar #vodBulkAddBtn {
color: #fff;
background: #16803d;
border-color: #1c9a4a;
}
.vod-bulk-bar #vodBulkAddBtn:hover:not(:disabled) {
background: #1a9146;
border-color: #22c55e;
}
@keyframes vod-bulk-dock-in {
from {
opacity: 0;
transform: translate(-50%, 12px) scale(0.98);
}
to {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
} }
.vod-grid { .vod-grid {
@@ -1347,8 +1653,35 @@ input[type="range"] {
} }
.vod-card.selected { .vod-card.selected {
border-color: var(--workspace-primary); border-color: #22c55e;
box-shadow: inset 0 0 0 1px var(--workspace-primary); box-shadow: inset 0 0 0 1px #22c55e, 0 0 0 1px rgba(34, 197, 94, 0.24);
}
input[type="checkbox"].vod-select-checkbox {
width: 20px;
height: 20px;
z-index: 4;
opacity: 1;
background-color: rgba(18, 22, 20, 0.94);
border: 2px solid rgba(255, 255, 255, 0.82);
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.85);
}
input[type="checkbox"].vod-select-checkbox:hover:not(:disabled) {
border-color: #86efac;
}
input[type="checkbox"].vod-select-checkbox:checked {
background-color: rgba(18, 36, 25, 0.96);
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%2322c55e' stroke-width='2.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3.5 8.5 6.5 11.5 12.5 5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: center;
background-size: 14px;
border-color: #22c55e;
}
input[type="checkbox"].vod-select-checkbox:focus-visible {
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.28), 0 1px 5px rgba(0, 0, 0, 0.85);
} }
.vod-thumbnail, .vod-thumbnail,
@@ -1357,20 +1690,73 @@ input[type="range"] {
} }
.vod-info { .vod-info {
min-width: 0;
padding: 10px; padding: 10px;
} }
.vod-title { .vod-title {
display: block;
min-width: 0;
margin-bottom: 7px;
overflow: hidden;
color: var(--workspace-text); color: var(--workspace-text);
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
} }
.vod-meta, .vod-meta {
.vod-date, display: grid;
.vod-duration { grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.vod-meta > span {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 5px;
overflow: hidden;
color: var(--workspace-text-muted); color: var(--workspace-text-muted);
font-size: 11px; font-size: 12px;
font-weight: 500;
text-overflow: ellipsis;
}
.vod-meta svg {
width: 13px;
height: 13px;
flex: 0 0 auto;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.8;
}
.vod-views {
text-align: right;
}
.vod-duration-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 22px;
padding: 3px 8px;
box-sizing: border-box;
border: 1px solid rgba(255, 255, 255, 0.72);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.9), 0 0 0 1px rgba(0, 0, 0, 0.7);
font-size: 12px;
font-weight: 700;
line-height: 1.2;
z-index: 4;
opacity: 1;
} }
.streamer-profile-header { .streamer-profile-header {
@@ -1383,6 +1769,13 @@ input[type="range"] {
border-radius: var(--workspace-radius); border-radius: var(--workspace-radius);
box-shadow: none; box-shadow: none;
animation: none; animation: none;
width: 100%;
min-height: 94px;
box-sizing: border-box;
}
.streamer-profile-header.is-hidden {
min-height: 0;
} }
.streamer-profile-header::before { .streamer-profile-header::before {
@@ -1530,6 +1923,11 @@ input[type="range"] {
border-radius: 0; border-radius: 0;
} }
#settingsTab .settings-card[data-settings-pane] {
margin-bottom: 0;
border-bottom: 0;
}
#settingsTab .settings-card:last-child { #settingsTab .settings-card:last-child {
margin-bottom: 0; margin-bottom: 0;
border-bottom: 0; border-bottom: 0;
@@ -1669,6 +2067,10 @@ input[type="range"] {
box-shadow: none; box-shadow: none;
} }
.status-dot.public {
background: #f59e0b;
}
.update-banner { .update-banner {
position: static; position: static;
display: none; display: none;
@@ -1770,6 +2172,14 @@ input[type="range"] {
border-color: var(--workspace-border-strong); border-color: var(--workspace-border-strong);
} }
.update-modal-actions button {
min-height: 44px;
height: auto;
padding: 7px 12px;
line-height: 1.25;
white-space: normal;
}
.update-modal-eyebrow, .update-modal-eyebrow,
.update-changelog-toggle, .update-changelog-toggle,
.template-guide-preview-label { .template-guide-preview-label {
@@ -2167,6 +2577,20 @@ input[type="range"] {
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.top-nav::before {
transition: transform 420ms cubic-bezier(0.22, 0.76, 0.22, 1), width 420ms cubic-bezier(0.22, 0.76, 0.22, 1), height 420ms cubic-bezier(0.22, 0.76, 0.22, 1) !important;
}
.context-sidebar .streamers.streamer-indicator-ready::before {
transition: transform 360ms cubic-bezier(0.22, 0.76, 0.22, 1), width 360ms cubic-bezier(0.22, 0.76, 0.22, 1), height 360ms cubic-bezier(0.22, 0.76, 0.22, 1) !important;
}
.context-switcher.segmented-indicator-ready::before,
.language-picker.segmented-indicator-ready::before,
[data-context-for="settings"] .context-list.segmented-indicator-ready::before {
transition: transform 360ms cubic-bezier(0.22, 0.76, 0.22, 1), width 360ms cubic-bezier(0.22, 0.76, 0.22, 1), height 360ms cubic-bezier(0.22, 0.76, 0.22, 1) !important;
}
*, *,
*::before, *::before,
*::after { *::after {
@@ -2295,7 +2719,7 @@ input[type="range"] {
.update-banner.workspace-update { .update-banner.workspace-update {
position: relative; position: relative;
display: flex; display: none;
width: auto; width: auto;
min-width: 90px; min-width: 90px;
height: 40px; height: 40px;
@@ -2309,7 +2733,7 @@ input[type="range"] {
transform: none; transform: none;
} }
.update-banner.workspace-update.show { .update-banner.workspace-update.show:not([hidden]) {
display: flex; display: flex;
} }
@@ -2422,6 +2846,8 @@ input[type="range"] {
.context-switcher { .context-switcher {
display: flex; display: flex;
position: relative;
isolation: isolate;
flex: 0 0 auto; flex: 0 0 auto;
width: calc(100% - 24px); width: calc(100% - 24px);
min-height: 36px; min-height: 36px;
@@ -2433,8 +2859,39 @@ input[type="range"] {
border-radius: 6px; border-radius: 6px;
} }
.context-switcher::before,
.language-picker::before,
[data-context-for="settings"] .context-list::before {
position: absolute;
z-index: 0;
top: 0;
left: 0;
width: var(--segment-active-width, 0px);
height: var(--segment-active-height, 0px);
pointer-events: none;
background: var(--workspace-primary);
border-radius: 5px;
content: '';
opacity: 0;
transform: translate3d(var(--segment-active-x, 0px), var(--segment-active-y, 0px), 0);
}
.context-switcher.segmented-indicator-visible::before,
.language-picker.segmented-indicator-visible::before,
[data-context-for="settings"] .context-list.segmented-indicator-visible::before {
opacity: 1;
}
.context-switcher.segmented-indicator-ready::before,
.language-picker.segmented-indicator-ready::before,
[data-context-for="settings"] .context-list.segmented-indicator-ready::before {
transition: transform 360ms cubic-bezier(0.22, 0.76, 0.22, 1), width 360ms cubic-bezier(0.22, 0.76, 0.22, 1), height 360ms cubic-bezier(0.22, 0.76, 0.22, 1);
}
.context-switcher button { .context-switcher button {
display: inline-flex; display: inline-flex;
position: relative;
z-index: 1;
flex: 1 1 0; flex: 1 1 0;
height: 28px; height: 28px;
align-items: center; align-items: center;
@@ -2455,7 +2912,53 @@ input[type="range"] {
.context-switcher button.active { .context-switcher button.active {
color: var(--workspace-primary-text); color: var(--workspace-primary-text);
background: var(--workspace-primary); background: transparent;
}
.context-switcher button.active:hover {
background: transparent;
}
#settingsTab .language-picker {
display: grid;
position: relative;
isolation: isolate;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
padding: 4px;
overflow: hidden;
background: var(--workspace-border);
border-radius: 6px;
}
#settingsTab .language-picker .lang-option {
position: relative;
z-index: 1;
justify-content: center;
min-width: 0;
background: transparent;
border-color: transparent;
box-shadow: none;
}
#settingsTab .language-picker .lang-option > span:not(.flag-icon) {
color: #fff;
mix-blend-mode: difference;
}
#settingsTab .language-picker .lang-option:hover {
background: var(--workspace-control);
}
#settingsTab .language-picker .lang-option.active {
color: var(--workspace-primary-text);
background: transparent;
border-color: transparent;
box-shadow: none;
}
#settingsTab .language-picker .lang-option.active:hover {
background: transparent;
} }
.context-panel-heading { .context-panel-heading {
@@ -2483,6 +2986,16 @@ input[type="range"] {
overflow-y: auto; overflow-y: auto;
} }
[data-context-for="settings"] .context-list {
position: relative;
isolation: isolate;
}
[data-context-for="settings"] .context-list::before {
background: var(--workspace-control);
border-radius: 6px;
}
.context-link { .context-link {
display: flex; display: flex;
flex: 0 0 auto; flex: 0 0 auto;
@@ -2530,6 +3043,16 @@ input[type="range"] {
white-space: nowrap; white-space: nowrap;
} }
[data-context-for="settings"] .context-link {
position: relative;
z-index: 1;
}
[data-context-for="settings"] .context-link.active,
[data-context-for="settings"] .context-link.active:hover {
background: transparent;
}
.context-sidebar .section-title, .context-sidebar .section-title,
.context-sidebar .streamer-item, .context-sidebar .streamer-item,
.context-sidebar .queue-title { .context-sidebar .queue-title {
@@ -2880,8 +3403,8 @@ input[type="range"] {
.top-nav-item.nav-item.active:hover { .top-nav-item.nav-item.active:hover {
color: var(--workspace-primary-text); color: var(--workspace-primary-text);
background: var(--workspace-primary-hover); background: transparent;
border-color: var(--workspace-primary-hover); border-color: transparent;
} }
.top-nav .top-nav-item:focus-visible { .top-nav .top-nav-item:focus-visible {
@@ -2891,7 +3414,7 @@ input[type="range"] {
} }
#settingsTab .settings-card:has(#downloadPath) { #settingsTab .settings-card:has(#downloadPath) {
width: min(720px, 100%); width: 100%;
} }
#settingsTab .form-row:has(#downloadPath) { #settingsTab .form-row:has(#downloadPath) {
@@ -2909,6 +3432,114 @@ input[type="range"] {
white-space: nowrap; white-space: nowrap;
} }
.download-settings-storage {
margin-bottom: 14px;
}
.download-settings-layout {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
gap: 16px;
}
.download-settings-column {
display: grid;
min-width: 0;
gap: 16px;
}
#settingsTab .download-settings-section {
min-width: 0;
padding: 16px;
background: var(--workspace-panel-raised);
border: 1px solid var(--workspace-border);
border-radius: var(--workspace-radius);
}
#settingsTab .download-settings-section h4 {
margin: 0 0 14px;
font-size: 14px;
line-height: 20px;
}
.download-settings-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
#settingsTab .download-settings-section-heading h4 {
margin: 0;
}
.download-settings-section .form-group:last-child,
.download-settings-section .filename-template-grid:last-child {
margin-bottom: 0;
}
.download-settings-toggles {
display: grid;
gap: 5px;
}
#settingsTab .download-settings-toggles .toggle-row {
min-height: 32px;
gap: 10px;
margin: 0;
padding: 5px 7px;
border-radius: var(--workspace-radius-small);
font-size: 13px;
line-height: 18px;
}
#settingsTab .download-settings-toggles .toggle-row:hover {
background: var(--workspace-control-hover);
}
#settingsTab .download-settings-toggles input[type="checkbox"],
#settingsTab .sidebar-layout-setting input[type="checkbox"] {
width: 20px;
height: 20px;
flex: 0 0 20px;
background-size: 15px;
}
.metadata-cache-setting {
margin-top: 14px;
margin-bottom: 0;
padding-top: 14px;
border-top: 1px solid var(--workspace-border);
}
.template-presets {
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
#settingsTab .download-settings-section #filenameTemplateHint {
margin-top: 9px;
}
.sidebar-layout-setting {
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid var(--workspace-border);
}
#settingsTab .sidebar-layout-setting .toggle-row {
gap: 10px;
font-size: 13px;
font-weight: 600;
}
#settingsTab .sidebar-layout-setting .form-note {
margin: 7px 0 0 30px;
}
#clipsTab.active { #clipsTab.active {
display: grid; display: grid;
grid-template-columns: minmax(420px, 1.35fr) minmax(280px, 0.65fr); grid-template-columns: minmax(420px, 1.35fr) minmax(280px, 0.65fr);
@@ -3111,9 +3742,9 @@ input[type="range"] {
@media (min-width: 1180px) { @media (min-width: 1180px) {
.topbar-navigation-cluster { .topbar-navigation-cluster {
flex: 0 1 1000px; flex: 1 1 auto;
width: 1000px; width: auto;
max-width: calc(100vw - 254px); max-width: none;
} }
.topbar-navigation-cluster .topbar-brand { .topbar-navigation-cluster .topbar-brand {
@@ -3141,7 +3772,7 @@ input[type="range"] {
} }
.topbar-navigation-cluster .top-nav-item.nav-item { .topbar-navigation-cluster .top-nav-item.nav-item {
flex: 1 1 auto; flex: 1 0 var(--top-nav-item-width, 100px);
width: auto; width: auto;
min-width: 44px; min-width: 44px;
max-width: none; max-width: none;
@@ -3165,6 +3796,34 @@ input[type="range"] {
border: 0; border: 0;
font-size: 12px; font-size: 12px;
} }
.topbar-navigation-cluster .top-nav-item[data-tab="vods"] {
--top-nav-item-width: 110px;
}
.topbar-navigation-cluster .top-nav-item[data-tab="clips"] {
--top-nav-item-width: 110px;
}
.topbar-navigation-cluster .top-nav-item[data-tab="cutter"] {
--top-nav-item-width: 138px;
}
.topbar-navigation-cluster .top-nav-item[data-tab="merge"] {
--top-nav-item-width: 180px;
}
.topbar-navigation-cluster .top-nav-item[data-tab="stats"] {
--top-nav-item-width: 110px;
}
.topbar-navigation-cluster .top-nav-item[data-tab="archive"] {
--top-nav-item-width: 90px;
}
.topbar-navigation-cluster .top-nav-item[data-tab="settings"] {
--top-nav-item-width: 120px;
}
} }
@media (max-width: 1179px) { @media (max-width: 1179px) {
@@ -3195,6 +3854,12 @@ input[type="range"] {
} }
} }
@media (max-width: 1200px) {
.download-settings-layout {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 820px) { @media (max-width: 820px) {
#clipsTab.active { #clipsTab.active {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);