const API_BASE = window.location.protocol === 'https:' ? 'https://tracking.sctvonline.vn/catchup/api' : 'http://tracking.sctvonline.vn/catchup/api'; const TS_BASE = '/hls/catchup'; // ── State ── let programs = []; let selectedProgram = null; let queue = []; let sliderState = { min: 0, max: 0, start: 0, end: 0, dragging: null }; let generatedM3u8Url = ''; let currentSegments = []; // ── DOM ── const $ = id => document.getElementById(id); const channelSelect = $('channelSelect'); const dateInput = $('dateInput'); const programList = $('programList'); const trimPanel = $('trimPanel'); const trimChannel = $('trimChannel'); const trimTitle = $('trimTitle'); const sliderTrack = $('sliderTrack'); const sliderRange = $('sliderRange'); const handleStart = $('handleStart'); const handleEnd = $('handleEnd'); const labelMin = $('labelMin'); const labelMax = $('labelMax'); const startTimeInput = $('startTimeInput'); const endTimeInput = $('endTimeInput'); const durationLabel = $('durationLabel'); const btnAddSegment = $('btnAddSegment'); const queueList = $('queueList'); const btnGenerate = $('btnGenerate'); const outputSection = $('outputSection'); const m3u8Output = $('m3u8Output'); const btnCopy = $('btnCopy'); const btnDownload = $('btnDownload'); const btnPlayM3U8 = $('btnPlayM3U8'); const toast = $('toast'); const videoPlayer = $('videoPlayer'); const modalOverlay = $('modalOverlay'); const modalClose = $('modalClose'); const modalVideo = $('modalVideo'); const modalChannel = $('modalChannel'); const modalTitleEl = $('modalTitle'); const modalMeta = $('modalMeta'); let modalHls = null; // ── Init ── function init() { btnPlayM3U8.style.display = 'none'; dateInput.value = new Date().toISOString().split('T')[0]; loadChannels(); loadQueue(); channelSelect.addEventListener('change', () => { document.body.classList.remove('hide-scrollbars'); const chName = (channelSelect.options[channelSelect.selectedIndex]?.text || '').toLowerCase().trim(); if (chName) { trimChannel.innerHTML = chName.toUpperCase(); } loadPrograms(); }); dateInput.addEventListener('change', loadPrograms); btnAddSegment.addEventListener('click', addToQueue); btnGenerate.addEventListener('click', generateM3U8); btnCopy.addEventListener('click', copyM3U8); btnDownload.addEventListener('click', downloadM3U8); btnPlayM3U8.addEventListener('click', playGeneratedM3U8); startTimeInput.addEventListener('change', onTimeInputChange); endTimeInput.addEventListener('change', onTimeInputChange); modalClose.addEventListener('click', closeModal); modalOverlay.addEventListener('click', e => { if (e.target === modalOverlay) closeModal(); }); setupSlider(); } // ── Modal ── function openModal(channelName, title, meta, blobUrl) { modalChannel.textContent = channelName.toUpperCase(); modalTitleEl.textContent = title; modalMeta.textContent = meta; modalOverlay.classList.add('active'); if (Hls.isSupported()) { if (modalHls) modalHls.destroy(); modalHls = new Hls(); modalHls.loadSource(blobUrl); modalHls.attachMedia(modalVideo); modalHls.on(Hls.Events.MANIFEST_PARSED, () => { modalVideo.currentTime = 0; modalVideo.play().catch(() => {}); }); } else if (modalVideo.canPlayType('application/vnd.apple.mpegurl')) { modalVideo.src = blobUrl; modalVideo.load(); modalVideo.addEventListener('loadedmetadata', () => { modalVideo.currentTime = 0; modalVideo.play().catch(() => {}); }, { once: true }); } } function closeModal() { modalOverlay.classList.remove('active'); if (modalHls) { modalHls.destroy(); modalHls = null; } modalVideo.pause(); modalVideo.removeAttribute('src'); } function buildBlobM3U8(segments) { let maxDur = 10; segments.forEach(s => { if (Math.ceil(s.duration) > maxDur) maxDur = Math.ceil(s.duration); }); let c = '#EXTM3U\n#EXT-X-VERSION:3\n'; c += `#EXT-X-TARGETDURATION:${maxDur}\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:VOD\n`; segments.forEach(s => { c += `#EXTINF:${s.duration.toFixed(6)},\n${s.url}\n`; }); c += '#EXT-X-ENDLIST\n'; return URL.createObjectURL(new Blob([c], { type: 'application/x-mpegurl' })); } function buildCombinedBlobM3U8(items) { let maxDur = 10, body = '', first = true; items.forEach(item => { if (!item.segments || !item.segments.length) return; if (!first) body += '\n#EXT-X-DISCONTINUITY\n'; first = false; item.segments.forEach(s => { if (Math.ceil(s.duration) > maxDur) maxDur = Math.ceil(s.duration); body += `#EXTINF:${s.duration.toFixed(6)},\n${s.url}\n`; }); }); let c = '#EXTM3U\n#EXT-X-VERSION:3\n'; c += `#EXT-X-TARGETDURATION:${maxDur}\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:VOD\n`; c += body + '#EXT-X-ENDLIST\n'; return URL.createObjectURL(new Blob([c], { type: 'application/x-mpegurl' })); } // ── API ── async function loadChannels() { try { const res = await fetch(`${API_BASE}/channels.php`); const data = await res.json(); channelSelect.innerHTML = ''; data.forEach(ch => { const opt = document.createElement('option'); opt.value = ch.id; opt.textContent = ch.name; channelSelect.appendChild(opt); }); } catch (e) { showToast('Không thể tải danh sách kênh'); } } async function loadPrograms() { const channelId = channelSelect.value; const date = dateInput.value; if (!channelId || !date) return; programList.innerHTML = '
Đang tải...
'; try { const res = await fetch(`${API_BASE}/programs.php?channel_id=${channelId}&date=${date}`); programs = await res.json(); renderPrograms(); } catch (e) { programList.innerHTML = '
Tải thất bại
'; } } function renderPrograms() { if (!programs.length) { programList.innerHTML = '
Không tìm thấy chương trình
'; return; } programList.innerHTML = ''; programs.forEach(p => { const div = document.createElement('div'); div.className = 'program-item'; div.innerHTML = `
${escHtml(p.title)}
${formatDT(p.SCHEDULE_BEGINDATE)} — ${formatDT(p.SCHEDULE_ENDDATE)}
`; div.addEventListener('click', () => selectProgram(p, div)); programList.appendChild(div); }); } // ── Select Program ── function selectProgram(p, el) { document.querySelectorAll('.program-item').forEach(e => e.classList.remove('active')); el.classList.add('active'); selectedProgram = p; document.body.classList.remove('hide-scrollbars'); const chName = (channelSelect.options[channelSelect.selectedIndex]?.text || '').toLowerCase().trim(); trimPanel.style.display = ''; trimChannel.innerHTML = chName.toUpperCase(); trimTitle.textContent = p.title; sliderState.min = p.begin_unix; sliderState.max = p.end_unix; sliderState.start = p.begin_unix; sliderState.end = p.end_unix; labelMin.textContent = unixToHMS(p.begin_unix); labelMax.textContent = unixToHMS(p.end_unix); updateSliderUI(); updateTimeInputs(); const m3u8Url = `http://192.168.16.221/hls/catchup/${chName}/${p.begin_unix}_${p.end_unix}.m3u8`; const linkEl = $('programM3u8Link'); linkEl.href = m3u8Url; linkEl.textContent = m3u8Url; updateVideoPlayer(); fetchAndParseM3U8(m3u8Url, chName); } // ── Slider ── function setupSlider() { const onDown = (handle, which) => e => { e.preventDefault(); sliderState.dragging = which; handle.style.cursor = 'grabbing'; const onMove = ev => { const clientX = ev.touches ? ev.touches[0].clientX : ev.clientX; const rect = sliderTrack.getBoundingClientRect(); let pct = (clientX - rect.left) / rect.width; pct = Math.max(0, Math.min(1, pct)); const val = Math.round(sliderState.min + pct * (sliderState.max - sliderState.min)); if (which === 'start') { sliderState.start = Math.min(val, sliderState.end - 1); } else { sliderState.end = Math.max(val, sliderState.start + 1); } updateSliderUI(); updateTimeInputs(); }; const onUp = () => { sliderState.dragging = null; handle.style.cursor = 'grab'; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', onUp); updateVideoPlayer(which === 'start' ? sliderState.start : sliderState.end); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); document.addEventListener('touchmove', onMove, { passive: false }); document.addEventListener('touchend', onUp); }; handleStart.addEventListener('mousedown', onDown(handleStart, 'start')); handleStart.addEventListener('touchstart', onDown(handleStart, 'start'), { passive: false }); handleEnd.addEventListener('mousedown', onDown(handleEnd, 'end')); handleEnd.addEventListener('touchstart', onDown(handleEnd, 'end'), { passive: false }); } function updateSliderUI() { const range = sliderState.max - sliderState.min || 1; const startPct = ((sliderState.start - sliderState.min) / range) * 100; const endPct = ((sliderState.end - sliderState.min) / range) * 100; handleStart.style.left = startPct + '%'; handleEnd.style.left = endPct + '%'; sliderRange.style.left = startPct + '%'; sliderRange.style.width = (endPct - startPct) + '%'; } function updateTimeInputs() { startTimeInput.value = unixToHMS(sliderState.start); endTimeInput.value = unixToHMS(sliderState.end); const dur = sliderState.end - sliderState.start; const m = Math.floor(dur / 60); const s = dur % 60; durationLabel.textContent = `${pad(m)}:${pad(s)}`; } function onTimeInputChange() { if (!selectedProgram) return; const startParsed = parseHMS(startTimeInput.value, sliderState.min); const endParsed = parseHMS(endTimeInput.value, sliderState.min); if (startParsed !== null) { sliderState.start = Math.max(sliderState.min, Math.min(startParsed, sliderState.end - 1)); } if (endParsed !== null) { sliderState.end = Math.min(sliderState.max, Math.max(endParsed, sliderState.start + 1)); } updateSliderUI(); updateTimeInputs(); updateVideoPlayer(); } function parseHMS(str, baseUnix) { const parts = str.split(':').map(Number); if (parts.length < 2 || parts.some(isNaN)) return null; const h = parts[0], m = parts[1], s = parts[2] || 0; // Build unix from same day const d = new Date(baseUnix * 1000); d.setHours(h, m, s, 0); return Math.floor(d.getTime() / 1000); } let hls = null; function updateVideoPlayer(seekTs) { if (!selectedProgram) return; const chName = (channelSelect.options[channelSelect.selectedIndex]?.text || '').toLowerCase().trim(); const beginUnix = selectedProgram.begin_unix; const endUnix = selectedProgram.end_unix; const m3u8Url = `${TS_BASE}/${chName}/${beginUnix}_${endUnix}.m3u8`; const seekOffset = (seekTs || sliderState.start) - beginUnix; if (Hls.isSupported()) { if (hls) hls.destroy(); hls = new Hls(); hls.loadSource(m3u8Url); hls.attachMedia(videoPlayer); hls.on(Hls.Events.MANIFEST_PARSED, () => { videoPlayer.currentTime = Math.max(0, seekOffset); videoPlayer.play().catch(() => { }); }); } else if (videoPlayer.canPlayType('application/vnd.apple.mpegurl')) { videoPlayer.src = m3u8Url; videoPlayer.addEventListener('loadedmetadata', () => { videoPlayer.currentTime = Math.max(0, seekOffset); videoPlayer.play().catch(() => { }); }, { once: true }); } } // ── Queue ── async function fetchAndParseM3U8(url, chName) { currentSegments = []; try { const res = await fetch(url); if (!res.ok) throw new Error('Failed to fetch'); const text = await res.text(); const lines = text.split('\n'); const segments = []; let currentDuration = 10.0; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (line.startsWith('#EXTINF:')) { currentDuration = parseFloat(line.substring(8)); } else if (line.endsWith('.ts')) { const match = line.match(/\/(\d+)\.ts$/) || line.match(/(\d+)\.ts$/); if (match) { const ms = parseInt(match[1]); let segUrl = line; if (!line.startsWith('http://') && !line.startsWith('https://')) { const cleanLine = line.startsWith('/') ? line.substring(1) : line; segUrl = `http://192.168.16.221/hls/catchup/${chName}/${cleanLine}`; } segments.push({ url: segUrl, duration: currentDuration, timestamp: Math.floor(ms / 1000) }); } } } currentSegments = segments; } catch (e) { console.error('Error fetching/parsing original m3u8:', e); } } function saveQueue() { localStorage.setItem('catchup_queue', JSON.stringify(queue)); } function loadQueue() { try { const stored = localStorage.getItem('catchup_queue'); if (stored) { queue = JSON.parse(stored); renderQueue(); } } catch (e) { console.error(e); } } function addToQueue() { if (!selectedProgram) return; const chName = channelSelect.options[channelSelect.selectedIndex]?.text || ''; const matchedSegments = currentSegments.filter(seg => seg.timestamp >= sliderState.start && seg.timestamp < sliderState.end); if (matchedSegments.length === 0) { const chLower = chName.toLowerCase().trim(); for (let ts = sliderState.start; ts < sliderState.end; ts += 10) { const ms = ts * 1000; const dateObj = new Date(ts * 1000); const dayStr = String(dateObj.getDate()).padStart(2, '0'); matchedSegments.push({ url: `http://192.168.16.221/hls/catchup/${chLower}/ts/${dayStr}/${ms}.ts`, duration: 10.0, timestamp: ts }); } } queue.push({ channel_id: selectedProgram.CHANNEL_ID, channel_name: chName, title: selectedProgram.title, start_unix: sliderState.start, end_unix: sliderState.end, segments: matchedSegments }); saveQueue(); renderQueue(); showToast('Đã thêm phân đoạn'); } function removeFromQueue(idx) { queue.splice(idx, 1); saveQueue(); renderQueue(); } function renderQueue() { if (!queue.length) { queueList.innerHTML = '
Chưa có phân đoạn nào
'; btnGenerate.disabled = true; return; } btnGenerate.disabled = false; queueList.innerHTML = ''; queue.forEach((seg, i) => { const div = document.createElement('div'); div.className = 'queue-item'; div.draggable = true; div.dataset.idx = i; div.innerHTML = ` ${escHtml(seg.channel_name)}
${escHtml(seg.title)}
${unixToHMS(seg.start_unix)} → ${unixToHMS(seg.end_unix)} (${formatDuration(seg.end_unix - seg.start_unix)})
`; div.querySelector('.btn-danger-sm').addEventListener('click', (e) => { e.stopPropagation(); removeFromQueue(i); }); div.querySelector('.qi-info').addEventListener('click', () => { const item = queue[i]; if (!item || !item.segments || !item.segments.length) return; const blobUrl = buildBlobM3U8(item.segments); const meta = `${unixToHMS(item.start_unix)} → ${unixToHMS(item.end_unix)} · ${formatDuration(item.end_unix - item.start_unix)}`; openModal(item.channel_name, item.title, meta, blobUrl); }); div.addEventListener('dragstart', e => { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', i); div.classList.add('dragging'); }); div.addEventListener('dragend', () => div.classList.remove('dragging')); div.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; div.classList.add('drag-over'); }); div.addEventListener('dragleave', () => div.classList.remove('drag-over')); div.addEventListener('drop', e => { e.preventDefault(); div.classList.remove('drag-over'); const from = parseInt(e.dataTransfer.getData('text/plain')); const to = parseInt(div.dataset.idx); if (from !== to) { const [item] = queue.splice(from, 1); queue.splice(to, 0, item); saveQueue(); renderQueue(); } }); queueList.appendChild(div); }); } // ── Generate ── async function generateM3U8() { if (!queue.length) return; try { const res = await fetch(`${API_BASE}/generate.php`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ segments: queue }), }); const data = await res.json(); if (data.success) { m3u8Output.textContent = data.command; generatedM3u8Url = buildCombinedBlobM3U8(queue); btnPlayM3U8.style.display = ''; outputSection.style.display = ''; showToast('Đã tạo lệnh thành công!'); } else { showToast('Lỗi: ' + (data.error || 'Không xác định')); } } catch (e) { showToast('Lỗi kết nối'); } } function playGeneratedM3U8() { if (!generatedM3u8Url) return; const titles = queue.map(q => q.title).join(' + '); const chNames = [...new Set(queue.map(q => q.channel_name))].join(', '); const totalDur = queue.reduce((s, q) => s + (q.end_unix - q.start_unix), 0); openModal(chNames, titles, `Tổng: ${formatDuration(totalDur)}`, generatedM3u8Url); } function copyM3U8() { const text = m3u8Output.textContent; const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); showToast('Đã sao chép!'); } catch (e) { showToast('Sao chép thất bại'); } document.body.removeChild(ta); } function downloadM3U8() { const blob = new Blob([m3u8Output.textContent], { type: 'application/x-mpegurl' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'catchup_' + Date.now() + '.m3u8'; a.click(); URL.revokeObjectURL(url); showToast('Đã tải xuống!'); } // ── Helpers ── function unixToHMS(ts) { const d = new Date(ts * 1000); return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } function pad(n) { return String(n).padStart(2, '0'); } function formatDT(dt) { return dt.split(' ')[1]?.substring(0, 5) || dt; } function formatDuration(sec) { const m = Math.floor(sec / 60); const s = sec % 60; return `${m}m${s ? pad(s) + 's' : ''}`; } function escHtml(s) { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function showToast(msg) { toast.textContent = msg; toast.classList.add('show'); clearTimeout(toast._t); toast._t = setTimeout(() => toast.classList.remove('show'), 2500); } // ── Boot ── init();