Files
convert/frontend/src/main.ts
T

1513 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import './style.css';
import { dict, type Lang } from './i18n.ts';
import { clampTrimRange, formatBytes, formatTimecode, parseTimecode } from './timecode.ts';
import { bindWheelControls } from './wheel.ts';
import type {
AppInfo,
ConvertRequest,
LoadedVideo,
ProgressEvent,
} from './wails.d.ts';
interface State {
lang: Lang;
appInfo: AppInfo | null;
loaded: LoadedVideo | null;
trimStart: number;
trimEnd: number;
loopTrim: boolean;
previewMixAudio: boolean;
mutedPreview: boolean;
previewVolume: number;
usingProxy: boolean;
encoder: string;
hwDecode: string;
crf: number;
preset: string;
tune: string;
fps: number;
speed: number;
audioMode: 'merge' | 'track' | 'mute';
audioTrack: number;
selectedTracks: number[];
cropEnabled: boolean;
cropX: number;
cropY: number;
cropW: number;
cropH: number;
outputPath: string;
converting: boolean;
progress: ProgressEvent | null;
donePath: string;
doneBytes: number;
errorMsg: string;
cliExpanded: boolean;
cliText: string;
}
const state: State = {
lang: 'ru',
appInfo: null,
loaded: null,
trimStart: 0,
trimEnd: 0,
loopTrim: true,
previewMixAudio: false,
mutedPreview: false,
previewVolume: Number(localStorage.getItem('convert_preview_volume') ?? 1),
usingProxy: false,
encoder: 'libx264',
hwDecode: 'none',
crf: 20,
preset: 'slow',
tune: 'film',
fps: 60,
speed: 1,
audioMode: 'merge',
audioTrack: 0,
selectedTracks: [],
cropEnabled: false,
cropX: 0,
cropY: 0,
cropW: 1280,
cropH: 720,
outputPath: '',
converting: false,
progress: null,
donePath: '',
doneBytes: 0,
errorMsg: '',
cliExpanded: false,
cliText: '',
};
let dragTarget: 'seek' | 'start' | 'end' | null = null;
let cropDrag: {
mode: string;
startX: number;
startY: number;
origX: number;
origY: number;
origW: number;
origH: number;
} | null = null;
let playerResizeObserver: ResizeObserver | null = null;
let sourceLoadAbort: AbortController | null = null;
const appApi = () => window.go?.main?.App;
const t = () => dict[state.lang];
function el<K extends keyof HTMLElementTagNameMap>(id: string): HTMLElementTagNameMap[K] {
return document.getElementById(id) as HTMLElementTagNameMap[K];
}
function fitVideoStage() {
const wrap = el<'div'>('playerWrap');
const stage = el<'div'>('videoStage');
const video = el<'video'>('videoEl');
if (!wrap || !stage || stage.classList.contains('hidden')) return;
const vw = video?.videoWidth || state.loaded?.info.width || 16;
const vh = video?.videoHeight || state.loaded?.info.height || 9;
if (vw <= 0 || vh <= 0) return;
const rect = wrap.getBoundingClientRect();
const availW = Math.max(1, rect.width - 2);
const availH = Math.max(1, rect.height - 2);
const scale = Math.min(availW / vw, availH / vh);
const fitW = Math.max(1, Math.floor(vw * scale));
const fitH = Math.max(1, Math.floor(vh * scale));
stage.style.width = `${fitW}px`;
stage.style.height = `${fitH}px`;
}
function renderShell() {
const tr = t();
const root = document.getElementById('app')!;
const verText = state.appInfo?.version ? `v${state.appInfo.version}` : 'v2.0';
root.innerHTML = `
<header class="topbar" id="topbar">
<div class="brand">
<div class="brand-icon">▶</div>
<span class="brand-title">ConvertGUI</span>
<span class="badge" id="verBadge">${verText}</span>
</div>
<div class="topbar-center">
<button id="openBtn" class="primary">📂 ${tr.openVideo}</button>
<div id="filePill" class="file-pill hidden">
<strong id="fileName"></strong>
<span id="fileMeta" class="file-meta"></span>
</div>
</div>
<div class="topbar-right">
<button id="ffmpegStatusBtn" class="status-pill warn">${tr.ffmpegMissing}</button>
<button id="langBtn">${state.lang.toUpperCase()}</button>
<div class="win-controls" id="winControls">
<button id="winMinBtn" class="win-btn" title="Свернуть">
<svg width="10" height="1" viewBox="0 0 10 1"><rect width="10" height="1" fill="currentColor"/></svg>
</button>
<button id="winMaxBtn" class="win-btn" title="Развернуть">
<svg id="winMaxIcon" width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.2"><rect x="0.6" y="0.6" width="8.8" height="8.8"/></svg>
</button>
<button id="winCloseBtn" class="win-btn close" title="Закрыть">
<svg width="10" height="10" viewBox="0 0 10 10" stroke="currentColor" stroke-width="1.2"><line x1="0.5" y1="0.5" x2="9.5" y2="9.5"/><line x1="9.5" y1="0.5" x2="0.5" y2="9.5"/></svg>
</button>
</div>
</div>
</header>
<main class="workspace">
<section class="stage-col">
<div class="player-wrap" id="playerWrap">
<div class="proxy-banner hidden" id="proxyBanner">${tr.proxyLoading}</div>
<div class="video-stage hidden" id="videoStage">
<video id="videoEl" preload="metadata"></video>
<div class="crop-overlay hidden" id="cropOverlay">
<div class="crop-box" id="cropBox">
<div class="crop-handle nw" data-handle="nw"></div>
<div class="crop-handle ne" data-handle="ne"></div>
<div class="crop-handle sw" data-handle="sw"></div>
<div class="crop-handle se" data-handle="se"></div>
</div>
</div>
</div>
<div class="drop-overlay" id="dropOverlay">
<div style="font-size:40px">🎬</div>
<h2>${tr.dropTitle}</h2>
<div>${tr.dropHint}</div>
</div>
</div>
<div class="transport">
<div class="transport-group">
<button id="playPauseBtn" disabled>▶ / ❚❚</button>
<button id="stepBackBtn" disabled title="-0.1s">⏪</button>
<button id="stepFwdBtn" disabled title="+0.1s">⏩</button>
<span class="time-readout" id="timeReadout">00:00:00.000 / 00:00:00.000</span>
</div>
<div class="transport-group">
<button id="setStartBtn" disabled>⏮ ${tr.setStart}</button>
<button id="setEndBtn" disabled>${tr.setEnd} ⏭</button>
<button id="loopBtn" class="pill-active">🔁 ${tr.loopTrim}</button>
<button id="previewMixBtn" class="hidden">🎧 ${tr.previewMix}</button>
<div class="volume-box">
<button id="mutePreviewBtn" title="Звук">🔊</button>
<input type="range" id="volumeRange" min="0" max="1" step="0.05" value="1" title="Громкость" />
<span id="volumeVal" class="mono volume-val">100%</span>
</div>
</div>
</div>
<div class="timeline-card">
<div class="timeline-track" id="timelineTrack">
<div class="filmstrip" id="filmstrip"></div>
<div class="trim-dim-left" id="dimLeft"></div>
<div class="trim-dim-right" id="dimRight"></div>
<div class="trim-selection" id="trimSelection"></div>
<div class="trim-handle start" id="handleStart"></div>
<div class="trim-handle end" id="handleEnd"></div>
<div class="playhead" id="playhead"></div>
</div>
<div class="trim-inputs">
<div class="field-mini">
<label>${tr.trimStart}</label>
<input type="text" id="trimStartInput" class="mono" value="00:00:00.000" disabled />
</div>
<div class="field-mini">
<label>${tr.trimEnd}</label>
<input type="text" id="trimEndInput" class="mono" value="00:00:00.000" disabled />
</div>
<div class="field-mini">
<label>${tr.clipDur}</label>
<div class="stat-chip" id="clipDurChip">00:00.000</div>
</div>
<div class="field-mini">
<label>${tr.outDur}</label>
<div class="stat-chip" id="outDurChip">00:00.000</div>
</div>
<button id="resetTrimBtn" disabled>${tr.resetTrim}</button>
</div>
</div>
</section>
<aside class="settings-col">
<div class="settings-scroll">
<div class="card">
<div class="card-title">${tr.sectionVideo}</div>
<div class="grid-2">
<div class="field">
<label>${tr.encoder}</label>
<select id="encoderSelect">
<option value="libx264">H.264 (libx264)</option>
<option value="h264_nvenc">H.264 NVENC</option>
<option value="libx265">H.265 (libx265)</option>
<option value="hevc_nvenc">H.265 NVENC</option>
</select>
</div>
<div class="field">
<label>${tr.hwDecode}</label>
<select id="hwDecodeSelect">
<option value="none">CPU</option>
<option value="nvdec">NVDEC</option>
<option value="d3d11va">D3D11VA</option>
<option value="auto">Auto</option>
</select>
</div>
</div>
<div class="field">
<label>
<span>${tr.crf}</span>
<span id="crfLabel" class="mono">20</span>
</label>
<input type="range" id="crfRange" min="0" max="51" step="1" value="20" />
</div>
<div class="grid-3" style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;">
<div class="field">
<label>${tr.preset}</label>
<select id="presetSelect">
<option value="ultrafast">ultrafast</option>
<option value="superfast">superfast</option>
<option value="veryfast">veryfast</option>
<option value="faster">faster</option>
<option value="fast">fast</option>
<option value="medium">medium</option>
<option value="slow" selected>slow</option>
<option value="slower">slower</option>
<option value="veryslow">veryslow</option>
</select>
</div>
<div class="field">
<label>${tr.tune}</label>
<select id="tuneSelect">
<option value="film" selected>film</option>
<option value="animation">animation</option>
<option value="grain">grain</option>
<option value="none">none</option>
</select>
</div>
<div class="field">
<label>${tr.fps}</label>
<select id="fpsSelect">
<option value="0">${tr.fpsOriginal}</option>
<option value="30">30</option>
<option value="60" selected>60</option>
<option value="120">120</option>
</select>
</div>
</div>
</div>
<div class="card">
<div class="card-title">
<span>${tr.sectionSpeed}</span>
<span id="speedVal" class="mono">1x</span>
</div>
<div class="pill-row" id="speedPills">
<button class="pill-btn" data-speed="0.5">0.5x</button>
<button class="pill-btn pill-active" data-speed="1">1x</button>
<button class="pill-btn" data-speed="1.5">1.5x</button>
<button class="pill-btn" data-speed="2">2x</button>
<button class="pill-btn" data-speed="4">4x</button>
</div>
<input type="range" id="speedRange" min="0.25" max="8" step="0.05" value="1" />
</div>
<div class="card">
<div class="card-title">${tr.sectionAudio}</div>
<div class="pill-row" id="audioModeBtns">
<button class="pill-btn" data-amode="merge">${tr.audioMerge}</button>
<button class="pill-btn" data-amode="track">${tr.audioTrack}</button>
<button class="pill-btn" data-amode="mute">${tr.audioMute}</button>
</div>
<div id="audioTracksContainer" class="track-list"></div>
</div>
<div class="card">
<div class="card-title">
<span>${tr.sectionCrop}</span>
<label style="display:flex;align-items:center;gap:6px;text-transform:none;font-weight:600;cursor:pointer">
<input type="checkbox" id="cropCheck" /> ${tr.cropEnable}
</label>
</div>
<div id="cropControls" class="hidden" style="display:flex;flex-direction:column;gap:8px;">
<div class="pill-row" id="cropPresets">
<button class="pill-btn" data-ratio="16:9">16:9</button>
<button class="pill-btn" data-ratio="9:16">9:16</button>
<button class="pill-btn" data-ratio="1:1">1:1</button>
<button class="pill-btn" data-ratio="4:3">4:3</button>
<button class="pill-btn" data-ratio="reset">100%</button>
</div>
<div class="grid-4">
<div class="field"><label>X</label><input type="number" id="cropX" min="0" step="2" /></div>
<div class="field"><label>Y</label><input type="number" id="cropY" min="0" step="2" /></div>
<div class="field"><label>W</label><input type="number" id="cropW" min="2" step="2" /></div>
<div class="field"><label>H</label><input type="number" id="cropH" min="2" step="2" /></div>
</div>
</div>
</div>
<div class="card">
<div class="card-title">${tr.sectionOutput}</div>
<div class="input-row">
<input type="text" id="outputInput" class="mono" />
<button id="browseOutBtn">${tr.browse}</button>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;">
<button id="toggleCliBtn" class="pill-btn">${tr.showCli}</button>
<button id="copyCliBtn" class="pill-btn hidden">${tr.copy}</button>
</div>
<pre class="cli-box hidden" id="cliBox"></pre>
</div>
</div>
<div class="action-footer">
<div id="errorBanner" class="error-banner hidden"></div>
<div id="doneBanner" class="done-banner hidden">
<div style="display:flex;justify-content:space-between;align-items:center;">
<strong style="color:#6ee7b7">${tr.doneTitle}</strong>
<span id="doneStats" class="mono" style="font-size:11px"></span>
</div>
<div style="display:flex;gap:8px;">
<button id="openFolderBtn" style="flex:1">📁 ${tr.openFolder}</button>
<button id="playOutBtn" style="flex:1">▶ ${tr.playOutput}</button>
</div>
</div>
<div id="progressWrap" class="progress-wrap hidden">
<div class="progress-bar-bg"><div id="progressFill" class="progress-bar-fill"></div></div>
<div class="progress-meta">
<span id="progressLeft">0%</span>
<span id="progressRight"></span>
</div>
</div>
<div style="display:flex;gap:8px;">
<button id="convertBtn" class="primary" style="flex:1;justify-content:center;padding:9px 14px;font-size:13px" disabled>
⚡ ${tr.convertBtn}
</button>
<button id="cancelBtn" class="danger hidden">${tr.cancelBtn}</button>
</div>
</div>
</aside>
</main>
`;
bindEvents();
syncControlsFromState();
updateFFmpegBadge();
}
function updateFFmpegBadge() {
const btn = el<'button'>('ffmpegStatusBtn');
const tr = t();
const bins = state.appInfo?.binaries;
if (bins && bins.ffmpeg && bins.ffprobe) {
btn.className = 'status-pill ok';
btn.textContent = `✓ ${tr.ffmpegOk}`;
btn.title = `${bins.ffmpeg} (${bins.source})`;
} else {
btn.className = 'status-pill warn';
btn.textContent = `⚠ ${tr.ffmpegMissing}`;
btn.title = 'Click to locate ffmpeg.exe';
}
}
function crfQualityHint(crf: number): string {
const tr = t();
if (crf <= 18) return tr.crfHintBest;
if (crf <= 23) return tr.crfHintBal;
if (crf <= 28) return tr.crfHintComp;
return tr.crfHintSmall;
}
let saveCfgTimer = 0;
function persistVideoSettings() {
const api = appApi();
if (!api || !state.appInfo) return;
state.appInfo.config.defaultEncoder = state.encoder;
state.appInfo.config.defaultHwDecode = state.hwDecode;
state.appInfo.config.defaultPreset = state.preset;
state.appInfo.config.defaultCrf = state.crf;
state.appInfo.config.defaultFps = state.fps;
state.appInfo.config.defaultTune = state.tune;
state.appInfo.config.defaultMergeAudio = state.audioMode === 'merge';
window.clearTimeout(saveCfgTimer);
saveCfgTimer = window.setTimeout(() => {
if (state.appInfo) {
void api.SaveConfig(state.appInfo.config);
}
}, 350);
}
function syncControlsFromState() {
el<'select'>('encoderSelect').value = state.encoder;
el<'select'>('hwDecodeSelect').value = state.hwDecode;
el<'input'>('crfRange').value = String(state.crf);
el<'span'>('crfLabel').textContent = `${state.crf} · ${crfQualityHint(state.crf)}`;
el<'select'>('presetSelect').value = state.preset;
el<'select'>('tuneSelect').value = state.tune;
el<'select'>('tuneSelect').disabled = state.encoder !== 'libx264';
el<'select'>('fpsSelect').value = String(state.fps);
el<'input'>('speedRange').value = String(state.speed);
el<'span'>('speedVal').textContent = `${state.speed}x`;
el<'input'>('outputInput').value = state.outputPath;
document.querySelectorAll<HTMLButtonElement>('#speedPills button').forEach((b) => {
b.classList.toggle('pill-active', Number(b.dataset.speed) === state.speed);
});
const video = el<'video'>('videoEl');
if (video) {
const rate = Math.max(0.25, Math.min(8, state.speed));
video.defaultPlaybackRate = rate;
video.playbackRate = rate;
}
el<'input'>('cropCheck').checked = state.cropEnabled;
el<'div'>('cropControls').classList.toggle('hidden', !state.cropEnabled);
el<'div'>('cropOverlay').classList.toggle('hidden', !state.cropEnabled || !state.loaded);
el<'input'>('cropX').value = String(state.cropX);
el<'input'>('cropY').value = String(state.cropY);
el<'input'>('cropW').value = String(state.cropW);
el<'input'>('cropH').value = String(state.cropH);
fitVideoStage();
updateCropBoxVisual();
renderAudioSection();
updateTimelineVisuals();
void refreshCLI();
}
function renderAudioSection() {
const tr = t();
const container = el<'div'>('audioTracksContainer');
const tracks = state.loaded?.info.audioStreams ?? [];
document.querySelectorAll<HTMLButtonElement>('#audioModeBtns button').forEach((b) => {
const mode = b.dataset.amode;
b.classList.toggle('pill-active', mode === state.audioMode);
if (mode === 'merge') {
b.disabled = tracks.length < 2;
} else {
b.disabled = tracks.length === 0 && mode !== 'mute';
}
});
if (!state.loaded) {
container.innerHTML = '';
return;
}
if (tracks.length === 0) {
container.innerHTML = `<div class="muted" style="color:var(--muted);font-size:11px">${tr.noAudio}</div>`;
return;
}
if (state.audioMode === 'mute') {
container.innerHTML = '';
return;
}
if (state.audioMode === 'merge' && state.selectedTracks.length === 0) {
state.selectedTracks = tracks.map((a) => a.audioOrder);
}
container.innerHTML = tracks
.map((s) => {
const label = [
`#${s.audioOrder + 1}`,
s.codec.toUpperCase(),
s.channelLayout || `${s.channels}ch`,
s.title || s.language || '',
]
.filter(Boolean)
.join(' · ');
if (state.audioMode === 'merge') {
const checked = state.selectedTracks.includes(s.audioOrder);
return `<label class="track-item"><input type="checkbox" data-atrack-check="${s.audioOrder}" ${checked ? 'checked' : ''} /> <span>${label}</span></label>`;
}
const checked = state.audioTrack === s.audioOrder;
return `<label class="track-item"><input type="radio" name="atrack" data-atrack-radio="${s.audioOrder}" ${checked ? 'checked' : ''} /> <span>${label}</span></label>`;
})
.join('');
container.querySelectorAll<HTMLInputElement>('[data-atrack-check]').forEach((cb) => {
cb.addEventListener('change', () => {
const next: number[] = [];
container.querySelectorAll<HTMLInputElement>('[data-atrack-check]').forEach((box) => {
if (box.checked) next.push(Number(box.dataset.atrackCheck));
});
state.selectedTracks = next;
if (next.length === 0) {
state.audioMode = 'mute';
syncControlsFromState();
return;
}
void refreshCLI();
});
});
container.querySelectorAll<HTMLInputElement>('[data-atrack-radio]').forEach((rb) => {
rb.addEventListener('change', () => {
state.audioTrack = Number(rb.dataset.atrackRadio);
void refreshCLI();
});
});
}
function updateTimelineVisuals() {
const dur = state.loaded?.info.duration ?? 0;
const startPct = dur > 0 ? (state.trimStart / dur) * 100 : 0;
const endPct = dur > 0 ? (state.trimEnd / dur) * 100 : 100;
el<'div'>('dimLeft').style.width = `${startPct}%`;
el<'div'>('dimRight').style.width = `${100 - endPct}%`;
const sel = el<'div'>('trimSelection');
sel.style.left = `${startPct}%`;
sel.style.width = `${Math.max(0, endPct - startPct)}%`;
el<'div'>('handleStart').style.left = `${startPct}%`;
el<'div'>('handleEnd').style.left = `${endPct}%`;
const selDur = Math.max(0, state.trimEnd - state.trimStart);
const outDur = selDur / (state.speed || 1);
el<'div'>('clipDurChip').textContent = formatTimecode(selDur, true);
el<'div'>('outDurChip').textContent = formatTimecode(outDur, true);
const startInp = el<'input'>('trimStartInput');
const endInp = el<'input'>('trimEndInput');
if (document.activeElement !== startInp) {
startInp.value = formatTimecode(state.trimStart, true);
}
if (document.activeElement !== endInp) {
endInp.value = formatTimecode(state.trimEnd, true);
}
}
function updatePlayhead(currentTime: number) {
const dur = state.loaded?.info.duration ?? 0;
const pct = dur > 0 ? Math.max(0, Math.min(100, (currentTime / dur) * 100)) : 0;
el<'div'>('playhead').style.left = `${pct}%`;
el<'span'>('timeReadout').textContent = `${formatTimecode(currentTime, true)} / ${formatTimecode(dur, true)}`;
}
function updateVolumeUI() {
const video = el<'video'>('videoEl');
const btn = el<'button'>('mutePreviewBtn');
const rng = el<'input'>('volumeRange');
const val = el<'span'>('volumeVal');
if (!btn || !rng || !val) return;
const vol = Math.max(0, Math.min(1, state.previewVolume));
if (video) {
video.volume = vol;
video.muted = state.mutedPreview;
}
rng.value = String(vol);
val.textContent = state.mutedPreview ? 'Mute' : `${Math.round(vol * 100)}%`;
if (state.mutedPreview || vol === 0) {
btn.textContent = '🔇';
btn.title = 'Включить звук';
} else if (vol < 0.4) {
btn.textContent = '🔈';
btn.title = 'Выключить звук';
} else if (vol < 0.8) {
btn.textContent = '🔉';
btn.title = 'Выключить звук';
} else {
btn.textContent = '🔊';
btn.title = 'Выключить звук';
}
}
function buildRequest(): ConvertRequest {
return {
inputPath: state.loaded?.info.path ?? '',
outputPath: state.outputPath,
trimStart: state.trimStart,
trimEnd: state.trimEnd,
encoder: state.encoder,
hwDecode: state.hwDecode,
preset: state.preset,
crf: state.crf,
tune: state.tune,
fps: state.fps,
speed: state.speed,
audioMode: state.audioMode,
audioTrack: state.audioTrack,
selectedTracks: state.selectedTracks,
crop: {
enabled: state.cropEnabled,
x: state.cropX,
y: state.cropY,
w: state.cropW,
h: state.cropH,
},
fastStart: true,
overwrite: true,
};
}
async function refreshCLI() {
const api = appApi();
if (!api || !state.loaded) {
el<'pre'>('cliBox').textContent = '';
return;
}
try {
const cmd = await api.PreviewCommand(buildRequest());
state.cliText = cmd;
el<'pre'>('cliBox').textContent = cmd;
} catch {
// ignore validation transient
}
}
async function loadVideoFile(path: string) {
const api = appApi();
if (!api || !path) return;
state.errorMsg = '';
state.donePath = '';
el<'div'>('errorBanner').classList.add('hidden');
el<'div'>('doneBanner').classList.add('hidden');
try {
const res = await api.LoadVideo(path);
state.loaded = res;
state.usingProxy = false;
state.previewMixAudio = false;
state.trimStart = 0;
state.trimEnd = res.info.duration;
state.outputPath = res.defaultOutputPath;
// Default crop to full frame
state.cropEnabled = false;
state.cropX = 0;
state.cropY = 0;
state.cropW = res.info.width - (res.info.width % 2);
state.cropH = res.info.height - (res.info.height % 2);
// Default audio mode based on track count
if (res.info.audioStreams.length === 0) {
state.audioMode = 'mute';
state.selectedTracks = [];
} else if (res.info.audioStreams.length >= 2) {
state.audioMode = 'merge';
state.selectedTracks = res.info.audioStreams.map((a) => a.audioOrder);
} else {
state.audioMode = 'track';
state.audioTrack = 0;
state.selectedTracks = [0];
}
applyLoadedUI(res, 0, false, true);
} catch (err) {
showError(String(err));
}
}
function applyLoadedUI(
res: LoadedVideo,
seekSec: number,
autoPlay: boolean,
reloadSource: boolean
) {
el<'div'>('filePill').classList.remove('hidden');
el<'strong'>('fileName').textContent = res.info.fileName;
el<'span'>('fileMeta').textContent = `${res.info.width}×${res.info.height} · ${res.info.fps ? res.info.fps.toFixed(0) + 'fps' : '?'} · ${res.info.videoCodec.toUpperCase()} · ${formatBytes(res.info.sizeBytes)}`;
el<'div'>('dropOverlay').classList.add('hidden');
el<'div'>('videoStage').classList.remove('hidden');
el<'button'>('playPauseBtn').disabled = false;
el<'button'>('stepBackBtn').disabled = false;
el<'button'>('stepFwdBtn').disabled = false;
el<'button'>('setStartBtn').disabled = false;
el<'button'>('setEndBtn').disabled = false;
el<'button'>('resetTrimBtn').disabled = false;
el<'input'>('trimStartInput').disabled = false;
el<'input'>('trimEndInput').disabled = false;
el<'button'>('convertBtn').disabled = state.converting;
el<'button'>('loopBtn').classList.toggle('pill-active', state.loopTrim);
const mixBtn = el<'button'>('previewMixBtn');
mixBtn.classList.toggle('hidden', res.info.audioStreams.length < 2);
mixBtn.classList.toggle('pill-active', state.previewMixAudio);
updateVolumeUI();
el<'pre'>('cliBox').classList.toggle('hidden', !state.cliExpanded);
el<'button'>('copyCliBtn').classList.toggle('hidden', !state.cliExpanded);
if (state.converting) {
el<'button'>('cancelBtn').classList.remove('hidden');
el<'div'>('progressWrap').classList.remove('hidden');
if (state.progress) renderProgress(state.progress);
}
if (state.donePath && !state.converting) {
renderDoneBanner();
}
if (state.errorMsg) {
showError(state.errorMsg);
}
loadFilmstrip(res);
fitVideoStage();
if (reloadSource) {
setPlayerSource(res, state.usingProxy, seekSec, autoPlay);
}
syncControlsFromState();
updatePlayhead(seekSec);
}
function setPlayerSource(
res: LoadedVideo,
forceProxy: boolean,
seekSec = 0,
autoPlay = false
) {
const video = el<'video'>('videoEl');
const banner = el<'div'>('proxyBanner');
let src = res.previewUrl;
const needsProxy = forceProxy || !res.info.browserNative || state.previewMixAudio;
if (needsProxy) {
src = state.previewMixAudio ? res.proxyMergedUrl : res.proxyUrl;
state.usingProxy = true;
banner.classList.remove('hidden');
} else {
state.usingProxy = false;
banner.classList.add('hidden');
}
const rate = Math.max(0.25, Math.min(8, state.speed));
video.muted = state.mutedPreview;
video.volume = Math.max(0, Math.min(1, state.previewVolume));
video.defaultPlaybackRate = rate;
video.playbackRate = rate;
sourceLoadAbort?.abort();
sourceLoadAbort = new AbortController();
video.addEventListener(
'loadedmetadata',
() => {
const r = Math.max(0.25, Math.min(8, state.speed));
video.defaultPlaybackRate = r;
video.playbackRate = r;
video.muted = state.mutedPreview;
video.volume = Math.max(0, Math.min(1, state.previewVolume));
fitVideoStage();
if (seekSec > 0 && Number.isFinite(seekSec)) {
video.currentTime = Math.min(seekSec, res.info.duration || seekSec);
}
if (autoPlay) {
void video.play();
}
},
{ once: true, signal: sourceLoadAbort.signal }
);
video.src = src;
video.load();
}
function loadFilmstrip(res: LoadedVideo) {
const strip = el<'div'>('filmstrip');
const dur = Math.max(0.1, res.info.duration);
const count = 12;
let html = '';
for (let i = 0; i < count; i++) {
const tSec = ((i + 0.5) / count) * dur;
html += `<img src="${res.thumbBaseUrl}&t=${tSec.toFixed(2)}" loading="lazy" alt="" />`;
}
strip.innerHTML = html;
}
function updateCropBoxVisual() {
if (!state.loaded || !state.cropEnabled) return;
const info = state.loaded.info;
if (info.width <= 0 || info.height <= 0) return;
const box = el<'div'>('cropBox');
const left = (state.cropX / info.width) * 100;
const top = (state.cropY / info.height) * 100;
const width = (state.cropW / info.width) * 100;
const height = (state.cropH / info.height) * 100;
box.style.left = `${left}%`;
box.style.top = `${top}%`;
box.style.width = `${width}%`;
box.style.height = `${height}%`;
}
function showError(msg: string) {
state.errorMsg = msg;
const b = el<'div'>('errorBanner');
if (b) {
b.textContent = msg;
b.classList.remove('hidden');
}
}
function renderProgress(p: ProgressEvent) {
el<'div'>('progressFill').style.width = `${p.percent.toFixed(1)}%`;
el<'span'>('progressLeft').textContent = `${p.percent.toFixed(1)}% · ${p.speed || '-'} · ${p.fps ? p.fps.toFixed(0) + 'fps' : ''}`;
const etaStr = p.etaSec > 0 ? `${t().eta}: ${formatTimecode(p.etaSec, false)}` : '';
const sizeStr = p.totalSize > 0 ? formatBytes(p.totalSize) : '';
el<'span'>('progressRight').textContent = [sizeStr, etaStr].filter(Boolean).join(' · ');
}
function renderDoneBanner() {
const srcBytes = state.loaded?.info.sizeBytes ?? 0;
const outBytes = state.doneBytes;
let stat = formatBytes(outBytes);
if (srcBytes > 0 && outBytes > 0) {
const diffPct = Math.round(((outBytes - srcBytes) / srcBytes) * 100);
stat = `${formatBytes(srcBytes)} → ${formatBytes(outBytes)} (${diffPct > 0 ? '+' : ''}${diffPct}%)`;
}
el<'span'>('doneStats').textContent = stat;
el<'div'>('doneBanner').classList.remove('hidden');
}
function togglePlay() {
const video = el<'video'>('videoEl');
if (!state.loaded || !video) return;
if (video.paused) {
if (state.loopTrim && (video.currentTime < state.trimStart || video.currentTime >= state.trimEnd)) {
video.currentTime = state.trimStart;
}
void video.play();
} else {
video.pause();
}
}
function clientXToTime(clientX: number): number {
const track = el<'div'>('timelineTrack');
const rect = track.getBoundingClientRect();
if (rect.width <= 0) return 0;
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
return ratio * (state.loaded?.info.duration ?? 0);
}
function bindGlobalEvents() {
window.addEventListener('pointermove', (e) => {
if (dragTarget && state.loaded) {
const video = el<'video'>('videoEl');
const dur = state.loaded.info.duration;
const minGap = Math.min(0.05, dur);
const tSec = clientXToTime(e.clientX);
if (dragTarget === 'seek') {
video.currentTime = tSec;
updatePlayhead(tSec);
} else if (dragTarget === 'start') {
const maxStart = Math.max(0, state.trimEnd - minGap);
const nextStart = Math.max(0, Math.min(tSec, maxStart));
state.trimStart = nextStart;
video.currentTime = nextStart;
updateTimelineVisuals();
updatePlayhead(nextStart);
} else if (dragTarget === 'end') {
const minEnd = Math.min(dur, state.trimStart + minGap);
const nextEnd = Math.min(dur, Math.max(tSec, minEnd));
state.trimEnd = nextEnd;
video.currentTime = nextEnd;
updateTimelineVisuals();
updatePlayhead(nextEnd);
}
}
if (cropDrag && state.loaded) {
const stage = el<'div'>('videoStage').getBoundingClientRect();
if (stage.width <= 0 || stage.height <= 0) return;
const iw = state.loaded.info.width;
const ih = state.loaded.info.height;
const dx = Math.round((((e.clientX - cropDrag.startX) / stage.width) * iw) / 2) * 2;
const dy = Math.round((((e.clientY - cropDrag.startY) / stage.height) * ih) / 2) * 2;
if (cropDrag.mode === 'move') {
state.cropX = Math.max(0, Math.min(iw - cropDrag.origW, cropDrag.origX + dx)) & ~1;
state.cropY = Math.max(0, Math.min(ih - cropDrag.origH, cropDrag.origY + dy)) & ~1;
} else {
let nx = cropDrag.origX;
let ny = cropDrag.origY;
let nw = cropDrag.origW;
let nh = cropDrag.origH;
if (cropDrag.mode.includes('e')) nw = Math.max(16, Math.min(iw - nx, cropDrag.origW + dx));
if (cropDrag.mode.includes('s')) nh = Math.max(16, Math.min(ih - ny, cropDrag.origH + dy));
if (cropDrag.mode.includes('w')) {
const maxShift = cropDrag.origW - 16;
const shift = Math.max(-cropDrag.origX, Math.min(maxShift, dx));
nx = cropDrag.origX + shift;
nw = cropDrag.origW - shift;
}
if (cropDrag.mode.includes('n')) {
const maxShift = cropDrag.origH - 16;
const shift = Math.max(-cropDrag.origY, Math.min(maxShift, dy));
ny = cropDrag.origY + shift;
nh = cropDrag.origH - shift;
}
state.cropX = nx & ~1;
state.cropY = ny & ~1;
state.cropW = Math.max(16, nw & ~1);
state.cropH = Math.max(16, nh & ~1);
}
syncControlsFromState();
}
});
window.addEventListener('pointerup', () => {
if (dragTarget === 'start' || dragTarget === 'end') {
void refreshCLI();
}
dragTarget = null;
cropDrag = null;
});
window.addEventListener('resize', () => {
fitVideoStage();
void syncMaximizedUI();
});
window.addEventListener('keydown', (e) => {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return;
if (!state.loaded) return;
const video = el<'video'>('videoEl');
if (e.code === 'Space') {
e.preventDefault();
togglePlay();
} else if (e.code === 'ArrowLeft') {
e.preventDefault();
video.pause();
video.currentTime = Math.max(0, video.currentTime - (e.shiftKey ? 1 : 0.1));
} else if (e.code === 'ArrowRight') {
e.preventDefault();
video.pause();
video.currentTime = Math.min(state.loaded.info.duration, video.currentTime + (e.shiftKey ? 1 : 0.1));
} else if (e.code === 'KeyI') {
e.preventDefault();
el<'button'>('setStartBtn').click();
} else if (e.code === 'KeyO') {
e.preventDefault();
el<'button'>('setEndBtn').click();
}
});
}
function bindEvents() {
const api = appApi();
if (playerResizeObserver) {
playerResizeObserver.disconnect();
playerResizeObserver = null;
}
const playerWrap = el<'div'>('playerWrap');
const dropOverlay = el<'div'>('dropOverlay');
if (typeof ResizeObserver !== 'undefined') {
playerResizeObserver = new ResizeObserver(() => fitVideoStage());
playerResizeObserver.observe(playerWrap);
}
playerWrap.addEventListener('dragenter', () => dropOverlay.classList.add('drag-over'));
playerWrap.addEventListener('dragleave', (e) => {
if (!playerWrap.contains(e.relatedTarget as Node | null)) {
dropOverlay.classList.remove('drag-over');
}
});
playerWrap.addEventListener('drop', () => dropOverlay.classList.remove('drag-over'));
el<'button'>('openBtn').addEventListener('click', async () => {
const path = await api?.OpenVideoDialog();
if (path) await loadVideoFile(path);
});
dropOverlay.addEventListener('click', async () => {
const path = await api?.OpenVideoDialog();
if (path) await loadVideoFile(path);
});
el<'button'>('ffmpegStatusBtn').addEventListener('click', async () => {
const info = await api?.SelectFFmpegDialog();
if (info) {
state.appInfo = info;
updateFFmpegBadge();
}
});
el<'button'>('langBtn').addEventListener('click', async () => {
const prevVideo = el<'video'>('videoEl');
const prevTime = prevVideo?.currentTime || 0;
const wasPlaying = prevVideo ? !prevVideo.paused : false;
state.lang = state.lang === 'ru' ? 'en' : 'ru';
if (state.appInfo) {
state.appInfo.config.language = state.lang;
await api?.SaveConfig(state.appInfo.config);
}
const prevLoaded = state.loaded;
renderShell();
if (prevLoaded) {
applyLoadedUI(prevLoaded, prevTime, wasPlaying, true);
} else {
if (state.errorMsg) showError(state.errorMsg);
}
});
// Video element events
const video = el<'video'>('videoEl');
video.addEventListener('canplay', () => {
el<'div'>('proxyBanner').classList.add('hidden');
const r = Math.max(0.25, Math.min(8, state.speed));
video.playbackRate = r;
});
video.addEventListener('error', () => {
if (state.loaded && !state.usingProxy) {
setPlayerSource(state.loaded, true);
}
});
video.addEventListener('timeupdate', () => {
if (state.loopTrim && !video.paused && state.trimEnd > state.trimStart) {
if (video.currentTime >= state.trimEnd) {
video.currentTime = state.trimStart;
}
}
updatePlayhead(video.currentTime);
});
// Transport
el<'button'>('playPauseBtn').addEventListener('click', togglePlay);
el<'button'>('stepBackBtn').addEventListener('click', () => {
video.pause();
video.currentTime = Math.max(0, video.currentTime - 0.1);
});
el<'button'>('stepFwdBtn').addEventListener('click', () => {
video.pause();
video.currentTime = Math.min(state.loaded?.info.duration ?? 0, video.currentTime + 0.1);
});
el<'button'>('setStartBtn').addEventListener('click', () => {
if (!state.loaded) return;
const clamped = clampTrimRange(video.currentTime, state.trimEnd, state.loaded.info.duration);
state.trimStart = clamped.start;
state.trimEnd = clamped.end;
updateTimelineVisuals();
void refreshCLI();
});
el<'button'>('setEndBtn').addEventListener('click', () => {
if (!state.loaded) return;
const clamped = clampTrimRange(state.trimStart, video.currentTime, state.loaded.info.duration);
state.trimStart = clamped.start;
state.trimEnd = clamped.end;
updateTimelineVisuals();
void refreshCLI();
});
el<'button'>('loopBtn').addEventListener('click', () => {
state.loopTrim = !state.loopTrim;
el<'button'>('loopBtn').classList.toggle('pill-active', state.loopTrim);
});
el<'button'>('previewMixBtn').addEventListener('click', () => {
if (!state.loaded) return;
state.previewMixAudio = !state.previewMixAudio;
el<'button'>('previewMixBtn').classList.toggle('pill-active', state.previewMixAudio);
const cur = video.currentTime;
const wasPlaying = !video.paused;
setPlayerSource(state.loaded, state.previewMixAudio, cur, wasPlaying);
});
el<'button'>('mutePreviewBtn').addEventListener('click', () => {
state.mutedPreview = !state.mutedPreview;
if (!state.mutedPreview && state.previewVolume === 0) {
state.previewVolume = 0.5;
localStorage.setItem('convert_preview_volume', '0.5');
}
updateVolumeUI();
});
el<'input'>('volumeRange').addEventListener('input', (e) => {
const v = Number((e.target as HTMLInputElement).value);
state.previewVolume = v;
if (v > 0) {
state.mutedPreview = false;
}
localStorage.setItem('convert_preview_volume', String(v));
updateVolumeUI();
});
// Timeline drag (handles + seek)
const track = el<'div'>('timelineTrack');
el<'div'>('handleStart').addEventListener('pointerdown', (e) => {
e.stopPropagation();
dragTarget = 'start';
});
el<'div'>('handleEnd').addEventListener('pointerdown', (e) => {
e.stopPropagation();
dragTarget = 'end';
});
track.addEventListener('pointerdown', (e) => {
if (!state.loaded) return;
dragTarget = 'seek';
const tSec = clientXToTime(e.clientX);
video.currentTime = tSec;
updatePlayhead(tSec);
});
// Trim manual inputs
el<'input'>('trimStartInput').addEventListener('change', () => {
if (!state.loaded) return;
const parsed = parseTimecode(el<'input'>('trimStartInput').value);
if (parsed !== null) {
const clamped = clampTrimRange(parsed, state.trimEnd, state.loaded.info.duration);
state.trimStart = clamped.start;
state.trimEnd = clamped.end;
video.currentTime = clamped.start;
void refreshCLI();
}
updateTimelineVisuals();
});
el<'input'>('trimEndInput').addEventListener('change', () => {
if (!state.loaded) return;
const parsed = parseTimecode(el<'input'>('trimEndInput').value);
if (parsed !== null) {
const clamped = clampTrimRange(state.trimStart, parsed, state.loaded.info.duration);
state.trimStart = clamped.start;
state.trimEnd = clamped.end;
video.currentTime = clamped.end;
void refreshCLI();
}
updateTimelineVisuals();
});
el<'button'>('resetTrimBtn').addEventListener('click', () => {
if (!state.loaded) return;
state.trimStart = 0;
state.trimEnd = state.loaded.info.duration;
updateTimelineVisuals();
void refreshCLI();
});
// Settings listeners
el<'select'>('encoderSelect').addEventListener('change', (e) => {
state.encoder = (e.target as HTMLSelectElement).value;
syncControlsFromState();
persistVideoSettings();
});
el<'select'>('hwDecodeSelect').addEventListener('change', (e) => {
state.hwDecode = (e.target as HTMLSelectElement).value;
persistVideoSettings();
void refreshCLI();
});
el<'input'>('crfRange').addEventListener('input', (e) => {
state.crf = Number((e.target as HTMLInputElement).value);
el<'span'>('crfLabel').textContent = `${state.crf} · ${crfQualityHint(state.crf)}`;
persistVideoSettings();
void refreshCLI();
});
el<'select'>('presetSelect').addEventListener('change', (e) => {
state.preset = (e.target as HTMLSelectElement).value;
persistVideoSettings();
void refreshCLI();
});
el<'select'>('tuneSelect').addEventListener('change', (e) => {
state.tune = (e.target as HTMLSelectElement).value;
persistVideoSettings();
void refreshCLI();
});
el<'select'>('fpsSelect').addEventListener('change', (e) => {
state.fps = Number((e.target as HTMLSelectElement).value);
persistVideoSettings();
void refreshCLI();
});
document.querySelectorAll<HTMLButtonElement>('#speedPills button').forEach((b) => {
b.addEventListener('click', () => {
state.speed = Number(b.dataset.speed);
syncControlsFromState();
});
});
el<'input'>('speedRange').addEventListener('input', (e) => {
state.speed = Number((e.target as HTMLInputElement).value);
syncControlsFromState();
});
document.querySelectorAll<HTMLButtonElement>('#audioModeBtns button').forEach((b) => {
b.addEventListener('click', () => {
state.audioMode = b.dataset.amode as 'merge' | 'track' | 'mute';
syncControlsFromState();
persistVideoSettings();
});
});
// Crop controls
el<'input'>('cropCheck').addEventListener('change', (e) => {
state.cropEnabled = (e.target as HTMLInputElement).checked;
syncControlsFromState();
});
const bindCropNum = (id: string, key: 'cropX' | 'cropY' | 'cropW' | 'cropH') => {
el<'input'>(id).addEventListener('change', (e) => {
const val = Math.max(0, Math.round(Number((e.target as HTMLInputElement).value) / 2) * 2);
state[key] = val;
if (state.loaded) {
state.cropX = Math.min(state.cropX, Math.max(0, state.loaded.info.width - 2));
state.cropY = Math.min(state.cropY, Math.max(0, state.loaded.info.height - 2));
state.cropW = Math.max(2, Math.min(state.cropW, state.loaded.info.width - state.cropX));
state.cropH = Math.max(2, Math.min(state.cropH, state.loaded.info.height - state.cropY));
}
syncControlsFromState();
});
};
bindCropNum('cropX', 'cropX');
bindCropNum('cropY', 'cropY');
bindCropNum('cropW', 'cropW');
bindCropNum('cropH', 'cropH');
document.querySelectorAll<HTMLButtonElement>('#cropPresets button').forEach((b) => {
b.addEventListener('click', () => {
if (!state.loaded) return;
const iw = state.loaded.info.width;
const ih = state.loaded.info.height;
const r = b.dataset.ratio;
if (r === 'reset') {
state.cropX = 0;
state.cropY = 0;
state.cropW = iw & ~1;
state.cropH = ih & ~1;
} else if (r) {
const [rw, rh] = r.split(':').map(Number);
const targetRatio = rw / rh;
let w = iw;
let h = Math.round(w / targetRatio);
if (h > ih) {
h = ih;
w = Math.round(h * targetRatio);
}
w &= ~1;
h &= ~1;
state.cropW = w;
state.cropH = h;
state.cropX = Math.round((iw - w) / 2) & ~1;
state.cropY = Math.round((ih - h) / 2) & ~1;
}
syncControlsFromState();
});
});
// Visual crop drag
el<'div'>('cropBox').addEventListener('pointerdown', (e) => {
if (!state.loaded) return;
e.stopPropagation();
const target = e.target as HTMLElement;
const mode = target.dataset.handle || 'move';
cropDrag = {
mode,
startX: e.clientX,
startY: e.clientY,
origX: state.cropX,
origY: state.cropY,
origW: state.cropW,
origH: state.cropH,
};
});
// Output & CLI
el<'input'>('outputInput').addEventListener('input', (e) => {
state.outputPath = (e.target as HTMLInputElement).value;
void refreshCLI();
});
el<'button'>('browseOutBtn').addEventListener('click', async () => {
const chosen = await api?.SaveVideoDialog(state.outputPath || 'output.mp4');
if (chosen) {
state.outputPath = chosen;
el<'input'>('outputInput').value = chosen;
void refreshCLI();
}
});
el<'button'>('toggleCliBtn').addEventListener('click', () => {
state.cliExpanded = !state.cliExpanded;
el<'pre'>('cliBox').classList.toggle('hidden', !state.cliExpanded);
el<'button'>('copyCliBtn').classList.toggle('hidden', !state.cliExpanded);
});
el<'button'>('copyCliBtn').addEventListener('click', async () => {
if (state.cliText) {
await navigator.clipboard.writeText(state.cliText);
const btn = el<'button'>('copyCliBtn');
btn.textContent = t().copied;
setTimeout(() => {
btn.textContent = t().copy;
}, 1500);
}
});
// Convert / Cancel
el<'button'>('convertBtn').addEventListener('click', async () => {
if (!state.loaded || state.converting) return;
state.errorMsg = '';
state.donePath = '';
state.doneBytes = 0;
state.progress = null;
el<'div'>('errorBanner').classList.add('hidden');
el<'div'>('doneBanner').classList.add('hidden');
state.converting = true;
el<'button'>('convertBtn').disabled = true;
el<'button'>('cancelBtn').classList.remove('hidden');
el<'div'>('progressWrap').classList.remove('hidden');
el<'div'>('progressFill').style.width = '0%';
el<'span'>('progressLeft').textContent = '0%';
el<'span'>('progressRight').textContent = '';
try {
await api?.StartConvert(buildRequest());
} catch (err) {
state.converting = false;
el<'button'>('convertBtn').disabled = false;
el<'button'>('cancelBtn').classList.add('hidden');
el<'div'>('progressWrap').classList.add('hidden');
showError(String(err));
}
});
el<'button'>('cancelBtn').addEventListener('click', async () => {
await api?.CancelConvert();
});
el<'button'>('openFolderBtn').addEventListener('click', () => {
if (state.donePath) void api?.OpenInExplorer(state.donePath);
});
el<'button'>('playOutBtn').addEventListener('click', () => {
if (state.donePath) void api?.OpenOutputFile(state.donePath);
});
// Window titlebar controls (frameless mode)
el<'button'>('winMinBtn')?.addEventListener('click', async () => {
if (window.runtime?.WindowMinimise) {
window.runtime.WindowMinimise();
} else {
await api?.WindowMinimise();
}
});
const toggleMaximize = async () => {
if (window.runtime?.WindowToggleMaximise) {
window.runtime.WindowToggleMaximise();
} else {
await api?.WindowToggleMaximise();
}
await syncMaximizedUI();
};
el<'button'>('winMaxBtn')?.addEventListener('click', () => {
void toggleMaximize();
});
el<'button'>('winCloseBtn')?.addEventListener('click', async () => {
if (window.runtime?.WindowClose) {
window.runtime.WindowClose();
} else {
await api?.WindowClose();
}
});
el<'header'>('topbar')?.addEventListener('dblclick', (e) => {
const target = e.target as HTMLElement;
if (target.closest('button, input, select, .file-pill')) return;
void toggleMaximize();
});
bindWheelControls();
void syncMaximizedUI();
}
async function syncMaximizedUI() {
const api = appApi();
let isMax = false;
try {
if (window.runtime?.WindowIsMaximised) {
isMax = await window.runtime.WindowIsMaximised();
} else if (api?.WindowIsMaximised) {
isMax = await api.WindowIsMaximised();
}
} catch {
// ignore
}
document.body.classList.toggle('maximized', isMax);
const maxBtn = el<'button'>('winMaxBtn');
if (maxBtn) {
maxBtn.innerHTML = isMax
? `<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1"><rect x="2.5" y="0.5" width="7" height="7"/><polyline points="2.5,2.5 0.5,2.5 0.5,9.5 7.5,9.5 7.5,7.5"/></svg>`
: `<svg id="winMaxIcon" width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.2"><rect x="0.6" y="0.6" width="8.8" height="8.8"/></svg>`;
maxBtn.title = isMax ? 'Восстановить' : 'Развернуть';
}
}
function setupWailsEvents() {
const rt = window.runtime;
if (!rt) return;
if (rt.OnFileDrop) {
rt.OnFileDrop((_x, _y, paths) => {
el<'div'>('dropOverlay')?.classList.remove('drag-over');
if (paths && paths.length > 0) {
void loadVideoFile(paths[0]);
}
}, false);
}
rt.EventsOn('convert:progress', (p: ProgressEvent) => {
state.progress = p;
state.doneBytes = p.outputBytes || p.totalSize || state.doneBytes;
renderProgress(p);
});
rt.EventsOn('convert:done', (outPath: string) => {
state.converting = false;
state.donePath = outPath;
el<'button'>('convertBtn').disabled = false;
el<'button'>('cancelBtn').classList.add('hidden');
el<'div'>('progressWrap').classList.add('hidden');
renderDoneBanner();
});
rt.EventsOn('convert:error', (errMsg: string) => {
state.converting = false;
el<'button'>('convertBtn').disabled = false;
el<'button'>('cancelBtn').classList.add('hidden');
el<'div'>('progressWrap').classList.add('hidden');
showError(errMsg);
});
}
async function init() {
bindGlobalEvents();
setupWailsEvents();
const api = appApi();
let initialFile = '';
let initErr = '';
if (api) {
try {
const info = await api.GetAppInfo();
state.appInfo = info;
if (info.config.language === 'en' || info.config.language === 'ru') {
state.lang = info.config.language;
}
state.encoder = info.config.defaultEncoder || 'libx264';
state.hwDecode = info.config.defaultHwDecode || 'none';
state.preset = info.config.defaultPreset || 'slow';
state.crf = info.config.defaultCrf ?? 20;
state.fps = info.config.defaultFps ?? 60;
state.tune = info.config.defaultTune || 'film';
initialFile = info.initialFile;
} catch (err) {
initErr = String(err);
}
}
renderShell();
if (initErr) {
showError(initErr);
}
if (initialFile) {
await loadVideoFile(initialFile);
}
}
void init();