206 lines
7.3 KiB
JavaScript
206 lines
7.3 KiB
JavaScript
|
|
// Escape EPG-provided strings before injecting them into HTML.
|
|
function esc(str) {
|
|
const d = document.createElement('div');
|
|
d.textContent = str || '';
|
|
return d.innerHTML;
|
|
}
|
|
|
|
let channels = [];
|
|
let programs = {};
|
|
let m3uStreams = {}; // Normalized slug -> stream URL.
|
|
let scrollListenerAdded = false; // Keeps the channel column vertically synced with the timeline.
|
|
let availableCategories = [];
|
|
|
|
function pad(n) { return String(n).padStart(2,'0'); }
|
|
|
|
function copyUrl(type) {
|
|
const url = type === 'epg' ? currentEpgUrl : currentM3uUrl;
|
|
if (!url) return;
|
|
navigator.clipboard.writeText(url).then(() => {
|
|
const btns = document.querySelectorAll('.copy-btn');
|
|
btns.forEach(b => { if (b.textContent.toLowerCase() === type) {
|
|
b.textContent = L.copied;
|
|
b.classList.add('copied');
|
|
setTimeout(() => { b.textContent = type.toUpperCase(); b.classList.remove('copied'); }, 1800);
|
|
}});
|
|
}).catch(() => {
|
|
prompt(url, url);
|
|
});
|
|
}
|
|
function fmtTime(d) { return `${pad(d.getHours())}:${pad(d.getMinutes())}`; }
|
|
function fmtTimeFull(d) { return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; }
|
|
function isMobile() { return window.innerWidth <= 600; }
|
|
|
|
function parseXMLTVDate(str) {
|
|
if (!str) return null;
|
|
const m = str.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{4})?/);
|
|
if (!m) return null;
|
|
// XMLTV timestamps use a compact timezone suffix like +0200, so we rebuild
|
|
// the UTC instant manually before letting Date convert it to local time.
|
|
const offset = m[7] ? parseInt(m[7]) : 0;
|
|
const offsetMs = (Math.floor(Math.abs(offset)/100)*60 + (Math.abs(offset)%100)) * 60000 * (offset<0?-1:1);
|
|
return new Date(Date.UTC(+m[1],+m[2]-1,+m[3],+m[4],+m[5],+m[6]) - offsetMs);
|
|
}
|
|
|
|
function updateClock() {
|
|
const now = new Date();
|
|
document.getElementById('clock').textContent = fmtTimeFull(now);
|
|
document.getElementById('dateLabel').textContent =
|
|
`${L.days[now.getDay()]} ${now.getDate()} ${L.months[now.getMonth()]} ${now.getFullYear()}`;
|
|
}
|
|
setInterval(updateClock, 1000);
|
|
updateClock();
|
|
|
|
async function fetchXML(url) {
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
return new DOMParser().parseFromString(await res.text(), 'text/xml');
|
|
}
|
|
|
|
async function loadEPG(url) {
|
|
document.getElementById('loading').style.display = 'flex';
|
|
document.getElementById('errorMsg').style.display = 'none';
|
|
try {
|
|
let doc;
|
|
try { doc = await fetchXML(url); }
|
|
catch(e) { doc = await fetchXML(CORS_PROXY + encodeURIComponent(url)); }
|
|
parseAndRender(doc);
|
|
document.getElementById('loading').style.display = 'none';
|
|
} catch(e) {
|
|
document.getElementById('loading').style.display = 'none';
|
|
document.getElementById('errorMsg').style.display = 'flex';
|
|
document.getElementById('errorDetail').textContent = `${L.epg_error_sub} (${e.message})`;
|
|
document.getElementById('epgInput').value = url;
|
|
}
|
|
}
|
|
|
|
function loadFromInput() {
|
|
const url = document.getElementById('epgInput').value.trim();
|
|
if (url) loadEPG(url);
|
|
}
|
|
|
|
function parseEpisode(p) {
|
|
// xmltv_ns format: "S.E.part" (0-indexed), e.g. "0.2.0/1" -> S01E03
|
|
const ns = p.querySelector('episode-num[system="xmltv_ns"]')?.textContent;
|
|
if (ns) {
|
|
const parts = ns.split('.');
|
|
const s = parts[0] !== undefined && parts[0].trim() !== '' ? parseInt(parts[0].trim()) + 1 : null;
|
|
const ePart = parts[1] !== undefined ? parts[1].trim().split('/')[0] : '';
|
|
const e = ePart !== '' ? parseInt(ePart) + 1 : null;
|
|
if (s !== null || e !== null) return { season: s, episode: e };
|
|
}
|
|
// onscreen format: "S01E03" or "s1e3"
|
|
const os = p.querySelector('episode-num[system="onscreen"]')?.textContent;
|
|
if (os) {
|
|
const m = os.match(/[Ss](\d+)[Ee](\d+)/);
|
|
if (m) return { season: parseInt(m[1]), episode: parseInt(m[2]) };
|
|
const m2 = os.match(/[Ee][Pp]?\s*(\d+)/i);
|
|
if (m2) return { season: null, episode: parseInt(m2[1]) };
|
|
}
|
|
return { season: null, episode: null };
|
|
}
|
|
|
|
function fmtEpisode(season, episode) {
|
|
if (season !== null && episode !== null) return `S${String(season).padStart(2,'0')}E${String(episode).padStart(2,'0')}`;
|
|
if (episode !== null) return `E${String(episode).padStart(2,'0')}`;
|
|
return null;
|
|
}
|
|
|
|
function parseCategories(p) {
|
|
const seen = new Set();
|
|
const categories = [];
|
|
p.querySelectorAll('category').forEach(cat => {
|
|
const value = (cat.textContent || '').trim();
|
|
const key = value.toLowerCase();
|
|
if (!value || seen.has(key)) return;
|
|
seen.add(key);
|
|
categories.push(value);
|
|
});
|
|
return categories;
|
|
}
|
|
|
|
function formatCategories(categories) {
|
|
return Array.isArray(categories) && categories.length ? categories.join(' · ') : '';
|
|
}
|
|
|
|
function normalizeCategory(value) {
|
|
return String(value || '').trim().toLowerCase();
|
|
}
|
|
|
|
function collectAvailableCategories() {
|
|
const seen = new Set();
|
|
const list = [];
|
|
Object.values(programs).forEach(items => {
|
|
items.forEach(program => {
|
|
(program.categories || []).forEach(category => {
|
|
const label = String(category || '').trim();
|
|
const key = normalizeCategory(label);
|
|
if (!label || seen.has(key)) return;
|
|
seen.add(key);
|
|
list.push(label);
|
|
});
|
|
});
|
|
});
|
|
availableCategories = list.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
|
}
|
|
|
|
function parseAirDate(value) {
|
|
if (!value) return null;
|
|
const match = String(value).trim().match(/^(\d{4})/);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
function parseRatingValue(p) {
|
|
const rating = p.querySelector('rating > value')?.textContent || '';
|
|
return rating.trim() || null;
|
|
}
|
|
|
|
function parseAndRender(doc) {
|
|
channels = []; programs = {};
|
|
doc.querySelectorAll('channel').forEach(ch => {
|
|
const id = ch.getAttribute('id');
|
|
const name = ch.querySelector('display-name')?.textContent || id;
|
|
const icon = ch.querySelector('icon')?.getAttribute('src') || null;
|
|
const num = parseInt(name.match(/^(\d+)/)?.[1] ?? '9999');
|
|
channels.push({ id, name, icon, num });
|
|
programs[id] = [];
|
|
});
|
|
channels.sort((a,b) => a.num - b.num);
|
|
|
|
doc.querySelectorAll('programme').forEach(p => {
|
|
const chId = p.getAttribute('channel');
|
|
if (!programs[chId]) programs[chId] = [];
|
|
const start = parseXMLTVDate(p.getAttribute('start'));
|
|
const stop = parseXMLTVDate(p.getAttribute('stop'));
|
|
const title = p.querySelector('title')?.textContent || '';
|
|
const subtitle = p.querySelector('sub-title')?.textContent || '';
|
|
const desc = p.querySelector('desc')?.textContent || '';
|
|
const categories = parseCategories(p);
|
|
const airDate = parseAirDate(p.querySelector('date')?.textContent || '');
|
|
const rating = parseRatingValue(p);
|
|
const epInfo = parseEpisode(p);
|
|
if (start && stop) programs[chId].push({ start, stop, title, subtitle, desc, categories, airDate, rating, ...epInfo });
|
|
});
|
|
Object.keys(programs).forEach(id => programs[id].sort((a,b) => a.start - b.start));
|
|
|
|
collectAvailableCategories();
|
|
if (typeof refreshCategoryFilterOptions === 'function') refreshCategoryFilterOptions();
|
|
renderAll();
|
|
if (typeof refreshReminderSchedules === 'function') refreshReminderSchedules();
|
|
startLiveUpdates();
|
|
}
|
|
|
|
function renderUnfiltered() {
|
|
if (isMobile()) { renderMobile(); }
|
|
else { renderGrid(); setTimeout(centerNow, 150); }
|
|
}
|
|
|
|
function renderAll() {
|
|
if (typeof applyFilters === 'function') {
|
|
applyFilters();
|
|
return;
|
|
}
|
|
renderUnfiltered();
|
|
}
|