/* ================================================================
Jigarzzz❤️ — Premium Video Suite · app.js v5
================================================================ */
'use strict';
// ─────────────────────────────────────────────────────────────────
// STATE
// ─────────────────────────────────────────────────────────────────
const state = {
voiceData: null, // { voices, voice_styles, mood_labels }
currentLang: 'ur-PK',
currentVoice: '',
currentStyle: '', // selected mood key
rateVal: 0,
pitchVal: 0,
statsFiles: 0,
statsClips: 0,
statsAudio: 0,
};
// ─────────────────────────────────────────────────────────────────
// UTILITIES
// ─────────────────────────────────────────────────────────────────
function $(id) { return document.getElementById(id); }
function $q(sel) { return document.querySelector(sel); }
function showToast(msg, type = 'success', duration = 3500) {
const t = $('toast');
t.textContent = msg;
t.className = `toast ${type} show`;
setTimeout(() => t.classList.remove('show'), duration);
}
function animateCount(el, target) {
const start = parseInt(el.textContent) || 0;
const diff = target - start;
if (diff === 0) return;
const step = Math.ceil(Math.abs(diff) / 20);
const dir = diff > 0 ? 1 : -1;
let cur = start;
const iv = setInterval(() => {
cur += dir * step;
if ((dir > 0 && cur >= target) || (dir < 0 && cur <= target)) {
cur = target;
clearInterval(iv);
}
el.textContent = cur;
}, 40);
}
// ─────────────────────────────────────────────────────────────────
// GALLERY / LIBRARY
// ─────────────────────────────────────────────────────────────────
async function loadGallery() {
const container = $('gallery-container');
const searchVal = ($('library-search')?.value || '').toLowerCase();
try {
const res = await fetch('/api/outputs');
const data = await res.json();
const filtered = searchVal
? data.filter(f => f.filename.toLowerCase().includes(searchVal))
: data;
if (!filtered.length) {
container.innerHTML = `
${searchVal ? '🔍' : '🎞️'}
${searchVal ? 'No files match your search.' : 'No files exported yet.
Run a merge or clip operation to begin.'}
`;
animateCount($('stat-files'), 0);
return;
}
state.statsFiles = data.length;
animateCount($('stat-files'), data.length);
container.innerHTML = filtered.map(file => {
const isClip = file.filename.includes('clip_');
const icon = isClip ? '✂️' : '🎬';
return `
${icon} ${file.filename}
${file.duration} | ${file.size}
`;
}).join('');
// Wire up play / download
container.querySelectorAll('.play-action').forEach(btn => {
btn.addEventListener('click', () => openVideoModal(btn.dataset.fn));
});
container.querySelectorAll('.download-action').forEach(btn => {
btn.addEventListener('click', () => {
const a = document.createElement('a');
a.href = `/api/outputs/${btn.dataset.fn}`;
a.download = btn.dataset.fn;
a.click();
});
});
} catch (e) {
container.innerHTML = `Failed to load library.
`;
}
}
// ─────────────────────────────────────────────────────────────────
// CLEAR LIBRARY
// ─────────────────────────────────────────────────────────────────
async function clearLibrary() {
const confirmed = confirm('🗑️ Delete ALL exported files from disk?\n\nThis cannot be undone.');
if (!confirmed) return;
const btn = $('clear-library-btn');
btn.textContent = '⏳ Clearing…';
btn.disabled = true;
try {
const res = await fetch('/api/clear-library', { method: 'POST' });
const data = await res.json();
showToast(`✅ Cleared ${data.deleted} file${data.deleted !== 1 ? 's' : ''} from library`, 'success');
animateCount($('stat-files'), 0);
await loadGallery();
} catch (e) {
showToast('❌ Failed to clear library', 'error');
} finally {
btn.textContent = '🗑 Clear All';
btn.disabled = false;
}
}
// ─────────────────────────────────────────────────────────────────
// VIDEO MODAL
// ─────────────────────────────────────────────────────────────────
function openVideoModal(filename) {
$('modal-title').textContent = `▶ ${filename}`;
$('modal-player').src = `/api/outputs/${filename}`;
$('video-modal').classList.add('active');
$('modal-player').play();
}
function closeVideoModal() {
$('modal-player').pause();
$('modal-player').src = '';
$('video-modal').classList.remove('active');
}
// ─────────────────────────────────────────────────────────────────
// VOICE SYSTEM
// ─────────────────────────────────────────────────────────────────
async function loadVoices() {
try {
const res = await fetch('/api/voices');
state.voiceData = await res.json();
updateVoiceDropdown(state.currentLang, 'tts');
updateVoiceDropdown(state.currentLang, 'ai');
} catch (e) {
console.error('Failed to load voices:', e);
}
}
function updateVoiceDropdown(lang, prefix = 'tts') {
const voiceSel = $(`${prefix}-voice`);
if (!voiceSel || !state.voiceData) return;
const voices = state.voiceData.voices[lang] || {};
voiceSel.innerHTML = Object.entries(voices).map(([label, id]) =>
``
).join('');
if (prefix === 'tts') {
state.currentVoice = voiceSel.value;
updateMoodGrid('tts');
buildAgeGrid('tts');
} else {
state.aiCurrentVoice = voiceSel.value;
updateMoodGrid('ai');
buildAgeGrid('ai');
}
}
function updateMoodGrid(prefix = 'tts') {
const voice = $(`${prefix}-voice`)?.value || '';
const moodGrid = $(`${prefix}-mood-grid`);
const moodTag = $(`${prefix}-mood-support-tag`);
const moodData = state.voiceData?.voice_styles || {};
const moodLabels = state.voiceData?.mood_labels || {};
const supported = moodData[voice] || [];
if (prefix === 'tts') state.currentVoice = voice;
else state.aiCurrentVoice = voice;
if (!moodGrid) return;
if (supported.length === 0) {
if (moodTag) {
moodTag.textContent = '⚠ Default only — switch to Aria/Jenny/Tony/Nancy for moods';
moodTag.className = 'mood-support-tag unsupported';
}
const allMoods = Object.entries(moodLabels);
moodGrid.innerHTML = allMoods.map(([key, label]) => {
const active = key === '' ? 'active' : '';
return `${label}`;
}).join('');
if (prefix === 'tts') {
state.currentStyle = '';
$('tts-style').value = '';
} else {
state.aiCurrentStyle = '';
$('ai-tts-style').value = '';
}
} else {
if (moodTag) {
moodTag.textContent = '✓ Mood supported';
moodTag.className = 'mood-support-tag supported';
}
const currentStyle = prefix === 'tts' ? state.currentStyle : state.aiCurrentStyle;
const chips = [['', moodLabels[''] || '🎙️ Default Style'], ...supported.map(k => [k, moodLabels[k] || k])];
moodGrid.innerHTML = chips.map(([key, label]) => {
const active = key === currentStyle ? 'active' : '';
return `${label}`;
}).join('');
}
// Wire mood chip clicks
moodGrid.querySelectorAll('.mood-chip:not(.disabled)').forEach(chip => {
chip.addEventListener('click', () => {
moodGrid.querySelectorAll('.mood-chip').forEach(c => c.classList.remove('active'));
chip.classList.add('active');
if (prefix === 'tts') {
state.currentStyle = chip.dataset.mood;
$('tts-style').value = chip.dataset.mood;
} else {
state.aiCurrentStyle = chip.dataset.mood;
$('ai-tts-style').value = chip.dataset.mood;
}
});
});
}
function buildAgeGrid(prefix = 'tts') {
const grid = $(`${prefix}-age-grid`);
const presets = state.voiceData?.age_presets || {};
if (!grid) return;
const currentAge = prefix === 'tts' ? (state.currentAge || 'adult') : (state.aiCurrentAge || 'adult');
grid.innerHTML = Object.entries(presets).map(([key, p]) => {
const active = key === currentAge ? 'active' : '';
return `
${p.label}
${p.desc}
`;
}).join('');
grid.querySelectorAll('.age-chip').forEach(chip => {
chip.addEventListener('click', () => {
grid.querySelectorAll('.age-chip').forEach(c => c.classList.remove('active'));
chip.classList.add('active');
if (prefix === 'tts') {
state.currentAge = chip.dataset.age;
} else {
state.aiCurrentAge = chip.dataset.age;
}
const rateStr = chip.dataset.rate;
const pitchStr = chip.dataset.pitch;
const rateNum = parseInt(rateStr);
const pitchNum = parseInt(pitchStr);
// Update sliders (forms read from sliders on submit)
const rSlider = $(`${prefix}-rate-slider`);
const pSlider = $(`${prefix}-pitch-slider`);
if (rSlider) {
rSlider.value = rateNum;
$(`${prefix}-rate-val`).textContent = formatRate(rateNum);
// Dispatch input event so initSliders listeners also update state
rSlider.dispatchEvent(new Event('input'));
}
if (pSlider) {
pSlider.value = pitchNum;
$(`${prefix}-pitch-val`).textContent = formatPitch(pitchNum);
pSlider.dispatchEvent(new Event('input'));
}
});
});
if (prefix === 'tts' && !state.currentAge) state.currentAge = 'adult';
if (prefix === 'ai' && !state.aiCurrentAge) state.aiCurrentAge = 'adult';
}
function formatRate(v) {
if (v === 0) return 'Normal';
return v > 0 ? `+${v}% faster` : `${v}% slower`;
}
function formatPitch(v) {
if (v === 0) return 'Normal';
return v > 0 ? `+${v}Hz higher` : `${v}Hz lower`;
}
function initSliders(prefix = 'tts') {
const rateSlider = $(`${prefix}-rate-slider`);
const pitchSlider = $(`${prefix}-pitch-slider`);
if (!rateSlider || !pitchSlider) return;
rateSlider.addEventListener('input', () => {
const v = parseInt(rateSlider.value);
if (prefix === 'tts') state.rateVal = v;
else state.aiRateVal = v;
$(`${prefix}-rate-val`).textContent = formatRate(v);
});
pitchSlider.addEventListener('input', () => {
const v = parseInt(pitchSlider.value);
if (prefix === 'tts') state.pitchVal = v;
else state.aiPitchVal = v;
$(`${prefix}-pitch-val`).textContent = formatPitch(v);
});
}
async function previewVoice(prefix = 'tts') {
const btn = $(prefix === 'tts' ? 'preview-voice-btn' : 'ai-preview-voice-btn');
const player = $(prefix === 'tts' ? 'voice-preview-player' : 'ai-voice-preview-player');
const audio = $(prefix === 'tts' ? 'preview-audio' : 'ai-preview-audio');
const voice = $(`${prefix}-voice`)?.value;
const lang = $(`${prefix}-language`)?.value || 'ur-PK';
const style = prefix === 'tts' ? state.currentStyle : state.aiCurrentStyle;
const rateV = parseInt($(`${prefix}-rate-slider`)?.value || 0);
const pitchV = parseInt($(`${prefix}-pitch-slider`)?.value || 0);
const rate = rateV >= 0 ? `+${rateV}%` : `${rateV}%`;
const pitch = pitchV >= 0 ? `+${pitchV}Hz` : `${pitchV}Hz`;
if (!voice) return;
btn.classList.add('loading');
btn.textContent = '⏳ Generating…';
try {
const fd = new FormData();
fd.append('voice', voice);
fd.append('lang', lang);
fd.append('style', style);
fd.append('style_degree', '1.5');
fd.append('rate', rate);
fd.append('pitch', pitch);
const res = await fetch('/api/preview-voice', { method: 'POST', body: fd });
if (!res.ok) throw new Error('Server error');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
audio.src = url;
player.style.display = 'block';
audio.play();
showToast('🎧 Playing voice preview!', 'success', 2500);
} catch (e) {
showToast('❌ Preview failed — check server logs', 'error');
} finally {
btn.classList.remove('loading');
btn.textContent = '▶ Preview';
}
}
// ─────────────────────────────────────────────────────────────────
// TABS
// ─────────────────────────────────────────────────────────────────
function initTabs() {
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
btn.classList.add('active');
$(btn.dataset.tab)?.classList.add('active');
});
});
}
// ─────────────────────────────────────────────────────────────────
// AUDIO SOURCE TOGGLE (TTS / Upload / None)
// ─────────────────────────────────────────────────────────────────
function initAudioToggle() {
const toggles = document.querySelectorAll('#merger-tab .toggle-btn');
toggles.forEach(btn => {
btn.addEventListener('click', () => {
toggles.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const type = btn.dataset.type;
$('tts-panel')?.classList.toggle('active', type === 'script');
$('audio-upload-panel')?.classList.toggle('active', type === 'upload');
$('no-audio-panel')?.classList.toggle('active', type === 'none');
});
});
const clipToggles = document.querySelectorAll('#clipper-tab .toggle-btn');
clipToggles.forEach(btn => {
btn.addEventListener('click', () => {
clipToggles.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const type = btn.dataset.type;
$('auto-clip-panel')?.classList.toggle('active', type === 'auto');
$('timestamps-clip-panel')?.classList.toggle('active', type === 'timestamps');
});
});
}
// ─────────────────────────────────────────────────────────────────
// DROPZONE
// ─────────────────────────────────────────────────────────────────
function initDropzone() {
const dropzone = $('video-dropzone');
const fileInput = $('videos');
const queue = $('video-queue');
const pill = $('video-count-pill');
if (!dropzone) return;
dropzone.addEventListener('click', () => fileInput.click());
dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('drag-over'); });
dropzone.addEventListener('dragleave', () => dropzone.classList.remove('drag-over'));
dropzone.addEventListener('drop', e => {
e.preventDefault();
dropzone.classList.remove('drag-over');
handleFiles(e.dataTransfer.files);
});
fileInput.addEventListener('change', () => handleFiles(fileInput.files));
function handleFiles(files) {
const arr = Array.from(files).filter(f => f.type.startsWith('video/'));
queue.innerHTML = arr.map(f =>
`🎞️ ${f.name}${(f.size/1024/1024).toFixed(1)} MB
`
).join('');
pill.style.display = arr.length ? 'inline-flex' : 'none';
pill.textContent = `${arr.length} file${arr.length !== 1 ? 's' : ''}`;
}
}
// ─────────────────────────────────────────────────────────────────
// PROGRESS BAR ANIMATION
// ─────────────────────────────────────────────────────────────────
function startProgress(fillId, pctId, stepId, steps, onDone) {
const fill = $(fillId);
const pct = $(pctId);
const step = $(stepId);
if (!fill) return null;
let idx = 0;
let cur = 0;
const iv = setInterval(() => {
if (idx >= steps.length) { clearInterval(iv); onDone?.(); return; }
const { pct: target, label, dur } = steps[idx];
step.textContent = label;
const speed = (target - cur) / (dur / 120);
const inner = setInterval(() => {
cur = Math.min(cur + speed, target);
fill.style.width = `${cur}%`;
pct.textContent = `${Math.round(cur)}%`;
if (cur >= target) { clearInterval(inner); idx++; }
}, 120);
}, 0);
return iv;
}
function showProgress(progressId) {
$(progressId)?.classList.add('visible');
}
function hideProgress(progressId) {
$(progressId)?.classList.remove('visible');
}
// ─────────────────────────────────────────────────────────────────
// MERGE FORM
// ─────────────────────────────────────────────────────────────────
function initMergeForm() {
const form = $('merge-form');
if (!form) return;
form.addEventListener('submit', async e => {
e.preventDefault();
const audioType = $q('#merger-tab .toggle-btn.active')?.dataset.type || 'none';
if (audioType === 'script' && !$('script_text').value.trim()) {
showToast('⚠️ Please enter a voiceover script', 'error'); return;
}
const fd = new FormData(form);
fd.set('audio_source', audioType);
// Sync slider values to form data (use tts- prefix for merge tab)
const rateV = parseInt($('tts-rate-slider')?.value || 0);
const pitchV = parseInt($('tts-pitch-slider')?.value || 0);
fd.set('rate', rateV >= 0 ? `+${rateV}%` : `${rateV}%`);
fd.set('pitch', pitchV >= 0 ? `+${pitchV}Hz` : `${pitchV}Hz`);
fd.set('style', state.currentStyle);
const btn = $('merge-submit-btn');
btn.disabled = true; btn.classList.add('loading');
showProgress('merge-progress');
const steps = [
{ pct: 25, label: '🎞️ Processing video clips…', dur: 1800 },
{ pct: 50, label: '🔗 Merging & cropping…', dur: 2200 },
{ pct: 70, label: '🎙️ Generating voiceover…', dur: 1800 },
{ pct: 90, label: '🎵 Mixing audio tracks…', dur: 1200 },
{ pct: 99, label: '📦 Finalising output…', dur: 800 },
];
startProgress('merge-fill', 'merge-pct', 'merge-step', steps);
try {
const res = await fetch('/api/merge', { method: 'POST', body: fd });
const data = await res.json();
$('merge-fill').style.width = '100%';
$('merge-pct').textContent = '100%';
$('merge-step').textContent = '✅ Done!';
if (data.success) {
showToast(`✅ ${data.message}`, 'success', 5000);
state.statsAudio++;
animateCount($('stat-audio'), state.statsAudio);
await loadGallery();
} else {
showToast(`❌ ${data.error}`, 'error', 6000);
}
} catch (err) {
showToast('❌ Network error — check server', 'error');
} finally {
btn.disabled = false; btn.classList.remove('loading');
setTimeout(() => hideProgress('merge-progress'), 2000);
}
});
}
// ─────────────────────────────────────────────────────────────────
// CLIP FORM
// ─────────────────────────────────────────────────────────────────
function initClipForm() {
const form = $('clip-form');
if (!form) return;
form.addEventListener('submit', async e => {
e.preventDefault();
const url = $('url')?.value.trim();
if (!url || !url.includes('youtube')) {
showToast('⚠️ Please enter a valid YouTube URL', 'error'); return;
}
const btn = $('clip-submit-btn');
btn.disabled = true; btn.classList.add('loading');
showProgress('clip-progress');
const steps = [
{ pct: 20, label: '⬇️ Downloading video…', dur: 3000 },
{ pct: 50, label: '✂️ Slicing into clips…', dur: 2500 },
{ pct: 75, label: '🎨 Applying safety filters…', dur: 2000 },
{ pct: 90, label: '💾 Saving clips to library…', dur: 1200 },
{ pct: 99, label: '📦 Cleaning up temp files…', dur: 600 },
];
startProgress('clip-fill', 'clip-pct', 'clip-step', steps);
try {
const fd = new FormData(form);
const res = await fetch('/api/clip', { method: 'POST', body: fd });
const data = await res.json();
$('clip-fill').style.width = '100%';
$('clip-pct').textContent = '100%';
$('clip-step').textContent = '✅ Done!';
if (data.success) {
showToast(`✅ ${data.message}`, 'success', 5000);
state.statsClips += data.filenames?.length || 0;
animateCount($('stat-clips'), state.statsClips);
await loadGallery();
} else {
showToast(`❌ ${data.error}`, 'error', 7000);
}
} catch (err) {
showToast('❌ Network error — check server logs', 'error');
} finally {
btn.disabled = false; btn.classList.remove('loading');
setTimeout(() => hideProgress('clip-progress'), 2000);
}
});
}
function initAIVideoForm() {
const form = $('ai-video-form');
if (!form) return;
form.addEventListener('submit', async e => {
e.preventDefault();
const scriptText = $('ai_script_text').value.trim();
if (!scriptText) {
showToast('⚠️ Please enter a script', 'error');
return;
}
const fd = new FormData(form);
// Sync slider values to form data
const rateV = parseInt($('ai-rate-slider')?.value || 0);
const pitchV = parseInt($('ai-pitch-slider')?.value || 0);
fd.set('rate', rateV >= 0 ? `+${rateV}%` : `${rateV}%`);
fd.set('pitch', pitchV >= 0 ? `+${pitchV}Hz` : `${pitchV}Hz`);
fd.set('style', state.aiCurrentStyle || '');
const btn = $('ai-submit-btn');
btn.disabled = true;
btn.classList.add('loading');
showProgress('ai-progress');
const steps = [
{ pct: 15, label: '📝 Parsing script & sentences…', dur: 1200 },
{ pct: 35, label: '🎙️ Generating narration speech…', dur: 3500 },
{ pct: 60, label: '🖼️ Downloading stock imagery…', dur: 4500 },
{ pct: 85, label: '🪄 Rendering dynamic slides…', dur: 4000 },
{ pct: 98, label: '📦 Mixing & assembling video…', dur: 2000 },
];
startProgress('ai-fill', 'ai-pct', 'ai-step', steps);
try {
const res = await fetch('/api/generate-video', { method: 'POST', body: fd });
const data = await res.json();
$('ai-fill').style.width = '100%';
$('ai-pct').textContent = '100%';
$('ai-step').textContent = '✅ Done!';
if (data.success) {
showToast(`✅ ${data.message} (${data.slides} slides)`, 'success', 5000);
await loadGallery();
} else {
showToast(`❌ ${data.error}`, 'error', 7000);
}
} catch (err) {
showToast('❌ Network error — check server logs', 'error');
} finally {
btn.disabled = false;
btn.classList.remove('loading');
setTimeout(() => hideProgress('ai-progress'), 2000);
}
});
}
// ─────────────────────────────────────────────────────────────────
// INIT
// ─────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
// Add age to state
state.currentAge = 'adult';
state.aiCurrentAge = 'adult';
// 1. Initialize synchronous UI components immediately so they are clickable
initTabs();
initAudioToggle();
initDropzone();
initSliders('tts');
initSliders('ai');
initMergeForm();
initClipForm();
initAIVideoForm();
// Video modal
$('modal-close')?.addEventListener('click', closeVideoModal);
$('modal-close-btn')?.addEventListener('click', closeVideoModal);
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeVideoModal(); });
// Ratio card visual selection
document.querySelectorAll('.ratio-card input[type=radio]').forEach(radio => {
radio.addEventListener('change', () => {
document.querySelectorAll('.ratio-card').forEach(c => c.classList.remove('selected'));
radio.closest('.ratio-card').classList.add('selected');
});
if (radio.checked) radio.closest('.ratio-card').classList.add('selected');
});
// Populate AI language selector from TTS language selector
const aiLang = $('ai-language');
if (aiLang && $('tts-language')) {
aiLang.innerHTML = $('tts-language').innerHTML;
}
// 2. Load async data without blocking UI click handlers
(async () => {
try {
await loadVoices();
buildAgeGrid('tts');
buildAgeGrid('ai');
} catch (err) {
console.error("Error loading voices:", err);
}
try {
await loadGallery();
} catch (err) {
console.error("Error loading gallery:", err);
}
// Voice interactions (TTS Panel)
$('tts-language')?.addEventListener('change', e => {
state.currentLang = e.target.value;
state.currentStyle = '';
state.currentAge = 'adult';
$('tts-style').value = '';
updateVoiceDropdown(e.target.value, 'tts');
buildAgeGrid('tts');
});
$('tts-voice')?.addEventListener('change', () => {
state.currentStyle = '';
$('tts-style').value = '';
updateMoodGrid('tts');
});
$('preview-voice-btn')?.addEventListener('click', () => previewVoice('tts'));
// Voice interactions (AI Panel)
$('ai-language')?.addEventListener('change', e => {
state.aiCurrentLang = e.target.value;
state.aiCurrentStyle = '';
state.aiCurrentAge = 'adult';
$('ai-tts-style').value = '';
updateVoiceDropdown(e.target.value, 'ai');
buildAgeGrid('ai');
});
$('ai-voice')?.addEventListener('change', () => {
state.aiCurrentStyle = '';
$('ai-tts-style').value = '';
updateMoodGrid('ai');
});
$('ai-preview-voice-btn')?.addEventListener('click', () => previewVoice('ai'));
// Library
$('refresh-gallery')?.addEventListener('click', loadGallery);
$('clear-library-btn')?.addEventListener('click', clearLibrary);
$('library-search')?.addEventListener('input', loadGallery);
// Auto-refresh library every 30s
setInterval(loadGallery, 30000);
})();
});