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();