fix: Wails file drop, duplicate listeners, trim drag, crop stage, preview cache/pixfmt, NSIS temp quoting
Build Windows Installer / build (push) Successful in 8m46s
Build Windows Installer / build (push) Successful in 8m46s
This commit is contained in:
12 files changed
+563
-226
No files matched your search
+344
-208
@@ -16,6 +16,7 @@ interface State {
|
||||
trimEnd: number;
|
||||
loopTrim: boolean;
|
||||
previewMixAudio: boolean;
|
||||
mutedPreview: boolean;
|
||||
usingProxy: boolean;
|
||||
encoder: string;
|
||||
hwDecode: string;
|
||||
@@ -50,6 +51,7 @@ const state: State = {
|
||||
trimEnd: 0,
|
||||
loopTrim: true,
|
||||
previewMixAudio: false,
|
||||
mutedPreview: false,
|
||||
usingProxy: false,
|
||||
encoder: 'libx264',
|
||||
hwDecode: 'none',
|
||||
@@ -76,6 +78,19 @@ const state: State = {
|
||||
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];
|
||||
|
||||
@@ -83,15 +98,36 @@ function el<K extends keyof HTMLElementTagNameMap>(id: string): HTMLElementTagNa
|
||||
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">
|
||||
<div class="brand">
|
||||
<div class="brand-icon">▶</div>
|
||||
<span class="brand-title">ConvertGUI</span>
|
||||
<span class="badge" id="verBadge">v1.0</span>
|
||||
<span class="badge" id="verBadge">${verText}</span>
|
||||
</div>
|
||||
<div class="topbar-center">
|
||||
<button id="openBtn" class="primary">📂 ${tr.openVideo}</button>
|
||||
@@ -385,7 +421,9 @@ function syncControlsFromState() {
|
||||
|
||||
const video = el<'video'>('videoEl');
|
||||
if (video) {
|
||||
video.playbackRate = Math.max(0.25, Math.min(8, state.speed));
|
||||
const rate = Math.max(0.25, Math.min(8, state.speed));
|
||||
video.defaultPlaybackRate = rate;
|
||||
video.playbackRate = rate;
|
||||
}
|
||||
|
||||
el<'input'>('cropCheck').checked = state.cropEnabled;
|
||||
@@ -395,6 +433,7 @@ function syncControlsFromState() {
|
||||
el<'input'>('cropY').value = String(state.cropY);
|
||||
el<'input'>('cropW').value = String(state.cropW);
|
||||
el<'input'>('cropH').value = String(state.cropH);
|
||||
fitVideoStage();
|
||||
updateCropBoxVisual();
|
||||
|
||||
renderAudioSection();
|
||||
@@ -432,6 +471,10 @@ function renderAudioSection() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.audioMode === 'merge' && state.selectedTracks.length === 0) {
|
||||
state.selectedTracks = tracks.map((a) => a.audioOrder);
|
||||
}
|
||||
|
||||
container.innerHTML = tracks
|
||||
.map((s) => {
|
||||
const label = [
|
||||
@@ -444,8 +487,7 @@ function renderAudioSection() {
|
||||
.join(' · ');
|
||||
|
||||
if (state.audioMode === 'merge') {
|
||||
const checked =
|
||||
state.selectedTracks.length === 0 || state.selectedTracks.includes(s.audioOrder);
|
||||
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;
|
||||
@@ -460,6 +502,11 @@ function renderAudioSection() {
|
||||
if (box.checked) next.push(Number(box.dataset.atrackCheck));
|
||||
});
|
||||
state.selectedTracks = next;
|
||||
if (next.length === 0) {
|
||||
state.audioMode = 'mute';
|
||||
syncControlsFromState();
|
||||
return;
|
||||
}
|
||||
void refreshCLI();
|
||||
});
|
||||
});
|
||||
@@ -588,42 +635,75 @@ async function loadVideoFile(path: string) {
|
||||
state.selectedTracks = [0];
|
||||
}
|
||||
|
||||
// Update UI header & enable controls
|
||||
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 = false;
|
||||
|
||||
const mixBtn = el<'button'>('previewMixBtn');
|
||||
mixBtn.classList.toggle('hidden', res.info.audioStreams.length < 2);
|
||||
mixBtn.classList.remove('pill-active');
|
||||
|
||||
loadFilmstrip(res);
|
||||
setPlayerSource(res, false);
|
||||
syncControlsFromState();
|
||||
updatePlayhead(0);
|
||||
applyLoadedUI(res, 0, false, true);
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
function setPlayerSource(res: LoadedVideo, forceProxy: boolean) {
|
||||
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);
|
||||
el<'button'>('mutePreviewBtn').textContent = state.mutedPreview ? '🔇' : '🔊';
|
||||
|
||||
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;
|
||||
let needsProxy = forceProxy || !res.info.browserNative || state.previewMixAudio;
|
||||
const needsProxy = forceProxy || !res.info.browserNative || state.previewMixAudio;
|
||||
if (needsProxy) {
|
||||
src = state.previewMixAudio ? res.proxyMergedUrl : res.proxyUrl;
|
||||
state.usingProxy = true;
|
||||
@@ -633,8 +713,33 @@ function setPlayerSource(res: LoadedVideo, forceProxy: boolean) {
|
||||
banner.classList.add('hidden');
|
||||
}
|
||||
|
||||
const rate = Math.max(0.25, Math.min(8, state.speed));
|
||||
video.muted = state.mutedPreview;
|
||||
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;
|
||||
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.playbackRate = state.speed;
|
||||
video.load();
|
||||
}
|
||||
|
||||
@@ -670,19 +775,185 @@ function updateCropBoxVisual() {
|
||||
function showError(msg: string) {
|
||||
state.errorMsg = msg;
|
||||
const b = el<'div'>('errorBanner');
|
||||
b.textContent = msg;
|
||||
b.classList.remove('hidden');
|
||||
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();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
el<'div'>('dropOverlay').addEventListener('click', async () => {
|
||||
dropOverlay.addEventListener('click', async () => {
|
||||
const path = await api?.OpenVideoDialog();
|
||||
if (path) await loadVideoFile(path);
|
||||
});
|
||||
@@ -696,6 +967,9 @@ function bindEvents() {
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -704,25 +978,9 @@ function bindEvents() {
|
||||
const prevLoaded = state.loaded;
|
||||
renderShell();
|
||||
if (prevLoaded) {
|
||||
state.loaded = prevLoaded;
|
||||
el<'div'>('filePill').classList.remove('hidden');
|
||||
el<'strong'>('fileName').textContent = prevLoaded.info.fileName;
|
||||
el<'span'>('fileMeta').textContent = `${prevLoaded.info.width}×${prevLoaded.info.height} · ${prevLoaded.info.fps ? prevLoaded.info.fps.toFixed(0) + 'fps' : '?'} · ${prevLoaded.info.videoCodec.toUpperCase()} · ${formatBytes(prevLoaded.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 = false;
|
||||
el<'button'>('previewMixBtn').classList.toggle('hidden', prevLoaded.info.audioStreams.length < 2);
|
||||
loadFilmstrip(prevLoaded);
|
||||
setPlayerSource(prevLoaded, state.usingProxy);
|
||||
syncControlsFromState();
|
||||
applyLoadedUI(prevLoaded, prevTime, wasPlaying, true);
|
||||
} else {
|
||||
if (state.errorMsg) showError(state.errorMsg);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -730,6 +988,8 @@ function bindEvents() {
|
||||
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) {
|
||||
@@ -746,17 +1006,6 @@ function bindEvents() {
|
||||
});
|
||||
|
||||
// Transport
|
||||
const togglePlay = () => {
|
||||
if (!state.loaded) 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();
|
||||
}
|
||||
};
|
||||
el<'button'>('playPauseBtn').addEventListener('click', togglePlay);
|
||||
el<'button'>('stepBackBtn').addEventListener('click', () => {
|
||||
video.pause();
|
||||
@@ -791,30 +1040,17 @@ function bindEvents() {
|
||||
state.previewMixAudio = !state.previewMixAudio;
|
||||
el<'button'>('previewMixBtn').classList.toggle('pill-active', state.previewMixAudio);
|
||||
const cur = video.currentTime;
|
||||
setPlayerSource(state.loaded, state.previewMixAudio);
|
||||
video.addEventListener(
|
||||
'loadedmetadata',
|
||||
() => {
|
||||
video.currentTime = cur;
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
const wasPlaying = !video.paused;
|
||||
setPlayerSource(state.loaded, state.previewMixAudio, cur, wasPlaying);
|
||||
});
|
||||
el<'button'>('mutePreviewBtn').addEventListener('click', () => {
|
||||
video.muted = !video.muted;
|
||||
el<'button'>('mutePreviewBtn').textContent = video.muted ? '🔇' : '🔊';
|
||||
state.mutedPreview = !state.mutedPreview;
|
||||
video.muted = state.mutedPreview;
|
||||
el<'button'>('mutePreviewBtn').textContent = state.mutedPreview ? '🔇' : '🔊';
|
||||
});
|
||||
|
||||
// Timeline drag (handles + seek)
|
||||
const track = el<'div'>('timelineTrack');
|
||||
let dragTarget: 'seek' | 'start' | 'end' | null = null;
|
||||
|
||||
const clientXToTime = (clientX: number): number => {
|
||||
const rect = track.getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
return ratio * (state.loaded?.info.duration ?? 0);
|
||||
};
|
||||
|
||||
el<'div'>('handleStart').addEventListener('pointerdown', (e) => {
|
||||
e.stopPropagation();
|
||||
dragTarget = 'start';
|
||||
@@ -831,34 +1067,6 @@ function bindEvents() {
|
||||
updatePlayhead(tSec);
|
||||
});
|
||||
|
||||
window.addEventListener('pointermove', (e) => {
|
||||
if (!dragTarget || !state.loaded) return;
|
||||
const dur = state.loaded.info.duration;
|
||||
const tSec = clientXToTime(e.clientX);
|
||||
if (dragTarget === 'seek') {
|
||||
video.currentTime = tSec;
|
||||
updatePlayhead(tSec);
|
||||
} else if (dragTarget === 'start') {
|
||||
const clamped = clampTrimRange(tSec, state.trimEnd, dur);
|
||||
state.trimStart = clamped.start;
|
||||
video.currentTime = clamped.start;
|
||||
updateTimelineVisuals();
|
||||
updatePlayhead(clamped.start);
|
||||
} else if (dragTarget === 'end') {
|
||||
const clamped = clampTrimRange(state.trimStart, tSec, dur);
|
||||
state.trimEnd = clamped.end;
|
||||
video.currentTime = clamped.end;
|
||||
updateTimelineVisuals();
|
||||
updatePlayhead(clamped.end);
|
||||
}
|
||||
});
|
||||
window.addEventListener('pointerup', () => {
|
||||
if (dragTarget === 'start' || dragTarget === 'end') {
|
||||
void refreshCLI();
|
||||
}
|
||||
dragTarget = null;
|
||||
});
|
||||
|
||||
// Trim manual inputs
|
||||
el<'input'>('trimStartInput').addEventListener('change', () => {
|
||||
if (!state.loaded) return;
|
||||
@@ -992,7 +1200,6 @@ function bindEvents() {
|
||||
});
|
||||
|
||||
// Visual crop drag
|
||||
let cropDrag: { mode: string; startX: number; startY: number; origX: number; origY: number; origW: number; origH: number } | null = null;
|
||||
el<'div'>('cropBox').addEventListener('pointerdown', (e) => {
|
||||
if (!state.loaded) return;
|
||||
e.stopPropagation();
|
||||
@@ -1008,47 +1215,6 @@ function bindEvents() {
|
||||
origH: state.cropH,
|
||||
};
|
||||
});
|
||||
window.addEventListener('pointermove', (e) => {
|
||||
if (!cropDrag || !state.loaded) return;
|
||||
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', () => {
|
||||
cropDrag = null;
|
||||
});
|
||||
|
||||
// Output & CLI
|
||||
el<'input'>('outputInput').addEventListener('input', (e) => {
|
||||
@@ -1084,6 +1250,8 @@ function bindEvents() {
|
||||
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;
|
||||
@@ -1115,50 +1283,25 @@ function bindEvents() {
|
||||
el<'button'>('playOutBtn').addEventListener('click', () => {
|
||||
if (state.donePath) void api?.OpenOutputFile(state.donePath);
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
window.addEventListener('keydown', (e) => {
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return;
|
||||
if (!state.loaded) return;
|
||||
|
||||
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 setupWailsEvents() {
|
||||
const rt = window.runtime;
|
||||
if (!rt) return;
|
||||
|
||||
rt.EventsOn('app:file-dropped', (filePath: string) => {
|
||||
void loadVideoFile(filePath);
|
||||
});
|
||||
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;
|
||||
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(' · ');
|
||||
renderProgress(p);
|
||||
});
|
||||
|
||||
rt.EventsOn('convert:done', (outPath: string) => {
|
||||
@@ -1167,16 +1310,7 @@ function setupWailsEvents() {
|
||||
el<'button'>('convertBtn').disabled = false;
|
||||
el<'button'>('cancelBtn').classList.add('hidden');
|
||||
el<'div'>('progressWrap').classList.add('hidden');
|
||||
|
||||
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');
|
||||
renderDoneBanner();
|
||||
});
|
||||
|
||||
rt.EventsOn('convert:error', (errMsg: string) => {
|
||||
@@ -1189,10 +1323,12 @@ function setupWailsEvents() {
|
||||
}
|
||||
|
||||
async function init() {
|
||||
renderShell();
|
||||
bindGlobalEvents();
|
||||
setupWailsEvents();
|
||||
|
||||
const api = appApi();
|
||||
let initialFile = '';
|
||||
let initErr = '';
|
||||
if (api) {
|
||||
try {
|
||||
const info = await api.GetAppInfo();
|
||||
@@ -1206,19 +1342,19 @@ async function init() {
|
||||
state.crf = info.config.defaultCrf ?? 20;
|
||||
state.fps = info.config.defaultFps ?? 60;
|
||||
state.tune = info.config.defaultTune || 'film';
|
||||
|
||||
renderShell();
|
||||
el<'span'>('verBadge').textContent = `v${info.version}`;
|
||||
updateFFmpegBadge();
|
||||
syncControlsFromState();
|
||||
|
||||
if (info.initialFile) {
|
||||
await loadVideoFile(info.initialFile);
|
||||
}
|
||||
initialFile = info.initialFile;
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
initErr = String(err);
|
||||
}
|
||||
}
|
||||
|
||||
renderShell();
|
||||
if (initErr) {
|
||||
showError(initErr);
|
||||
}
|
||||
if (initialFile) {
|
||||
await loadVideoFile(initialFile);
|
||||
}
|
||||
}
|
||||
|
||||
void init();
|
||||
Reference in new issue
Block a user