From 0be8c34b3112929d102e923505ccd237f46af7bf Mon Sep 17 00:00:00 2001 From: Johnnybegood90 Date: Wed, 11 Mar 2026 23:47:22 +0100 Subject: [PATCH 1/6] refactor: split monolithic index.php into src/tpl + src/js modules --- index.php | 1523 +------------------------------------------- src/config.php | 39 ++ src/js/config.js | 20 + src/js/epg.js | 125 ++++ src/js/live.js | 35 + src/js/m3u.js | 57 ++ src/js/mobile.js | 57 ++ src/js/player.js | 58 ++ src/js/sources.js | 59 ++ src/js/themes.js | 20 + src/js/tooltip.js | 27 + src/js/utils.js | 134 ++++ src/tpl/footer.php | 21 + src/tpl/grid.php | 30 + src/tpl/head.php | 764 ++++++++++++++++++++++ src/tpl/modals.php | 22 + src/tpl/topbar.php | 52 ++ 17 files changed, 1526 insertions(+), 1517 deletions(-) create mode 100644 src/config.php create mode 100644 src/js/config.js create mode 100644 src/js/epg.js create mode 100644 src/js/live.js create mode 100644 src/js/m3u.js create mode 100644 src/js/mobile.js create mode 100644 src/js/player.js create mode 100644 src/js/sources.js create mode 100644 src/js/themes.js create mode 100644 src/js/tooltip.js create mode 100644 src/js/utils.js create mode 100644 src/tpl/footer.php create mode 100644 src/tpl/grid.php create mode 100644 src/tpl/head.php create mode 100644 src/tpl/modals.php create mode 100644 src/tpl/topbar.php diff --git a/index.php b/index.php index 2f1ab46..d94472c 100644 --- a/index.php +++ b/index.php @@ -1,1519 +1,8 @@ 'Main', - 'epg_url' => $config['epg_url'], - 'm3u_url' => $config['m3u_url'] ?? '', - ]]; - $config['allow_personal_epg'] = false; - unset($config['epg_url'], $config['m3u_url']); - file_put_contents($config_path, json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); -} -// ───────────────────────────────────────────────────────────────────────────── - -$group_name = htmlspecialchars($config['group_name'] ?? 'GridTV'); -$epg_sources = $config['epg_sources'] ?? []; -$allow_personal_epg = !empty($config['allow_personal_epg']); - -// Première source par défaut (pour compatibilité avec le reste du code) -$first_source = $epg_sources[0] ?? []; -$epg_url = htmlspecialchars($first_source['epg_url'] ?? ''); -$m3u_url = htmlspecialchars($first_source['m3u_url'] ?? ''); +require_once __DIR__ . '/src/config.php'; ?> - - - - - -GridTV — <?= $group_name ?> - - - - - - - -
-
Chargement du guide
-
-
- -
-
⚠ Impossible de charger l'EPG
-
Vérifiez l'URL de votre flux XMLTV.
-
- - -
-
- -
-
-
-
-
- -
- -
-
00:00:00
- -
- - - - -
- 1 || $allow_personal_epg): ?> - - - -
LIVE
-
- - -
-
-
-
- -
- -
⚠ Stream indisponible
-
- - -
-
-
Chaînes
-
-
-
-
-
-
-
-
-
-
-
- - -
- - - - - - - - + + + + + diff --git a/src/config.php b/src/config.php new file mode 100644 index 0000000..da6c8d2 --- /dev/null +++ b/src/config.php @@ -0,0 +1,39 @@ + 'Main', + 'epg_url' => $config['epg_url'], + 'm3u_url' => $config['m3u_url'] ?? '', + ]]; + $config['allow_personal_epg'] = false; + unset($config['epg_url'], $config['m3u_url']); + file_put_contents($config_path, json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); +} +// ───────────────────────────────────────────────────────────────────────────── + +$group_name = htmlspecialchars($config['group_name'] ?? 'GridTV'); +$epg_sources = $config['epg_sources'] ?? []; +$allow_personal_epg = !empty($config['allow_personal_epg']); + +// Première source par défaut (pour compatibilité avec le reste du code) +$first_source = $epg_sources[0] ?? []; +$epg_url = htmlspecialchars($first_source['epg_url'] ?? ''); +$m3u_url = htmlspecialchars($first_source['m3u_url'] ?? ''); +?> + + + + + +GridTV — <?= $group_name ?> diff --git a/src/js/config.js b/src/js/config.js new file mode 100644 index 0000000..313521c --- /dev/null +++ b/src/js/config.js @@ -0,0 +1,20 @@ +const EPG_SOURCES = ; +const ALLOW_PERSONAL_EPG = ; + +// Source active (index) +let activeSourceIndex = 0; + +const DEFAULT_EPG_URL = EPG_SOURCES[0]?.epg_url ?? ''; +const DEFAULT_M3U_URL = EPG_SOURCES[0]?.m3u_url ?? ''; +const PX_PER_MIN = 5; +const GRID_HOURS = 72; +const CORS_PROXY = 'https://corsproxy.io/?'; + +// Ancre : 2h avant maintenant, arrondi au quart d'heure inférieur +const _d = new Date(); +_d.setMinutes(Math.floor(_d.getMinutes()/15)*15, 0, 0); +const GRID_START = new Date(_d.getTime() - 2*3600000); +const GRID_END = new Date(GRID_START.getTime() + GRID_HOURS*3600000); +const TOTAL_WIDTH_PX = GRID_HOURS * 60 * PX_PER_MIN; + +function msToX(ms) { return (ms - GRID_START.getTime()) / 60000 * PX_PER_MIN; } diff --git a/src/js/epg.js b/src/js/epg.js new file mode 100644 index 0000000..333c858 --- /dev/null +++ b/src/js/epg.js @@ -0,0 +1,125 @@ +// ── GRID ────────────────────────────────────────────────────────────────────── +function renderGrid() { + renderChannels(); renderRuler(); renderPrograms(); positionNowLine(); +} + +function makePlaceholder(name) { + const el = document.createElement('div'); + el.className = 'channel-logo-placeholder'; + el.textContent = name.substring(0,2).toUpperCase(); + return el; +} + +function renderChannels() { + const list = document.getElementById('channelsList'); + list.innerHTML = ''; + channels.forEach(ch => { + const cell = document.createElement('div'); + cell.className = 'channel-cell'; + if (ch.icon) { + const img = document.createElement('img'); + img.className = 'channel-logo'; img.src = ch.icon; + img.onerror = () => img.replaceWith(makePlaceholder(ch.name)); + cell.appendChild(img); + } else { cell.appendChild(makePlaceholder(ch.name)); } + const name = document.createElement('div'); + name.className = 'channel-name'; name.textContent = ch.name; name.title = ch.name; + cell.appendChild(name); + cell.addEventListener('click', () => openPip(ch.name, getNowPlaying(ch.id))); + list.appendChild(cell); + }); +} + +function renderRuler() { + const ruler = document.getElementById('timeRulerInner'); + ruler.innerHTML = ''; ruler.style.width = TOTAL_WIDTH_PX + 'px'; + document.getElementById('timeRuler').style.width = TOTAL_WIDTH_PX + 'px'; + let t = new Date(GRID_START); t.setSeconds(0,0); + const rem = t.getMinutes()%15; if (rem) t.setMinutes(t.getMinutes()+(15-rem)); + while (t <= GRID_END) { + const x = msToX(t.getTime()); const isHour = t.getMinutes()===0; + const tick = document.createElement('div'); tick.className='time-tick'; tick.style.left=x+'px'; + const lbl = document.createElement('div'); lbl.className='time-tick-label'+(isHour?' hour':''); lbl.textContent=fmtTime(t); + const line = document.createElement('div'); line.className='time-tick-line'+(isHour?' hour':''); + tick.appendChild(lbl); tick.appendChild(line); ruler.appendChild(tick); + t = new Date(t.getTime()+15*60000); + } +} + +function renderPrograms() { + const ROW_H = 80; + const inner = document.getElementById('programsInner'); + const totalH = channels.length * ROW_H; + inner.innerHTML = ''; + inner.style.width = TOTAL_WIDTH_PX+'px'; + inner.style.height = totalH+'px'; + + // Remettre la now-line (détruite par innerHTML='') + const nowLine = document.createElement('div'); + nowLine.className = 'now-line'; nowLine.id = 'nowLine'; + nowLine.style.height = totalH + 'px'; + inner.appendChild(nowLine); + + const now = new Date(); + + channels.forEach((ch, i) => { + const row = document.createElement('div'); + row.className = 'program-row'; + row.style.cssText = `top:${i*ROW_H}px;position:absolute;left:0;right:0;`; + + (programs[ch.id]||[]).filter(p => p.stop>GRID_START && p.start { + const x = Math.max(0, msToX(p.start.getTime())); + const w = Math.min(TOTAL_WIDTH_PX, msToX(p.stop.getTime())) - x; + if (w < 2) return; + const block = document.createElement('div'); block.className='program-block'; + if (p.stop < now) block.classList.add('is-past'); + if (p.start <= now && p.stop > now) { + block.classList.add('is-live'); + block.style.setProperty('--progress',(((now-p.start)/(p.stop-p.start))*100).toFixed(1)+'%'); + } + block.style.left=x+'px'; block.style.width=w+'px'; + if (w>40) { const t=document.createElement('div'); t.className='program-title'; t.textContent=p.title; block.appendChild(t); } + if (w>80) { + const ep = fmtEpisode(p.season, p.episode); + const t=document.createElement('div'); t.className='program-time'; + t.textContent = (ep ? ep + ' ' : '') + `${fmtTime(p.start)} — ${fmtTime(p.stop)}`; + block.appendChild(t); + } + block.addEventListener('mouseenter', e=>showTooltip(e,p)); + block.addEventListener('mousemove', e=>moveTooltip(e)); + block.addEventListener('mouseleave', hideTooltip); + if (p.start <= now && p.stop > now) { + block.addEventListener('click', () => openPip(ch.name, p.title)); + } + row.appendChild(block); + }); + inner.appendChild(row); + }); + + positionNowLine(); + if (!scrollListenerAdded) { + scrollListenerAdded = true; + document.getElementById('timelineArea').addEventListener('scroll', function() { + document.getElementById('channelsList').scrollTop = this.scrollTop; + }); + } +} + +function positionNowLine() { + const x = msToX(Date.now()); + const line = document.getElementById('nowLine'); + if (line) line.style.left = x + 'px'; +} + +function centerNow() { + const area = document.getElementById('timelineArea'); + area.scrollLeft = Math.max(0, msToX(Date.now()) - area.offsetWidth * 0.3); + document.querySelectorAll('.nav-btn').forEach((b,i) => b.classList.toggle('active', i===1)); +} + +function shiftView(mins) { + const area = document.getElementById('timelineArea'); + area.scrollLeft = Math.max(0, area.scrollLeft + mins * PX_PER_MIN); + document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active')); +} + diff --git a/src/js/live.js b/src/js/live.js new file mode 100644 index 0000000..9305225 --- /dev/null +++ b/src/js/live.js @@ -0,0 +1,35 @@ +// ── RESPONSIVE SWITCH ───────────────────────────────────────────────────────── +let lastMobile = null; +window.addEventListener('resize', () => { + const m = isMobile(); + if (m !== lastMobile) { lastMobile=m; scrollListenerAdded=false; if (channels.length) renderAll(); } +}); + +window.addEventListener('load', () => { + const savedSource = localStorage.getItem('gridtv-active-source'); + if (savedSource === 'personal') { + const epg = localStorage.getItem('gridtv-personal-epg'); + const m3u = localStorage.getItem('gridtv-personal-m3u') ?? ''; + if (epg) { + currentEpgUrl = epg; + currentM3uUrl = m3u; + const sel = document.getElementById('sourceSelect'); + if (sel) sel.value = 'personal'; + loadEPG(epg); + if (m3u) loadM3U(m3u); + return; + } + } else if (savedSource !== null) { + const idx = parseInt(savedSource); + const src = EPG_SOURCES[idx]; + if (src) { + activeSourceIndex = idx; + currentEpgUrl = src.epg_url; + currentM3uUrl = src.m3u_url ?? ''; + const sel = document.getElementById('sourceSelect'); + if (sel) sel.value = idx; + } + } + loadEPG(currentEpgUrl); + loadM3U(currentM3uUrl); +}); diff --git a/src/js/m3u.js b/src/js/m3u.js new file mode 100644 index 0000000..b73f606 --- /dev/null +++ b/src/js/m3u.js @@ -0,0 +1,57 @@ +// ── M3U PARSER ──────────────────────────────────────────────────────────────── +function slugify(str) { + return str.toLowerCase() + .replace(/^\d+\s*/, '') // virer le numéro de chaîne en début + .replace(/[^a-z0-9]/g, ''); // garder que alphanum +} + +async function loadM3U(url) { + if (!url) return; + try { + let text; + try { + const r = await fetch(url); text = await r.text(); + } catch(e) { + const r = await fetch(CORS_PROXY + encodeURIComponent(url)); text = await r.text(); + } + parseM3U(text); + } catch(e) { console.warn('M3U load failed:', e); } +} + +function parseM3U(text) { + m3uStreams = {}; + const lines = text.split('\n'); + let currentName = null; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line.startsWith('#EXTINF')) { + // Extraire le nom : dernière partie après la virgule + const comma = line.lastIndexOf(','); + currentName = comma !== -1 ? line.slice(comma + 1).trim() : null; + } else if (line && !line.startsWith('#') && currentName) { + m3uStreams[slugify(currentName)] = line; + currentName = null; + } + } + console.log(`M3U: ${Object.keys(m3uStreams).length} streams chargés`); +} + +function getStreamUrl(channelName) { + const slug = slugify(channelName); + let url = null; + if (m3uStreams[slug]) { + url = m3uStreams[slug]; + } else { + // Fallback : chercher une clé qui contient le slug ou l'inverse + for (const [key, u] of Object.entries(m3uStreams)) { + if (key.includes(slug) || slug.includes(key)) { url = u; break; } + } + } + if (!url) return null; + // Passer par proxy.php pour éviter le mixed content HTTP/HTTPS + if (url.startsWith('http://')) { + return 'proxy.php?url=' + encodeURIComponent(url); + } + return url; +} + diff --git a/src/js/mobile.js b/src/js/mobile.js new file mode 100644 index 0000000..a144f1d --- /dev/null +++ b/src/js/mobile.js @@ -0,0 +1,57 @@ +// ── MOBILE ──────────────────────────────────────────────────────────────────── +function renderMobile() { + const container = document.getElementById('mobileView'); + container.innerHTML = ''; + const now = new Date(); + const winStart = new Date(now.getTime() - 30*60000); + const winEnd = new Date(now.getTime() + 4*3600000); + + channels.forEach(ch => { + const progs = (programs[ch.id]||[]).filter(p => p.stop>winStart && p.start img.replaceWith(makePlaceholder(ch.name)); + header.appendChild(img); + } else { header.appendChild(makePlaceholder(ch.name)); } + + const name = document.createElement('div'); name.className='channel-name'; name.textContent=ch.name; + header.appendChild(name); section.appendChild(header); + + const list = document.createElement('div'); list.className='mobile-programs'; + progs.forEach(p => { + const isLive = p.start<=now && p.stop>now; + const isPast = p.stop${fmtTime(p.start)}${fmtTime(p.stop)}`; + item.appendChild(timeCol); + + const info = document.createElement('div'); info.className='mobile-program-info'; + const ep = fmtEpisode(p.season, p.episode); + info.innerHTML = `
${p.title}
${ep ? ep + ' · ' : ''}${dur} min
`; + item.appendChild(info); + + if (isLive) { + const badge = document.createElement('div'); badge.className='mobile-live-badge'; badge.textContent='LIVE'; + item.appendChild(badge); + } + list.appendChild(item); + }); + section.appendChild(list); container.appendChild(section); + }); +} + diff --git a/src/js/player.js b/src/js/player.js new file mode 100644 index 0000000..1c9c68a --- /dev/null +++ b/src/js/player.js @@ -0,0 +1,58 @@ +// ── PLAYER PiP ──────────────────────────────────────────────────────────────── +let hlsInstance = null; + +function openPip(channelName, programTitle) { + const url = getStreamUrl(channelName); + const pip = document.getElementById('pip'); + const video = document.getElementById('pipVideo'); + const err = document.getElementById('pipError'); + + document.getElementById('pipChannel').textContent = channelName; + document.getElementById('pipProgram').textContent = programTitle || ''; + err.style.display = 'none'; + pip.classList.add('visible'); + + // Détruire l'instance HLS précédente + if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; } + video.src = ''; + + if (!url) { + err.style.display = 'block'; + err.textContent = '⚠ Aucun stream trouvé pour cette chaîne'; + return; + } + + if (Hls.isSupported()) { + hlsInstance = new Hls({ enableWorker: false, debug: false }); + hlsInstance.loadSource(url); + hlsInstance.attachMedia(video); + hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => { + console.log('[PiP] Manifest parsed, lecture...'); + video.play().catch(e => console.warn('[PiP] play() refusé:', e)); + }); + hlsInstance.on(Hls.Events.ERROR, (e, data) => { + console.error('[PiP] HLS error:', data.type, data.details, data.fatal, data.response); + if (data.fatal) { + const detail = data.response ? ` (HTTP ${data.response.code})` : ` (${data.details})`; + err.style.display = 'block'; + err.textContent = '⚠ Erreur de lecture du stream' + detail; + } + }); + } else if (video.canPlayType('application/vnd.apple.mpegurl')) { + // Safari natif HLS + video.src = url; + video.addEventListener('loadedmetadata', () => video.play().catch(() => {})); + } else { + err.style.display = 'block'; + err.textContent = '⚠ HLS non supporté sur ce navigateur'; + } +} + +function closePip() { + const pip = document.getElementById('pip'); + const video = document.getElementById('pipVideo'); + pip.classList.remove('visible'); + if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; } + video.pause(); video.src = ''; +} + diff --git a/src/js/sources.js b/src/js/sources.js new file mode 100644 index 0000000..31c819f --- /dev/null +++ b/src/js/sources.js @@ -0,0 +1,59 @@ +// ── SOURCE SWITCHER ─────────────────────────────────────────────────────────── +let currentEpgUrl = EPG_SOURCES[0]?.epg_url ?? ''; +let currentM3uUrl = EPG_SOURCES[0]?.m3u_url ?? ''; + +function switchSource(value) { + if (value === 'personal') { + openPersonalEpgModal(); + return; + } + const idx = parseInt(value); + const src = EPG_SOURCES[idx]; + if (!src) return; + activeSourceIndex = idx; + currentEpgUrl = src.epg_url; + currentM3uUrl = src.m3u_url ?? ''; + localStorage.setItem('gridtv-active-source', idx); + loadEPG(currentEpgUrl); + if (currentM3uUrl) loadM3U(currentM3uUrl); + updateCopyButtons(); +} + +function updateCopyButtons() { + // Rien à faire visuellement, copyUrl() lit currentEpgUrl/currentM3uUrl +} + +function openPersonalEpgModal() { + const saved_epg = localStorage.getItem('gridtv-personal-epg') ?? ''; + const saved_m3u = localStorage.getItem('gridtv-personal-m3u') ?? ''; + const modal = document.getElementById('personalEpgModal'); + document.getElementById('personalEpgInput').value = saved_epg; + document.getElementById('personalM3uInput').value = saved_m3u; + modal.classList.add('visible'); +} + +function closePersonalEpgModal(applied) { + document.getElementById('personalEpgModal').classList.remove('visible'); + // Si annulé (pas applied), remettre le select sur la source précédente + if (!applied) { + const sel = document.getElementById('sourceSelect'); + if (sel) sel.value = localStorage.getItem('gridtv-active-source') === 'personal' ? 'personal' : activeSourceIndex; + } +} + +function applyPersonalEpg() { + const epg = document.getElementById('personalEpgInput').value.trim(); + const m3u = document.getElementById('personalM3uInput').value.trim(); + if (!epg) { alert('URL EPG obligatoire'); return; } + localStorage.setItem('gridtv-personal-epg', epg); + localStorage.setItem('gridtv-personal-m3u', m3u); + localStorage.setItem('gridtv-active-source', 'personal'); + currentEpgUrl = epg; + currentM3uUrl = m3u; + const sel = document.getElementById('sourceSelect'); + if (sel) sel.value = 'personal'; + closePersonalEpgModal(true); + loadEPG(epg); + if (m3u) loadM3U(m3u); +} + diff --git a/src/js/themes.js b/src/js/themes.js new file mode 100644 index 0000000..3fce918 --- /dev/null +++ b/src/js/themes.js @@ -0,0 +1,20 @@ +// ── THÈMES ──────────────────────────────────────────────────────────────────── +function setTheme(theme) { + document.getElementById('themeStylesheet').href = 'themes/' + theme + '.css'; + localStorage.setItem('gridtv-theme', theme); + document.getElementById('themeSelect').value = theme; +} + +function initTheme() { + const saved = localStorage.getItem('gridtv-theme') || 'default'; + setTheme(saved); +} +initTheme(); + +// ── LIVE UPDATES ────────────────────────────────────────────────────────────── +function startLiveUpdates() { + setInterval(() => { if (!isMobile()) positionNowLine(); }, 30000); + setInterval(() => { if (channels.length) renderAll(); }, 60000); + setInterval(() => loadEPG(DEFAULT_EPG_URL), 30*60*1000); +} + diff --git a/src/js/tooltip.js b/src/js/tooltip.js new file mode 100644 index 0000000..87ebd72 --- /dev/null +++ b/src/js/tooltip.js @@ -0,0 +1,27 @@ +// ── HELPERS ────────────────────────────────────────────────────────────────── +function getNowPlaying(chId) { + const now = new Date(); + const p = (programs[chId] || []).find(p => p.start <= now && p.stop > now); + return p ? p.title : null; +} + +// ── TOOLTIP ─────────────────────────────────────────────────────────────────── +function showTooltip(e, p) { + const ep = fmtEpisode(p.season, p.episode); + document.getElementById('tt-title').textContent = p.title + (ep ? ' ' + ep : ''); + document.getElementById('tt-time').textContent = `${fmtTime(p.start)} — ${fmtTime(p.stop)} · ${Math.round((p.stop-p.start)/60000)} min`; + document.getElementById('tt-desc').textContent = p.desc || '—'; + document.getElementById('tooltip').classList.add('visible'); + moveTooltip(e); +} +function moveTooltip(e) { + const tt = document.getElementById('tooltip'); + const x=e.clientX+16, y=e.clientY+16; + tt.style.left = (x+tt.offsetWidth >window.innerWidth ? x-tt.offsetWidth -32 : x)+'px'; + tt.style.top = (y+tt.offsetHeight>window.innerHeight ? y-tt.offsetHeight-32 : y)+'px'; +} +function hideTooltip() { document.getElementById('tooltip').classList.remove('visible'); } + + + + diff --git a/src/js/utils.js b/src/js/utils.js new file mode 100644 index 0000000..754f1db --- /dev/null +++ b/src/js/utils.js @@ -0,0 +1,134 @@ + +let channels = []; +let programs = {}; +let m3uStreams = {}; // slug normalisé → url stream +let scrollListenerAdded = false; // sync scroll vertical chaînes + +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 = '✓ copié'; + b.classList.add('copied'); + setTimeout(() => { b.textContent = type.toUpperCase(); b.classList.remove('copied'); }, 1800); + }}); + }).catch(() => { + prompt('Copiez cette 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; + 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); + const days=['Dim','Lun','Mar','Mer','Jeu','Ven','Sam']; + const months=['Jan','Fév','Mar','Avr','Mai','Jun','Jul','Aoû','Sep','Oct','Nov','Déc']; + document.getElementById('dateLabel').textContent = + `${days[now.getDay()]} ${now.getDate()} ${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 = `Erreur: ${e.message}`; + document.getElementById('epgInput').value = url; + } +} + +function loadFromInput() { + const url = document.getElementById('epgInput').value.trim(); + if (url) loadEPG(url); +} + +function parseEpisode(p) { + // Format xmltv_ns : "S.E.part" (0-indexed) ex: "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 }; + } + // Format onscreen : "S01E03" ou "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 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 desc = p.querySelector('desc')?.textContent || ''; + const epInfo = parseEpisode(p); + if (start && stop) programs[chId].push({ start, stop, title, desc, ...epInfo }); + }); + Object.keys(programs).forEach(id => programs[id].sort((a,b) => a.start - b.start)); + + renderAll(); + startLiveUpdates(); +} + +function renderAll() { + if (isMobile()) { renderMobile(); } + else { renderGrid(); setTimeout(centerNow, 150); } +} + diff --git a/src/tpl/footer.php b/src/tpl/footer.php new file mode 100644 index 0000000..fc2eace --- /dev/null +++ b/src/tpl/footer.php @@ -0,0 +1,21 @@ + + + + diff --git a/src/tpl/grid.php b/src/tpl/grid.php new file mode 100644 index 0000000..67112d1 --- /dev/null +++ b/src/tpl/grid.php @@ -0,0 +1,30 @@ + +
+
+
+
+ +
+ +
⚠ Stream indisponible
+
+ + +
+
+
Chaînes
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ diff --git a/src/tpl/head.php b/src/tpl/head.php new file mode 100644 index 0000000..42d4d30 --- /dev/null +++ b/src/tpl/head.php @@ -0,0 +1,764 @@ + + + + + +GridTV — <?= $group_name ?> + + + + diff --git a/src/tpl/modals.php b/src/tpl/modals.php new file mode 100644 index 0000000..22d36b8 --- /dev/null +++ b/src/tpl/modals.php @@ -0,0 +1,22 @@ + + + + + diff --git a/src/tpl/topbar.php b/src/tpl/topbar.php new file mode 100644 index 0000000..579f985 --- /dev/null +++ b/src/tpl/topbar.php @@ -0,0 +1,52 @@ +
+ +
+
00:00:00
+ +
+ + + + +
+ 1 || $allow_personal_epg): ?> + + + +
LIVE
+
+ From 7b14f0e3fa77a4e2764c13f572dc5088fdb183a5 Mon Sep 17 00:00:00 2001 From: Johnnybegood90 Date: Wed, 11 Mar 2026 23:51:38 +0100 Subject: [PATCH 2/6] Footer fix --- src/tpl/footer.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/tpl/footer.php b/src/tpl/footer.php index fc2eace..f25f729 100644 --- a/src/tpl/footer.php +++ b/src/tpl/footer.php @@ -1,18 +1,18 @@