Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afaec19fae | ||
|
|
642e090c32 | ||
|
|
d7b0730ab2 | ||
|
|
851a1fcf5f | ||
|
|
d1e15da33f | ||
|
|
eb9e3028cf | ||
|
|
9b068e7c74 | ||
|
|
9e543365d8 | ||
|
|
e903a0b3d6 | ||
|
|
ef374b2633 | ||
|
|
68f65f84b2 | ||
|
|
bdf3b4797d | ||
|
|
16abfab05b | ||
|
|
ab56bd9c70 | ||
|
|
d560f49dac | ||
|
|
804490ca48 | ||
|
|
721eb67c2f | ||
|
|
411b5bb354 | ||
|
|
3f8aab5e70 | ||
|
|
dde85a4e38 | ||
|
|
214a2b9cc5 | ||
|
|
a81e33ccb8 | ||
|
|
c46ca14f4c | ||
|
|
674c13ae26 | ||
|
|
fc608ee0e0 | ||
|
|
90679e9221 | ||
|
|
bc5c9e296a | ||
|
|
27ef739845 | ||
|
|
77f02361ab | ||
|
|
8dabd2d190 | ||
|
|
062954a780 | ||
|
|
1959734b56 | ||
|
|
de1f5a336c | ||
|
|
8ba8c3013e | ||
|
|
8c81c05bca | ||
|
|
62732ab2c6 | ||
|
|
e81cd8b64d | ||
|
|
30d8a24927 | ||
|
|
17db9f56f7 | ||
|
|
de0e1adf44 | ||
|
|
30c0de2a2d | ||
|
|
77f0c2523b | ||
|
|
73bdde5093 | ||
|
|
005fca9a05 | ||
|
|
f82bc65472 | ||
|
|
ca25d5000b | ||
|
|
96c9835a27 | ||
|
|
b312e8cc26 | ||
|
|
79a077a85f | ||
|
|
2172202d25 | ||
|
|
d7541c0fa4 | ||
|
|
9e4614d8df | ||
|
|
8ca85e778e | ||
|
|
181e477a0e | ||
|
|
8b032f56de | ||
|
|
44f4eeaafe |
@@ -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,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: johnnybegood320 # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -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
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
config.json
|
||||
.demo
|
||||
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
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
|
||||
A public demo instance is available here:
|
||||
|
||||
👉 https://guide.demo.johnnybegood.fr/
|
||||
- Guide: [guide.demo.johnnybegood.fr](https://guide.demo.johnnybegood.fr/)
|
||||
- Health: [guide.demo.johnnybegood.fr/health.php](https://guide.demo.johnnybegood.fr/health.php)
|
||||
- Setup (read-only on demo): [guide.demo.johnnybegood.fr/setup.php](https://guide.demo.johnnybegood.fr/setup.php)
|
||||
|
||||
This demo runs with sample XMLTV feeds to showcase the interface.
|
||||
|
||||
@@ -33,17 +35,24 @@ This demo runs with sample XMLTV feeds to showcase the interface.
|
||||
- 🕶️ **Past programs** automatically dimmed
|
||||
- 📋 **Hover tooltip** — title, synopsis, season/episode, schedule, duration
|
||||
- 🎬 **Program popup** — click any program for full details + IMDb search link
|
||||
- 🔔 **Program reminders** — save a browser reminder from the program popup and get notified 15 minutes before airing
|
||||
- 🔗 **One-click copy** buttons for EPG & M3U URLs in the topbar
|
||||
- ▶️ **Built-in HLS player** — click any channel or live program to watch in a PiP overlay
|
||||
- 🔀 **HTTP→HTTPS proxy** — streams Tunarr over HTTP transparently from an HTTPS page
|
||||
- 🎨 **Theme system** — drop a CSS file in `themes/` and it appears in the menu automatically
|
||||
- 📡 **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)
|
||||
- 🗂️ **Category filter** — narrow the guide to movies, sports, news, kids content, etc.
|
||||
- 🔍 **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, with browser-side admin key memory for convenience
|
||||
- 🩺 **Admin health page** — check config, XMLTV, M3U, response times, redirects, payload size, and metadata coverage/quality
|
||||
- 🖨️ **24h printable export** — generate a polished daily schedule for PDF/print and PNG export
|
||||
- 🧪 **Demo mode** — drop a `.demo` file at the project root to expose admin pages safely in read-only mode
|
||||
- 🔔 **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 +60,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 +77,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 +105,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 +144,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 +172,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`.
|
||||
@@ -161,9 +231,22 @@ GridTV detects the missing config and automatically redirects you to the setup p
|
||||
| **Personal EPG** | Toggle to allow visitors to use your instance with their own EPG/M3U |
|
||||
|
||||
Once submitted, `config.json` is created on the server. **The setup page becomes inaccessible until you re-enter your admin key.**
|
||||
On the same browser, GridTV also remembers the admin key in `localStorage` so you do not need to type it again on every visit.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
`health.php` is admin-only by default. `export.php` is public so visitors can print or save the daily guide.
|
||||
|
||||
---
|
||||
|
||||
<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 +255,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
|
||||
@@ -193,7 +278,8 @@ nano /var/www/gridtv/config.json
|
||||
"m3u_url": ""
|
||||
}
|
||||
],
|
||||
"allow_personal_epg": true
|
||||
"allow_personal_epg": true,
|
||||
"allow_personal_m3u": true
|
||||
}
|
||||
```
|
||||
|
||||
@@ -201,17 +287,124 @@ nano /var/www/gridtv/config.json
|
||||
|
||||
---
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Health page</summary>
|
||||
|
||||
## 🩺 Health page
|
||||
|
||||
The health page gives you a quick operational view of your instance without needing SSH first:
|
||||
|
||||
- checks whether `config.json` exists and is writable by PHP
|
||||
- shows the current PHP version and whether `php-curl` / `php-xml` are loaded
|
||||
- tests each configured XMLTV source
|
||||
- tests each configured M3U playlist when present
|
||||
- shows response times, final URL after redirects, content type, and payload size
|
||||
- highlights XMLTV coverage for things like `category`, `sub-title`, `rating`, `date`, `desc`, logos, and stop times
|
||||
- gives you a rough XMLTV quality score per source
|
||||
- shows M3U stream counts, HTTP vs HTTPS split, and duplicate stream URLs
|
||||
|
||||
By default, the page is protected by the same admin key as `setup.php`:
|
||||
|
||||
```text
|
||||
http://guide.your-domain.com/health.php
|
||||
```
|
||||
|
||||
If you enable demo mode with a `.demo` file, visitors can open the page without the key.
|
||||
|
||||
For monitoring tools such as Uptime Kuma, GridTV also exposes a lightweight probe endpoint:
|
||||
|
||||
```text
|
||||
http://guide.your-domain.com/health.php?format=probe
|
||||
```
|
||||
|
||||
- returns HTTP `200` when the instance looks healthy
|
||||
- returns HTTP `503` when config, PHP extensions, or the first XMLTV source fail
|
||||
- returns compact JSON suitable for external monitoring
|
||||
|
||||
If you prefer a plain text response:
|
||||
|
||||
```text
|
||||
http://guide.your-domain.com/health.php?format=probe&plain=1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>24h export</summary>
|
||||
|
||||
## 🖨️ 24h export
|
||||
|
||||
The export page builds a printable 24-hour TV guide from the current XMLTV source:
|
||||
|
||||
- filter by source
|
||||
- choose the target date
|
||||
- restrict the export to a time window (`00h-24h`, morning, afternoon, evening, prime time)
|
||||
- limit the number of visible channels
|
||||
- choose between a readable card layout or a dense timeline
|
||||
- export to PDF via the browser print dialog
|
||||
- export to PNG directly from the page
|
||||
|
||||
Public URL:
|
||||
|
||||
```text
|
||||
http://guide.your-domain.com/export.php
|
||||
```
|
||||
|
||||
This page is intentionally public so you can share a “TV guide sheet” with other people.
|
||||
|
||||
---
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Demo mode</summary>
|
||||
|
||||
## 🧪 Demo mode
|
||||
|
||||
If you want to expose your instance publicly without letting visitors modify it, create an empty `.demo` file at the project root:
|
||||
|
||||
```bash
|
||||
touch /var/www/gridtv/.demo
|
||||
```
|
||||
|
||||
When `.demo` exists:
|
||||
|
||||
- `setup.php` becomes read-only
|
||||
- `health.php` can be viewed without entering the admin key
|
||||
- visitors can inspect the current configuration safely
|
||||
|
||||
To disable demo mode:
|
||||
|
||||
```bash
|
||||
rm /var/www/gridtv/.demo
|
||||
```
|
||||
|
||||
> `.demo` is ignored by Git, just like `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
|
||||
├── .gitignore # local instance files excluded
|
||||
├── locales/ # i18n translation files
|
||||
│ ├── en.json
|
||||
│ ├── fr.json
|
||||
@@ -223,13 +416,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 # Program reminder modal + 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
|
||||
@@ -239,8 +443,11 @@ gridtv/
|
||||
├── m3u.js # M3U parser
|
||||
├── player.js # HLS PiP player
|
||||
├── themes.js # Theme switcher
|
||||
├── search.js # Search + filter (debounced)
|
||||
├── search.js # Search + category filter
|
||||
├── reminders.js # Program reminders (localStorage + browser notifications)
|
||||
├── program.js # Program info modal + IMDb link
|
||||
├── favorites.js # Favorites (localStorage)
|
||||
├── updater.js # GitHub update checker
|
||||
└── live.js # Live updates + responsive
|
||||
```
|
||||
|
||||
@@ -248,6 +455,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 +480,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 +493,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 +506,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 +523,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 +540,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 +560,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 +597,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 |
+538
@@ -0,0 +1,538 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/src/lib/admin.php';
|
||||
|
||||
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');
|
||||
$channel_icons = $xp->query('/tv/channel/icon');
|
||||
|
||||
$first = null;
|
||||
$last = null;
|
||||
$with_desc = 0;
|
||||
$with_subtitle = 0;
|
||||
$with_category = 0;
|
||||
$with_rating = 0;
|
||||
$with_date = 0;
|
||||
$with_stop = 0;
|
||||
$unique_categories = [];
|
||||
|
||||
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;
|
||||
if ($stop !== '') $with_stop++;
|
||||
if (trim($xp->evaluate('string(desc)', $programme)) !== '') $with_desc++;
|
||||
if (trim($xp->evaluate('string(sub-title)', $programme)) !== '') $with_subtitle++;
|
||||
if (trim($xp->evaluate('string(category)', $programme)) !== '') $with_category++;
|
||||
if (trim($xp->evaluate('string(rating/value)', $programme)) !== '') $with_rating++;
|
||||
if (trim($xp->evaluate('string(date)', $programme)) !== '') $with_date++;
|
||||
foreach ($xp->query('category', $programme) as $category) {
|
||||
$value = trim($category->textContent);
|
||||
if ($value !== '') $unique_categories[strtolower($value)] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$channel_count = $channels->length;
|
||||
$programme_count = $programmes->length;
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'channels' => $channel_count,
|
||||
'programmes' => $programme_count,
|
||||
'categories' => $categories->length,
|
||||
'subtitles' => $subtitles->length,
|
||||
'ratings' => $ratings->length,
|
||||
'dates' => $dates->length,
|
||||
'channel_icons' => $channel_icons->length,
|
||||
'channels_without_icon' => max(0, $channel_count - $channel_icons->length),
|
||||
'programmes_with_desc' => $with_desc,
|
||||
'programmes_with_subtitle' => $with_subtitle,
|
||||
'programmes_with_category' => $with_category,
|
||||
'programmes_with_rating' => $with_rating,
|
||||
'programmes_with_date' => $with_date,
|
||||
'programmes_with_stop' => $with_stop,
|
||||
'unique_categories' => count($unique_categories),
|
||||
'avg_programmes_per_channel' => $channel_count > 0 ? round($programme_count / $channel_count, 1) : 0,
|
||||
'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;
|
||||
$http = 0;
|
||||
$https = 0;
|
||||
$duplicates = 0;
|
||||
$seen_urls = [];
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '') continue;
|
||||
if (stripos($line, '#EXTINF') === 0) {
|
||||
$extinf++;
|
||||
continue;
|
||||
}
|
||||
if ($line[0] !== '#') {
|
||||
$streams++;
|
||||
if (stripos($line, 'https://') === 0) $https++;
|
||||
if (stripos($line, 'http://') === 0) $http++;
|
||||
if (isset($seen_urls[$line])) $duplicates++;
|
||||
$seen_urls[$line] = true;
|
||||
}
|
||||
}
|
||||
return [
|
||||
'streams' => $streams,
|
||||
'extinf' => $extinf,
|
||||
'http_streams' => $http,
|
||||
'https_streams' => $https,
|
||||
'duplicate_urls' => $duplicates,
|
||||
'size_bytes' => strlen($body),
|
||||
];
|
||||
}
|
||||
|
||||
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_probe_payload(): array {
|
||||
$config_path = gridtv_config_path();
|
||||
$config_exists = file_exists($config_path);
|
||||
$config_valid = false;
|
||||
$config = [];
|
||||
|
||||
if ($config_exists) {
|
||||
$decoded = json_decode(file_get_contents($config_path), true);
|
||||
if (is_array($decoded)) {
|
||||
$config = $decoded;
|
||||
$config_valid = true;
|
||||
}
|
||||
}
|
||||
|
||||
$sources = $config_valid ? array_values($config['epg_sources'] ?? []) : [];
|
||||
$first_source = $sources[0] ?? null;
|
||||
|
||||
$checks = [
|
||||
'config_exists' => $config_exists,
|
||||
'config_valid' => $config_valid,
|
||||
'php_curl' => extension_loaded('curl'),
|
||||
'php_xml' => class_exists('DOMDocument'),
|
||||
'source_configured' => !empty($sources),
|
||||
];
|
||||
|
||||
$details = [
|
||||
'group_name' => $config['group_name'] ?? 'GridTV',
|
||||
'source_count' => count($sources),
|
||||
];
|
||||
|
||||
if ($first_source && !empty($first_source['epg_url'])) {
|
||||
$fetch = gridtv_fetch_url((string) $first_source['epg_url'], 12);
|
||||
$checks['xmltv_reachable'] = $fetch['ok'];
|
||||
$details['xmltv_status'] = $fetch['status'];
|
||||
$details['xmltv_time_total'] = round((float) $fetch['time_total'], 3);
|
||||
if ($fetch['ok']) {
|
||||
$stats = health_parse_xmltv($fetch['body']);
|
||||
$checks['xmltv_valid'] = !empty($stats['ok']);
|
||||
if (!empty($stats['ok'])) {
|
||||
$details['xmltv_channels'] = $stats['channels'];
|
||||
$details['xmltv_programmes'] = $stats['programmes'];
|
||||
} else {
|
||||
$details['xmltv_error'] = $stats['error'] ?? 'Invalid XMLTV';
|
||||
}
|
||||
} else {
|
||||
$details['xmltv_error'] = $fetch['error'] ?: ('HTTP ' . $fetch['status']);
|
||||
}
|
||||
} else {
|
||||
$checks['xmltv_reachable'] = false;
|
||||
$details['xmltv_error'] = 'No XMLTV source configured';
|
||||
}
|
||||
|
||||
$ok = !in_array(false, $checks, true);
|
||||
|
||||
return [
|
||||
'ok' => $ok,
|
||||
'status' => $ok ? 'ok' : 'degraded',
|
||||
'timestamp' => gmdate('c'),
|
||||
'checks' => $checks,
|
||||
'details' => $details,
|
||||
];
|
||||
}
|
||||
|
||||
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']];
|
||||
$epg_fetch_public = $epg_fetch;
|
||||
$epg_fetch_public['body_bytes'] = strlen((string) ($epg_fetch_public['body'] ?? ''));
|
||||
unset($epg_fetch_public['body']);
|
||||
|
||||
$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;
|
||||
}
|
||||
$m3u_fetch_public = $m3u_fetch;
|
||||
if (is_array($m3u_fetch_public)) {
|
||||
$m3u_fetch_public['body_bytes'] = strlen((string) ($m3u_fetch_public['body'] ?? ''));
|
||||
unset($m3u_fetch_public['body']);
|
||||
}
|
||||
|
||||
if (!empty($epg_stats['ok'])) {
|
||||
$epg_stats['first_start_local'] = health_format_xmltv_stamp($epg_stats['first_start'] ?? null);
|
||||
$epg_stats['last_stop_local'] = health_format_xmltv_stamp($epg_stats['last_stop'] ?? 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_public,
|
||||
'epg_stats' => $epg_stats,
|
||||
'm3u_fetch' => $m3u_fetch_public,
|
||||
'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'] === 'probe') {
|
||||
$payload = health_probe_payload();
|
||||
http_response_code($payload['ok'] ? 200 : 503);
|
||||
if (isset($_GET['plain']) && $_GET['plain'] === '1') {
|
||||
header('Content-Type: text/plain; charset=UTF-8');
|
||||
echo $payload['ok'] ? 'OK' : 'KO';
|
||||
} else {
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
echo json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
$config = gridtv_load_config();
|
||||
gridtv_require_admin($config, 'Health');
|
||||
[$locale, $L] = gridtv_load_locale($config);
|
||||
|
||||
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)}
|
||||
.probe-box{margin-bottom:20px;padding:18px 20px;background:rgba(19,22,27,.95);border:1px solid var(--border)}
|
||||
.probe-box h2{margin:0 0 10px;font-size:18px;letter-spacing:.06em;text-transform:uppercase}
|
||||
.probe-box p{margin:0 0 12px;color:var(--muted);font-size:13px;line-height:1.5}
|
||||
.probe-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px}
|
||||
.probe-item{padding:12px;background:var(--surface2);border:1px solid var(--border)}
|
||||
.probe-item .eyebrow{margin-bottom:6px}
|
||||
.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>
|
||||
|
||||
<section class="probe-box">
|
||||
<h2>Monitoring probe</h2>
|
||||
<p>Use these public endpoints with Uptime Kuma or any HTTP monitoring tool. They return <code>200</code> when the instance is healthy and <code>503</code> when a critical check fails.</p>
|
||||
<div class="probe-grid">
|
||||
<div class="probe-item">
|
||||
<div class="eyebrow">JSON probe</div>
|
||||
<div class="mono"><?= htmlspecialchars(strtok($_SERVER['REQUEST_URI'] ?? '/health.php', '?')) ?>?format=probe</div>
|
||||
</div>
|
||||
<div class="probe-item">
|
||||
<div class="eyebrow">Plain text probe</div>
|
||||
<div class="mono"><?= htmlspecialchars(strtok($_SERVER['REQUEST_URI'] ?? '/health.php', '?')) ?>?format=probe&plain=1</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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) {
|
||||
function percent(part, total) {
|
||||
if (!total) return '0%';
|
||||
return Math.round((part / total) * 100) + '%';
|
||||
}
|
||||
|
||||
function qualityScore(stats) {
|
||||
if (!stats || !stats.programmes) return 0;
|
||||
const values = [
|
||||
stats.programmes_with_desc / stats.programmes,
|
||||
stats.programmes_with_category / stats.programmes,
|
||||
stats.programmes_with_subtitle / stats.programmes,
|
||||
stats.programmes_with_rating / stats.programmes
|
||||
];
|
||||
return Math.round((values.reduce((sum, value) => sum + value, 0) / values.length) * 100);
|
||||
}
|
||||
|
||||
function qualityLabel(score) {
|
||||
if (score >= 85) return 'Excellent';
|
||||
if (score >= 70) return 'Good';
|
||||
if (score >= 50) return 'Partial';
|
||||
return 'Poor';
|
||||
}
|
||||
|
||||
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 score = epgHealthy ? qualityScore(report.epg_stats) : 0;
|
||||
const epgRows = epgHealthy ? `
|
||||
<div>Channels</div><div>${report.epg_stats.channels}</div>
|
||||
<div>Programmes</div><div>${report.epg_stats.programmes}</div>
|
||||
<div>Channel logos</div><div>${report.epg_stats.channel_icons} (${report.epg_stats.channels_without_icon} missing)</div>
|
||||
<div>Descriptions</div><div>${report.epg_stats.programmes_with_desc} (${percent(report.epg_stats.programmes_with_desc, report.epg_stats.programmes)})</div>
|
||||
<div>Categories coverage</div><div>${report.epg_stats.programmes_with_category} (${percent(report.epg_stats.programmes_with_category, report.epg_stats.programmes)})</div>
|
||||
<div>Subtitle coverage</div><div>${report.epg_stats.programmes_with_subtitle} (${percent(report.epg_stats.programmes_with_subtitle, report.epg_stats.programmes)})</div>
|
||||
<div>Rating coverage</div><div>${report.epg_stats.programmes_with_rating} (${percent(report.epg_stats.programmes_with_rating, report.epg_stats.programmes)})</div>
|
||||
<div>Date coverage</div><div>${report.epg_stats.programmes_with_date} (${percent(report.epg_stats.programmes_with_date, report.epg_stats.programmes)})</div>
|
||||
<div>Stop time coverage</div><div>${report.epg_stats.programmes_with_stop} (${percent(report.epg_stats.programmes_with_stop, report.epg_stats.programmes)})</div>
|
||||
<div>Unique categories</div><div>${report.epg_stats.unique_categories}</div>
|
||||
<div>Programmes / channel</div><div>${report.epg_stats.avg_programmes_per_channel}</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>Window (local)</div><div>${report.epg_stats.first_start_local || '—'} → ${report.epg_stats.last_stop_local || '—'}</div>
|
||||
<div>Content-Type</div><div>${report.epg_fetch.content_type || '—'}</div>
|
||||
<div>Final URL</div><div class="mono">${report.epg_fetch.final_url || report.epg_url}</div>
|
||||
<div>Payload size</div><div>${report.epg_fetch.headers['content-length'] || report.epg_fetch.body_bytes || '—'} bytes</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>HTTPS streams</div><div>${report.m3u_stats.https_streams}</div>
|
||||
<div>HTTP streams</div><div>${report.m3u_stats.http_streams}</div>
|
||||
<div>Duplicate URLs</div><div>${report.m3u_stats.duplicate_urls}</div>
|
||||
<div>Payload size</div><div>${report.m3u_fetch.headers['content-length'] || report.m3u_fetch.body_bytes || report.m3u_stats.size_bytes} bytes</div>
|
||||
<div>Content-Type</div><div>${report.m3u_fetch.content_type || '—'}</div>
|
||||
<div>Final URL</div><div class="mono">${report.m3u_fetch.final_url || report.m3u_url}</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 ? `${qualityLabel(score)} XMLTV · ${score}%` : '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>Quality score</div><div>${epgHealthy ? `${score}% · ${qualityLabel(score)}` : '—'}</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>
|
||||
+24
-2
@@ -32,10 +32,28 @@
|
||||
"search_placeholder": "Search channel or program...",
|
||||
"copy_epg": "EPG",
|
||||
"copy_m3u": "M3U",
|
||||
"category_filter_title": "Filter by category",
|
||||
"category_all": "All categories",
|
||||
"copied": "✓ copied",
|
||||
"program_info": "Program info",
|
||||
"watch_now": "Watch now",
|
||||
"imdb_search": "Search on IMDb",
|
||||
"reminder_button": "Reminder",
|
||||
"reminder_title": "Notify me before broadcast",
|
||||
"reminder_save": "Enable reminder",
|
||||
"reminder_remove": "Remove reminder",
|
||||
"reminder_when": "A browser notification will be sent {minutes} minutes before broadcast.",
|
||||
"reminder_when_soon": "This program starts soon. The reminder will fire almost immediately.",
|
||||
"reminder_status_idle": "No reminder active.",
|
||||
"reminder_status_set": "Reminder active for {time}.",
|
||||
"reminder_status_started": "This program is already airing.",
|
||||
"reminder_saved": "Reminder enabled.",
|
||||
"reminder_removed": "Reminder removed.",
|
||||
"reminder_error_unsupported": "This browser does not support notifications.",
|
||||
"reminder_error_denied": "Notifications are blocked in this browser.",
|
||||
"reminder_error_generic": "Unable to enable reminder.",
|
||||
"reminder_notification_title": "GridTV reminder",
|
||||
"reminder_notification_body": "\"{title}\" starts at {time} on {channel}.",
|
||||
"close": "Close",
|
||||
"duration_min": "min",
|
||||
"personal_epg_title": "Personal EPG",
|
||||
@@ -43,11 +61,15 @@
|
||||
"personal_epg_label": "EPG URL (XMLTV) *",
|
||||
"personal_epg_m3u_label": "M3U URL",
|
||||
"personal_epg_optional": "optional",
|
||||
"personal_epg_m3u_enabled": "This instance allows a personal M3U playlist.",
|
||||
"personal_epg_m3u_disabled": "The administrator of this instance has disabled personal M3U playlists.",
|
||||
"cancel": "Cancel",
|
||||
"apply": "Apply",
|
||||
"setup_already_done": "Configuration already done. Use your admin key to access setup again.",
|
||||
"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"
|
||||
}
|
||||
|
||||
+24
-2
@@ -32,10 +32,28 @@
|
||||
"search_placeholder": "Buscar canal o programa...",
|
||||
"copy_epg": "EPG",
|
||||
"copy_m3u": "M3U",
|
||||
"category_filter_title": "Filtrar por categoría",
|
||||
"category_all": "Todas las categorías",
|
||||
"copied": "✓ copiado",
|
||||
"program_info": "Info del programa",
|
||||
"watch_now": "Ver ahora",
|
||||
"imdb_search": "Buscar en IMDb",
|
||||
"reminder_button": "Recordatorio",
|
||||
"reminder_title": "Avísame antes de la emisión",
|
||||
"reminder_save": "Activar recordatorio",
|
||||
"reminder_remove": "Eliminar recordatorio",
|
||||
"reminder_when": "Se enviará una notificación del navegador {minutes} minutos antes de la emisión.",
|
||||
"reminder_when_soon": "Este programa empieza pronto. El recordatorio saldrá casi de inmediato.",
|
||||
"reminder_status_idle": "No hay recordatorio activo.",
|
||||
"reminder_status_set": "Recordatorio activo para las {time}.",
|
||||
"reminder_status_started": "Este programa ya está en emisión.",
|
||||
"reminder_saved": "Recordatorio activado.",
|
||||
"reminder_removed": "Recordatorio eliminado.",
|
||||
"reminder_error_unsupported": "Este navegador no admite notificaciones.",
|
||||
"reminder_error_denied": "Las notificaciones están bloqueadas en este navegador.",
|
||||
"reminder_error_generic": "No se puede activar el recordatorio.",
|
||||
"reminder_notification_title": "Recordatorio GridTV",
|
||||
"reminder_notification_body": "\"{title}\" empieza a las {time} en {channel}.",
|
||||
"close": "Cerrar",
|
||||
"duration_min": "min",
|
||||
"personal_epg_title": "EPG Personal",
|
||||
@@ -43,11 +61,15 @@
|
||||
"personal_epg_label": "URL EPG (XMLTV) *",
|
||||
"personal_epg_m3u_label": "URL M3U",
|
||||
"personal_epg_optional": "opcional",
|
||||
"personal_epg_m3u_enabled": "Esta instancia permite una lista M3U personal.",
|
||||
"personal_epg_m3u_disabled": "El administrador de esta instancia ha desactivado las listas M3U personales.",
|
||||
"cancel": "Cancelar",
|
||||
"apply": "Aplicar",
|
||||
"setup_already_done": "Configuración ya realizada. Usa tu clave de administrador para volver al setup.",
|
||||
"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"
|
||||
}
|
||||
|
||||
+24
-2
@@ -32,10 +32,28 @@
|
||||
"search_placeholder": "Rechercher une chaîne ou un programme...",
|
||||
"copy_epg": "EPG",
|
||||
"copy_m3u": "M3U",
|
||||
"category_filter_title": "Filtrer par catégorie",
|
||||
"category_all": "Toutes catégories",
|
||||
"copied": "✓ copié",
|
||||
"program_info": "Infos programme",
|
||||
"watch_now": "Regarder",
|
||||
"imdb_search": "Rechercher sur IMDb",
|
||||
"reminder_button": "Rappel",
|
||||
"reminder_title": "Préviens-moi avant la diffusion",
|
||||
"reminder_save": "Activer le rappel",
|
||||
"reminder_remove": "Supprimer le rappel",
|
||||
"reminder_when": "Une notification navigateur sera envoyée {minutes} minutes avant la diffusion.",
|
||||
"reminder_when_soon": "Ce programme commence bientôt. Le rappel partira presque immédiatement.",
|
||||
"reminder_status_idle": "Aucun rappel actif.",
|
||||
"reminder_status_set": "Rappel actif pour {time}.",
|
||||
"reminder_status_started": "Ce programme est déjà en cours.",
|
||||
"reminder_saved": "Rappel activé.",
|
||||
"reminder_removed": "Rappel supprimé.",
|
||||
"reminder_error_unsupported": "Ce navigateur ne gère pas les notifications.",
|
||||
"reminder_error_denied": "Les notifications sont bloquées dans ce navigateur.",
|
||||
"reminder_error_generic": "Impossible d'activer le rappel.",
|
||||
"reminder_notification_title": "Rappel GridTV",
|
||||
"reminder_notification_body": "“{title}” commence à {time} sur {channel}.",
|
||||
"close": "Fermer",
|
||||
"duration_min": "min",
|
||||
"personal_epg_title": "EPG Personnel",
|
||||
@@ -43,11 +61,15 @@
|
||||
"personal_epg_label": "URL EPG (XMLTV) *",
|
||||
"personal_epg_m3u_label": "URL M3U",
|
||||
"personal_epg_optional": "optionnel",
|
||||
"personal_epg_m3u_enabled": "Cette instance autorise l'ajout d'une playlist M3U personnelle.",
|
||||
"personal_epg_m3u_disabled": "L'administrateur de cette instance a désactivé l'ajout d'une playlist M3U personnelle.",
|
||||
"cancel": "Annuler",
|
||||
"apply": "Appliquer",
|
||||
"setup_already_done": "Configuration déjà effectuée. Utilisez votre clé admin pour accéder à nouveau au setup.",
|
||||
"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;
|
||||
$path = parse_url($url, PHP_URL_PATH) ?? '';
|
||||
// ── 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);
|
||||
$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,28 +1,40 @@
|
||||
<?php
|
||||
$config_path = __DIR__ . '/config.json';
|
||||
$demo_mode = file_exists(__DIR__ . '/.demo');
|
||||
|
||||
// Migration automatique ancien format
|
||||
// Automatically migrate the legacy config format.
|
||||
if (file_exists($config_path)) {
|
||||
$existing = json_decode(file_get_contents($config_path), true);
|
||||
if (isset($existing['epg_url']) && !isset($existing['epg_sources'])) {
|
||||
$existing['epg_sources'] = [[
|
||||
'name' => 'Main',
|
||||
'epg_url' => $existing['epg_url'],
|
||||
'm3u_url' => $existing['m3u_url'] ?? '',
|
||||
]];
|
||||
$existing['allow_personal_epg'] = false;
|
||||
unset($existing['epg_url'], $existing['m3u_url']);
|
||||
file_put_contents($config_path, json_encode($existing, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
// Bootstrap : config existante sans cle admin -> on en genere une maintenant
|
||||
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));
|
||||
if (is_array($existing)) {
|
||||
if (isset($existing['epg_url']) && !isset($existing['epg_sources'])) {
|
||||
$existing['epg_sources'] = [[
|
||||
'name' => 'Main',
|
||||
'epg_url' => $existing['epg_url'],
|
||||
'm3u_url' => $existing['m3u_url'] ?? '',
|
||||
]];
|
||||
$existing['allow_personal_epg'] = false;
|
||||
unset($existing['epg_url'], $existing['m3u_url']);
|
||||
file_put_contents($config_path, json_encode($existing, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$is_first_setup = !file_exists($config_path);
|
||||
$existing = $is_first_setup ? [] : json_decode(file_get_contents($config_path), true);
|
||||
$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;
|
||||
|
||||
$mode = 'setup';
|
||||
@@ -30,7 +42,9 @@ $error = '';
|
||||
$new_key = '';
|
||||
|
||||
if (!$is_first_setup) {
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['admin_key_input'])) {
|
||||
if ($demo_mode) {
|
||||
$mode = 'setup';
|
||||
} elseif ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['admin_key_input'])) {
|
||||
if (trim($_POST['admin_key_input']) === $admin_key) {
|
||||
$mode = 'setup';
|
||||
} else {
|
||||
@@ -50,8 +64,12 @@ if (!$is_first_setup) {
|
||||
}
|
||||
|
||||
if ($mode === 'setup' && $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['group_name'])) {
|
||||
if ($demo_mode) {
|
||||
$error = 'Demo mode is enabled. Configuration is read-only on this instance.';
|
||||
} else {
|
||||
$group_name = trim($_POST['group_name'] ?? '');
|
||||
$allow_personal_epg = isset($_POST['allow_personal_epg']);
|
||||
$allow_personal_m3u = $allow_personal_epg && isset($_POST['allow_personal_m3u']);
|
||||
|
||||
$names = $_POST['source_name'] ?? [];
|
||||
$epg_urls = $_POST['source_epg_url'] ?? [];
|
||||
@@ -86,6 +104,7 @@ if ($mode === 'setup' && $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['
|
||||
'group_name' => $group_name,
|
||||
'epg_sources' => $epg_sources,
|
||||
'allow_personal_epg' => $allow_personal_epg,
|
||||
'allow_personal_m3u' => $allow_personal_m3u,
|
||||
'admin_key' => $new_key,
|
||||
];
|
||||
$written = file_put_contents($config_path, json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
@@ -96,11 +115,15 @@ if ($mode === 'setup' && $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$prefill_group = $_POST['group_name'] ?? $existing['group_name'] ?? '';
|
||||
$prefill_sources = $existing['epg_sources'] ?? [];
|
||||
$prefill_epg = !empty($existing['allow_personal_epg']);
|
||||
$prefill_personal_m3u = array_key_exists('allow_personal_m3u', $existing)
|
||||
? !empty($existing['allow_personal_m3u'])
|
||||
: $prefill_epg;
|
||||
$submitted_key = trim($_POST['admin_key_input'] ?? $_POST['admin_key_hidden'] ?? '');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
@@ -109,7 +132,7 @@ $submitted_key = trim($_POST['admin_key_input'] ?? $_POST['admin_key_hidden']
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GridTV — Setup</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Barlow+Condensed:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/assets/fonts/fonts.css">
|
||||
<style>
|
||||
:root {
|
||||
--bg:#0a0b0d;--surface:#111318;--surface2:#181b22;
|
||||
@@ -171,12 +194,19 @@ $submitted_key = trim($_POST['admin_key_input'] ?? $_POST['admin_key_hidden']
|
||||
.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;}
|
||||
.demo-banner{padding:12px 14px;font-size:13px;margin-bottom:24px;line-height:1.5;border-left:3px solid var(--accent2);background:rgba(74,158,255,.08);color:var(--text-bright);}
|
||||
.readonly-wrap{opacity:.82;}
|
||||
.readonly-hint{margin-top:12px;font-size:12px;color:var(--text-dim);line-height:1.5;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="setup-card">
|
||||
<div class="setup-logo">Grid<span>/</span>TV</div>
|
||||
|
||||
<?php if ($demo_mode): ?>
|
||||
<div class="demo-banner">Demo mode is enabled on this instance. Visitors can view the current configuration, but saving changes is disabled.</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?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; ?>
|
||||
@@ -186,7 +216,7 @@ $submitted_key = trim($_POST['admin_key_input'] ?? $_POST['admin_key_hidden']
|
||||
<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">
|
||||
<input type="password" id="setupAdminKeyInput" 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>
|
||||
@@ -208,7 +238,7 @@ $submitted_key = trim($_POST['admin_key_input'] ?? $_POST['admin_key_hidden']
|
||||
|
||||
<?php else: ?>
|
||||
<div class="setup-subtitle">
|
||||
<?= $is_first_setup ? 'Welcome to GridTV. Fill in the fields below to get started.' : 'Edit your GridTV configuration.' ?>
|
||||
<?= $is_first_setup ? 'Welcome to GridTV. Fill in the fields below to get started.' : ($demo_mode ? 'View the current GridTV configuration.' : 'Edit your GridTV configuration.') ?>
|
||||
</div>
|
||||
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
||||
|
||||
@@ -216,6 +246,7 @@ $submitted_key = trim($_POST['admin_key_input'] ?? $_POST['admin_key_hidden']
|
||||
<?php if (!$is_first_setup): ?>
|
||||
<input type="hidden" name="admin_key_hidden" value="<?= htmlspecialchars($submitted_key ?: $admin_key) ?>">
|
||||
<?php endif; ?>
|
||||
<?php if ($demo_mode): ?><div class="readonly-wrap"><fieldset disabled style="border:none;padding:0;margin:0"><?php endif; ?>
|
||||
|
||||
<div class="form-group">
|
||||
<label>TV Group Name <span class="required">*</span></label>
|
||||
@@ -263,7 +294,21 @@ $submitted_key = trim($_POST['admin_key_input'] ?? $_POST['admin_key_hidden']
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-label">
|
||||
Allow personal M3U
|
||||
<small>Lets visitors add their own stream playlist in the Personal EPG modal.</small>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="allow_personal_m3u" id="allowPersonalM3u"<?= $prefill_personal_m3u ? ' checked' : '' ?><?= !$prefill_epg ? ' disabled' : '' ?>>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<?php if (!$demo_mode): ?>
|
||||
<button type="submit" class="btn-submit">Save configuration</button>
|
||||
<?php endif; ?>
|
||||
<?php if ($demo_mode): ?></fieldset></div><div class="readonly-hint">Saving is disabled in demo mode. Remove the <code>.demo</code> file on the server to restore normal editing.</div><?php endif; ?>
|
||||
</form>
|
||||
|
||||
<?php endif; ?>
|
||||
@@ -295,6 +340,50 @@ function addSource() {
|
||||
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'); }
|
||||
const allowPersonalEpgToggle = document.getElementById('allowPersonalEpg');
|
||||
const allowPersonalM3uToggle = document.getElementById('allowPersonalM3u');
|
||||
function syncPersonalM3uToggle() {
|
||||
if (!allowPersonalEpgToggle || !allowPersonalM3uToggle) return;
|
||||
allowPersonalM3uToggle.disabled = !allowPersonalEpgToggle.checked;
|
||||
if (!allowPersonalEpgToggle.checked) allowPersonalM3uToggle.checked = false;
|
||||
}
|
||||
allowPersonalEpgToggle?.addEventListener('change', syncPersonalM3uToggle);
|
||||
syncPersonalM3uToggle();
|
||||
|
||||
const GRIDTV_ADMIN_KEY_STORAGE = 'gridtv-admin-key';
|
||||
const setupAdminKeyInput = document.getElementById('setupAdminKeyInput');
|
||||
const setupAdminHiddenInput = document.querySelector('input[name="admin_key_hidden"]');
|
||||
|
||||
try {
|
||||
const savedAdminKey = localStorage.getItem(GRIDTV_ADMIN_KEY_STORAGE) || '';
|
||||
if (setupAdminKeyInput && savedAdminKey) setupAdminKeyInput.value = savedAdminKey;
|
||||
if (setupAdminHiddenInput && savedAdminKey) setupAdminHiddenInput.value = savedAdminKey;
|
||||
} catch (_) {}
|
||||
|
||||
if (setupAdminKeyInput?.form) {
|
||||
setupAdminKeyInput.form.addEventListener('submit', () => {
|
||||
try {
|
||||
localStorage.setItem(GRIDTV_ADMIN_KEY_STORAGE, setupAdminKeyInput.value.trim());
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
if (setupAdminHiddenInput?.form) {
|
||||
const persistHiddenAdminKey = () => {
|
||||
try {
|
||||
const currentKey = setupAdminHiddenInput.value.trim();
|
||||
if (currentKey) localStorage.setItem(GRIDTV_ADMIN_KEY_STORAGE, currentKey);
|
||||
} catch (_) {}
|
||||
};
|
||||
persistHiddenAdminKey();
|
||||
setupAdminHiddenInput.form.addEventListener('submit', persistHiddenAdminKey);
|
||||
}
|
||||
|
||||
<?php if ($mode === 'success' && !empty($new_key)): ?>
|
||||
try {
|
||||
localStorage.setItem(GRIDTV_ADMIN_KEY_STORAGE, <?= json_encode($new_key) ?>);
|
||||
} catch (_) {}
|
||||
<?php endif; ?>
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+18
-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',
|
||||
@@ -25,22 +29,32 @@ if (isset($config['epg_url']) && !isset($config['epg_sources'])) {
|
||||
$group_name = htmlspecialchars($config['group_name'] ?? 'GridTV');
|
||||
$epg_sources = $config['epg_sources'] ?? [];
|
||||
$allow_personal_epg = !empty($config['allow_personal_epg']);
|
||||
$allow_personal_m3u = array_key_exists('allow_personal_m3u', $config)
|
||||
? !empty($config['allow_personal_m3u'])
|
||||
: $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,97 @@
|
||||
/* ── 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-field-note {
|
||||
margin-top: 6px; font-size: 11px; line-height: 1.45; opacity: 0.82;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.modal-field-note.is-disabled {
|
||||
color: #ffb0a5; opacity: 1;
|
||||
}
|
||||
.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; }
|
||||
|
||||
.pm-reminder-panel {
|
||||
margin-top: 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface2);
|
||||
padding: 12px;
|
||||
}
|
||||
.pm-reminder-submenu {
|
||||
display: none;
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 10px;
|
||||
}
|
||||
.pm-reminder-submenu.visible { display: block; }
|
||||
.pm-reminder-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.pm-reminder-text,
|
||||
.pm-reminder-status {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.pm-reminder-status { margin-top: 8px; }
|
||||
.pm-reminder-status.active { color: var(--accent2); }
|
||||
.pm-reminder-status.muted { opacity: 0.75; }
|
||||
.pm-reminder-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pm-reminder-save {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
.pm-reminder-remove {
|
||||
border: 1px solid var(--border-bright);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
.pm-reminder-save:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -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,273 @@
|
||||
/* ═══════════════════════════════════════════
|
||||
TOP BAR
|
||||
═══════════════════════════════════════════ */
|
||||
.topbar {
|
||||
height: 50px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
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; }
|
||||
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
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);
|
||||
}
|
||||
.support-links {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.support-link-github:hover {
|
||||
border-color: var(--text);
|
||||
color: var(--text);
|
||||
}
|
||||
.support-link-coffee {
|
||||
border-color: #f6c453;
|
||||
color: #f6c453;
|
||||
}
|
||||
.support-link-coffee:hover {
|
||||
border-color: #ffd978;
|
||||
color: #ffd978;
|
||||
background: rgba(246, 196, 83, 0.08);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
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; }
|
||||
|
||||
.category-filter {
|
||||
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;
|
||||
max-width: 180px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
.category-filter:hover { border-color: var(--accent2); color: var(--accent2); }
|
||||
.category-filter option { background: #111; color: #eee; font-family: sans-serif; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
body { overflow: hidden; }
|
||||
|
||||
.topbar {
|
||||
height: auto;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
.logo { font-size: 11px; }
|
||||
.clock { font-size: 14px; }
|
||||
.category-filter { max-width: 100%; }
|
||||
.topbar-right { width: 100%; justify-content: space-between; }
|
||||
|
||||
.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; }
|
||||
}
|
||||
+6
-3
@@ -1,17 +1,20 @@
|
||||
const EPG_SOURCES = <?= json_encode(array_values($epg_sources)) ?>;
|
||||
const L = <?= json_encode($L) ?>;
|
||||
const ALLOW_PERSONAL_EPG = <?= $allow_personal_epg ? 'true' : 'false' ?>;
|
||||
const ALLOW_PERSONAL_M3U = <?= $allow_personal_m3u ? '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=';
|
||||
const REMINDER_STORAGE_KEY = 'gridtv-reminders';
|
||||
const REMINDER_OFFSET_MS = 15 * 60 * 1000;
|
||||
|
||||
// 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'));
|
||||
}
|
||||
|
||||
|
||||
+4
-5
@@ -6,15 +6,15 @@ window.addEventListener('resize', () => {
|
||||
});
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
refreshReminderSchedules();
|
||||
const savedSource = localStorage.getItem('gridtv-active-source');
|
||||
if (savedSource === 'personal') {
|
||||
const epg = localStorage.getItem('gridtv-personal-epg');
|
||||
const m3u = localStorage.getItem('gridtv-personal-m3u') ?? '';
|
||||
const m3u = ALLOW_PERSONAL_M3U ? (localStorage.getItem('gridtv-personal-m3u') ?? '') : '';
|
||||
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 +26,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);
|
||||
|
||||
+9
-7
@@ -1,12 +1,15 @@
|
||||
// ── 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) {
|
||||
if (!url) return;
|
||||
if (!url) {
|
||||
m3uStreams = {};
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let text;
|
||||
try {
|
||||
@@ -25,7 +28,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 +45,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 = '';
|
||||
}
|
||||
|
||||
|
||||
+14
-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,24 +30,26 @@ 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`;
|
||||
|
||||
bindReminderPanel(ch, p);
|
||||
document.getElementById('programModal').classList.add('visible');
|
||||
}
|
||||
|
||||
function closeProgramModal() {
|
||||
document.getElementById('programModal').classList.remove('visible');
|
||||
document.getElementById('pm-reminder-submenu')?.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();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// ── PROGRAM REMINDERS ────────────────────────────────────────────────────────
|
||||
let currentReminderProgram = null;
|
||||
let reminderTimers = {};
|
||||
|
||||
function reminderProgramId(ch, p) {
|
||||
return [currentEpgUrl || 'default', ch.id || ch.name, p.start?.getTime() || 0, p.title || ''].join('::');
|
||||
}
|
||||
|
||||
function reminderPayload(ch, p) {
|
||||
return {
|
||||
id: reminderProgramId(ch, p),
|
||||
source: currentEpgUrl || '',
|
||||
channelId: ch.id || '',
|
||||
channelName: ch.name || '',
|
||||
title: p.title || '',
|
||||
subtitle: p.subtitle || '',
|
||||
start: p.start?.getTime() || 0,
|
||||
stop: p.stop?.getTime() || 0,
|
||||
notifyAt: Math.max(Date.now() + 5000, (p.start?.getTime() || 0) - REMINDER_OFFSET_MS)
|
||||
};
|
||||
}
|
||||
|
||||
function getStoredReminders() {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(REMINDER_STORAGE_KEY) || '[]');
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveStoredReminders(reminders) {
|
||||
localStorage.setItem(REMINDER_STORAGE_KEY, JSON.stringify(reminders));
|
||||
}
|
||||
|
||||
function findStoredReminder(id) {
|
||||
return getStoredReminders().find(item => item.id === id) || null;
|
||||
}
|
||||
|
||||
function upsertReminder(reminder) {
|
||||
const reminders = getStoredReminders().filter(item => item.id !== reminder.id);
|
||||
reminders.push(reminder);
|
||||
saveStoredReminders(reminders);
|
||||
}
|
||||
|
||||
function removeReminder(id) {
|
||||
if (reminderTimers[id]) {
|
||||
clearTimeout(reminderTimers[id]);
|
||||
delete reminderTimers[id];
|
||||
}
|
||||
saveStoredReminders(getStoredReminders().filter(item => item.id !== id));
|
||||
updateReminderPanel();
|
||||
}
|
||||
|
||||
function pruneExpiredReminders() {
|
||||
const now = Date.now();
|
||||
const reminders = getStoredReminders().filter(item => (item.stop || item.start || 0) > now);
|
||||
saveStoredReminders(reminders);
|
||||
return reminders;
|
||||
}
|
||||
|
||||
async function ensureReminderPermission() {
|
||||
if (!('Notification' in window)) {
|
||||
throw new Error(L.reminder_error_unsupported || 'Notifications are not supported on this browser.');
|
||||
}
|
||||
if (Notification.permission === 'granted') return true;
|
||||
if (Notification.permission === 'denied') {
|
||||
throw new Error(L.reminder_error_denied || 'Notifications were blocked in this browser.');
|
||||
}
|
||||
const result = await Notification.requestPermission();
|
||||
if (result !== 'granted') {
|
||||
throw new Error(L.reminder_error_denied || 'Notifications were blocked in this browser.');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function showReminderNotification(reminder) {
|
||||
const title = L.reminder_notification_title || 'Program reminder';
|
||||
const body = (L.reminder_notification_body || '“{title}” starts at {time} on {channel}.')
|
||||
.replace('{title}', reminder.title || '')
|
||||
.replace('{time}', fmtTime(new Date(reminder.start)))
|
||||
.replace('{channel}', reminder.channelName || '');
|
||||
|
||||
try {
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
await registration.showNotification(title, {
|
||||
body,
|
||||
tag: reminder.id,
|
||||
icon: '/assets/icon-192.png',
|
||||
badge: '/assets/icon-192.png',
|
||||
data: {
|
||||
url: '/index.php',
|
||||
reminderId: reminder.id
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification(title, { body, icon: '/assets/icon-192.png', tag: reminder.id });
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReminder(reminder) {
|
||||
if (!reminder?.id) return;
|
||||
if (reminderTimers[reminder.id]) clearTimeout(reminderTimers[reminder.id]);
|
||||
const delay = reminder.notifyAt - Date.now();
|
||||
if (delay <= 0) {
|
||||
showReminderNotification(reminder).finally(() => removeReminder(reminder.id));
|
||||
return;
|
||||
}
|
||||
reminderTimers[reminder.id] = setTimeout(() => {
|
||||
showReminderNotification(reminder).finally(() => removeReminder(reminder.id));
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function refreshReminderSchedules() {
|
||||
Object.keys(reminderTimers).forEach(id => {
|
||||
clearTimeout(reminderTimers[id]);
|
||||
delete reminderTimers[id];
|
||||
});
|
||||
pruneExpiredReminders().forEach(scheduleReminder);
|
||||
updateReminderPanel();
|
||||
}
|
||||
|
||||
function reminderTimingText(p) {
|
||||
const startMs = p && typeof p.start === 'number'
|
||||
? p.start
|
||||
: (p?.start?.getTime ? p.start.getTime() : 0);
|
||||
const notifyAt = Math.max(Date.now() + 5000, startMs - REMINDER_OFFSET_MS);
|
||||
const deltaMinutes = Math.max(0, Math.round((startMs - notifyAt) / 60000));
|
||||
if (deltaMinutes <= 1) return L.reminder_when_soon || 'This program starts soon. The reminder will fire almost immediately.';
|
||||
return (L.reminder_when || 'A browser notification will be sent {minutes} minutes before broadcast.')
|
||||
.replace('{minutes}', String(deltaMinutes));
|
||||
}
|
||||
|
||||
function updateReminderPanel(message) {
|
||||
const panel = document.getElementById('pm-reminder-panel');
|
||||
const submenu = document.getElementById('pm-reminder-submenu');
|
||||
const text = document.getElementById('pm-reminder-text');
|
||||
const status = document.getElementById('pm-reminder-status');
|
||||
const removeBtn = document.getElementById('pm-reminder-remove');
|
||||
const saveBtn = document.getElementById('pm-reminder-save');
|
||||
if (!panel || !submenu || !text || !status || !removeBtn || !saveBtn) return;
|
||||
|
||||
if (!currentReminderProgram) {
|
||||
panel.style.display = 'none';
|
||||
submenu.classList.remove('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
panel.style.display = 'block';
|
||||
const reminder = findStoredReminder(currentReminderProgram.id);
|
||||
const hasStarted = currentReminderProgram.start <= Date.now();
|
||||
|
||||
text.textContent = reminderTimingText(currentReminderProgram);
|
||||
status.textContent = message || (reminder
|
||||
? (L.reminder_status_set || 'Reminder active for {time}.').replace('{time}', fmtTime(new Date(reminder.notifyAt)))
|
||||
: (hasStarted ? (L.reminder_status_started || 'This program is already airing.') : (L.reminder_status_idle || 'No reminder active.')));
|
||||
status.className = 'pm-reminder-status' + (reminder ? ' active' : '') + (hasStarted ? ' muted' : '');
|
||||
removeBtn.style.display = reminder ? 'inline-flex' : 'none';
|
||||
saveBtn.disabled = hasStarted;
|
||||
}
|
||||
|
||||
async function saveCurrentReminder() {
|
||||
if (!currentReminderProgram) return;
|
||||
try {
|
||||
await ensureReminderPermission();
|
||||
upsertReminder(currentReminderProgram);
|
||||
scheduleReminder(currentReminderProgram);
|
||||
updateReminderPanel(L.reminder_saved || 'Reminder enabled.');
|
||||
} catch (error) {
|
||||
updateReminderPanel(error.message || (L.reminder_error_generic || 'Unable to enable reminder.'));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleReminderSubmenu() {
|
||||
const submenu = document.getElementById('pm-reminder-submenu');
|
||||
if (!submenu || !currentReminderProgram) return;
|
||||
submenu.classList.toggle('visible');
|
||||
updateReminderPanel();
|
||||
}
|
||||
|
||||
function bindReminderPanel(ch, p) {
|
||||
currentReminderProgram = reminderPayload(ch, p);
|
||||
const toggleBtn = document.getElementById('pm-reminder-toggle');
|
||||
const saveBtn = document.getElementById('pm-reminder-save');
|
||||
const removeBtn = document.getElementById('pm-reminder-remove');
|
||||
const submenu = document.getElementById('pm-reminder-submenu');
|
||||
if (!toggleBtn || !saveBtn || !removeBtn || !submenu) return;
|
||||
|
||||
submenu.classList.remove('visible');
|
||||
toggleBtn.onclick = () => toggleReminderSubmenu();
|
||||
saveBtn.onclick = () => saveCurrentReminder();
|
||||
removeBtn.onclick = () => {
|
||||
if (currentReminderProgram) removeReminder(currentReminderProgram.id);
|
||||
updateReminderPanel(L.reminder_removed || 'Reminder removed.');
|
||||
};
|
||||
updateReminderPanel();
|
||||
}
|
||||
+110
-41
@@ -2,6 +2,7 @@
|
||||
let searchActive = false;
|
||||
let searchQuery = '';
|
||||
let searchTimer = null;
|
||||
let currentCategoryFilter = '';
|
||||
|
||||
function toggleSearch() {
|
||||
const bar = document.getElementById('searchBar');
|
||||
@@ -18,47 +19,94 @@ function toggleSearch() {
|
||||
function clearSearch() {
|
||||
searchQuery = '';
|
||||
document.getElementById('searchInput').value = '';
|
||||
applySearch('');
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function onSearchInput(val) {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
searchQuery = val.trim().toLowerCase();
|
||||
applySearch(searchQuery);
|
||||
applyFilters();
|
||||
}, 350);
|
||||
}
|
||||
|
||||
function applySearch(q) {
|
||||
if (!q) {
|
||||
// Remettre toutes les chaines
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0,0,0,0);
|
||||
const tomorrow = new Date(today.getTime() + 24*3600000);
|
||||
|
||||
// Filtrer les chaines : nom OU programme aujourd'hui correspond
|
||||
const filtered = channels.filter(ch => {
|
||||
// Match sur le nom de la chaine
|
||||
if (ch.name.toLowerCase().includes(q)) return true;
|
||||
// Match sur les programmes d'aujourd'hui (titre + desc)
|
||||
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;
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
renderFiltered(filtered, q);
|
||||
function programMatchesQuery(p, q) {
|
||||
if (!q) return true;
|
||||
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;
|
||||
}
|
||||
|
||||
function renderFiltered(filteredChannels, q) {
|
||||
function programMatchesCategory(p, category) {
|
||||
if (!category) return true;
|
||||
const key = normalizeCategory(category);
|
||||
return (p.categories || []).some(cat => normalizeCategory(cat) === key);
|
||||
}
|
||||
|
||||
function getFilteredChannels(q, category) {
|
||||
const qNorm = (q || '').trim().toLowerCase();
|
||||
const nowStart = new Date(GRID_START);
|
||||
const nowEnd = new Date(GRID_END);
|
||||
|
||||
return channels.filter(ch => {
|
||||
const channelMatchesQuery = qNorm ? ch.name.toLowerCase().includes(qNorm) : false;
|
||||
const items = programs[ch.id] || [];
|
||||
const matchingProgram = items.some(p => {
|
||||
if (p.stop < nowStart || p.start > nowEnd) return false;
|
||||
if (!programMatchesCategory(p, category)) return false;
|
||||
if (!qNorm) return true;
|
||||
return programMatchesQuery(p, qNorm);
|
||||
});
|
||||
if (category) return matchingProgram;
|
||||
return channelMatchesQuery || matchingProgram || (!qNorm && !category);
|
||||
});
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const q = searchQuery;
|
||||
const category = currentCategoryFilter;
|
||||
if (!q && !category) {
|
||||
renderUnfiltered();
|
||||
return;
|
||||
}
|
||||
renderFiltered(getFilteredChannels(q, category), q, category);
|
||||
}
|
||||
|
||||
function setCategoryFilter(value) {
|
||||
currentCategoryFilter = value || '';
|
||||
const select = document.getElementById('categoryFilter');
|
||||
if (select && select.value !== currentCategoryFilter) select.value = currentCategoryFilter;
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function refreshCategoryFilterOptions() {
|
||||
const select = document.getElementById('categoryFilter');
|
||||
if (!select) return;
|
||||
const previous = currentCategoryFilter;
|
||||
select.innerHTML = '';
|
||||
const baseOption = document.createElement('option');
|
||||
baseOption.value = '';
|
||||
baseOption.textContent = L.category_all || 'All categories';
|
||||
select.appendChild(baseOption);
|
||||
availableCategories.forEach(category => {
|
||||
const option = document.createElement('option');
|
||||
option.value = category;
|
||||
option.textContent = category;
|
||||
select.appendChild(option);
|
||||
});
|
||||
if (previous && availableCategories.some(category => normalizeCategory(category) === normalizeCategory(previous))) {
|
||||
select.value = availableCategories.find(category => normalizeCategory(category) === normalizeCategory(previous)) || '';
|
||||
currentCategoryFilter = select.value;
|
||||
} else {
|
||||
currentCategoryFilter = '';
|
||||
select.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function renderFiltered(filteredChannels, q, category) {
|
||||
if (isMobile()) {
|
||||
renderMobileFiltered(filteredChannels, q);
|
||||
renderMobileFiltered(filteredChannels, q, category);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -72,7 +120,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 +129,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,12 +144,17 @@ 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;`;
|
||||
|
||||
(programs[ch.id]||[]).filter(p => p.stop > GRID_START && p.start < GRID_END).forEach(p => {
|
||||
const channelMatchesQuery = q ? ch.name.toLowerCase().includes(q) : false;
|
||||
(programs[ch.id]||[]).filter(p => {
|
||||
if (!(p.stop > GRID_START && p.start < GRID_END)) return false;
|
||||
if (!programMatchesCategory(p, category)) return false;
|
||||
return true;
|
||||
}).forEach(p => {
|
||||
const x = Math.max(0, msToX(p.start.getTime()));
|
||||
const w = Math.min(TOTAL_WIDTH_PX, msToX(p.stop.getTime())) - x;
|
||||
if (w < 2) return;
|
||||
@@ -109,9 +162,15 @@ 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 =
|
||||
!!q && (
|
||||
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 (category && !matchesProg && !channelMatchesQuery) block.classList.add('search-match');
|
||||
|
||||
if (p.stop < now) block.classList.add('is-past');
|
||||
if (p.start <= now && p.stop > now) {
|
||||
@@ -140,7 +199,7 @@ function renderFiltered(filteredChannels, q) {
|
||||
positionNowLine();
|
||||
}
|
||||
|
||||
function renderMobileFiltered(filteredChannels, q) {
|
||||
function renderMobileFiltered(filteredChannels, q, category) {
|
||||
const container = document.getElementById('mobileView');
|
||||
container.innerHTML = '';
|
||||
const now = new Date();
|
||||
@@ -148,7 +207,12 @@ function renderMobileFiltered(filteredChannels, q) {
|
||||
const winEnd = new Date(now.getTime() + 4*3600000);
|
||||
|
||||
filteredChannels.forEach(ch => {
|
||||
const progs = (programs[ch.id]||[]).filter(p => p.stop > winStart && p.start < winEnd);
|
||||
const channelMatchesQuery = q ? ch.name.toLowerCase().includes(q) : false;
|
||||
const progs = (programs[ch.id]||[]).filter(p => {
|
||||
if (!(p.stop > winStart && p.start < winEnd)) return false;
|
||||
if (!programMatchesCategory(p, category)) return false;
|
||||
return true;
|
||||
});
|
||||
if (!progs.length) return;
|
||||
|
||||
const section = document.createElement('div'); section.className = 'mobile-channel';
|
||||
@@ -168,9 +232,14 @@ 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 =
|
||||
!!q && (
|
||||
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' : '');
|
||||
item.className = 'mobile-program' + (isLive ? ' is-live' : '') + (isPast ? ' is-past' : '') + (matchesProg || (category && !channelMatchesQuery) ? ' search-match' : '');
|
||||
|
||||
if (isLive) {
|
||||
const bar = document.createElement('div'); bar.className = 'mobile-program-progress';
|
||||
@@ -179,12 +248,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) {
|
||||
|
||||
+43
-5
@@ -21,21 +21,33 @@ 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() {
|
||||
const saved_epg = localStorage.getItem('gridtv-personal-epg') ?? '';
|
||||
const saved_m3u = localStorage.getItem('gridtv-personal-m3u') ?? '';
|
||||
const modal = document.getElementById('personalEpgModal');
|
||||
const m3uInput = document.getElementById('personalM3uInput');
|
||||
const m3uHint = document.getElementById('personalM3uHint');
|
||||
document.getElementById('personalEpgInput').value = saved_epg;
|
||||
document.getElementById('personalM3uInput').value = saved_m3u;
|
||||
if (m3uInput) {
|
||||
m3uInput.value = ALLOW_PERSONAL_M3U ? saved_m3u : '';
|
||||
m3uInput.disabled = !ALLOW_PERSONAL_M3U;
|
||||
}
|
||||
if (m3uHint) {
|
||||
m3uHint.textContent = ALLOW_PERSONAL_M3U
|
||||
? (m3uHint.dataset.enabledText ?? '')
|
||||
: (m3uHint.dataset.disabledText ?? '');
|
||||
m3uHint.classList.toggle('is-disabled', !ALLOW_PERSONAL_M3U);
|
||||
m3uHint.style.display = '';
|
||||
}
|
||||
modal.classList.add('visible');
|
||||
}
|
||||
|
||||
function closePersonalEpgModal(applied) {
|
||||
document.getElementById('personalEpgModal').classList.remove('visible');
|
||||
// Si annulé (pas applied), remettre le select sur la source précédente
|
||||
// If 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;
|
||||
@@ -44,11 +56,16 @@ function closePersonalEpgModal(applied) {
|
||||
|
||||
function applyPersonalEpg() {
|
||||
const epg = document.getElementById('personalEpgInput').value.trim();
|
||||
const m3u = document.getElementById('personalM3uInput').value.trim();
|
||||
const m3u = ALLOW_PERSONAL_M3U ? document.getElementById('personalM3uInput').value.trim() : '';
|
||||
if (!epg) { alert('URL EPG obligatoire'); return; }
|
||||
localStorage.setItem('gridtv-personal-epg', epg);
|
||||
localStorage.setItem('gridtv-personal-m3u', m3u);
|
||||
if (ALLOW_PERSONAL_M3U) {
|
||||
localStorage.setItem('gridtv-personal-m3u', m3u);
|
||||
} else {
|
||||
localStorage.removeItem('gridtv-personal-m3u');
|
||||
}
|
||||
localStorage.setItem('gridtv-active-source', 'personal');
|
||||
updateSourceDropdownLabel('✏ Personal EPG');
|
||||
currentEpgUrl = epg;
|
||||
currentM3uUrl = m3u;
|
||||
const sel = document.getElementById('sourceSelect');
|
||||
@@ -58,3 +75,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
|
||||
}
|
||||
})();
|
||||
+79
-6
@@ -1,8 +1,16 @@
|
||||
|
||||
// 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.
|
||||
let availableCategories = [];
|
||||
|
||||
function pad(n) { return String(n).padStart(2,'0'); }
|
||||
|
||||
@@ -28,6 +36,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 +81,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 +90,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 +107,55 @@ 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 normalizeCategory(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function collectAvailableCategories() {
|
||||
const seen = new Set();
|
||||
const list = [];
|
||||
Object.values(programs).forEach(items => {
|
||||
items.forEach(program => {
|
||||
(program.categories || []).forEach(category => {
|
||||
const label = String(category || '').trim();
|
||||
const key = normalizeCategory(label);
|
||||
if (!label || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
list.push(label);
|
||||
});
|
||||
});
|
||||
});
|
||||
availableCategories = list.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||
}
|
||||
|
||||
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,18 +174,32 @@ 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));
|
||||
|
||||
collectAvailableCategories();
|
||||
if (typeof refreshCategoryFilterOptions === 'function') refreshCategoryFilterOptions();
|
||||
renderAll();
|
||||
if (typeof refreshReminderSchedules === 'function') refreshReminderSchedules();
|
||||
startLiveUpdates();
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
function renderUnfiltered() {
|
||||
if (isMobile()) { renderMobile(); }
|
||||
else { renderGrid(); setTimeout(centerNow, 150); }
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
if (typeof applyFilters === 'function') {
|
||||
applyFilters();
|
||||
return;
|
||||
}
|
||||
renderUnfiltered();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/common.php';
|
||||
|
||||
function gridtv_require_admin(array $config, string $page_title = 'Admin'): array {
|
||||
if (gridtv_is_demo_mode()) {
|
||||
return ['unlocked' => true, 'error' => '', 'demo' => true];
|
||||
}
|
||||
|
||||
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' => '', 'demo' => false];
|
||||
}
|
||||
|
||||
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" id="gridtvAdminUnlockForm"><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><script>const GRIDTV_ADMIN_KEY_STORAGE="gridtv-admin-key";const adminInput=document.getElementById("admin_key_input");const adminForm=document.getElementById("gridtvAdminUnlockForm");try{const savedKey=localStorage.getItem(GRIDTV_ADMIN_KEY_STORAGE)||"";if(adminInput&&savedKey)adminInput.value=savedKey;}catch(_){}if(adminForm&&adminInput){adminForm.addEventListener("submit",()=>{try{localStorage.setItem(GRIDTV_ADMIN_KEY_STORAGE,adminInput.value.trim());}catch(_){}});}</script></body></html>';
|
||||
exit;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
function gridtv_config_path(): string {
|
||||
return dirname(__DIR__, 2) . '/config.json';
|
||||
}
|
||||
|
||||
function gridtv_demo_path(): string {
|
||||
return dirname(__DIR__, 2) . '/.demo';
|
||||
}
|
||||
|
||||
function gridtv_is_demo_mode(): bool {
|
||||
return file_exists(gridtv_demo_path());
|
||||
}
|
||||
|
||||
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', 'reminders', '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,8 +4,23 @@
|
||||
<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-reminder-panel" id="pm-reminder-panel">
|
||||
<button class="pm-btn pm-reminder" id="pm-reminder-toggle" type="button"><?= htmlspecialchars($L["reminder_button"] ?? 'Reminder') ?></button>
|
||||
<div class="pm-reminder-submenu" id="pm-reminder-submenu">
|
||||
<div class="pm-reminder-title"><?= htmlspecialchars($L["reminder_title"] ?? 'Notify me before this program starts') ?></div>
|
||||
<div class="pm-reminder-text" id="pm-reminder-text"></div>
|
||||
<div class="pm-reminder-status" id="pm-reminder-status"></div>
|
||||
<div class="pm-reminder-actions">
|
||||
<button class="pm-btn pm-reminder-save" id="pm-reminder-save" type="button"><?= htmlspecialchars($L["reminder_save"] ?? 'Activate reminder') ?></button>
|
||||
<button class="pm-btn pm-reminder-remove" id="pm-reminder-remove" type="button"><?= htmlspecialchars($L["reminder_remove"] ?? 'Remove reminder') ?></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pm-actions">
|
||||
<button class="pm-btn pm-watch" id="pm-watch">▶ <?= htmlspecialchars($L["watch_now"]) ?></button>
|
||||
<a class="pm-btn pm-imdb" id="pm-imdb" href="#" target="_blank" rel="noopener">🎥 <?= htmlspecialchars($L["imdb_search"]) ?></a>
|
||||
@@ -26,6 +41,7 @@
|
||||
<div class="modal-field">
|
||||
<label><?= htmlspecialchars($L["personal_epg_m3u_label"]) ?> <span style="opacity:.5;font-size:10px">(<?= htmlspecialchars($L["personal_epg_optional"]) ?>)</span></label>
|
||||
<input type="url" id="personalM3uInput" placeholder="http://mon-serveur/channels.m3u">
|
||||
<div id="personalM3uHint" class="modal-field-note" data-enabled-text="<?= htmlspecialchars($L["personal_epg_m3u_enabled"]) ?>" data-disabled-text="<?= htmlspecialchars($L["personal_epg_m3u_disabled"]) ?>"></div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-btn-cancel" onclick="closePersonalEpgModal()"><?= htmlspecialchars($L["cancel"]) ?></button>
|
||||
|
||||
+27
-8
@@ -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);
|
||||
@@ -47,11 +58,19 @@ foreach ($theme_files as $file) {
|
||||
}
|
||||
?>
|
||||
</select>
|
||||
<select class="category-filter" id="categoryFilter" onchange="setCategoryFilter(this.value)" title="<?= htmlspecialchars($L["category_filter_title"] ?? 'Filter by category') ?>">
|
||||
<option value=""><?= htmlspecialchars($L["category_all"] ?? 'All categories') ?></option>
|
||||
</select>
|
||||
<button class="search-btn" onclick="toggleSearch()" title="Search">🔍</button>
|
||||
<div class="live-dot"><?= htmlspecialchars($L["live"]) ?></div>
|
||||
<div class="topbar-right">
|
||||
<div class="support-links">
|
||||
<a class="copy-btn support-link support-link-github" href="https://github.com/Johnnybegood90/GridTV" target="_blank" rel="noopener" title="GitHub">GitHub</a>
|
||||
<a class="copy-btn support-link support-link-coffee" href="https://buymeacoffee.com/johnnybegood320" target="_blank" rel="noopener" title="Buy Me a Coffee">Buy Me a Coffee</a>
|
||||
</div>
|
||||
<div class="live-dot"><?= htmlspecialchars($L["live"]) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-bar" id="searchBar">
|
||||
<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,94 @@
|
||||
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))
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', e => {
|
||||
const url = e.notification?.data?.url || '/index.php';
|
||||
e.notification.close();
|
||||
e.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(windowClients => {
|
||||
for (const client of windowClients) {
|
||||
if ('focus' in client) {
|
||||
client.navigate(url);
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (clients.openWindow) return clients.openWindow(url);
|
||||
return undefined;
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -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.7.0"
|
||||
}
|
||||
Reference in New Issue
Block a user