diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3bf31f9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM php:8.2-apache + +RUN docker-php-ext-install curl && \ + a2enmod rewrite + +COPY . /var/www/html/ + +RUN chown -R www-data:www-data /var/www/html && \ + chmod -R 775 /var/www/html + +EXPOSE 80 diff --git a/README.md b/README.md index 002c2cf..c47c40c 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ ![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) +![Docker](https://img.shields.io/badge/Docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white) ![License](https://img.shields.io/badge/license-AGPLv3-blue?style=flat-square) --- @@ -19,7 +20,7 @@ A public demo instance is available here: ๐Ÿ‘‰ https://guide.demo.johnnybegood.fr/ -This demo runs with a sample XMLTV feed and fake channels to showcase the interface. +This demo runs with sample XMLTV feeds to showcase the interface. --- @@ -35,7 +36,7 @@ This demo runs with a sample XMLTV feed and fake channels to showcase the interf - ๐Ÿ”€ **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 - ๐Ÿ“ก **Multi-EPG sources** โ€” configure multiple EPG/M3U sources, switch from the topbar -- ๐Ÿ‘ค **Personal EPG** โ€” optionally let visitors use your instance with their own EPG/M3U +- ๐Ÿ‘ค **Personal EPG** โ€” optionally let visitors use your instance with their own EPG/M3U URLs (saved in localStorage) - โš™๏ธ **Guided setup** on first launch โ€” no config files to edit manually - ๐Ÿ”„ **Auto-reload** EPG every 30 minutes - 0๏ธโƒฃ **Zero JS dependencies** โ€” vanilla PHP, hls.js loaded from CDN @@ -47,31 +48,47 @@ 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-curl** extension -- **Apache** or **Nginx** +- **Apache** or **Nginx** โ€” or just use **Docker** - An **EPG source in XMLTV format** (e.g. Tunarr, Jellyfin, xTeVe...) - *(Optional)* An **M3U playlist** โ€” required for the built-in player --- -### 1. Clone the repo +### Option A โ€” Docker (recommended) + +```bash +git clone https://github.com/Johnnybegood90/GridTV.git +cd GridTV +docker compose up -d +``` + +Then open `http://localhost:8080` and follow the setup wizard. + +To run on a custom port: + +```bash +PORT=9000 docker compose up -d +``` + +--- + +### Option B โ€” Apache / Nginx + +#### 1. Clone the repo ```bash git clone https://github.com/Johnnybegood90/GridTV.git /var/www/gridtv cd /var/www/gridtv ``` ---- - -### 2. Set permissions +#### 2. Set permissions ```bash chmod 775 /var/www/gridtv chown -R www-data:www-data /var/www/gridtv ``` ---- - -### 3a. Configure Nginx +#### 3a. Configure Nginx ```nginx server { @@ -98,9 +115,7 @@ server { nginx -t && systemctl reload nginx ``` ---- - -### 3b. Configure Apache +#### 3b. Configure Apache ```apache @@ -119,23 +134,17 @@ a2enmod php8.4 rewrite systemctl reload apache2 ``` ---- - -### 4. Install php-curl +#### 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 +#### 5. First launch โ€” Setup Open your browser at `http://guide.your-domain.com`. @@ -147,11 +156,19 @@ GridTV detects the missing config and automatically redirects you to the setup p | **EPG sources** | Add one or more XMLTV sources, each with an optional M3U URL | | **Personal EPG** | Toggle to allow visitors to use your instance with their own EPG/M3U | -Once submitted, `config.json` is created on the server. **The setup page becomes inaccessible.** +Once submitted, `config.json` is created on the server. **The setup page becomes inaccessible until you re-enter your admin key.** --- -### 6. 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: + +``` +http://guide.your-domain.com/setup.php +``` + +Or edit `config.json` directly: ```bash nano /var/www/gridtv/config.json @@ -176,7 +193,7 @@ nano /var/www/gridtv/config.json } ``` -> Instances running the old single-source format (`epg_url` at root) are **migrated automatically** on first load โ€” no manual action needed. +> Instances running the old single-source format (`epg_url` at root) are **migrated automatically** on first load. --- @@ -184,17 +201,37 @@ 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 -โ”œโ”€โ”€ proxy.php # HTTPโ†’HTTPS stream proxy (required for the player) +โ”œโ”€โ”€ index.php # Entry point +โ”œโ”€โ”€ setup.php # First-launch + re-configuration page +โ”œโ”€โ”€ proxy.php # HTTPโ†’HTTPS stream proxy โ”œโ”€โ”€ config.example.json # Config template -โ”œโ”€โ”€ .gitignore # config.json excluded from the repo +โ”œโ”€โ”€ Dockerfile +โ”œโ”€โ”€ docker-compose.yml +โ”œโ”€โ”€ .gitignore # config.json excluded โ”œโ”€โ”€ themes/ # CSS theme files -โ”‚ โ”œโ”€โ”€ default.css # Dark studio (default) -โ”‚ โ”œโ”€โ”€ magazine.css # Vintage newspaper -โ”‚ โ”œโ”€โ”€ cyberpunk.css # Neon on black -โ”‚ โ””โ”€โ”€ steampunk.css # Victorian copper -โ””โ”€โ”€ README.md +โ”‚ โ”œโ”€โ”€ default.css +โ”‚ โ”œโ”€โ”€ magazine.css +โ”‚ โ”œโ”€โ”€ cyberpunk.css +โ”‚ โ””โ”€โ”€ steampunk.css +โ””โ”€โ”€ src/ + โ”œโ”€โ”€ config.php # Config loader + migration + โ”œโ”€โ”€ tpl/ # HTML templates + โ”‚ โ”œโ”€โ”€ head.php + โ”‚ โ”œโ”€โ”€ topbar.php + โ”‚ โ”œโ”€โ”€ grid.php + โ”‚ โ”œโ”€โ”€ modals.php + โ”‚ โ””โ”€โ”€ footer.php + โ””โ”€โ”€ js/ # JavaScript modules + โ”œโ”€โ”€ config.js # Constants + PHP-injected vars + โ”œโ”€โ”€ utils.js # Helpers, clock, EPG fetch + โ”œโ”€โ”€ epg.js # Grid rendering + โ”œโ”€โ”€ mobile.js # Mobile list view + โ”œโ”€โ”€ tooltip.js # Hover tooltip + โ”œโ”€โ”€ sources.js # Multi-EPG switcher + โ”œโ”€โ”€ m3u.js # M3U parser + โ”œโ”€โ”€ player.js # HLS PiP player + โ”œโ”€โ”€ themes.js # Theme switcher + โ””โ”€โ”€ live.js # Live updates + responsive ``` > `config.json` is listed in `.gitignore` โ€” your private URLs will never be pushed to GitHub. @@ -214,11 +251,10 @@ GridTV ships with 4 built-in themes. To add your own, create a CSS file in `them :root { --bg: #0a0b0d; --accent: #e8c842; - /* ... */ } ``` -Drop it in `themes/` โ€” it appears in the theme selector automatically, no code changes needed. +Drop it in `themes/` โ€” it appears in the theme selector automatically. --- @@ -232,19 +268,15 @@ If `allow_personal_epg` is `true`, a **"โœ Personal EPG"** option appears in th ## โ–ถ๏ธ 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. +Click on any **channel name** or **currently airing program** to open a PiP player in the bottom-right corner. -The player requires: -- An **M3U URL** configured in the active source (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. +Requires an **M3U URL** in the active source and the **php-curl** extension. The `proxy.php` handles HTTPโ†’HTTPS relay transparently. --- ## โš™๏ธ Advanced configuration -The following constants can be tweaked directly in `index.php`: +Tweak these constants in `src/js/config.js`: ```js const PX_PER_MIN = 5; // Horizontal zoom (pixels per minute) @@ -260,17 +292,17 @@ GridTV parses the standard **XMLTV format**. Tested with: - โœ… [Tunarr](https://github.com/chrisbenincasa/tunarr) - โœ… [xTeVe](https://github.com/xteve-project/xTeVe) -- โœ… [Jellyfin](https://jellyfin.org/) (via LiveTV plugin) -- โœ… Any XMLTV-compliant file +- โœ… [Jellyfin](https://jellyfin.org/) +- โœ… Any XMLTV-compliant source --- ## ๐Ÿค Contributing -PRs are welcome! Got an idea, a fix, or a feature request โ€” open an issue or send a PR directly. +PRs are welcome! Got an idea, a fix, or a feature request โ€” open an issue or send a PR. --- ## ๐Ÿ“„ License -GNU Affero (AGPLv3) +GNU Affero General Public License v3.0 (AGPLv3) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..12629bc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + gridtv: + build: . + ports: + - "${PORT:-8080}:80" + volumes: + - ./config.json:/var/www/html/config.json + restart: unless-stopped diff --git a/index.php b/index.php index 2f1ab46..d94472c 100644 --- a/index.php +++ b/index.php @@ -1,1519 +1,8 @@ 'Main', - 'epg_url' => $config['epg_url'], - 'm3u_url' => $config['m3u_url'] ?? '', - ]]; - $config['allow_personal_epg'] = false; - unset($config['epg_url'], $config['m3u_url']); - file_put_contents($config_path, json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); -} -// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -$group_name = htmlspecialchars($config['group_name'] ?? 'GridTV'); -$epg_sources = $config['epg_sources'] ?? []; -$allow_personal_epg = !empty($config['allow_personal_epg']); - -// Premiรจre source par dรฉfaut (pour compatibilitรฉ avec le reste du code) -$first_source = $epg_sources[0] ?? []; -$epg_url = htmlspecialchars($first_source['epg_url'] ?? ''); -$m3u_url = htmlspecialchars($first_source['m3u_url'] ?? ''); +require_once __DIR__ . '/src/config.php'; ?> - - - - - -GridTV โ€” <?= $group_name ?> - - - - - - - -
-
Chargement du guide
-
-
- -
-
โš  Impossible de charger l'EPG
-
Vรฉrifiez l'URL de votre flux XMLTV.
-
- - -
-
- -
-
-
-
-
- -
- -
-
00:00:00
- -
- - - - -
- 1 || $allow_personal_epg): ?> - - - -
LIVE
-
- - -
-
-
โ€”
-
- -
- -
โš  Stream indisponible
-
- - -
-
-
Chaรฎnes
-
-
-
-
-
-
-
-
-
-
-
- - -
- - - - - - - - + + + + + diff --git a/src/config.php b/src/config.php new file mode 100644 index 0000000..da6c8d2 --- /dev/null +++ b/src/config.php @@ -0,0 +1,39 @@ + 'Main', + 'epg_url' => $config['epg_url'], + 'm3u_url' => $config['m3u_url'] ?? '', + ]]; + $config['allow_personal_epg'] = false; + unset($config['epg_url'], $config['m3u_url']); + file_put_contents($config_path, json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); +} +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +$group_name = htmlspecialchars($config['group_name'] ?? 'GridTV'); +$epg_sources = $config['epg_sources'] ?? []; +$allow_personal_epg = !empty($config['allow_personal_epg']); + +// Premiรจre source par dรฉfaut (pour compatibilitรฉ avec le reste du code) +$first_source = $epg_sources[0] ?? []; +$epg_url = htmlspecialchars($first_source['epg_url'] ?? ''); +$m3u_url = htmlspecialchars($first_source['m3u_url'] ?? ''); +?> + + + + + +GridTV โ€” <?= $group_name ?> diff --git a/src/js/config.js b/src/js/config.js new file mode 100644 index 0000000..313521c --- /dev/null +++ b/src/js/config.js @@ -0,0 +1,20 @@ +const EPG_SOURCES = ; +const ALLOW_PERSONAL_EPG = ; + +// Source active (index) +let activeSourceIndex = 0; + +const DEFAULT_EPG_URL = EPG_SOURCES[0]?.epg_url ?? ''; +const DEFAULT_M3U_URL = EPG_SOURCES[0]?.m3u_url ?? ''; +const PX_PER_MIN = 5; +const GRID_HOURS = 72; +const CORS_PROXY = 'https://corsproxy.io/?'; + +// Ancre : 2h avant maintenant, arrondi au quart d'heure infรฉrieur +const _d = new Date(); +_d.setMinutes(Math.floor(_d.getMinutes()/15)*15, 0, 0); +const GRID_START = new Date(_d.getTime() - 2*3600000); +const GRID_END = new Date(GRID_START.getTime() + GRID_HOURS*3600000); +const TOTAL_WIDTH_PX = GRID_HOURS * 60 * PX_PER_MIN; + +function msToX(ms) { return (ms - GRID_START.getTime()) / 60000 * PX_PER_MIN; } diff --git a/src/js/epg.js b/src/js/epg.js new file mode 100644 index 0000000..333c858 --- /dev/null +++ b/src/js/epg.js @@ -0,0 +1,125 @@ +// โ”€โ”€ GRID โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function renderGrid() { + renderChannels(); renderRuler(); renderPrograms(); positionNowLine(); +} + +function makePlaceholder(name) { + const el = document.createElement('div'); + el.className = 'channel-logo-placeholder'; + el.textContent = name.substring(0,2).toUpperCase(); + return el; +} + +function renderChannels() { + const list = document.getElementById('channelsList'); + list.innerHTML = ''; + channels.forEach(ch => { + const cell = document.createElement('div'); + cell.className = 'channel-cell'; + if (ch.icon) { + const img = document.createElement('img'); + img.className = 'channel-logo'; img.src = ch.icon; + img.onerror = () => img.replaceWith(makePlaceholder(ch.name)); + cell.appendChild(img); + } else { cell.appendChild(makePlaceholder(ch.name)); } + const name = document.createElement('div'); + name.className = 'channel-name'; name.textContent = ch.name; name.title = ch.name; + cell.appendChild(name); + cell.addEventListener('click', () => openPip(ch.name, getNowPlaying(ch.id))); + list.appendChild(cell); + }); +} + +function renderRuler() { + const ruler = document.getElementById('timeRulerInner'); + ruler.innerHTML = ''; ruler.style.width = TOTAL_WIDTH_PX + 'px'; + document.getElementById('timeRuler').style.width = TOTAL_WIDTH_PX + 'px'; + let t = new Date(GRID_START); t.setSeconds(0,0); + const rem = t.getMinutes()%15; if (rem) t.setMinutes(t.getMinutes()+(15-rem)); + while (t <= GRID_END) { + const x = msToX(t.getTime()); const isHour = t.getMinutes()===0; + const tick = document.createElement('div'); tick.className='time-tick'; tick.style.left=x+'px'; + const lbl = document.createElement('div'); lbl.className='time-tick-label'+(isHour?' hour':''); lbl.textContent=fmtTime(t); + const line = document.createElement('div'); line.className='time-tick-line'+(isHour?' hour':''); + tick.appendChild(lbl); tick.appendChild(line); ruler.appendChild(tick); + t = new Date(t.getTime()+15*60000); + } +} + +function renderPrograms() { + const ROW_H = 80; + const inner = document.getElementById('programsInner'); + const totalH = channels.length * ROW_H; + inner.innerHTML = ''; + inner.style.width = TOTAL_WIDTH_PX+'px'; + inner.style.height = totalH+'px'; + + // Remettre la now-line (dรฉtruite par innerHTML='') + const nowLine = document.createElement('div'); + nowLine.className = 'now-line'; nowLine.id = 'nowLine'; + nowLine.style.height = totalH + 'px'; + inner.appendChild(nowLine); + + const now = new Date(); + + channels.forEach((ch, i) => { + const row = document.createElement('div'); + row.className = 'program-row'; + row.style.cssText = `top:${i*ROW_H}px;position:absolute;left:0;right:0;`; + + (programs[ch.id]||[]).filter(p => p.stop>GRID_START && p.start { + const x = Math.max(0, msToX(p.start.getTime())); + const w = Math.min(TOTAL_WIDTH_PX, msToX(p.stop.getTime())) - x; + if (w < 2) return; + const block = document.createElement('div'); block.className='program-block'; + if (p.stop < now) block.classList.add('is-past'); + if (p.start <= now && p.stop > now) { + block.classList.add('is-live'); + block.style.setProperty('--progress',(((now-p.start)/(p.stop-p.start))*100).toFixed(1)+'%'); + } + block.style.left=x+'px'; block.style.width=w+'px'; + if (w>40) { const t=document.createElement('div'); t.className='program-title'; t.textContent=p.title; block.appendChild(t); } + if (w>80) { + const ep = fmtEpisode(p.season, p.episode); + const t=document.createElement('div'); t.className='program-time'; + t.textContent = (ep ? ep + ' ' : '') + `${fmtTime(p.start)} โ€” ${fmtTime(p.stop)}`; + block.appendChild(t); + } + block.addEventListener('mouseenter', e=>showTooltip(e,p)); + block.addEventListener('mousemove', e=>moveTooltip(e)); + block.addEventListener('mouseleave', hideTooltip); + if (p.start <= now && p.stop > now) { + block.addEventListener('click', () => openPip(ch.name, p.title)); + } + row.appendChild(block); + }); + inner.appendChild(row); + }); + + positionNowLine(); + if (!scrollListenerAdded) { + scrollListenerAdded = true; + document.getElementById('timelineArea').addEventListener('scroll', function() { + document.getElementById('channelsList').scrollTop = this.scrollTop; + }); + } +} + +function positionNowLine() { + const x = msToX(Date.now()); + const line = document.getElementById('nowLine'); + if (line) line.style.left = x + 'px'; +} + +function centerNow() { + const area = document.getElementById('timelineArea'); + area.scrollLeft = Math.max(0, msToX(Date.now()) - area.offsetWidth * 0.3); + document.querySelectorAll('.nav-btn').forEach((b,i) => b.classList.toggle('active', i===1)); +} + +function shiftView(mins) { + const area = document.getElementById('timelineArea'); + area.scrollLeft = Math.max(0, area.scrollLeft + mins * PX_PER_MIN); + document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active')); +} + diff --git a/src/js/live.js b/src/js/live.js new file mode 100644 index 0000000..9305225 --- /dev/null +++ b/src/js/live.js @@ -0,0 +1,35 @@ +// โ”€โ”€ RESPONSIVE SWITCH โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +let lastMobile = null; +window.addEventListener('resize', () => { + const m = isMobile(); + if (m !== lastMobile) { lastMobile=m; scrollListenerAdded=false; if (channels.length) renderAll(); } +}); + +window.addEventListener('load', () => { + const savedSource = localStorage.getItem('gridtv-active-source'); + if (savedSource === 'personal') { + const epg = localStorage.getItem('gridtv-personal-epg'); + const m3u = localStorage.getItem('gridtv-personal-m3u') ?? ''; + if (epg) { + currentEpgUrl = epg; + currentM3uUrl = m3u; + const sel = document.getElementById('sourceSelect'); + if (sel) sel.value = 'personal'; + loadEPG(epg); + if (m3u) loadM3U(m3u); + return; + } + } else if (savedSource !== null) { + const idx = parseInt(savedSource); + const src = EPG_SOURCES[idx]; + if (src) { + activeSourceIndex = idx; + currentEpgUrl = src.epg_url; + currentM3uUrl = src.m3u_url ?? ''; + const sel = document.getElementById('sourceSelect'); + if (sel) sel.value = idx; + } + } + loadEPG(currentEpgUrl); + loadM3U(currentM3uUrl); +}); diff --git a/src/js/m3u.js b/src/js/m3u.js new file mode 100644 index 0000000..b73f606 --- /dev/null +++ b/src/js/m3u.js @@ -0,0 +1,57 @@ +// โ”€โ”€ M3U PARSER โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function slugify(str) { + return str.toLowerCase() + .replace(/^\d+\s*/, '') // virer le numรฉro de chaรฎne en dรฉbut + .replace(/[^a-z0-9]/g, ''); // garder que alphanum +} + +async function loadM3U(url) { + if (!url) return; + try { + let text; + try { + const r = await fetch(url); text = await r.text(); + } catch(e) { + const r = await fetch(CORS_PROXY + encodeURIComponent(url)); text = await r.text(); + } + parseM3U(text); + } catch(e) { console.warn('M3U load failed:', e); } +} + +function parseM3U(text) { + m3uStreams = {}; + const lines = text.split('\n'); + let currentName = null; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line.startsWith('#EXTINF')) { + // Extraire le nom : derniรจre partie aprรจs la virgule + const comma = line.lastIndexOf(','); + currentName = comma !== -1 ? line.slice(comma + 1).trim() : null; + } else if (line && !line.startsWith('#') && currentName) { + m3uStreams[slugify(currentName)] = line; + currentName = null; + } + } + console.log(`M3U: ${Object.keys(m3uStreams).length} streams chargรฉs`); +} + +function getStreamUrl(channelName) { + const slug = slugify(channelName); + 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; } + } + } + 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; +} + diff --git a/src/js/mobile.js b/src/js/mobile.js new file mode 100644 index 0000000..a144f1d --- /dev/null +++ b/src/js/mobile.js @@ -0,0 +1,57 @@ +// โ”€โ”€ MOBILE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function renderMobile() { + const container = document.getElementById('mobileView'); + container.innerHTML = ''; + const now = new Date(); + const winStart = new Date(now.getTime() - 30*60000); + const winEnd = new Date(now.getTime() + 4*3600000); + + channels.forEach(ch => { + const progs = (programs[ch.id]||[]).filter(p => p.stop>winStart && p.start img.replaceWith(makePlaceholder(ch.name)); + header.appendChild(img); + } else { header.appendChild(makePlaceholder(ch.name)); } + + const name = document.createElement('div'); name.className='channel-name'; name.textContent=ch.name; + header.appendChild(name); section.appendChild(header); + + const list = document.createElement('div'); list.className='mobile-programs'; + progs.forEach(p => { + const isLive = p.start<=now && p.stop>now; + const isPast = p.stop${fmtTime(p.start)}${fmtTime(p.stop)}`; + item.appendChild(timeCol); + + const info = document.createElement('div'); info.className='mobile-program-info'; + const ep = fmtEpisode(p.season, p.episode); + info.innerHTML = `
${p.title}
${ep ? ep + ' ยท ' : ''}${dur} min
`; + item.appendChild(info); + + if (isLive) { + const badge = document.createElement('div'); badge.className='mobile-live-badge'; badge.textContent='LIVE'; + item.appendChild(badge); + } + list.appendChild(item); + }); + section.appendChild(list); container.appendChild(section); + }); +} + diff --git a/src/js/player.js b/src/js/player.js new file mode 100644 index 0000000..1c9c68a --- /dev/null +++ b/src/js/player.js @@ -0,0 +1,58 @@ +// โ”€โ”€ PLAYER PiP โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +let hlsInstance = null; + +function openPip(channelName, programTitle) { + const url = getStreamUrl(channelName); + const pip = document.getElementById('pip'); + const video = document.getElementById('pipVideo'); + const err = document.getElementById('pipError'); + + document.getElementById('pipChannel').textContent = channelName; + document.getElementById('pipProgram').textContent = programTitle || ''; + err.style.display = 'none'; + pip.classList.add('visible'); + + // Dรฉtruire l'instance HLS prรฉcรฉdente + if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; } + video.src = ''; + + if (!url) { + err.style.display = 'block'; + err.textContent = 'โš  Aucun stream trouvรฉ pour cette chaรฎne'; + return; + } + + if (Hls.isSupported()) { + hlsInstance = new Hls({ enableWorker: false, debug: false }); + hlsInstance.loadSource(url); + hlsInstance.attachMedia(video); + 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) => { + 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 + video.src = url; + video.addEventListener('loadedmetadata', () => video.play().catch(() => {})); + } else { + err.style.display = 'block'; + err.textContent = 'โš  HLS non supportรฉ sur ce navigateur'; + } +} + +function closePip() { + const pip = document.getElementById('pip'); + const video = document.getElementById('pipVideo'); + pip.classList.remove('visible'); + if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; } + video.pause(); video.src = ''; +} + diff --git a/src/js/sources.js b/src/js/sources.js new file mode 100644 index 0000000..31c819f --- /dev/null +++ b/src/js/sources.js @@ -0,0 +1,59 @@ +// โ”€โ”€ SOURCE SWITCHER โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +let currentEpgUrl = EPG_SOURCES[0]?.epg_url ?? ''; +let currentM3uUrl = EPG_SOURCES[0]?.m3u_url ?? ''; + +function switchSource(value) { + if (value === 'personal') { + openPersonalEpgModal(); + return; + } + const idx = parseInt(value); + const src = EPG_SOURCES[idx]; + if (!src) return; + activeSourceIndex = idx; + currentEpgUrl = src.epg_url; + currentM3uUrl = src.m3u_url ?? ''; + localStorage.setItem('gridtv-active-source', idx); + loadEPG(currentEpgUrl); + if (currentM3uUrl) loadM3U(currentM3uUrl); + updateCopyButtons(); +} + +function updateCopyButtons() { + // Rien ร  faire visuellement, copyUrl() lit currentEpgUrl/currentM3uUrl +} + +function openPersonalEpgModal() { + const saved_epg = localStorage.getItem('gridtv-personal-epg') ?? ''; + const saved_m3u = localStorage.getItem('gridtv-personal-m3u') ?? ''; + const modal = document.getElementById('personalEpgModal'); + document.getElementById('personalEpgInput').value = saved_epg; + document.getElementById('personalM3uInput').value = saved_m3u; + modal.classList.add('visible'); +} + +function closePersonalEpgModal(applied) { + document.getElementById('personalEpgModal').classList.remove('visible'); + // Si annulรฉ (pas applied), remettre le select sur la source prรฉcรฉdente + if (!applied) { + const sel = document.getElementById('sourceSelect'); + if (sel) sel.value = localStorage.getItem('gridtv-active-source') === 'personal' ? 'personal' : activeSourceIndex; + } +} + +function applyPersonalEpg() { + const epg = document.getElementById('personalEpgInput').value.trim(); + const m3u = document.getElementById('personalM3uInput').value.trim(); + if (!epg) { alert('URL EPG obligatoire'); return; } + localStorage.setItem('gridtv-personal-epg', epg); + localStorage.setItem('gridtv-personal-m3u', m3u); + localStorage.setItem('gridtv-active-source', 'personal'); + currentEpgUrl = epg; + currentM3uUrl = m3u; + const sel = document.getElementById('sourceSelect'); + if (sel) sel.value = 'personal'; + closePersonalEpgModal(true); + loadEPG(epg); + if (m3u) loadM3U(m3u); +} + diff --git a/src/js/themes.js b/src/js/themes.js new file mode 100644 index 0000000..3fce918 --- /dev/null +++ b/src/js/themes.js @@ -0,0 +1,20 @@ +// โ”€โ”€ THรˆMES โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function setTheme(theme) { + document.getElementById('themeStylesheet').href = 'themes/' + theme + '.css'; + localStorage.setItem('gridtv-theme', theme); + document.getElementById('themeSelect').value = theme; +} + +function initTheme() { + const saved = localStorage.getItem('gridtv-theme') || 'default'; + setTheme(saved); +} +initTheme(); + +// โ”€โ”€ LIVE UPDATES โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function startLiveUpdates() { + setInterval(() => { if (!isMobile()) positionNowLine(); }, 30000); + setInterval(() => { if (channels.length) renderAll(); }, 60000); + setInterval(() => loadEPG(DEFAULT_EPG_URL), 30*60*1000); +} + diff --git a/src/js/tooltip.js b/src/js/tooltip.js new file mode 100644 index 0000000..87ebd72 --- /dev/null +++ b/src/js/tooltip.js @@ -0,0 +1,27 @@ +// โ”€โ”€ HELPERS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function getNowPlaying(chId) { + const now = new Date(); + const p = (programs[chId] || []).find(p => p.start <= now && p.stop > now); + return p ? p.title : null; +} + +// โ”€โ”€ TOOLTIP โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function showTooltip(e, p) { + const ep = fmtEpisode(p.season, p.episode); + document.getElementById('tt-title').textContent = p.title + (ep ? ' ' + ep : ''); + document.getElementById('tt-time').textContent = `${fmtTime(p.start)} โ€” ${fmtTime(p.stop)} ยท ${Math.round((p.stop-p.start)/60000)} min`; + document.getElementById('tt-desc').textContent = p.desc || 'โ€”'; + document.getElementById('tooltip').classList.add('visible'); + moveTooltip(e); +} +function moveTooltip(e) { + const tt = document.getElementById('tooltip'); + const x=e.clientX+16, y=e.clientY+16; + tt.style.left = (x+tt.offsetWidth >window.innerWidth ? x-tt.offsetWidth -32 : x)+'px'; + tt.style.top = (y+tt.offsetHeight>window.innerHeight ? y-tt.offsetHeight-32 : y)+'px'; +} +function hideTooltip() { document.getElementById('tooltip').classList.remove('visible'); } + + + + diff --git a/src/js/utils.js b/src/js/utils.js new file mode 100644 index 0000000..754f1db --- /dev/null +++ b/src/js/utils.js @@ -0,0 +1,134 @@ + +let channels = []; +let programs = {}; +let m3uStreams = {}; // slug normalisรฉ โ†’ url stream +let scrollListenerAdded = false; // sync scroll vertical chaรฎnes + +function pad(n) { return String(n).padStart(2,'0'); } + +function copyUrl(type) { + const url = type === 'epg' ? currentEpgUrl : currentM3uUrl; + if (!url) return; + navigator.clipboard.writeText(url).then(() => { + const btns = document.querySelectorAll('.copy-btn'); + btns.forEach(b => { if (b.textContent.toLowerCase() === type) { + b.textContent = 'โœ“ copiรฉ'; + b.classList.add('copied'); + setTimeout(() => { b.textContent = type.toUpperCase(); b.classList.remove('copied'); }, 1800); + }}); + }).catch(() => { + prompt('Copiez cette URL :', url); + }); +} +function fmtTime(d) { return `${pad(d.getHours())}:${pad(d.getMinutes())}`; } +function fmtTimeFull(d) { return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } +function isMobile() { return window.innerWidth <= 600; } + +function parseXMLTVDate(str) { + if (!str) return null; + const m = str.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{4})?/); + if (!m) return null; + 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); + return new Date(Date.UTC(+m[1],+m[2]-1,+m[3],+m[4],+m[5],+m[6]) - offsetMs); +} + +function updateClock() { + const now = new Date(); + document.getElementById('clock').textContent = fmtTimeFull(now); + const days=['Dim','Lun','Mar','Mer','Jeu','Ven','Sam']; + const months=['Jan','Fรฉv','Mar','Avr','Mai','Jun','Jul','Aoรป','Sep','Oct','Nov','Dรฉc']; + document.getElementById('dateLabel').textContent = + `${days[now.getDay()]} ${now.getDate()} ${months[now.getMonth()]} ${now.getFullYear()}`; +} +setInterval(updateClock, 1000); +updateClock(); + +async function fetchXML(url) { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return new DOMParser().parseFromString(await res.text(), 'text/xml'); +} + +async function loadEPG(url) { + document.getElementById('loading').style.display = 'flex'; + document.getElementById('errorMsg').style.display = 'none'; + try { + let doc; + try { doc = await fetchXML(url); } + catch(e) { doc = await fetchXML(CORS_PROXY + encodeURIComponent(url)); } + parseAndRender(doc); + document.getElementById('loading').style.display = 'none'; + } catch(e) { + document.getElementById('loading').style.display = 'none'; + document.getElementById('errorMsg').style.display = 'flex'; + document.getElementById('errorDetail').textContent = `Erreur: ${e.message}`; + document.getElementById('epgInput').value = url; + } +} + +function loadFromInput() { + const url = document.getElementById('epgInput').value.trim(); + if (url) loadEPG(url); +} + +function parseEpisode(p) { + // Format xmltv_ns : "S.E.part" (0-indexed) ex: "0.2.0/1" = S01E03 + const ns = p.querySelector('episode-num[system="xmltv_ns"]')?.textContent; + if (ns) { + const parts = ns.split('.'); + const s = parts[0] !== undefined && parts[0].trim() !== '' ? parseInt(parts[0].trim()) + 1 : null; + const ePart = parts[1] !== undefined ? parts[1].trim().split('/')[0] : ''; + const e = ePart !== '' ? parseInt(ePart) + 1 : null; + if (s !== null || e !== null) return { season: s, episode: e }; + } + // Format onscreen : "S01E03" ou "s1e3" + const os = p.querySelector('episode-num[system="onscreen"]')?.textContent; + if (os) { + const m = os.match(/[Ss](\d+)[Ee](\d+)/); + if (m) return { season: parseInt(m[1]), episode: parseInt(m[2]) }; + const m2 = os.match(/[Ee][Pp]?\s*(\d+)/i); + if (m2) return { season: null, episode: parseInt(m2[1]) }; + } + return { season: null, episode: null }; +} + +function fmtEpisode(season, episode) { + if (season !== null && episode !== null) return `S${String(season).padStart(2,'0')}E${String(episode).padStart(2,'0')}`; + if (episode !== null) return `E${String(episode).padStart(2,'0')}`; + return null; +} + +function parseAndRender(doc) { + channels = []; programs = {}; + doc.querySelectorAll('channel').forEach(ch => { + const id = ch.getAttribute('id'); + const name = ch.querySelector('display-name')?.textContent || id; + const icon = ch.querySelector('icon')?.getAttribute('src') || null; + const num = parseInt(name.match(/^(\d+)/)?.[1] ?? '9999'); + channels.push({ id, name, icon, num }); + programs[id] = []; + }); + channels.sort((a,b) => a.num - b.num); + + doc.querySelectorAll('programme').forEach(p => { + const chId = p.getAttribute('channel'); + if (!programs[chId]) programs[chId] = []; + const start = parseXMLTVDate(p.getAttribute('start')); + const stop = parseXMLTVDate(p.getAttribute('stop')); + const title = p.querySelector('title')?.textContent || ''; + const desc = p.querySelector('desc')?.textContent || ''; + const epInfo = parseEpisode(p); + if (start && stop) programs[chId].push({ start, stop, title, desc, ...epInfo }); + }); + Object.keys(programs).forEach(id => programs[id].sort((a,b) => a.start - b.start)); + + renderAll(); + startLiveUpdates(); +} + +function renderAll() { + if (isMobile()) { renderMobile(); } + else { renderGrid(); setTimeout(centerNow, 150); } +} + diff --git a/src/tpl/footer.php b/src/tpl/footer.php new file mode 100644 index 0000000..dc7e6eb --- /dev/null +++ b/src/tpl/footer.php @@ -0,0 +1,13 @@ + + + + diff --git a/src/tpl/grid.php b/src/tpl/grid.php new file mode 100644 index 0000000..196060f --- /dev/null +++ b/src/tpl/grid.php @@ -0,0 +1,50 @@ +
+
Chargement du guide
+
+
+ +
+
⚠ Impossible de charger l'EPG
+
Vรฉrifiez l'URL de votre flux XMLTV.
+
+ + +
+
+ +
+
+
+
+
+ + +
+
+
โ€”
+
+ +
+ +
โš  Stream indisponible
+
+ + +
+
+
Chaรฎnes
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ diff --git a/src/tpl/head.php b/src/tpl/head.php new file mode 100644 index 0000000..e269506 --- /dev/null +++ b/src/tpl/head.php @@ -0,0 +1,766 @@ + + + + + +GridTV โ€” <?= $group_name ?> + + + + + + diff --git a/src/tpl/modals.php b/src/tpl/modals.php new file mode 100644 index 0000000..22d36b8 --- /dev/null +++ b/src/tpl/modals.php @@ -0,0 +1,22 @@ + + + + + diff --git a/src/tpl/topbar.php b/src/tpl/topbar.php new file mode 100644 index 0000000..579f985 --- /dev/null +++ b/src/tpl/topbar.php @@ -0,0 +1,52 @@ +
+ +
+
00:00:00
+ +
+ + + + +
+ 1 || $allow_personal_epg): ?> + + + +
LIVE
+
+