diff --git a/README.md b/README.md index b2e195b..2cc9a07 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ > A real-time IPTV TV guide, built for [Tunarr](https://github.com/chrisbenincasa/tunarr) and any XMLTV/M3U source. ![PHP](https://img.shields.io/badge/PHP-8.0+-777BB4?style=flat-square&logo=php&logoColor=white) +![Apache](https://img.shields.io/badge/Apache-ready-D22128?style=flat-square&logo=apache&logoColor=white) ![Nginx](https://img.shields.io/badge/Nginx-ready-009639?style=flat-square&logo=nginx&logoColor=white) ![License](https://img.shields.io/badge/license-AGPLv3-blue?style=flat-square) @@ -30,9 +31,12 @@ This demo runs with a sample XMLTV feed and fake channels to showcase the interf - πŸ•ΆοΈ **Past programs** automatically dimmed - πŸ“‹ **Hover tooltip** β€” title, synopsis, season/episode, schedule, duration - πŸ”— **One-click copy** buttons for EPG & M3U URLs in the topbar +- ▢️ **Built-in HLS player** β€” click any channel or live program to watch in a PiP overlay +- πŸ”€ **HTTPβ†’HTTPS proxy** β€” streams Tunarr over HTTP transparently from an HTTPS page +- 🎨 **Theme system** β€” drop a CSS file in `themes/` and it appears in the menu automatically - βš™οΈ **Guided setup** on first launch β€” no config files to edit manually - πŸ”„ **Auto-reload** EPG every 30 minutes -- 0️⃣ **Zero dependencies** β€” vanilla PHP + nginx, that's it +- 0️⃣ **Zero JS dependencies** β€” vanilla PHP, hls.js loaded from CDN --- @@ -40,10 +44,10 @@ This demo runs with a sample XMLTV feed and fake channels to showcase the interf ### Requirements -- A web server running **PHP 8.0+** with **php-fpm** -- **Nginx** (or Apache) +- A web server running **PHP 8.0+** with **php-curl** extension +- **Apache** or **Nginx** - An **EPG source in XMLTV format** (e.g. Tunarr, Jellyfin, xTeVe...) -- *(Optional)* An **M3U playlist** +- *(Optional)* An **M3U playlist** β€” required for the built-in player --- @@ -59,14 +63,13 @@ cd /var/www/gridtv ### 2. Set permissions ```bash -# The web server needs write access to create config.json during setup chmod 775 /var/www/gridtv chown -R www-data:www-data /var/www/gridtv ``` --- -### 3. Configure Nginx +### 3a. Configure Nginx ```nginx server { @@ -95,7 +98,42 @@ nginx -t && systemctl reload nginx --- -### 4. First launch β€” Setup +### 3b. Configure Apache + +```apache + + ServerName guide.your-domain.com + DocumentRoot /var/www/gridtv + + + AllowOverride All + Require all granted + + +``` + +```bash +a2enmod php8.4 rewrite +systemctl reload apache2 +``` + +--- + +### 4. Install php-curl + +The built-in player uses `proxy.php` to relay HTTP streams over HTTPS. This requires the **php-curl** extension: + +```bash +# Debian/Ubuntu β€” adjust version to match your PHP +apt install php8.4-curl + +# Then reload the web server +systemctl reload apache2 # or: systemctl reload nginx +``` + +--- + +### 5. First launch β€” Setup Open your browser at `http://guide.your-domain.com`. @@ -105,16 +143,15 @@ GridTV detects the missing config and automatically redirects you to the setup p |---|---| | **Group name** | Displayed top-left in the topbar (e.g. *MyTV*, *FamilyTV*...) | | **EPG URL (XMLTV)** | Your electronic program guide URL | -| **M3U URL** *(optional)* | Your IPTV playlist, for quick copy from the topbar | +| **M3U URL** *(optional)* | Your IPTV playlist β€” used by the built-in player to match channels to streams | Once submitted, `config.json` is created on the server. **The setup page becomes inaccessible.** --- -### 5. Editing the config later +### 6. Editing the config later ```bash -# Via SSH nano /var/www/gridtv/config.json ``` @@ -134,8 +171,14 @@ nano /var/www/gridtv/config.json gridtv/ β”œβ”€β”€ index.php # The TV guide (redirects to setup if no config found) β”œβ”€β”€ setup.php # First-launch configuration page -β”œβ”€β”€ config.example.json # Config template (copy to config.json and fill in) +β”œβ”€β”€ proxy.php # HTTPβ†’HTTPS stream proxy (required for the player) +β”œβ”€β”€ config.example.json # Config template β”œβ”€β”€ .gitignore # config.json excluded from the repo +β”œβ”€β”€ themes/ # CSS theme files +β”‚ β”œβ”€β”€ default.css # Dark studio (default) +β”‚ β”œβ”€β”€ magazine.css # Vintage newspaper +β”‚ β”œβ”€β”€ cyberpunk.css # Neon on black +β”‚ └── steampunk.css # Victorian copper └── README.md ``` @@ -143,15 +186,47 @@ gridtv/ --- +## 🎨 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: + +```css +/* + * @name My Theme + * @emoji πŸŒ™ + */ + +:root { + --bg: #0a0b0d; + --accent: #e8c842; + /* ... */ +} +``` + +Drop it in `themes/` β€” it appears in the theme selector automatically, no code changes needed. + +--- + +## ▢️ Built-in Player + +Click on any **channel name** or **currently airing program** to open a PiP (picture-in-picture) player in the bottom-right corner. + +The player requires: +- An **M3U URL** configured in setup (used to match channel names to stream URLs) +- The **php-curl** extension installed on the server + +If your stream source (e.g. Tunarr) serves streams over HTTP while GridTV runs on HTTPS, `proxy.php` handles the relay transparently β€” no browser mixed-content errors. + +--- + ## βš™οΈ Advanced configuration The following constants can be tweaked directly in `index.php`: ```js -const PX_PER_MIN = 5; // Horizontal zoom (pixels per minute) -const HOURS_BEFORE = 1; // Past hours visible in the grid -const HOURS_AFTER = 3; // Future hours visible in the grid -const ROW_H = 80; // Channel row height (px) +const PX_PER_MIN = 5; // Horizontal zoom (pixels per minute) +const GRID_HOURS = 72; // Total timeline duration (hours) +const ROW_H = 80; // Channel row height (px) ``` --- diff --git a/index.php b/index.php index fb97ee1..3342197 100644 --- a/index.php +++ b/index.php @@ -630,6 +630,86 @@ $m3u_url = htmlspecialchars($config["m3u_url"] ?? ""); .tooltip { display: none !important; } #navBtns { display: none !important; } } + + /* ═══════════════════════════════════════════ + PLAYER PiP + ═══════════════════════════════════════════ */ + .pip { + position: fixed; + bottom: 24px; + right: 24px; + width: 380px; + background: #000; + border: 1px solid var(--border-bright); + box-shadow: 0 8px 32px rgba(0,0,0,0.8); + z-index: 2000; + display: none; + flex-direction: column; + } + .pip.visible { display: flex; } + .pip-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + background: var(--surface); + border-bottom: 1px solid var(--border); + gap: 8px; + flex-shrink: 0; + } + .pip-channel { + font-family: var(--font-mono); + font-size: 11px; + color: var(--accent); + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + } + .pip-program { + font-size: 11px; + color: var(--text-dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 2; + text-align: center; + } + .pip-close { + background: none; + border: none; + color: var(--text-dim); + font-size: 18px; + cursor: pointer; + padding: 0 4px; + line-height: 1; + flex-shrink: 0; + transition: color 0.15s; + } + .pip-close:hover { color: var(--accent-live); } + .pip video { + width: 100%; + aspect-ratio: 16/9; + display: block; + background: #000; + } + .pip-error { + padding: 20px; + text-align: center; + font-family: var(--font-mono); + font-size: 11px; + color: var(--accent-live); + letter-spacing: 0.06em; + } + .channel-cell { cursor: pointer; } + .channel-cell:hover { background: var(--surface2); } + .program-block.is-live { cursor: pointer; } + @media (max-width: 600px) { + .pip { width: calc(100vw - 16px); bottom: 8px; right: 8px; left: 8px; } + } + @@ -1125,12 +1205,21 @@ function parseM3U(text) { 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; + let url = null; + if (m3uStreams[slug]) { + url = m3uStreams[slug]; + } else { + // Fallback : chercher une clΓ© qui contient le slug ou l'inverse + for (const [key, u] of Object.entries(m3uStreams)) { + if (key.includes(slug) || slug.includes(key)) { url = u; break; } + } } - return null; + if (!url) return null; + // Passer par proxy.php pour Γ©viter le mixed content HTTP/HTTPS + if (url.startsWith('http://')) { + return 'proxy.php?url=' + encodeURIComponent(url); + } + return url; } // ── PLAYER PiP ──────────────────────────────────────────────────────────────── @@ -1158,12 +1247,20 @@ function openPip(channelName, programTitle) { } if (Hls.isSupported()) { - hlsInstance = new Hls({ enableWorker: true }); + hlsInstance = new Hls({ enableWorker: false, debug: false }); hlsInstance.loadSource(url); hlsInstance.attachMedia(video); - hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => {})); + hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => { + console.log('[PiP] Manifest parsed, lecture...'); + video.play().catch(e => console.warn('[PiP] play() refusΓ©:', e)); + }); hlsInstance.on(Hls.Events.ERROR, (e, data) => { - if (data.fatal) { err.style.display = 'block'; err.textContent = '⚠ Erreur de lecture du stream'; } + console.error('[PiP] HLS error:', data.type, data.details, data.fatal, data.response); + if (data.fatal) { + const detail = data.response ? ` (HTTP ${data.response.code})` : ` (${data.details})`; + err.style.display = 'block'; + err.textContent = '⚠ Erreur de lecture du stream' + detail; + } }); } else if (video.canPlayType('application/vnd.apple.mpegurl')) { // Safari natif HLS diff --git a/proxy.php b/proxy.php new file mode 100644 index 0000000..4e5610e --- /dev/null +++ b/proxy.php @@ -0,0 +1,101 @@ + true, + CURLOPT_TIMEOUT => 30, + CURLOPT_USERAGENT => $ua, + CURLOPT_HTTPHEADER => ['Accept: */*'], + CURLOPT_RETURNTRANSFER => false, + CURLOPT_WRITEFUNCTION => function($ch, $data) { + echo $data; + flush(); + return strlen($data); + }, + CURLOPT_HEADERFUNCTION => function($ch, $header) { + $h = trim($header); + if (preg_match('/^Content-Type:/i', $h)) { + header($h); + } + return strlen($header); + }, + ]); + + $ok = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + if (!$ok || $code >= 400) { + http_response_code($code ?: 502); + } + curl_close($ch); + +} else { + // Playlist .m3u8 β€” fetch + réécriture URLs + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 15, + CURLOPT_USERAGENT => $ua, + CURLOPT_HTTPHEADER => ['Accept: */*'], + CURLOPT_RETURNTRANSFER => true, + ]); + $body = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($body === false || $code >= 400) { + http_response_code($code ?: 502); + die("Upstream error $code"); + } + + header('Content-Type: application/vnd.apple.mpegurl'); + + $proxy_base = (isset($_SERVER['HTTPS']) ? 'https' : 'http') + . '://' . $_SERVER['HTTP_HOST'] + . strtok($_SERVER['REQUEST_URI'], '?') + . '?url='; + + $out = []; + foreach (explode("\n", $body) as $line) { + $line = rtrim($line); + if ($line === '' || $line[0] === '#') { + $line = preg_replace_callback('/URI="([^"]+)"/', function($m) use ($base, $proxy_base) { + $seg = strpos($m[1], 'http') === 0 ? $m[1] + : ($m[1][0] === '/' ? $origin . $m[1] : $base . $m[1]); + return 'URI="' . $proxy_base . urlencode($seg) . '"'; + }, $line); + $out[] = $line; + } else { + $seg = strpos($line, 'http') === 0 ? $line + : ($line[0] === '/' ? $origin . $line : $base . $line); + $out[] = $proxy_base . urlencode($seg); + } + } + echo implode("\n", $out); +}