19 Commits
Author SHA1 Message Date
Johnnybegood90 3f83055198 version.json update 2026-03-14 13:40:47 +01:00
Johnnybegood90 c46ca14f4c version.json update 2026-03-14 13:40:31 +01:00
Johnnybegood90 ea7407f1d8 Added support for new tags in XMLTV 2026-03-14 13:38:52 +01:00
Johnnybegood90 674c13ae26 Feat: Added support for new tags in XMLTV 2026-03-14 13:36:12 +01:00
Johnnybegood90 96601b76b6 Fix HLS proxy fallback and avoid overwriting invalid config 2026-03-14 13:16:29 +01:00
Johnnybegood90 fc608ee0e0 Fix HLS proxy fallback and avoid overwriting invalid config 2026-03-14 13:16:09 +01:00
Johnnybegood90 45cf0975b0 Merge remote-tracking branch 'origin/beta'
Auto-discover locales and harden JSON config loading

- discover available locale files dynamically instead of relying on a hardcoded language list
- make config JSON decoding more defensive to avoid null-state failures on invalid or empty data
- improve startup resilience when configuration files are missing or malformed
2026-03-14 13:09:25 +01:00
Johnnybegood90 90679e9221 Auto-discover locales and harden JSON config loading
- discover available locale files dynamically instead of relying on a hardcoded language list
- make config JSON decoding more defensive to avoid null-state failures on invalid or empty data
- improve startup resilience when configuration files are missing or malformed
2026-03-14 13:08:33 +01:00
Johnnybegood90 7f500ff9ee Merge remote-tracking branch 'origin/beta'
Improve proxy redirect handling and avoid double-fetching media segments

- validate redirect chains more robustly, including scheme-relative, query-only, fragment, and normalized relative paths
- resolve media segment redirects with HEAD before streaming to avoid downloading the same segment twice
- keep strict host allowlisting in place while making HLS/CDN redirect handling more reliable
2026-03-14 12:20:28 +01:00
Johnnybegood90 bc5c9e296a Improve proxy redirect handling and avoid double-fetching media segments
- validate redirect chains more robustly, including scheme-relative, query-only, fragment, and normalized relative paths
- resolve media segment redirects with HEAD before streaming to avoid downloading the same segment twice
- keep strict host allowlisting in place while making HLS/CDN redirect handling more reliable
2026-03-14 12:20:02 +01:00
Johnnybegood90 985a417ccb security: replace corsproxy.io with local proxy fallback, Fix: Update avail. cache. 2026-03-14 12:10:20 +01:00
Johnnybegood90 27ef739845 Fix: Update avail. was cached and making mistake.. 2026-03-14 12:08:06 +01:00
Johnnybegood90 77f02361ab security: replace corsproxy.io with local proxy fallback. 2026-03-14 12:03:42 +01:00
Johnnybegood90 6f379fd18e Adjusting comments into files. 2026-03-14 04:24:06 +01:00
Johnnybegood90 8dabd2d190 Adjusting comments into files. 2026-03-14 04:23:44 +01:00
Johnnybegood90 1402d9b5fc Adjusting README.md 2026-03-14 03:51:48 +01:00
Johnnybegood90 062954a780 Adjusting README.md 2026-03-14 03:51:30 +01:00
Johnnybegood90 ad56bd85cc Add deploy workflow 2026-03-14 03:42:43 +01:00
Johnnybegood90 1959734b56 Add deploy workflow 2026-03-14 03:42:20 +01:00
24 changed files with 328 additions and 104 deletions
+32
View File
@@ -0,0 +1,32 @@
name: Deploy GridTV
on:
push:
branches:
- main
- beta
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Start SSH agent
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_KEY }}
- name: Add server to known_hosts
run: |
mkdir -p ~/.ssh
ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts
- name: Deploy main
if: github.ref == 'refs/heads/main'
run: |
ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} main
- name: Deploy beta
if: github.ref == 'refs/heads/beta'
run: |
ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} beta
+54
View File
@@ -227,6 +227,9 @@ Once submitted, `config.json` is created on the server. **The setup page becomes
--- ---
<details>
<summary>Editing the config later</summary>
### Editing the config later ### Editing the config later
You can re-open the setup page at any time using the admin key generated during first setup: You can re-open the setup page at any time using the admin key generated during first setup:
@@ -266,6 +269,11 @@ nano /var/www/gridtv/config.json
--- ---
</details>
<details>
<summary>Project structure</summary>
## 📁 Project structure ## 📁 Project structure
``` ```
@@ -327,6 +335,11 @@ gridtv/
--- ---
</details>
<details>
<summary>Themes</summary>
## 🎨 Themes ## 🎨 Themes
GridTV ships with 4 built-in themes. To add your own, create a CSS file in `themes/` with these metadata comments at the top: GridTV ships with 4 built-in themes. To add your own, create a CSS file in `themes/` with these metadata comments at the top:
@@ -347,6 +360,11 @@ Drop it in `themes/` — it appears in the theme selector automatically.
--- ---
</details>
<details>
<summary>Multiple EPG Sources</summary>
## 📡 Multiple EPG Sources ## 📡 Multiple EPG Sources
Configure as many EPG/M3U sources as you want in `config.json`. A dropdown appears in the topbar when more than one source is defined. Configure as many EPG/M3U sources as you want in `config.json`. A dropdown appears in the topbar when more than one source is defined.
@@ -355,6 +373,11 @@ If `allow_personal_epg` is `true`, a **"✏ Personal EPG"** option appears in th
--- ---
</details>
<details>
<summary>Built-in Player</summary>
## ▶️ Built-in Player ## ▶️ Built-in Player
Click on any **channel name** or **currently airing program** to open a PiP player in the bottom-right corner. Click on any **channel name** or **currently airing program** to open a PiP player in the bottom-right corner.
@@ -363,6 +386,11 @@ Requires an **M3U URL** in the active source and the **php-curl** extension. The
--- ---
</details>
<details>
<summary>Advanced configuration</summary>
## ⚙️ Advanced configuration ## ⚙️ Advanced configuration
Tweak these constants in `src/js/config.js`: Tweak these constants in `src/js/config.js`:
@@ -375,6 +403,11 @@ const ROW_H = 80; // Channel row height (px)
--- ---
</details>
<details>
<summary>EPG Compatibility</summary>
## 🧩 EPG Compatibility ## 🧩 EPG Compatibility
GridTV parses the standard **XMLTV format**. Tested with: GridTV parses the standard **XMLTV format**. Tested with:
@@ -387,6 +420,11 @@ GridTV parses the standard **XMLTV format**. Tested with:
--- ---
</details>
<details>
<summary>Internationalization</summary>
## 🌍 Internationalization ## 🌍 Internationalization
GridTV auto-detects the visitor's browser language and serves the matching locale. Supported out of the box: **English**, **French**, **Spanish**. GridTV auto-detects the visitor's browser language and serves the matching locale. Supported out of the box: **English**, **French**, **Spanish**.
@@ -402,18 +440,33 @@ GridTV will automatically pick it up for browsers with that language set — no
--- ---
</details>
<details>
<summary>Contributing</summary>
## 🤝 Contributing ## 🤝 Contributing
PRs are welcome! Got an idea, a fix, or a feature request — open an issue or send a PR. PRs are welcome! Got an idea, a fix, or a feature request — open an issue or send a PR.
--- ---
</details>
<details>
<summary>License</summary>
## 📄 License ## 📄 License
GNU Affero General Public License v3.0 (AGPLv3) GNU Affero General Public License v3.0 (AGPLv3)
--- ---
</details>
<details>
<summary>Roadmap</summary>
## 🗺️ Roadmap ## 🗺️ Roadmap
### ✅ Done ### ✅ Done
@@ -437,3 +490,4 @@ GNU Affero General Public License v3.0 (AGPLv3)
### 🔜 Coming soon ### 🔜 Coming soon
- **Dark/Light auto mode** — follow system preference when using the default theme - **Dark/Light auto mode** — follow system preference when using the default theme
- **Grid export** — export today's schedule as PDF or image - **Grid export** — export today's schedule as PDF or image
</details>
+76 -20
View File
@@ -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;
@@ -31,21 +31,72 @@ function is_allowed_url(string $url, array $allowed_hosts): bool {
return $host !== '' && in_array($host, $allowed_hosts, true); return $host !== '' && in_array($host, $allowed_hosts, true);
} }
function normalize_path(string $path): string {
$segments = explode('/', $path);
$normalized = [];
foreach ($segments as $segment) {
if ($segment === '' || $segment === '.') {
continue;
}
if ($segment === '..') {
array_pop($normalized);
continue;
}
$normalized[] = $segment;
}
return '/' . implode('/', $normalized);
}
function resolve_url(string $base, string $location): string { function resolve_url(string $base, string $location): string {
if (preg_match('#^https?://#i', $location)) return $location; if (preg_match('#^https?://#i', $location)) return $location;
$parts = parse_url($base); $parts = parse_url($base);
$origin = $parts['scheme'] . '://' . $parts['host']; $origin = $parts['scheme'] . '://' . $parts['host'];
if (!empty($parts['port'])) $origin .= ':' . $parts['port']; if (!empty($parts['port'])) $origin .= ':' . $parts['port'];
if ($location[0] === '/') return $origin . $location;
return $origin . rtrim(dirname($parts['path'] ?? '/'), '/') . '/' . $location; if (strpos($location, '//') === 0) {
return $parts['scheme'] . ':' . $location;
}
if ($location === '') {
return $base;
}
if ($location[0] === '#') {
$without_fragment = preg_replace('/#.*$/', '', $base);
return $without_fragment . $location;
}
if ($location[0] === '?') {
$path = $parts['path'] ?? '/';
return $origin . $path . $location;
}
$location_parts = parse_url($location);
$path = $location_parts['path'] ?? '';
$query = isset($location_parts['query']) ? '?' . $location_parts['query'] : '';
$fragment = isset($location_parts['fragment']) ? '#' . $location_parts['fragment'] : '';
if ($path !== '' && $path[0] === '/') {
return $origin . normalize_path($path) . $query . $fragment;
}
$base_dir = dirname($parts['path'] ?? '/');
if ($base_dir === '\\' || $base_dir === '.') {
$base_dir = '/';
}
$combined = rtrim($base_dir, '/') . '/' . $path;
return $origin . normalize_path($combined) . $query . $fragment;
} }
/** /**
* 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)
*/ */
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 $head_only = false): array {
$max_redirects = 5; $max_redirects = 5;
for ($i = 0; $i <= $max_redirects; $i++) { for ($i = 0; $i <= $max_redirects; $i++) {
@@ -59,6 +110,7 @@ function fetch_with_checked_redirects(string $url, array $allowed_hosts, bool $s
CURLOPT_FOLLOWLOCATION => false, CURLOPT_FOLLOWLOCATION => false,
CURLOPT_HEADER => true, CURLOPT_HEADER => true,
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_NOBODY => $head_only,
CURLOPT_TIMEOUT => 30, CURLOPT_TIMEOUT => 30,
CURLOPT_USERAGENT => $ua, CURLOPT_USERAGENT => $ua,
CURLOPT_HTTPHEADER => ['Accept: */*'], CURLOPT_HTTPHEADER => ['Accept: */*'],
@@ -76,7 +128,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 +143,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 +151,31 @@ 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 // Resolve redirects with HEAD first so we do not download the segment twice.
// apres avoir valide l'URL finale via fetch_with_checked_redirects en mode non-stream // Some HLS/CDN servers reject HEAD on media objects even when GET works.
[$code, , , $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, false); [$code, , , $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, true);
if ($code === 403 || $code === 405) {
[, , , $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, false);
$code = 200;
}
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 +198,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);
+14 -3
View File
@@ -1,9 +1,10 @@
<?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 (is_array($existing)) {
if (isset($existing['epg_url']) && !isset($existing['epg_sources'])) { if (isset($existing['epg_url']) && !isset($existing['epg_sources'])) {
$existing['epg_sources'] = [[ $existing['epg_sources'] = [[
'name' => 'Main', 'name' => 'Main',
@@ -14,15 +15,25 @@ 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));
} }
} }
}
$is_first_setup = !file_exists($config_path); $is_first_setup = !file_exists($config_path);
$existing = $is_first_setup ? [] : json_decode(file_get_contents($config_path), true); $existing = [];
$corrupt_config = false;
if (!$is_first_setup) {
$existing = json_decode(file_get_contents($config_path), true);
if (!is_array($existing)) {
$existing = [];
$corrupt_config = true;
$is_first_setup = true;
}
}
$admin_key = $existing['admin_key'] ?? null; $admin_key = $existing['admin_key'] ?? null;
$mode = 'setup'; $mode = 'setup';
+15 -4
View File
@@ -8,8 +8,12 @@ if (!file_exists($config_path)) {
} }
$config = json_decode(file_get_contents($config_path), true); $config = json_decode(file_get_contents($config_path), true);
if (!is_array($config)) {
http_response_code(500);
die('<h2>GridTV — Configuration error</h2><p><code>config.json</code> is invalid or corrupted.<br>Please delete it and re-run <a href="setup.php">setup</a>, or fix it manually via SSH.</p>');
}
// ── 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,21 +30,28 @@ $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'] ?? '');
// ── Locale detection ───────────────────────────────────────────────────────── // ── Locale detection ─────────────────────────────────────────────────────────
$supported_locales = ['en', 'fr', 'es']; // Auto-discover available locales from the locales/ directory.
$locale_files = glob(__DIR__ . '/../locales/*.json') ?: [];
$supported_locales = array_map(fn($f) => basename($f, '.json'), $locale_files);
$locale = 'en'; $locale = 'en';
$accept = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? 'en'; $accept = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? 'en';
foreach (explode(',', $accept) as $lang) { foreach (explode(',', $accept) as $lang) {
$code = strtolower(substr(trim($lang), 0, 2)); $code = strtolower(substr(trim($lang), 0, 2));
if (in_array($code, $supported_locales)) { $locale = $code; break; } if (in_array($code, $supported_locales, true)) { $locale = $code; break; }
} }
$locale_path = __DIR__ . "/../locales/{$locale}.json"; $locale_path = __DIR__ . "/../locales/{$locale}.json";
$L = json_decode(file_get_contents($locale_path), true); $L = json_decode(file_get_contents($locale_path), true);
if (!is_array($L)) {
// Fallback to English if the locale file is missing or corrupted.
$fallback_path = __DIR__ . '/../locales/en.json';
$L = is_file($fallback_path) ? (json_decode(file_get_contents($fallback_path), true) ?? []) : [];
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
+1 -1
View File
@@ -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
View File
@@ -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;
+13 -2
View File
@@ -17,8 +17,19 @@
font-size: 20px; font-weight: 700; color: var(--text); font-size: 20px; font-weight: 700; color: var(--text);
margin-bottom: 8px; line-height: 1.2; margin-bottom: 8px; line-height: 1.2;
} }
.pm-subtitle {
font-size: 13px; color: var(--text); margin-bottom: 10px;
font-style: italic;
}
.pm-time { .pm-time {
font-size: 12px; color: var(--accent); margin-bottom: 14px; font-size: 12px; color: var(--accent); margin-bottom: 10px;
}
.pm-meta {
font-size: 12px; color: var(--text-muted); margin-bottom: 10px;
}
.pm-genres {
font-size: 12px; color: var(--text); margin-bottom: 12px;
font-weight: 600;
} }
.pm-desc { .pm-desc {
font-size: 13px; color: var(--text-muted); line-height: 1.6; font-size: 13px; color: var(--text-muted); line-height: 1.6;
@@ -39,7 +50,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
View File
@@ -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
View File
@@ -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); }
+3 -3
View File
@@ -2,16 +2,16 @@ 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 ?? '';
const DEFAULT_M3U_URL = EPG_SOURCES[0]?.m3u_url ?? ''; const DEFAULT_M3U_URL = EPG_SOURCES[0]?.m3u_url ?? '';
const PX_PER_MIN = 5; const PX_PER_MIN = 5;
const GRID_HOURS = 72; const GRID_HOURS = 72;
const CORS_PROXY = 'https://corsproxy.io/?'; const CORS_PROXY = '/proxy.php?url=';
// 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
View File
@@ -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
View File
@@ -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
View File
@@ -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 = '';
} }
+12 -4
View File
@@ -4,16 +4,24 @@ function openProgramModal(ch, p) {
const isLive = p.start <= now && p.stop > now; const isLive = p.start <= now && p.stop > now;
const dur = Math.round((p.stop - p.start) / 60000); const dur = Math.round((p.stop - p.start) / 60000);
const ep = fmtEpisode(p.season, p.episode); const ep = fmtEpisode(p.season, p.episode);
const genres = formatCategories(p.categories);
const meta = [p.airDate, p.rating].filter(Boolean).join(' · ');
document.getElementById('pm-title').textContent = p.title; document.getElementById('pm-title').textContent = p.title;
document.getElementById('pm-channel').textContent = ch.name; document.getElementById('pm-channel').textContent = ch.name;
document.getElementById('pm-subtitle').textContent = p.subtitle || '';
document.getElementById('pm-subtitle').style.display = p.subtitle ? 'block' : 'none';
document.getElementById('pm-time').textContent = document.getElementById('pm-time').textContent =
`${fmtTime(p.start)}${fmtTime(p.stop)} · ${dur} ${L.duration_min}` + `${fmtTime(p.start)}${fmtTime(p.stop)} · ${dur} ${L.duration_min}` +
(ep ? ` · ${ep}` : ''); (ep ? ` · ${ep}` : '');
document.getElementById('pm-meta').textContent = meta;
document.getElementById('pm-meta').style.display = meta ? 'block' : 'none';
document.getElementById('pm-genres').textContent = genres;
document.getElementById('pm-genres').style.display = genres ? 'block' : 'none';
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 +30,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 +41,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();
}); });
+17 -10
View File
@@ -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,15 +40,16 @@ 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;
if (p.desc && p.desc.toLowerCase().includes(q)) return true; if (p.desc && p.desc.toLowerCase().includes(q)) return true;
if ((p.categories || []).some(cat => cat.toLowerCase().includes(q))) return true;
return false; return false;
}); });
}); });
@@ -72,7 +73,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 +82,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 +97,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,8 +110,11 @@ 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)) ||
(p.categories || []).some(cat => cat.toLowerCase().includes(q));
if (matchesProg) block.classList.add('search-match'); if (matchesProg) block.classList.add('search-match');
if (p.stop < now) block.classList.add('is-past'); if (p.stop < now) block.classList.add('is-past');
@@ -168,7 +172,10 @@ function renderMobileFiltered(filteredChannels, q) {
const isLive = p.start <= now && p.stop > now; const isLive = p.start <= now && p.stop > now;
const isPast = p.stop < now; const isPast = p.stop < now;
const dur = Math.round((p.stop - p.start) / 60000); const dur = Math.round((p.stop - p.start) / 60000);
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)) ||
(p.categories || []).some(cat => cat.toLowerCase().includes(q));
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'mobile-program' + (isLive ? ' is-live' : '') + (isPast ? ' is-past' : '') + (matchesProg ? ' search-match' : ''); item.className = 'mobile-program' + (isLive ? ' is-live' : '') + (isPast ? ' is-past' : '') + (matchesProg ? ' search-match' : '');
+4 -4
View File
@@ -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
View File
@@ -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 -4
View File
@@ -8,8 +8,13 @@ function getNowPlaying(chId) {
// ── TOOLTIP ─────────────────────────────────────────────────────────────────── // ── TOOLTIP ───────────────────────────────────────────────────────────────────
function showTooltip(e, p) { function showTooltip(e, p) {
const ep = fmtEpisode(p.season, p.episode); const ep = fmtEpisode(p.season, p.episode);
document.getElementById('tt-title').textContent = p.title + (ep ? ' ' + ep : ''); const genres = formatCategories(p.categories);
document.getElementById('tt-time').textContent = `${fmtTime(p.start)} ${fmtTime(p.stop)} · ${Math.round((p.stop-p.start)/60000)} min`; const title = p.title + (p.subtitle ? ` - ${p.subtitle}` : '') + (ep ? ' ' + ep : '');
const meta = [p.airDate, p.rating, genres].filter(Boolean).join(' · ');
document.getElementById('tt-title').textContent = title;
document.getElementById('tt-time').textContent =
`${fmtTime(p.start)}${fmtTime(p.stop)} · ${Math.round((p.stop-p.start)/60000)} min` +
(meta ? ` · ${meta}` : '');
document.getElementById('tt-desc').textContent = p.desc || '—'; document.getElementById('tt-desc').textContent = p.desc || '—';
document.getElementById('tooltip').classList.add('visible'); document.getElementById('tooltip').classList.add('visible');
moveTooltip(e); moveTooltip(e);
@@ -23,5 +28,3 @@ function moveTooltip(e) {
function hideTooltip() { document.getElementById('tooltip').classList.remove('visible'); } function hideTooltip() { document.getElementById('tooltip').classList.remove('visible'); }
+40 -7
View File
@@ -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+)/);
@@ -104,6 +106,34 @@ function fmtEpisode(season, episode) {
return null; 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 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) { function parseAndRender(doc) {
channels = []; programs = {}; channels = []; programs = {};
doc.querySelectorAll('channel').forEach(ch => { doc.querySelectorAll('channel').forEach(ch => {
@@ -122,9 +152,13 @@ function parseAndRender(doc) {
const start = parseXMLTVDate(p.getAttribute('start')); const start = parseXMLTVDate(p.getAttribute('start'));
const stop = parseXMLTVDate(p.getAttribute('stop')); const stop = parseXMLTVDate(p.getAttribute('stop'));
const title = p.querySelector('title')?.textContent || ''; const title = p.querySelector('title')?.textContent || '';
const subtitle = p.querySelector('sub-title')?.textContent || '';
const desc = p.querySelector('desc')?.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); const epInfo = parseEpisode(p);
if (start && stop) programs[chId].push({ start, stop, title, desc, ...epInfo }); 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)); Object.keys(programs).forEach(id => programs[id].sort((a,b) => a.start - b.start));
@@ -136,4 +170,3 @@ function renderAll() {
if (isMobile()) { renderMobile(); } if (isMobile()) { renderMobile(); }
else { renderGrid(); setTimeout(centerNow, 150); } else { renderGrid(); setTimeout(centerNow, 150); }
} }
+3
View File
@@ -4,7 +4,10 @@
<button class="pm-close" onclick="closeProgramModal()">&#10005;</button> <button class="pm-close" onclick="closeProgramModal()">&#10005;</button>
<div class="pm-channel" id="pm-channel"></div> <div class="pm-channel" id="pm-channel"></div>
<div class="pm-title" id="pm-title"></div> <div class="pm-title" id="pm-title"></div>
<div class="pm-subtitle" id="pm-subtitle"></div>
<div class="pm-time" id="pm-time"></div> <div class="pm-time" id="pm-time"></div>
<div class="pm-meta" id="pm-meta"></div>
<div class="pm-genres" id="pm-genres"></div>
<div class="pm-desc" id="pm-desc"></div> <div class="pm-desc" id="pm-desc"></div>
<div class="pm-actions"> <div class="pm-actions">
<button class="pm-btn pm-watch" id="pm-watch">&#9654; <?= htmlspecialchars($L["watch_now"]) ?></button> <button class="pm-btn pm-watch" id="pm-watch">&#9654; <?= htmlspecialchars($L["watch_now"]) ?></button>
+2 -3
View File
@@ -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()">&#10005;</button> <button class="search-clear" onclick="clearSearch()">&#10005;</button>
</div> </div>
+9 -8
View File
@@ -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,21 +29,22 @@ 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, the GitHub API, and version.json stay network-only.
if ( if (
url.pathname.includes('xmltv') || url.pathname.includes('xmltv') ||
url.pathname.includes('.m3u') || url.pathname.includes('.m3u') ||
url.hostname === 'api.github.com' || url.hostname === 'api.github.com' ||
url.pathname.includes('proxy.php') url.pathname.includes('proxy.php') ||
url.pathname.endsWith('version.json')
) { ) {
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 +64,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 => {
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"version": "1.4.3" "version": "1.5.0"
} }