Two real UX wins.
1. Auto-resume queue on startup. New checkbox in Settings -> Download
("Queue beim Start automatisch fortsetzen"). When enabled and the
persisted queue has pending items, processQueue() fires ~5 seconds
after did-finish-load — long enough for the user to see the queue
and pause if they did not actually want this. Default off so the
existing behaviour (explicit Start click) is preserved on upgrade.
The Settings auto-save fingerprint includes the new flag and
syncSettingsFormFromConfig restores it. Tooltip explains the
timing on hover.
2. Already-downloaded indicator on VOD cards. Config gains
downloaded_vod_ids: string[] (bounded to 4096 latest entries).
Every successful queue-item download appends its parsed VOD ID
(or every component ID for merge groups). On the VOD grid each
card whose vod.id is in the set gets a small green checkmark
badge in the top-right plus a slightly dimmed thumbnail, with a
localized "Already downloaded" / "Bereits heruntergeladen"
tooltip. The lookup builds a Set once per render so it stays
O(1) per card. The renderer refreshes its local config copy on
every "newly completed" queue update so the badge appears live
without waiting for a settings save.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1305 lines
48 KiB
TypeScript
1305 lines
48 KiB
TypeScript
const QUEUE_SYNC_FAST_MS = 900;
|
|
const QUEUE_SYNC_DEFAULT_MS = 1800;
|
|
const QUEUE_SYNC_IDLE_MS = 4500;
|
|
const QUEUE_SYNC_HIDDEN_MS = 9000;
|
|
const QUEUE_SYNC_RECENT_ACTIVITY_WINDOW_MS = 15000;
|
|
|
|
async function init(): Promise<void> {
|
|
const [loadedConfig, initialQueue, isDown, version] = await Promise.all([
|
|
window.api.getConfig(),
|
|
window.api.getQueue(),
|
|
window.api.isDownloading(),
|
|
window.api.getVersion()
|
|
]);
|
|
config = loadedConfig;
|
|
const language = setLanguage((config.language as string) || 'en');
|
|
config.language = language;
|
|
queue = Array.isArray(initialQueue) ? initialQueue : [];
|
|
downloading = isDown;
|
|
markQueueActivity();
|
|
|
|
byId('versionText').textContent = `v${version}`;
|
|
byId('versionInfo').textContent = `Version: v${version}`;
|
|
document.title = `${UI_TEXT.appName} v${version}`;
|
|
|
|
byId<HTMLInputElement>('clientId').value = config.client_id ?? '';
|
|
byId<HTMLInputElement>('clientSecret').value = config.client_secret ?? '';
|
|
byId<HTMLInputElement>('downloadPath').value = config.download_path ?? '';
|
|
byId<HTMLSelectElement>('themeSelect').value = config.theme ?? 'twitch';
|
|
byId<HTMLSelectElement>('languageSelect').value = config.language ?? 'en';
|
|
updateLanguagePicker(config.language ?? 'en');
|
|
byId<HTMLSelectElement>('downloadMode').value = config.download_mode ?? 'full';
|
|
byId<HTMLInputElement>('partMinutes').value = String(config.part_minutes ?? 120);
|
|
byId<HTMLSelectElement>('performanceMode').value = (config.performance_mode as string) || 'balanced';
|
|
byId<HTMLInputElement>('smartSchedulerToggle').checked = (config.smart_queue_scheduler as boolean) !== false;
|
|
byId<HTMLInputElement>('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false;
|
|
byId<HTMLInputElement>('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10);
|
|
byId<HTMLInputElement>('vodFilenameTemplate').value = (config.filename_template_vod as string) || DEFAULT_VOD_TEMPLATE;
|
|
byId<HTMLInputElement>('partsFilenameTemplate').value = (config.filename_template_parts as string) || DEFAULT_PARTS_TEMPLATE;
|
|
byId<HTMLInputElement>('defaultClipFilenameTemplate').value = (config.filename_template_clip as string) || DEFAULT_CLIP_TEMPLATE;
|
|
initSettingsAutoSave();
|
|
|
|
changeTheme(config.theme ?? 'twitch');
|
|
renderStreamers();
|
|
renderQueue();
|
|
initQueueDragDrop();
|
|
updateDownloadButtonState();
|
|
updateStatusBarQueueSummary();
|
|
|
|
// Restore persisted VOD filter into the input — the filter itself only
|
|
// takes effect once VODs load (renderVODs reads vodFilterQuery).
|
|
vodFilterQuery = loadPersistedVodFilter();
|
|
const vodFilterInput = document.getElementById('vodFilterInput') as HTMLInputElement | null;
|
|
if (vodFilterInput) vodFilterInput.value = vodFilterQuery;
|
|
syncVodFilterClearButton();
|
|
|
|
// Restore persisted VOD sort key. Apply localized labels to <option>s
|
|
// before syncing the select value so the right option is preselected
|
|
// even on first load before any language change fires.
|
|
vodSortKey = loadPersistedVodSort();
|
|
refreshVodSortSelectLabels();
|
|
syncVodSortSelect();
|
|
|
|
// Restore last active tab from previous session (default 'vods')
|
|
showTab(loadPersistedActiveTab());
|
|
|
|
window.api.onQueueUpdated(async (q: QueueItem[]) => {
|
|
const previouslyCompleted = new Set(queue.filter((i) => i.status === 'completed').map((i) => i.id));
|
|
const next = Array.isArray(q) ? q : [];
|
|
const newlyCompletedItem = next.some((i) => i.status === 'completed' && !previouslyCompleted.has(i.id));
|
|
queue = mergeQueueState(next);
|
|
|
|
// When an item flips to 'completed' the main process appends its
|
|
// VOD ID to config.downloaded_vod_ids. Refresh our local config
|
|
// copy so the "already downloaded" badge on the VOD grid updates
|
|
// live without waiting for a settings save.
|
|
if (newlyCompletedItem) {
|
|
try {
|
|
config = await window.api.getConfig();
|
|
} catch { /* network blip — next sync will refresh */ }
|
|
if (typeof renderVodGridFromCurrentState === 'function' && lastLoadedStreamer) {
|
|
renderVodGridFromCurrentState();
|
|
}
|
|
}
|
|
|
|
renderQueue();
|
|
updateStatusBarQueueSummary();
|
|
markQueueActivity();
|
|
});
|
|
|
|
window.api.onQueueDuplicateSkipped((payload) => {
|
|
const title = payload?.title ? ` (${payload.title})` : '';
|
|
showAppToast(`${UI_TEXT.queue.duplicateSkipped}${title}`, 'warn');
|
|
});
|
|
|
|
window.api.onDownloadProgress((progress: DownloadProgress) => {
|
|
const item = queue.find((i: QueueItem) => i.id === progress.id);
|
|
if (!item) {
|
|
return;
|
|
}
|
|
|
|
item.status = 'downloading';
|
|
item.progress = progress.progress;
|
|
item.speed = progress.speed;
|
|
item.eta = progress.eta;
|
|
item.currentPart = progress.currentPart;
|
|
item.totalParts = progress.totalParts;
|
|
item.downloadedBytes = progress.downloadedBytes;
|
|
item.totalBytes = progress.totalBytes;
|
|
item.progressStatus = progress.status;
|
|
updateQueueItemProgress(progress);
|
|
updateStatusBarQueueSummary();
|
|
markQueueActivity();
|
|
});
|
|
|
|
window.api.onDownloadStarted(() => {
|
|
downloading = true;
|
|
updateDownloadButtonState();
|
|
markQueueActivity();
|
|
});
|
|
|
|
window.api.onDownloadFinished(() => {
|
|
downloading = false;
|
|
updateDownloadButtonState();
|
|
markQueueActivity();
|
|
});
|
|
|
|
window.api.onCutProgress((percent: number) => {
|
|
byId('cutProgressBar').style.width = percent + '%';
|
|
byId('cutProgressText').textContent = Math.round(percent) + '%';
|
|
});
|
|
|
|
window.api.onMergeProgress((percent: number) => {
|
|
byId('mergeProgressBar').style.width = percent + '%';
|
|
byId('mergeProgressText').textContent = Math.round(percent) + '%';
|
|
});
|
|
|
|
// Update stats bar
|
|
updateStatsBar();
|
|
const _statsInterval = setInterval(updateStatsBar, 5000);
|
|
|
|
if (config.client_id && config.client_secret) {
|
|
await connect();
|
|
} else {
|
|
updateStatus(UI_TEXT.status.noLogin, false);
|
|
}
|
|
|
|
if (config.streamers && config.streamers.length > 0) {
|
|
await selectStreamer(config.streamers[0]);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
void checkUpdateSilent();
|
|
}, 3000);
|
|
|
|
void runPreflight(false);
|
|
void refreshDebugLog();
|
|
validateFilenameTemplates();
|
|
void refreshRuntimeMetrics();
|
|
|
|
document.addEventListener('visibilitychange', () => {
|
|
scheduleQueueSync(document.hidden ? 600 : 150);
|
|
});
|
|
|
|
// Keyboard shortcuts
|
|
document.addEventListener('keydown', (e) => {
|
|
// Esc closes any open modal — works regardless of focus, so users can dismiss
|
|
// a modal that took focus from inside an input field
|
|
if (e.key === 'Escape') {
|
|
if (closeTopmostOpenModal()) {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
|
|
// No modal open: if the VOD filter has focus or content, clear it.
|
|
// Otherwise let Esc bubble (e.g. blur).
|
|
if (e.target instanceof HTMLInputElement && e.target.id === 'vodFilterInput') {
|
|
if (vodFilterQuery) {
|
|
clearVodFilter();
|
|
e.preventDefault();
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Ctrl+F (or Cmd+F): focus the VOD filter — only when on the VODs tab.
|
|
// Browser's default Ctrl+F is suppressed because Electron's renderer
|
|
// doesn't have a native find bar anyway.
|
|
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 'f' || e.key === 'F')) {
|
|
const onVodsTab = document.getElementById('vodsTab')?.classList.contains('active');
|
|
if (onVodsTab) {
|
|
e.preventDefault();
|
|
focusVodFilter();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Skip rest if user is typing in an input field
|
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) return;
|
|
|
|
// Ctrl+1..5 jumps directly to a tab (Cmd on macOS via metaKey)
|
|
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key >= '1' && e.key <= '5') {
|
|
const tabIndex = parseInt(e.key, 10) - 1;
|
|
if (tabIndex >= 0 && tabIndex < TAB_IDS.length) {
|
|
e.preventDefault();
|
|
showTab(TAB_IDS[tabIndex]);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (e.key === 'Delete' && selectedQueueIds.length > 0) {
|
|
// Delete selected queue items
|
|
const idsToRemove = [...selectedQueueIds];
|
|
selectedQueueIds = [];
|
|
(async () => {
|
|
for (const id of idsToRemove) {
|
|
queue = await window.api.removeFromQueue(id);
|
|
}
|
|
renderQueue();
|
|
})();
|
|
}
|
|
|
|
if ((e.key === 's' || e.key === 'S') && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
|
e.preventDefault();
|
|
toggleDownload();
|
|
}
|
|
});
|
|
|
|
scheduleQueueSync(QUEUE_SYNC_DEFAULT_MS);
|
|
}
|
|
|
|
function openTwitchDevConsole(): void {
|
|
void window.api.openExternal('https://dev.twitch.tv/console/apps');
|
|
}
|
|
|
|
function closeTopmostOpenModal(): boolean {
|
|
// Try each known modal in priority order: clip dialog, template guide, update modal
|
|
const clipModal = document.getElementById('clipModal');
|
|
if (clipModal?.classList.contains('show')) {
|
|
closeClipDialog();
|
|
return true;
|
|
}
|
|
|
|
const templateGuideModal = document.getElementById('templateGuideModal');
|
|
if (templateGuideModal?.classList.contains('show')) {
|
|
closeTemplateGuide();
|
|
return true;
|
|
}
|
|
|
|
const updateModal = document.getElementById('updateModal');
|
|
if (updateModal?.classList.contains('show')) {
|
|
dismissUpdateModal();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function formatBytesRenderer(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
}
|
|
|
|
function formatSpeedRenderer(bytesPerSec: number): string {
|
|
if (bytesPerSec < 1024) return `${bytesPerSec.toFixed(0)} B/s`;
|
|
if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`;
|
|
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
|
|
}
|
|
|
|
function updateStatusBarQueueSummary(): void {
|
|
const node = document.getElementById('statusBarQueueSummary');
|
|
if (!node) return;
|
|
if (!Array.isArray(queue) || queue.length === 0) {
|
|
node.textContent = '';
|
|
return;
|
|
}
|
|
|
|
let downloading = 0;
|
|
let pending = 0;
|
|
for (const item of queue) {
|
|
if (item.status === 'downloading') downloading++;
|
|
else if (item.status === 'pending') pending++;
|
|
}
|
|
|
|
if (downloading === 0 && pending === 0) {
|
|
node.textContent = '';
|
|
return;
|
|
}
|
|
|
|
node.textContent = UI_TEXT.queue.statusBarSummary
|
|
.replace('{downloading}', String(downloading))
|
|
.replace('{pending}', String(pending));
|
|
}
|
|
|
|
async function updateStatsBar(): Promise<void> {
|
|
try {
|
|
const metrics = await window.api.getRuntimeMetrics();
|
|
const bar = byId('statsBar');
|
|
if (!bar) return;
|
|
const totalDL = formatBytesRenderer(metrics.downloadedBytesTotal);
|
|
const avgSpeed = metrics.avgSpeedBytesPerSec > 0 ? formatSpeedRenderer(metrics.avgSpeedBytesPerSec) : '-';
|
|
bar.textContent = `${totalDL} | ${avgSpeed} avg | ${metrics.downloadsCompleted} done | ${metrics.downloadsFailed} failed`;
|
|
} catch { }
|
|
}
|
|
|
|
let toastHideTimer: number | null = null;
|
|
let queueSyncTimer: number | null = null;
|
|
let queueSyncInFlight = false;
|
|
let lastQueueActivityAt = Date.now();
|
|
|
|
function markQueueActivity(): void {
|
|
lastQueueActivityAt = Date.now();
|
|
}
|
|
|
|
function hasActiveQueueWork(): boolean {
|
|
return queue.some((item) => item.status === 'pending' || item.status === 'downloading' || item.status === 'paused');
|
|
}
|
|
|
|
function getNextQueueSyncDelayMs(): number {
|
|
if (document.hidden) {
|
|
return QUEUE_SYNC_HIDDEN_MS;
|
|
}
|
|
|
|
if (downloading || queue.some((item) => item.status === 'downloading')) {
|
|
return QUEUE_SYNC_FAST_MS;
|
|
}
|
|
|
|
if (hasActiveQueueWork()) {
|
|
return QUEUE_SYNC_DEFAULT_MS;
|
|
}
|
|
|
|
const idleForMs = Date.now() - lastQueueActivityAt;
|
|
return idleForMs > QUEUE_SYNC_RECENT_ACTIVITY_WINDOW_MS ? QUEUE_SYNC_IDLE_MS : QUEUE_SYNC_DEFAULT_MS;
|
|
}
|
|
|
|
function scheduleQueueSync(delayMs = getNextQueueSyncDelayMs()): void {
|
|
if (queueSyncTimer) {
|
|
clearTimeout(queueSyncTimer);
|
|
queueSyncTimer = null;
|
|
}
|
|
|
|
queueSyncTimer = window.setTimeout(() => {
|
|
queueSyncTimer = null;
|
|
void runQueueSyncCycle();
|
|
}, Math.max(300, Math.floor(delayMs)));
|
|
}
|
|
|
|
async function runQueueSyncCycle(): Promise<void> {
|
|
if (queueSyncInFlight) {
|
|
scheduleQueueSync(400);
|
|
return;
|
|
}
|
|
|
|
queueSyncInFlight = true;
|
|
try {
|
|
await syncQueueAndDownloadState();
|
|
} catch {
|
|
// ignore transient IPC errors and retry on next cycle
|
|
} finally {
|
|
queueSyncInFlight = false;
|
|
scheduleQueueSync();
|
|
}
|
|
}
|
|
|
|
function showAppToast(message: string, type: 'info' | 'warn' = 'info'): void {
|
|
let toast = document.getElementById('appToast');
|
|
if (!toast) {
|
|
toast = document.createElement('div');
|
|
toast.id = 'appToast';
|
|
toast.className = 'app-toast';
|
|
document.body.appendChild(toast);
|
|
}
|
|
|
|
toast.textContent = message;
|
|
toast.classList.remove('warn', 'show');
|
|
if (type === 'warn') {
|
|
toast.classList.add('warn');
|
|
}
|
|
|
|
requestAnimationFrame(() => {
|
|
toast?.classList.add('show');
|
|
});
|
|
|
|
if (toastHideTimer) {
|
|
clearTimeout(toastHideTimer);
|
|
toastHideTimer = null;
|
|
}
|
|
|
|
toastHideTimer = window.setTimeout(() => {
|
|
toast?.classList.remove('show');
|
|
}, 3200);
|
|
}
|
|
|
|
function mergeQueueState(nextQueue: QueueItem[]): QueueItem[] {
|
|
const prevById = new Map(queue.map((item) => [item.id, item]));
|
|
|
|
return nextQueue.map((item) => {
|
|
const prev = prevById.get(item.id);
|
|
if (!prev) {
|
|
return item;
|
|
}
|
|
|
|
if (item.status !== 'downloading') {
|
|
return item;
|
|
}
|
|
|
|
// Keep the higher progress value to prevent backward jumps from stale data
|
|
const bestProgress = (prev.status === 'downloading' && prev.progress > item.progress)
|
|
? prev.progress
|
|
: (item.progress > 0 ? item.progress : prev.progress);
|
|
|
|
return {
|
|
...item,
|
|
progress: bestProgress,
|
|
speed: item.speed || prev.speed,
|
|
eta: item.eta || prev.eta,
|
|
currentPart: item.currentPart || prev.currentPart,
|
|
totalParts: item.totalParts || prev.totalParts,
|
|
downloadedBytes: item.downloadedBytes || prev.downloadedBytes,
|
|
totalBytes: item.totalBytes || prev.totalBytes,
|
|
progressStatus: item.progressStatus || prev.progressStatus
|
|
};
|
|
});
|
|
}
|
|
|
|
function getQueueStateFingerprint(items: QueueItem[]): string {
|
|
return items.map((item) => [
|
|
item.id,
|
|
item.status,
|
|
Math.round((Number(item.progress) || 0) * 10),
|
|
item.currentPart || 0,
|
|
item.totalParts || 0,
|
|
item.last_error || '',
|
|
item.progressStatus || ''
|
|
].join(':')).join('|');
|
|
}
|
|
|
|
function updateDownloadButtonState(): void {
|
|
const btn = byId('btnStart');
|
|
const hasPaused = queue.some((item) => item.status === 'paused');
|
|
btn.textContent = downloading ? UI_TEXT.queue.stop : (hasPaused ? UI_TEXT.queue.resume : UI_TEXT.queue.start);
|
|
btn.classList.toggle('downloading', downloading);
|
|
}
|
|
|
|
async function syncQueueAndDownloadState(): Promise<void> {
|
|
const previousFingerprint = getQueueStateFingerprint(queue);
|
|
const latestQueue = await window.api.getQueue();
|
|
queue = mergeQueueState(Array.isArray(latestQueue) ? latestQueue : []);
|
|
const nextFingerprint = getQueueStateFingerprint(queue);
|
|
if (nextFingerprint !== previousFingerprint) {
|
|
markQueueActivity();
|
|
}
|
|
renderQueue();
|
|
|
|
const backendDownloading = await window.api.isDownloading();
|
|
if (backendDownloading !== downloading) {
|
|
downloading = backendDownloading;
|
|
updateDownloadButtonState();
|
|
}
|
|
}
|
|
|
|
const TAB_IDS = ['vods', 'clips', 'cutter', 'merge', 'settings'] as const;
|
|
const ACTIVE_TAB_STORAGE_KEY = 'twitch-vod-manager:active-tab';
|
|
|
|
function isKnownTab(value: string): value is typeof TAB_IDS[number] {
|
|
return (TAB_IDS as readonly string[]).includes(value);
|
|
}
|
|
|
|
function loadPersistedActiveTab(): string {
|
|
try {
|
|
const stored = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY);
|
|
if (stored && isKnownTab(stored)) return stored;
|
|
} catch { /* localStorage may be unavailable in privacy modes */ }
|
|
return 'vods';
|
|
}
|
|
|
|
function persistActiveTab(tab: string): void {
|
|
if (!isKnownTab(tab)) return;
|
|
try { localStorage.setItem(ACTIVE_TAB_STORAGE_KEY, tab); } catch { }
|
|
}
|
|
|
|
function showTab(tab: string): void {
|
|
queryAll('.nav-item').forEach((i) => i.classList.remove('active'));
|
|
queryAll('.tab-content').forEach((c) => c.classList.remove('active'));
|
|
|
|
const navItem = query(`.nav-item[data-tab="${tab}"]`);
|
|
if (!navItem) {
|
|
// Unknown tab — fall back to vods so the user is never stuck on an empty screen
|
|
showTab('vods');
|
|
return;
|
|
}
|
|
navItem.classList.add('active');
|
|
byId(tab + 'Tab').classList.add('active');
|
|
|
|
const titles: Record<string, string> = UI_TEXT.tabs;
|
|
|
|
// Only show the streamer name on the VODs tab — otherwise the title would
|
|
// mismatch the tab content (e.g. "streamer X" while on Settings)
|
|
byId('pageTitle').textContent = (tab === 'vods' && currentStreamer)
|
|
? currentStreamer
|
|
: (titles[tab] || UI_TEXT.appName);
|
|
|
|
persistActiveTab(tab);
|
|
}
|
|
|
|
function parseDurationToSeconds(durStr: string): number {
|
|
let seconds = 0;
|
|
const hours = durStr.match(/(\d+)h/);
|
|
const minutes = durStr.match(/(\d+)m/);
|
|
const secs = durStr.match(/(\d+)s/);
|
|
|
|
if (hours) seconds += parseInt(hours[1], 10) * 3600;
|
|
if (minutes) seconds += parseInt(minutes[1], 10) * 60;
|
|
if (secs) seconds += parseInt(secs[1], 10);
|
|
|
|
return seconds;
|
|
}
|
|
|
|
function formatSecondsToTime(seconds: number): string {
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
function formatSecondsToTimeDashed(seconds: number): string {
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
return `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
const DEFAULT_VOD_TEMPLATE = '{title}.mp4';
|
|
const DEFAULT_PARTS_TEMPLATE = '{date}_Part{part_padded}.mp4';
|
|
const DEFAULT_CLIP_TEMPLATE = '{date}_{part}.mp4';
|
|
|
|
type TemplateGuideSource = 'vod' | 'parts' | 'clip';
|
|
|
|
let templateGuideSource: TemplateGuideSource = 'vod';
|
|
|
|
function formatDateWithPattern(date: Date, pattern: string): string {
|
|
const tokenMap: Record<string, string> = {
|
|
yyyy: date.getFullYear().toString(),
|
|
yy: date.getFullYear().toString().slice(-2),
|
|
MM: (date.getMonth() + 1).toString().padStart(2, '0'),
|
|
M: (date.getMonth() + 1).toString(),
|
|
dd: date.getDate().toString().padStart(2, '0'),
|
|
d: date.getDate().toString(),
|
|
HH: date.getHours().toString().padStart(2, '0'),
|
|
H: date.getHours().toString(),
|
|
hh: date.getHours().toString().padStart(2, '0'),
|
|
h: date.getHours().toString(),
|
|
mm: date.getMinutes().toString().padStart(2, '0'),
|
|
m: date.getMinutes().toString(),
|
|
ss: date.getSeconds().toString().padStart(2, '0'),
|
|
s: date.getSeconds().toString()
|
|
};
|
|
|
|
return pattern
|
|
.replace(/yyyy|yy|MM|M|dd|d|HH|H|hh|h|mm|m|ss|s/g, (token) => tokenMap[token] ?? token)
|
|
.replace(/\\(.)/g, '$1');
|
|
}
|
|
|
|
function formatSecondsWithPattern(totalSeconds: number, pattern: string): string {
|
|
const safe = Math.max(0, Math.floor(totalSeconds));
|
|
const hours = Math.floor(safe / 3600);
|
|
const minutes = Math.floor((safe % 3600) / 60);
|
|
const seconds = safe % 60;
|
|
|
|
const tokenMap: Record<string, string> = {
|
|
HH: hours.toString().padStart(2, '0'),
|
|
H: hours.toString(),
|
|
hh: hours.toString().padStart(2, '0'),
|
|
h: hours.toString(),
|
|
mm: minutes.toString().padStart(2, '0'),
|
|
m: minutes.toString(),
|
|
ss: seconds.toString().padStart(2, '0'),
|
|
s: seconds.toString()
|
|
};
|
|
|
|
return pattern
|
|
.replace(/HH|H|hh|h|mm|m|ss|s/g, (token) => tokenMap[token] ?? token)
|
|
.replace(/\\(.)/g, '$1');
|
|
}
|
|
|
|
function getSelectedFilenameFormat(): 'simple' | 'timestamp' | 'template' | 'parts' {
|
|
const selected = query<HTMLInputElement>('input[name="filenameFormat"]:checked').value;
|
|
if (selected === 'template') return 'template';
|
|
if (selected === 'timestamp') return 'timestamp';
|
|
if (selected === 'parts') return 'parts';
|
|
return 'simple';
|
|
}
|
|
|
|
function updateFilenameTemplateVisibility(): void {
|
|
const selected = getSelectedFilenameFormat();
|
|
const wrap = byId('clipFilenameTemplateWrap');
|
|
wrap.style.display = selected === 'template' ? 'block' : 'none';
|
|
}
|
|
|
|
interface TemplatePreviewContext {
|
|
title: string;
|
|
date: Date;
|
|
streamer: string;
|
|
partNum: string;
|
|
startSec: number;
|
|
durationSec: number;
|
|
totalSec: number;
|
|
}
|
|
|
|
function buildTemplatePreview(template: string, context: TemplatePreviewContext): string {
|
|
const dateStr = `${context.date.getDate().toString().padStart(2, '0')}.${(context.date.getMonth() + 1).toString().padStart(2, '0')}.${context.date.getFullYear()}`;
|
|
const normalizedPart = context.partNum || '1';
|
|
let output = template
|
|
.replace(/\{title\}/g, context.title || 'Untitled')
|
|
.replace(/\{id\}/g, '123456789')
|
|
.replace(/\{channel\}/g, context.streamer || 'streamer')
|
|
.replace(/\{channel_id\}/g, '0')
|
|
.replace(/\{date\}/g, dateStr)
|
|
.replace(/\{part\}/g, normalizedPart)
|
|
.replace(/\{part_padded\}/g, normalizedPart.padStart(2, '0'))
|
|
.replace(/\{trim_start\}/g, formatSecondsToTimeDashed(context.startSec))
|
|
.replace(/\{trim_end\}/g, formatSecondsToTimeDashed(context.startSec + context.durationSec))
|
|
.replace(/\{trim_length\}/g, formatSecondsToTimeDashed(context.durationSec))
|
|
.replace(/\{length\}/g, formatSecondsToTimeDashed(context.totalSec))
|
|
.replace(/\{ext\}/g, 'mp4')
|
|
.replace(/\{random_string\}/g, 'abcd1234');
|
|
|
|
output = output.replace(/\{date_custom="(.*?)"\}/g, (_, pattern: string) => formatDateWithPattern(context.date, pattern));
|
|
output = output.replace(/\{trim_start_custom="(.*?)"\}/g, (_, pattern: string) => formatSecondsWithPattern(context.startSec, pattern));
|
|
output = output.replace(/\{trim_end_custom="(.*?)"\}/g, (_, pattern: string) => formatSecondsWithPattern(context.startSec + context.durationSec, pattern));
|
|
output = output.replace(/\{trim_length_custom="(.*?)"\}/g, (_, pattern: string) => formatSecondsWithPattern(context.durationSec, pattern));
|
|
output = output.replace(/\{length_custom="(.*?)"\}/g, (_, pattern: string) => formatSecondsWithPattern(context.totalSec, pattern));
|
|
|
|
return output;
|
|
}
|
|
|
|
function getTemplateForSource(source: TemplateGuideSource): string {
|
|
if (source === 'vod') {
|
|
return ((config.filename_template_vod as string) || DEFAULT_VOD_TEMPLATE).trim() || DEFAULT_VOD_TEMPLATE;
|
|
}
|
|
|
|
if (source === 'parts') {
|
|
return ((config.filename_template_parts as string) || DEFAULT_PARTS_TEMPLATE).trim() || DEFAULT_PARTS_TEMPLATE;
|
|
}
|
|
|
|
const clipField = document.getElementById('clipFilenameTemplate') as HTMLInputElement | null;
|
|
const clipFromDialog = clipField?.value.trim() || '';
|
|
if (clipFromDialog) {
|
|
return clipFromDialog;
|
|
}
|
|
|
|
return ((config.filename_template_clip as string) || DEFAULT_CLIP_TEMPLATE).trim() || DEFAULT_CLIP_TEMPLATE;
|
|
}
|
|
|
|
function getTemplateGuidePreviewContext(source: TemplateGuideSource): { context: TemplatePreviewContext; contextText: string } {
|
|
const now = new Date();
|
|
const sampleDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 20, 15, 8);
|
|
const sampleStreamer = currentStreamer || 'sample_streamer';
|
|
|
|
if (source === 'clip' && clipDialogData) {
|
|
const startSec = parseTimeToSeconds(byId<HTMLInputElement>('clipStartTime').value);
|
|
const endSec = parseTimeToSeconds(byId<HTMLInputElement>('clipEndTime').value);
|
|
const clipDuration = Math.max(1, endSec - startSec);
|
|
const totalSec = Math.max(1, clipTotalSeconds || parseDurationToSeconds(clipDialogData.duration));
|
|
|
|
return {
|
|
context: {
|
|
title: clipDialogData.title || 'Clip Title',
|
|
date: new Date(clipDialogData.date),
|
|
streamer: clipDialogData.streamer || sampleStreamer,
|
|
partNum: byId<HTMLInputElement>('clipStartPart').value.trim() || '1',
|
|
startSec,
|
|
durationSec: clipDuration,
|
|
totalSec
|
|
},
|
|
contextText: UI_TEXT.static.templateGuideContextClipLive
|
|
};
|
|
}
|
|
|
|
if (source === 'parts') {
|
|
const partLen = Math.max(60, Number(config.part_minutes ?? 120) * 60);
|
|
return {
|
|
context: {
|
|
title: 'Epic Ranked Session',
|
|
date: sampleDate,
|
|
streamer: sampleStreamer,
|
|
partNum: '3',
|
|
startSec: partLen * 2,
|
|
durationSec: partLen,
|
|
totalSec: partLen * 5
|
|
},
|
|
contextText: UI_TEXT.static.templateGuideContextParts
|
|
};
|
|
}
|
|
|
|
if (source === 'clip') {
|
|
return {
|
|
context: {
|
|
title: 'Funny Clip Moment',
|
|
date: sampleDate,
|
|
streamer: sampleStreamer,
|
|
partNum: '1',
|
|
startSec: 95,
|
|
durationSec: 45,
|
|
totalSec: 5400
|
|
},
|
|
contextText: UI_TEXT.static.templateGuideContextClip
|
|
};
|
|
}
|
|
|
|
return {
|
|
context: {
|
|
title: 'Epic Ranked Session',
|
|
date: sampleDate,
|
|
streamer: sampleStreamer,
|
|
partNum: '1',
|
|
startSec: 0,
|
|
durationSec: 3 * 3600 + 12 * 60 + 5,
|
|
totalSec: 3 * 3600 + 12 * 60 + 5
|
|
},
|
|
contextText: UI_TEXT.static.templateGuideContextVod
|
|
};
|
|
}
|
|
|
|
interface TemplateVariableDoc {
|
|
placeholder: string;
|
|
description: string;
|
|
exampleTemplate: string;
|
|
}
|
|
|
|
function getTemplateVariableDocs(): TemplateVariableDoc[] {
|
|
const de = currentLanguage !== 'en';
|
|
const text = (deText: string, enText: string) => de ? deText : enText;
|
|
|
|
return [
|
|
{ placeholder: '{title}', description: text('Titel des VODs/Clips', 'Title of the VOD/clip'), exampleTemplate: '{title}' },
|
|
{ placeholder: '{id}', description: text('VOD-ID', 'VOD id'), exampleTemplate: '{id}' },
|
|
{ placeholder: '{channel}', description: text('Kanalname', 'Channel name'), exampleTemplate: '{channel}' },
|
|
{ placeholder: '{date}', description: text('Datum (DD.MM.YYYY)', 'Date (DD.MM.YYYY)'), exampleTemplate: '{date}' },
|
|
{ placeholder: '{part}', description: text('Teilnummer', 'Part number'), exampleTemplate: '{part}' },
|
|
{ placeholder: '{part_padded}', description: text('Teilnummer mit 2 Stellen', 'Part number padded to 2 digits'), exampleTemplate: '{part_padded}' },
|
|
{ placeholder: '{trim_start}', description: text('Startzeit des Ausschnitts', 'Trim start time'), exampleTemplate: '{trim_start}' },
|
|
{ placeholder: '{trim_end}', description: text('Endzeit des Ausschnitts', 'Trim end time'), exampleTemplate: '{trim_end}' },
|
|
{ placeholder: '{trim_length}', description: text('Lange des Ausschnitts', 'Trimmed duration'), exampleTemplate: '{trim_length}' },
|
|
{ placeholder: '{length}', description: text('Gesamtdauer', 'Total duration'), exampleTemplate: '{length}' },
|
|
{ placeholder: '{ext}', description: text('Dateiendung', 'File extension'), exampleTemplate: '{ext}' },
|
|
{ placeholder: '{random_string}', description: text('Zufallsstring (8 Zeichen)', 'Random string (8 chars)'), exampleTemplate: '{random_string}' },
|
|
{ placeholder: '{date_custom="yyyy-MM-dd"}', description: text('Datum mit eigenem Format', 'Custom-formatted date'), exampleTemplate: '{date_custom="yyyy-MM-dd"}' },
|
|
{ placeholder: '{trim_start_custom="HH-mm-ss"}', description: text('Startzeit mit eigenem Format', 'Custom-formatted trim start'), exampleTemplate: '{trim_start_custom="HH-mm-ss"}' },
|
|
{ placeholder: '{trim_end_custom="HH-mm-ss"}', description: text('Endzeit mit eigenem Format', 'Custom-formatted trim end'), exampleTemplate: '{trim_end_custom="HH-mm-ss"}' },
|
|
{ placeholder: '{trim_length_custom="HH-mm-ss"}', description: text('Trim-Dauer mit eigenem Format', 'Custom-formatted trim length'), exampleTemplate: '{trim_length_custom="HH-mm-ss"}' },
|
|
{ placeholder: '{length_custom="HH-mm-ss"}', description: text('Gesamtdauer mit eigenem Format', 'Custom-formatted total duration'), exampleTemplate: '{length_custom="HH-mm-ss"}' }
|
|
];
|
|
}
|
|
|
|
function renderTemplateGuideTable(context: TemplatePreviewContext): void {
|
|
const body = byId('templateGuideBody');
|
|
body.innerHTML = '';
|
|
|
|
for (const item of getTemplateVariableDocs()) {
|
|
const row = document.createElement('tr');
|
|
const varCell = document.createElement('td');
|
|
const descCell = document.createElement('td');
|
|
const exampleCell = document.createElement('td');
|
|
|
|
varCell.textContent = item.placeholder;
|
|
descCell.textContent = item.description;
|
|
exampleCell.textContent = buildTemplatePreview(item.exampleTemplate, context);
|
|
|
|
row.append(varCell, descCell, exampleCell);
|
|
body.appendChild(row);
|
|
}
|
|
}
|
|
|
|
function updateTemplateGuidePresetButtons(): void {
|
|
const activeId: Record<TemplateGuideSource, string> = {
|
|
vod: 'templateGuideUseVod',
|
|
parts: 'templateGuideUseParts',
|
|
clip: 'templateGuideUseClip'
|
|
};
|
|
|
|
(Object.keys(activeId) as TemplateGuideSource[]).forEach((key) => {
|
|
const btn = byId<HTMLButtonElement>(activeId[key]);
|
|
btn.classList.toggle('active', key === templateGuideSource);
|
|
});
|
|
}
|
|
|
|
function refreshTemplateGuideTexts(): void {
|
|
setText('settingsTemplateGuideBtn', UI_TEXT.static.templateGuideButton);
|
|
setText('clipTemplateGuideBtn', UI_TEXT.static.templateGuideButton);
|
|
setText('templateGuideTitle', UI_TEXT.static.templateGuideTitle);
|
|
setText('templateGuideIntro', UI_TEXT.static.templateGuideIntro);
|
|
setText('templateGuideTemplateLabel', UI_TEXT.static.templateGuideTemplateLabel);
|
|
setText('templateGuideOutputLabel', UI_TEXT.static.templateGuideOutputLabel);
|
|
setText('templateGuideVarsTitle', UI_TEXT.static.templateGuideVarsTitle);
|
|
setText('templateGuideVarCol', UI_TEXT.static.templateGuideVarCol);
|
|
setText('templateGuideDescCol', UI_TEXT.static.templateGuideDescCol);
|
|
setText('templateGuideExampleCol', UI_TEXT.static.templateGuideExampleCol);
|
|
setText('templateGuideUseVod', UI_TEXT.static.templateGuideUseVod);
|
|
setText('templateGuideUseParts', UI_TEXT.static.templateGuideUseParts);
|
|
setText('templateGuideUseClip', UI_TEXT.static.templateGuideUseClip);
|
|
setText('templateGuideCloseBtn', UI_TEXT.static.templateGuideClose);
|
|
setPlaceholder('templateGuideInput', getTemplateForSource(templateGuideSource));
|
|
updateTemplateGuidePresetButtons();
|
|
|
|
const modal = document.getElementById('templateGuideModal');
|
|
if (modal?.classList.contains('show')) {
|
|
updateTemplateGuidePreview();
|
|
}
|
|
}
|
|
|
|
function openTemplateGuide(source: TemplateGuideSource = 'vod'): void {
|
|
templateGuideSource = source;
|
|
byId('templateGuideModal').classList.add('show');
|
|
refreshTemplateGuideTexts();
|
|
setTemplateGuidePreset(source);
|
|
}
|
|
|
|
function closeTemplateGuide(): void {
|
|
byId('templateGuideModal').classList.remove('show');
|
|
}
|
|
|
|
function setTemplateGuidePreset(source: TemplateGuideSource): void {
|
|
templateGuideSource = source;
|
|
const template = getTemplateForSource(source);
|
|
byId<HTMLInputElement>('templateGuideInput').value = template;
|
|
setPlaceholder('templateGuideInput', template);
|
|
updateTemplateGuidePresetButtons();
|
|
updateTemplateGuidePreview();
|
|
}
|
|
|
|
function updateTemplateGuidePreview(): void {
|
|
const input = byId<HTMLInputElement>('templateGuideInput');
|
|
const template = input.value.trim() || getTemplateForSource(templateGuideSource);
|
|
const { context, contextText } = getTemplateGuidePreviewContext(templateGuideSource);
|
|
|
|
byId('templateGuideOutput').textContent = buildTemplatePreview(template, context);
|
|
byId('templateGuideContext').textContent = contextText;
|
|
renderTemplateGuideTable(context);
|
|
}
|
|
|
|
function parseTimeToSeconds(timeStr: string): number {
|
|
const parts = timeStr.split(':').map((p: string) => parseInt(p, 10) || 0);
|
|
if (parts.length === 3) {
|
|
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
function openClipDialog(url: string, title: string, date: string, streamer: string, duration: string): void {
|
|
clipDialogData = { url, title, date, streamer, duration };
|
|
clipTotalSeconds = parseDurationToSeconds(duration);
|
|
|
|
byId('clipDialogTitle').textContent = `${UI_TEXT.clips.dialogTitle} (${duration})`;
|
|
byId<HTMLInputElement>('clipStartSlider').max = String(clipTotalSeconds);
|
|
byId<HTMLInputElement>('clipEndSlider').max = String(clipTotalSeconds);
|
|
byId<HTMLInputElement>('clipStartSlider').value = '0';
|
|
byId<HTMLInputElement>('clipEndSlider').value = String(Math.min(60, clipTotalSeconds));
|
|
|
|
byId<HTMLInputElement>('clipStartTime').value = '00:00:00';
|
|
byId<HTMLInputElement>('clipEndTime').value = formatSecondsToTime(Math.min(60, clipTotalSeconds));
|
|
byId<HTMLInputElement>('clipStartPart').value = '';
|
|
byId<HTMLInputElement>('clipFilenameTemplate').value = (config.filename_template_clip as string) || DEFAULT_CLIP_TEMPLATE;
|
|
query<HTMLInputElement>('input[name="filenameFormat"][value="simple"]').checked = true;
|
|
updateFilenameTemplateVisibility();
|
|
|
|
updateClipDuration();
|
|
updateFilenameExamples();
|
|
byId('clipModal').classList.add('show');
|
|
}
|
|
|
|
function closeClipDialog(): void {
|
|
byId('clipModal').classList.remove('show');
|
|
clipDialogData = null;
|
|
}
|
|
|
|
function updateFromSlider(which: string): void {
|
|
const startSlider = byId<HTMLInputElement>('clipStartSlider');
|
|
const endSlider = byId<HTMLInputElement>('clipEndSlider');
|
|
|
|
if (which === 'start') {
|
|
byId<HTMLInputElement>('clipStartTime').value = formatSecondsToTime(parseInt(startSlider.value, 10));
|
|
} else {
|
|
byId<HTMLInputElement>('clipEndTime').value = formatSecondsToTime(parseInt(endSlider.value, 10));
|
|
}
|
|
|
|
updateClipDuration();
|
|
}
|
|
|
|
function updateFromInput(which: string): void {
|
|
const startSec = parseTimeToSeconds(byId<HTMLInputElement>('clipStartTime').value);
|
|
const endSec = parseTimeToSeconds(byId<HTMLInputElement>('clipEndTime').value);
|
|
|
|
if (which === 'start') {
|
|
byId<HTMLInputElement>('clipStartSlider').value = String(Math.max(0, Math.min(startSec, clipTotalSeconds)));
|
|
} else {
|
|
byId<HTMLInputElement>('clipEndSlider').value = String(Math.max(0, Math.min(endSec, clipTotalSeconds)));
|
|
}
|
|
|
|
updateClipDuration();
|
|
}
|
|
|
|
function updateClipDuration(): void {
|
|
const startSec = parseTimeToSeconds(byId<HTMLInputElement>('clipStartTime').value);
|
|
const endSec = parseTimeToSeconds(byId<HTMLInputElement>('clipEndTime').value);
|
|
const duration = endSec - startSec;
|
|
const durationDisplay = byId('clipDurationDisplay');
|
|
|
|
if (duration > 0) {
|
|
durationDisplay.textContent = formatSecondsToTime(duration);
|
|
durationDisplay.style.color = '#00c853';
|
|
} else {
|
|
durationDisplay.textContent = UI_TEXT.clips.invalidDuration;
|
|
durationDisplay.style.color = '#ff4444';
|
|
}
|
|
|
|
updateFilenameExamples();
|
|
}
|
|
|
|
function updateFilenameExamples(): void {
|
|
if (!clipDialogData) {
|
|
return;
|
|
}
|
|
|
|
const date = new Date(clipDialogData.date);
|
|
const dateStr = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`;
|
|
const partNum = byId<HTMLInputElement>('clipStartPart').value || '1';
|
|
const startSec = parseTimeToSeconds(byId<HTMLInputElement>('clipStartTime').value);
|
|
const endSec = parseTimeToSeconds(byId<HTMLInputElement>('clipEndTime').value);
|
|
const durationSec = Math.max(1, endSec - startSec);
|
|
const timeStr = formatSecondsToTimeDashed(startSec);
|
|
const template = byId<HTMLInputElement>('clipFilenameTemplate').value.trim() || (config.filename_template_clip as string) || DEFAULT_CLIP_TEMPLATE;
|
|
const unknownTokens = collectUnknownTemplatePlaceholders(template);
|
|
const clipLint = byId('clipTemplateLint');
|
|
|
|
updateFilenameTemplateVisibility();
|
|
|
|
if (!unknownTokens.length) {
|
|
clipLint.style.color = '#8bc34a';
|
|
clipLint.textContent = UI_TEXT.static.templateLintOk;
|
|
} else {
|
|
clipLint.style.color = '#ff8a80';
|
|
clipLint.textContent = `${UI_TEXT.static.templateLintWarn}: ${unknownTokens.join(' ')}`;
|
|
}
|
|
|
|
byId('formatSimple').textContent = `${dateStr}_${partNum}.mp4 ${UI_TEXT.clips.formatSimple}`;
|
|
byId('formatTimestamp').textContent = `${dateStr}_CLIP_${timeStr}_${partNum}.mp4 ${UI_TEXT.clips.formatTimestamp}`;
|
|
byId('formatParts').textContent = `${dateStr}_Part${partNum.padStart(2, '0')}.mp4 ${UI_TEXT.clips.formatParts}`;
|
|
byId('formatTemplate').textContent = `${buildTemplatePreview(template, {
|
|
title: clipDialogData.title,
|
|
date,
|
|
streamer: clipDialogData.streamer,
|
|
partNum,
|
|
startSec,
|
|
durationSec,
|
|
totalSec: clipTotalSeconds
|
|
})} ${UI_TEXT.clips.formatTemplate}`;
|
|
|
|
const guideModal = document.getElementById('templateGuideModal');
|
|
if (guideModal?.classList.contains('show') && templateGuideSource === 'clip') {
|
|
updateTemplateGuidePreview();
|
|
}
|
|
}
|
|
|
|
async function confirmClipDialog(): Promise<void> {
|
|
if (!clipDialogData) {
|
|
return;
|
|
}
|
|
|
|
const startSec = parseTimeToSeconds(byId<HTMLInputElement>('clipStartTime').value);
|
|
const endSec = parseTimeToSeconds(byId<HTMLInputElement>('clipEndTime').value);
|
|
const durationSec = endSec - startSec;
|
|
const startPartStr = byId<HTMLInputElement>('clipStartPart').value.trim();
|
|
const startPart = startPartStr ? parseInt(startPartStr, 10) : 1;
|
|
const filenameFormat = getSelectedFilenameFormat();
|
|
const filenameTemplate = byId<HTMLInputElement>('clipFilenameTemplate').value.trim();
|
|
|
|
if (isNaN(startSec) || isNaN(endSec) || isNaN(durationSec)) {
|
|
alert('Invalid time values');
|
|
return;
|
|
}
|
|
|
|
if (startSec < 0) {
|
|
alert(UI_TEXT.clips.outOfRange);
|
|
return;
|
|
}
|
|
|
|
if (durationSec <= 0) {
|
|
alert(UI_TEXT.clips.endBeforeStart);
|
|
return;
|
|
}
|
|
|
|
if (endSec > clipTotalSeconds) {
|
|
alert(UI_TEXT.clips.outOfRange);
|
|
return;
|
|
}
|
|
|
|
if (filenameFormat === 'template' && !filenameTemplate) {
|
|
alert(UI_TEXT.clips.templateEmpty);
|
|
return;
|
|
}
|
|
|
|
if (filenameFormat === 'template') {
|
|
const unknownTokens = collectUnknownTemplatePlaceholders(filenameTemplate);
|
|
if (unknownTokens.length > 0) {
|
|
alert(`${UI_TEXT.static.templateLintWarn}: ${unknownTokens.join(' ')}`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const customClip: CustomClip = {
|
|
startSec,
|
|
durationSec,
|
|
startPart,
|
|
filenameFormat,
|
|
filenameTemplate: filenameFormat === 'template' ? filenameTemplate : undefined
|
|
};
|
|
|
|
if ((config.prevent_duplicate_downloads as boolean) !== false && hasActiveQueueDuplicate(
|
|
clipDialogData.url,
|
|
clipDialogData.streamer,
|
|
clipDialogData.date,
|
|
customClip
|
|
)) {
|
|
alert(UI_TEXT.queue.duplicateSkipped);
|
|
return;
|
|
}
|
|
|
|
queue = await window.api.addToQueue({
|
|
url: clipDialogData.url,
|
|
title: clipDialogData.title,
|
|
date: clipDialogData.date,
|
|
streamer: clipDialogData.streamer,
|
|
duration_str: clipDialogData.duration,
|
|
customClip
|
|
});
|
|
|
|
renderQueue();
|
|
closeClipDialog();
|
|
}
|
|
|
|
async function downloadClip(): Promise<void> {
|
|
const url = byId<HTMLInputElement>('clipUrl').value.trim();
|
|
const status = byId('clipStatus');
|
|
const btn = byId('btnClip');
|
|
|
|
if (!url) {
|
|
status.textContent = UI_TEXT.clips.enterUrl;
|
|
status.className = 'clip-status error';
|
|
return;
|
|
}
|
|
|
|
btn.disabled = true;
|
|
btn.textContent = UI_TEXT.clips.loadingButton;
|
|
status.textContent = UI_TEXT.clips.loadingStatus;
|
|
status.className = 'clip-status loading';
|
|
|
|
const result = await window.api.downloadClip(url);
|
|
|
|
btn.disabled = false;
|
|
btn.textContent = UI_TEXT.clips.downloadButton;
|
|
|
|
if (result.success) {
|
|
status.textContent = UI_TEXT.clips.success;
|
|
status.className = 'clip-status success';
|
|
return;
|
|
}
|
|
|
|
// Backend now produces locale-aware error strings via tBackend(),
|
|
// so we no longer need a renderer-side translation table here.
|
|
const backendError = (result.error || '').trim();
|
|
status.textContent = UI_TEXT.clips.errorPrefix + (backendError || UI_TEXT.clips.unknownError);
|
|
status.className = 'clip-status error';
|
|
}
|
|
|
|
async function selectCutterVideo(): Promise<void> {
|
|
const filePath = await window.api.selectVideoFile();
|
|
if (!filePath) {
|
|
return;
|
|
}
|
|
|
|
cutterFile = filePath;
|
|
byId<HTMLInputElement>('cutterFilePath').value = filePath;
|
|
|
|
const info = await window.api.getVideoInfo(filePath);
|
|
if (!info) {
|
|
alert(UI_TEXT.cutter.videoInfoFailed);
|
|
return;
|
|
}
|
|
|
|
cutterVideoInfo = info;
|
|
cutterStartTime = 0;
|
|
cutterEndTime = info.duration;
|
|
|
|
byId('cutterInfo').style.display = 'flex';
|
|
byId('timelineContainer').style.display = 'block';
|
|
byId('btnCut').disabled = false;
|
|
|
|
byId('infoDuration').textContent = formatTime(info.duration);
|
|
byId('infoResolution').textContent = `${info.width}x${info.height}`;
|
|
byId('infoFps').textContent = Math.round(info.fps);
|
|
byId('infoSelection').textContent = formatTime(info.duration);
|
|
|
|
byId<HTMLInputElement>('startTime').value = '00:00:00';
|
|
byId<HTMLInputElement>('endTime').value = formatTime(info.duration);
|
|
|
|
updateTimeline();
|
|
await updatePreview(0);
|
|
}
|
|
|
|
function formatTime(seconds: number): string {
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
function parseTime(timeStr: string): number {
|
|
const parts = timeStr.split(':').map((p: string) => parseInt(p, 10) || 0);
|
|
if (parts.length === 3) {
|
|
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
function updateTimeline(): void {
|
|
if (!cutterVideoInfo) {
|
|
return;
|
|
}
|
|
|
|
const selection = byId('timelineSelection');
|
|
const startPercent = (cutterStartTime / cutterVideoInfo.duration) * 100;
|
|
const endPercent = (cutterEndTime / cutterVideoInfo.duration) * 100;
|
|
|
|
selection.style.left = startPercent + '%';
|
|
selection.style.width = (endPercent - startPercent) + '%';
|
|
|
|
const duration = cutterEndTime - cutterStartTime;
|
|
byId('infoSelection').textContent = formatTime(duration);
|
|
}
|
|
|
|
function updateTimeFromInput(): void {
|
|
const startStr = byId<HTMLInputElement>('startTime').value;
|
|
const endStr = byId<HTMLInputElement>('endTime').value;
|
|
|
|
cutterStartTime = Math.max(0, parseTime(startStr));
|
|
cutterEndTime = Math.min(cutterVideoInfo?.duration || 0, parseTime(endStr));
|
|
|
|
if (cutterEndTime <= cutterStartTime) {
|
|
cutterEndTime = cutterStartTime + 1;
|
|
}
|
|
|
|
updateTimeline();
|
|
}
|
|
|
|
async function seekTimeline(event: MouseEvent): Promise<void> {
|
|
if (!cutterVideoInfo) {
|
|
return;
|
|
}
|
|
|
|
const timeline = byId<HTMLElement>('timeline');
|
|
const rect = timeline.getBoundingClientRect();
|
|
const percent = (event.clientX - rect.left) / rect.width;
|
|
const time = percent * cutterVideoInfo.duration;
|
|
|
|
byId('timelineCurrent').style.left = (percent * 100) + '%';
|
|
await updatePreview(time);
|
|
}
|
|
|
|
async function updatePreview(time: number): Promise<void> {
|
|
if (!cutterFile) {
|
|
return;
|
|
}
|
|
|
|
const preview = byId('cutterPreview');
|
|
preview.innerHTML = `<div class="placeholder"><p>${UI_TEXT.cutter.previewLoading}</p></div>`;
|
|
|
|
const frame = await window.api.extractFrame(cutterFile, time);
|
|
if (frame) {
|
|
preview.innerHTML = `<img src="${frame}" alt="Preview">`;
|
|
return;
|
|
}
|
|
|
|
preview.innerHTML = `<div class="placeholder"><p>${UI_TEXT.cutter.previewUnavailable}</p></div>`;
|
|
}
|
|
|
|
async function startCutting(): Promise<void> {
|
|
if (!cutterFile || isCutting) {
|
|
return;
|
|
}
|
|
|
|
isCutting = true;
|
|
byId('btnCut').disabled = true;
|
|
byId('btnCut').textContent = UI_TEXT.cutter.cutting;
|
|
byId('cutProgress').classList.add('show');
|
|
|
|
const result = await window.api.cutVideo(cutterFile, cutterStartTime, cutterEndTime);
|
|
|
|
isCutting = false;
|
|
byId('btnCut').disabled = false;
|
|
byId('btnCut').textContent = UI_TEXT.cutter.cut;
|
|
byId('cutProgress').classList.remove('show');
|
|
|
|
if (result.success) {
|
|
alert(`${UI_TEXT.cutter.cutSuccess}\n\n${result.outputFile}`);
|
|
return;
|
|
}
|
|
|
|
alert(UI_TEXT.cutter.cutFailed);
|
|
}
|
|
|
|
async function addMergeFiles(): Promise<void> {
|
|
const files = await window.api.selectMultipleVideos();
|
|
if (!files || files.length === 0) {
|
|
return;
|
|
}
|
|
|
|
mergeFiles = [...mergeFiles, ...files];
|
|
renderMergeFiles();
|
|
}
|
|
|
|
function renderMergeFiles(): void {
|
|
const list = byId('mergeFileList');
|
|
byId('btnMerge').disabled = mergeFiles.length < 2;
|
|
|
|
if (mergeFiles.length === 0) {
|
|
list.innerHTML = `
|
|
<div class="empty-state" style="padding: 40px 20px;">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.3"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
|
<p style="margin-top:10px">${UI_TEXT.merge.empty}</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = mergeFiles.map((file: string, index: number) => {
|
|
const name = file.split(/[/\\]/).pop();
|
|
return `
|
|
<div class="file-item" draggable="true" data-index="${index}">
|
|
<div class="file-order">${index + 1}</div>
|
|
<div class="file-name" title="${file}">${name}</div>
|
|
<div class="file-actions">
|
|
<button class="file-btn" onclick="moveMergeFile(${index}, -1)" ${index === 0 ? 'disabled' : ''}>▲</button>
|
|
<button class="file-btn" onclick="moveMergeFile(${index}, 1)" ${index === mergeFiles.length - 1 ? 'disabled' : ''}>▼</button>
|
|
<button class="file-btn remove" onclick="removeMergeFile(${index})">x</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function moveMergeFile(index: number, direction: number): void {
|
|
const newIndex = index + direction;
|
|
if (newIndex < 0 || newIndex >= mergeFiles.length) {
|
|
return;
|
|
}
|
|
|
|
const temp = mergeFiles[index];
|
|
mergeFiles[index] = mergeFiles[newIndex];
|
|
mergeFiles[newIndex] = temp;
|
|
renderMergeFiles();
|
|
}
|
|
|
|
function removeMergeFile(index: number): void {
|
|
mergeFiles.splice(index, 1);
|
|
renderMergeFiles();
|
|
}
|
|
|
|
async function startMerging(): Promise<void> {
|
|
if (mergeFiles.length < 2 || isMerging) {
|
|
return;
|
|
}
|
|
|
|
const outputFile = await window.api.saveVideoDialog('merged_video.mp4');
|
|
if (!outputFile) {
|
|
return;
|
|
}
|
|
|
|
isMerging = true;
|
|
byId('btnMerge').disabled = true;
|
|
byId('btnMerge').textContent = UI_TEXT.merge.merging;
|
|
byId('mergeProgress').classList.add('show');
|
|
|
|
const result = await window.api.mergeVideos(mergeFiles, outputFile);
|
|
|
|
isMerging = false;
|
|
byId('btnMerge').disabled = false;
|
|
byId('btnMerge').textContent = UI_TEXT.merge.merge;
|
|
byId('mergeProgress').classList.remove('show');
|
|
|
|
if (result.success) {
|
|
alert(`${UI_TEXT.merge.success}\n\n${result.outputFile}`);
|
|
mergeFiles = [];
|
|
renderMergeFiles();
|
|
return;
|
|
}
|
|
|
|
alert(UI_TEXT.merge.failed);
|
|
}
|
|
|
|
void init();
|