Merge remote-tracking branch 'origin/beta'

Ajout du lecteur HLS V1
This commit is contained in:
Johnnybegood90
2026-03-10 10:31:14 +01:00
+125 -2
View File
@@ -631,6 +631,7 @@ $m3u_url = htmlspecialchars($config["m3u_url"] ?? "");
#navBtns { display: none !important; } #navBtns { display: none !important; }
} }
</style> </style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js"></script>
<link id="themeStylesheet" rel="stylesheet" href="themes/default.css"> <link id="themeStylesheet" rel="stylesheet" href="themes/default.css">
</head> </head>
<body> <body>
@@ -690,13 +691,24 @@ foreach ($theme_files as $file) {
preg_match('/@emoji\s+(\S+)/u', $css, $m_emoji); preg_match('/@emoji\s+(\S+)/u', $css, $m_emoji);
$name = isset($m_name[1]) ? trim($m_name[1]) : ucfirst($slug); $name = isset($m_name[1]) ? trim($m_name[1]) : ucfirst($slug);
$emoji = isset($m_emoji[1]) ? trim($m_emoji[1]) : '🎨'; $emoji = isset($m_emoji[1]) ? trim($m_emoji[1]) : '🎨';
echo " <option value="" . htmlspecialchars($slug) . "">{$emoji} {$name}</option>\n"; echo ' <option value="' . htmlspecialchars($slug) . '">' . $emoji . ' ' . $name . '</option>' . "\n";
} }
?> ?>
</select> </select>
<div class="live-dot">LIVE</div> <div class="live-dot">LIVE</div>
</div> </div>
<!-- PLAYER PiP -->
<div class="pip" id="pip">
<div class="pip-header">
<div class="pip-channel" id="pipChannel">—</div>
<div class="pip-program" id="pipProgram"></div>
<button class="pip-close" onclick="closePip()" title="Fermer">✕</button>
</div>
<video id="pipVideo" controls playsinline></video>
<div class="pip-error" id="pipError">⚠ Stream indisponible</div>
</div>
<!-- GRID (desktop/tablet) --> <!-- GRID (desktop/tablet) -->
<div class="grid-wrapper"> <div class="grid-wrapper">
<div class="channels-col"> <div class="channels-col">
@@ -734,6 +746,7 @@ function msToX(ms) { return (ms - GRID_START.getTime()) / 60000 * PX_PER_MIN; }
let channels = []; let channels = [];
let programs = {}; let programs = {};
let m3uStreams = {}; // slug normalisé → url stream
let scrollListenerAdded = false; // sync scroll vertical chaînes let scrollListenerAdded = false; // sync scroll vertical chaînes
function pad(n) { return String(n).padStart(2,'0'); } function pad(n) { return String(n).padStart(2,'0'); }
@@ -891,6 +904,7 @@ function renderChannels() {
const name = document.createElement('div'); const name = document.createElement('div');
name.className = 'channel-name'; name.textContent = ch.name; name.title = ch.name; name.className = 'channel-name'; name.textContent = ch.name; name.title = ch.name;
cell.appendChild(name); cell.appendChild(name);
cell.addEventListener('click', () => openPip(ch.name, getNowPlaying(ch.id)));
list.appendChild(cell); list.appendChild(cell);
}); });
} }
@@ -953,6 +967,9 @@ function renderPrograms() {
block.addEventListener('mouseenter', e=>showTooltip(e,p)); block.addEventListener('mouseenter', e=>showTooltip(e,p));
block.addEventListener('mousemove', e=>moveTooltip(e)); block.addEventListener('mousemove', e=>moveTooltip(e));
block.addEventListener('mouseleave', hideTooltip); block.addEventListener('mouseleave', hideTooltip);
if (p.start <= now && p.stop > now) {
block.addEventListener('click', () => openPip(ch.name, p.title));
}
row.appendChild(block); row.appendChild(block);
}); });
inner.appendChild(row); inner.appendChild(row);
@@ -1042,6 +1059,13 @@ function renderMobile() {
}); });
} }
// ── 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 ─────────────────────────────────────────────────────────────────── // ── TOOLTIP ───────────────────────────────────────────────────────────────────
function showTooltip(e, p) { function showTooltip(e, p) {
const ep = fmtEpisode(p.season, p.episode); const ep = fmtEpisode(p.season, p.episode);
@@ -1060,6 +1084,105 @@ function moveTooltip(e) {
function hideTooltip() { document.getElementById('tooltip').classList.remove('visible'); } function hideTooltip() { document.getElementById('tooltip').classList.remove('visible'); }
// ── 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);
if (m3uStreams[slug]) return m3uStreams[slug];
// Fallback : chercher une clé qui contient le slug ou l'inverse
for (const [key, url] of Object.entries(m3uStreams)) {
if (key.includes(slug) || slug.includes(key)) return url;
}
return null;
}
// ── 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: true });
hlsInstance.loadSource(url);
hlsInstance.attachMedia(video);
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => {}));
hlsInstance.on(Hls.Events.ERROR, (e, data) => {
if (data.fatal) { err.style.display = 'block'; err.textContent = '⚠ Erreur de lecture du stream'; }
});
} 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 = '';
}
// ── THÈMES ──────────────────────────────────────────────────────────────────── // ── THÈMES ────────────────────────────────────────────────────────────────────
function setTheme(theme) { function setTheme(theme) {
document.getElementById('themeStylesheet').href = 'themes/' + theme + '.css'; document.getElementById('themeStylesheet').href = 'themes/' + theme + '.css';
@@ -1087,7 +1210,7 @@ window.addEventListener('resize', () => {
if (m !== lastMobile) { lastMobile=m; scrollListenerAdded=false; if (channels.length) renderAll(); } if (m !== lastMobile) { lastMobile=m; scrollListenerAdded=false; if (channels.length) renderAll(); }
}); });
window.addEventListener('load', () => loadEPG(DEFAULT_EPG_URL)); window.addEventListener('load', () => { loadEPG(DEFAULT_EPG_URL); loadM3U(DEFAULT_M3U_URL); });
</script> </script>
</body> </body>
</html> </html>