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(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 = `
▶
ConvertGUI ${verText}
🎬

${tr.dropTitle}

${tr.dropHint}
00:00:00.000 / 00:00:00.000
100%
00:00.000
00:00.000
`; 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('#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('#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 = `
${tr.noAudio}
`; 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 ``; } const checked = state.audioTrack === s.audioOrder; return ``; }) .join(''); container.querySelectorAll('[data-atrack-check]').forEach((cb) => { cb.addEventListener('change', () => { const next: number[] = []; container.querySelectorAll('[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('[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 += ``; } 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('#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('#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('#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 ? `` : ``; 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();