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.

+


@@ -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);
+}