Adjusting comments into files.
This commit is contained in:
@@ -2,12 +2,12 @@
|
|||||||
/**
|
/**
|
||||||
* GridTV — proxy.php
|
* GridTV — proxy.php
|
||||||
*
|
*
|
||||||
* Proxy HTTP->HTTPS restreint aux hotes explicitement configures dans config.json.
|
* HTTP->HTTPS proxy restricted to hosts explicitly configured in config.json.
|
||||||
* Les IP privees et LAN sont autorisees si l'administrateur les a configurees.
|
* Private and LAN IPs are allowed only when the administrator configured them.
|
||||||
* Les redirections sont suivies manuellement avec revalidation de l'hote a chaque saut.
|
* Redirects are followed manually and the host is revalidated at every hop.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ── Whitelist : hotes autorises extraits de config.json ───────────────────────
|
// ── Allowlist: authorized hosts extracted from config.json ────────────────────
|
||||||
$config_path = __DIR__ . '/config.json';
|
$config_path = __DIR__ . '/config.json';
|
||||||
$allowed_hosts = [];
|
$allowed_hosts = [];
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ if (file_exists($config_path)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Fonctions ─────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function is_allowed_url(string $url, array $allowed_hosts): bool {
|
function is_allowed_url(string $url, array $allowed_hosts): bool {
|
||||||
if (!preg_match('#^https?://#i', $url)) return false;
|
if (!preg_match('#^https?://#i', $url)) return false;
|
||||||
@@ -41,9 +41,10 @@ function resolve_url(string $base, string $location): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch avec redirections manuelles — chaque Location: est revalidee contre la whitelist.
|
* Fetch with manual redirect handling so every Location header is checked
|
||||||
* $stream = true : stream chunk par chunk (segments video)
|
* against the allowlist before following it.
|
||||||
* $stream = false : retourne le body complet (playlists m3u8)
|
* $stream = true: stream the response chunk by chunk (video segments)
|
||||||
|
* $stream = false: return the full body (m3u8 playlists)
|
||||||
*/
|
*/
|
||||||
function fetch_with_checked_redirects(string $url, array $allowed_hosts, bool $stream = false): array {
|
function fetch_with_checked_redirects(string $url, array $allowed_hosts, bool $stream = false): array {
|
||||||
$max_redirects = 5;
|
$max_redirects = 5;
|
||||||
@@ -76,7 +77,7 @@ function fetch_with_checked_redirects(string $url, array $allowed_hosts, bool $s
|
|||||||
$raw_headers = substr($response, 0, $header_size);
|
$raw_headers = substr($response, 0, $header_size);
|
||||||
$body = substr($response, $header_size);
|
$body = substr($response, $header_size);
|
||||||
|
|
||||||
// Redirection
|
// Follow redirects manually so each hop is rechecked against the allowlist.
|
||||||
if ($code >= 300 && $code < 400) {
|
if ($code >= 300 && $code < 400) {
|
||||||
if (!preg_match('/^Location:\s*(.+)$/mi', $raw_headers, $m)) {
|
if (!preg_match('/^Location:\s*(.+)$/mi', $raw_headers, $m)) {
|
||||||
http_response_code(502); die('Invalid redirect');
|
http_response_code(502); die('Invalid redirect');
|
||||||
@@ -91,7 +92,7 @@ function fetch_with_checked_redirects(string $url, array $allowed_hosts, bool $s
|
|||||||
http_response_code(508); die('Too many redirects');
|
http_response_code(508); die('Too many redirects');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Valider l'URL initiale ─────────────────────────────────────────────────────
|
// ── Validate the initial URL ───────────────────────────────────────────────────
|
||||||
$url = $_GET['url'] ?? '';
|
$url = $_GET['url'] ?? '';
|
||||||
|
|
||||||
if (!is_allowed_url($url, $allowed_hosts)) {
|
if (!is_allowed_url($url, $allowed_hosts)) {
|
||||||
@@ -99,27 +100,27 @@ if (!is_allowed_url($url, $allowed_hosts)) {
|
|||||||
die(empty($allowed_hosts) ? 'No sources configured' : 'Host not allowed');
|
die(empty($allowed_hosts) ? 'No sources configured' : 'Host not allowed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Determiner le type de ressource ───────────────────────────────────────────
|
// ── Detect the resource type ───────────────────────────────────────────────────
|
||||||
$path = parse_url($url, PHP_URL_PATH) ?? '';
|
$path = parse_url($url, PHP_URL_PATH) ?? '';
|
||||||
$is_segment = preg_match('#\.(ts|aac|mp4|m4s|fmp4)(\?|$)#i', $path);
|
$is_segment = preg_match('#\.(ts|aac|mp4|m4s|fmp4)(\?|$)#i', $path);
|
||||||
|
|
||||||
header('Access-Control-Allow-Origin: *');
|
header('Access-Control-Allow-Origin: *');
|
||||||
header('Cache-Control: no-cache');
|
header('Cache-Control: no-cache');
|
||||||
|
|
||||||
// ── Segment binaire — stream chunk par chunk ───────────────────────────────────
|
// ── Binary segment: stream chunk by chunk ──────────────────────────────────────
|
||||||
if ($is_segment) {
|
if ($is_segment) {
|
||||||
header('Content-Type: video/MP2T');
|
header('Content-Type: video/MP2T');
|
||||||
header('X-Content-Type-Options: nosniff');
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
if (ob_get_level()) ob_end_clean();
|
if (ob_get_level()) ob_end_clean();
|
||||||
|
|
||||||
// Pour les segments, on suit les redirections en streaming direct
|
// For segments, resolve redirects first and only stream once the final URL
|
||||||
// apres avoir valide l'URL finale via fetch_with_checked_redirects en mode non-stream
|
// has already been validated.
|
||||||
[$code, , , $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, false);
|
[$code, , , $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, false);
|
||||||
|
|
||||||
if ($code >= 400) { http_response_code($code); die(); }
|
if ($code >= 400) { http_response_code($code); die(); }
|
||||||
|
|
||||||
// Maintenant streamer l'URL finale
|
// Stream the validated final URL.
|
||||||
$ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0';
|
$ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0';
|
||||||
$ch = curl_init($final_url);
|
$ch = curl_init($final_url);
|
||||||
curl_setopt_array($ch, [
|
curl_setopt_array($ch, [
|
||||||
@@ -142,7 +143,7 @@ if ($is_segment) {
|
|||||||
if (!$ok || $code >= 400) http_response_code($code ?: 502);
|
if (!$ok || $code >= 400) http_response_code($code ?: 502);
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
|
|
||||||
// ── Playlist m3u8 — fetch + réécriture URLs ────────────────────────────────────
|
// ── m3u8 playlist: fetch and rewrite URLs ──────────────────────────────────────
|
||||||
} else {
|
} else {
|
||||||
[$code, , $body, $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, false);
|
[$code, , $body, $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, false);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
$config_path = __DIR__ . '/config.json';
|
$config_path = __DIR__ . '/config.json';
|
||||||
|
|
||||||
// Migration automatique ancien format
|
// Automatically migrate the legacy config format.
|
||||||
if (file_exists($config_path)) {
|
if (file_exists($config_path)) {
|
||||||
$existing = json_decode(file_get_contents($config_path), true);
|
$existing = json_decode(file_get_contents($config_path), true);
|
||||||
if (isset($existing['epg_url']) && !isset($existing['epg_sources'])) {
|
if (isset($existing['epg_url']) && !isset($existing['epg_sources'])) {
|
||||||
@@ -14,7 +14,7 @@ if (file_exists($config_path)) {
|
|||||||
unset($existing['epg_url'], $existing['m3u_url']);
|
unset($existing['epg_url'], $existing['m3u_url']);
|
||||||
file_put_contents($config_path, json_encode($existing, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
file_put_contents($config_path, json_encode($existing, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||||
}
|
}
|
||||||
// Bootstrap : config existante sans cle admin -> on en genere une maintenant
|
// Bootstrap an admin key for older configs that do not have one yet.
|
||||||
if (empty($existing['admin_key'])) {
|
if (empty($existing['admin_key'])) {
|
||||||
$existing['admin_key'] = bin2hex(random_bytes(16));
|
$existing['admin_key'] = bin2hex(random_bytes(16));
|
||||||
file_put_contents($config_path, json_encode($existing, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
file_put_contents($config_path, json_encode($existing, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||||
|
|||||||
+2
-2
@@ -9,7 +9,7 @@ if (!file_exists($config_path)) {
|
|||||||
|
|
||||||
$config = json_decode(file_get_contents($config_path), true);
|
$config = json_decode(file_get_contents($config_path), true);
|
||||||
|
|
||||||
// ── Migration automatique ancien format → nouveau ─────────────────────────────
|
// ── Automatically migrate the legacy config format to the current one ─────────
|
||||||
if (isset($config['epg_url']) && !isset($config['epg_sources'])) {
|
if (isset($config['epg_url']) && !isset($config['epg_sources'])) {
|
||||||
$config['epg_sources'] = [[
|
$config['epg_sources'] = [[
|
||||||
'name' => 'Main',
|
'name' => 'Main',
|
||||||
@@ -26,7 +26,7 @@ $group_name = htmlspecialchars($config['group_name'] ?? 'GridTV');
|
|||||||
$epg_sources = $config['epg_sources'] ?? [];
|
$epg_sources = $config['epg_sources'] ?? [];
|
||||||
$allow_personal_epg = !empty($config['allow_personal_epg']);
|
$allow_personal_epg = !empty($config['allow_personal_epg']);
|
||||||
|
|
||||||
// Première source par défaut (pour compatibilité avec le reste du code)
|
// Default to the first configured source to keep the rest of the app compatible.
|
||||||
$first_source = $epg_sources[0] ?? [];
|
$first_source = $epg_sources[0] ?? [];
|
||||||
$epg_url = htmlspecialchars($first_source['epg_url'] ?? '');
|
$epg_url = htmlspecialchars($first_source['epg_url'] ?? '');
|
||||||
$m3u_url = htmlspecialchars($first_source['m3u_url'] ?? '');
|
$m3u_url = htmlspecialchars($first_source['m3u_url'] ?? '');
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
/* Variables de base — surchargées par le fichier thème */
|
/* Base variables overridden by the active theme file */
|
||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--bg: #0a0b0d;
|
--bg: #0a0b0d;
|
||||||
|
|||||||
+1
-1
@@ -97,7 +97,7 @@
|
|||||||
.timeline-area::-webkit-scrollbar-track { background: transparent; }
|
.timeline-area::-webkit-scrollbar-track { background: transparent; }
|
||||||
.timeline-area::-webkit-scrollbar-thumb { background: var(--border-bright); border-radius: 2px; }
|
.timeline-area::-webkit-scrollbar-thumb { background: var(--border-bright); border-radius: 2px; }
|
||||||
|
|
||||||
/* Conteneur unique — ruler + programmes scrollent ensemble */
|
/* Single container so the ruler and programs scroll together */
|
||||||
.timeline-inner {
|
.timeline-inner {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@
|
|||||||
.pm-imdb { background: #f5c518; color: #000; }
|
.pm-imdb { background: #f5c518; color: #000; }
|
||||||
.pm-cancel { background: var(--bg); color: var(--text-muted); border: 1px solid var(--border); }
|
.pm-cancel { background: var(--bg); color: var(--text-muted); border: 1px solid var(--border); }
|
||||||
|
|
||||||
/* Date midnight dans la timebar */
|
/* Midnight date marker in the timebar */
|
||||||
.time-tick-label.midnight {
|
.time-tick-label.midnight {
|
||||||
color: var(--accent); font-weight: 700; font-size: 11px;
|
color: var(--accent); font-weight: 700; font-size: 11px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@
|
|||||||
}
|
}
|
||||||
.search-clear:hover { color: var(--accent); }
|
.search-clear:hover { color: var(--accent); }
|
||||||
|
|
||||||
/* Highlight programmes qui matchent */
|
/* Highlight programs that match the current query */
|
||||||
.program-block.search-match {
|
.program-block.search-match {
|
||||||
border: 1px solid var(--accent);
|
border: 1px solid var(--accent);
|
||||||
box-shadow: 0 0 6px var(--accent);
|
box-shadow: 0 0 6px var(--accent);
|
||||||
|
|||||||
+1
-2
@@ -128,7 +128,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════
|
/* ═══════════════════════════════════════════
|
||||||
SÉLECTEUR DE THÈME
|
THEME SELECTOR
|
||||||
═══════════════════════════════════════════ */
|
═══════════════════════════════════════════ */
|
||||||
.theme-select {
|
.theme-select {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -222,4 +222,3 @@
|
|||||||
.source-dropdown-item:hover { background: var(--surface2); color: var(--accent2); }
|
.source-dropdown-item:hover { background: var(--surface2); color: var(--accent2); }
|
||||||
.source-dropdown-item.active { color: var(--accent); }
|
.source-dropdown-item.active { color: var(--accent); }
|
||||||
.source-dropdown-personal { color: var(--accent2); }
|
.source-dropdown-personal { color: var(--accent2); }
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@ const EPG_SOURCES = <?= json_encode(array_values($epg_sources)) ?>;
|
|||||||
const L = <?= json_encode($L) ?>;
|
const L = <?= json_encode($L) ?>;
|
||||||
const ALLOW_PERSONAL_EPG = <?= $allow_personal_epg ? 'true' : 'false' ?>;
|
const ALLOW_PERSONAL_EPG = <?= $allow_personal_epg ? 'true' : 'false' ?>;
|
||||||
|
|
||||||
// Source active (index)
|
// Index of the currently selected source.
|
||||||
let activeSourceIndex = 0;
|
let activeSourceIndex = 0;
|
||||||
|
|
||||||
const DEFAULT_EPG_URL = EPG_SOURCES[0]?.epg_url ?? '';
|
const DEFAULT_EPG_URL = EPG_SOURCES[0]?.epg_url ?? '';
|
||||||
@@ -11,7 +11,7 @@ const PX_PER_MIN = 5;
|
|||||||
const GRID_HOURS = 72;
|
const GRID_HOURS = 72;
|
||||||
const CORS_PROXY = 'https://corsproxy.io/?';
|
const CORS_PROXY = 'https://corsproxy.io/?';
|
||||||
|
|
||||||
// Ancre : 2h avant maintenant, arrondi au quart d'heure inférieur
|
// Anchor the grid 2 hours before "now", rounded down to the previous quarter-hour.
|
||||||
const _d = new Date();
|
const _d = new Date();
|
||||||
_d.setMinutes(Math.floor(_d.getMinutes()/15)*15, 0, 0);
|
_d.setMinutes(Math.floor(_d.getMinutes()/15)*15, 0, 0);
|
||||||
const GRID_START = new Date(_d.getTime() - 2*3600000);
|
const GRID_START = new Date(_d.getTime() - 2*3600000);
|
||||||
|
|||||||
+1
-2
@@ -73,7 +73,7 @@ function renderPrograms() {
|
|||||||
inner.style.width = TOTAL_WIDTH_PX+'px';
|
inner.style.width = TOTAL_WIDTH_PX+'px';
|
||||||
inner.style.height = totalH+'px';
|
inner.style.height = totalH+'px';
|
||||||
|
|
||||||
// Remettre la now-line (détruite par innerHTML='')
|
// Recreate the now-line because it was removed by innerHTML = ''.
|
||||||
const nowLine = document.createElement('div');
|
const nowLine = document.createElement('div');
|
||||||
nowLine.className = 'now-line'; nowLine.id = 'nowLine';
|
nowLine.className = 'now-line'; nowLine.id = 'nowLine';
|
||||||
nowLine.style.height = totalH + 'px';
|
nowLine.style.height = totalH + 'px';
|
||||||
@@ -141,4 +141,3 @@ function shiftView(mins) {
|
|||||||
area.scrollLeft = Math.max(0, area.scrollLeft + mins * PX_PER_MIN);
|
area.scrollLeft = Math.max(0, area.scrollLeft + mins * PX_PER_MIN);
|
||||||
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-6
@@ -1,8 +1,8 @@
|
|||||||
// ── M3U PARSER ────────────────────────────────────────────────────────────────
|
// ── M3U PARSER ────────────────────────────────────────────────────────────────
|
||||||
function slugify(str) {
|
function slugify(str) {
|
||||||
return str.toLowerCase()
|
return str.toLowerCase()
|
||||||
.replace(/^\d+\s*/, '') // virer le numéro de chaîne en début
|
.replace(/^\d+\s*/, '') // Strip any leading channel number.
|
||||||
.replace(/[^a-z0-9]/g, ''); // garder que alphanum
|
.replace(/[^a-z0-9]/g, ''); // Keep only alphanumeric characters.
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadM3U(url) {
|
async function loadM3U(url) {
|
||||||
@@ -25,7 +25,7 @@ function parseM3U(text) {
|
|||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const line = lines[i].trim();
|
const line = lines[i].trim();
|
||||||
if (line.startsWith('#EXTINF')) {
|
if (line.startsWith('#EXTINF')) {
|
||||||
// Extraire le nom : dernière partie après la virgule
|
// Extract the display name from the last comma-separated segment.
|
||||||
const comma = line.lastIndexOf(',');
|
const comma = line.lastIndexOf(',');
|
||||||
currentName = comma !== -1 ? line.slice(comma + 1).trim() : null;
|
currentName = comma !== -1 ? line.slice(comma + 1).trim() : null;
|
||||||
} else if (line && !line.startsWith('#') && currentName) {
|
} else if (line && !line.startsWith('#') && currentName) {
|
||||||
@@ -42,16 +42,15 @@ function getStreamUrl(channelName) {
|
|||||||
if (m3uStreams[slug]) {
|
if (m3uStreams[slug]) {
|
||||||
url = m3uStreams[slug];
|
url = m3uStreams[slug];
|
||||||
} else {
|
} else {
|
||||||
// Fallback : chercher une clé qui contient le slug ou l'inverse
|
// Fallback: try a partial match in either direction.
|
||||||
for (const [key, u] of Object.entries(m3uStreams)) {
|
for (const [key, u] of Object.entries(m3uStreams)) {
|
||||||
if (key.includes(slug) || slug.includes(key)) { url = u; break; }
|
if (key.includes(slug) || slug.includes(key)) { url = u; break; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
// Passer par proxy.php pour éviter le mixed content HTTP/HTTPS
|
// Route plain HTTP streams through proxy.php to avoid mixed-content issues.
|
||||||
if (url.startsWith('http://')) {
|
if (url.startsWith('http://')) {
|
||||||
return 'proxy.php?url=' + encodeURIComponent(url);
|
return 'proxy.php?url=' + encodeURIComponent(url);
|
||||||
}
|
}
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-3
@@ -12,7 +12,7 @@ function openPip(channelName, programTitle) {
|
|||||||
err.style.display = 'none';
|
err.style.display = 'none';
|
||||||
pip.classList.add('visible');
|
pip.classList.add('visible');
|
||||||
|
|
||||||
// Détruire l'instance HLS précédente
|
// Tear down the previous HLS instance before opening another stream.
|
||||||
if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; }
|
if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; }
|
||||||
video.src = '';
|
video.src = '';
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ function openPip(channelName, programTitle) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
// Safari natif HLS
|
// Safari can play HLS natively without hls.js.
|
||||||
video.src = url;
|
video.src = url;
|
||||||
video.addEventListener('loadedmetadata', () => video.play().catch(() => {}));
|
video.addEventListener('loadedmetadata', () => video.play().catch(() => {}));
|
||||||
} else {
|
} else {
|
||||||
@@ -55,4 +55,3 @@ function closePip() {
|
|||||||
if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; }
|
if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; }
|
||||||
video.pause(); video.src = '';
|
video.pause(); video.src = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -13,7 +13,7 @@ function openProgramModal(ch, p) {
|
|||||||
document.getElementById('pm-desc').textContent = p.desc || '';
|
document.getElementById('pm-desc').textContent = p.desc || '';
|
||||||
document.getElementById('pm-desc').style.display = p.desc ? 'block' : 'none';
|
document.getElementById('pm-desc').style.display = p.desc ? 'block' : 'none';
|
||||||
|
|
||||||
// Bouton Watch now — seulement si live ET M3U fourni pour la source active
|
// Only show "Watch now" when the program is live and the active source has M3U data.
|
||||||
const watchBtn = document.getElementById('pm-watch');
|
const watchBtn = document.getElementById('pm-watch');
|
||||||
if (isLive && currentM3uUrl) {
|
if (isLive && currentM3uUrl) {
|
||||||
watchBtn.style.display = 'inline-flex';
|
watchBtn.style.display = 'inline-flex';
|
||||||
@@ -22,7 +22,7 @@ function openProgramModal(ch, p) {
|
|||||||
watchBtn.style.display = 'none';
|
watchBtn.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bouton IMDb
|
// IMDb search shortcut.
|
||||||
document.getElementById('pm-imdb').href =
|
document.getElementById('pm-imdb').href =
|
||||||
`https://www.imdb.com/find/?q=${encodeURIComponent(p.title)}&s=tt`;
|
`https://www.imdb.com/find/?q=${encodeURIComponent(p.title)}&s=tt`;
|
||||||
|
|
||||||
@@ -33,13 +33,13 @@ function closeProgramModal() {
|
|||||||
document.getElementById('programModal').classList.remove('visible');
|
document.getElementById('programModal').classList.remove('visible');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fermer sur clic overlay
|
// Close when the overlay itself is clicked.
|
||||||
document.addEventListener('click', e => {
|
document.addEventListener('click', e => {
|
||||||
const modal = document.getElementById('programModal');
|
const modal = document.getElementById('programModal');
|
||||||
if (e.target === modal) closeProgramModal();
|
if (e.target === modal) closeProgramModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fermer sur Escape
|
// Close on Escape.
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
if (e.key === 'Escape') closeProgramModal();
|
if (e.key === 'Escape') closeProgramModal();
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-8
@@ -31,7 +31,7 @@ function onSearchInput(val) {
|
|||||||
|
|
||||||
function applySearch(q) {
|
function applySearch(q) {
|
||||||
if (!q) {
|
if (!q) {
|
||||||
// Remettre toutes les chaines
|
// Restore the full channel list when the query is cleared.
|
||||||
renderAll();
|
renderAll();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -40,11 +40,11 @@ function applySearch(q) {
|
|||||||
today.setHours(0,0,0,0);
|
today.setHours(0,0,0,0);
|
||||||
const tomorrow = new Date(today.getTime() + 24*3600000);
|
const tomorrow = new Date(today.getTime() + 24*3600000);
|
||||||
|
|
||||||
// Filtrer les chaines : nom OU programme aujourd'hui correspond
|
// Keep channels whose name matches or that have a matching program today.
|
||||||
const filtered = channels.filter(ch => {
|
const filtered = channels.filter(ch => {
|
||||||
// Match sur le nom de la chaine
|
// Match against the channel name.
|
||||||
if (ch.name.toLowerCase().includes(q)) return true;
|
if (ch.name.toLowerCase().includes(q)) return true;
|
||||||
// Match sur les programmes d'aujourd'hui (titre + desc)
|
// Match against today's programs (title + description).
|
||||||
return (programs[ch.id]||[]).some(p => {
|
return (programs[ch.id]||[]).some(p => {
|
||||||
if (p.stop < today || p.start > tomorrow) return false;
|
if (p.stop < today || p.start > tomorrow) return false;
|
||||||
if (p.title.toLowerCase().includes(q)) return true;
|
if (p.title.toLowerCase().includes(q)) return true;
|
||||||
@@ -72,7 +72,7 @@ function renderFiltered(filteredChannels, q) {
|
|||||||
inner.style.width = TOTAL_WIDTH_PX + 'px';
|
inner.style.width = TOTAL_WIDTH_PX + 'px';
|
||||||
inner.style.height = (filteredChannels.length * ROW_H) + 'px';
|
inner.style.height = (filteredChannels.length * ROW_H) + 'px';
|
||||||
|
|
||||||
// Remettre la now-line
|
// Recreate the now-line after clearing the container.
|
||||||
const nowLine = document.createElement('div');
|
const nowLine = document.createElement('div');
|
||||||
nowLine.className = 'now-line'; nowLine.id = 'nowLine';
|
nowLine.className = 'now-line'; nowLine.id = 'nowLine';
|
||||||
nowLine.style.height = (filteredChannels.length * ROW_H) + 'px';
|
nowLine.style.height = (filteredChannels.length * ROW_H) + 'px';
|
||||||
@@ -81,7 +81,7 @@ function renderFiltered(filteredChannels, q) {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
filteredChannels.forEach((ch, i) => {
|
filteredChannels.forEach((ch, i) => {
|
||||||
// Colonne chaîne
|
// Channel column.
|
||||||
const cell = document.createElement('div');
|
const cell = document.createElement('div');
|
||||||
cell.className = 'channel-cell';
|
cell.className = 'channel-cell';
|
||||||
if (ch.icon) {
|
if (ch.icon) {
|
||||||
@@ -96,7 +96,7 @@ function renderFiltered(filteredChannels, q) {
|
|||||||
cell.addEventListener('click', () => openPip(ch.name, getNowPlaying(ch.id)));
|
cell.addEventListener('click', () => openPip(ch.name, getNowPlaying(ch.id)));
|
||||||
list.appendChild(cell);
|
list.appendChild(cell);
|
||||||
|
|
||||||
// Programmes
|
// Programs row.
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'program-row';
|
row.className = 'program-row';
|
||||||
row.style.cssText = `top:${i*ROW_H}px;position:absolute;left:0;right:0;`;
|
row.style.cssText = `top:${i*ROW_H}px;position:absolute;left:0;right:0;`;
|
||||||
@@ -109,7 +109,7 @@ function renderFiltered(filteredChannels, q) {
|
|||||||
const block = document.createElement('div');
|
const block = document.createElement('div');
|
||||||
block.className = 'program-block';
|
block.className = 'program-block';
|
||||||
|
|
||||||
// Highlight si le programme matche la recherche
|
// Highlight programs that match the current search query.
|
||||||
const matchesProg = p.title.toLowerCase().includes(q) || (p.desc && p.desc.toLowerCase().includes(q));
|
const matchesProg = p.title.toLowerCase().includes(q) || (p.desc && p.desc.toLowerCase().includes(q));
|
||||||
if (matchesProg) block.classList.add('search-match');
|
if (matchesProg) block.classList.add('search-match');
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -21,7 +21,7 @@ function switchSource(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateCopyButtons() {
|
function updateCopyButtons() {
|
||||||
// Rien à faire visuellement, copyUrl() lit currentEpgUrl/currentM3uUrl
|
// No visual update is needed here because copyUrl() reads the current URLs directly.
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPersonalEpgModal() {
|
function openPersonalEpgModal() {
|
||||||
@@ -35,7 +35,7 @@ function openPersonalEpgModal() {
|
|||||||
|
|
||||||
function closePersonalEpgModal(applied) {
|
function closePersonalEpgModal(applied) {
|
||||||
document.getElementById('personalEpgModal').classList.remove('visible');
|
document.getElementById('personalEpgModal').classList.remove('visible');
|
||||||
// Si annulé (pas applied), remettre le select sur la source précédente
|
// If the modal was cancelled, restore the previously selected source.
|
||||||
if (!applied) {
|
if (!applied) {
|
||||||
const sel = document.getElementById('sourceSelect');
|
const sel = document.getElementById('sourceSelect');
|
||||||
if (sel) sel.value = localStorage.getItem('gridtv-active-source') === 'personal' ? 'personal' : activeSourceIndex;
|
if (sel) sel.value = localStorage.getItem('gridtv-active-source') === 'personal' ? 'personal' : activeSourceIndex;
|
||||||
@@ -67,7 +67,7 @@ function closeSourceDropdown() {
|
|||||||
document.getElementById('sourceDropdownMenu')?.classList.remove('open');
|
document.getElementById('sourceDropdownMenu')?.classList.remove('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fermer si clic en dehors
|
// Close the dropdown when clicking outside of it.
|
||||||
document.addEventListener('click', e => {
|
document.addEventListener('click', e => {
|
||||||
if (!e.target.closest('.source-dropdown')) closeSourceDropdown();
|
if (!e.target.closest('.source-dropdown')) closeSourceDropdown();
|
||||||
});
|
});
|
||||||
@@ -75,7 +75,7 @@ document.addEventListener('click', e => {
|
|||||||
function updateSourceDropdownLabel(label) {
|
function updateSourceDropdownLabel(label) {
|
||||||
const el = document.getElementById('sourceDropdownLabel');
|
const el = document.getElementById('sourceDropdownLabel');
|
||||||
if (el) el.textContent = label;
|
if (el) el.textContent = label;
|
||||||
// Mettre à jour la classe active
|
// Update the active item styling in the dropdown.
|
||||||
document.querySelectorAll('.source-dropdown-item').forEach((btn, i) => {
|
document.querySelectorAll('.source-dropdown-item').forEach((btn, i) => {
|
||||||
btn.classList.toggle('active', i === activeSourceIndex);
|
btn.classList.toggle('active', i === activeSourceIndex);
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-2
@@ -1,4 +1,4 @@
|
|||||||
// ── THÈMES ────────────────────────────────────────────────────────────────────
|
// ── THEMES ────────────────────────────────────────────────────────────────────
|
||||||
function setTheme(theme) {
|
function setTheme(theme) {
|
||||||
document.getElementById('themeStylesheet').href = 'themes/' + theme + '.css';
|
document.getElementById('themeStylesheet').href = 'themes/' + theme + '.css';
|
||||||
localStorage.setItem('gridtv-theme', theme);
|
localStorage.setItem('gridtv-theme', theme);
|
||||||
@@ -17,4 +17,3 @@ function startLiveUpdates() {
|
|||||||
setInterval(() => { if (channels.length) renderAll(); }, 60000);
|
setInterval(() => { if (channels.length) renderAll(); }, 60000);
|
||||||
setInterval(() => loadEPG(DEFAULT_EPG_URL), 30*60*1000);
|
setInterval(() => loadEPG(DEFAULT_EPG_URL), 30*60*1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-6
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
// Echapper les donnees issues de l'EPG pour eviter les injections HTML
|
// Escape EPG-provided strings before injecting them into HTML.
|
||||||
function esc(str) {
|
function esc(str) {
|
||||||
const d = document.createElement('div');
|
const d = document.createElement('div');
|
||||||
d.textContent = str || '';
|
d.textContent = str || '';
|
||||||
@@ -8,8 +8,8 @@ function esc(str) {
|
|||||||
|
|
||||||
let channels = [];
|
let channels = [];
|
||||||
let programs = {};
|
let programs = {};
|
||||||
let m3uStreams = {}; // slug normalisé → url stream
|
let m3uStreams = {}; // Normalized slug -> stream URL.
|
||||||
let scrollListenerAdded = false; // sync scroll vertical chaînes
|
let scrollListenerAdded = false; // Keeps the channel column vertically synced with the timeline.
|
||||||
|
|
||||||
function pad(n) { return String(n).padStart(2,'0'); }
|
function pad(n) { return String(n).padStart(2,'0'); }
|
||||||
|
|
||||||
@@ -35,6 +35,8 @@ function parseXMLTVDate(str) {
|
|||||||
if (!str) return null;
|
if (!str) return null;
|
||||||
const m = str.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{4})?/);
|
const m = str.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{4})?/);
|
||||||
if (!m) return null;
|
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 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);
|
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);
|
return new Date(Date.UTC(+m[1],+m[2]-1,+m[3],+m[4],+m[5],+m[6]) - offsetMs);
|
||||||
@@ -78,7 +80,7 @@ function loadFromInput() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseEpisode(p) {
|
function parseEpisode(p) {
|
||||||
// Format xmltv_ns : "S.E.part" (0-indexed) ex: "0.2.0/1" = S01E03
|
// 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;
|
const ns = p.querySelector('episode-num[system="xmltv_ns"]')?.textContent;
|
||||||
if (ns) {
|
if (ns) {
|
||||||
const parts = ns.split('.');
|
const parts = ns.split('.');
|
||||||
@@ -87,7 +89,7 @@ function parseEpisode(p) {
|
|||||||
const e = ePart !== '' ? parseInt(ePart) + 1 : null;
|
const e = ePart !== '' ? parseInt(ePart) + 1 : null;
|
||||||
if (s !== null || e !== null) return { season: s, episode: e };
|
if (s !== null || e !== null) return { season: s, episode: e };
|
||||||
}
|
}
|
||||||
// Format onscreen : "S01E03" ou "s1e3"
|
// onscreen format: "S01E03" or "s1e3"
|
||||||
const os = p.querySelector('episode-num[system="onscreen"]')?.textContent;
|
const os = p.querySelector('episode-num[system="onscreen"]')?.textContent;
|
||||||
if (os) {
|
if (os) {
|
||||||
const m = os.match(/[Ss](\d+)[Ee](\d+)/);
|
const m = os.match(/[Ss](\d+)[Ee](\d+)/);
|
||||||
@@ -136,4 +138,3 @@ function renderAll() {
|
|||||||
if (isMobile()) { renderMobile(); }
|
if (isMobile()) { renderMobile(); }
|
||||||
else { renderGrid(); setTimeout(centerNow, 150); }
|
else { renderGrid(); setTimeout(centerNow, 150); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-3
@@ -37,7 +37,7 @@
|
|||||||
<?php
|
<?php
|
||||||
$theme_dir = __DIR__ . '/../../themes';
|
$theme_dir = __DIR__ . '/../../themes';
|
||||||
$theme_files = glob($theme_dir . '/*.css');
|
$theme_files = glob($theme_dir . '/*.css');
|
||||||
// default en premier, puis le reste par ordre alphabétique
|
// Put the default theme first, then sort the rest alphabetically.
|
||||||
usort($theme_files, function($a, $b) {
|
usort($theme_files, function($a, $b) {
|
||||||
$a_base = basename($a, '.css');
|
$a_base = basename($a, '.css');
|
||||||
$b_base = basename($b, '.css');
|
$b_base = basename($b, '.css');
|
||||||
@@ -48,7 +48,7 @@ usort($theme_files, function($a, $b) {
|
|||||||
foreach ($theme_files as $file) {
|
foreach ($theme_files as $file) {
|
||||||
$slug = basename($file, '.css');
|
$slug = basename($file, '.css');
|
||||||
$css = file_get_contents($file);
|
$css = file_get_contents($file);
|
||||||
// Lire @name et @emoji dans le premier bloc commentaire
|
// Read @name and @emoji from the first comment block.
|
||||||
preg_match('/@name\s+(.+)/u', $css, $m_name);
|
preg_match('/@name\s+(.+)/u', $css, $m_name);
|
||||||
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);
|
||||||
@@ -64,4 +64,3 @@ foreach ($theme_files as $file) {
|
|||||||
<input class="search-input" id="searchInput" type="text" placeholder="<?= htmlspecialchars($L["search_placeholder"]) ?>" oninput="onSearchInput(this.value)" />
|
<input class="search-input" id="searchInput" type="text" placeholder="<?= htmlspecialchars($L["search_placeholder"]) ?>" oninput="onSearchInput(this.value)" />
|
||||||
<button class="search-clear" onclick="clearSearch()">✕</button>
|
<button class="search-clear" onclick="clearSearch()">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const CACHE_NAME = 'gridtv-v2';
|
const CACHE_NAME = 'gridtv-v2';
|
||||||
|
|
||||||
// Ressources statiques à mettre en cache immédiatement
|
// Static assets cached during installation.
|
||||||
const STATIC_ASSETS = [
|
const STATIC_ASSETS = [
|
||||||
'/',
|
'/',
|
||||||
'/index.php',
|
'/index.php',
|
||||||
@@ -11,7 +11,7 @@ const STATIC_ASSETS = [
|
|||||||
'/assets/vendor/hls.min.js'
|
'/assets/vendor/hls.min.js'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Install : mise en cache des assets statiques
|
// Install: cache the static assets.
|
||||||
self.addEventListener('install', e => {
|
self.addEventListener('install', e => {
|
||||||
e.waitUntil(
|
e.waitUntil(
|
||||||
caches.open(CACHE_NAME)
|
caches.open(CACHE_NAME)
|
||||||
@@ -20,7 +20,7 @@ self.addEventListener('install', e => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Activate : purger les anciens caches
|
// Activate: remove outdated caches.
|
||||||
self.addEventListener('activate', e => {
|
self.addEventListener('activate', e => {
|
||||||
e.waitUntil(
|
e.waitUntil(
|
||||||
caches.keys().then(keys =>
|
caches.keys().then(keys =>
|
||||||
@@ -29,11 +29,11 @@ self.addEventListener('activate', e => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch : stratégie selon le type de ressource
|
// Fetch: choose a caching strategy based on resource type.
|
||||||
self.addEventListener('fetch', e => {
|
self.addEventListener('fetch', e => {
|
||||||
const url = new URL(e.request.url);
|
const url = new URL(e.request.url);
|
||||||
|
|
||||||
// Flux EPG/M3U et API GitHub : network only (pas de cache)
|
// EPG/M3U feeds and the GitHub API stay network-only.
|
||||||
if (
|
if (
|
||||||
url.pathname.includes('xmltv') ||
|
url.pathname.includes('xmltv') ||
|
||||||
url.pathname.includes('.m3u') ||
|
url.pathname.includes('.m3u') ||
|
||||||
@@ -43,7 +43,7 @@ self.addEventListener('fetch', e => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assets statiques : cache first
|
// Static assets use a cache-first strategy.
|
||||||
if (
|
if (
|
||||||
e.request.destination === 'style' ||
|
e.request.destination === 'style' ||
|
||||||
e.request.destination === 'script' ||
|
e.request.destination === 'script' ||
|
||||||
@@ -63,7 +63,7 @@ self.addEventListener('fetch', e => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pages PHP (index.php) : network first, fallback cache
|
// PHP pages use network-first with cache fallback.
|
||||||
e.respondWith(
|
e.respondWith(
|
||||||
fetch(e.request)
|
fetch(e.request)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
|
|||||||
Reference in New Issue
Block a user