80 Commits
Author SHA1 Message Date
Johnnybegood90 56781be784 Fix: Removing the key request on export page. 2026-03-15 02:51:17 +01:00
Johnnybegood90 16abfab05b Simplifie export et nettoie UI 2026-03-15 02:49:09 +01:00
Johnnybegood90 b5fcf5c902 Feat: Adding heath page & export page. 2026-03-15 02:44:23 +01:00
Johnnybegood90 ab56bd9c70 Supprime magazine et nettoie export 2026-03-15 02:42:45 +01:00
Johnnybegood90 d560f49dac fix: Magazine card wasn't working. 2026-03-15 02:35:50 +01:00
Johnnybegood90 804490ca48 fix: Magazine card wasn't working. 2026-03-15 02:28:35 +01:00
Johnnybegood90 721eb67c2f Improvment: Getting Magazine export even better. 2026-03-15 02:22:21 +01:00
Johnnybegood90 411b5bb354 Feat: Adding Magazine mode to export. 2026-03-15 02:14:51 +01:00
Johnnybegood90 3f8aab5e70 Fix: Error 500 on export. Fix: Show health page immediately then load the diag data. 2026-03-15 02:08:22 +01:00
Johnnybegood90 dde85a4e38 Fix: Export was unreadable due to overdata. 2026-03-15 02:01:22 +01:00
Johnnybegood90 214a2b9cc5 Fix: Error 500 on health.php and export button missing. 2026-03-15 01:50:35 +01:00
Johnnybegood90 a81e33ccb8 Add admin health page and printable 24h export
- add a protected health.php page for config, XMLTV and M3U diagnostics
- add shared admin/config helpers for authenticated internal tools
- add a styled 24h export page designed for PDF/print output
- add browser-side PNG export for the daily schedule
- update README with the new admin tools
2026-03-15 01:44:22 +01:00
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
Johnnybegood90 3d0b4796b2 Update version 2026-03-14 03:13:28 +01:00
Johnnybegood90 de1f5a336c Version update. 2026-03-14 03:13:11 +01:00
Johnnybegood90 4f0e1f55ba Merge remote-tracking branch 'origin/beta'
Harden Docker packaging and bundle frontend assets locally

- replace fragile Docker file bind mount with persistent /data storage
- add container entrypoint to link config.json from persistent storage
- tighten Docker image permissions and add a basic healthcheck
- add .dockerignore to keep git metadata and runtime files out of the image
- bundle hls.js locally instead of loading it from a CDN
- download and serve UI/theme fonts locally instead of Google Fonts
- update service worker cache entries for local assets
- refresh README to reflect local bundled assets and cleaner deployment docs
2026-03-14 03:11:11 +01:00
Johnnybegood90 8ba8c3013e Harden Docker packaging and bundle frontend assets locally
- replace fragile Docker file bind mount with persistent /data storage
- add container entrypoint to link config.json from persistent storage
- tighten Docker image permissions and add a basic healthcheck
- add .dockerignore to keep git metadata and runtime files out of the image
- bundle hls.js locally instead of loading it from a CDN
- download and serve UI/theme fonts locally instead of Google Fonts
- update service worker cache entries for local assets
- refresh README to reflect local bundled assets and cleaner deployment docs
2026-03-14 03:10:21 +01:00
Johnnybegood90 9609f096ef Updating Docker Compose & README.md 2026-03-14 02:58:05 +01:00
Johnnybegood90 8c81c05bca Updating Docker Compose & README.md 2026-03-14 02:57:13 +01:00
Johnnybegood90 ff998d379d security: harden proxy.php with config whitelist, fix XSS via EPG innerHTML 2026-03-14 02:44:48 +01:00
Johnnybegood90 62732ab2c6 security: harden proxy.php with config whitelist, fix XSS via EPG innerHTML 2026-03-14 02:43:11 +01:00
Johnnybegood90 25dabc97b2 security: harden proxy.php with config whitelist, fix XSS via EPG innerHTML 2026-03-14 02:31:25 +01:00
Johnnybegood90 e81cd8b64d security: harden proxy.php with config whitelist, fix XSS via EPG innerHTML 2026-03-14 02:29:09 +01:00
Johnnybegood90 30d8a24927 security: harden proxy.php with config whitelist, fix XSS via EPG innerHTML 2026-03-14 02:26:36 +01:00
Johnnybegood90 d6532d3501 Improvment: Replacing change EPG menu. 2026-03-14 01:22:16 +01:00
Johnnybegood90 17db9f56f7 Fix version.json 2026-03-14 01:22:05 +01:00
Johnnybegood90 874214aa41 Improvment: Replacing change EPG menu. 2026-03-14 01:18:38 +01:00
Johnnybegood90 de0e1adf44 Improvment: Replacing change EPG menu. 2026-03-14 01:18:02 +01:00
Johnnybegood90 42d161beff Update topbar CSS 2026-03-14 01:10:58 +01:00
Johnnybegood90 30c0de2a2d Fix: CSS Topbar. 2026-03-14 01:09:33 +01:00
Johnnybegood90 77f0c2523b Fix: CSS Topbar. 2026-03-14 01:06:32 +01:00
Johnnybegood90 73bdde5093 Fix: CSS Topbar. 2026-03-14 01:03:44 +01:00
Johnnybegood90 0c2bce40fb Update assets. 2026-03-14 00:30:04 +01:00
Johnnybegood90 005fca9a05 Adjusting assets. 2026-03-14 00:28:52 +01:00
Johnnybegood90 f82bc65472 Fix: CSS Topbar. 2026-03-14 00:11:34 +01:00
Johnnybegood90 d57855c385 Hotfix desktop CSS 2026-03-14 00:08:29 +01:00
Johnnybegood90 ca25d5000b Hotfix css desktop 2026-03-14 00:07:10 +01:00
Johnnybegood90 f935a82e91 Hotfix mobile CSS 2026-03-14 00:01:17 +01:00
Johnnybegood90 96c9835a27 Hotfix css mobile 2026-03-14 00:00:05 +01:00
Johnnybegood90 b312e8cc26 Updating favicon. 2026-03-13 23:51:49 +01:00
Johnnybegood90 79a077a85f Adding PWA support. 2026-03-13 23:46:14 +01:00
Johnnybegood90 8e3dc664ed Remove Sublime workspace file 2026-03-13 23:27:25 +01:00
Johnnybegood90 2172202d25 update .gitignore 2026-03-13 23:24:54 +01:00
Johnnybegood90 804bb96e07 Update .gitignore. 2026-03-13 23:23:20 +01:00
Johnnybegood90 d7541c0fa4 update .gitignore 2026-03-13 23:23:05 +01:00
Johnnybegood90 7f23ba92ad Update README.md. 2026-03-13 23:12:20 +01:00
Johnnybegood90 9e4614d8df Update README.md 2026-03-13 23:12:00 +01:00
Johnnybegood90 5fd4eb8a5c Split the CSS from head.php into separate files. 2026-03-13 23:06:43 +01:00
Johnnybegood90 8ca85e778e Split the CSS from head.php into separate files. 2026-03-13 23:04:55 +01:00
Johnnybegood90 181e477a0e Split the CSS from head.php into separate files. 2026-03-13 22:56:40 +01:00
Johnnybegood90 da501bccc7 removing useless config.example.json 2026-03-13 22:05:28 +01:00
Johnnybegood90 8a465dc6da removing useless config.example.json 2026-03-13 22:05:06 +01:00
Johnnybegood90 2d4d8c031e Feat: Update available checker. 2026-03-13 21:49:02 +01:00
Johnnybegood90 8b032f56de Update available fix. 2026-03-13 21:47:49 +01:00
Johnnybegood90 44f4eeaafe Update available tag. 2026-03-13 21:39:34 +01:00
Johnnybegood90 aac2b4404f Feat: Possibility added to reconfigure via setup.php. A key is created into the config file. 2026-03-13 21:30:00 +01:00
Johnnybegood90 8460771d20 Fix: setup.php now add a key in existing setup post update. 2026-03-13 21:28:18 +01:00
Johnnybegood90 76dbd18ca7 Feat: Reconfigure in setup.php 2026-03-13 21:24:51 +01:00
Johnnybegood90 e924c945d6 Feat: Favorite channels in Localstorage. 2026-03-13 20:47:47 +01:00
Johnnybegood90 7b42539bc8 Feat: Fav channel in localstorage 2026-03-13 20:46:01 +01:00
Johnnybegood90 d7617bfeaa Update screenshots. 2026-03-12 15:57:24 +01:00
Johnnybegood90 23e0f29990 Update screenshots. 2026-03-12 15:57:00 +01:00
74 changed files with 3087 additions and 1328 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.gitignore
.DS_Store
*.swp
data
assets/preview.png
assets/.DS_Store
src/.DS_Store
.version.json.swp
+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
+1 -2
View File
@@ -1,4 +1,3 @@
config.json config.json
data/
.DS_Store .DS_Store
generate_epg.php
epg.xml
+12 -2
View File
@@ -4,8 +4,18 @@ RUN docker-php-ext-install curl && \
a2enmod rewrite a2enmod rewrite
COPY . /var/www/html/ COPY . /var/www/html/
COPY docker/docker-entrypoint.sh /usr/local/bin/gridtv-entrypoint.sh
RUN chown -R www-data:www-data /var/www/html && \ RUN mkdir -p /data && \
chmod -R 775 /var/www/html chown -R www-data:www-data /var/www/html /data && \
find /var/www/html -type d -exec chmod 755 {} \; && \
find /var/www/html -type f -exec chmod 644 {} \; && \
chmod 755 /usr/local/bin/gridtv-entrypoint.sh
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD php -r "exit(@file_get_contents('http://127.0.0.1/') === false ? 1 : 0);"
ENTRYPOINT ["gridtv-entrypoint.sh"]
CMD ["apache2-foreground"]
EXPOSE 80 EXPOSE 80
+165 -21
View File
@@ -40,10 +40,14 @@ This demo runs with sample XMLTV feeds to showcase the interface.
- 📡 **Multi-EPG sources** — configure multiple EPG/M3U sources, switch from the topbar - 📡 **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 URLs (saved in localStorage) - 👤 **Personal EPG** — optionally let visitors use your instance with their own EPG/M3U URLs (saved in localStorage)
- 🔍 **Search** — filter the grid by channel name or program title/synopsis (debounced) - 🔍 **Search** — filter the grid by channel name or program title/synopsis (debounced)
-**Favorites** — pin channels to the top of the grid, persisted in localStorage
- 🌍 **i18n** — auto-detects browser language, supports EN / FR / ES (add your own in `locales/`) - 🌍 **i18n** — auto-detects browser language, supports EN / FR / ES (add your own in `locales/`)
- ⚙️ **Guided setup** on first launch — no config files to edit manually - ⚙️ **Re-editable setup** — protected by an admin key, no SSH required to update config
- 🩺 **Admin health page** — check config, XMLTV, M3U, response times, and metadata coverage
- 🖨️ **24h printable export** — generate a polished daily schedule for PDF/print and PNG export
- 🔔 **Update notifications** — a badge appears in the topbar when a new release is available on GitHub
- 🔄 **Auto-reload** EPG every 30 minutes - 🔄 **Auto-reload** EPG every 30 minutes
- 0️⃣ **Zero JS dependencies** — vanilla PHP, hls.js loaded from CDN - 0️⃣ **Zero build tooling** — vanilla PHP/JS/CSS, with bundled local assets
--- ---
@@ -51,7 +55,7 @@ This demo runs with sample XMLTV feeds to showcase the interface.
### Requirements ### Requirements
- A web server running **PHP 8.0+** with **php-curl** extension - A web server running **PHP 8.0+** with **php-curl** and **php-xml** extensions
- **Apache** or **Nginx** — or just use **Docker** - **Apache** or **Nginx** — or just use **Docker**
- An **EPG source in XMLTV format** (e.g. Tunarr, Jellyfin, xTeVe...) - An **EPG source in XMLTV format** (e.g. Tunarr, Jellyfin, xTeVe...)
- *(Optional)* An **M3U playlist** — required for the built-in player - *(Optional)* An **M3U playlist** — required for the built-in player
@@ -68,15 +72,19 @@ docker compose up -d
Then open `http://localhost:8080` and follow the setup wizard. Then open `http://localhost:8080` and follow the setup wizard.
Docker stores the generated configuration in `./data/config.json` on the host.
To run on a custom port: To run on a custom port:
```bash ```bash
PORT=9000 docker compose up -d PORT=9000 docker compose up -d
``` ```
To back up or migrate your Docker setup, keep the `data/` directory.
--- ---
### Option B — Apache / Nginx ### Option B — Native PHP host
#### 1. Clone the repo #### 1. Clone the repo
@@ -92,7 +100,25 @@ chmod 775 /var/www/gridtv
chown -R www-data:www-data /var/www/gridtv chown -R www-data:www-data /var/www/gridtv
``` ```
#### 3a. Configure Nginx #### 3. Install php-curl + php-xml
The built-in player uses `proxy.php` to relay HTTP streams over HTTPS, and the admin tools parse XMLTV directly. This requires **php-curl** and **php-xml**:
```bash
# Debian/Ubuntu — adjust version to match your PHP
apt install php8.4-curl php8.4-xml
systemctl reload apache2 # or: systemctl reload nginx
```
#### 4. Reverse proxy examples
<details>
<summary>Show reverse proxy / vhost examples</summary>
These examples are intentionally minimal. Replace `guide.your-domain.com` with your domain and adjust the PHP socket or upstream target to match your host.
<details>
<summary>Nginx + PHP-FPM</summary>
```nginx ```nginx
server { server {
@@ -113,13 +139,16 @@ server {
} }
``` ```
> 💡 Adjust the PHP version (`php8.2-fpm`) to match the one installed on your server.
```bash ```bash
nginx -t && systemctl reload nginx nginx -t && systemctl reload nginx
``` ```
#### 3b. Configure Apache > Adjust `php8.2-fpm.sock` to match the PHP-FPM version installed on your server.
</details>
<details>
<summary>Apache vhost</summary>
```apache ```apache
<VirtualHost *:80> <VirtualHost *:80>
@@ -138,16 +167,52 @@ a2enmod php8.4 rewrite
systemctl reload apache2 systemctl reload apache2
``` ```
#### 4. Install php-curl </details>
The built-in player uses `proxy.php` to relay HTTP streams over HTTPS. This requires the **php-curl** extension: <details>
<summary>Caddy</summary>
```caddy
guide.your-domain.com {
root * /var/www/gridtv
php_fastcgi unix//run/php/php8.2-fpm.sock
file_server
}
```
```bash ```bash
# Debian/Ubuntu — adjust version to match your PHP systemctl reload caddy
apt install php8.4-curl
systemctl reload apache2 # or: systemctl reload nginx
``` ```
> If you use `xcaddy` or a distro package, keep the same site block and only adapt the PHP-FPM socket path.
</details>
<details>
<summary>Traefik (Docker labels)</summary>
Use this if GridTV runs in Docker and Traefik is your front proxy:
```yaml
services:
gridtv:
build: .
volumes:
- ./data:/data
labels:
- "traefik.enable=true"
- "traefik.http.routers.gridtv.rule=Host(`guide.your-domain.com`)"
- "traefik.http.routers.gridtv.entrypoints=websecure"
- "traefik.http.routers.gridtv.tls=true"
- "traefik.http.services.gridtv.loadbalancer.server.port=80"
```
You still need a running Traefik instance with `websecure` configured and DNS pointing to it.
</details>
</details>
#### 5. First launch — Setup #### 5. First launch — Setup
Open your browser at `http://guide.your-domain.com`. Open your browser at `http://guide.your-domain.com`.
@@ -162,8 +227,18 @@ GridTV detects the missing config and automatically redirects you to the setup p
Once submitted, `config.json` is created on the server. **The setup page becomes inaccessible until you re-enter your admin key.** Once submitted, `config.json` is created on the server. **The setup page becomes inaccessible until you re-enter your admin key.**
Admin tools are available here once unlocked with the same key:
```text
http://guide.your-domain.com/health.php
http://guide.your-domain.com/export.php
```
--- ---
<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:
@@ -172,6 +247,8 @@ You can re-open the setup page at any time using the admin key generated during
http://guide.your-domain.com/setup.php http://guide.your-domain.com/setup.php
``` ```
Enter your admin key to unlock the configuration form. The key is stored in `config.json` under `admin_key` — if you lose it, you can retrieve it there via SSH.
Or edit `config.json` directly: Or edit `config.json` directly:
```bash ```bash
@@ -201,14 +278,21 @@ nano /var/www/gridtv/config.json
--- ---
</details>
<details>
<summary>Project structure</summary>
## 📁 Project structure ## 📁 Project structure
``` ```
gridtv/ gridtv/
├── index.php # Entry point ├── index.php # Entry point
├── setup.php # First-launch + re-configuration page ├── setup.php # Setup + re-configuration (admin key protected)
├── health.php # Admin diagnostics page
├── export.php # 24h printable export (PDF/image friendly)
├── proxy.php # HTTP→HTTPS stream proxy ├── proxy.php # HTTP→HTTPS stream proxy
├── config.example.json # Config template ├── version.json # Current version (used for update check)
├── Dockerfile ├── Dockerfile
├── docker-compose.yml ├── docker-compose.yml
├── .gitignore # config.json excluded ├── .gitignore # config.json excluded
@@ -223,13 +307,24 @@ gridtv/
│ └── steampunk.css │ └── steampunk.css
└── src/ └── src/
├── config.php # Config loader, migration, locale detection ├── config.php # Config loader, migration, locale detection
├── css/ # CSS modules (one file per feature)
│ ├── base.css # Variables, reset, global layout
│ ├── topbar.css # Topbar, nav, source switcher, theme selector
│ ├── grid.css # Grid, channels, programs, ruler, tooltip, loading
│ ├── mobile.css # Mobile list view
│ ├── player.css # HLS PiP player
│ ├── modals.css # Personal EPG modal
│ ├── search.css # Search bar + highlight
│ ├── program.css # Program info modal + midnight marker
│ ├── favorites.css# Favorite button + separator
│ └── updater.css # Update notification badge
├── tpl/ # HTML templates ├── tpl/ # HTML templates
│ ├── head.php # DOCTYPE, <head>, CSS │ ├── head.php # DOCTYPE, <head>, includes CSS modules
│ ├── topbar.php # Topbar (nav, source switcher, search, theme) │ ├── topbar.php # Topbar (nav, source switcher, search, theme)
│ ├── grid.php # Grid + mobile + PiP + overlays │ ├── grid.php # Grid + mobile + PiP + overlays
│ ├── modals.php # Program info modal + Personal EPG modal │ ├── modals.php # Program info modal + Personal EPG modal
│ └── footer.php # JS modules loader, </body></html> │ └── footer.php # JS modules loader, </body></html>
└── js/ # JavaScript modules └── js/ # JavaScript modules (one file per feature)
├── config.js # Constants + PHP-injected vars (incl. locale) ├── config.js # Constants + PHP-injected vars (incl. locale)
├── utils.js # Helpers, clock, EPG fetch ├── utils.js # Helpers, clock, EPG fetch
├── epg.js # Grid rendering ├── epg.js # Grid rendering
@@ -241,6 +336,8 @@ gridtv/
├── themes.js # Theme switcher ├── themes.js # Theme switcher
├── search.js # Search + filter (debounced) ├── search.js # Search + filter (debounced)
├── program.js # Program info modal + IMDb link ├── program.js # Program info modal + IMDb link
├── favorites.js # Favorites (localStorage)
├── updater.js # GitHub update checker
└── live.js # Live updates + responsive └── live.js # Live updates + responsive
``` ```
@@ -248,6 +345,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:
@@ -268,6 +370,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.
@@ -276,6 +383,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.
@@ -284,6 +396,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`:
@@ -296,6 +413,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:
@@ -308,6 +430,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**.
@@ -323,18 +450,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
@@ -345,15 +487,17 @@ GNU Affero General Public License v3.0 (AGPLv3)
- Theme system (4 built-in themes) - Theme system (4 built-in themes)
- Multi-EPG sources with topbar switcher - Multi-EPG sources with topbar switcher
- Personal EPG (localStorage) - Personal EPG (localStorage)
- Modular codebase (src/tpl + src/js) - Modular codebase (src/tpl + src/js + src/css)
- Docker support - Docker support
- **Search** — filter grid by channel name or program title/synopsis (debounced) - **Search** — filter grid by channel name or program title/synopsis (debounced)
- **Date in timebar** — midnight markers with day/date in the ruler - **Date in timebar** — midnight markers with day/date in the ruler
- **Program popup** — full details + IMDb search link on any program click - **Program popup** — full details + IMDb search link on any program click
- **i18n** — EN / FR / ES with browser auto-detection, extensible via locales/ - **i18n** — EN / FR / ES with browser auto-detection, extensible via locales/
- **Favorites** — pin channels to the top of the grid (localStorage)
- **Setup re-editable** — protected by an admin key, auto-generated on first setup
- **Admin health page** — diagnostics for config, XMLTV, M3U, timing, and metadata coverage
- **Update notifications** — topbar badge links to latest GitHub release
### 🔜 Coming soon ### 🔜 Coming soon
- **Favorites** — pin channels to the top of the grid (localStorage)
- **Setup re-editable** — reopen setup.php with an admin key, no SSH required
- **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 </details>
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+87
View File
@@ -0,0 +1,87 @@
@font-face {
font-family: 'Barlow Condensed';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('/assets/fonts/barlow-condensed-300.ttf') format('truetype');
}
@font-face {
font-family: 'Barlow Condensed';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/assets/fonts/barlow-condensed-400.ttf') format('truetype');
}
@font-face {
font-family: 'Barlow Condensed';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/assets/fonts/barlow-condensed-600.ttf') format('truetype');
}
@font-face {
font-family: 'Barlow Condensed';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/assets/fonts/barlow-condensed-700.ttf') format('truetype');
}
@font-face {
font-family: 'Share Tech Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/assets/fonts/share-tech-mono-400.ttf') format('truetype');
}
@font-face {
font-family: 'Rajdhani';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/assets/fonts/rajdhani-400.ttf') format('truetype');
}
@font-face {
font-family: 'Rajdhani';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/assets/fonts/rajdhani-600.ttf') format('truetype');
}
@font-face {
font-family: 'Rajdhani';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/assets/fonts/rajdhani-700.ttf') format('truetype');
}
@font-face {
font-family: 'IM Fell English';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/assets/fonts/im-fell-english-400.ttf') format('truetype');
}
@font-face {
font-family: 'IM Fell English';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('/assets/fonts/im-fell-english-400-italic.ttf') format('truetype');
}
@font-face {
font-family: 'Special Elite';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/assets/fonts/special-elite-400.ttf') format('truetype');
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 642 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

+2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-5
View File
@@ -1,5 +0,0 @@
{
"group_name": "MonGroupe TV",
"epg_url": "http://votre-serveur/api/xmltv.xml",
"m3u_url": "http://votre-serveur/api/channels.m3u"
}
+1 -1
View File
@@ -4,5 +4,5 @@ services:
ports: ports:
- "${PORT:-8080}:80" - "${PORT:-8080}:80"
volumes: volumes:
- ./config.json:/var/www/html/config.json - ./data:/data
restart: unless-stopped restart: unless-stopped
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -eu
mkdir -p /data
chown -R www-data:www-data /data
chmod 775 /data
rm -f /var/www/html/config.json
ln -s /data/config.json /var/www/html/config.json
exec docker-php-entrypoint "$@"
+392
View File
@@ -0,0 +1,392 @@
<?php
require_once __DIR__ . '/src/lib/common.php';
$config = gridtv_load_config();
[$locale, $L] = gridtv_load_locale($config);
function export_parse_xmltv_programmes(string $xml, string $target_day): array {
if (!class_exists('DOMDocument')) {
return ['channels' => [], 'programmes' => [], 'error' => 'PHP DOM/XML extension is not installed'];
}
libxml_use_internal_errors(true);
$dom = new DOMDocument();
if (!$dom->loadXML($xml, LIBXML_NOERROR | LIBXML_NOWARNING)) {
return ['channels' => [], 'programmes' => [], 'error' => 'Invalid XMLTV response'];
}
$xp = new DOMXPath($dom);
$channels = [];
foreach ($xp->query('/tv/channel') as $channel) {
$id = $channel->getAttribute('id');
$name = trim($xp->evaluate('string(display-name[1])', $channel));
$icon = trim($xp->evaluate('string(icon/@src)', $channel));
$channels[$id] = ['id' => $id, 'name' => $name ?: $id, 'icon' => $icon];
}
$start_day = new DateTimeImmutable($target_day . ' 00:00:00');
$end_day = $start_day->modify('+1 day');
$rows = [];
foreach ($xp->query('/tv/programme') as $programme) {
$channel_id = $programme->getAttribute('channel');
$start = DateTimeImmutable::createFromFormat('YmdHis O', $programme->getAttribute('start'));
$stop = DateTimeImmutable::createFromFormat('YmdHis O', $programme->getAttribute('stop'));
if (!$start || !$stop) continue;
if ($stop <= $start_day || $start >= $end_day) continue;
$title = trim($xp->evaluate('string(title)', $programme));
$subtitle = trim($xp->evaluate('string(sub-title)', $programme));
$desc = trim($xp->evaluate('string(desc)', $programme));
$rating = trim($xp->evaluate('string(rating/value)', $programme));
$cats = [];
foreach ($xp->query('category', $programme) as $cat) {
$value = trim($cat->textContent);
if ($value !== '') $cats[strtolower($value)] = $value;
}
$rows[$channel_id][] = [
'start' => $start,
'stop' => $stop,
'title' => $title,
'subtitle' => $subtitle,
'desc' => $desc,
'rating' => $rating,
'categories' => array_values($cats),
];
}
uasort($channels, static function ($a, $b) {
preg_match('/^\d+/', $a['name'], $ma);
preg_match('/^\d+/', $b['name'], $mb);
$na = isset($ma[0]) ? (int) $ma[0] : 9999;
$nb = isset($mb[0]) ? (int) $mb[0] : 9999;
return $na <=> $nb ?: strcmp($a['name'], $b['name']);
});
foreach ($rows as &$programmes) {
usort($programmes, static fn($a, $b) => $a['start'] <=> $b['start']);
}
return ['channels' => $channels, 'programmes' => $rows, 'error' => ''];
}
function export_window_definitions(string $target_day): array {
return [
'full' => [
'label' => '00h-24h',
'start' => new DateTimeImmutable($target_day . ' 00:00:00'),
'end' => new DateTimeImmutable($target_day . ' 23:59:59'),
],
'morning' => [
'label' => 'Morning',
'start' => new DateTimeImmutable($target_day . ' 06:00:00'),
'end' => new DateTimeImmutable($target_day . ' 11:59:59'),
],
'afternoon' => [
'label' => 'Afternoon',
'start' => new DateTimeImmutable($target_day . ' 12:00:00'),
'end' => new DateTimeImmutable($target_day . ' 17:59:59'),
],
'evening' => [
'label' => 'Evening',
'start' => new DateTimeImmutable($target_day . ' 18:00:00'),
'end' => new DateTimeImmutable($target_day . ' 23:59:59'),
],
'prime' => [
'label' => 'Prime time',
'start' => new DateTimeImmutable($target_day . ' 20:00:00'),
'end' => new DateTimeImmutable($target_day . ' 23:59:59'),
],
];
}
function export_filter_programmes(array $programmes, DateTimeImmutable $start, DateTimeImmutable $end): array {
return array_values(array_filter($programmes, static fn($p) => $p['stop'] > $start && $p['start'] < $end));
}
function export_hour_slots(DateTimeImmutable $start, DateTimeImmutable $end): array {
$slots = [];
$cursor = $start;
while ($cursor < $end) {
$slot_end = $cursor->modify('+1 hour');
if ($slot_end > $end) $slot_end = $end;
$slots[] = ['start' => $cursor, 'end' => $slot_end];
$cursor = $slot_end;
}
return $slots;
}
function export_programme_weight(array $programme): int {
$duration = max(1, (int) round(($programme['stop']->getTimestamp() - $programme['start']->getTimestamp()) / 60));
$weight = min($duration, 240);
if (!empty($programme['subtitle'])) $weight += 10;
if (!empty($programme['categories'])) $weight += 8;
if (!empty($programme['desc'])) $weight += min(40, (int) floor(strlen($programme['desc']) / 20));
return $weight;
}
function export_programme_duration(array $programme): int {
return max(1, (int) round(($programme['stop']->getTimestamp() - $programme['start']->getTimestamp()) / 60));
}
function export_programme_is_series(array $programme): bool {
return !empty($programme['subtitle']);
}
function export_programme_has_category(array $programme, array $needles): bool {
foreach ($programme['categories'] as $category) {
$value = strtolower($category);
foreach ($needles as $needle) {
if (str_contains($value, strtolower($needle))) return true;
}
}
return false;
}
function export_take_entries(array $entries, callable $filter, int $limit = 4): array {
$picked = [];
foreach ($entries as $entry) {
if (!$filter($entry)) continue;
$picked[] = $entry;
if (count($picked) >= $limit) break;
}
return $picked;
}
$sources = array_values($config['epg_sources'] ?? []);
$source_index = max(0, min((int) ($_GET['source'] ?? 0), max(count($sources) - 1, 0)));
$source = $sources[$source_index] ?? null;
$target_day = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_GET['day'] ?? '')) ? (string) $_GET['day'] : (new DateTimeImmutable('today'))->format('Y-m-d');
$layout_input = isset($_GET['layout']) ? (string) $_GET['layout'] : 'cards';
$limit_input = isset($_GET['limit']) ? (string) $_GET['limit'] : '8';
$window_input = isset($_GET['window']) ? (string) $_GET['window'] : 'full';
$layout = in_array($layout_input, ['cards', 'timeline'], true) ? $layout_input : 'cards';
$limit = in_array($limit_input, ['4', '8', 'all'], true) ? $limit_input : '8';
$windows = export_window_definitions($target_day);
$window_key = array_key_exists($window_input, $windows) ? $window_input : 'full';
$window = $windows[$window_key];
$slots = export_hour_slots($window['start'], $window['end']->modify('+1 second'));
$fetch = $source ? gridtv_fetch_url((string) $source['epg_url'], 25) : ['ok' => false, 'error' => 'No source configured', 'body' => ''];
$parsed = $fetch['ok'] ? export_parse_xmltv_programmes($fetch['body'], $target_day) : ['channels' => [], 'programmes' => [], 'error' => $fetch['error'] ?: 'Unable to fetch XMLTV'];
$visible_channels = [];
foreach ($parsed['channels'] as $channel_id => $channel) {
$filtered = export_filter_programmes($parsed['programmes'][$channel_id] ?? [], $window['start'], $window['end']->modify('+1 second'));
if (!$filtered) continue;
$visible_channels[$channel_id] = $channel + ['programmes' => $filtered];
}
if ($limit !== 'all') {
$visible_channels = array_slice($visible_channels, 0, (int) $limit, true);
}
$day_label = (new DateTimeImmutable($target_day))->format('l d F Y');
$layout_label = $layout === 'cards' ? 'Readable cards' : 'Dense timeline';
?><!DOCTYPE html>
<html lang="<?= htmlspecialchars($locale) ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GridTV — Export 24h</title>
<link rel="stylesheet" href="/assets/fonts/fonts.css">
<script src="/assets/vendor/html2canvas.min.js"></script>
<style>
:root{--paper:#f4ecda;--ink:#22201b;--ink-soft:#675f52;--accent:#aa4231;--accent-2:#264653;--line:#d3c5ab;--paper-2:#fbf6ea}
*{box-sizing:border-box}body{margin:0;font-family:'Barlow Condensed',sans-serif;background:linear-gradient(180deg,#1f2125 0,#121317 100%);color:#f7f4ee}
.page{max-width:1380px;margin:0 auto;padding:26px 18px 40px}
.toolbar{display:flex;justify-content:space-between;gap:16px;align-items:flex-start;flex-wrap:wrap;margin-bottom:18px}
.heading{max-width:760px}.heading h1{margin:0 0 6px;font-size:34px;letter-spacing:.06em;text-transform:uppercase}.heading p{margin:0;color:#bcb7ad;font-size:14px;line-height:1.5}
.controls,.filter-form{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
.controls a,.controls button,.controls select,.controls input,.filter-form select,.filter-form input,.filter-form button{height:42px;border:none}
.controls a,.controls button,.filter-form button{display:inline-flex;align-items:center;justify-content:center;padding:0 14px;background:#ebd58a;color:#1f1d18;text-decoration:none;font-size:13px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;cursor:pointer}
.ghost{background:#262a31 !important;color:#f7f4ee !important;border:1px solid #3c4450 !important}
.controls select,.controls input,.filter-form select,.filter-form input{padding:0 12px;background:#262a31;color:#fff;border:1px solid #3c4450;font-family:'Share Tech Mono',monospace}
.sheet-wrap{overflow:auto;padding:8px 0}
.sheet{width:1120px;margin:0 auto;background:var(--paper);color:var(--ink);padding:30px 34px 36px;box-shadow:0 26px 70px rgba(0,0,0,.45);border:10px solid #f8f2e7;position:relative}
.sheet::before{content:'';position:absolute;inset:14px;border:1px solid rgba(38,70,83,.18);pointer-events:none}
.sheet-head{display:flex;justify-content:space-between;gap:20px;align-items:flex-end;border-bottom:3px solid var(--accent);padding-bottom:16px;margin-bottom:18px}
.sheet-title{font-family:'IM Fell English',serif;font-size:44px;line-height:1.05;letter-spacing:.01em}
.sheet-sub{font-family:'Share Tech Mono',monospace;font-size:12px;letter-spacing:.16em;text-transform:uppercase;color:var(--accent-2)}
.sheet-meta{text-align:right}.sheet-meta .group{font-size:22px;font-weight:700}.sheet-meta .date{font-size:14px;color:var(--ink-soft)}
.legend{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:18px}
.legend span{padding:6px 10px;border:1px solid var(--line);font-family:'Share Tech Mono',monospace;font-size:11px;letter-spacing:.08em;text-transform:uppercase}
.empty{color:var(--ink-soft);font-style:italic;font-size:13px;padding-top:24px}
.note{margin-top:16px;font-size:12px;color:var(--ink-soft);text-align:right}
.cards-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}
.channel-card{background:rgba(255,255,255,.35);border:1px solid var(--line);padding:14px 14px 12px;break-inside:avoid}
.channel-head{display:flex;gap:12px;align-items:center;border-bottom:2px solid var(--accent-2);padding-bottom:10px;margin-bottom:12px}
.channel-head img{width:46px;height:46px;object-fit:contain;background:#fff;border:1px solid var(--line);padding:4px}
.channel-name{font-size:24px;font-weight:700;line-height:1.05}
.channel-count{font-family:'Share Tech Mono',monospace;font-size:11px;color:var(--ink-soft);letter-spacing:.08em;text-transform:uppercase}
.prog-list{display:grid;gap:8px}
.prog{display:grid;grid-template-columns:90px 1fr;gap:10px;padding:8px 0;border-top:1px dashed var(--line)}
.prog:first-child{border-top:none;padding-top:0}
.prog-time{font-family:'Share Tech Mono',monospace;font-size:11px;color:var(--accent-2);letter-spacing:.04em}
.prog-title{font-size:16px;font-weight:700;line-height:1.08}
.prog-sub,.prog-cats,.prog-desc{font-size:12px;color:var(--ink-soft);line-height:1.3;margin-top:2px}
.prog-desc{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.timeline-wrap{display:grid;gap:14px}
.timeline-row{display:grid;grid-template-columns:180px 1fr;gap:14px;align-items:stretch}
.timeline-channel{border-top:2px solid var(--accent-2);padding-top:8px}
.timeline-grid{display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));border-top:2px solid var(--accent-2);min-height:108px}
.timeline-slot{border-left:1px dashed var(--line);padding:8px 8px 10px}
.timeline-slot:first-child{border-left:none}
.slot-label{font-family:'Share Tech Mono',monospace;font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-soft);margin-bottom:8px}
.mini{display:block;padding:7px;background:rgba(170,66,49,.08);border-left:3px solid var(--accent);margin-bottom:6px}
.mini-title{font-size:13px;font-weight:700;line-height:1.05}
.mini-meta{font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--ink-soft);margin-top:3px}
@media print{body{background:#fff}.page{padding:0}.toolbar{display:none}.sheet{box-shadow:none;border:none;width:auto;margin:0}.sheet-wrap{overflow:visible}.cards-grid{grid-template-columns:1fr 1fr}}
@media (max-width:1200px){.sheet{width:100%}.cards-grid{grid-template-columns:1fr}.timeline-row{grid-template-columns:1fr}.timeline-grid{overflow:auto}}
</style>
</head>
<body>
<div class="page">
<div class="toolbar">
<div class="heading">
<h1>Export 24h</h1>
<p>Version imprimable pensée pour être vraiment lisible. Le mode “cartes” sert au PDF ou à lenvoi à quelquun, la timeline dense reste disponible pour une vue plus technique.</p>
</div>
<div class="controls">
<form method="GET" class="filter-form">
<select name="source">
<?php foreach ($sources as $i => $src): ?>
<option value="<?= $i ?>"<?= $i === $source_index ? ' selected' : '' ?>><?= htmlspecialchars($src['name']) ?></option>
<?php endforeach; ?>
</select>
<input type="date" name="day" value="<?= htmlspecialchars($target_day) ?>">
<select name="window">
<?php foreach ($windows as $key => $def): ?>
<option value="<?= htmlspecialchars($key) ?>"<?= $key === $window_key ? ' selected' : '' ?>><?= htmlspecialchars($def['label']) ?></option>
<?php endforeach; ?>
</select>
<select name="limit">
<option value="4"<?= $limit === '4' ? ' selected' : '' ?>>4 channels</option>
<option value="8"<?= $limit === '8' ? ' selected' : '' ?>>8 channels</option>
<option value="all"<?= $limit === 'all' ? ' selected' : '' ?>>All channels</option>
</select>
<select name="layout">
<option value="cards"<?= $layout === 'cards' ? ' selected' : '' ?>>Readable cards</option>
<option value="timeline"<?= $layout === 'timeline' ? ' selected' : '' ?>>Dense timeline</option>
</select>
<button type="submit" class="ghost">Refresh</button>
</form>
<button type="button" onclick="window.print()">PDF / Print</button>
<button type="button" class="ghost" onclick="downloadImage()">Image</button>
<a href="/index.php" class="ghost">Guide</a>
</div>
</div>
<div class="sheet-wrap">
<div class="sheet" id="exportSheet">
<div class="sheet-head">
<div>
<div class="sheet-sub">TV Guide · Printable edition · <?= htmlspecialchars($layout_label) ?></div>
<div class="sheet-title">Le programme télé de la journée</div>
</div>
<div class="sheet-meta">
<div class="group"><?= htmlspecialchars($config['group_name'] ?? 'GridTV') ?></div>
<div class="date"><?= htmlspecialchars($day_label) ?> · <?= htmlspecialchars($source['name'] ?? 'Source') ?></div>
</div>
</div>
<div class="legend">
<span><?= htmlspecialchars($window['label']) ?></span>
<span><?= count($visible_channels) ?> channel(s)</span>
<span><?= htmlspecialchars($layout_label) ?></span>
</div>
<?php if (!$fetch['ok'] || $parsed['error']): ?>
<div class="empty">Impossible de générer la feuille: <?= htmlspecialchars($parsed['error'] ?: ($fetch['error'] ?? 'Unknown error')) ?></div>
<?php elseif (!$visible_channels): ?>
<div class="empty">Aucun programme trouvé avec ce filtre.</div>
<?php elseif ($layout === 'cards'): ?>
<div class="cards-grid">
<?php foreach ($visible_channels as $channel): ?>
<section class="channel-card">
<div class="channel-head">
<?php if ($channel['icon']): ?><img src="<?= htmlspecialchars($channel['icon']) ?>" alt=""><?php endif; ?>
<div>
<div class="channel-name"><?= htmlspecialchars($channel['name']) ?></div>
<div class="channel-count"><?= count($channel['programmes']) ?> programme(s)</div>
</div>
</div>
<div class="prog-list">
<?php foreach ($channel['programmes'] as $programme): ?>
<?php
$meta = $programme['rating'];
$cats = $programme['categories'] ? implode(' · ', $programme['categories']) : '';
?>
<article class="prog">
<div class="prog-time"><?= htmlspecialchars($programme['start']->format('H:i')) ?><br><?= htmlspecialchars($programme['stop']->format('H:i')) ?></div>
<div>
<div class="prog-title"><?= htmlspecialchars($programme['title']) ?></div>
<?php if ($programme['subtitle']): ?><div class="prog-sub"><?= htmlspecialchars($programme['subtitle']) ?></div><?php endif; ?>
<?php if ($meta): ?><div class="prog-sub"><?= htmlspecialchars($meta) ?></div><?php endif; ?>
<?php if ($cats): ?><div class="prog-cats"><?= htmlspecialchars($cats) ?></div><?php endif; ?>
<?php if ($programme['desc']): ?><div class="prog-desc"><?= htmlspecialchars($programme['desc']) ?></div><?php endif; ?>
</div>
</article>
<?php endforeach; ?>
</div>
</section>
<?php endforeach; ?>
</div>
<?php elseif ($layout === 'timeline'): ?>
<div class="timeline-wrap">
<?php foreach ($visible_channels as $channel): ?>
<div class="timeline-row">
<div class="timeline-channel">
<div class="channel-head">
<?php if ($channel['icon']): ?><img src="<?= htmlspecialchars($channel['icon']) ?>" alt=""><?php endif; ?>
<div>
<div class="channel-name"><?= htmlspecialchars($channel['name']) ?></div>
<div class="channel-count"><?= count($channel['programmes']) ?> programme(s)</div>
</div>
</div>
</div>
<div class="timeline-grid" style="--cols:<?= count($slots) ?>">
<?php foreach ($slots as $slot): ?>
<?php
$items = array_values(array_filter($channel['programmes'], static fn($p) => $p['stop'] > $slot['start'] && $p['start'] < $slot['end']));
?>
<div class="timeline-slot">
<div class="slot-label"><?= htmlspecialchars($slot['start']->format('H:i')) ?></div>
<?php if (!$items): ?>
<div class="empty">—</div>
<?php else: ?>
<?php foreach ($items as $programme): ?>
<div class="mini">
<div class="mini-title"><?= htmlspecialchars($programme['title']) ?></div>
<div class="mini-meta"><?= htmlspecialchars($programme['start']->format('H:i') . ' - ' . $programme['stop']->format('H:i')) ?></div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="note">Astuce: “Readable cards” reste le plus propre pour une impression exhaustive.</div>
</div>
</div>
</div>
<script>
async function downloadImage() {
const sheet = document.getElementById('exportSheet');
if (!sheet || typeof html2canvas !== 'function') return;
const canvas = await html2canvas(sheet, {backgroundColor: '#f4ecda', scale: 2, useCORS: true});
const link = document.createElement('a');
link.href = canvas.toDataURL('image/png');
link.download = 'gridtv-<?= htmlspecialchars($window_key) ?>-<?= htmlspecialchars($target_day) ?>.png';
link.click();
}
</script>
</body>
</html>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

+337
View File
@@ -0,0 +1,337 @@
<?php
require_once __DIR__ . '/src/lib/admin.php';
$config = gridtv_load_config();
gridtv_require_admin($config, 'Health');
[$locale, $L] = gridtv_load_locale($config);
function health_status_class(bool $ok): string {
return $ok ? 'ok' : 'ko';
}
function health_parse_xmltv(string $xml): array {
if (!class_exists('DOMDocument')) {
return ['ok' => false, 'error' => 'PHP DOM/XML extension is not installed.'];
}
if ($xml === '') {
return ['ok' => false, 'error' => 'Empty response'];
}
libxml_use_internal_errors(true);
$dom = new DOMDocument();
$loaded = $dom->loadXML($xml, LIBXML_NOERROR | LIBXML_NOWARNING);
$errors = array_map(static fn($e) => trim($e->message), libxml_get_errors());
libxml_clear_errors();
if (!$loaded) {
return ['ok' => false, 'error' => $errors[0] ?? 'Invalid XML'];
}
$xp = new DOMXPath($dom);
$programmes = $xp->query('/tv/programme');
$channels = $xp->query('/tv/channel');
$categories = $xp->query('/tv/programme/category');
$subtitles = $xp->query('/tv/programme/sub-title');
$ratings = $xp->query('/tv/programme/rating');
$dates = $xp->query('/tv/programme/date');
$first = null;
$last = null;
foreach ($programmes as $programme) {
$start = $programme->getAttribute('start');
$stop = $programme->getAttribute('stop');
if ($start !== '' && ($first === null || strcmp($start, $first) < 0)) $first = $start;
if ($stop !== '' && ($last === null || strcmp($stop, $last) > 0)) $last = $stop;
}
return [
'ok' => true,
'channels' => $channels->length,
'programmes' => $programmes->length,
'categories' => $categories->length,
'subtitles' => $subtitles->length,
'ratings' => $ratings->length,
'dates' => $dates->length,
'first_start' => $first,
'last_stop' => $last,
];
}
function health_parse_m3u(string $body): array {
$lines = preg_split('/\r\n|\r|\n/', $body);
$streams = 0;
$extinf = 0;
foreach ($lines as $line) {
$line = trim($line);
if ($line === '') continue;
if (stripos($line, '#EXTINF') === 0) {
$extinf++;
continue;
}
if ($line[0] !== '#') $streams++;
}
return ['streams' => $streams, 'extinf' => $extinf];
}
function health_format_xmltv_stamp(?string $stamp): string {
if (!$stamp) return '—';
$dt = DateTime::createFromFormat('YmdHis O', $stamp);
if (!$dt) return $stamp;
return $dt->format('d/m/Y H:i');
}
function health_build_payload(array $config, string $locale): array {
$config_path = gridtv_config_path();
$config_exists = file_exists($config_path);
$config_writable = $config_exists ? is_writable($config_path) : is_writable(dirname($config_path));
$sources = array_values($config['epg_sources'] ?? []);
$checks = [
[
'label' => 'Config file',
'ok' => $config_exists,
'detail' => $config_exists ? basename($config_path) . ' found' : 'Missing config.json',
],
[
'label' => 'Config writable',
'ok' => $config_writable,
'detail' => $config_writable ? 'Writable by PHP' : 'Not writable by PHP',
],
[
'label' => 'PHP version',
'ok' => version_compare(PHP_VERSION, '8.0.0', '>='),
'detail' => 'PHP ' . PHP_VERSION,
],
[
'label' => 'php-curl',
'ok' => extension_loaded('curl'),
'detail' => extension_loaded('curl') ? 'Extension loaded' : 'Missing php-curl',
],
[
'label' => 'php-xml / DOM',
'ok' => class_exists('DOMDocument'),
'detail' => class_exists('DOMDocument') ? 'Extension loaded' : 'Missing php-xml / DOM',
],
[
'label' => 'Configured sources',
'ok' => count($sources) > 0,
'detail' => count($sources) . ' source(s) in config',
],
];
$source_reports = [];
foreach ($sources as $index => $source) {
$epg_url = trim((string) ($source['epg_url'] ?? ''));
$m3u_url = trim((string) ($source['m3u_url'] ?? ''));
$epg_fetch = $epg_url !== '' ? gridtv_fetch_url($epg_url, 25) : ['ok' => false, 'status' => 0, 'error' => 'Missing URL', 'body' => '', 'content_type' => '', 'final_url' => '', 'time_total' => 0.0];
$epg_stats = $epg_fetch['ok'] ? health_parse_xmltv($epg_fetch['body']) : ['ok' => false, 'error' => $epg_fetch['error'] ?: 'HTTP ' . $epg_fetch['status']];
$m3u_fetch = null;
$m3u_stats = null;
if ($m3u_url !== '') {
$m3u_fetch = gridtv_fetch_url($m3u_url, 20);
$m3u_stats = $m3u_fetch['ok'] ? health_parse_m3u($m3u_fetch['body']) : null;
}
$source_reports[] = [
'name' => $source['name'] ?: 'Source ' . ($index + 1),
'epg_url' => $epg_url,
'm3u_url' => $m3u_url,
'epg_host' => gridtv_extract_host($epg_url),
'm3u_host' => gridtv_extract_host($m3u_url),
'epg_fetch' => $epg_fetch,
'epg_stats' => $epg_stats,
'm3u_fetch' => $m3u_fetch,
'm3u_stats' => $m3u_stats,
];
}
return [
'summary' => [
'group_name' => $config['group_name'] ?? 'GridTV',
'source_count' => count($sources),
'locale' => strtoupper($locale),
'config_size' => $config_exists ? gridtv_format_bytes((int) filesize($config_path)) : '0 B',
'config_path' => $config_path,
],
'checks' => $checks,
'sources' => $source_reports,
'helpers' => [
'status_ok' => 'OK',
'status_bad' => 'Issue',
],
];
}
if (isset($_GET['format']) && $_GET['format'] === 'json') {
header('Content-Type: application/json; charset=UTF-8');
echo json_encode(health_build_payload($config, $locale), JSON_UNESCAPED_SLASHES);
exit;
}
?><!DOCTYPE html>
<html lang="<?= htmlspecialchars($locale) ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GridTV — Health</title>
<link rel="stylesheet" href="/assets/fonts/fonts.css">
<style>
:root{--bg:#0b0d10;--surface:#13161b;--surface2:#1a1f27;--border:#2a3240;--accent:#e8c842;--accent2:#5ec0ff;--text:#eaf0f6;--muted:#8f9aac;--ok:#4fd18b;--warn:#ff6767}
*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at top left,#1b2330 0,#0b0d10 48%);color:var(--text);font-family:'Barlow Condensed',sans-serif}
.page{max-width:1200px;margin:0 auto;padding:32px 20px 56px}
.hero{display:flex;justify-content:space-between;gap:18px;align-items:flex-end;margin-bottom:24px}
.logo{font-family:'Share Tech Mono',monospace;font-size:26px;letter-spacing:.12em;color:var(--accent);text-transform:uppercase}
.subtitle{color:var(--muted);font-size:14px;max-width:700px;line-height:1.5}
.actions{display:flex;gap:10px;flex-wrap:wrap}.btn{display:inline-flex;align-items:center;gap:8px;padding:11px 14px;border:1px solid var(--border);background:var(--surface);color:var(--text);text-decoration:none;font-size:13px;letter-spacing:.08em;text-transform:uppercase}.btn-primary{background:var(--accent);color:#000;border-color:var(--accent)}
.grid,.checks,.source-list{display:grid;gap:14px}.grid{grid-template-columns:repeat(auto-fit,minmax(220px,1fr));margin-bottom:22px}.checks{grid-template-columns:repeat(auto-fit,minmax(220px,1fr));margin-bottom:24px}.source-list{gap:18px}
.card,.source,.check{background:rgba(19,22,27,.95);border:1px solid var(--border)}.card,.check,.source{padding:16px}.source{padding:20px}
.eyebrow{font-family:'Share Tech Mono',monospace;font-size:11px;color:var(--accent2);letter-spacing:.12em;text-transform:uppercase;margin-bottom:10px}.value{font-size:28px;font-weight:700}.small{font-size:13px;color:var(--muted)}
.status{display:inline-flex;align-items:center;gap:8px;font-family:'Share Tech Mono',monospace;font-size:11px;letter-spacing:.1em;text-transform:uppercase}.status::before{content:'';width:10px;height:10px;border-radius:50%}.status.ok{color:var(--ok)}.status.ok::before{background:var(--ok)}.status.ko{color:var(--warn)}.status.ko::before{background:var(--warn)}
.source-head{display:flex;justify-content:space-between;gap:14px;align-items:flex-start;margin-bottom:16px}.source-title{font-size:24px;font-weight:700}.pill{display:inline-block;padding:6px 10px;font-family:'Share Tech Mono',monospace;font-size:11px;letter-spacing:.1em;text-transform:uppercase;background:var(--surface2);border:1px solid var(--border);color:var(--accent2)}
.split{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:14px}.panel{padding:16px;background:var(--surface2);border:1px solid var(--border)}.panel h3{margin:0 0 10px;font-size:16px;letter-spacing:.06em;text-transform:uppercase}
.kv{display:grid;grid-template-columns:160px 1fr;gap:8px;font-size:14px;line-height:1.45}.kv div:nth-child(odd){color:var(--muted)}.mono{font-family:'Share Tech Mono',monospace;font-size:12px;word-break:break-all}
.skeleton{position:relative;overflow:hidden}.skeleton::after{content:'';position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(255,255,255,.08),transparent);transform:translateX(-100%);animation:shine 1.5s infinite}.skeleton-line{height:16px;background:#1c2430;margin-top:8px}.skeleton-line.short{width:42%}.splash{display:flex;align-items:center;gap:14px;padding:18px 20px;margin-bottom:20px;background:rgba(19,22,27,.95);border:1px solid var(--border)}.spinner{width:22px;height:22px;border:3px solid rgba(94,192,255,.18);border-top-color:var(--accent2);border-radius:50%;animation:spin 1s linear infinite}
.hidden{display:none!important}
@keyframes spin{to{transform:rotate(360deg)}}@keyframes shine{100%{transform:translateX(100%)}}@media (max-width:700px){.hero{flex-direction:column;align-items:flex-start}.kv{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="page">
<div class="hero">
<div>
<div class="logo">GridTV / Health</div>
<div class="subtitle">Page de diagnostic admin. L’écran apparaît tout de suite, puis les checks XMLTV/M3U se remplissent en arrière-plan.</div>
</div>
<div class="actions">
<a class="btn btn-primary" href="/export.php">Export 24h</a>
<a class="btn" href="/setup.php">Setup</a>
<a class="btn" href="/index.php">Guide</a>
</div>
</div>
<div class="splash" id="splash">
<div class="spinner"></div>
<div>
<div style="font-size:18px;font-weight:700">Loading diagnostics…</div>
<div class="small">Configuration, XMLTV et M3U sont testés en arrière-plan.</div>
</div>
</div>
<div class="grid" id="summaryGrid">
<div class="card skeleton"><div class="eyebrow">Group</div><div class="skeleton-line"></div><div class="skeleton-line short"></div></div>
<div class="card skeleton"><div class="eyebrow">Sources</div><div class="skeleton-line"></div><div class="skeleton-line short"></div></div>
<div class="card skeleton"><div class="eyebrow">Locale</div><div class="skeleton-line"></div><div class="skeleton-line short"></div></div>
<div class="card skeleton"><div class="eyebrow">Config Size</div><div class="skeleton-line"></div><div class="skeleton-line short"></div></div>
</div>
<div class="checks" id="checksGrid">
<div class="check skeleton"><div class="skeleton-line"></div><div class="skeleton-line short"></div></div>
<div class="check skeleton"><div class="skeleton-line"></div><div class="skeleton-line short"></div></div>
<div class="check skeleton"><div class="skeleton-line"></div><div class="skeleton-line short"></div></div>
</div>
<div class="source-list" id="sourceList">
<div class="source skeleton"><div class="skeleton-line"></div><div class="skeleton-line"></div><div class="skeleton-line short"></div></div>
</div>
</div>
<script>
const healthUrl = '<?= htmlspecialchars(strtok($_SERVER['REQUEST_URI'] ?? '/health.php', '?')) ?>?format=json';
function statusHtml(ok, labels) {
const cls = ok ? 'ok' : 'ko';
return `<span class="status ${cls}">${ok ? labels.status_ok : labels.status_bad}</span>`;
}
function renderSummary(summary) {
document.getElementById('summaryGrid').innerHTML = `
<div class="card"><div class="eyebrow">Group</div><div class="value">${summary.group_name}</div><div class="small">Configured TV group name</div></div>
<div class="card"><div class="eyebrow">Sources</div><div class="value">${summary.source_count}</div><div class="small">Configured XMLTV sources</div></div>
<div class="card"><div class="eyebrow">Locale</div><div class="value">${summary.locale}</div><div class="small">Detected UI locale</div></div>
<div class="card"><div class="eyebrow">Config Size</div><div class="value">${summary.config_size}</div><div class="small">${summary.config_path}</div></div>
`;
}
function renderChecks(checks, labels) {
document.getElementById('checksGrid').innerHTML = checks.map(check => `
<div class="check">
${statusHtml(check.ok, labels)}
<div style="font-size:18px;font-weight:700;margin:8px 0 6px">${check.label}</div>
<div class="small">${check.detail}</div>
</div>
`).join('');
}
function renderSources(sources, labels) {
document.getElementById('sourceList').innerHTML = sources.map(report => {
const epgHealthy = report.epg_fetch.ok && report.epg_stats.ok;
const m3uHealthy = report.m3u_fetch ? report.m3u_fetch.ok : true;
const epgRows = epgHealthy ? `
<div>Channels</div><div>${report.epg_stats.channels}</div>
<div>Programmes</div><div>${report.epg_stats.programmes}</div>
<div>Sub-titles</div><div>${report.epg_stats.subtitles}</div>
<div>Ratings</div><div>${report.epg_stats.ratings}</div>
<div>Categories</div><div>${report.epg_stats.categories}</div>
<div>Date tags</div><div>${report.epg_stats.dates}</div>
<div>Window</div><div>${report.epg_stats.first_start || '—'} → ${report.epg_stats.last_stop || '—'}</div>
<div>Content-Type</div><div>${report.epg_fetch.content_type || '—'}</div>
` : `<div>Parse</div><div>${report.epg_stats.error || 'Unable to read XMLTV'}</div>`;
const m3uRows = !report.m3u_url ? `<div class="small">No M3U URL configured for this source.</div>` : `
<div class="kv">
<div>Status</div><div>${statusHtml(m3uHealthy, labels)}</div>
<div>URL</div><div class="mono">${report.m3u_url}</div>
<div>HTTP</div><div>${report.m3u_fetch.status || 0}${report.m3u_fetch.error ? ' · ' + report.m3u_fetch.error : ''}</div>
<div>Response time</div><div>${Number(report.m3u_fetch.time_total || 0).toFixed(2)} s</div>
${report.m3u_fetch.ok && report.m3u_stats ? `
<div>Streams</div><div>${report.m3u_stats.streams}</div>
<div>#EXTINF</div><div>${report.m3u_stats.extinf}</div>
<div>Content-Type</div><div>${report.m3u_fetch.content_type || '—'}</div>
` : `<div>Parse</div><div>Unable to load playlist.</div>`}
</div>`;
return `
<section class="source">
<div class="source-head">
<div>
<div class="source-title">${report.name}</div>
<div class="small">EPG host: ${report.epg_host || '—'}${report.m3u_host ? ' · M3U host: ' + report.m3u_host : ''}</div>
</div>
<span class="pill">${epgHealthy ? 'Healthy XMLTV' : 'Check source'}</span>
</div>
<div class="split">
<div class="panel">
<h3>XMLTV</h3>
<div class="kv">
<div>Status</div><div>${statusHtml(epgHealthy, labels)}</div>
<div>URL</div><div class="mono">${report.epg_url}</div>
<div>HTTP</div><div>${report.epg_fetch.status}${report.epg_fetch.error ? ' · ' + report.epg_fetch.error : ''}</div>
<div>Response time</div><div>${Number(report.epg_fetch.time_total).toFixed(2)} s</div>
${epgRows}
</div>
</div>
<div class="panel">
<h3>M3U</h3>
${m3uRows}
</div>
</div>
</section>
`;
}).join('');
}
fetch(healthUrl, {credentials: 'same-origin'})
.then(r => r.json())
.then(data => {
renderSummary(data.summary);
renderChecks(data.checks, data.helpers);
renderSources(data.sources, data.helpers);
document.getElementById('splash').classList.add('hidden');
})
.catch(err => {
document.getElementById('splash').innerHTML = `<div><div style="font-size:18px;font-weight:700;color:#ff6767">Unable to load diagnostics</div><div class="small">${err.message}</div></div>`;
});
</script>
</body>
</html>
+30 -5
View File
@@ -1,6 +1,27 @@
{ {
"days": ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], "days": [
"months": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], "Sun",
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat"
],
"months": [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec"
],
"now": "Now", "now": "Now",
"channels": "Channels", "channels": "Channels",
"live": "LIVE", "live": "LIVE",
@@ -11,7 +32,7 @@
"search_placeholder": "Search channel or program...", "search_placeholder": "Search channel or program...",
"copy_epg": "EPG", "copy_epg": "EPG",
"copy_m3u": "M3U", "copy_m3u": "M3U",
"copied": "\u2713 copied", "copied": "✓ copied",
"program_info": "Program info", "program_info": "Program info",
"watch_now": "Watch now", "watch_now": "Watch now",
"imdb_search": "Search on IMDb", "imdb_search": "Search on IMDb",
@@ -25,6 +46,10 @@
"cancel": "Cancel", "cancel": "Cancel",
"apply": "Apply", "apply": "Apply",
"setup_already_done": "Configuration already done. Use your admin key to access setup again.", "setup_already_done": "Configuration already done. Use your admin key to access setup again.",
"shift_back": "\u25c4 1h", "shift_back": " 1h",
"shift_fwd": "1h \u25ba" "shift_fwd": "1h ►",
"fav_add": "Add to favorites",
"fav_remove": "Remove from favorites",
"update_available": "&#8593; Update available:",
"update_title": "Click to download the latest version"
} }
+32 -7
View File
@@ -1,17 +1,38 @@
{ {
"days": ["Dom", "Lun", "Mar", "Mi\u00e9", "Jue", "Vie", "S\u00e1b"], "days": [
"months": ["Ene", "Feb", "Mar", "Abr", "May", "Jun", "Jul", "Ago", "Sep", "Oct", "Nov", "Dic"], "Dom",
"Lun",
"Mar",
"Mié",
"Jue",
"Vie",
"Sáb"
],
"months": [
"Ene",
"Feb",
"Mar",
"Abr",
"May",
"Jun",
"Jul",
"Ago",
"Sep",
"Oct",
"Nov",
"Dic"
],
"now": "Ahora", "now": "Ahora",
"channels": "Canales", "channels": "Canales",
"live": "LIVE", "live": "LIVE",
"loading": "Cargando gu\u00eda...", "loading": "Cargando guía...",
"epg_error": "No se puede cargar el EPG", "epg_error": "No se puede cargar el EPG",
"epg_error_sub": "Comprueba la URL de tu fuente XMLTV.", "epg_error_sub": "Comprueba la URL de tu fuente XMLTV.",
"load": "Cargar", "load": "Cargar",
"search_placeholder": "Buscar canal o programa...", "search_placeholder": "Buscar canal o programa...",
"copy_epg": "EPG", "copy_epg": "EPG",
"copy_m3u": "M3U", "copy_m3u": "M3U",
"copied": "\u2713 copiado", "copied": "✓ copiado",
"program_info": "Info del programa", "program_info": "Info del programa",
"watch_now": "Ver ahora", "watch_now": "Ver ahora",
"imdb_search": "Buscar en IMDb", "imdb_search": "Buscar en IMDb",
@@ -24,7 +45,11 @@
"personal_epg_optional": "opcional", "personal_epg_optional": "opcional",
"cancel": "Cancelar", "cancel": "Cancelar",
"apply": "Aplicar", "apply": "Aplicar",
"setup_already_done": "Configuraci\u00f3n ya realizada. Usa tu clave de administrador para volver al setup.", "setup_already_done": "Configuración ya realizada. Usa tu clave de administrador para volver al setup.",
"shift_back": "\u25c4 1h", "shift_back": " 1h",
"shift_fwd": "1h \u25ba" "shift_fwd": "1h ►",
"fav_add": "Agregar a favoritos",
"fav_remove": "Quitar de favoritos",
"update_available": "&#8593; Actualizacion disponible:",
"update_title": "Haz clic para descargar la ultima version"
} }
+34 -9
View File
@@ -1,17 +1,38 @@
{ {
"days": ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"], "days": [
"months": ["Jan", "F\u00e9v", "Mar", "Avr", "Mai", "Jun", "Jul", "Ao\u00fb", "Sep", "Oct", "Nov", "D\u00e9c"], "Dim",
"Lun",
"Mar",
"Mer",
"Jeu",
"Ven",
"Sam"
],
"months": [
"Jan",
"Fév",
"Mar",
"Avr",
"Mai",
"Jun",
"Jul",
"Aoû",
"Sep",
"Oct",
"Nov",
"Déc"
],
"now": "Maintenant", "now": "Maintenant",
"channels": "Cha\u00eenes", "channels": "Chaînes",
"live": "LIVE", "live": "LIVE",
"loading": "Chargement du guide...", "loading": "Chargement du guide...",
"epg_error": "Impossible de charger l'EPG", "epg_error": "Impossible de charger l'EPG",
"epg_error_sub": "V\u00e9rifiez l'URL de votre flux XMLTV.", "epg_error_sub": "Vérifiez l'URL de votre flux XMLTV.",
"load": "Charger", "load": "Charger",
"search_placeholder": "Rechercher une cha\u00eene ou un programme...", "search_placeholder": "Rechercher une chaîne ou un programme...",
"copy_epg": "EPG", "copy_epg": "EPG",
"copy_m3u": "M3U", "copy_m3u": "M3U",
"copied": "\u2713 copi\u00e9", "copied": "✓ copié",
"program_info": "Infos programme", "program_info": "Infos programme",
"watch_now": "Regarder", "watch_now": "Regarder",
"imdb_search": "Rechercher sur IMDb", "imdb_search": "Rechercher sur IMDb",
@@ -24,7 +45,11 @@
"personal_epg_optional": "optionnel", "personal_epg_optional": "optionnel",
"cancel": "Annuler", "cancel": "Annuler",
"apply": "Appliquer", "apply": "Appliquer",
"setup_already_done": "Configuration d\u00e9j\u00e0 effectu\u00e9e. Utilisez votre cl\u00e9 admin pour acc\u00e9der \u00e0 nouveau au setup.", "setup_already_done": "Configuration déjà effectuée. Utilisez votre clé admin pour accéder à nouveau au setup.",
"shift_back": "\u25c4 1h", "shift_back": " 1h",
"shift_fwd": "1h \u25ba" "shift_fwd": "1h ►",
"fav_add": "Ajouter aux favoris",
"fav_remove": "Retirer des favoris",
"update_available": "&#8593; Mise a jour disponible :",
"update_title": "Cliquez pour telecharger la derniere version"
} }
+24
View File
@@ -0,0 +1,24 @@
{
"name": "GridTV",
"short_name": "GridTV",
"description": "Self-hosted IPTV TV guide",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0b0d",
"theme_color": "#0a0b0d",
"orientation": "any",
"icons": [
{
"src": "assets/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "assets/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+175 -40
View File
@@ -1,81 +1,216 @@
<?php <?php
/** /**
* GridTV — proxy.php (Apache + cURL) * GridTV — proxy.php
*
* HTTP->HTTPS proxy restricted to hosts explicitly configured in config.json.
* Private and LAN IPs are allowed only when the administrator configured them.
* Redirects are followed manually and the host is revalidated at every hop.
*/ */
$url = $_GET['url'] ?? ''; // ── Allowlist: authorized hosts extracted from config.json ────────────────────
if (empty($url) || !preg_match('#^https?://#i', $url)) { $config_path = __DIR__ . '/config.json';
http_response_code(400); die('Invalid URL'); $allowed_hosts = [];
if (file_exists($config_path)) {
$config = json_decode(file_get_contents($config_path), true) ?? [];
foreach ($config['epg_sources'] ?? [] as $src) {
foreach (['epg_url', 'm3u_url'] as $key) {
if (!empty($src[$key])) {
$host = strtolower(parse_url($src[$key], PHP_URL_HOST) ?? '');
if ($host !== '') $allowed_hosts[] = $host;
}
}
}
} }
$base = preg_replace('#[^/]*(\?.*)?$#', '', $url); // ── Helpers ────────────────────────────────────────────────────────────────────
$origin = parse_url($url, PHP_URL_SCHEME) . '://' . parse_url($url, PHP_URL_HOST);
$port = parse_url($url, PHP_URL_PORT); function is_allowed_url(string $url, array $allowed_hosts): bool {
if ($port) $origin .= ':' . $port; if (!preg_match('#^https?://#i', $url)) return false;
$host = strtolower(parse_url($url, PHP_URL_HOST) ?? '');
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 {
if (preg_match('#^https?://#i', $location)) return $location;
$parts = parse_url($base);
$origin = $parts['scheme'] . '://' . $parts['host'];
if (!empty($parts['port'])) $origin .= ':' . $parts['port'];
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 with manual redirect handling so every Location header is checked
* against the allowlist before following it.
*/
function fetch_with_checked_redirects(string $url, array $allowed_hosts, bool $head_only = false): array {
$max_redirects = 5;
for ($i = 0; $i <= $max_redirects; $i++) {
if (!is_allowed_url($url, $allowed_hosts)) {
http_response_code(403); die('Host not allowed after redirect');
}
$ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_HEADER => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_NOBODY => $head_only,
CURLOPT_TIMEOUT => 30,
CURLOPT_USERAGENT => $ua,
CURLOPT_HTTPHEADER => ['Accept: */*'],
]);
$response = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
if ($response === false) {
http_response_code(502); die('Upstream error');
}
$raw_headers = substr($response, 0, $header_size);
$body = substr($response, $header_size);
// Follow redirects manually so each hop is rechecked against the allowlist.
if ($code >= 300 && $code < 400) {
if (!preg_match('/^Location:\s*(.+)$/mi', $raw_headers, $m)) {
http_response_code(502); die('Invalid redirect');
}
$url = resolve_url($url, trim($m[1]));
continue;
}
return [$code, $raw_headers, $body, $url];
}
http_response_code(508); die('Too many redirects');
}
// ── Validate the initial URL ───────────────────────────────────────────────────
$url = $_GET['url'] ?? '';
if (!is_allowed_url($url, $allowed_hosts)) {
http_response_code(empty($allowed_hosts) ? 503 : 403);
die(empty($allowed_hosts) ? 'No sources configured' : 'Host not allowed');
}
// ── 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');
$ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0'; // ── Binary segment: stream chunk by chunk ──────────────────────────────────────
if ($is_segment) { if ($is_segment) {
// Segment binaire — stream via cURL chunk par chunk
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();
$ch = curl_init($url); // Resolve redirects with HEAD first so we do not download the segment twice.
// Some HLS/CDN servers reject HEAD on media objects even when GET works.
[$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(); }
// Stream the validated final URL.
$ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0';
$ch = curl_init($final_url);
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_FOLLOWLOCATION => true, CURLOPT_FOLLOWLOCATION => false,
CURLOPT_TIMEOUT => 30, CURLOPT_TIMEOUT => 30,
CURLOPT_USERAGENT => $ua, CURLOPT_USERAGENT => $ua,
CURLOPT_HTTPHEADER => ['Accept: */*'], CURLOPT_HTTPHEADER => ['Accept: */*'],
CURLOPT_RETURNTRANSFER => false, CURLOPT_RETURNTRANSFER => false,
CURLOPT_WRITEFUNCTION => function($ch, $data) { CURLOPT_WRITEFUNCTION => function($ch, $data) {
echo $data; echo $data; flush(); return strlen($data);
flush();
return strlen($data);
}, },
CURLOPT_HEADERFUNCTION => function($ch, $header) { CURLOPT_HEADERFUNCTION => function($ch, $header) {
$h = trim($header); $h = trim($header);
if (preg_match('/^Content-Type:/i', $h)) { if (preg_match('/^Content-Type:/i', $h)) header($h);
header($h);
}
return strlen($header); return strlen($header);
}, },
]); ]);
$ok = curl_exec($ch); $ok = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (!$ok || $code >= 400) { if (!$ok || $code >= 400) http_response_code($code ?: 502);
http_response_code($code ?: 502);
}
curl_close($ch); curl_close($ch);
// ── m3u8 playlist: fetch and rewrite URLs ──────────────────────────────────────
} else { } else {
// Playlist .m3u8 — fetch + réécriture URLs [$code, , $body, $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, false);
$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) { if ($code >= 400) { http_response_code($code); die("Upstream error $code"); }
http_response_code($code ?: 502);
die("Upstream error $code");
}
header('Content-Type: application/vnd.apple.mpegurl'); header('Content-Type: application/vnd.apple.mpegurl');
$final_parts = parse_url($final_url);
$origin = $final_parts['scheme'] . '://' . $final_parts['host'];
if (!empty($final_parts['port'])) $origin .= ':' . $final_parts['port'];
$base = preg_replace('#[^/]*(\?.*)?$#', '', $final_url);
$proxy_base = (isset($_SERVER['HTTPS']) ? 'https' : 'http') $proxy_base = (isset($_SERVER['HTTPS']) ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST'] . '://' . $_SERVER['HTTP_HOST']
. strtok($_SERVER['REQUEST_URI'], '?') . strtok($_SERVER['REQUEST_URI'], '?')
@@ -85,7 +220,7 @@ if ($is_segment) {
foreach (explode("\n", $body) as $line) { foreach (explode("\n", $body) as $line) {
$line = rtrim($line); $line = rtrim($line);
if ($line === '' || $line[0] === '#') { if ($line === '' || $line[0] === '#') {
$line = preg_replace_callback('/URI="([^"]+)"/', function($m) use ($base, $proxy_base) { $line = preg_replace_callback('/URI="([^"]+)"/', function($m) use ($base, $origin, $proxy_base) {
$seg = strpos($m[1], 'http') === 0 ? $m[1] $seg = strpos($m[1], 'http') === 0 ? $m[1]
: ($m[1][0] === '/' ? $origin . $m[1] : $base . $m[1]); : ($m[1][0] === '/' ? $origin . $m[1] : $base . $m[1]);
return 'URI="' . $proxy_base . urlencode($seg) . '"'; return 'URI="' . $proxy_base . urlencode($seg) . '"';
+188 -230
View File
@@ -1,10 +1,10 @@
<?php <?php
// ── Migration automatique ancien format → nouveau ─────────────────────────────
$config_path = __DIR__ . '/config.json'; $config_path = __DIR__ . '/config.json';
// 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);
// Vieille instance : epg_url à la racine, pas de epg_sources 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',
@@ -15,18 +15,55 @@ 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));
} }
http_response_code(403); // Bootstrap an admin key for older configs that do not have one yet.
die('Configuration déjà effectuée. Modifiez config.json via SSH pour changer les paramètres.'); if (empty($existing['admin_key'])) {
$existing['admin_key'] = bin2hex(random_bytes(16));
file_put_contents($config_path, json_encode($existing, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
}
}
} }
$error = ''; $is_first_setup = !file_exists($config_path);
$success = false; $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;
if ($_SERVER['REQUEST_METHOD'] === 'POST') { $mode = 'setup';
$error = '';
$new_key = '';
if (!$is_first_setup) {
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['admin_key_input'])) {
if (trim($_POST['admin_key_input']) === $admin_key) {
$mode = 'setup';
} else {
$mode = 'lock';
$error = 'Invalid admin key.';
}
} elseif ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['group_name'])) {
if (trim($_POST['admin_key_hidden'] ?? '') !== $admin_key) {
$mode = 'lock';
$error = 'Invalid admin key.';
} else {
$mode = 'setup';
}
} else {
$mode = 'lock';
}
}
if ($mode === 'setup' && $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['group_name'])) {
$group_name = trim($_POST['group_name'] ?? ''); $group_name = trim($_POST['group_name'] ?? '');
$allow_personal_epg = isset($_POST['allow_personal_epg']); $allow_personal_epg = isset($_POST['allow_personal_epg']);
// Récupérer les sources EPG dynamiques
$names = $_POST['source_name'] ?? []; $names = $_POST['source_name'] ?? [];
$epg_urls = $_POST['source_epg_url'] ?? []; $epg_urls = $_POST['source_epg_url'] ?? [];
$m3u_urls = $_POST['source_m3u_url'] ?? []; $m3u_urls = $_POST['source_m3u_url'] ?? [];
@@ -37,13 +74,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$m3u_url = trim($m3u_urls[$i] ?? ''); $m3u_url = trim($m3u_urls[$i] ?? '');
if (empty($epg_url)) continue; if (empty($epg_url)) continue;
if (!filter_var($epg_url, FILTER_VALIDATE_URL)) { if (!filter_var($epg_url, FILTER_VALIDATE_URL)) {
$error = "URL EPG invalide pour la source « $name »."; break; $error = "Invalid EPG URL for source \"$name\"."; break;
} }
if (!empty($m3u_url) && !filter_var($m3u_url, FILTER_VALIDATE_URL)) { if (!empty($m3u_url) && !filter_var($m3u_url, FILTER_VALIDATE_URL)) {
$error = "URL M3U invalide pour la source « $name »."; break; $error = "Invalid M3U URL for source \"$name\"."; break;
} }
$epg_sources[] = [ $epg_sources[] = [
'name' => trim($name) ?: "Source " . ($i + 1), 'name' => trim($name) ?: 'Source ' . ($i + 1),
'epg_url' => $epg_url, 'epg_url' => $epg_url,
'm3u_url' => $m3u_url, 'm3u_url' => $m3u_url,
]; ];
@@ -51,303 +88,224 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (empty($error)) { if (empty($error)) {
if (empty($group_name)) { if (empty($group_name)) {
$error = 'Le nom du groupe est obligatoire.'; $error = 'Group name is required.';
} elseif (empty($epg_sources)) { } elseif (empty($epg_sources)) {
$error = 'Au moins une source EPG est obligatoire.'; $error = 'At least one EPG source is required.';
} else { } else {
$new_key = $is_first_setup ? strtoupper(bin2hex(random_bytes(8))) : $admin_key;
$config = [ $config = [
'group_name' => $group_name, 'group_name' => $group_name,
'epg_sources' => $epg_sources, 'epg_sources' => $epg_sources,
'allow_personal_epg' => $allow_personal_epg, 'allow_personal_epg' => $allow_personal_epg,
'admin_key' => $new_key,
]; ];
$written = file_put_contents( $written = file_put_contents($config_path, json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
$config_path,
json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
);
if ($written === false) { if ($written === false) {
$error = 'Impossible d\'écrire config.json. Vérifiez les permissions (chmod 775 .).'; $error = 'Cannot write config.json. Check permissions (chmod 775 .).';
} else { } else {
$success = true; $mode = 'success';
} }
} }
} }
} }
$prefill_group = $_POST['group_name'] ?? $existing['group_name'] ?? '';
$prefill_sources = $existing['epg_sources'] ?? [];
$prefill_epg = !empty($existing['allow_personal_epg']);
$submitted_key = trim($_POST['admin_key_input'] ?? $_POST['admin_key_hidden'] ?? '');
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="fr"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GridTV — Configuration</title> <title>GridTV — Setup</title>
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Barlow+Condensed:wght@300;400;600;700&display=swap" rel="stylesheet"> <link rel="stylesheet" href="/assets/fonts/fonts.css">
<style> <style>
:root { :root {
--bg: #0a0b0d; --surface: #111318; --surface2: #181b22; --bg:#0a0b0d;--surface:#111318;--surface2:#181b22;
--border: #232733; --border-bright: #2e3444; --border:#232733;--border-bright:#2e3444;
--accent: #e8c842; --accent2: #4a9eff; --accent:#e8c842;--accent2:#4a9eff;
--text: #c8cdd8; --text-dim: #5a6070; --text-bright: #eef0f5; --text:#c8cdd8;--text-dim:#5a6070;--text-bright:#eef0f5;
--error: #ff4444; --success: #44cc77; --error:#ff4444;--success:#44cc77;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
min-height: 100%; background: var(--bg); color: var(--text);
font-family: 'Barlow Condensed', sans-serif;
display: flex; align-items: flex-start; justify-content: center;
}
.setup-card {
width: 100%; max-width: 580px;
background: var(--surface); border: 1px solid var(--border-bright);
padding: 40px 40px 36px; margin: 40px 16px;
}
.setup-logo {
font-family: 'Share Tech Mono', monospace; font-size: 22px;
color: var(--accent); letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 6px;
}
.setup-logo span { color: var(--text-dim); margin: 0 6px; }
.setup-subtitle {
font-size: 13px; color: var(--text-dim); letter-spacing: 0.05em;
margin-bottom: 32px; line-height: 1.5;
}
.form-group { margin-bottom: 22px; }
.section-title {
font-family: 'Share Tech Mono', monospace; font-size: 11px;
letter-spacing: 0.12em; text-transform: uppercase;
color: var(--accent); margin: 32px 0 16px;
padding-bottom: 8px; border-bottom: 1px solid var(--border);
}
label {
display: block; font-size: 11px; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--text-dim);
margin-bottom: 7px; font-family: 'Share Tech Mono', monospace;
}
label .required { color: var(--accent); margin-left: 3px; }
label .optional { color: var(--text-dim); font-size: 10px; text-transform: none; letter-spacing: 0; margin-left: 4px; opacity: 0.6; }
input[type="text"], input[type="url"] {
width: 100%; background: var(--surface2); border: 1px solid var(--border-bright);
color: var(--text-bright); font-family: 'Share Tech Mono', monospace;
font-size: 13px; padding: 10px 12px; outline: none; transition: border-color 0.15s;
}
input[type="text"]:focus, input[type="url"]:focus { border-color: var(--accent); }
input::placeholder { color: var(--text-dim); opacity: 0.5; }
.hint { font-size: 11px; color: var(--text-dim); margin-top: 5px; line-height: 1.4; }
/* Sources EPG */
.sources-list { display: flex; flex-direction: column; gap: 16px; }
.source-block {
background: var(--surface2); border: 1px solid var(--border);
padding: 16px; position: relative;
}
.source-block .source-num {
font-family: 'Share Tech Mono', monospace; font-size: 10px;
color: var(--accent); letter-spacing: 0.1em; margin-bottom: 12px;
}
.source-fields { display: grid; gap: 10px; }
.source-remove {
position: absolute; top: 10px; right: 10px;
background: none; border: none; color: var(--text-dim);
font-size: 16px; cursor: pointer; line-height: 1; padding: 2px 5px;
}
.source-remove:hover { color: var(--error); }
.btn-add-source {
width: 100%; background: none; border: 1px dashed var(--border-bright);
color: var(--accent2); font-family: 'Barlow Condensed', sans-serif;
font-size: 13px; font-weight: 600; letter-spacing: 0.1em;
text-transform: uppercase; padding: 10px; cursor: pointer;
margin-top: 8px; transition: border-color 0.15s, color 0.15s;
}
.btn-add-source:hover { border-color: var(--accent2); color: var(--text-bright); }
/* Toggle personal EPG */
.toggle-row {
display: flex; align-items: center; justify-content: space-between;
background: var(--surface2); border: 1px solid var(--border);
padding: 14px 16px; margin-bottom: 22px;
}
.toggle-label { font-size: 13px; color: var(--text); line-height: 1.4; }
.toggle-label small { display: block; font-size: 11px; color: var(--text-dim); margin-top: 2px; }
.toggle {
position: relative; width: 40px; height: 22px; flex-shrink: 0; margin-left: 16px;
}
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute; inset: 0; background: var(--border-bright);
cursor: pointer; transition: background 0.2s; border-radius: 22px;
}
.toggle-slider:before {
content: ''; position: absolute; width: 16px; height: 16px;
left: 3px; top: 3px; background: var(--text-dim);
transition: transform 0.2s, background 0.2s; border-radius: 50%;
}
.toggle input:checked + .toggle-slider { background: var(--accent); }
.toggle input:checked + .toggle-slider:before { transform: translateX(18px); background: #000; }
.btn-submit {
width: 100%; background: var(--accent); color: #000; border: none;
font-family: 'Barlow Condensed', sans-serif; font-size: 15px; font-weight: 700;
letter-spacing: 0.12em; text-transform: uppercase; padding: 13px;
cursor: pointer; margin-top: 8px; transition: opacity 0.15s;
}
.btn-submit:hover { opacity: 0.85; }
.alert {
padding: 12px 14px; font-size: 13px; margin-bottom: 24px;
line-height: 1.5; border-left: 3px solid;
}
.alert-error { background: rgba(255,68,68,0.08); border-color: var(--error); color: var(--error); }
.alert-success{ background: rgba(68,204,119,0.08); border-color: var(--success); color: var(--success); }
.success-actions { text-align: center; margin-top: 28px; }
.btn-go {
display: inline-block; background: var(--accent); color: #000;
font-family: 'Barlow Condensed', sans-serif; font-size: 16px; font-weight: 700;
letter-spacing: 0.12em; text-transform: uppercase; padding: 13px 40px;
text-decoration: none; transition: opacity 0.15s;
}
.btn-go:hover { opacity: 0.85; }
.setup-note {
margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--border);
font-size: 11px; color: var(--text-dim); line-height: 1.6;
}
.setup-note code {
font-family: 'Share Tech Mono', monospace; background: var(--surface2);
padding: 1px 5px; border: 1px solid var(--border); color: var(--accent2); font-size: 11px;
} }
*{box-sizing:border-box;margin:0;padding:0;}
html,body{min-height:100%;background:var(--bg);color:var(--text);font-family:'Barlow Condensed',sans-serif;display:flex;align-items:flex-start;justify-content:center;}
.setup-card{width:100%;max-width:580px;background:var(--surface);border:1px solid var(--border-bright);padding:40px 40px 36px;margin:40px 16px;}
.setup-logo{font-family:'Share Tech Mono',monospace;font-size:22px;color:var(--accent);letter-spacing:.1em;text-transform:uppercase;margin-bottom:6px;}
.setup-logo span{color:var(--text-dim);margin:0 6px;}
.setup-subtitle{font-size:13px;color:var(--text-dim);letter-spacing:.05em;margin-bottom:32px;line-height:1.5;}
.form-group{margin-bottom:22px;}
.section-title{font-family:'Share Tech Mono',monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--accent);margin:32px 0 16px;padding-bottom:8px;border-bottom:1px solid var(--border);}
label{display:block;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--text-dim);margin-bottom:7px;font-family:'Share Tech Mono',monospace;}
label .required{color:var(--accent);margin-left:3px;}
label .optional{color:var(--text-dim);font-size:10px;text-transform:none;letter-spacing:0;margin-left:4px;opacity:.6;}
input[type=text],input[type=url],input[type=password]{width:100%;background:var(--surface2);border:1px solid var(--border-bright);color:var(--text-bright);font-family:'Share Tech Mono',monospace;font-size:13px;padding:10px 12px;outline:none;transition:border-color .15s;}
input:focus{border-color:var(--accent);}
input::placeholder{color:var(--text-dim);opacity:.5;}
.hint{font-size:11px;color:var(--text-dim);margin-top:5px;line-height:1.4;}
.sources-list{display:flex;flex-direction:column;gap:16px;}
.source-block{background:var(--surface2);border:1px solid var(--border);padding:16px;position:relative;}
.source-block .source-num{font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--accent);letter-spacing:.1em;margin-bottom:12px;}
.source-fields{display:grid;gap:10px;}
.source-remove{position:absolute;top:10px;right:10px;background:none;border:none;color:var(--text-dim);font-size:16px;cursor:pointer;line-height:1;padding:2px 5px;}
.source-remove:hover{color:var(--error);}
.btn-add-source{width:100%;background:none;border:1px dashed var(--border-bright);color:var(--accent2);font-family:'Barlow Condensed',sans-serif;font-size:13px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;padding:10px;cursor:pointer;margin-top:8px;transition:border-color .15s,color .15s;}
.btn-add-source:hover{border-color:var(--accent2);color:var(--text-bright);}
.toggle-row{display:flex;align-items:center;justify-content:space-between;background:var(--surface2);border:1px solid var(--border);padding:14px 16px;margin-bottom:22px;}
.toggle-label{font-size:13px;color:var(--text);line-height:1.4;}
.toggle-label small{display:block;font-size:11px;color:var(--text-dim);margin-top:2px;}
.toggle{position:relative;width:40px;height:22px;flex-shrink:0;margin-left:16px;}
.toggle input{opacity:0;width:0;height:0;}
.toggle-slider{position:absolute;inset:0;background:var(--border-bright);cursor:pointer;transition:background .2s;border-radius:22px;}
.toggle-slider:before{content:'';position:absolute;width:16px;height:16px;left:3px;top:3px;background:var(--text-dim);transition:transform .2s,background .2s;border-radius:50%;}
.toggle input:checked+.toggle-slider{background:var(--accent);}
.toggle input:checked+.toggle-slider:before{transform:translateX(18px);background:#000;}
.btn-submit{width:100%;background:var(--accent);color:#000;border:none;font-family:'Barlow Condensed',sans-serif;font-size:15px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;padding:13px;cursor:pointer;margin-top:8px;transition:opacity .15s;}
.btn-submit:hover{opacity:.85;}
.alert{padding:12px 14px;font-size:13px;margin-bottom:24px;line-height:1.5;border-left:3px solid;}
.alert-error{background:rgba(255,68,68,.08);border-color:var(--error);color:var(--error);}
.alert-success{background:rgba(68,204,119,.08);border-color:var(--success);color:var(--success);}
.success-actions{text-align:center;margin-top:28px;}
.btn-go{display:inline-block;background:var(--accent);color:#000;font-family:'Barlow Condensed',sans-serif;font-size:16px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;padding:13px 40px;text-decoration:none;transition:opacity .15s;}
.btn-go:hover{opacity:.85;}
.key-box{background:var(--surface2);border:1px solid var(--accent);padding:18px 20px;margin:24px 0;text-align:center;}
.key-label{font-family:'Share Tech Mono',monospace;font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--text-dim);margin-bottom:10px;}
.key-value{font-family:'Share Tech Mono',monospace;font-size:22px;color:var(--accent);letter-spacing:.15em;word-break:break-all;}
.key-warning{font-size:11px;color:var(--text-dim);margin-top:10px;line-height:1.5;}
.key-warning strong{color:var(--error);}
.lock-screen{text-align:center;padding:10px 0 20px;}
.lock-icon{font-size:36px;margin-bottom:16px;}
.lock-title{font-family:'Share Tech Mono',monospace;font-size:13px;letter-spacing:.1em;color:var(--text);margin-bottom:8px;}
.lock-sub{font-size:12px;color:var(--text-dim);margin-bottom:24px;line-height:1.5;}
.lock-form{display:flex;gap:8px;}
.lock-form input{flex:1;}
.setup-note{margin-top:28px;padding-top:20px;border-top:1px solid var(--border);font-size:11px;color:var(--text-dim);line-height:1.6;}
.setup-note code{font-family:'Share Tech Mono',monospace;background:var(--surface2);padding:1px 5px;border:1px solid var(--border);color:var(--accent2);font-size:11px;}
</style> </style>
</head> </head>
<body> <body>
<div class="setup-card"> <div class="setup-card">
<div class="setup-logo">Grid<span>/</span>TV</div> <div class="setup-logo">Grid<span>/</span>TV</div>
<div class="setup-subtitle">
Bienvenue sur GridTV — votre guide TV IPTV.<br> <?php if ($mode === 'lock'): ?>
Configurez votre instance en remplissant les champs ci-dessous. <div class="setup-subtitle">Enter your admin key to access the configuration.</div>
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<div class="lock-screen">
<div class="lock-icon">&#128274;</div>
<div class="lock-title">Configuration locked</div>
<div class="lock-sub">GridTV is already configured.<br>Enter your admin key to edit the settings.</div>
<form method="POST" action="setup.php">
<div class="lock-form">
<input type="password" name="admin_key_input" placeholder="Your admin key" autofocus autocomplete="off">
<button type="submit" class="btn-submit" style="width:auto;padding:10px 20px;">Unlock</button>
</div>
</form>
</div> </div>
<?php if ($error): ?> <?php elseif ($mode === 'success'): ?>
<div class="alert alert-error"><?= htmlspecialchars($error) ?></div> <div class="setup-subtitle">Configuration saved successfully.</div>
<?php endif; ?> <div class="alert alert-success">&#10003; Settings saved to <code>config.json</code>.</div>
<?php if ($is_first_setup): ?>
<?php if ($success): ?> <div class="key-box">
<div class="alert alert-success"> <div class="key-label">Your admin key</div>
✓ Configuration enregistrée dans <code>config.json</code>.<br> <div class="key-value"><?= htmlspecialchars($new_key) ?></div>
Cette page ne sera plus accessible. Pour modifier la config, éditez <code>config.json</code> via SSH. <div class="key-warning"><strong>Save this key now.</strong> It will not be shown again.<br>You will need it to access this setup page in the future.</div>
</div>
<div class="success-actions">
<a href="index.php" class="btn-go">Ouvrir GridTV →</a>
</div> </div>
<?php else: ?> <?php else: ?>
<div class="alert alert-success" style="margin-top:0">Your admin key is unchanged.</div>
<?php endif; ?>
<div class="success-actions"><a href="index.php" class="btn-go">Open GridTV &rarr;</a></div>
<?php else: ?>
<div class="setup-subtitle">
<?= $is_first_setup ? 'Welcome to GridTV. Fill in the fields below to get started.' : 'Edit your GridTV configuration.' ?>
</div>
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<form method="POST" action="setup.php"> <form method="POST" action="setup.php">
<?php if (!$is_first_setup): ?>
<input type="hidden" name="admin_key_hidden" value="<?= htmlspecialchars($submitted_key ?: $admin_key) ?>">
<?php endif; ?>
<div class="form-group"> <div class="form-group">
<label>Nom du groupe TV <span class="required">*</span></label> <label>TV Group Name <span class="required">*</span></label>
<input type="text" name="group_name" <input type="text" name="group_name" value="<?= htmlspecialchars($prefill_group) ?>" placeholder="e.g. JohnnyBeGood, MyTV, FamilyTV..." required>
value="<?= htmlspecialchars($_POST['group_name'] ?? '') ?>" <div class="hint">Displayed top-left in the topbar.</div>
placeholder="Ex: JohnnyBeGood, MaTV, FamilleTV..."
required>
<div class="hint">S'affiche en haut à gauche dans la topbar.</div>
</div> </div>
<div class="section-title">Sources EPG</div> <div class="section-title">EPG Sources</div>
<div class="sources-list" id="sourcesList"> <div class="sources-list" id="sourcesList">
<div class="source-block" data-index="0"> <?php
<div class="source-num">SOURCE 1</div> $sources_to_show = !empty($prefill_sources) ? $prefill_sources : [['name'=>'Main','epg_url'=>'','m3u_url'=>'']];
<button type="button" class="source-remove" onclick="removeSource(this)" style="display:none">✕</button> foreach ($sources_to_show as $i => $src): ?>
<div class="source-block" data-index="<?= $i ?>">
<div class="source-num">SOURCE <?= $i + 1 ?></div>
<button type="button" class="source-remove" onclick="removeSource(this)"<?= count($sources_to_show) === 1 ? ' style="display:none"' : '' ?>>&#10005;</button>
<div class="source-fields"> <div class="source-fields">
<div> <div>
<label>Nom de la source</label> <label>Source name</label>
<input type="text" name="source_name[]" value="Main" placeholder="Ex: Main, Sports, Films..."> <input type="text" name="source_name[]" value="<?= htmlspecialchars($src['name'] ?? '') ?>" placeholder="e.g. Main, Sports, Movies...">
</div> </div>
<div> <div>
<label>URL EPG (XMLTV) <span class="required">*</span></label> <label>EPG URL (XMLTV) <span class="required">*</span></label>
<input type="url" name="source_epg_url[]" placeholder="http://votre-serveur/api/xmltv.xml" required> <input type="url" name="source_epg_url[]" value="<?= htmlspecialchars($src['epg_url'] ?? '') ?>" placeholder="http://your-server/api/xmltv.xml">
</div> </div>
<div> <div>
<label>URL M3U <span class="optional">(optionnel)</span></label> <label>M3U URL <span class="optional">(optional)</span></label>
<input type="url" name="source_m3u_url[]" placeholder="http://votre-serveur/api/channels.m3u"> <input type="url" name="source_m3u_url[]" value="<?= htmlspecialchars($src['m3u_url'] ?? '') ?>" placeholder="http://your-server/api/channels.m3u">
<div class="hint">Utilisé par le lecteur intégré pour matcher les chaînes.</div> <div class="hint">Used by the built-in player to match channels to streams.</div>
</div> </div>
</div> </div>
</div> </div>
<?php endforeach; ?>
</div> </div>
<button type="button" class="btn-add-source" onclick="addSource()">+ Add EPG source</button>
<button type="button" class="btn-add-source" onclick="addSource()">+ Ajouter une source EPG</button>
<div class="section-title">Options</div> <div class="section-title">Options</div>
<div class="toggle-row"> <div class="toggle-row">
<div class="toggle-label"> <div class="toggle-label">
Autoriser les EPG personnels Allow personal EPG
<small>Les visiteurs peuvent utiliser votre instance avec leur propre EPG/M3U.</small> <small>Visitors can use your instance with their own EPG/M3U URLs.</small>
</div> </div>
<label class="toggle"> <label class="toggle">
<input type="checkbox" name="allow_personal_epg" id="allowPersonalEpg"> <input type="checkbox" name="allow_personal_epg" id="allowPersonalEpg"<?= $prefill_epg ? ' checked' : '' ?>>
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
</div> </div>
<button type="submit" class="btn-submit">Enregistrer la configuration</button> <button type="submit" class="btn-submit">Save configuration</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<div class="setup-note"> <div class="setup-note">
<strong>Note :</strong> La configuration est stockée dans <code>config.json</code> sur le serveur. <strong>Note:</strong> Configuration is stored in <code>config.json</code> on the server.
Ce fichier est exclu du dépôt Git (<code>.gitignore</code>). This file is excluded from the Git repository (<code>.gitignore</code>).
Pour modifier les paramètres ultérieurement, éditez ce fichier directement via SSH.
</div> </div>
</div> </div>
<script> <script>
let sourceCount = 1; let sourceCount = document.querySelectorAll('.source-block').length;
function addSource() { function addSource() {
sourceCount++; sourceCount++;
const list = document.getElementById('sourcesList'); const list = document.getElementById('sourcesList');
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'source-block'; div.className = 'source-block';
div.dataset.index = sourceCount - 1;
div.innerHTML = ` div.innerHTML = `
<div class="source-num">SOURCE ${sourceCount}</div> <div class="source-num">SOURCE ${sourceCount}</div>
<button type="button" class="source-remove" onclick="removeSource(this)"></button> <button type="button" class="source-remove" onclick="removeSource(this)">&#10005;</button>
<div class="source-fields"> <div class="source-fields">
<div> <div><label>Source name</label><input type="text" name="source_name[]" placeholder="e.g. Sports, Movies..."></div>
<label>Nom de la source</label> <div><label>EPG URL (XMLTV) <span class="required">*</span></label><input type="url" name="source_epg_url[]" placeholder="http://your-server/api/xmltv.xml"></div>
<input type="text" name="source_name[]" placeholder="Ex: Sports, Films..."> <div><label>M3U URL <span class="optional">(optional)</span></label><input type="url" name="source_m3u_url[]" placeholder="http://your-server/api/channels.m3u"><div class="hint">Used by the built-in player to match channels to streams.</div></div>
</div>
<div>
<label>URL EPG (XMLTV) <span class="required">*</span></label>
<input type="url" name="source_epg_url[]" placeholder="http://votre-serveur/api/xmltv.xml">
</div>
<div>
<label>URL M3U <span class="optional">(optionnel)</span></label>
<input type="url" name="source_m3u_url[]" placeholder="http://votre-serveur/api/channels.m3u">
<div class="hint">Utilisé par le lecteur intégré pour matcher les chaînes.</div>
</div>
</div>`; </div>`;
list.appendChild(div); list.appendChild(div);
updateRemoveButtons(); updateRemoveButtons();
} }
function removeSource(btn) { btn.closest('.source-block').remove(); renumberSources(); updateRemoveButtons(); }
function removeSource(btn) { function renumberSources() { document.querySelectorAll('.source-block').forEach((b,i) => b.querySelector('.source-num').textContent = `SOURCE ${i+1}`); }
btn.closest('.source-block').remove(); function updateRemoveButtons() { const b = document.querySelectorAll('.source-block'); b.forEach(bl => bl.querySelector('.source-remove').style.display = b.length > 1 ? '' : 'none'); }
renumberSources();
updateRemoveButtons();
}
function renumberSources() {
document.querySelectorAll('.source-block').forEach((block, i) => {
block.querySelector('.source-num').textContent = `SOURCE ${i + 1}`;
});
}
function updateRemoveButtons() {
const blocks = document.querySelectorAll('.source-block');
blocks.forEach(block => {
block.querySelector('.source-remove').style.display = blocks.length > 1 ? '' : 'none';
});
}
</script> </script>
</body> </body>
</html> </html>
+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>
+36
View File
@@ -0,0 +1,36 @@
/* Base variables overridden by the active theme file */
:root {
color-scheme: dark;
--bg: #0a0b0d;
--surface: #111318;
--surface2: #181b22;
--border: #232733;
--border-bright: #2e3444;
--accent: #e8c842;
--accent2: #4a9eff;
--accent-live: #ff4444;
--text: #c8cdd8;
--text-dim: #5a6070;
--text-bright: #eef0f5;
--prog-bg: #141820;
--prog-border: #1e2a3a;
--prog-live-bg: #0d2040;
--prog-live-border: #4a9eff;
--font-ui: 'Barlow Condensed', sans-serif;
--font-mono: 'Share Tech Mono', monospace;
--channel-w: 160px;
--row-h: 80px;
--header-h: 52px;
}
html, body { height: 100%; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-ui);
font-size: 14px;
overflow: hidden;
display: flex;
flex-direction: column;
}
+16
View File
@@ -0,0 +1,16 @@
/* ── FAVORITES ───────────────────────────────────────────────────────────── */
.fav-btn {
background: none; border: none; color: var(--text-muted);
cursor: pointer; font-size: 14px; line-height: 1;
margin-left: auto; padding: 2px 4px; flex-shrink: 0;
transition: color .15s, transform .15s;
opacity: 0;
}
.channel-cell:hover .fav-btn { opacity: 1; }
.fav-btn.is-fav { color: var(--accent); opacity: 1; }
.fav-btn:hover { transform: scale(1.2); }
.fav-separator {
height: 2px; background: var(--accent);
opacity: .3; margin: 0;
}
+215
View File
@@ -0,0 +1,215 @@
/* ═══════════════════════════════════════════
GRID VIEW (desktop / tablet)
═══════════════════════════════════════════ */
.grid-wrapper {
flex: 1;
display: flex;
overflow: hidden;
position: relative;
min-height: 0;
}
.channels-col {
width: var(--channel-w);
flex-shrink: 0;
background: var(--surface);
border-right: 1px solid var(--border-bright);
z-index: 10;
display: flex;
flex-direction: column;
min-height: 0;
}
.channels-col-header {
height: var(--header-h);
border-bottom: 1px solid var(--border-bright);
display: flex;
align-items: center;
padding: 0 12px;
flex-shrink: 0;
}
.channels-col-header span {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
color: var(--text-dim);
letter-spacing: 0.15em;
text-transform: uppercase;
}
.channels-list {
overflow-y: auto;
flex: 1;
scrollbar-width: none;
}
.channels-list::-webkit-scrollbar { display: none; }
.channel-cell {
height: var(--row-h);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 12px;
gap: 8px;
flex-shrink: 0;
}
.channel-logo {
width: 32px; height: 32px;
border-radius: 4px;
object-fit: contain;
background: var(--surface2);
flex-shrink: 0;
}
.channel-logo-placeholder {
width: 32px; height: 32px;
border-radius: 4px;
background: var(--surface2);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
color: var(--text-dim);
flex-shrink: 0;
}
.channel-name {
font-size: 12px;
font-weight: 600;
color: var(--text-bright);
letter-spacing: 0.04em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-transform: uppercase;
}
.timeline-area {
flex: 1;
overflow: auto;
position: relative;
min-height: 0;
scrollbar-width: thin;
scrollbar-color: var(--border-bright) transparent;
}
.timeline-area::-webkit-scrollbar { height: 4px; width: 4px; }
.timeline-area::-webkit-scrollbar-track { background: transparent; }
.timeline-area::-webkit-scrollbar-thumb { background: var(--border-bright); border-radius: 2px; }
/* Single container so the ruler and programs scroll together */
.timeline-inner {
position: relative;
display: block;
min-width: 100%;
}
.time-ruler {
height: var(--header-h);
background: var(--surface);
border-bottom: 1px solid var(--border-bright);
position: sticky;
top: 0;
z-index: 20;
}
.time-ruler-inner {
position: relative;
height: 100%;
}
.time-tick {
position: absolute;
bottom: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.time-tick-label {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
color: var(--text-dim);
letter-spacing: 0.08em;
padding: 0 6px 6px;
white-space: nowrap;
}
.time-tick-label.hour { color: var(--text); font-size: 12px; }
.time-tick-line { width: 1px; height: 8px; background: var(--border-bright); }
.time-tick-line.hour { height: 14px; }
.now-line {
position: absolute;
top: 0;
width: 2px;
height: 100%;
background: var(--accent-live);
z-index: 50;
pointer-events: none;
}
.now-line::before {
content: '';
position: absolute;
top: 0; left: -4px;
width: 10px; height: 10px;
background: var(--accent-live);
border-radius: 50%;
}
.programs-inner {
position: relative;
background-image: repeating-linear-gradient(
to bottom,
transparent 0px, transparent 79px,
var(--border) 79px, var(--border) 80px
);
}
.program-row {
height: var(--row-h);
border-bottom: 1px solid var(--border);
position: absolute;
left: 0; right: 0;
}
.program-block {
position: absolute;
top: 5px; bottom: 5px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0 8px;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
cursor: default;
transition: background 0.12s, border-color 0.12s;
min-width: 2px;
}
.program-block:hover { background: var(--surface); border-color: var(--accent2); z-index: 5; }
.program-block.is-live { border-color: var(--accent2); background: #0d1a2e; }
.program-block.is-live::after {
content: '';
position: absolute;
bottom: 0; left: 0;
height: 2px;
background: var(--accent2);
width: var(--progress, 0%);
transition: width 1s linear;
}
.program-block.is-past { opacity: 0.35; }
.program-title {
font-size: 12px;
font-weight: 600;
color: var(--text-bright);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 0.02em;
}
.program-time {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
color: var(--text-dim);
letter-spacing: 0.06em;
white-space: nowrap;
}
+210
View File
@@ -0,0 +1,210 @@
/* ═══════════════════════════════════════════
MOBILE LIST VIEW
═══════════════════════════════════════════ */
.mobile-view {
display: none;
flex: 1;
overflow-y: auto;
min-height: 0;
-webkit-overflow-scrolling: touch;
}
.mobile-channel {
border-bottom: 2px solid var(--border-bright);
}
.mobile-channel-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--surface);
position: sticky;
top: 0;
z-index: 10;
border-bottom: 1px solid var(--border);
}
.mobile-programs {
padding: 6px 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
.mobile-program {
display: flex;
align-items: stretch;
border-radius: 4px;
overflow: hidden;
border: 1px solid var(--border);
background: var(--surface2);
position: relative;
}
.mobile-program.is-live { border-color: var(--accent2); background: #0d1a2e; }
.mobile-program.is-past { opacity: 0.4; }
.mobile-program-progress {
position: absolute;
bottom: 0; left: 0;
height: 2px;
background: var(--accent2);
}
.mobile-program-time {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
color: var(--text-dim);
padding: 8px 10px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
min-width: 72px;
flex-shrink: 0;
gap: 2px;
}
.mobile-program.is-live .mobile-program-time { color: var(--accent2); }
.mobile-program-info {
padding: 8px 10px;
flex: 1;
min-width: 0;
}
.mobile-program-title {
font-size: 13px;
font-weight: 600;
color: var(--text-bright);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mobile-program-dur {
font-size: 10px;
color: var(--text-dim);
margin-top: 2px;
font-family: 'Share Tech Mono', monospace;
}
.mobile-live-badge {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
color: var(--accent-live);
background: rgba(255,68,68,0.12);
border: 1px solid var(--accent-live);
border-radius: 2px;
padding: 2px 5px;
letter-spacing: 0.1em;
align-self: center;
margin-right: 8px;
flex-shrink: 0;
}
/* TOOLTIP */
.tooltip {
position: fixed;
background: var(--surface);
border: 1px solid var(--accent2);
border-radius: 4px;
padding: 10px 14px;
z-index: 999;
pointer-events: none;
max-width: 300px;
opacity: 0;
transition: opacity 0.15s;
}
.tooltip.visible { opacity: 1; }
.tooltip-title { font-size: 14px; font-weight: 700; color: var(--text-bright); margin-bottom: 4px; }
.tooltip-time { font-family: 'Share Tech Mono', monospace; font-size: 10px; color: var(--accent2); margin-bottom: 6px; }
.tooltip-desc { font-size: 12px; color: var(--text); line-height: 1.4; max-height: 80px; overflow: hidden; }
/* LOADING */
.loading {
position: fixed; inset: 0;
background: var(--bg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1000;
gap: 20px;
}
.loading-text {
font-family: 'Share Tech Mono', monospace;
font-size: 12px;
color: var(--text-dim);
letter-spacing: 0.2em;
text-transform: uppercase;
}
.loading-bar {
width: 200px; height: 2px;
background: var(--border);
position: relative;
overflow: hidden;
}
.loading-bar::after {
content: '';
position: absolute;
top: 0; left: -60%;
width: 60%; height: 100%;
background: var(--accent);
animation: loading-anim 1.2s ease-in-out infinite;
}
@keyframes loading-anim { to { left: 110%; } }
.error-msg {
position: fixed; inset: 0;
background: var(--bg);
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1000;
gap: 16px;
display: none;
padding: 20px;
}
.err-title { font-family: 'Share Tech Mono', monospace; font-size: 14px; color: var(--accent-live); letter-spacing: 0.1em; }
.err-sub { font-size: 13px; color: var(--text-dim); text-align: center; max-width: 400px; line-height: 1.5; }
.epg-input-bar { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; justify-content: center; }
.epg-input {
background: var(--surface2);
border: 1px solid var(--border-bright);
color: var(--text-bright);
font-family: 'Share Tech Mono', monospace;
font-size: 12px;
padding: 8px 12px;
width: 320px;
outline: none;
}
.epg-input:focus { border-color: var(--accent); }
.epg-btn {
background: var(--accent);
border: none; color: #000;
font-family: 'Barlow Condensed', sans-serif;
font-weight: 700; font-size: 13px;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 8px 18px;
cursor: pointer;
}
@media (max-width: 600px) {
html, body { height: 100%; overflow: hidden; }
body { overflow: hidden; }
.topbar {
height: auto;
flex-wrap: wrap;
padding: 8px 12px;
gap: 8px;
}
.logo { font-size: 11px; }
.clock { font-size: 14px; }
.grid-wrapper { display: none; }
.mobile-view { display: block; }
.tooltip { display: none !important; }
#navBtns { display: none !important; }
}
+40
View File
@@ -0,0 +1,40 @@
/* ── MODAL PERSONAL EPG ─────────────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
z-index: 3000; display: none; align-items: center; justify-content: center;
}
.modal-overlay.visible { display: flex; }
.modal-card {
background: var(--surface); border: 1px solid var(--border-bright);
padding: 28px; width: 100%; max-width: 420px; margin: 16px;
}
.modal-title {
font-family: var(--font-mono); font-size: 13px; color: var(--accent);
letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 6px;
}
.modal-subtitle { font-size: 12px; color: var(--text-dim); margin-bottom: 20px; line-height: 1.5; }
.modal-field { margin-bottom: 14px; }
.modal-field label {
display: block; font-family: var(--font-mono); font-size: 10px;
letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-dim); margin-bottom: 6px;
}
.modal-field input {
width: 100%; background: var(--surface2); border: 1px solid var(--border-bright);
color: var(--text-bright); font-family: var(--font-mono); font-size: 12px;
padding: 9px 10px; outline: none; transition: border-color 0.15s;
}
.modal-field input:focus { border-color: var(--accent); }
.modal-field input::placeholder { color: var(--text-dim); opacity: 0.4; }
.modal-actions { display: flex; gap: 10px; margin-top: 20px; justify-content: flex-end; }
.modal-btn-cancel {
background: none; border: 1px solid var(--border-bright); color: var(--text-dim);
font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em;
text-transform: uppercase; padding: 8px 16px; cursor: pointer; transition: border-color 0.15s;
}
.modal-btn-cancel:hover { border-color: var(--text-dim); color: var(--text); }
.modal-btn-apply {
background: var(--accent); border: none; color: #000;
font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em;
text-transform: uppercase; padding: 8px 20px; cursor: pointer; transition: opacity 0.15s;
}
.modal-btn-apply:hover { opacity: 0.85; }
+78
View File
@@ -0,0 +1,78 @@
/* ═══════════════════════════════════════════
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; }
}
+63
View File
@@ -0,0 +1,63 @@
/* ── PROGRAM MODAL ───────────────────────────────────────────────────────── */
.program-card {
max-width: 520px; padding: 28px; position: relative;
}
.pm-close {
position: absolute; top: 14px; right: 14px;
background: none; border: none; color: var(--text-muted);
cursor: pointer; font-size: 18px; line-height: 1;
transition: color .2s;
}
.pm-close:hover { color: var(--accent); }
.pm-channel {
font-size: 11px; letter-spacing: .1em; text-transform: uppercase;
color: var(--accent); margin-bottom: 6px;
}
.pm-title {
font-size: 20px; font-weight: 700; color: var(--text);
margin-bottom: 8px; line-height: 1.2;
}
.pm-subtitle {
font-size: 13px; color: var(--text); margin-bottom: 10px;
font-style: italic;
}
.pm-time {
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 {
font-size: 13px; color: var(--text-muted); line-height: 1.6;
margin-bottom: 20px; max-height: 140px; overflow-y: auto;
}
.pm-actions {
display: flex; gap: 10px; flex-wrap: wrap;
}
.pm-btn {
border-radius: 4px; cursor: pointer; font-family: inherit;
font-size: 13px; font-weight: 600; padding: 8px 16px;
text-decoration: none; transition: opacity .2s;
display: inline-flex; align-items: center; gap: 6px;
border: none;
}
.pm-btn:hover { opacity: .8; }
.pm-watch { background: var(--accent); color: var(--bg); }
.pm-imdb { background: #f5c518; color: #000; }
.pm-cancel { background: var(--bg); color: var(--text-muted); border: 1px solid var(--border); }
/* Midnight date marker in the timebar */
.time-tick-label.midnight {
color: var(--accent); font-weight: 700; font-size: 11px;
white-space: nowrap;
}
.time-tick-line.midnight {
background: var(--accent); opacity: .4; width: 2px;
}
/* ── FAVORITES ───────────────────────────────────────────────────────────── */
+39
View File
@@ -0,0 +1,39 @@
/* ── SEARCH ───────────────────────────────────────────────────────────────── */
.search-btn {
background: none; border: 1px solid var(--border); border-radius: 4px;
color: var(--text-muted); cursor: pointer; font-size: 14px;
padding: 4px 8px; transition: color .2s, border-color .2s;
}
.search-btn:hover { color: var(--accent); border-color: var(--accent); }
.search-bar {
display: none; align-items: center; gap: 8px;
background: var(--bg-card); border-bottom: 1px solid var(--border);
padding: 8px 16px;
}
.search-bar.visible { display: flex; }
.search-input {
flex: 1; background: var(--bg); border: 1px solid var(--border);
border-radius: 4px; color: var(--text); font-family: inherit;
font-size: 14px; outline: none; padding: 6px 12px;
transition: border-color .2s;
}
.search-input:focus { border-color: var(--accent); }
.search-input::placeholder { color: var(--text-muted); }
.search-clear {
background: none; border: none; color: var(--text-muted);
cursor: pointer; font-size: 16px; padding: 4px 8px;
transition: color .2s;
}
.search-clear:hover { color: var(--accent); }
/* Highlight programs that match the current query */
.program-block.search-match {
border: 1px solid var(--accent);
box-shadow: 0 0 6px var(--accent);
}
.mobile-program.search-match {
border-left: 3px solid var(--accent);
}
+225
View File
@@ -0,0 +1,225 @@
/* ═══════════════════════════════════════════
TOP BAR
═══════════════════════════════════════════ */
.topbar {
height: 50px;
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 16px;
gap: 12px;
flex-shrink: 0;
position: relative;
z-index: 100;
}
.logo {
font-family: 'Share Tech Mono', monospace;
font-size: 13px;
color: var(--accent);
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.logo span { color: var(--text-dim); margin: 0 5px; }
.logo-sub { display: inline; }
.live-dot {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-family: 'Share Tech Mono', monospace;
color: var(--accent-live);
letter-spacing: 0.1em;
margin-left: auto;
flex-shrink: 0;
}
.live-dot::before {
content: '';
width: 7px; height: 7px;
background: var(--accent-live);
border-radius: 50%;
animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse {
0%,100% { opacity:1; transform:scale(1); }
50% { opacity:0.3; transform:scale(0.7); }
}
.clock {
font-family: 'Share Tech Mono', monospace;
font-size: 20px;
color: var(--text-bright);
letter-spacing: 0.05em;
flex-shrink: 0;
}
.date-label {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
color: var(--text-dim);
letter-spacing: 0.1em;
text-transform: uppercase;
white-space: nowrap;
}
.nav-btns {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.nav-btn {
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
font-family: 'Barlow Condensed', sans-serif;
font-size: 12px;
letter-spacing: 0.06em;
padding: 4px 10px;
cursor: pointer;
text-transform: uppercase;
transition: all 0.15s;
white-space: nowrap;
}
.nav-btn:hover, .nav-btn.active {
background: var(--accent);
color: #000;
border-color: var(--accent);
}
.copy-btns {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.copy-btn {
background: transparent;
border: 1px solid var(--border-bright);
color: var(--text-dim);
text-decoration: none;
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
letter-spacing: 0.1em;
padding: 4px 9px;
cursor: pointer;
text-transform: uppercase;
transition: all 0.15s;
white-space: nowrap;
position: relative;
}
.copy-btn:hover {
border-color: var(--accent2);
color: var(--accent2);
}
.copy-btn.copied {
border-color: var(--accent);
color: var(--accent);
}
/* ═══════════════════════════════════════════
RESPONSIVE
═══════════════════════════════════════════ */
@media (max-width: 900px) {
:root { --channel-w: 110px; }
.logo-sub { display: none; }
.date-label { display: none; }
.clock { font-size: 16px; }
}
/* ═══════════════════════════════════════════
THEME SELECTOR
═══════════════════════════════════════════ */
.theme-select {
background: transparent;
border: 1px solid var(--border-bright);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
padding: 4px 8px;
cursor: pointer;
text-transform: uppercase;
transition: all 0.15s;
flex-shrink: 0;
-webkit-appearance: none;
appearance: none;
}
.theme-select:hover { border-color: var(--accent2); color: var(--accent2); }
.theme-select option { background: #111; color: #eee; font-family: sans-serif; }
body { overflow: hidden; }
.topbar {
height: auto;
flex-wrap: wrap;
padding: 8px 12px;
gap: 8px;
}
.logo { font-size: 11px; }
.clock { font-size: 14px; }
.grid-wrapper { display: none; }
.mobile-view { display: block; }
.tooltip { display: none !important; }
#navBtns { display: none !important; }
}
/* ── SOURCE SELECTOR ─────────────────────────────────────────────────────── */
.source-select {
background-color: var(--surface2) !important;
border: 1px solid var(--border-bright);
color: var(--text) !important;
font-family: var(--font-mono);
font-size: 11px;
padding: 5px 8px;
cursor: pointer;
letter-spacing: 0.06em;
height: 28px;
outline: none;
transition: border-color 0.15s, color 0.15s;
max-width: 130px;
-webkit-appearance: none;
appearance: none;
}
.source-select:hover { border-color: var(--accent2); color: var(--accent2) !important; }
.source-select option { background: #111 !important; color: #eee !important; font-family: sans-serif; }
/* ── SOURCE DROPDOWN ─────────────────────────────────────────────────────── */
.source-dropdown {
position: relative;
flex-shrink: 0;
}
.source-dropdown-btn {
display: flex; align-items: center; gap: 6px;
background: var(--surface2); border: 1px solid var(--border-bright);
color: var(--text); font-family: var(--font-mono); font-size: 11px;
letter-spacing: 0.06em; padding: 5px 10px; height: 28px;
cursor: pointer; white-space: nowrap;
transition: border-color 0.15s, color 0.15s;
}
.source-dropdown-btn:hover { border-color: var(--accent2); color: var(--accent2); }
.source-dropdown-arrow { font-size: 8px; opacity: 0.6; }
.source-dropdown-menu {
display: none; position: absolute; top: calc(100% + 4px); left: 0;
background: var(--surface); border: 1px solid var(--border-bright);
min-width: 160px; z-index: 1000;
flex-direction: column;
box-shadow: 0 8px 24px rgba(0,0,0,0.6);
}
.source-dropdown-menu.open { display: flex; }
.source-dropdown-item {
background: none; border: none; color: var(--text);
font-family: var(--font-mono); font-size: 11px;
letter-spacing: 0.06em; padding: 10px 14px;
text-align: left; cursor: pointer; white-space: nowrap;
transition: background 0.1s, color 0.1s;
border-bottom: 1px solid var(--border);
}
.source-dropdown-item:last-child { border-bottom: none; }
.source-dropdown-item:hover { background: var(--surface2); color: var(--accent2); }
.source-dropdown-item.active { color: var(--accent); }
.source-dropdown-personal { color: var(--accent2); }
+13
View File
@@ -0,0 +1,13 @@
/* ── UPDATE BADGE ────────────────────────────────────────────────────────── */
.update-badge {
background: var(--accent); color: #000;
font-size: 11px; font-weight: 700; letter-spacing: 0.06em;
padding: 4px 10px; text-decoration: none; white-space: nowrap;
animation: pulse-badge 2s ease-in-out infinite;
border-radius: 2px; margin-left: 4px;
}
.update-badge:hover { opacity: .85; }
@keyframes pulse-badge {
0%, 100% { opacity: 1; }
50% { opacity: .65; }
}
+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);
+21 -9
View File
@@ -10,10 +10,7 @@ function makePlaceholder(name) {
return el; return el;
} }
function renderChannels() { function makeChannelCell(ch) {
const list = document.getElementById('channelsList');
list.innerHTML = '';
channels.forEach(ch => {
const cell = document.createElement('div'); const cell = document.createElement('div');
cell.className = 'channel-cell'; cell.className = 'channel-cell';
if (ch.icon) { if (ch.icon) {
@@ -25,9 +22,23 @@ function renderChannels() {
const name = document.createElement('div'); const name = document.createElement('div');
name.className = 'channel-name'; name.textContent = ch.name; name.title = ch.name; name.className = 'channel-name'; name.textContent = ch.name; name.title = ch.name;
cell.appendChild(name); cell.appendChild(name);
cell.appendChild(makeFavBtn(ch.id));
cell.addEventListener('click', () => openPip(ch.name, getNowPlaying(ch.id))); cell.addEventListener('click', () => openPip(ch.name, getNowPlaying(ch.id)));
list.appendChild(cell); return cell;
}); }
function renderChannels() {
const list = document.getElementById('channelsList');
list.innerHTML = '';
const { favs, rest } = sortedChannels(channels);
if (favs.length) {
favs.forEach(ch => list.appendChild(makeChannelCell(ch)));
const sep = document.createElement('div');
sep.className = 'fav-separator';
list.appendChild(sep);
}
rest.forEach(ch => list.appendChild(makeChannelCell(ch)));
} }
function renderRuler() { function renderRuler() {
@@ -62,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';
@@ -70,7 +81,9 @@ function renderPrograms() {
const now = new Date(); const now = new Date();
channels.forEach((ch, i) => { const { favs: _f, rest: _r } = sortedChannels(channels);
const orderedChannels = [..._f, ..._r];
orderedChannels.forEach((ch, i) => {
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;`;
@@ -128,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'));
} }
+35
View File
@@ -0,0 +1,35 @@
// ── FAVORITES ─────────────────────────────────────────────────────────────────
let favorites = new Set(JSON.parse(localStorage.getItem('gridtv-favorites') || '[]'));
function saveFavorites() {
localStorage.setItem('gridtv-favorites', JSON.stringify([...favorites]));
}
function toggleFavorite(chId, btn) {
if (favorites.has(chId)) {
favorites.delete(chId);
btn.classList.remove('is-fav');
btn.title = L.fav_add;
} else {
favorites.add(chId);
btn.classList.add('is-fav');
btn.title = L.fav_remove;
}
saveFavorites();
renderAll();
}
function makeFavBtn(chId) {
const btn = document.createElement('button');
btn.className = 'fav-btn' + (favorites.has(chId) ? ' is-fav' : '');
btn.title = favorites.has(chId) ? L.fav_remove : L.fav_add;
btn.textContent = '★';
btn.addEventListener('click', e => { e.stopPropagation(); toggleFavorite(chId, btn); });
return btn;
}
function sortedChannels(list) {
const favs = list.filter(ch => favorites.has(ch.id));
const rest = list.filter(ch => !favorites.has(ch.id));
return { favs, rest };
}
+2 -4
View File
@@ -13,8 +13,7 @@ window.addEventListener('load', () => {
if (epg) { if (epg) {
currentEpgUrl = epg; currentEpgUrl = epg;
currentM3uUrl = m3u; currentM3uUrl = m3u;
const sel = document.getElementById('sourceSelect'); updateSourceDropdownLabel('✏ Personal EPG');
if (sel) sel.value = 'personal';
loadEPG(epg); loadEPG(epg);
if (m3u) loadM3U(m3u); if (m3u) loadM3U(m3u);
return; return;
@@ -26,8 +25,7 @@ window.addEventListener('load', () => {
activeSourceIndex = idx; activeSourceIndex = idx;
currentEpgUrl = src.epg_url; currentEpgUrl = src.epg_url;
currentM3uUrl = src.m3u_url ?? ''; currentM3uUrl = src.m3u_url ?? '';
const sel = document.getElementById('sourceSelect'); if (src) updateSourceDropdownLabel(src.name);
if (sel) sel.value = idx;
} }
} }
loadEPG(currentEpgUrl); loadEPG(currentEpgUrl);
+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;
} }
+5 -3
View File
@@ -6,7 +6,9 @@ function renderMobile() {
const winStart = new Date(now.getTime() - 30*60000); const winStart = new Date(now.getTime() - 30*60000);
const winEnd = new Date(now.getTime() + 4*3600000); const winEnd = new Date(now.getTime() + 4*3600000);
channels.forEach(ch => { const { favs: _f, rest: _r } = sortedChannels(channels);
const orderedChannels = [..._f, ..._r];
orderedChannels.forEach(ch => {
const progs = (programs[ch.id]||[]).filter(p => p.stop>winStart && p.start<winEnd); const progs = (programs[ch.id]||[]).filter(p => p.stop>winStart && p.start<winEnd);
if (!progs.length) return; if (!progs.length) return;
@@ -37,12 +39,12 @@ function renderMobile() {
} }
const timeCol = document.createElement('div'); timeCol.className='mobile-program-time'; const timeCol = document.createElement('div'); timeCol.className='mobile-program-time';
timeCol.innerHTML = `<span>${fmtTime(p.start)}</span><span>${fmtTime(p.stop)}</span>`; timeCol.innerHTML = `<span>${esc(fmtTime(p.start))}</span><span>${esc(fmtTime(p.stop))}</span>`;
item.appendChild(timeCol); item.appendChild(timeCol);
const info = document.createElement('div'); info.className='mobile-program-info'; const info = document.createElement('div'); info.className='mobile-program-info';
const ep = fmtEpisode(p.season, p.episode); const ep = fmtEpisode(p.season, p.episode);
info.innerHTML = `<div class="mobile-program-title">${p.title}</div><div class="mobile-program-dur">${ep ? ep + ' · ' : ''}${dur} min</div>`; info.innerHTML = `<div class="mobile-program-title">${esc(p.title)}</div><div class="mobile-program-dur">${ep ? esc(ep) + ' · ' : ''}${dur} min</div>`;
item.appendChild(info); item.appendChild(info);
if (isLive) { if (isLive) {
+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();
}); });
+19 -12
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' : '');
@@ -179,12 +186,12 @@ function renderMobileFiltered(filteredChannels, q) {
} }
const timeCol = document.createElement('div'); timeCol.className = 'mobile-program-time'; const timeCol = document.createElement('div'); timeCol.className = 'mobile-program-time';
timeCol.innerHTML = `<span>${fmtTime(p.start)}</span><span>${fmtTime(p.stop)}</span>`; timeCol.innerHTML = `<span>${esc(fmtTime(p.start))}</span><span>${esc(fmtTime(p.stop))}</span>`;
item.appendChild(timeCol); item.appendChild(timeCol);
const info = document.createElement('div'); info.className = 'mobile-program-info'; const info = document.createElement('div'); info.className = 'mobile-program-info';
const ep = fmtEpisode(p.season, p.episode); const ep = fmtEpisode(p.season, p.episode);
info.innerHTML = `<div class="mobile-program-title">${p.title}</div><div class="mobile-program-dur">${ep ? ep + ' · ' : ''}${dur} min</div>`; info.innerHTML = `<div class="mobile-program-title">${esc(p.title)}</div><div class="mobile-program-dur">${ep ? esc(ep) + ' · ' : ''}${dur} min</div>`;
item.appendChild(info); item.appendChild(info);
if (isLive) { if (isLive) {
+24 -2
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;
@@ -49,6 +49,7 @@ function applyPersonalEpg() {
localStorage.setItem('gridtv-personal-epg', epg); localStorage.setItem('gridtv-personal-epg', epg);
localStorage.setItem('gridtv-personal-m3u', m3u); localStorage.setItem('gridtv-personal-m3u', m3u);
localStorage.setItem('gridtv-active-source', 'personal'); localStorage.setItem('gridtv-active-source', 'personal');
updateSourceDropdownLabel('✏ Personal EPG');
currentEpgUrl = epg; currentEpgUrl = epg;
currentM3uUrl = m3u; currentM3uUrl = m3u;
const sel = document.getElementById('sourceSelect'); const sel = document.getElementById('sourceSelect');
@@ -58,3 +59,24 @@ function applyPersonalEpg() {
if (m3u) loadM3U(m3u); if (m3u) loadM3U(m3u);
} }
function toggleSourceDropdown() {
document.getElementById('sourceDropdownMenu')?.classList.toggle('open');
}
function closeSourceDropdown() {
document.getElementById('sourceDropdownMenu')?.classList.remove('open');
}
// Close the dropdown when clicking outside of it.
document.addEventListener('click', e => {
if (!e.target.closest('.source-dropdown')) closeSourceDropdown();
});
function updateSourceDropdownLabel(label) {
const el = document.getElementById('sourceDropdownLabel');
if (el) el.textContent = label;
// Update the active item styling in the dropdown.
document.querySelectorAll('.source-dropdown-item').forEach((btn, i) => {
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'); }
+38
View File
@@ -0,0 +1,38 @@
// ── UPDATE CHECKER ────────────────────────────────────────────────────────────
(async function checkForUpdate() {
try {
const [localRes, remoteRes] = await Promise.all([
fetch('version.json'),
fetch('https://api.github.com/repos/Johnnybegood90/GridTV/releases/latest')
]);
if (!localRes.ok || !remoteRes.ok) return;
const local = (await localRes.json()).version ?? '0.0.0';
const remote = (await remoteRes.json()).tag_name ?? '0.0.0';
const clean = v => v.replace(/^v/, '');
const toNum = v => v.split('.').map(Number);
const [lMaj, lMin, lPat] = toNum(clean(local));
const [rMaj, rMin, rPat] = toNum(clean(remote));
const hasUpdate =
rMaj > lMaj ||
(rMaj === lMaj && rMin > lMin) ||
(rMaj === lMaj && rMin === lMin && rPat > lPat);
if (!hasUpdate) return;
const badge = document.createElement('a');
badge.className = 'update-badge';
badge.href = `https://github.com/Johnnybegood90/GridTV/releases/latest`;
badge.target = '_blank';
badge.rel = 'noopener';
badge.innerHTML = `${L.update_available} ${clean(remote)}`;
badge.title = L.update_title;
document.querySelector('.topbar')?.appendChild(badge);
} catch (_) {
// Silencieux — pas de connexion ou rate limit GitHub
}
})();
+46 -6
View File
@@ -1,8 +1,15 @@
// Escape EPG-provided strings before injecting them into HTML.
function esc(str) {
const d = document.createElement('div');
d.textContent = str || '';
return d.innerHTML;
}
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'); }
@@ -28,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);
@@ -71,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('.');
@@ -80,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+)/);
@@ -97,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 => {
@@ -115,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));
@@ -129,4 +170,3 @@ function renderAll() {
if (isMobile()) { renderMobile(); } if (isMobile()) { renderMobile(); }
else { renderGrid(); setTimeout(centerNow, 150); } else { renderGrid(); setTimeout(centerNow, 150); }
} }
+31
View File
@@ -0,0 +1,31 @@
<?php
require_once __DIR__ . '/common.php';
function gridtv_require_admin(array $config, string $page_title = 'Admin'): array {
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
$admin_key = (string) ($config['admin_key'] ?? '');
$session_key = $_SESSION['gridtv_admin_unlocked'] ?? '';
$error = '';
if ($admin_key !== '' && hash_equals($admin_key, (string) $session_key)) {
return ['unlocked' => true, 'error' => ''];
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['admin_key_input'])) {
$submitted = trim((string) ($_POST['admin_key_input'] ?? ''));
if ($admin_key !== '' && hash_equals($admin_key, $submitted)) {
$_SESSION['gridtv_admin_unlocked'] = $admin_key;
header('Location: ' . strtok($_SERVER['REQUEST_URI'], '?'));
exit;
}
$error = 'Invalid admin key.';
}
http_response_code(401);
echo '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>GridTV — ' . htmlspecialchars($page_title) . '</title><link rel="stylesheet" href="/assets/fonts/fonts.css"><style>:root{--bg:#0a0b0d;--surface:#111318;--surface2:#181b22;--border:#232733;--border-bright:#2e3444;--accent:#e8c842;--text:#c8cdd8;--text-dim:#5a6070;--text-bright:#eef0f5;--error:#ff4444;}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:var(--bg);color:var(--text);font-family:"Barlow Condensed",sans-serif;padding:24px}.card{width:100%;max-width:460px;background:var(--surface);border:1px solid var(--border-bright);padding:34px}.logo{font-family:"Share Tech Mono",monospace;font-size:22px;color:var(--accent);letter-spacing:.1em;text-transform:uppercase;margin-bottom:8px}.sub{font-size:13px;color:var(--text-dim);line-height:1.5;margin-bottom:24px}.error{background:rgba(255,68,68,.08);border-left:3px solid var(--error);padding:12px 14px;color:var(--error);font-size:13px;margin-bottom:18px}label{display:block;font-family:"Share Tech Mono",monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--text-dim);margin-bottom:8px}input{width:100%;background:var(--surface2);border:1px solid var(--border-bright);color:var(--text-bright);font-family:"Share Tech Mono",monospace;font-size:13px;padding:11px 12px;margin-bottom:16px}button{width:100%;background:var(--accent);color:#000;border:none;font-family:"Barlow Condensed",sans-serif;font-size:14px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;padding:12px;cursor:pointer}.links{margin-top:16px;font-size:12px;color:var(--text-dim)}.links a{color:var(--accent);text-decoration:none}</style></head><body><div class="card"><div class="logo">GridTV</div><div class="sub">Enter your admin key to access the ' . htmlspecialchars($page_title) . ' page.</div>' . ($error ? '<div class="error">' . htmlspecialchars($error) . '</div>' : '') . '<form method="POST"><label for="admin_key_input">Admin key</label><input id="admin_key_input" type="password" name="admin_key_input" autocomplete="off" autofocus><button type="submit">Unlock</button></form><div class="links"><a href="/setup.php">Open setup</a></div></div></body></html>';
exit;
}
+127
View File
@@ -0,0 +1,127 @@
<?php
function gridtv_config_path(): string {
return dirname(__DIR__, 2) . '/config.json';
}
function gridtv_load_config(): array {
$config_path = gridtv_config_path();
if (!file_exists($config_path)) {
header('Location: /setup.php');
exit;
}
$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>');
}
if (isset($config['epg_url']) && !isset($config['epg_sources'])) {
$config['epg_sources'] = [[
'name' => '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));
}
return $config;
}
function gridtv_load_locale(array $config = []): array {
$locale_files = glob(dirname(__DIR__, 2) . '/locales/*.json') ?: [];
$supported_locales = array_map(fn($f) => basename($f, '.json'), $locale_files);
$locale = 'en';
$accept = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? 'en';
foreach (explode(',', $accept) as $lang) {
$code = strtolower(substr(trim($lang), 0, 2));
if (in_array($code, $supported_locales, true)) {
$locale = $code;
break;
}
}
$locale_path = dirname(__DIR__, 2) . "/locales/{$locale}.json";
$strings = is_file($locale_path) ? json_decode(file_get_contents($locale_path), true) : null;
if (!is_array($strings)) {
$fallback_path = dirname(__DIR__, 2) . '/locales/en.json';
$strings = is_file($fallback_path) ? (json_decode(file_get_contents($fallback_path), true) ?? []) : [];
$locale = 'en';
}
return [$locale, $strings];
}
function gridtv_fetch_url(string $url, int $timeout = 20, bool $head_only = false): array {
if (!function_exists('curl_init')) {
return [
'ok' => false,
'status' => 0,
'error' => 'php-curl extension is not installed.',
'body' => '',
'headers' => [],
'content_type' => '',
'final_url' => $url,
'time_total' => 0.0,
];
}
$headers = [];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_CONNECTTIMEOUT => 8,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_USERAGENT => $_SERVER['HTTP_USER_AGENT'] ?? 'GridTV/health',
CURLOPT_HEADERFUNCTION => static function ($ch, $line) use (&$headers) {
$trimmed = trim($line);
if ($trimmed !== '' && strpos($trimmed, ':') !== false) {
[$name, $value] = explode(':', $trimmed, 2);
$headers[strtolower(trim($name))] = trim($value);
}
return strlen($line);
},
CURLOPT_HTTPHEADER => ['Accept: */*'],
CURLOPT_NOBODY => $head_only,
]);
$body = curl_exec($ch);
$error = curl_error($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$content_type = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
$final_url = (string) curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
$time_total = (float) curl_getinfo($ch, CURLINFO_TOTAL_TIME);
curl_close($ch);
return [
'ok' => $error === '' && $status >= 200 && $status < 400,
'status' => $status,
'error' => $error,
'body' => is_string($body) ? $body : '',
'headers' => $headers,
'content_type' => $content_type,
'final_url' => $final_url ?: $url,
'time_total' => $time_total,
];
}
function gridtv_format_bytes(int $bytes): string {
$units = ['B', 'KB', 'MB', 'GB'];
$size = (float) $bytes;
$unit = 0;
while ($size >= 1024 && $unit < count($units) - 1) {
$size /= 1024;
$unit++;
}
return number_format($size, $size >= 10 || $unit === 0 ? 0 : 1) . ' ' . $units[$unit];
}
function gridtv_extract_host(string $url): string {
return strtolower(parse_url($url, PHP_URL_HOST) ?? '');
}
+10 -2
View File
@@ -1,13 +1,21 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js"></script> <script src="/assets/vendor/hls.min.js"></script>
<script> <script>
<?php <?php
$js_dir = __DIR__ . '/../js/'; $js_dir = __DIR__ . '/../js/';
include $js_dir . 'config.js'; include $js_dir . 'config.js';
$js_modules = ['utils', 'epg', 'mobile', 'tooltip', 'sources', 'm3u', 'player', 'themes', 'search', 'program', 'live']; $js_modules = ['utils', 'favorites', 'epg', 'mobile', 'tooltip', 'sources', 'm3u', 'player', 'themes', 'search', 'program', 'updater', 'live'];
foreach ($js_modules as $mod) { foreach ($js_modules as $mod) {
echo file_get_contents($js_dir . $mod . '.js'); echo file_get_contents($js_dir . $mod . '.js');
} }
?> ?>
</script> </script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
</script>
</body> </body>
</html> </html>
+19 -849
View File
@@ -1,857 +1,27 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="fr"> <html lang="<?= $locale ?>">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GridTV <?= $group_name ?></title> <title>GridTV <?= $group_name ?></title>
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Barlow+Condensed:wght@300;400;600;700&display=swap" rel="stylesheet"> <link rel="manifest" href="/manifest.json">
<style> <link rel="icon" href="/assets/favicon.png" type="image/png">
/* Variables de base — surchargées par le fichier thème */ <link rel="apple-touch-icon" href="/assets/apple-touch-icon.png">
:root { <meta name="theme-color" content="#0a0b0d">
--bg: #0a0b0d; <meta name="mobile-web-app-capable" content="yes">
--surface: #111318; <meta name="apple-mobile-web-app-capable" content="yes">
--surface2: #181b22; <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
--border: #232733; <meta name="apple-mobile-web-app-title" content="GridTV">
--border-bright: #2e3444; <link rel="stylesheet" href="/assets/fonts/fonts.css">
--accent: #e8c842; <?php
--accent2: #4a9eff; $css_dir = __DIR__ . '/../css/';
--accent-live: #ff4444; $css_files = ['base', 'topbar', 'grid', 'mobile', 'player', 'modals', 'search', 'program', 'favorites', 'updater'];
--text: #c8cdd8; echo "<style>\n";
--text-dim: #5a6070; foreach ($css_files as $file) {
--text-bright: #eef0f5; echo file_get_contents($css_dir . $file . '.css') . "\n";
--prog-bg: #141820; }
--prog-border: #1e2a3a; echo "</style>\n";
--prog-live-bg: #0d2040; ?>
--prog-live-border: #4a9eff;
--font-ui: 'Barlow Condensed', sans-serif;
--font-mono: 'Share Tech Mono', monospace;
--channel-w: 160px;
--row-h: 80px;
--header-h: 52px;
}
html, body { height: 100%; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-ui);
font-size: 14px;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ═══════════════════════════════════════════
TOP BAR
═══════════════════════════════════════════ */
.topbar {
height: 50px;
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 16px;
gap: 12px;
flex-shrink: 0;
position: relative;
z-index: 100;
}
.logo {
font-family: 'Share Tech Mono', monospace;
font-size: 13px;
color: var(--accent);
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.logo span { color: var(--text-dim); margin: 0 5px; }
.logo-sub { display: inline; }
.live-dot {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-family: 'Share Tech Mono', monospace;
color: var(--accent-live);
letter-spacing: 0.1em;
margin-left: auto;
flex-shrink: 0;
}
.live-dot::before {
content: '';
width: 7px; height: 7px;
background: var(--accent-live);
border-radius: 50%;
animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse {
0%,100% { opacity:1; transform:scale(1); }
50% { opacity:0.3; transform:scale(0.7); }
}
.clock {
font-family: 'Share Tech Mono', monospace;
font-size: 20px;
color: var(--text-bright);
letter-spacing: 0.05em;
flex-shrink: 0;
}
.date-label {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
color: var(--text-dim);
letter-spacing: 0.1em;
text-transform: uppercase;
white-space: nowrap;
}
.nav-btns {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.nav-btn {
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
font-family: 'Barlow Condensed', sans-serif;
font-size: 12px;
letter-spacing: 0.06em;
padding: 4px 10px;
cursor: pointer;
text-transform: uppercase;
transition: all 0.15s;
white-space: nowrap;
}
.nav-btn:hover, .nav-btn.active {
background: var(--accent);
color: #000;
border-color: var(--accent);
}
.copy-btns {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.copy-btn {
background: transparent;
border: 1px solid var(--border-bright);
color: var(--text-dim);
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
letter-spacing: 0.1em;
padding: 4px 9px;
cursor: pointer;
text-transform: uppercase;
transition: all 0.15s;
white-space: nowrap;
position: relative;
}
.copy-btn:hover {
border-color: var(--accent2);
color: var(--accent2);
}
.copy-btn.copied {
border-color: var(--accent);
color: var(--accent);
}
/* ═══════════════════════════════════════════
GRID VIEW (desktop / tablet)
═══════════════════════════════════════════ */
.grid-wrapper {
flex: 1;
display: flex;
overflow: hidden;
position: relative;
min-height: 0;
}
.channels-col {
width: var(--channel-w);
flex-shrink: 0;
background: var(--surface);
border-right: 1px solid var(--border-bright);
z-index: 10;
display: flex;
flex-direction: column;
min-height: 0;
}
.channels-col-header {
height: var(--header-h);
border-bottom: 1px solid var(--border-bright);
display: flex;
align-items: center;
padding: 0 12px;
flex-shrink: 0;
}
.channels-col-header span {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
color: var(--text-dim);
letter-spacing: 0.15em;
text-transform: uppercase;
}
.channels-list {
overflow-y: auto;
flex: 1;
scrollbar-width: none;
}
.channels-list::-webkit-scrollbar { display: none; }
.channel-cell {
height: var(--row-h);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 12px;
gap: 8px;
flex-shrink: 0;
}
.channel-logo {
width: 32px; height: 32px;
border-radius: 4px;
object-fit: contain;
background: var(--surface2);
flex-shrink: 0;
}
.channel-logo-placeholder {
width: 32px; height: 32px;
border-radius: 4px;
background: var(--surface2);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
color: var(--text-dim);
flex-shrink: 0;
}
.channel-name {
font-size: 12px;
font-weight: 600;
color: var(--text-bright);
letter-spacing: 0.04em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-transform: uppercase;
}
.timeline-area {
flex: 1;
overflow: auto;
position: relative;
min-height: 0;
scrollbar-width: thin;
scrollbar-color: var(--border-bright) transparent;
}
.timeline-area::-webkit-scrollbar { height: 4px; width: 4px; }
.timeline-area::-webkit-scrollbar-track { background: transparent; }
.timeline-area::-webkit-scrollbar-thumb { background: var(--border-bright); border-radius: 2px; }
/* Conteneur unique — ruler + programmes scrollent ensemble */
.timeline-inner {
position: relative;
display: block;
min-width: 100%;
}
.time-ruler {
height: var(--header-h);
background: var(--surface);
border-bottom: 1px solid var(--border-bright);
position: sticky;
top: 0;
z-index: 20;
}
.time-ruler-inner {
position: relative;
height: 100%;
}
.time-tick {
position: absolute;
bottom: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.time-tick-label {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
color: var(--text-dim);
letter-spacing: 0.08em;
padding: 0 6px 6px;
white-space: nowrap;
}
.time-tick-label.hour { color: var(--text); font-size: 12px; }
.time-tick-line { width: 1px; height: 8px; background: var(--border-bright); }
.time-tick-line.hour { height: 14px; }
.now-line {
position: absolute;
top: 0;
width: 2px;
height: 100%;
background: var(--accent-live);
z-index: 50;
pointer-events: none;
}
.now-line::before {
content: '';
position: absolute;
top: 0; left: -4px;
width: 10px; height: 10px;
background: var(--accent-live);
border-radius: 50%;
}
.programs-inner {
position: relative;
background-image: repeating-linear-gradient(
to bottom,
transparent 0px, transparent 79px,
var(--border) 79px, var(--border) 80px
);
}
.program-row {
height: var(--row-h);
border-bottom: 1px solid var(--border);
position: absolute;
left: 0; right: 0;
}
.program-block {
position: absolute;
top: 5px; bottom: 5px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0 8px;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
cursor: default;
transition: background 0.12s, border-color 0.12s;
min-width: 2px;
}
.program-block:hover { background: var(--surface); border-color: var(--accent2); z-index: 5; }
.program-block.is-live { border-color: var(--accent2); background: #0d1a2e; }
.program-block.is-live::after {
content: '';
position: absolute;
bottom: 0; left: 0;
height: 2px;
background: var(--accent2);
width: var(--progress, 0%);
transition: width 1s linear;
}
.program-block.is-past { opacity: 0.35; }
.program-title {
font-size: 12px;
font-weight: 600;
color: var(--text-bright);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 0.02em;
}
.program-time {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
color: var(--text-dim);
letter-spacing: 0.06em;
white-space: nowrap;
}
/* ═══════════════════════════════════════════
MOBILE LIST VIEW
═══════════════════════════════════════════ */
.mobile-view {
display: none;
flex: 1;
overflow-y: auto;
min-height: 0;
-webkit-overflow-scrolling: touch;
}
.mobile-channel {
border-bottom: 2px solid var(--border-bright);
}
.mobile-channel-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--surface);
position: sticky;
top: 0;
z-index: 10;
border-bottom: 1px solid var(--border);
}
.mobile-programs {
padding: 6px 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
.mobile-program {
display: flex;
align-items: stretch;
border-radius: 4px;
overflow: hidden;
border: 1px solid var(--border);
background: var(--surface2);
position: relative;
}
.mobile-program.is-live { border-color: var(--accent2); background: #0d1a2e; }
.mobile-program.is-past { opacity: 0.4; }
.mobile-program-progress {
position: absolute;
bottom: 0; left: 0;
height: 2px;
background: var(--accent2);
}
.mobile-program-time {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
color: var(--text-dim);
padding: 8px 10px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
min-width: 72px;
flex-shrink: 0;
gap: 2px;
}
.mobile-program.is-live .mobile-program-time { color: var(--accent2); }
.mobile-program-info {
padding: 8px 10px;
flex: 1;
min-width: 0;
}
.mobile-program-title {
font-size: 13px;
font-weight: 600;
color: var(--text-bright);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mobile-program-dur {
font-size: 10px;
color: var(--text-dim);
margin-top: 2px;
font-family: 'Share Tech Mono', monospace;
}
.mobile-live-badge {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
color: var(--accent-live);
background: rgba(255,68,68,0.12);
border: 1px solid var(--accent-live);
border-radius: 2px;
padding: 2px 5px;
letter-spacing: 0.1em;
align-self: center;
margin-right: 8px;
flex-shrink: 0;
}
/* TOOLTIP */
.tooltip {
position: fixed;
background: var(--surface);
border: 1px solid var(--accent2);
border-radius: 4px;
padding: 10px 14px;
z-index: 999;
pointer-events: none;
max-width: 300px;
opacity: 0;
transition: opacity 0.15s;
}
.tooltip.visible { opacity: 1; }
.tooltip-title { font-size: 14px; font-weight: 700; color: var(--text-bright); margin-bottom: 4px; }
.tooltip-time { font-family: 'Share Tech Mono', monospace; font-size: 10px; color: var(--accent2); margin-bottom: 6px; }
.tooltip-desc { font-size: 12px; color: var(--text); line-height: 1.4; max-height: 80px; overflow: hidden; }
/* LOADING */
.loading {
position: fixed; inset: 0;
background: var(--bg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1000;
gap: 20px;
}
.loading-text {
font-family: 'Share Tech Mono', monospace;
font-size: 12px;
color: var(--text-dim);
letter-spacing: 0.2em;
text-transform: uppercase;
}
.loading-bar {
width: 200px; height: 2px;
background: var(--border);
position: relative;
overflow: hidden;
}
.loading-bar::after {
content: '';
position: absolute;
top: 0; left: -60%;
width: 60%; height: 100%;
background: var(--accent);
animation: loading-anim 1.2s ease-in-out infinite;
}
@keyframes loading-anim { to { left: 110%; } }
.error-msg {
position: fixed; inset: 0;
background: var(--bg);
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1000;
gap: 16px;
display: none;
padding: 20px;
}
.err-title { font-family: 'Share Tech Mono', monospace; font-size: 14px; color: var(--accent-live); letter-spacing: 0.1em; }
.err-sub { font-size: 13px; color: var(--text-dim); text-align: center; max-width: 400px; line-height: 1.5; }
.epg-input-bar { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; justify-content: center; }
.epg-input {
background: var(--surface2);
border: 1px solid var(--border-bright);
color: var(--text-bright);
font-family: 'Share Tech Mono', monospace;
font-size: 12px;
padding: 8px 12px;
width: 320px;
outline: none;
}
.epg-input:focus { border-color: var(--accent); }
.epg-btn {
background: var(--accent);
border: none; color: #000;
font-family: 'Barlow Condensed', sans-serif;
font-weight: 700; font-size: 13px;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 8px 18px;
cursor: pointer;
}
/* ═══════════════════════════════════════════
RESPONSIVE
═══════════════════════════════════════════ */
@media (max-width: 900px) {
:root { --channel-w: 110px; }
.logo-sub { display: none; }
.date-label { display: none; }
.clock { font-size: 16px; }
}
/* ═══════════════════════════════════════════
SÉLECTEUR DE THÈME
═══════════════════════════════════════════ */
.theme-select {
background: transparent;
border: 1px solid var(--border-bright);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
padding: 4px 8px;
cursor: pointer;
text-transform: uppercase;
transition: all 0.15s;
flex-shrink: 0;
-webkit-appearance: none;
appearance: none;
}
.theme-select:hover { border-color: var(--accent2); color: var(--accent2); }
.theme-select option { background: #111; color: #eee; font-family: sans-serif; }
@media (max-width: 600px) {
html, body { height: 100%; overflow: hidden; }
body { overflow: hidden; }
.topbar {
height: auto;
flex-wrap: wrap;
padding: 8px 12px;
gap: 8px;
}
.logo { font-size: 11px; }
.clock { font-size: 14px; }
.grid-wrapper { display: none; }
.mobile-view { display: block; }
.tooltip { display: none !important; }
#navBtns { display: none !important; }
}
/* ── MODAL PERSONAL EPG ─────────────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
z-index: 3000; display: none; align-items: center; justify-content: center;
}
.modal-overlay.visible { display: flex; }
.modal-card {
background: var(--surface); border: 1px solid var(--border-bright);
padding: 28px; width: 100%; max-width: 420px; margin: 16px;
}
.modal-title {
font-family: var(--font-mono); font-size: 13px; color: var(--accent);
letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 6px;
}
.modal-subtitle { font-size: 12px; color: var(--text-dim); margin-bottom: 20px; line-height: 1.5; }
.modal-field { margin-bottom: 14px; }
.modal-field label {
display: block; font-family: var(--font-mono); font-size: 10px;
letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-dim); margin-bottom: 6px;
}
.modal-field input {
width: 100%; background: var(--surface2); border: 1px solid var(--border-bright);
color: var(--text-bright); font-family: var(--font-mono); font-size: 12px;
padding: 9px 10px; outline: none; transition: border-color 0.15s;
}
.modal-field input:focus { border-color: var(--accent); }
.modal-field input::placeholder { color: var(--text-dim); opacity: 0.4; }
.modal-actions { display: flex; gap: 10px; margin-top: 20px; justify-content: flex-end; }
.modal-btn-cancel {
background: none; border: 1px solid var(--border-bright); color: var(--text-dim);
font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em;
text-transform: uppercase; padding: 8px 16px; cursor: pointer; transition: border-color 0.15s;
}
.modal-btn-cancel:hover { border-color: var(--text-dim); color: var(--text); }
.modal-btn-apply {
background: var(--accent); border: none; color: #000;
font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em;
text-transform: uppercase; padding: 8px 20px; cursor: pointer; transition: opacity 0.15s;
}
.modal-btn-apply:hover { opacity: 0.85; }
/* ── SOURCE SELECTOR ─────────────────────────────────────────────────────── */
.source-select {
background: var(--surface2);
border: 1px solid var(--border-bright);
color: var(--text);
font-family: var(--font-mono);
font-size: 11px;
padding: 5px 8px;
cursor: pointer;
letter-spacing: 0.06em;
height: 28px;
outline: none;
transition: border-color 0.15s, color 0.15s;
max-width: 130px;
}
.source-select:hover { border-color: var(--accent2); color: var(--accent2); }
.source-select option { background: #111; color: #eee; font-family: sans-serif; }
/* ═══════════════════════════════════════════
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; }
}
/* ── SEARCH ───────────────────────────────────────────────────────────────── */
.search-btn {
background: none; border: 1px solid var(--border); border-radius: 4px;
color: var(--text-muted); cursor: pointer; font-size: 14px;
padding: 4px 8px; transition: color .2s, border-color .2s;
}
.search-btn:hover { color: var(--accent); border-color: var(--accent); }
.search-bar {
display: none; align-items: center; gap: 8px;
background: var(--bg-card); border-bottom: 1px solid var(--border);
padding: 8px 16px;
}
.search-bar.visible { display: flex; }
.search-input {
flex: 1; background: var(--bg); border: 1px solid var(--border);
border-radius: 4px; color: var(--text); font-family: inherit;
font-size: 14px; outline: none; padding: 6px 12px;
transition: border-color .2s;
}
.search-input:focus { border-color: var(--accent); }
.search-input::placeholder { color: var(--text-muted); }
.search-clear {
background: none; border: none; color: var(--text-muted);
cursor: pointer; font-size: 16px; padding: 4px 8px;
transition: color .2s;
}
.search-clear:hover { color: var(--accent); }
/* Highlight programmes qui matchent */
.program-block.search-match {
border: 1px solid var(--accent);
box-shadow: 0 0 6px var(--accent);
}
.mobile-program.search-match {
border-left: 3px solid var(--accent);
}
/* ── PROGRAM MODAL ───────────────────────────────────────────────────────── */
.program-card {
max-width: 520px; padding: 28px; position: relative;
}
.pm-close {
position: absolute; top: 14px; right: 14px;
background: none; border: none; color: var(--text-muted);
cursor: pointer; font-size: 18px; line-height: 1;
transition: color .2s;
}
.pm-close:hover { color: var(--accent); }
.pm-channel {
font-size: 11px; letter-spacing: .1em; text-transform: uppercase;
color: var(--accent); margin-bottom: 6px;
}
.pm-title {
font-size: 20px; font-weight: 700; color: var(--text);
margin-bottom: 8px; line-height: 1.2;
}
.pm-time {
font-size: 12px; color: var(--accent); margin-bottom: 14px;
}
.pm-desc {
font-size: 13px; color: var(--text-muted); line-height: 1.6;
margin-bottom: 20px; max-height: 140px; overflow-y: auto;
}
.pm-actions {
display: flex; gap: 10px; flex-wrap: wrap;
}
.pm-btn {
border-radius: 4px; cursor: pointer; font-family: inherit;
font-size: 13px; font-weight: 600; padding: 8px 16px;
text-decoration: none; transition: opacity .2s;
display: inline-flex; align-items: center; gap: 6px;
border: none;
}
.pm-btn:hover { opacity: .8; }
.pm-watch { background: var(--accent); color: var(--bg); }
.pm-imdb { background: #f5c518; color: #000; }
.pm-cancel { background: var(--bg); color: var(--text-muted); border: 1px solid var(--border); }
/* Date midnight dans la timebar */
.time-tick-label.midnight {
color: var(--accent); font-weight: 700; font-size: 11px;
white-space: nowrap;
}
.time-tick-line.midnight {
background: var(--accent); opacity: .4; width: 2px;
}
</style>
<link id="themeStylesheet" rel="stylesheet" href="themes/default.css"> <link id="themeStylesheet" rel="stylesheet" href="themes/default.css">
</head> </head>
+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>
+17 -7
View File
@@ -12,22 +12,33 @@
<?php if (!empty($m3u_url)): ?> <?php if (!empty($m3u_url)): ?>
<button class="copy-btn" onclick="copyUrl('m3u')" title="<?= htmlspecialchars($L["copy_m3u"]) ?>"><?= htmlspecialchars($L["copy_m3u"]) ?></button> <button class="copy-btn" onclick="copyUrl('m3u')" title="<?= htmlspecialchars($L["copy_m3u"]) ?>"><?= htmlspecialchars($L["copy_m3u"]) ?></button>
<?php endif; ?> <?php endif; ?>
<a class="copy-btn" href="/export.php" title="Export 24h">Export</a>
</div> </div>
<?php if (count($epg_sources) > 1 || $allow_personal_epg): ?> <?php if (count($epg_sources) > 1 || $allow_personal_epg): ?>
<select class="source-select" id="sourceSelect" onchange="switchSource(this.value)" title="Source EPG"> <div class="source-dropdown" id="sourceDropdown">
<button class="source-dropdown-btn" onclick="toggleSourceDropdown()" id="sourceDropdownBtn">
<span id="sourceDropdownLabel"><?= htmlspecialchars($epg_sources[0]['name'] ?? 'EPG') ?></span>
<span class="source-dropdown-arrow">&#9660;</span>
</button>
<div class="source-dropdown-menu" id="sourceDropdownMenu">
<?php foreach ($epg_sources as $i => $src): ?> <?php foreach ($epg_sources as $i => $src): ?>
<option value="<?= $i ?>"><?= htmlspecialchars($src['name']) ?></option> <button class="source-dropdown-item<?= $i === 0 ? ' active' : '' ?>" onclick="switchSource(<?= $i ?>); closeSourceDropdown()">
<?= htmlspecialchars($src['name']) ?>
</button>
<?php endforeach; ?> <?php endforeach; ?>
<?php if ($allow_personal_epg): ?> <?php if ($allow_personal_epg): ?>
<option value="personal">✏ Personal EPG</option> <button class="source-dropdown-item source-dropdown-personal" onclick="switchSource('personal'); closeSourceDropdown()">
&#9998; Personal EPG
</button>
<?php endif; ?> <?php endif; ?>
</select> </div>
</div>
<?php endif; ?> <?php endif; ?>
<select class="theme-select" id="themeSelect" onchange="setTheme(this.value)" title="Thème"> <select class="theme-select" id="themeSelect" onchange="setTheme(this.value)" title="Thème">
<?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');
@@ -38,7 +49,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);
@@ -54,4 +65,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>
+77
View File
@@ -0,0 +1,77 @@
const CACHE_NAME = 'gridtv-v2';
// Static assets cached during installation.
const STATIC_ASSETS = [
'/',
'/index.php',
'/manifest.json',
'/assets/fonts/fonts.css',
'/assets/icon-192.png',
'/assets/icon-512.png',
'/assets/vendor/hls.min.js'
];
// Install: cache the static assets.
self.addEventListener('install', e => {
e.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(STATIC_ASSETS))
.then(() => self.skipWaiting())
);
});
// Activate: remove outdated caches.
self.addEventListener('activate', e => {
e.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
).then(() => self.clients.claim())
);
});
// Fetch: choose a caching strategy based on resource type.
self.addEventListener('fetch', e => {
const url = new URL(e.request.url);
// EPG/M3U feeds, the GitHub API, and version.json stay network-only.
if (
url.pathname.includes('xmltv') ||
url.pathname.includes('.m3u') ||
url.hostname === 'api.github.com' ||
url.pathname.includes('proxy.php') ||
url.pathname.endsWith('version.json')
) {
return;
}
// Static assets use a cache-first strategy.
if (
e.request.destination === 'style' ||
e.request.destination === 'script' ||
e.request.destination === 'font' ||
e.request.destination === 'image' ||
url.pathname.endsWith('.json') ||
url.pathname.endsWith('.css') ||
url.pathname.endsWith('.js')
) {
e.respondWith(
caches.match(e.request).then(cached => cached || fetch(e.request).then(res => {
const clone = res.clone();
caches.open(CACHE_NAME).then(cache => cache.put(e.request, clone));
return res;
}))
);
return;
}
// PHP pages use network-first with cache fallback.
e.respondWith(
fetch(e.request)
.then(res => {
const clone = res.clone();
caches.open(CACHE_NAME).then(cache => cache.put(e.request, clone));
return res;
})
.catch(() => caches.match(e.request))
);
});
-2
View File
@@ -3,8 +3,6 @@
* Néons rose/cyan sur noir absolu. Blade Runner. * Néons rose/cyan sur noir absolu. Blade Runner.
*/ */
@import url('https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;600;700&display=swap');
:root { :root {
--bg: #000008; --bg: #000008;
--surface: #080010; --surface: #080010;
-2
View File
@@ -3,8 +3,6 @@
* Salle de régie sombre, la base. * Salle de régie sombre, la base.
*/ */
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Barlow+Condensed:wght@300;400;600;700&display=swap');
:root { :root {
--bg: #0a0b0d; --bg: #0a0b0d;
--surface: #111318; --surface: #111318;
-2
View File
@@ -3,8 +3,6 @@
* Papier jauni, encre rouge et bleue, machine à écrire. * Papier jauni, encre rouge et bleue, machine à écrire.
*/ */
@import url('https://fonts.googleapis.com/css2?family=Special+Elite&display=swap');
:root { :root {
--bg: #f2e8c8; --bg: #f2e8c8;
--surface: #ede0b0; --surface: #ede0b0;
-2
View File
@@ -3,8 +3,6 @@
* Brun chaud, cuivre, laiton. Très 1880. * Brun chaud, cuivre, laiton. Très 1880.
*/ */
@import url('https://fonts.googleapis.com/css2?family=IM+Fell+English:ital@0;1&display=swap');
:root { :root {
--bg: #160e04; --bg: #160e04;
--surface: #201408; --surface: #201408;
+3
View File
@@ -0,0 +1,3 @@
{
"version": "1.6.0"
}