Merge remote-tracking branch 'origin/beta'
Ajout du lecteur HLS V1
This commit is contained in:
@@ -631,6 +631,7 @@ $m3u_url = htmlspecialchars($config["m3u_url"] ?? "");
|
||||
#navBtns { display: none !important; }
|
||||
}
|
||||
</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">
|
||||
</head>
|
||||
<body>
|
||||
@@ -690,13 +691,24 @@ foreach ($theme_files as $file) {
|
||||
preg_match('/@emoji\s+(\S+)/u', $css, $m_emoji);
|
||||
$name = isset($m_name[1]) ? trim($m_name[1]) : ucfirst($slug);
|
||||
$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>
|
||||
<div class="live-dot">LIVE</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) -->
|
||||
<div class="grid-wrapper">
|
||||
<div class="channels-col">
|
||||
@@ -734,6 +746,7 @@ function msToX(ms) { return (ms - GRID_START.getTime()) / 60000 * PX_PER_MIN; }
|
||||
|
||||
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'); }
|
||||
@@ -891,6 +904,7 @@ function renderChannels() {
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -953,6 +967,9 @@ function renderPrograms() {
|
||||
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);
|
||||
@@ -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 ───────────────────────────────────────────────────────────────────
|
||||
function showTooltip(e, p) {
|
||||
const ep = fmtEpisode(p.season, p.episode);
|
||||
@@ -1060,6 +1084,105 @@ function moveTooltip(e) {
|
||||
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 ────────────────────────────────────────────────────────────────────
|
||||
function setTheme(theme) {
|
||||
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(); }
|
||||
});
|
||||
|
||||
window.addEventListener('load', () => loadEPG(DEFAULT_EPG_URL));
|
||||
window.addEventListener('load', () => { loadEPG(DEFAULT_EPG_URL); loadM3U(DEFAULT_M3U_URL); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user