Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56781be784 | ||
|
|
16abfab05b | ||
|
|
b5fcf5c902 | ||
|
|
ab56bd9c70 | ||
|
|
d560f49dac | ||
|
|
804490ca48 | ||
|
|
721eb67c2f | ||
|
|
411b5bb354 | ||
|
|
3f8aab5e70 | ||
|
|
dde85a4e38 | ||
|
|
214a2b9cc5 | ||
|
|
a81e33ccb8 | ||
|
|
3f83055198 | ||
|
|
c46ca14f4c | ||
|
|
ea7407f1d8 | ||
|
|
674c13ae26 | ||
|
|
96601b76b6 | ||
|
|
fc608ee0e0 | ||
|
|
45cf0975b0 | ||
|
|
90679e9221 | ||
|
|
7f500ff9ee | ||
|
|
bc5c9e296a | ||
|
|
985a417ccb | ||
|
|
27ef739845 | ||
|
|
77f02361ab | ||
|
|
6f379fd18e | ||
|
|
8dabd2d190 | ||
|
|
1402d9b5fc | ||
|
|
062954a780 | ||
|
|
ad56bd85cc | ||
|
|
1959734b56 | ||
|
|
3d0b4796b2 | ||
|
|
de1f5a336c | ||
|
|
4f0e1f55ba | ||
|
|
8ba8c3013e | ||
|
|
9609f096ef | ||
|
|
8c81c05bca | ||
|
|
ff998d379d | ||
|
|
62732ab2c6 | ||
|
|
25dabc97b2 | ||
|
|
e81cd8b64d | ||
|
|
30d8a24927 | ||
|
|
d6532d3501 | ||
|
|
17db9f56f7 | ||
|
|
874214aa41 | ||
|
|
de0e1adf44 | ||
|
|
42d161beff | ||
|
|
30c0de2a2d | ||
|
|
77f0c2523b | ||
|
|
73bdde5093 | ||
|
|
0c2bce40fb | ||
|
|
005fca9a05 | ||
|
|
f82bc65472 | ||
|
|
d57855c385 | ||
|
|
ca25d5000b | ||
|
|
f935a82e91 | ||
|
|
96c9835a27 | ||
|
|
b312e8cc26 | ||
|
|
79a077a85f | ||
|
|
8e3dc664ed | ||
|
|
2172202d25 | ||
|
|
804bb96e07 | ||
|
|
d7541c0fa4 | ||
|
|
7f23ba92ad | ||
|
|
9e4614d8df | ||
|
|
5fd4eb8a5c | ||
|
|
8ca85e778e | ||
|
|
181e477a0e | ||
|
|
da501bccc7 | ||
|
|
8a465dc6da | ||
|
|
2d4d8c031e | ||
|
|
8b032f56de | ||
|
|
44f4eeaafe | ||
|
|
aac2b4404f | ||
|
|
8460771d20 | ||
|
|
76dbd18ca7 |
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
*.swp
|
||||
data
|
||||
assets/preview.png
|
||||
assets/.DS_Store
|
||||
src/.DS_Store
|
||||
.version.json.swp
|
||||
@@ -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
@@ -1,4 +1,3 @@
|
||||
config.json
|
||||
data/
|
||||
.DS_Store
|
||||
generate_epg.php
|
||||
epg.xml
|
||||
|
||||
+12
-2
@@ -4,8 +4,18 @@ RUN docker-php-ext-install curl && \
|
||||
a2enmod rewrite
|
||||
|
||||
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 && \
|
||||
chmod -R 775 /var/www/html
|
||||
RUN mkdir -p /data && \
|
||||
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
|
||||
|
||||
@@ -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
|
||||
- 👤 **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)
|
||||
- ⭐ **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/`)
|
||||
- ⚙️ **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
|
||||
- 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
|
||||
|
||||
- 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**
|
||||
- An **EPG source in XMLTV format** (e.g. Tunarr, Jellyfin, xTeVe...)
|
||||
- *(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.
|
||||
|
||||
Docker stores the generated configuration in `./data/config.json` on the host.
|
||||
|
||||
To run on a custom port:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
@@ -92,7 +100,25 @@ chmod 775 /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
|
||||
server {
|
||||
@@ -113,13 +139,16 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
> 💡 Adjust the PHP version (`php8.2-fpm`) to match the one installed on your server.
|
||||
|
||||
```bash
|
||||
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
|
||||
<VirtualHost *:80>
|
||||
@@ -138,16 +167,52 @@ a2enmod php8.4 rewrite
|
||||
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
|
||||
# Debian/Ubuntu — adjust version to match your PHP
|
||||
apt install php8.4-curl
|
||||
systemctl reload apache2 # or: systemctl reload nginx
|
||||
systemctl reload caddy
|
||||
```
|
||||
|
||||
> 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
|
||||
|
||||
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.**
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
@@ -201,14 +278,21 @@ nano /var/www/gridtv/config.json
|
||||
|
||||
---
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Project structure</summary>
|
||||
|
||||
## 📁 Project structure
|
||||
|
||||
```
|
||||
gridtv/
|
||||
├── 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
|
||||
├── config.example.json # Config template
|
||||
├── version.json # Current version (used for update check)
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── .gitignore # config.json excluded
|
||||
@@ -223,13 +307,24 @@ gridtv/
|
||||
│ └── steampunk.css
|
||||
└── src/
|
||||
├── 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
|
||||
│ ├── head.php # DOCTYPE, <head>, CSS
|
||||
│ ├── head.php # DOCTYPE, <head>, includes CSS modules
|
||||
│ ├── topbar.php # Topbar (nav, source switcher, search, theme)
|
||||
│ ├── grid.php # Grid + mobile + PiP + overlays
|
||||
│ ├── modals.php # Program info modal + Personal EPG modal
|
||||
│ └── 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)
|
||||
├── utils.js # Helpers, clock, EPG fetch
|
||||
├── epg.js # Grid rendering
|
||||
@@ -241,6 +336,8 @@ gridtv/
|
||||
├── themes.js # Theme switcher
|
||||
├── search.js # Search + filter (debounced)
|
||||
├── program.js # Program info modal + IMDb link
|
||||
├── favorites.js # Favorites (localStorage)
|
||||
├── updater.js # GitHub update checker
|
||||
└── live.js # Live updates + responsive
|
||||
```
|
||||
|
||||
@@ -248,6 +345,11 @@ gridtv/
|
||||
|
||||
---
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Themes</summary>
|
||||
|
||||
## 🎨 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:
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
GNU Affero General Public License v3.0 (AGPLv3)
|
||||
|
||||
---
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Roadmap</summary>
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
### ✅ Done
|
||||
@@ -345,15 +487,17 @@ GNU Affero General Public License v3.0 (AGPLv3)
|
||||
- Theme system (4 built-in themes)
|
||||
- Multi-EPG sources with topbar switcher
|
||||
- Personal EPG (localStorage)
|
||||
- Modular codebase (src/tpl + src/js)
|
||||
- Modular codebase (src/tpl + src/js + src/css)
|
||||
- Docker support
|
||||
- **Search** — filter grid by channel name or program title/synopsis (debounced)
|
||||
- **Date in timebar** — midnight markers with day/date in the ruler
|
||||
- **Program popup** — full details + IMDb search link on any program click
|
||||
- **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
|
||||
- **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
|
||||
- **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.
@@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 549 KiB |
Vendored
+2
File diff suppressed because one or more lines are too long
Vendored
+20
File diff suppressed because one or more lines are too long
@@ -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
@@ -4,5 +4,5 @@ services:
|
||||
ports:
|
||||
- "${PORT:-8080}:80"
|
||||
volumes:
|
||||
- ./config.json:/var/www/html/config.json
|
||||
- ./data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -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
@@ -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 à l’envoi à quelqu’un, 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
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
+337
@@ -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>
|
||||
+3
-1
@@ -49,5 +49,7 @@
|
||||
"shift_back": "◄ 1h",
|
||||
"shift_fwd": "1h ►",
|
||||
"fav_add": "Add to favorites",
|
||||
"fav_remove": "Remove from favorites"
|
||||
"fav_remove": "Remove from favorites",
|
||||
"update_available": "↑ Update available:",
|
||||
"update_title": "Click to download the latest version"
|
||||
}
|
||||
+3
-1
@@ -49,5 +49,7 @@
|
||||
"shift_back": "◄ 1h",
|
||||
"shift_fwd": "1h ►",
|
||||
"fav_add": "Agregar a favoritos",
|
||||
"fav_remove": "Quitar de favoritos"
|
||||
"fav_remove": "Quitar de favoritos",
|
||||
"update_available": "↑ Actualizacion disponible:",
|
||||
"update_title": "Haz clic para descargar la ultima version"
|
||||
}
|
||||
+3
-1
@@ -49,5 +49,7 @@
|
||||
"shift_back": "◄ 1h",
|
||||
"shift_fwd": "1h ►",
|
||||
"fav_add": "Ajouter aux favoris",
|
||||
"fav_remove": "Retirer des favoris"
|
||||
"fav_remove": "Retirer des favoris",
|
||||
"update_available": "↑ Mise a jour disponible :",
|
||||
"update_title": "Cliquez pour telecharger la derniere version"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,81 +1,216 @@
|
||||
<?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'] ?? '';
|
||||
if (empty($url) || !preg_match('#^https?://#i', $url)) {
|
||||
http_response_code(400); die('Invalid URL');
|
||||
// ── Allowlist: authorized hosts extracted from config.json ────────────────────
|
||||
$config_path = __DIR__ . '/config.json';
|
||||
$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);
|
||||
$origin = parse_url($url, PHP_URL_SCHEME) . '://' . parse_url($url, PHP_URL_HOST);
|
||||
$port = parse_url($url, PHP_URL_PORT);
|
||||
if ($port) $origin .= ':' . $port;
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function is_allowed_url(string $url, array $allowed_hosts): bool {
|
||||
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) ?? '';
|
||||
$is_segment = preg_match('#\.(ts|aac|mp4|m4s|fmp4)(\?|$)#i', $path);
|
||||
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Cache-Control: no-cache');
|
||||
|
||||
$ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0';
|
||||
|
||||
// ── Binary segment: stream chunk by chunk ──────────────────────────────────────
|
||||
if ($is_segment) {
|
||||
// Segment binaire — stream via cURL chunk par chunk
|
||||
header('Content-Type: video/MP2T');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
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, [
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_USERAGENT => $ua,
|
||||
CURLOPT_HTTPHEADER => ['Accept: */*'],
|
||||
CURLOPT_RETURNTRANSFER => false,
|
||||
CURLOPT_WRITEFUNCTION => function($ch, $data) {
|
||||
echo $data;
|
||||
flush();
|
||||
return strlen($data);
|
||||
echo $data; flush(); return strlen($data);
|
||||
},
|
||||
CURLOPT_HEADERFUNCTION => function($ch, $header) {
|
||||
$h = trim($header);
|
||||
if (preg_match('/^Content-Type:/i', $h)) {
|
||||
header($h);
|
||||
}
|
||||
if (preg_match('/^Content-Type:/i', $h)) header($h);
|
||||
return strlen($header);
|
||||
},
|
||||
]);
|
||||
|
||||
$ok = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
if (!$ok || $code >= 400) {
|
||||
http_response_code($code ?: 502);
|
||||
}
|
||||
if (!$ok || $code >= 400) http_response_code($code ?: 502);
|
||||
curl_close($ch);
|
||||
|
||||
// ── m3u8 playlist: fetch and rewrite URLs ──────────────────────────────────────
|
||||
} else {
|
||||
// Playlist .m3u8 — fetch + réécriture URLs
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_USERAGENT => $ua,
|
||||
CURLOPT_HTTPHEADER => ['Accept: */*'],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
[$code, , $body, $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, false);
|
||||
|
||||
if ($body === false || $code >= 400) {
|
||||
http_response_code($code ?: 502);
|
||||
die("Upstream error $code");
|
||||
}
|
||||
if ($code >= 400) { http_response_code($code); die("Upstream error $code"); }
|
||||
|
||||
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')
|
||||
. '://' . $_SERVER['HTTP_HOST']
|
||||
. strtok($_SERVER['REQUEST_URI'], '?')
|
||||
@@ -85,7 +220,7 @@ if ($is_segment) {
|
||||
foreach (explode("\n", $body) as $line) {
|
||||
$line = rtrim($line);
|
||||
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]
|
||||
: ($m[1][0] === '/' ? $origin . $m[1] : $base . $m[1]);
|
||||
return 'URI="' . $proxy_base . urlencode($seg) . '"';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
// ── Migration automatique ancien format → nouveau ─────────────────────────────
|
||||
$config_path = __DIR__ . '/config.json';
|
||||
|
||||
// Automatically migrate the legacy config format.
|
||||
if (file_exists($config_path)) {
|
||||
$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'])) {
|
||||
$existing['epg_sources'] = [[
|
||||
'name' => 'Main',
|
||||
@@ -15,18 +15,55 @@ if (file_exists($config_path)) {
|
||||
unset($existing['epg_url'], $existing['m3u_url']);
|
||||
file_put_contents($config_path, json_encode($existing, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
http_response_code(403);
|
||||
die('Configuration déjà effectuée. Modifiez config.json via SSH pour changer les paramètres.');
|
||||
// Bootstrap an admin key for older configs that do not have one yet.
|
||||
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 = '';
|
||||
$success = false;
|
||||
$is_first_setup = !file_exists($config_path);
|
||||
$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'] ?? '');
|
||||
$allow_personal_epg = isset($_POST['allow_personal_epg']);
|
||||
|
||||
// Récupérer les sources EPG dynamiques
|
||||
$names = $_POST['source_name'] ?? [];
|
||||
$epg_urls = $_POST['source_epg_url'] ?? [];
|
||||
$m3u_urls = $_POST['source_m3u_url'] ?? [];
|
||||
@@ -37,13 +74,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$m3u_url = trim($m3u_urls[$i] ?? '');
|
||||
if (empty($epg_url)) continue;
|
||||
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)) {
|
||||
$error = "URL M3U invalide pour la source « $name »."; break;
|
||||
$error = "Invalid M3U URL for source \"$name\"."; break;
|
||||
}
|
||||
$epg_sources[] = [
|
||||
'name' => trim($name) ?: "Source " . ($i + 1),
|
||||
'name' => trim($name) ?: 'Source ' . ($i + 1),
|
||||
'epg_url' => $epg_url,
|
||||
'm3u_url' => $m3u_url,
|
||||
];
|
||||
@@ -51,303 +88,224 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
if (empty($error)) {
|
||||
if (empty($group_name)) {
|
||||
$error = 'Le nom du groupe est obligatoire.';
|
||||
$error = 'Group name is required.';
|
||||
} elseif (empty($epg_sources)) {
|
||||
$error = 'Au moins une source EPG est obligatoire.';
|
||||
$error = 'At least one EPG source is required.';
|
||||
} else {
|
||||
$new_key = $is_first_setup ? strtoupper(bin2hex(random_bytes(8))) : $admin_key;
|
||||
$config = [
|
||||
'group_name' => $group_name,
|
||||
'epg_sources' => $epg_sources,
|
||||
'allow_personal_epg' => $allow_personal_epg,
|
||||
'admin_key' => $new_key,
|
||||
];
|
||||
$written = file_put_contents(
|
||||
$config_path,
|
||||
json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
$written = file_put_contents($config_path, json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
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 {
|
||||
$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>
|
||||
<html lang="fr">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GridTV — Configuration</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Barlow+Condensed:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||||
<title>GridTV — Setup</title>
|
||||
<link rel="stylesheet" href="/assets/fonts/fonts.css">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0b0d; --surface: #111318; --surface2: #181b22;
|
||||
--border: #232733; --border-bright: #2e3444;
|
||||
--accent: #e8c842; --accent2: #4a9eff;
|
||||
--text: #c8cdd8; --text-dim: #5a6070; --text-bright: #eef0f5;
|
||||
--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;
|
||||
--bg:#0a0b0d;--surface:#111318;--surface2:#181b22;
|
||||
--border:#232733;--border-bright:#2e3444;
|
||||
--accent:#e8c842;--accent2:#4a9eff;
|
||||
--text:#c8cdd8;--text-dim:#5a6070;--text-bright:#eef0f5;
|
||||
--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:.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>
|
||||
</head>
|
||||
<body>
|
||||
<div class="setup-card">
|
||||
|
||||
<div class="setup-logo">Grid<span>/</span>TV</div>
|
||||
<div class="setup-subtitle">
|
||||
Bienvenue sur GridTV — votre guide TV IPTV.<br>
|
||||
Configurez votre instance en remplissant les champs ci-dessous.
|
||||
|
||||
<?php if ($mode === 'lock'): ?>
|
||||
<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">🔒</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>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-error"><?= htmlspecialchars($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($success): ?>
|
||||
<div class="alert alert-success">
|
||||
✓ Configuration enregistrée dans <code>config.json</code>.<br>
|
||||
Cette page ne sera plus accessible. Pour modifier la config, éditez <code>config.json</code> via SSH.
|
||||
</div>
|
||||
<div class="success-actions">
|
||||
<a href="index.php" class="btn-go">Ouvrir GridTV →</a>
|
||||
<?php elseif ($mode === 'success'): ?>
|
||||
<div class="setup-subtitle">Configuration saved successfully.</div>
|
||||
<div class="alert alert-success">✓ Settings saved to <code>config.json</code>.</div>
|
||||
<?php if ($is_first_setup): ?>
|
||||
<div class="key-box">
|
||||
<div class="key-label">Your admin key</div>
|
||||
<div class="key-value"><?= htmlspecialchars($new_key) ?></div>
|
||||
<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>
|
||||
<?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 →</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">
|
||||
<?php if (!$is_first_setup): ?>
|
||||
<input type="hidden" name="admin_key_hidden" value="<?= htmlspecialchars($submitted_key ?: $admin_key) ?>">
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Nom du groupe TV <span class="required">*</span></label>
|
||||
<input type="text" name="group_name"
|
||||
value="<?= htmlspecialchars($_POST['group_name'] ?? '') ?>"
|
||||
placeholder="Ex: JohnnyBeGood, MaTV, FamilleTV..."
|
||||
required>
|
||||
<div class="hint">S'affiche en haut à gauche dans la topbar.</div>
|
||||
<label>TV Group Name <span class="required">*</span></label>
|
||||
<input type="text" name="group_name" value="<?= htmlspecialchars($prefill_group) ?>" placeholder="e.g. JohnnyBeGood, MyTV, FamilyTV..." required>
|
||||
<div class="hint">Displayed top-left in the topbar.</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Sources EPG</div>
|
||||
|
||||
<div class="section-title">EPG Sources</div>
|
||||
<div class="sources-list" id="sourcesList">
|
||||
<div class="source-block" data-index="0">
|
||||
<div class="source-num">SOURCE 1</div>
|
||||
<button type="button" class="source-remove" onclick="removeSource(this)" style="display:none">✕</button>
|
||||
<?php
|
||||
$sources_to_show = !empty($prefill_sources) ? $prefill_sources : [['name'=>'Main','epg_url'=>'','m3u_url'=>'']];
|
||||
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"' : '' ?>>✕</button>
|
||||
<div class="source-fields">
|
||||
<div>
|
||||
<label>Nom de la source</label>
|
||||
<input type="text" name="source_name[]" value="Main" placeholder="Ex: Main, Sports, Films...">
|
||||
<label>Source name</label>
|
||||
<input type="text" name="source_name[]" value="<?= htmlspecialchars($src['name'] ?? '') ?>" placeholder="e.g. Main, Sports, Movies...">
|
||||
</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" required>
|
||||
<label>EPG URL (XMLTV) <span class="required">*</span></label>
|
||||
<input type="url" name="source_epg_url[]" value="<?= htmlspecialchars($src['epg_url'] ?? '') ?>" placeholder="http://your-server/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>
|
||||
<label>M3U URL <span class="optional">(optional)</span></label>
|
||||
<input type="url" name="source_m3u_url[]" value="<?= htmlspecialchars($src['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>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-add-source" onclick="addSource()">+ Ajouter une source EPG</button>
|
||||
<button type="button" class="btn-add-source" onclick="addSource()">+ Add EPG source</button>
|
||||
|
||||
<div class="section-title">Options</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-label">
|
||||
Autoriser les EPG personnels
|
||||
<small>Les visiteurs peuvent utiliser votre instance avec leur propre EPG/M3U.</small>
|
||||
Allow personal EPG
|
||||
<small>Visitors can use your instance with their own EPG/M3U URLs.</small>
|
||||
</div>
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-submit">Enregistrer la configuration</button>
|
||||
|
||||
<button type="submit" class="btn-submit">Save configuration</button>
|
||||
</form>
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="setup-note">
|
||||
<strong>Note :</strong> La configuration est stockée dans <code>config.json</code> sur le serveur.
|
||||
Ce fichier est exclu du dépôt Git (<code>.gitignore</code>).
|
||||
Pour modifier les paramètres ultérieurement, éditez ce fichier directement via SSH.
|
||||
<strong>Note:</strong> Configuration is stored in <code>config.json</code> on the server.
|
||||
This file is excluded from the Git repository (<code>.gitignore</code>).
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let sourceCount = 1;
|
||||
|
||||
let sourceCount = document.querySelectorAll('.source-block').length;
|
||||
function addSource() {
|
||||
sourceCount++;
|
||||
const list = document.getElementById('sourcesList');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'source-block';
|
||||
div.dataset.index = sourceCount - 1;
|
||||
div.innerHTML = `
|
||||
<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)">✕</button>
|
||||
<div class="source-fields">
|
||||
<div>
|
||||
<label>Nom de la source</label>
|
||||
<input type="text" name="source_name[]" placeholder="Ex: Sports, Films...">
|
||||
</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><label>Source name</label><input type="text" name="source_name[]" placeholder="e.g. Sports, Movies..."></div>
|
||||
<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>
|
||||
<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>`;
|
||||
list.appendChild(div);
|
||||
updateRemoveButtons();
|
||||
}
|
||||
|
||||
function removeSource(btn) {
|
||||
btn.closest('.source-block').remove();
|
||||
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';
|
||||
});
|
||||
}
|
||||
function removeSource(btn) { btn.closest('.source-block').remove(); renumberSources(); updateRemoveButtons(); }
|
||||
function renumberSources() { document.querySelectorAll('.source-block').forEach((b,i) => b.querySelector('.source-num').textContent = `SOURCE ${i+1}`); }
|
||||
function updateRemoveButtons() { const b = document.querySelectorAll('.source-block'); b.forEach(bl => bl.querySelector('.source-remove').style.display = b.length > 1 ? '' : 'none'); }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+15
-4
@@ -8,8 +8,12 @@ if (!file_exists($config_path)) {
|
||||
}
|
||||
|
||||
$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'])) {
|
||||
$config['epg_sources'] = [[
|
||||
'name' => 'Main',
|
||||
@@ -26,21 +30,28 @@ $group_name = htmlspecialchars($config['group_name'] ?? 'GridTV');
|
||||
$epg_sources = $config['epg_sources'] ?? [];
|
||||
$allow_personal_epg = !empty($config['allow_personal_epg']);
|
||||
|
||||
// Première source par défaut (pour compatibilité avec le reste du code)
|
||||
// Default to the first configured source to keep the rest of the app compatible.
|
||||
$first_source = $epg_sources[0] ?? [];
|
||||
$epg_url = htmlspecialchars($first_source['epg_url'] ?? '');
|
||||
$m3u_url = htmlspecialchars($first_source['m3u_url'] ?? '');
|
||||
|
||||
// ── 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';
|
||||
$accept = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? 'en';
|
||||
foreach (explode(',', $accept) as $lang) {
|
||||
$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";
|
||||
$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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 ───────────────────────────────────────────────────────────── */
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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
@@ -2,16 +2,16 @@ const EPG_SOURCES = <?= json_encode(array_values($epg_sources)) ?>;
|
||||
const L = <?= json_encode($L) ?>;
|
||||
const ALLOW_PERSONAL_EPG = <?= $allow_personal_epg ? 'true' : 'false' ?>;
|
||||
|
||||
// Source active (index)
|
||||
// Index of the currently selected source.
|
||||
let activeSourceIndex = 0;
|
||||
|
||||
const DEFAULT_EPG_URL = EPG_SOURCES[0]?.epg_url ?? '';
|
||||
const DEFAULT_M3U_URL = EPG_SOURCES[0]?.m3u_url ?? '';
|
||||
const PX_PER_MIN = 5;
|
||||
const GRID_HOURS = 72;
|
||||
const CORS_PROXY = 'https://corsproxy.io/?';
|
||||
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();
|
||||
_d.setMinutes(Math.floor(_d.getMinutes()/15)*15, 0, 0);
|
||||
const GRID_START = new Date(_d.getTime() - 2*3600000);
|
||||
|
||||
+1
-2
@@ -73,7 +73,7 @@ function renderPrograms() {
|
||||
inner.style.width = TOTAL_WIDTH_PX+'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');
|
||||
nowLine.className = 'now-line'; nowLine.id = 'nowLine';
|
||||
nowLine.style.height = totalH + 'px';
|
||||
@@ -141,4 +141,3 @@ function shiftView(mins) {
|
||||
area.scrollLeft = Math.max(0, area.scrollLeft + mins * PX_PER_MIN);
|
||||
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
|
||||
}
|
||||
|
||||
|
||||
+2
-4
@@ -13,8 +13,7 @@ window.addEventListener('load', () => {
|
||||
if (epg) {
|
||||
currentEpgUrl = epg;
|
||||
currentM3uUrl = m3u;
|
||||
const sel = document.getElementById('sourceSelect');
|
||||
if (sel) sel.value = 'personal';
|
||||
updateSourceDropdownLabel('✏ Personal EPG');
|
||||
loadEPG(epg);
|
||||
if (m3u) loadM3U(m3u);
|
||||
return;
|
||||
@@ -26,8 +25,7 @@ window.addEventListener('load', () => {
|
||||
activeSourceIndex = idx;
|
||||
currentEpgUrl = src.epg_url;
|
||||
currentM3uUrl = src.m3u_url ?? '';
|
||||
const sel = document.getElementById('sourceSelect');
|
||||
if (sel) sel.value = idx;
|
||||
if (src) updateSourceDropdownLabel(src.name);
|
||||
}
|
||||
}
|
||||
loadEPG(currentEpgUrl);
|
||||
|
||||
+5
-6
@@ -1,8 +1,8 @@
|
||||
// ── M3U PARSER ────────────────────────────────────────────────────────────────
|
||||
function slugify(str) {
|
||||
return str.toLowerCase()
|
||||
.replace(/^\d+\s*/, '') // virer le numéro de chaîne en début
|
||||
.replace(/[^a-z0-9]/g, ''); // garder que alphanum
|
||||
.replace(/^\d+\s*/, '') // Strip any leading channel number.
|
||||
.replace(/[^a-z0-9]/g, ''); // Keep only alphanumeric characters.
|
||||
}
|
||||
|
||||
async function loadM3U(url) {
|
||||
@@ -25,7 +25,7 @@ function parseM3U(text) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (line.startsWith('#EXTINF')) {
|
||||
// Extraire le nom : dernière partie après la virgule
|
||||
// Extract the display name from the last comma-separated segment.
|
||||
const comma = line.lastIndexOf(',');
|
||||
currentName = comma !== -1 ? line.slice(comma + 1).trim() : null;
|
||||
} else if (line && !line.startsWith('#') && currentName) {
|
||||
@@ -42,16 +42,15 @@ function getStreamUrl(channelName) {
|
||||
if (m3uStreams[slug]) {
|
||||
url = m3uStreams[slug];
|
||||
} 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)) {
|
||||
if (key.includes(slug) || slug.includes(key)) { url = u; break; }
|
||||
}
|
||||
}
|
||||
if (!url) return null;
|
||||
// Passer par proxy.php pour éviter le mixed content HTTP/HTTPS
|
||||
// Route plain HTTP streams through proxy.php to avoid mixed-content issues.
|
||||
if (url.startsWith('http://')) {
|
||||
return 'proxy.php?url=' + encodeURIComponent(url);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -39,12 +39,12 @@ function renderMobile() {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const info = document.createElement('div'); info.className='mobile-program-info';
|
||||
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);
|
||||
|
||||
if (isLive) {
|
||||
|
||||
+2
-3
@@ -12,7 +12,7 @@ function openPip(channelName, programTitle) {
|
||||
err.style.display = 'none';
|
||||
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; }
|
||||
video.src = '';
|
||||
|
||||
@@ -39,7 +39,7 @@ function openPip(channelName, programTitle) {
|
||||
}
|
||||
});
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
// Safari natif HLS
|
||||
// Safari can play HLS natively without hls.js.
|
||||
video.src = url;
|
||||
video.addEventListener('loadedmetadata', () => video.play().catch(() => {}));
|
||||
} else {
|
||||
@@ -55,4 +55,3 @@ function closePip() {
|
||||
if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; }
|
||||
video.pause(); video.src = '';
|
||||
}
|
||||
|
||||
|
||||
+12
-4
@@ -4,16 +4,24 @@ function openProgramModal(ch, p) {
|
||||
const isLive = p.start <= now && p.stop > now;
|
||||
const dur = Math.round((p.stop - p.start) / 60000);
|
||||
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-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 =
|
||||
`${fmtTime(p.start)} — ${fmtTime(p.stop)} · ${dur} ${L.duration_min}` +
|
||||
(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').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');
|
||||
if (isLive && currentM3uUrl) {
|
||||
watchBtn.style.display = 'inline-flex';
|
||||
@@ -22,7 +30,7 @@ function openProgramModal(ch, p) {
|
||||
watchBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
// Bouton IMDb
|
||||
// IMDb search shortcut.
|
||||
document.getElementById('pm-imdb').href =
|
||||
`https://www.imdb.com/find/?q=${encodeURIComponent(p.title)}&s=tt`;
|
||||
|
||||
@@ -33,13 +41,13 @@ function closeProgramModal() {
|
||||
document.getElementById('programModal').classList.remove('visible');
|
||||
}
|
||||
|
||||
// Fermer sur clic overlay
|
||||
// Close when the overlay itself is clicked.
|
||||
document.addEventListener('click', e => {
|
||||
const modal = document.getElementById('programModal');
|
||||
if (e.target === modal) closeProgramModal();
|
||||
});
|
||||
|
||||
// Fermer sur Escape
|
||||
// Close on Escape.
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') closeProgramModal();
|
||||
});
|
||||
|
||||
+19
-12
@@ -31,7 +31,7 @@ function onSearchInput(val) {
|
||||
|
||||
function applySearch(q) {
|
||||
if (!q) {
|
||||
// Remettre toutes les chaines
|
||||
// Restore the full channel list when the query is cleared.
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
@@ -40,15 +40,16 @@ function applySearch(q) {
|
||||
today.setHours(0,0,0,0);
|
||||
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 => {
|
||||
// Match sur le nom de la chaine
|
||||
// Match against the channel name.
|
||||
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 => {
|
||||
if (p.stop < today || p.start > tomorrow) return false;
|
||||
if (p.title.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;
|
||||
});
|
||||
});
|
||||
@@ -72,7 +73,7 @@ function renderFiltered(filteredChannels, q) {
|
||||
inner.style.width = TOTAL_WIDTH_PX + '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');
|
||||
nowLine.className = 'now-line'; nowLine.id = 'nowLine';
|
||||
nowLine.style.height = (filteredChannels.length * ROW_H) + 'px';
|
||||
@@ -81,7 +82,7 @@ function renderFiltered(filteredChannels, q) {
|
||||
const now = new Date();
|
||||
|
||||
filteredChannels.forEach((ch, i) => {
|
||||
// Colonne chaîne
|
||||
// Channel column.
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'channel-cell';
|
||||
if (ch.icon) {
|
||||
@@ -96,7 +97,7 @@ function renderFiltered(filteredChannels, q) {
|
||||
cell.addEventListener('click', () => openPip(ch.name, getNowPlaying(ch.id)));
|
||||
list.appendChild(cell);
|
||||
|
||||
// Programmes
|
||||
// Programs row.
|
||||
const row = document.createElement('div');
|
||||
row.className = 'program-row';
|
||||
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');
|
||||
block.className = 'program-block';
|
||||
|
||||
// Highlight si le programme matche la recherche
|
||||
const matchesProg = p.title.toLowerCase().includes(q) || (p.desc && p.desc.toLowerCase().includes(q));
|
||||
// Highlight programs that match the current search query.
|
||||
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 (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 isPast = p.stop < now;
|
||||
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');
|
||||
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';
|
||||
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);
|
||||
|
||||
const info = document.createElement('div'); info.className = 'mobile-program-info';
|
||||
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);
|
||||
|
||||
if (isLive) {
|
||||
|
||||
+24
-2
@@ -21,7 +21,7 @@ function switchSource(value) {
|
||||
}
|
||||
|
||||
function updateCopyButtons() {
|
||||
// Rien à faire visuellement, copyUrl() lit currentEpgUrl/currentM3uUrl
|
||||
// No visual update is needed here because copyUrl() reads the current URLs directly.
|
||||
}
|
||||
|
||||
function openPersonalEpgModal() {
|
||||
@@ -35,7 +35,7 @@ function openPersonalEpgModal() {
|
||||
|
||||
function closePersonalEpgModal(applied) {
|
||||
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) {
|
||||
const sel = document.getElementById('sourceSelect');
|
||||
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-m3u', m3u);
|
||||
localStorage.setItem('gridtv-active-source', 'personal');
|
||||
updateSourceDropdownLabel('✏ Personal EPG');
|
||||
currentEpgUrl = epg;
|
||||
currentM3uUrl = m3u;
|
||||
const sel = document.getElementById('sourceSelect');
|
||||
@@ -58,3 +59,24 @@ function applyPersonalEpg() {
|
||||
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
@@ -1,4 +1,4 @@
|
||||
// ── THÈMES ────────────────────────────────────────────────────────────────────
|
||||
// ── THEMES ────────────────────────────────────────────────────────────────────
|
||||
function setTheme(theme) {
|
||||
document.getElementById('themeStylesheet').href = 'themes/' + theme + '.css';
|
||||
localStorage.setItem('gridtv-theme', theme);
|
||||
@@ -17,4 +17,3 @@ function startLiveUpdates() {
|
||||
setInterval(() => { if (channels.length) renderAll(); }, 60000);
|
||||
setInterval(() => loadEPG(DEFAULT_EPG_URL), 30*60*1000);
|
||||
}
|
||||
|
||||
|
||||
+7
-4
@@ -8,8 +8,13 @@ function getNowPlaying(chId) {
|
||||
// ── TOOLTIP ───────────────────────────────────────────────────────────────────
|
||||
function showTooltip(e, p) {
|
||||
const ep = fmtEpisode(p.season, p.episode);
|
||||
document.getElementById('tt-title').textContent = p.title + (ep ? ' ' + ep : '');
|
||||
document.getElementById('tt-time').textContent = `${fmtTime(p.start)} — ${fmtTime(p.stop)} · ${Math.round((p.stop-p.start)/60000)} min`;
|
||||
const genres = formatCategories(p.categories);
|
||||
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('tooltip').classList.add('visible');
|
||||
moveTooltip(e);
|
||||
@@ -23,5 +28,3 @@ function moveTooltip(e) {
|
||||
function hideTooltip() { document.getElementById('tooltip').classList.remove('visible'); }
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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 programs = {};
|
||||
let m3uStreams = {}; // slug normalisé → url stream
|
||||
let scrollListenerAdded = false; // sync scroll vertical chaînes
|
||||
let m3uStreams = {}; // Normalized slug -> stream URL.
|
||||
let scrollListenerAdded = false; // Keeps the channel column vertically synced with the timeline.
|
||||
|
||||
function pad(n) { return String(n).padStart(2,'0'); }
|
||||
|
||||
@@ -28,6 +35,8 @@ function parseXMLTVDate(str) {
|
||||
if (!str) return null;
|
||||
const m = str.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{4})?/);
|
||||
if (!m) return null;
|
||||
// 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 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);
|
||||
@@ -71,7 +80,7 @@ function loadFromInput() {
|
||||
}
|
||||
|
||||
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;
|
||||
if (ns) {
|
||||
const parts = ns.split('.');
|
||||
@@ -80,7 +89,7 @@ function parseEpisode(p) {
|
||||
const e = ePart !== '' ? parseInt(ePart) + 1 : null;
|
||||
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;
|
||||
if (os) {
|
||||
const m = os.match(/[Ss](\d+)[Ee](\d+)/);
|
||||
@@ -97,6 +106,34 @@ function fmtEpisode(season, episode) {
|
||||
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) {
|
||||
channels = []; programs = {};
|
||||
doc.querySelectorAll('channel').forEach(ch => {
|
||||
@@ -115,9 +152,13 @@ function parseAndRender(doc) {
|
||||
const start = parseXMLTVDate(p.getAttribute('start'));
|
||||
const stop = parseXMLTVDate(p.getAttribute('stop'));
|
||||
const title = p.querySelector('title')?.textContent || '';
|
||||
const subtitle = p.querySelector('sub-title')?.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);
|
||||
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));
|
||||
|
||||
@@ -129,4 +170,3 @@ function renderAll() {
|
||||
if (isMobile()) { renderMobile(); }
|
||||
else { renderGrid(); setTimeout(centerNow, 150); }
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
<?php
|
||||
$js_dir = __DIR__ . '/../js/';
|
||||
include $js_dir . 'config.js';
|
||||
$js_modules = ['utils', 'favorites', '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) {
|
||||
echo file_get_contents($js_dir . $mod . '.js');
|
||||
}
|
||||
?>
|
||||
</script>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+19
-867
@@ -1,875 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<html lang="<?= $locale ?>">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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">
|
||||
<style>
|
||||
/* Variables de base — surchargées par le fichier thème */
|
||||
:root {
|
||||
--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;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
/* ── 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;
|
||||
}
|
||||
|
||||
</style>
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="icon" href="/assets/favicon.png" type="image/png">
|
||||
<link rel="apple-touch-icon" href="/assets/apple-touch-icon.png">
|
||||
<meta name="theme-color" content="#0a0b0d">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="GridTV">
|
||||
<link rel="stylesheet" href="/assets/fonts/fonts.css">
|
||||
<?php
|
||||
$css_dir = __DIR__ . '/../css/';
|
||||
$css_files = ['base', 'topbar', 'grid', 'mobile', 'player', 'modals', 'search', 'program', 'favorites', 'updater'];
|
||||
echo "<style>\n";
|
||||
foreach ($css_files as $file) {
|
||||
echo file_get_contents($css_dir . $file . '.css') . "\n";
|
||||
}
|
||||
echo "</style>\n";
|
||||
?>
|
||||
<link id="themeStylesheet" rel="stylesheet" href="themes/default.css">
|
||||
</head>
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
<button class="pm-close" onclick="closeProgramModal()">✕</button>
|
||||
<div class="pm-channel" id="pm-channel"></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-meta" id="pm-meta"></div>
|
||||
<div class="pm-genres" id="pm-genres"></div>
|
||||
<div class="pm-desc" id="pm-desc"></div>
|
||||
<div class="pm-actions">
|
||||
<button class="pm-btn pm-watch" id="pm-watch">▶ <?= htmlspecialchars($L["watch_now"]) ?></button>
|
||||
|
||||
+17
-7
@@ -12,22 +12,33 @@
|
||||
<?php if (!empty($m3u_url)): ?>
|
||||
<button class="copy-btn" onclick="copyUrl('m3u')" title="<?= htmlspecialchars($L["copy_m3u"]) ?>"><?= htmlspecialchars($L["copy_m3u"]) ?></button>
|
||||
<?php endif; ?>
|
||||
<a class="copy-btn" href="/export.php" title="Export 24h">Export</a>
|
||||
</div>
|
||||
<?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">▼</span>
|
||||
</button>
|
||||
<div class="source-dropdown-menu" id="sourceDropdownMenu">
|
||||
<?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 if ($allow_personal_epg): ?>
|
||||
<option value="personal">✏ Personal EPG</option>
|
||||
<button class="source-dropdown-item source-dropdown-personal" onclick="switchSource('personal'); closeSourceDropdown()">
|
||||
✎ Personal EPG
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<select class="theme-select" id="themeSelect" onchange="setTheme(this.value)" title="Thème">
|
||||
<?php
|
||||
$theme_dir = __DIR__ . '/../../themes';
|
||||
$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) {
|
||||
$a_base = basename($a, '.css');
|
||||
$b_base = basename($b, '.css');
|
||||
@@ -38,7 +49,7 @@ usort($theme_files, function($a, $b) {
|
||||
foreach ($theme_files as $file) {
|
||||
$slug = basename($file, '.css');
|
||||
$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('/@emoji\s+(\S+)/u', $css, $m_emoji);
|
||||
$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)" />
|
||||
<button class="search-clear" onclick="clearSearch()">✕</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
});
|
||||
@@ -3,8 +3,6 @@
|
||||
* 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 {
|
||||
--bg: #000008;
|
||||
--surface: #080010;
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* 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 {
|
||||
--bg: #0a0b0d;
|
||||
--surface: #111318;
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Papier jauni, encre rouge et bleue, machine à écrire.
|
||||
*/
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Special+Elite&display=swap');
|
||||
|
||||
:root {
|
||||
--bg: #f2e8c8;
|
||||
--surface: #ede0b0;
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Brun chaud, cuivre, laiton. Très 1880.
|
||||
*/
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=IM+Fell+English:ital@0;1&display=swap');
|
||||
|
||||
:root {
|
||||
--bg: #160e04;
|
||||
--surface: #201408;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"version": "1.6.0"
|
||||
}
|
||||
Reference in New Issue
Block a user