1513 lines
52 KiB
TypeScript
1513 lines
52 KiB
TypeScript
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();
|