61 Commits
Author SHA1 Message Date
Johnnybegood90 3f83055198 version.json update 2026-03-14 13:40:47 +01:00
Johnnybegood90 c46ca14f4c version.json update 2026-03-14 13:40:31 +01:00
Johnnybegood90 ea7407f1d8 Added support for new tags in XMLTV 2026-03-14 13:38:52 +01:00
Johnnybegood90 674c13ae26 Feat: Added support for new tags in XMLTV 2026-03-14 13:36:12 +01:00
Johnnybegood90 96601b76b6 Fix HLS proxy fallback and avoid overwriting invalid config 2026-03-14 13:16:29 +01:00
Johnnybegood90 fc608ee0e0 Fix HLS proxy fallback and avoid overwriting invalid config 2026-03-14 13:16:09 +01:00
Johnnybegood90 45cf0975b0 Merge remote-tracking branch 'origin/beta'
Auto-discover locales and harden JSON config loading

- discover available locale files dynamically instead of relying on a hardcoded language list
- make config JSON decoding more defensive to avoid null-state failures on invalid or empty data
- improve startup resilience when configuration files are missing or malformed
2026-03-14 13:09:25 +01:00
Johnnybegood90 90679e9221 Auto-discover locales and harden JSON config loading
- discover available locale files dynamically instead of relying on a hardcoded language list
- make config JSON decoding more defensive to avoid null-state failures on invalid or empty data
- improve startup resilience when configuration files are missing or malformed
2026-03-14 13:08:33 +01:00
Johnnybegood90 7f500ff9ee Merge remote-tracking branch 'origin/beta'
Improve proxy redirect handling and avoid double-fetching media segments

- validate redirect chains more robustly, including scheme-relative, query-only, fragment, and normalized relative paths
- resolve media segment redirects with HEAD before streaming to avoid downloading the same segment twice
- keep strict host allowlisting in place while making HLS/CDN redirect handling more reliable
2026-03-14 12:20:28 +01:00
Johnnybegood90 bc5c9e296a Improve proxy redirect handling and avoid double-fetching media segments
- validate redirect chains more robustly, including scheme-relative, query-only, fragment, and normalized relative paths
- resolve media segment redirects with HEAD before streaming to avoid downloading the same segment twice
- keep strict host allowlisting in place while making HLS/CDN redirect handling more reliable
2026-03-14 12:20:02 +01:00
Johnnybegood90 985a417ccb security: replace corsproxy.io with local proxy fallback, Fix: Update avail. cache. 2026-03-14 12:10:20 +01:00
Johnnybegood90 27ef739845 Fix: Update avail. was cached and making mistake.. 2026-03-14 12:08:06 +01:00
Johnnybegood90 77f02361ab security: replace corsproxy.io with local proxy fallback. 2026-03-14 12:03:42 +01:00
Johnnybegood90 6f379fd18e Adjusting comments into files. 2026-03-14 04:24:06 +01:00
Johnnybegood90 8dabd2d190 Adjusting comments into files. 2026-03-14 04:23:44 +01:00
Johnnybegood90 1402d9b5fc Adjusting README.md 2026-03-14 03:51:48 +01:00
Johnnybegood90 062954a780 Adjusting README.md 2026-03-14 03:51:30 +01:00
Johnnybegood90 ad56bd85cc Add deploy workflow 2026-03-14 03:42:43 +01:00
Johnnybegood90 1959734b56 Add deploy workflow 2026-03-14 03:42:20 +01:00
Johnnybegood90 3d0b4796b2 Update version 2026-03-14 03:13:28 +01:00
Johnnybegood90 de1f5a336c Version update. 2026-03-14 03:13:11 +01:00
Johnnybegood90 4f0e1f55ba Merge remote-tracking branch 'origin/beta'
Harden Docker packaging and bundle frontend assets locally

- replace fragile Docker file bind mount with persistent /data storage
- add container entrypoint to link config.json from persistent storage
- tighten Docker image permissions and add a basic healthcheck
- add .dockerignore to keep git metadata and runtime files out of the image
- bundle hls.js locally instead of loading it from a CDN
- download and serve UI/theme fonts locally instead of Google Fonts
- update service worker cache entries for local assets
- refresh README to reflect local bundled assets and cleaner deployment docs
2026-03-14 03:11:11 +01:00
Johnnybegood90 8ba8c3013e Harden Docker packaging and bundle frontend assets locally
- replace fragile Docker file bind mount with persistent /data storage
- add container entrypoint to link config.json from persistent storage
- tighten Docker image permissions and add a basic healthcheck
- add .dockerignore to keep git metadata and runtime files out of the image
- bundle hls.js locally instead of loading it from a CDN
- download and serve UI/theme fonts locally instead of Google Fonts
- update service worker cache entries for local assets
- refresh README to reflect local bundled assets and cleaner deployment docs
2026-03-14 03:10:21 +01:00
Johnnybegood90 9609f096ef Updating Docker Compose & README.md 2026-03-14 02:58:05 +01:00
Johnnybegood90 8c81c05bca Updating Docker Compose & README.md 2026-03-14 02:57:13 +01:00
Johnnybegood90 ff998d379d security: harden proxy.php with config whitelist, fix XSS via EPG innerHTML 2026-03-14 02:44:48 +01:00
Johnnybegood90 62732ab2c6 security: harden proxy.php with config whitelist, fix XSS via EPG innerHTML 2026-03-14 02:43:11 +01:00
Johnnybegood90 25dabc97b2 security: harden proxy.php with config whitelist, fix XSS via EPG innerHTML 2026-03-14 02:31:25 +01:00
Johnnybegood90 e81cd8b64d security: harden proxy.php with config whitelist, fix XSS via EPG innerHTML 2026-03-14 02:29:09 +01:00
Johnnybegood90 30d8a24927 security: harden proxy.php with config whitelist, fix XSS via EPG innerHTML 2026-03-14 02:26:36 +01:00
Johnnybegood90 d6532d3501 Improvment: Replacing change EPG menu. 2026-03-14 01:22:16 +01:00
Johnnybegood90 17db9f56f7 Fix version.json 2026-03-14 01:22:05 +01:00
Johnnybegood90 874214aa41 Improvment: Replacing change EPG menu. 2026-03-14 01:18:38 +01:00
Johnnybegood90 de0e1adf44 Improvment: Replacing change EPG menu. 2026-03-14 01:18:02 +01:00
Johnnybegood90 42d161beff Update topbar CSS 2026-03-14 01:10:58 +01:00
Johnnybegood90 30c0de2a2d Fix: CSS Topbar. 2026-03-14 01:09:33 +01:00
Johnnybegood90 77f0c2523b Fix: CSS Topbar. 2026-03-14 01:06:32 +01:00
Johnnybegood90 73bdde5093 Fix: CSS Topbar. 2026-03-14 01:03:44 +01:00
Johnnybegood90 0c2bce40fb Update assets. 2026-03-14 00:30:04 +01:00
Johnnybegood90 005fca9a05 Adjusting assets. 2026-03-14 00:28:52 +01:00
Johnnybegood90 f82bc65472 Fix: CSS Topbar. 2026-03-14 00:11:34 +01:00
Johnnybegood90 d57855c385 Hotfix desktop CSS 2026-03-14 00:08:29 +01:00
Johnnybegood90 ca25d5000b Hotfix css desktop 2026-03-14 00:07:10 +01:00
Johnnybegood90 f935a82e91 Hotfix mobile CSS 2026-03-14 00:01:17 +01:00
Johnnybegood90 96c9835a27 Hotfix css mobile 2026-03-14 00:00:05 +01:00
Johnnybegood90 b312e8cc26 Updating favicon. 2026-03-13 23:51:49 +01:00
Johnnybegood90 79a077a85f Adding PWA support. 2026-03-13 23:46:14 +01:00
Johnnybegood90 8e3dc664ed Remove Sublime workspace file 2026-03-13 23:27:25 +01:00
Johnnybegood90 2172202d25 update .gitignore 2026-03-13 23:24:54 +01:00
Johnnybegood90 804bb96e07 Update .gitignore. 2026-03-13 23:23:20 +01:00
Johnnybegood90 d7541c0fa4 update .gitignore 2026-03-13 23:23:05 +01:00
Johnnybegood90 7f23ba92ad Update README.md. 2026-03-13 23:12:20 +01:00
Johnnybegood90 9e4614d8df Update README.md 2026-03-13 23:12:00 +01:00
Johnnybegood90 5fd4eb8a5c Split the CSS from head.php into separate files. 2026-03-13 23:06:43 +01:00
Johnnybegood90 8ca85e778e Split the CSS from head.php into separate files. 2026-03-13 23:04:55 +01:00
Johnnybegood90 181e477a0e Split the CSS from head.php into separate files. 2026-03-13 22:56:40 +01:00
Johnnybegood90 da501bccc7 removing useless config.example.json 2026-03-13 22:05:28 +01:00
Johnnybegood90 8a465dc6da removing useless config.example.json 2026-03-13 22:05:06 +01:00
Johnnybegood90 2d4d8c031e Feat: Update available checker. 2026-03-13 21:49:02 +01:00
Johnnybegood90 8b032f56de Update available fix. 2026-03-13 21:47:49 +01:00
Johnnybegood90 44f4eeaafe Update available tag. 2026-03-13 21:39:34 +01:00
67 changed files with 1784 additions and 1028 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.gitignore
.DS_Store
*.swp
data
assets/preview.png
assets/.DS_Store
src/.DS_Store
.version.json.swp
+32
View File
@@ -0,0 +1,32 @@
name: Deploy GridTV
on:
push:
branches:
- main
- beta
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Start SSH agent
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_KEY }}
- name: Add server to known_hosts
run: |
mkdir -p ~/.ssh
ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts
- name: Deploy main
if: github.ref == 'refs/heads/main'
run: |
ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} main
- name: Deploy beta
if: github.ref == 'refs/heads/beta'
run: |
ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} beta
+1 -2
View File
@@ -1,4 +1,3 @@
config.json config.json
data/
.DS_Store .DS_Store
generate_epg.php
epg.xml
+12 -2
View File
@@ -4,8 +4,18 @@ RUN docker-php-ext-install curl && \
a2enmod rewrite a2enmod rewrite
COPY . /var/www/html/ COPY . /var/www/html/
COPY docker/docker-entrypoint.sh /usr/local/bin/gridtv-entrypoint.sh
RUN chown -R www-data:www-data /var/www/html && \ RUN mkdir -p /data && \
chmod -R 775 /var/www/html chown -R www-data:www-data /var/www/html /data && \
find /var/www/html -type d -exec chmod 755 {} \; && \
find /var/www/html -type f -exec chmod 644 {} \; && \
chmod 755 /usr/local/bin/gridtv-entrypoint.sh
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD php -r "exit(@file_get_contents('http://127.0.0.1/') === false ? 1 : 0);"
ENTRYPOINT ["gridtv-entrypoint.sh"]
CMD ["apache2-foreground"]
EXPOSE 80 EXPOSE 80
+152 -18
View File
@@ -40,10 +40,12 @@ This demo runs with sample XMLTV feeds to showcase the interface.
- 📡 **Multi-EPG sources** — configure multiple EPG/M3U sources, switch from the topbar - 📡 **Multi-EPG sources** — configure multiple EPG/M3U sources, switch from the topbar
- 👤 **Personal EPG** — optionally let visitors use your instance with their own EPG/M3U URLs (saved in localStorage) - 👤 **Personal EPG** — optionally let visitors use your instance with their own EPG/M3U URLs (saved in localStorage)
- 🔍 **Search** — filter the grid by channel name or program title/synopsis (debounced) - 🔍 **Search** — filter the grid by channel name or program title/synopsis (debounced)
-**Favorites** — pin channels to the top of the grid, persisted in localStorage
- 🌍 **i18n** — auto-detects browser language, supports EN / FR / ES (add your own in `locales/`) - 🌍 **i18n** — auto-detects browser language, supports EN / FR / ES (add your own in `locales/`)
- ⚙️ **Guided setup** on first launch — no config files to edit manually - ⚙️ **Re-editable setup** — protected by an admin key, no SSH required to update config
- 🔔 **Update notifications** — a badge appears in the topbar when a new release is available on GitHub
- 🔄 **Auto-reload** EPG every 30 minutes - 🔄 **Auto-reload** EPG every 30 minutes
- 0️⃣ **Zero JS dependencies** — vanilla PHP, hls.js loaded from CDN - 0️⃣ **Zero build tooling** — vanilla PHP/JS/CSS, with bundled local assets
--- ---
@@ -68,15 +70,19 @@ docker compose up -d
Then open `http://localhost:8080` and follow the setup wizard. Then open `http://localhost:8080` and follow the setup wizard.
Docker stores the generated configuration in `./data/config.json` on the host.
To run on a custom port: To run on a custom port:
```bash ```bash
PORT=9000 docker compose up -d PORT=9000 docker compose up -d
``` ```
To back up or migrate your Docker setup, keep the `data/` directory.
--- ---
### Option B — Apache / Nginx ### Option B — Native PHP host
#### 1. Clone the repo #### 1. Clone the repo
@@ -92,7 +98,25 @@ chmod 775 /var/www/gridtv
chown -R www-data:www-data /var/www/gridtv chown -R www-data:www-data /var/www/gridtv
``` ```
#### 3a. Configure Nginx #### 3. Install php-curl
The built-in player uses `proxy.php` to relay HTTP streams over HTTPS. This requires the **php-curl** extension:
```bash
# Debian/Ubuntu — adjust version to match your PHP
apt install php8.4-curl
systemctl reload apache2 # or: systemctl reload nginx
```
#### 4. Reverse proxy examples
<details>
<summary>Show reverse proxy / vhost examples</summary>
These examples are intentionally minimal. Replace `guide.your-domain.com` with your domain and adjust the PHP socket or upstream target to match your host.
<details>
<summary>Nginx + PHP-FPM</summary>
```nginx ```nginx
server { server {
@@ -113,13 +137,16 @@ server {
} }
``` ```
> 💡 Adjust the PHP version (`php8.2-fpm`) to match the one installed on your server.
```bash ```bash
nginx -t && systemctl reload nginx nginx -t && systemctl reload nginx
``` ```
#### 3b. Configure Apache > Adjust `php8.2-fpm.sock` to match the PHP-FPM version installed on your server.
</details>
<details>
<summary>Apache vhost</summary>
```apache ```apache
<VirtualHost *:80> <VirtualHost *:80>
@@ -138,16 +165,52 @@ a2enmod php8.4 rewrite
systemctl reload apache2 systemctl reload apache2
``` ```
#### 4. Install php-curl </details>
The built-in player uses `proxy.php` to relay HTTP streams over HTTPS. This requires the **php-curl** extension: <details>
<summary>Caddy</summary>
```caddy
guide.your-domain.com {
root * /var/www/gridtv
php_fastcgi unix//run/php/php8.2-fpm.sock
file_server
}
```
```bash ```bash
# Debian/Ubuntu — adjust version to match your PHP systemctl reload caddy
apt install php8.4-curl
systemctl reload apache2 # or: systemctl reload nginx
``` ```
> If you use `xcaddy` or a distro package, keep the same site block and only adapt the PHP-FPM socket path.
</details>
<details>
<summary>Traefik (Docker labels)</summary>
Use this if GridTV runs in Docker and Traefik is your front proxy:
```yaml
services:
gridtv:
build: .
volumes:
- ./data:/data
labels:
- "traefik.enable=true"
- "traefik.http.routers.gridtv.rule=Host(`guide.your-domain.com`)"
- "traefik.http.routers.gridtv.entrypoints=websecure"
- "traefik.http.routers.gridtv.tls=true"
- "traefik.http.services.gridtv.loadbalancer.server.port=80"
```
You still need a running Traefik instance with `websecure` configured and DNS pointing to it.
</details>
</details>
#### 5. First launch — Setup #### 5. First launch — Setup
Open your browser at `http://guide.your-domain.com`. Open your browser at `http://guide.your-domain.com`.
@@ -164,6 +227,9 @@ Once submitted, `config.json` is created on the server. **The setup page becomes
--- ---
<details>
<summary>Editing the config later</summary>
### Editing the config later ### Editing the config later
You can re-open the setup page at any time using the admin key generated during first setup: You can re-open the setup page at any time using the admin key generated during first setup:
@@ -172,6 +238,8 @@ You can re-open the setup page at any time using the admin key generated during
http://guide.your-domain.com/setup.php http://guide.your-domain.com/setup.php
``` ```
Enter your admin key to unlock the configuration form. The key is stored in `config.json` under `admin_key` — if you lose it, you can retrieve it there via SSH.
Or edit `config.json` directly: Or edit `config.json` directly:
```bash ```bash
@@ -201,13 +269,19 @@ nano /var/www/gridtv/config.json
--- ---
</details>
<details>
<summary>Project structure</summary>
## 📁 Project structure ## 📁 Project structure
``` ```
gridtv/ gridtv/
├── index.php # Entry point ├── index.php # Entry point
├── setup.php # First-launch + re-configuration page ├── setup.php # Setup + re-configuration (admin key protected)
├── proxy.php # HTTP→HTTPS stream proxy ├── proxy.php # HTTP→HTTPS stream proxy
├── version.json # Current version (used for update check)
├── config.example.json # Config template ├── config.example.json # Config template
├── Dockerfile ├── Dockerfile
├── docker-compose.yml ├── docker-compose.yml
@@ -223,13 +297,24 @@ gridtv/
│ └── steampunk.css │ └── steampunk.css
└── src/ └── src/
├── config.php # Config loader, migration, locale detection ├── config.php # Config loader, migration, locale detection
├── css/ # CSS modules (one file per feature)
│ ├── base.css # Variables, reset, global layout
│ ├── topbar.css # Topbar, nav, source switcher, theme selector
│ ├── grid.css # Grid, channels, programs, ruler, tooltip, loading
│ ├── mobile.css # Mobile list view
│ ├── player.css # HLS PiP player
│ ├── modals.css # Personal EPG modal
│ ├── search.css # Search bar + highlight
│ ├── program.css # Program info modal + midnight marker
│ ├── favorites.css# Favorite button + separator
│ └── updater.css # Update notification badge
├── tpl/ # HTML templates ├── tpl/ # HTML templates
│ ├── head.php # DOCTYPE, <head>, CSS │ ├── head.php # DOCTYPE, <head>, includes CSS modules
│ ├── topbar.php # Topbar (nav, source switcher, search, theme) │ ├── topbar.php # Topbar (nav, source switcher, search, theme)
│ ├── grid.php # Grid + mobile + PiP + overlays │ ├── grid.php # Grid + mobile + PiP + overlays
│ ├── modals.php # Program info modal + Personal EPG modal │ ├── modals.php # Program info modal + Personal EPG modal
│ └── footer.php # JS modules loader, </body></html> │ └── footer.php # JS modules loader, </body></html>
└── js/ # JavaScript modules └── js/ # JavaScript modules (one file per feature)
├── config.js # Constants + PHP-injected vars (incl. locale) ├── config.js # Constants + PHP-injected vars (incl. locale)
├── utils.js # Helpers, clock, EPG fetch ├── utils.js # Helpers, clock, EPG fetch
├── epg.js # Grid rendering ├── epg.js # Grid rendering
@@ -241,6 +326,8 @@ gridtv/
├── themes.js # Theme switcher ├── themes.js # Theme switcher
├── search.js # Search + filter (debounced) ├── search.js # Search + filter (debounced)
├── program.js # Program info modal + IMDb link ├── program.js # Program info modal + IMDb link
├── favorites.js # Favorites (localStorage)
├── updater.js # GitHub update checker
└── live.js # Live updates + responsive └── live.js # Live updates + responsive
``` ```
@@ -248,6 +335,11 @@ gridtv/
--- ---
</details>
<details>
<summary>Themes</summary>
## 🎨 Themes ## 🎨 Themes
GridTV ships with 4 built-in themes. To add your own, create a CSS file in `themes/` with these metadata comments at the top: GridTV ships with 4 built-in themes. To add your own, create a CSS file in `themes/` with these metadata comments at the top:
@@ -268,6 +360,11 @@ Drop it in `themes/` — it appears in the theme selector automatically.
--- ---
</details>
<details>
<summary>Multiple EPG Sources</summary>
## 📡 Multiple EPG Sources ## 📡 Multiple EPG Sources
Configure as many EPG/M3U sources as you want in `config.json`. A dropdown appears in the topbar when more than one source is defined. Configure as many EPG/M3U sources as you want in `config.json`. A dropdown appears in the topbar when more than one source is defined.
@@ -276,6 +373,11 @@ If `allow_personal_epg` is `true`, a **"✏ Personal EPG"** option appears in th
--- ---
</details>
<details>
<summary>Built-in Player</summary>
## ▶️ Built-in Player ## ▶️ Built-in Player
Click on any **channel name** or **currently airing program** to open a PiP player in the bottom-right corner. Click on any **channel name** or **currently airing program** to open a PiP player in the bottom-right corner.
@@ -284,6 +386,11 @@ Requires an **M3U URL** in the active source and the **php-curl** extension. The
--- ---
</details>
<details>
<summary>Advanced configuration</summary>
## ⚙️ Advanced configuration ## ⚙️ Advanced configuration
Tweak these constants in `src/js/config.js`: Tweak these constants in `src/js/config.js`:
@@ -296,6 +403,11 @@ const ROW_H = 80; // Channel row height (px)
--- ---
</details>
<details>
<summary>EPG Compatibility</summary>
## 🧩 EPG Compatibility ## 🧩 EPG Compatibility
GridTV parses the standard **XMLTV format**. Tested with: GridTV parses the standard **XMLTV format**. Tested with:
@@ -308,6 +420,11 @@ GridTV parses the standard **XMLTV format**. Tested with:
--- ---
</details>
<details>
<summary>Internationalization</summary>
## 🌍 Internationalization ## 🌍 Internationalization
GridTV auto-detects the visitor's browser language and serves the matching locale. Supported out of the box: **English**, **French**, **Spanish**. GridTV auto-detects the visitor's browser language and serves the matching locale. Supported out of the box: **English**, **French**, **Spanish**.
@@ -323,18 +440,33 @@ GridTV will automatically pick it up for browsers with that language set — no
--- ---
</details>
<details>
<summary>Contributing</summary>
## 🤝 Contributing ## 🤝 Contributing
PRs are welcome! Got an idea, a fix, or a feature request — open an issue or send a PR. PRs are welcome! Got an idea, a fix, or a feature request — open an issue or send a PR.
--- ---
</details>
<details>
<summary>License</summary>
## 📄 License ## 📄 License
GNU Affero General Public License v3.0 (AGPLv3) GNU Affero General Public License v3.0 (AGPLv3)
--- ---
</details>
<details>
<summary>Roadmap</summary>
## 🗺️ Roadmap ## 🗺️ Roadmap
### ✅ Done ### ✅ Done
@@ -345,15 +477,17 @@ GNU Affero General Public License v3.0 (AGPLv3)
- Theme system (4 built-in themes) - Theme system (4 built-in themes)
- Multi-EPG sources with topbar switcher - Multi-EPG sources with topbar switcher
- Personal EPG (localStorage) - Personal EPG (localStorage)
- Modular codebase (src/tpl + src/js) - Modular codebase (src/tpl + src/js + src/css)
- Docker support - Docker support
- **Search** — filter grid by channel name or program title/synopsis (debounced) - **Search** — filter grid by channel name or program title/synopsis (debounced)
- **Date in timebar** — midnight markers with day/date in the ruler - **Date in timebar** — midnight markers with day/date in the ruler
- **Program popup** — full details + IMDb search link on any program click - **Program popup** — full details + IMDb search link on any program click
- **i18n** — EN / FR / ES with browser auto-detection, extensible via locales/ - **i18n** — EN / FR / ES with browser auto-detection, extensible via locales/
- **Favorites** — pin channels to the top of the grid (localStorage)
- **Setup re-editable** — protected by an admin key, auto-generated on first setup
- **Update notifications** — topbar badge links to latest GitHub release
### 🔜 Coming soon ### 🔜 Coming soon
- **Favorites** — pin channels to the top of the grid (localStorage)
- **Setup re-editable** — reopen setup.php with an admin key, no SSH required
- **Dark/Light auto mode** — follow system preference when using the default theme - **Dark/Light auto mode** — follow system preference when using the default theme
- **Grid export** — export today's schedule as PDF or image - **Grid export** — export today's schedule as PDF or image
</details>
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

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

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

+2
View File
File diff suppressed because one or more lines are too long
-5
View File
@@ -1,5 +0,0 @@
{
"group_name": "MonGroupe TV",
"epg_url": "http://votre-serveur/api/xmltv.xml",
"m3u_url": "http://votre-serveur/api/channels.m3u"
}
+1 -1
View File
@@ -4,5 +4,5 @@ services:
ports: ports:
- "${PORT:-8080}:80" - "${PORT:-8080}:80"
volumes: volumes:
- ./config.json:/var/www/html/config.json - ./data:/data
restart: unless-stopped restart: unless-stopped
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -eu
mkdir -p /data
chown -R www-data:www-data /data
chmod 775 /data
rm -f /var/www/html/config.json
ln -s /data/config.json /var/www/html/config.json
exec docker-php-entrypoint "$@"
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

+3 -1
View File
@@ -49,5 +49,7 @@
"shift_back": "◄ 1h", "shift_back": "◄ 1h",
"shift_fwd": "1h ►", "shift_fwd": "1h ►",
"fav_add": "Add to favorites", "fav_add": "Add to favorites",
"fav_remove": "Remove from favorites" "fav_remove": "Remove from favorites",
"update_available": "&#8593; Update available:",
"update_title": "Click to download the latest version"
} }
+3 -1
View File
@@ -49,5 +49,7 @@
"shift_back": "◄ 1h", "shift_back": "◄ 1h",
"shift_fwd": "1h ►", "shift_fwd": "1h ►",
"fav_add": "Agregar a favoritos", "fav_add": "Agregar a favoritos",
"fav_remove": "Quitar de favoritos" "fav_remove": "Quitar de favoritos",
"update_available": "&#8593; Actualizacion disponible:",
"update_title": "Haz clic para descargar la ultima version"
} }
+3 -1
View File
@@ -49,5 +49,7 @@
"shift_back": "◄ 1h", "shift_back": "◄ 1h",
"shift_fwd": "1h ►", "shift_fwd": "1h ►",
"fav_add": "Ajouter aux favoris", "fav_add": "Ajouter aux favoris",
"fav_remove": "Retirer des favoris" "fav_remove": "Retirer des favoris",
"update_available": "&#8593; Mise a jour disponible :",
"update_title": "Cliquez pour telecharger la derniere version"
} }
+24
View File
@@ -0,0 +1,24 @@
{
"name": "GridTV",
"short_name": "GridTV",
"description": "Self-hosted IPTV TV guide",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0b0d",
"theme_color": "#0a0b0d",
"orientation": "any",
"icons": [
{
"src": "assets/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "assets/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+175 -40
View File
@@ -1,81 +1,216 @@
<?php <?php
/** /**
* GridTV — proxy.php (Apache + cURL) * GridTV — proxy.php
*
* HTTP->HTTPS proxy restricted to hosts explicitly configured in config.json.
* Private and LAN IPs are allowed only when the administrator configured them.
* Redirects are followed manually and the host is revalidated at every hop.
*/ */
$url = $_GET['url'] ?? ''; // ── Allowlist: authorized hosts extracted from config.json ────────────────────
if (empty($url) || !preg_match('#^https?://#i', $url)) { $config_path = __DIR__ . '/config.json';
http_response_code(400); die('Invalid URL'); $allowed_hosts = [];
if (file_exists($config_path)) {
$config = json_decode(file_get_contents($config_path), true) ?? [];
foreach ($config['epg_sources'] ?? [] as $src) {
foreach (['epg_url', 'm3u_url'] as $key) {
if (!empty($src[$key])) {
$host = strtolower(parse_url($src[$key], PHP_URL_HOST) ?? '');
if ($host !== '') $allowed_hosts[] = $host;
}
}
}
} }
$base = preg_replace('#[^/]*(\?.*)?$#', '', $url); // ── Helpers ────────────────────────────────────────────────────────────────────
$origin = parse_url($url, PHP_URL_SCHEME) . '://' . parse_url($url, PHP_URL_HOST);
$port = parse_url($url, PHP_URL_PORT); function is_allowed_url(string $url, array $allowed_hosts): bool {
if ($port) $origin .= ':' . $port; if (!preg_match('#^https?://#i', $url)) return false;
$host = strtolower(parse_url($url, PHP_URL_HOST) ?? '');
return $host !== '' && in_array($host, $allowed_hosts, true);
}
function normalize_path(string $path): string {
$segments = explode('/', $path);
$normalized = [];
foreach ($segments as $segment) {
if ($segment === '' || $segment === '.') {
continue;
}
if ($segment === '..') {
array_pop($normalized);
continue;
}
$normalized[] = $segment;
}
return '/' . implode('/', $normalized);
}
function resolve_url(string $base, string $location): string {
if (preg_match('#^https?://#i', $location)) return $location;
$parts = parse_url($base);
$origin = $parts['scheme'] . '://' . $parts['host'];
if (!empty($parts['port'])) $origin .= ':' . $parts['port'];
if (strpos($location, '//') === 0) {
return $parts['scheme'] . ':' . $location;
}
if ($location === '') {
return $base;
}
if ($location[0] === '#') {
$without_fragment = preg_replace('/#.*$/', '', $base);
return $without_fragment . $location;
}
if ($location[0] === '?') {
$path = $parts['path'] ?? '/';
return $origin . $path . $location;
}
$location_parts = parse_url($location);
$path = $location_parts['path'] ?? '';
$query = isset($location_parts['query']) ? '?' . $location_parts['query'] : '';
$fragment = isset($location_parts['fragment']) ? '#' . $location_parts['fragment'] : '';
if ($path !== '' && $path[0] === '/') {
return $origin . normalize_path($path) . $query . $fragment;
}
$base_dir = dirname($parts['path'] ?? '/');
if ($base_dir === '\\' || $base_dir === '.') {
$base_dir = '/';
}
$combined = rtrim($base_dir, '/') . '/' . $path;
return $origin . normalize_path($combined) . $query . $fragment;
}
/**
* Fetch with manual redirect handling so every Location header is checked
* against the allowlist before following it.
*/
function fetch_with_checked_redirects(string $url, array $allowed_hosts, bool $head_only = false): array {
$max_redirects = 5;
for ($i = 0; $i <= $max_redirects; $i++) {
if (!is_allowed_url($url, $allowed_hosts)) {
http_response_code(403); die('Host not allowed after redirect');
}
$ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_HEADER => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_NOBODY => $head_only,
CURLOPT_TIMEOUT => 30,
CURLOPT_USERAGENT => $ua,
CURLOPT_HTTPHEADER => ['Accept: */*'],
]);
$response = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
if ($response === false) {
http_response_code(502); die('Upstream error');
}
$raw_headers = substr($response, 0, $header_size);
$body = substr($response, $header_size);
// Follow redirects manually so each hop is rechecked against the allowlist.
if ($code >= 300 && $code < 400) {
if (!preg_match('/^Location:\s*(.+)$/mi', $raw_headers, $m)) {
http_response_code(502); die('Invalid redirect');
}
$url = resolve_url($url, trim($m[1]));
continue;
}
return [$code, $raw_headers, $body, $url];
}
http_response_code(508); die('Too many redirects');
}
// ── Validate the initial URL ───────────────────────────────────────────────────
$url = $_GET['url'] ?? '';
if (!is_allowed_url($url, $allowed_hosts)) {
http_response_code(empty($allowed_hosts) ? 503 : 403);
die(empty($allowed_hosts) ? 'No sources configured' : 'Host not allowed');
}
// ── Detect the resource type ───────────────────────────────────────────────────
$path = parse_url($url, PHP_URL_PATH) ?? ''; $path = parse_url($url, PHP_URL_PATH) ?? '';
$is_segment = preg_match('#\.(ts|aac|mp4|m4s|fmp4)(\?|$)#i', $path); $is_segment = preg_match('#\.(ts|aac|mp4|m4s|fmp4)(\?|$)#i', $path);
header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Origin: *');
header('Cache-Control: no-cache'); header('Cache-Control: no-cache');
$ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0'; // ── Binary segment: stream chunk by chunk ──────────────────────────────────────
if ($is_segment) { if ($is_segment) {
// Segment binaire — stream via cURL chunk par chunk
header('Content-Type: video/MP2T'); header('Content-Type: video/MP2T');
header('X-Content-Type-Options: nosniff'); header('X-Content-Type-Options: nosniff');
if (ob_get_level()) ob_end_clean(); if (ob_get_level()) ob_end_clean();
$ch = curl_init($url); // Resolve redirects with HEAD first so we do not download the segment twice.
// Some HLS/CDN servers reject HEAD on media objects even when GET works.
[$code, , , $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, true);
if ($code === 403 || $code === 405) {
[, , , $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, false);
$code = 200;
}
if ($code >= 400) { http_response_code($code); die(); }
// Stream the validated final URL.
$ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0';
$ch = curl_init($final_url);
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_FOLLOWLOCATION => true, CURLOPT_FOLLOWLOCATION => false,
CURLOPT_TIMEOUT => 30, CURLOPT_TIMEOUT => 30,
CURLOPT_USERAGENT => $ua, CURLOPT_USERAGENT => $ua,
CURLOPT_HTTPHEADER => ['Accept: */*'], CURLOPT_HTTPHEADER => ['Accept: */*'],
CURLOPT_RETURNTRANSFER => false, CURLOPT_RETURNTRANSFER => false,
CURLOPT_WRITEFUNCTION => function($ch, $data) { CURLOPT_WRITEFUNCTION => function($ch, $data) {
echo $data; echo $data; flush(); return strlen($data);
flush();
return strlen($data);
}, },
CURLOPT_HEADERFUNCTION => function($ch, $header) { CURLOPT_HEADERFUNCTION => function($ch, $header) {
$h = trim($header); $h = trim($header);
if (preg_match('/^Content-Type:/i', $h)) { if (preg_match('/^Content-Type:/i', $h)) header($h);
header($h);
}
return strlen($header); return strlen($header);
}, },
]); ]);
$ok = curl_exec($ch); $ok = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (!$ok || $code >= 400) { if (!$ok || $code >= 400) http_response_code($code ?: 502);
http_response_code($code ?: 502);
}
curl_close($ch); curl_close($ch);
// ── m3u8 playlist: fetch and rewrite URLs ──────────────────────────────────────
} else { } else {
// Playlist .m3u8 — fetch + réécriture URLs [$code, , $body, $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, false);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_USERAGENT => $ua,
CURLOPT_HTTPHEADER => ['Accept: */*'],
CURLOPT_RETURNTRANSFER => true,
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $code >= 400) { if ($code >= 400) { http_response_code($code); die("Upstream error $code"); }
http_response_code($code ?: 502);
die("Upstream error $code");
}
header('Content-Type: application/vnd.apple.mpegurl'); header('Content-Type: application/vnd.apple.mpegurl');
$final_parts = parse_url($final_url);
$origin = $final_parts['scheme'] . '://' . $final_parts['host'];
if (!empty($final_parts['port'])) $origin .= ':' . $final_parts['port'];
$base = preg_replace('#[^/]*(\?.*)?$#', '', $final_url);
$proxy_base = (isset($_SERVER['HTTPS']) ? 'https' : 'http') $proxy_base = (isset($_SERVER['HTTPS']) ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST'] . '://' . $_SERVER['HTTP_HOST']
. strtok($_SERVER['REQUEST_URI'], '?') . strtok($_SERVER['REQUEST_URI'], '?')
@@ -85,7 +220,7 @@ if ($is_segment) {
foreach (explode("\n", $body) as $line) { foreach (explode("\n", $body) as $line) {
$line = rtrim($line); $line = rtrim($line);
if ($line === '' || $line[0] === '#') { if ($line === '' || $line[0] === '#') {
$line = preg_replace_callback('/URI="([^"]+)"/', function($m) use ($base, $proxy_base) { $line = preg_replace_callback('/URI="([^"]+)"/', function($m) use ($base, $origin, $proxy_base) {
$seg = strpos($m[1], 'http') === 0 ? $m[1] $seg = strpos($m[1], 'http') === 0 ? $m[1]
: ($m[1][0] === '/' ? $origin . $m[1] : $base . $m[1]); : ($m[1][0] === '/' ? $origin . $m[1] : $base . $m[1]);
return 'URI="' . $proxy_base . urlencode($seg) . '"'; return 'URI="' . $proxy_base . urlencode($seg) . '"';
+15 -4
View File
@@ -1,9 +1,10 @@
<?php <?php
$config_path = __DIR__ . '/config.json'; $config_path = __DIR__ . '/config.json';
// Migration automatique ancien format // Automatically migrate the legacy config format.
if (file_exists($config_path)) { if (file_exists($config_path)) {
$existing = json_decode(file_get_contents($config_path), true); $existing = json_decode(file_get_contents($config_path), true);
if (is_array($existing)) {
if (isset($existing['epg_url']) && !isset($existing['epg_sources'])) { if (isset($existing['epg_url']) && !isset($existing['epg_sources'])) {
$existing['epg_sources'] = [[ $existing['epg_sources'] = [[
'name' => 'Main', 'name' => 'Main',
@@ -14,15 +15,25 @@ if (file_exists($config_path)) {
unset($existing['epg_url'], $existing['m3u_url']); unset($existing['epg_url'], $existing['m3u_url']);
file_put_contents($config_path, json_encode($existing, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); file_put_contents($config_path, json_encode($existing, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
} }
// Bootstrap : config existante sans cle admin -> on en genere une maintenant // Bootstrap an admin key for older configs that do not have one yet.
if (empty($existing['admin_key'])) { if (empty($existing['admin_key'])) {
$existing['admin_key'] = bin2hex(random_bytes(16)); $existing['admin_key'] = bin2hex(random_bytes(16));
file_put_contents($config_path, json_encode($existing, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); file_put_contents($config_path, json_encode($existing, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
} }
}
} }
$is_first_setup = !file_exists($config_path); $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; $admin_key = $existing['admin_key'] ?? null;
$mode = 'setup'; $mode = 'setup';
@@ -109,7 +120,7 @@ $submitted_key = trim($_POST['admin_key_input'] ?? $_POST['admin_key_hidden']
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GridTV — Setup</title> <title>GridTV — Setup</title>
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Barlow+Condensed:wght@300;400;600;700&display=swap" rel="stylesheet"> <link rel="stylesheet" href="/assets/fonts/fonts.css">
<style> <style>
:root { :root {
--bg:#0a0b0d;--surface:#111318;--surface2:#181b22; --bg:#0a0b0d;--surface:#111318;--surface2:#181b22;
+15 -4
View File
@@ -8,8 +8,12 @@ if (!file_exists($config_path)) {
} }
$config = json_decode(file_get_contents($config_path), true); $config = json_decode(file_get_contents($config_path), true);
if (!is_array($config)) {
http_response_code(500);
die('<h2>GridTV — Configuration error</h2><p><code>config.json</code> is invalid or corrupted.<br>Please delete it and re-run <a href="setup.php">setup</a>, or fix it manually via SSH.</p>');
}
// ── Migration automatique ancien format → nouveau ───────────────────────────── // ── Automatically migrate the legacy config format to the current one ─────────
if (isset($config['epg_url']) && !isset($config['epg_sources'])) { if (isset($config['epg_url']) && !isset($config['epg_sources'])) {
$config['epg_sources'] = [[ $config['epg_sources'] = [[
'name' => 'Main', 'name' => 'Main',
@@ -26,21 +30,28 @@ $group_name = htmlspecialchars($config['group_name'] ?? 'GridTV');
$epg_sources = $config['epg_sources'] ?? []; $epg_sources = $config['epg_sources'] ?? [];
$allow_personal_epg = !empty($config['allow_personal_epg']); $allow_personal_epg = !empty($config['allow_personal_epg']);
// Première source par défaut (pour compatibilité avec le reste du code) // Default to the first configured source to keep the rest of the app compatible.
$first_source = $epg_sources[0] ?? []; $first_source = $epg_sources[0] ?? [];
$epg_url = htmlspecialchars($first_source['epg_url'] ?? ''); $epg_url = htmlspecialchars($first_source['epg_url'] ?? '');
$m3u_url = htmlspecialchars($first_source['m3u_url'] ?? ''); $m3u_url = htmlspecialchars($first_source['m3u_url'] ?? '');
// ── Locale detection ───────────────────────────────────────────────────────── // ── Locale detection ─────────────────────────────────────────────────────────
$supported_locales = ['en', 'fr', 'es']; // Auto-discover available locales from the locales/ directory.
$locale_files = glob(__DIR__ . '/../locales/*.json') ?: [];
$supported_locales = array_map(fn($f) => basename($f, '.json'), $locale_files);
$locale = 'en'; $locale = 'en';
$accept = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? 'en'; $accept = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? 'en';
foreach (explode(',', $accept) as $lang) { foreach (explode(',', $accept) as $lang) {
$code = strtolower(substr(trim($lang), 0, 2)); $code = strtolower(substr(trim($lang), 0, 2));
if (in_array($code, $supported_locales)) { $locale = $code; break; } if (in_array($code, $supported_locales, true)) { $locale = $code; break; }
} }
$locale_path = __DIR__ . "/../locales/{$locale}.json"; $locale_path = __DIR__ . "/../locales/{$locale}.json";
$L = json_decode(file_get_contents($locale_path), true); $L = json_decode(file_get_contents($locale_path), true);
if (!is_array($L)) {
// Fallback to English if the locale file is missing or corrupted.
$fallback_path = __DIR__ . '/../locales/en.json';
$L = is_file($fallback_path) ? (json_decode(file_get_contents($fallback_path), true) ?? []) : [];
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
+36
View File
@@ -0,0 +1,36 @@
/* Base variables overridden by the active theme file */
:root {
color-scheme: dark;
--bg: #0a0b0d;
--surface: #111318;
--surface2: #181b22;
--border: #232733;
--border-bright: #2e3444;
--accent: #e8c842;
--accent2: #4a9eff;
--accent-live: #ff4444;
--text: #c8cdd8;
--text-dim: #5a6070;
--text-bright: #eef0f5;
--prog-bg: #141820;
--prog-border: #1e2a3a;
--prog-live-bg: #0d2040;
--prog-live-border: #4a9eff;
--font-ui: 'Barlow Condensed', sans-serif;
--font-mono: 'Share Tech Mono', monospace;
--channel-w: 160px;
--row-h: 80px;
--header-h: 52px;
}
html, body { height: 100%; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-ui);
font-size: 14px;
overflow: hidden;
display: flex;
flex-direction: column;
}
+16
View File
@@ -0,0 +1,16 @@
/* ── FAVORITES ───────────────────────────────────────────────────────────── */
.fav-btn {
background: none; border: none; color: var(--text-muted);
cursor: pointer; font-size: 14px; line-height: 1;
margin-left: auto; padding: 2px 4px; flex-shrink: 0;
transition: color .15s, transform .15s;
opacity: 0;
}
.channel-cell:hover .fav-btn { opacity: 1; }
.fav-btn.is-fav { color: var(--accent); opacity: 1; }
.fav-btn:hover { transform: scale(1.2); }
.fav-separator {
height: 2px; background: var(--accent);
opacity: .3; margin: 0;
}
+215
View File
@@ -0,0 +1,215 @@
/* ═══════════════════════════════════════════
GRID VIEW (desktop / tablet)
═══════════════════════════════════════════ */
.grid-wrapper {
flex: 1;
display: flex;
overflow: hidden;
position: relative;
min-height: 0;
}
.channels-col {
width: var(--channel-w);
flex-shrink: 0;
background: var(--surface);
border-right: 1px solid var(--border-bright);
z-index: 10;
display: flex;
flex-direction: column;
min-height: 0;
}
.channels-col-header {
height: var(--header-h);
border-bottom: 1px solid var(--border-bright);
display: flex;
align-items: center;
padding: 0 12px;
flex-shrink: 0;
}
.channels-col-header span {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
color: var(--text-dim);
letter-spacing: 0.15em;
text-transform: uppercase;
}
.channels-list {
overflow-y: auto;
flex: 1;
scrollbar-width: none;
}
.channels-list::-webkit-scrollbar { display: none; }
.channel-cell {
height: var(--row-h);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 12px;
gap: 8px;
flex-shrink: 0;
}
.channel-logo {
width: 32px; height: 32px;
border-radius: 4px;
object-fit: contain;
background: var(--surface2);
flex-shrink: 0;
}
.channel-logo-placeholder {
width: 32px; height: 32px;
border-radius: 4px;
background: var(--surface2);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
color: var(--text-dim);
flex-shrink: 0;
}
.channel-name {
font-size: 12px;
font-weight: 600;
color: var(--text-bright);
letter-spacing: 0.04em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-transform: uppercase;
}
.timeline-area {
flex: 1;
overflow: auto;
position: relative;
min-height: 0;
scrollbar-width: thin;
scrollbar-color: var(--border-bright) transparent;
}
.timeline-area::-webkit-scrollbar { height: 4px; width: 4px; }
.timeline-area::-webkit-scrollbar-track { background: transparent; }
.timeline-area::-webkit-scrollbar-thumb { background: var(--border-bright); border-radius: 2px; }
/* Single container so the ruler and programs scroll together */
.timeline-inner {
position: relative;
display: block;
min-width: 100%;
}
.time-ruler {
height: var(--header-h);
background: var(--surface);
border-bottom: 1px solid var(--border-bright);
position: sticky;
top: 0;
z-index: 20;
}
.time-ruler-inner {
position: relative;
height: 100%;
}
.time-tick {
position: absolute;
bottom: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.time-tick-label {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
color: var(--text-dim);
letter-spacing: 0.08em;
padding: 0 6px 6px;
white-space: nowrap;
}
.time-tick-label.hour { color: var(--text); font-size: 12px; }
.time-tick-line { width: 1px; height: 8px; background: var(--border-bright); }
.time-tick-line.hour { height: 14px; }
.now-line {
position: absolute;
top: 0;
width: 2px;
height: 100%;
background: var(--accent-live);
z-index: 50;
pointer-events: none;
}
.now-line::before {
content: '';
position: absolute;
top: 0; left: -4px;
width: 10px; height: 10px;
background: var(--accent-live);
border-radius: 50%;
}
.programs-inner {
position: relative;
background-image: repeating-linear-gradient(
to bottom,
transparent 0px, transparent 79px,
var(--border) 79px, var(--border) 80px
);
}
.program-row {
height: var(--row-h);
border-bottom: 1px solid var(--border);
position: absolute;
left: 0; right: 0;
}
.program-block {
position: absolute;
top: 5px; bottom: 5px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0 8px;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
cursor: default;
transition: background 0.12s, border-color 0.12s;
min-width: 2px;
}
.program-block:hover { background: var(--surface); border-color: var(--accent2); z-index: 5; }
.program-block.is-live { border-color: var(--accent2); background: #0d1a2e; }
.program-block.is-live::after {
content: '';
position: absolute;
bottom: 0; left: 0;
height: 2px;
background: var(--accent2);
width: var(--progress, 0%);
transition: width 1s linear;
}
.program-block.is-past { opacity: 0.35; }
.program-title {
font-size: 12px;
font-weight: 600;
color: var(--text-bright);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 0.02em;
}
.program-time {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
color: var(--text-dim);
letter-spacing: 0.06em;
white-space: nowrap;
}
+210
View File
@@ -0,0 +1,210 @@
/* ═══════════════════════════════════════════
MOBILE LIST VIEW
═══════════════════════════════════════════ */
.mobile-view {
display: none;
flex: 1;
overflow-y: auto;
min-height: 0;
-webkit-overflow-scrolling: touch;
}
.mobile-channel {
border-bottom: 2px solid var(--border-bright);
}
.mobile-channel-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--surface);
position: sticky;
top: 0;
z-index: 10;
border-bottom: 1px solid var(--border);
}
.mobile-programs {
padding: 6px 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
.mobile-program {
display: flex;
align-items: stretch;
border-radius: 4px;
overflow: hidden;
border: 1px solid var(--border);
background: var(--surface2);
position: relative;
}
.mobile-program.is-live { border-color: var(--accent2); background: #0d1a2e; }
.mobile-program.is-past { opacity: 0.4; }
.mobile-program-progress {
position: absolute;
bottom: 0; left: 0;
height: 2px;
background: var(--accent2);
}
.mobile-program-time {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
color: var(--text-dim);
padding: 8px 10px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
min-width: 72px;
flex-shrink: 0;
gap: 2px;
}
.mobile-program.is-live .mobile-program-time { color: var(--accent2); }
.mobile-program-info {
padding: 8px 10px;
flex: 1;
min-width: 0;
}
.mobile-program-title {
font-size: 13px;
font-weight: 600;
color: var(--text-bright);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mobile-program-dur {
font-size: 10px;
color: var(--text-dim);
margin-top: 2px;
font-family: 'Share Tech Mono', monospace;
}
.mobile-live-badge {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
color: var(--accent-live);
background: rgba(255,68,68,0.12);
border: 1px solid var(--accent-live);
border-radius: 2px;
padding: 2px 5px;
letter-spacing: 0.1em;
align-self: center;
margin-right: 8px;
flex-shrink: 0;
}
/* TOOLTIP */
.tooltip {
position: fixed;
background: var(--surface);
border: 1px solid var(--accent2);
border-radius: 4px;
padding: 10px 14px;
z-index: 999;
pointer-events: none;
max-width: 300px;
opacity: 0;
transition: opacity 0.15s;
}
.tooltip.visible { opacity: 1; }
.tooltip-title { font-size: 14px; font-weight: 700; color: var(--text-bright); margin-bottom: 4px; }
.tooltip-time { font-family: 'Share Tech Mono', monospace; font-size: 10px; color: var(--accent2); margin-bottom: 6px; }
.tooltip-desc { font-size: 12px; color: var(--text); line-height: 1.4; max-height: 80px; overflow: hidden; }
/* LOADING */
.loading {
position: fixed; inset: 0;
background: var(--bg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1000;
gap: 20px;
}
.loading-text {
font-family: 'Share Tech Mono', monospace;
font-size: 12px;
color: var(--text-dim);
letter-spacing: 0.2em;
text-transform: uppercase;
}
.loading-bar {
width: 200px; height: 2px;
background: var(--border);
position: relative;
overflow: hidden;
}
.loading-bar::after {
content: '';
position: absolute;
top: 0; left: -60%;
width: 60%; height: 100%;
background: var(--accent);
animation: loading-anim 1.2s ease-in-out infinite;
}
@keyframes loading-anim { to { left: 110%; } }
.error-msg {
position: fixed; inset: 0;
background: var(--bg);
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1000;
gap: 16px;
display: none;
padding: 20px;
}
.err-title { font-family: 'Share Tech Mono', monospace; font-size: 14px; color: var(--accent-live); letter-spacing: 0.1em; }
.err-sub { font-size: 13px; color: var(--text-dim); text-align: center; max-width: 400px; line-height: 1.5; }
.epg-input-bar { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; justify-content: center; }
.epg-input {
background: var(--surface2);
border: 1px solid var(--border-bright);
color: var(--text-bright);
font-family: 'Share Tech Mono', monospace;
font-size: 12px;
padding: 8px 12px;
width: 320px;
outline: none;
}
.epg-input:focus { border-color: var(--accent); }
.epg-btn {
background: var(--accent);
border: none; color: #000;
font-family: 'Barlow Condensed', sans-serif;
font-weight: 700; font-size: 13px;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 8px 18px;
cursor: pointer;
}
@media (max-width: 600px) {
html, body { height: 100%; overflow: hidden; }
body { overflow: hidden; }
.topbar {
height: auto;
flex-wrap: wrap;
padding: 8px 12px;
gap: 8px;
}
.logo { font-size: 11px; }
.clock { font-size: 14px; }
.grid-wrapper { display: none; }
.mobile-view { display: block; }
.tooltip { display: none !important; }
#navBtns { display: none !important; }
}
+40
View File
@@ -0,0 +1,40 @@
/* ── MODAL PERSONAL EPG ─────────────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
z-index: 3000; display: none; align-items: center; justify-content: center;
}
.modal-overlay.visible { display: flex; }
.modal-card {
background: var(--surface); border: 1px solid var(--border-bright);
padding: 28px; width: 100%; max-width: 420px; margin: 16px;
}
.modal-title {
font-family: var(--font-mono); font-size: 13px; color: var(--accent);
letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 6px;
}
.modal-subtitle { font-size: 12px; color: var(--text-dim); margin-bottom: 20px; line-height: 1.5; }
.modal-field { margin-bottom: 14px; }
.modal-field label {
display: block; font-family: var(--font-mono); font-size: 10px;
letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-dim); margin-bottom: 6px;
}
.modal-field input {
width: 100%; background: var(--surface2); border: 1px solid var(--border-bright);
color: var(--text-bright); font-family: var(--font-mono); font-size: 12px;
padding: 9px 10px; outline: none; transition: border-color 0.15s;
}
.modal-field input:focus { border-color: var(--accent); }
.modal-field input::placeholder { color: var(--text-dim); opacity: 0.4; }
.modal-actions { display: flex; gap: 10px; margin-top: 20px; justify-content: flex-end; }
.modal-btn-cancel {
background: none; border: 1px solid var(--border-bright); color: var(--text-dim);
font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em;
text-transform: uppercase; padding: 8px 16px; cursor: pointer; transition: border-color 0.15s;
}
.modal-btn-cancel:hover { border-color: var(--text-dim); color: var(--text); }
.modal-btn-apply {
background: var(--accent); border: none; color: #000;
font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em;
text-transform: uppercase; padding: 8px 20px; cursor: pointer; transition: opacity 0.15s;
}
.modal-btn-apply:hover { opacity: 0.85; }
+78
View File
@@ -0,0 +1,78 @@
/* ═══════════════════════════════════════════
PLAYER PiP
═══════════════════════════════════════════ */
.pip {
position: fixed;
bottom: 24px;
right: 24px;
width: 380px;
background: #000;
border: 1px solid var(--border-bright);
box-shadow: 0 8px 32px rgba(0,0,0,0.8);
z-index: 2000;
display: none;
flex-direction: column;
}
.pip.visible { display: flex; }
.pip-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: var(--surface);
border-bottom: 1px solid var(--border);
gap: 8px;
flex-shrink: 0;
}
.pip-channel {
font-family: var(--font-mono);
font-size: 11px;
color: var(--accent);
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.pip-program {
font-size: 11px;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 2;
text-align: center;
}
.pip-close {
background: none;
border: none;
color: var(--text-dim);
font-size: 18px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
flex-shrink: 0;
transition: color 0.15s;
}
.pip-close:hover { color: var(--accent-live); }
.pip video {
width: 100%;
aspect-ratio: 16/9;
display: block;
background: #000;
}
.pip-error {
padding: 20px;
text-align: center;
font-family: var(--font-mono);
font-size: 11px;
color: var(--accent-live);
letter-spacing: 0.06em;
}
.channel-cell { cursor: pointer; }
.channel-cell:hover { background: var(--surface2); }
.program-block.is-live { cursor: pointer; }
@media (max-width: 600px) {
.pip { width: calc(100vw - 16px); bottom: 8px; right: 8px; left: 8px; }
}
+63
View File
@@ -0,0 +1,63 @@
/* ── PROGRAM MODAL ───────────────────────────────────────────────────────── */
.program-card {
max-width: 520px; padding: 28px; position: relative;
}
.pm-close {
position: absolute; top: 14px; right: 14px;
background: none; border: none; color: var(--text-muted);
cursor: pointer; font-size: 18px; line-height: 1;
transition: color .2s;
}
.pm-close:hover { color: var(--accent); }
.pm-channel {
font-size: 11px; letter-spacing: .1em; text-transform: uppercase;
color: var(--accent); margin-bottom: 6px;
}
.pm-title {
font-size: 20px; font-weight: 700; color: var(--text);
margin-bottom: 8px; line-height: 1.2;
}
.pm-subtitle {
font-size: 13px; color: var(--text); margin-bottom: 10px;
font-style: italic;
}
.pm-time {
font-size: 12px; color: var(--accent); margin-bottom: 10px;
}
.pm-meta {
font-size: 12px; color: var(--text-muted); margin-bottom: 10px;
}
.pm-genres {
font-size: 12px; color: var(--text); margin-bottom: 12px;
font-weight: 600;
}
.pm-desc {
font-size: 13px; color: var(--text-muted); line-height: 1.6;
margin-bottom: 20px; max-height: 140px; overflow-y: auto;
}
.pm-actions {
display: flex; gap: 10px; flex-wrap: wrap;
}
.pm-btn {
border-radius: 4px; cursor: pointer; font-family: inherit;
font-size: 13px; font-weight: 600; padding: 8px 16px;
text-decoration: none; transition: opacity .2s;
display: inline-flex; align-items: center; gap: 6px;
border: none;
}
.pm-btn:hover { opacity: .8; }
.pm-watch { background: var(--accent); color: var(--bg); }
.pm-imdb { background: #f5c518; color: #000; }
.pm-cancel { background: var(--bg); color: var(--text-muted); border: 1px solid var(--border); }
/* Midnight date marker in the timebar */
.time-tick-label.midnight {
color: var(--accent); font-weight: 700; font-size: 11px;
white-space: nowrap;
}
.time-tick-line.midnight {
background: var(--accent); opacity: .4; width: 2px;
}
/* ── FAVORITES ───────────────────────────────────────────────────────────── */
+39
View File
@@ -0,0 +1,39 @@
/* ── SEARCH ───────────────────────────────────────────────────────────────── */
.search-btn {
background: none; border: 1px solid var(--border); border-radius: 4px;
color: var(--text-muted); cursor: pointer; font-size: 14px;
padding: 4px 8px; transition: color .2s, border-color .2s;
}
.search-btn:hover { color: var(--accent); border-color: var(--accent); }
.search-bar {
display: none; align-items: center; gap: 8px;
background: var(--bg-card); border-bottom: 1px solid var(--border);
padding: 8px 16px;
}
.search-bar.visible { display: flex; }
.search-input {
flex: 1; background: var(--bg); border: 1px solid var(--border);
border-radius: 4px; color: var(--text); font-family: inherit;
font-size: 14px; outline: none; padding: 6px 12px;
transition: border-color .2s;
}
.search-input:focus { border-color: var(--accent); }
.search-input::placeholder { color: var(--text-muted); }
.search-clear {
background: none; border: none; color: var(--text-muted);
cursor: pointer; font-size: 16px; padding: 4px 8px;
transition: color .2s;
}
.search-clear:hover { color: var(--accent); }
/* Highlight programs that match the current query */
.program-block.search-match {
border: 1px solid var(--accent);
box-shadow: 0 0 6px var(--accent);
}
.mobile-program.search-match {
border-left: 3px solid var(--accent);
}
+224
View File
@@ -0,0 +1,224 @@
/* ═══════════════════════════════════════════
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);
}
/* ═══════════════════════════════════════════
RESPONSIVE
═══════════════════════════════════════════ */
@media (max-width: 900px) {
:root { --channel-w: 110px; }
.logo-sub { display: none; }
.date-label { display: none; }
.clock { font-size: 16px; }
}
/* ═══════════════════════════════════════════
THEME SELECTOR
═══════════════════════════════════════════ */
.theme-select {
background: transparent;
border: 1px solid var(--border-bright);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
padding: 4px 8px;
cursor: pointer;
text-transform: uppercase;
transition: all 0.15s;
flex-shrink: 0;
-webkit-appearance: none;
appearance: none;
}
.theme-select:hover { border-color: var(--accent2); color: var(--accent2); }
.theme-select option { background: #111; color: #eee; font-family: sans-serif; }
body { overflow: hidden; }
.topbar {
height: auto;
flex-wrap: wrap;
padding: 8px 12px;
gap: 8px;
}
.logo { font-size: 11px; }
.clock { font-size: 14px; }
.grid-wrapper { display: none; }
.mobile-view { display: block; }
.tooltip { display: none !important; }
#navBtns { display: none !important; }
}
/* ── SOURCE SELECTOR ─────────────────────────────────────────────────────── */
.source-select {
background-color: var(--surface2) !important;
border: 1px solid var(--border-bright);
color: var(--text) !important;
font-family: var(--font-mono);
font-size: 11px;
padding: 5px 8px;
cursor: pointer;
letter-spacing: 0.06em;
height: 28px;
outline: none;
transition: border-color 0.15s, color 0.15s;
max-width: 130px;
-webkit-appearance: none;
appearance: none;
}
.source-select:hover { border-color: var(--accent2); color: var(--accent2) !important; }
.source-select option { background: #111 !important; color: #eee !important; font-family: sans-serif; }
/* ── SOURCE DROPDOWN ─────────────────────────────────────────────────────── */
.source-dropdown {
position: relative;
flex-shrink: 0;
}
.source-dropdown-btn {
display: flex; align-items: center; gap: 6px;
background: var(--surface2); border: 1px solid var(--border-bright);
color: var(--text); font-family: var(--font-mono); font-size: 11px;
letter-spacing: 0.06em; padding: 5px 10px; height: 28px;
cursor: pointer; white-space: nowrap;
transition: border-color 0.15s, color 0.15s;
}
.source-dropdown-btn:hover { border-color: var(--accent2); color: var(--accent2); }
.source-dropdown-arrow { font-size: 8px; opacity: 0.6; }
.source-dropdown-menu {
display: none; position: absolute; top: calc(100% + 4px); left: 0;
background: var(--surface); border: 1px solid var(--border-bright);
min-width: 160px; z-index: 1000;
flex-direction: column;
box-shadow: 0 8px 24px rgba(0,0,0,0.6);
}
.source-dropdown-menu.open { display: flex; }
.source-dropdown-item {
background: none; border: none; color: var(--text);
font-family: var(--font-mono); font-size: 11px;
letter-spacing: 0.06em; padding: 10px 14px;
text-align: left; cursor: pointer; white-space: nowrap;
transition: background 0.1s, color 0.1s;
border-bottom: 1px solid var(--border);
}
.source-dropdown-item:last-child { border-bottom: none; }
.source-dropdown-item:hover { background: var(--surface2); color: var(--accent2); }
.source-dropdown-item.active { color: var(--accent); }
.source-dropdown-personal { color: var(--accent2); }
+13
View File
@@ -0,0 +1,13 @@
/* ── UPDATE BADGE ────────────────────────────────────────────────────────── */
.update-badge {
background: var(--accent); color: #000;
font-size: 11px; font-weight: 700; letter-spacing: 0.06em;
padding: 4px 10px; text-decoration: none; white-space: nowrap;
animation: pulse-badge 2s ease-in-out infinite;
border-radius: 2px; margin-left: 4px;
}
.update-badge:hover { opacity: .85; }
@keyframes pulse-badge {
0%, 100% { opacity: 1; }
50% { opacity: .65; }
}
+3 -3
View File
@@ -2,16 +2,16 @@ const EPG_SOURCES = <?= json_encode(array_values($epg_sources)) ?>;
const L = <?= json_encode($L) ?>; const L = <?= json_encode($L) ?>;
const ALLOW_PERSONAL_EPG = <?= $allow_personal_epg ? 'true' : 'false' ?>; const ALLOW_PERSONAL_EPG = <?= $allow_personal_epg ? 'true' : 'false' ?>;
// Source active (index) // Index of the currently selected source.
let activeSourceIndex = 0; let activeSourceIndex = 0;
const DEFAULT_EPG_URL = EPG_SOURCES[0]?.epg_url ?? ''; const DEFAULT_EPG_URL = EPG_SOURCES[0]?.epg_url ?? '';
const DEFAULT_M3U_URL = EPG_SOURCES[0]?.m3u_url ?? ''; const DEFAULT_M3U_URL = EPG_SOURCES[0]?.m3u_url ?? '';
const PX_PER_MIN = 5; const PX_PER_MIN = 5;
const GRID_HOURS = 72; const GRID_HOURS = 72;
const CORS_PROXY = 'https://corsproxy.io/?'; const CORS_PROXY = '/proxy.php?url=';
// Ancre : 2h avant maintenant, arrondi au quart d'heure inférieur // Anchor the grid 2 hours before "now", rounded down to the previous quarter-hour.
const _d = new Date(); const _d = new Date();
_d.setMinutes(Math.floor(_d.getMinutes()/15)*15, 0, 0); _d.setMinutes(Math.floor(_d.getMinutes()/15)*15, 0, 0);
const GRID_START = new Date(_d.getTime() - 2*3600000); const GRID_START = new Date(_d.getTime() - 2*3600000);
+1 -2
View File
@@ -73,7 +73,7 @@ function renderPrograms() {
inner.style.width = TOTAL_WIDTH_PX+'px'; inner.style.width = TOTAL_WIDTH_PX+'px';
inner.style.height = totalH+'px'; inner.style.height = totalH+'px';
// Remettre la now-line (détruite par innerHTML='') // Recreate the now-line because it was removed by innerHTML = ''.
const nowLine = document.createElement('div'); const nowLine = document.createElement('div');
nowLine.className = 'now-line'; nowLine.id = 'nowLine'; nowLine.className = 'now-line'; nowLine.id = 'nowLine';
nowLine.style.height = totalH + 'px'; nowLine.style.height = totalH + 'px';
@@ -141,4 +141,3 @@ function shiftView(mins) {
area.scrollLeft = Math.max(0, area.scrollLeft + mins * PX_PER_MIN); area.scrollLeft = Math.max(0, area.scrollLeft + mins * PX_PER_MIN);
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
} }
+2 -4
View File
@@ -13,8 +13,7 @@ window.addEventListener('load', () => {
if (epg) { if (epg) {
currentEpgUrl = epg; currentEpgUrl = epg;
currentM3uUrl = m3u; currentM3uUrl = m3u;
const sel = document.getElementById('sourceSelect'); updateSourceDropdownLabel('✏ Personal EPG');
if (sel) sel.value = 'personal';
loadEPG(epg); loadEPG(epg);
if (m3u) loadM3U(m3u); if (m3u) loadM3U(m3u);
return; return;
@@ -26,8 +25,7 @@ window.addEventListener('load', () => {
activeSourceIndex = idx; activeSourceIndex = idx;
currentEpgUrl = src.epg_url; currentEpgUrl = src.epg_url;
currentM3uUrl = src.m3u_url ?? ''; currentM3uUrl = src.m3u_url ?? '';
const sel = document.getElementById('sourceSelect'); if (src) updateSourceDropdownLabel(src.name);
if (sel) sel.value = idx;
} }
} }
loadEPG(currentEpgUrl); loadEPG(currentEpgUrl);
+5 -6
View File
@@ -1,8 +1,8 @@
// ── M3U PARSER ──────────────────────────────────────────────────────────────── // ── M3U PARSER ────────────────────────────────────────────────────────────────
function slugify(str) { function slugify(str) {
return str.toLowerCase() return str.toLowerCase()
.replace(/^\d+\s*/, '') // virer le numéro de chaîne en début .replace(/^\d+\s*/, '') // Strip any leading channel number.
.replace(/[^a-z0-9]/g, ''); // garder que alphanum .replace(/[^a-z0-9]/g, ''); // Keep only alphanumeric characters.
} }
async function loadM3U(url) { async function loadM3U(url) {
@@ -25,7 +25,7 @@ function parseM3U(text) {
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim(); const line = lines[i].trim();
if (line.startsWith('#EXTINF')) { if (line.startsWith('#EXTINF')) {
// Extraire le nom : dernière partie après la virgule // Extract the display name from the last comma-separated segment.
const comma = line.lastIndexOf(','); const comma = line.lastIndexOf(',');
currentName = comma !== -1 ? line.slice(comma + 1).trim() : null; currentName = comma !== -1 ? line.slice(comma + 1).trim() : null;
} else if (line && !line.startsWith('#') && currentName) { } else if (line && !line.startsWith('#') && currentName) {
@@ -42,16 +42,15 @@ function getStreamUrl(channelName) {
if (m3uStreams[slug]) { if (m3uStreams[slug]) {
url = m3uStreams[slug]; url = m3uStreams[slug];
} else { } else {
// Fallback : chercher une clé qui contient le slug ou l'inverse // Fallback: try a partial match in either direction.
for (const [key, u] of Object.entries(m3uStreams)) { for (const [key, u] of Object.entries(m3uStreams)) {
if (key.includes(slug) || slug.includes(key)) { url = u; break; } if (key.includes(slug) || slug.includes(key)) { url = u; break; }
} }
} }
if (!url) return null; if (!url) return null;
// Passer par proxy.php pour éviter le mixed content HTTP/HTTPS // Route plain HTTP streams through proxy.php to avoid mixed-content issues.
if (url.startsWith('http://')) { if (url.startsWith('http://')) {
return 'proxy.php?url=' + encodeURIComponent(url); return 'proxy.php?url=' + encodeURIComponent(url);
} }
return url; return url;
} }
+2 -2
View File
@@ -39,12 +39,12 @@ function renderMobile() {
} }
const timeCol = document.createElement('div'); timeCol.className='mobile-program-time'; const timeCol = document.createElement('div'); timeCol.className='mobile-program-time';
timeCol.innerHTML = `<span>${fmtTime(p.start)}</span><span>${fmtTime(p.stop)}</span>`; timeCol.innerHTML = `<span>${esc(fmtTime(p.start))}</span><span>${esc(fmtTime(p.stop))}</span>`;
item.appendChild(timeCol); item.appendChild(timeCol);
const info = document.createElement('div'); info.className='mobile-program-info'; const info = document.createElement('div'); info.className='mobile-program-info';
const ep = fmtEpisode(p.season, p.episode); const ep = fmtEpisode(p.season, p.episode);
info.innerHTML = `<div class="mobile-program-title">${p.title}</div><div class="mobile-program-dur">${ep ? ep + ' · ' : ''}${dur} min</div>`; info.innerHTML = `<div class="mobile-program-title">${esc(p.title)}</div><div class="mobile-program-dur">${ep ? esc(ep) + ' · ' : ''}${dur} min</div>`;
item.appendChild(info); item.appendChild(info);
if (isLive) { if (isLive) {
+2 -3
View File
@@ -12,7 +12,7 @@ function openPip(channelName, programTitle) {
err.style.display = 'none'; err.style.display = 'none';
pip.classList.add('visible'); pip.classList.add('visible');
// Détruire l'instance HLS précédente // Tear down the previous HLS instance before opening another stream.
if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; } if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; }
video.src = ''; video.src = '';
@@ -39,7 +39,7 @@ function openPip(channelName, programTitle) {
} }
}); });
} else if (video.canPlayType('application/vnd.apple.mpegurl')) { } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Safari natif HLS // Safari can play HLS natively without hls.js.
video.src = url; video.src = url;
video.addEventListener('loadedmetadata', () => video.play().catch(() => {})); video.addEventListener('loadedmetadata', () => video.play().catch(() => {}));
} else { } else {
@@ -55,4 +55,3 @@ function closePip() {
if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; } if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; }
video.pause(); video.src = ''; video.pause(); video.src = '';
} }
+12 -4
View File
@@ -4,16 +4,24 @@ function openProgramModal(ch, p) {
const isLive = p.start <= now && p.stop > now; const isLive = p.start <= now && p.stop > now;
const dur = Math.round((p.stop - p.start) / 60000); const dur = Math.round((p.stop - p.start) / 60000);
const ep = fmtEpisode(p.season, p.episode); const ep = fmtEpisode(p.season, p.episode);
const genres = formatCategories(p.categories);
const meta = [p.airDate, p.rating].filter(Boolean).join(' · ');
document.getElementById('pm-title').textContent = p.title; document.getElementById('pm-title').textContent = p.title;
document.getElementById('pm-channel').textContent = ch.name; document.getElementById('pm-channel').textContent = ch.name;
document.getElementById('pm-subtitle').textContent = p.subtitle || '';
document.getElementById('pm-subtitle').style.display = p.subtitle ? 'block' : 'none';
document.getElementById('pm-time').textContent = document.getElementById('pm-time').textContent =
`${fmtTime(p.start)}${fmtTime(p.stop)} · ${dur} ${L.duration_min}` + `${fmtTime(p.start)}${fmtTime(p.stop)} · ${dur} ${L.duration_min}` +
(ep ? ` · ${ep}` : ''); (ep ? ` · ${ep}` : '');
document.getElementById('pm-meta').textContent = meta;
document.getElementById('pm-meta').style.display = meta ? 'block' : 'none';
document.getElementById('pm-genres').textContent = genres;
document.getElementById('pm-genres').style.display = genres ? 'block' : 'none';
document.getElementById('pm-desc').textContent = p.desc || ''; document.getElementById('pm-desc').textContent = p.desc || '';
document.getElementById('pm-desc').style.display = p.desc ? 'block' : 'none'; document.getElementById('pm-desc').style.display = p.desc ? 'block' : 'none';
// Bouton Watch now — seulement si live ET M3U fourni pour la source active // Only show "Watch now" when the program is live and the active source has M3U data.
const watchBtn = document.getElementById('pm-watch'); const watchBtn = document.getElementById('pm-watch');
if (isLive && currentM3uUrl) { if (isLive && currentM3uUrl) {
watchBtn.style.display = 'inline-flex'; watchBtn.style.display = 'inline-flex';
@@ -22,7 +30,7 @@ function openProgramModal(ch, p) {
watchBtn.style.display = 'none'; watchBtn.style.display = 'none';
} }
// Bouton IMDb // IMDb search shortcut.
document.getElementById('pm-imdb').href = document.getElementById('pm-imdb').href =
`https://www.imdb.com/find/?q=${encodeURIComponent(p.title)}&s=tt`; `https://www.imdb.com/find/?q=${encodeURIComponent(p.title)}&s=tt`;
@@ -33,13 +41,13 @@ function closeProgramModal() {
document.getElementById('programModal').classList.remove('visible'); document.getElementById('programModal').classList.remove('visible');
} }
// Fermer sur clic overlay // Close when the overlay itself is clicked.
document.addEventListener('click', e => { document.addEventListener('click', e => {
const modal = document.getElementById('programModal'); const modal = document.getElementById('programModal');
if (e.target === modal) closeProgramModal(); if (e.target === modal) closeProgramModal();
}); });
// Fermer sur Escape // Close on Escape.
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeProgramModal(); if (e.key === 'Escape') closeProgramModal();
}); });
+19 -12
View File
@@ -31,7 +31,7 @@ function onSearchInput(val) {
function applySearch(q) { function applySearch(q) {
if (!q) { if (!q) {
// Remettre toutes les chaines // Restore the full channel list when the query is cleared.
renderAll(); renderAll();
return; return;
} }
@@ -40,15 +40,16 @@ function applySearch(q) {
today.setHours(0,0,0,0); today.setHours(0,0,0,0);
const tomorrow = new Date(today.getTime() + 24*3600000); const tomorrow = new Date(today.getTime() + 24*3600000);
// Filtrer les chaines : nom OU programme aujourd'hui correspond // Keep channels whose name matches or that have a matching program today.
const filtered = channels.filter(ch => { const filtered = channels.filter(ch => {
// Match sur le nom de la chaine // Match against the channel name.
if (ch.name.toLowerCase().includes(q)) return true; if (ch.name.toLowerCase().includes(q)) return true;
// Match sur les programmes d'aujourd'hui (titre + desc) // Match against today's programs (title + description).
return (programs[ch.id]||[]).some(p => { return (programs[ch.id]||[]).some(p => {
if (p.stop < today || p.start > tomorrow) return false; if (p.stop < today || p.start > tomorrow) return false;
if (p.title.toLowerCase().includes(q)) return true; if (p.title.toLowerCase().includes(q)) return true;
if (p.desc && p.desc.toLowerCase().includes(q)) return true; if (p.desc && p.desc.toLowerCase().includes(q)) return true;
if ((p.categories || []).some(cat => cat.toLowerCase().includes(q))) return true;
return false; return false;
}); });
}); });
@@ -72,7 +73,7 @@ function renderFiltered(filteredChannels, q) {
inner.style.width = TOTAL_WIDTH_PX + 'px'; inner.style.width = TOTAL_WIDTH_PX + 'px';
inner.style.height = (filteredChannels.length * ROW_H) + 'px'; inner.style.height = (filteredChannels.length * ROW_H) + 'px';
// Remettre la now-line // Recreate the now-line after clearing the container.
const nowLine = document.createElement('div'); const nowLine = document.createElement('div');
nowLine.className = 'now-line'; nowLine.id = 'nowLine'; nowLine.className = 'now-line'; nowLine.id = 'nowLine';
nowLine.style.height = (filteredChannels.length * ROW_H) + 'px'; nowLine.style.height = (filteredChannels.length * ROW_H) + 'px';
@@ -81,7 +82,7 @@ function renderFiltered(filteredChannels, q) {
const now = new Date(); const now = new Date();
filteredChannels.forEach((ch, i) => { filteredChannels.forEach((ch, i) => {
// Colonne chaîne // Channel column.
const cell = document.createElement('div'); const cell = document.createElement('div');
cell.className = 'channel-cell'; cell.className = 'channel-cell';
if (ch.icon) { if (ch.icon) {
@@ -96,7 +97,7 @@ function renderFiltered(filteredChannels, q) {
cell.addEventListener('click', () => openPip(ch.name, getNowPlaying(ch.id))); cell.addEventListener('click', () => openPip(ch.name, getNowPlaying(ch.id)));
list.appendChild(cell); list.appendChild(cell);
// Programmes // Programs row.
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'program-row'; row.className = 'program-row';
row.style.cssText = `top:${i*ROW_H}px;position:absolute;left:0;right:0;`; row.style.cssText = `top:${i*ROW_H}px;position:absolute;left:0;right:0;`;
@@ -109,8 +110,11 @@ function renderFiltered(filteredChannels, q) {
const block = document.createElement('div'); const block = document.createElement('div');
block.className = 'program-block'; block.className = 'program-block';
// Highlight si le programme matche la recherche // Highlight programs that match the current search query.
const matchesProg = p.title.toLowerCase().includes(q) || (p.desc && p.desc.toLowerCase().includes(q)); const matchesProg =
p.title.toLowerCase().includes(q) ||
(p.desc && p.desc.toLowerCase().includes(q)) ||
(p.categories || []).some(cat => cat.toLowerCase().includes(q));
if (matchesProg) block.classList.add('search-match'); if (matchesProg) block.classList.add('search-match');
if (p.stop < now) block.classList.add('is-past'); if (p.stop < now) block.classList.add('is-past');
@@ -168,7 +172,10 @@ function renderMobileFiltered(filteredChannels, q) {
const isLive = p.start <= now && p.stop > now; const isLive = p.start <= now && p.stop > now;
const isPast = p.stop < now; const isPast = p.stop < now;
const dur = Math.round((p.stop - p.start) / 60000); const dur = Math.round((p.stop - p.start) / 60000);
const matchesProg = p.title.toLowerCase().includes(q) || (p.desc && p.desc.toLowerCase().includes(q)); const matchesProg =
p.title.toLowerCase().includes(q) ||
(p.desc && p.desc.toLowerCase().includes(q)) ||
(p.categories || []).some(cat => cat.toLowerCase().includes(q));
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'mobile-program' + (isLive ? ' is-live' : '') + (isPast ? ' is-past' : '') + (matchesProg ? ' search-match' : ''); item.className = 'mobile-program' + (isLive ? ' is-live' : '') + (isPast ? ' is-past' : '') + (matchesProg ? ' search-match' : '');
@@ -179,12 +186,12 @@ function renderMobileFiltered(filteredChannels, q) {
} }
const timeCol = document.createElement('div'); timeCol.className = 'mobile-program-time'; const timeCol = document.createElement('div'); timeCol.className = 'mobile-program-time';
timeCol.innerHTML = `<span>${fmtTime(p.start)}</span><span>${fmtTime(p.stop)}</span>`; timeCol.innerHTML = `<span>${esc(fmtTime(p.start))}</span><span>${esc(fmtTime(p.stop))}</span>`;
item.appendChild(timeCol); item.appendChild(timeCol);
const info = document.createElement('div'); info.className = 'mobile-program-info'; const info = document.createElement('div'); info.className = 'mobile-program-info';
const ep = fmtEpisode(p.season, p.episode); const ep = fmtEpisode(p.season, p.episode);
info.innerHTML = `<div class="mobile-program-title">${p.title}</div><div class="mobile-program-dur">${ep ? ep + ' · ' : ''}${dur} min</div>`; info.innerHTML = `<div class="mobile-program-title">${esc(p.title)}</div><div class="mobile-program-dur">${ep ? esc(ep) + ' · ' : ''}${dur} min</div>`;
item.appendChild(info); item.appendChild(info);
if (isLive) { if (isLive) {
+24 -2
View File
@@ -21,7 +21,7 @@ function switchSource(value) {
} }
function updateCopyButtons() { function updateCopyButtons() {
// Rien à faire visuellement, copyUrl() lit currentEpgUrl/currentM3uUrl // No visual update is needed here because copyUrl() reads the current URLs directly.
} }
function openPersonalEpgModal() { function openPersonalEpgModal() {
@@ -35,7 +35,7 @@ function openPersonalEpgModal() {
function closePersonalEpgModal(applied) { function closePersonalEpgModal(applied) {
document.getElementById('personalEpgModal').classList.remove('visible'); document.getElementById('personalEpgModal').classList.remove('visible');
// Si annulé (pas applied), remettre le select sur la source précédente // If the modal was cancelled, restore the previously selected source.
if (!applied) { if (!applied) {
const sel = document.getElementById('sourceSelect'); const sel = document.getElementById('sourceSelect');
if (sel) sel.value = localStorage.getItem('gridtv-active-source') === 'personal' ? 'personal' : activeSourceIndex; if (sel) sel.value = localStorage.getItem('gridtv-active-source') === 'personal' ? 'personal' : activeSourceIndex;
@@ -49,6 +49,7 @@ function applyPersonalEpg() {
localStorage.setItem('gridtv-personal-epg', epg); localStorage.setItem('gridtv-personal-epg', epg);
localStorage.setItem('gridtv-personal-m3u', m3u); localStorage.setItem('gridtv-personal-m3u', m3u);
localStorage.setItem('gridtv-active-source', 'personal'); localStorage.setItem('gridtv-active-source', 'personal');
updateSourceDropdownLabel('✏ Personal EPG');
currentEpgUrl = epg; currentEpgUrl = epg;
currentM3uUrl = m3u; currentM3uUrl = m3u;
const sel = document.getElementById('sourceSelect'); const sel = document.getElementById('sourceSelect');
@@ -58,3 +59,24 @@ function applyPersonalEpg() {
if (m3u) loadM3U(m3u); if (m3u) loadM3U(m3u);
} }
function toggleSourceDropdown() {
document.getElementById('sourceDropdownMenu')?.classList.toggle('open');
}
function closeSourceDropdown() {
document.getElementById('sourceDropdownMenu')?.classList.remove('open');
}
// Close the dropdown when clicking outside of it.
document.addEventListener('click', e => {
if (!e.target.closest('.source-dropdown')) closeSourceDropdown();
});
function updateSourceDropdownLabel(label) {
const el = document.getElementById('sourceDropdownLabel');
if (el) el.textContent = label;
// Update the active item styling in the dropdown.
document.querySelectorAll('.source-dropdown-item').forEach((btn, i) => {
btn.classList.toggle('active', i === activeSourceIndex);
});
}
+1 -2
View File
@@ -1,4 +1,4 @@
// ── THÈMES ──────────────────────────────────────────────────────────────────── // ── THEMES ────────────────────────────────────────────────────────────────────
function setTheme(theme) { function setTheme(theme) {
document.getElementById('themeStylesheet').href = 'themes/' + theme + '.css'; document.getElementById('themeStylesheet').href = 'themes/' + theme + '.css';
localStorage.setItem('gridtv-theme', theme); localStorage.setItem('gridtv-theme', theme);
@@ -17,4 +17,3 @@ function startLiveUpdates() {
setInterval(() => { if (channels.length) renderAll(); }, 60000); setInterval(() => { if (channels.length) renderAll(); }, 60000);
setInterval(() => loadEPG(DEFAULT_EPG_URL), 30*60*1000); setInterval(() => loadEPG(DEFAULT_EPG_URL), 30*60*1000);
} }
+7 -4
View File
@@ -8,8 +8,13 @@ function getNowPlaying(chId) {
// ── TOOLTIP ─────────────────────────────────────────────────────────────────── // ── TOOLTIP ───────────────────────────────────────────────────────────────────
function showTooltip(e, p) { function showTooltip(e, p) {
const ep = fmtEpisode(p.season, p.episode); const ep = fmtEpisode(p.season, p.episode);
document.getElementById('tt-title').textContent = p.title + (ep ? ' ' + ep : ''); const genres = formatCategories(p.categories);
document.getElementById('tt-time').textContent = `${fmtTime(p.start)} ${fmtTime(p.stop)} · ${Math.round((p.stop-p.start)/60000)} min`; const title = p.title + (p.subtitle ? ` - ${p.subtitle}` : '') + (ep ? ' ' + ep : '');
const meta = [p.airDate, p.rating, genres].filter(Boolean).join(' · ');
document.getElementById('tt-title').textContent = title;
document.getElementById('tt-time').textContent =
`${fmtTime(p.start)}${fmtTime(p.stop)} · ${Math.round((p.stop-p.start)/60000)} min` +
(meta ? ` · ${meta}` : '');
document.getElementById('tt-desc').textContent = p.desc || '—'; document.getElementById('tt-desc').textContent = p.desc || '—';
document.getElementById('tooltip').classList.add('visible'); document.getElementById('tooltip').classList.add('visible');
moveTooltip(e); moveTooltip(e);
@@ -23,5 +28,3 @@ function moveTooltip(e) {
function hideTooltip() { document.getElementById('tooltip').classList.remove('visible'); } function hideTooltip() { document.getElementById('tooltip').classList.remove('visible'); }
+38
View File
@@ -0,0 +1,38 @@
// ── UPDATE CHECKER ────────────────────────────────────────────────────────────
(async function checkForUpdate() {
try {
const [localRes, remoteRes] = await Promise.all([
fetch('version.json'),
fetch('https://api.github.com/repos/Johnnybegood90/GridTV/releases/latest')
]);
if (!localRes.ok || !remoteRes.ok) return;
const local = (await localRes.json()).version ?? '0.0.0';
const remote = (await remoteRes.json()).tag_name ?? '0.0.0';
const clean = v => v.replace(/^v/, '');
const toNum = v => v.split('.').map(Number);
const [lMaj, lMin, lPat] = toNum(clean(local));
const [rMaj, rMin, rPat] = toNum(clean(remote));
const hasUpdate =
rMaj > lMaj ||
(rMaj === lMaj && rMin > lMin) ||
(rMaj === lMaj && rMin === lMin && rPat > lPat);
if (!hasUpdate) return;
const badge = document.createElement('a');
badge.className = 'update-badge';
badge.href = `https://github.com/Johnnybegood90/GridTV/releases/latest`;
badge.target = '_blank';
badge.rel = 'noopener';
badge.innerHTML = `${L.update_available} ${clean(remote)}`;
badge.title = L.update_title;
document.querySelector('.topbar')?.appendChild(badge);
} catch (_) {
// Silencieux — pas de connexion ou rate limit GitHub
}
})();
+46 -6
View File
@@ -1,8 +1,15 @@
// Escape EPG-provided strings before injecting them into HTML.
function esc(str) {
const d = document.createElement('div');
d.textContent = str || '';
return d.innerHTML;
}
let channels = []; let channels = [];
let programs = {}; let programs = {};
let m3uStreams = {}; // slug normalisé → url stream let m3uStreams = {}; // Normalized slug -> stream URL.
let scrollListenerAdded = false; // sync scroll vertical chaînes let scrollListenerAdded = false; // Keeps the channel column vertically synced with the timeline.
function pad(n) { return String(n).padStart(2,'0'); } function pad(n) { return String(n).padStart(2,'0'); }
@@ -28,6 +35,8 @@ function parseXMLTVDate(str) {
if (!str) return null; if (!str) return null;
const m = str.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{4})?/); const m = str.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{4})?/);
if (!m) return null; if (!m) return null;
// XMLTV timestamps use a compact timezone suffix like +0200, so we rebuild
// the UTC instant manually before letting Date convert it to local time.
const offset = m[7] ? parseInt(m[7]) : 0; const offset = m[7] ? parseInt(m[7]) : 0;
const offsetMs = (Math.floor(Math.abs(offset)/100)*60 + (Math.abs(offset)%100)) * 60000 * (offset<0?-1:1); const offsetMs = (Math.floor(Math.abs(offset)/100)*60 + (Math.abs(offset)%100)) * 60000 * (offset<0?-1:1);
return new Date(Date.UTC(+m[1],+m[2]-1,+m[3],+m[4],+m[5],+m[6]) - offsetMs); return new Date(Date.UTC(+m[1],+m[2]-1,+m[3],+m[4],+m[5],+m[6]) - offsetMs);
@@ -71,7 +80,7 @@ function loadFromInput() {
} }
function parseEpisode(p) { function parseEpisode(p) {
// Format xmltv_ns : "S.E.part" (0-indexed) ex: "0.2.0/1" = S01E03 // xmltv_ns format: "S.E.part" (0-indexed), e.g. "0.2.0/1" -> S01E03
const ns = p.querySelector('episode-num[system="xmltv_ns"]')?.textContent; const ns = p.querySelector('episode-num[system="xmltv_ns"]')?.textContent;
if (ns) { if (ns) {
const parts = ns.split('.'); const parts = ns.split('.');
@@ -80,7 +89,7 @@ function parseEpisode(p) {
const e = ePart !== '' ? parseInt(ePart) + 1 : null; const e = ePart !== '' ? parseInt(ePart) + 1 : null;
if (s !== null || e !== null) return { season: s, episode: e }; if (s !== null || e !== null) return { season: s, episode: e };
} }
// Format onscreen : "S01E03" ou "s1e3" // onscreen format: "S01E03" or "s1e3"
const os = p.querySelector('episode-num[system="onscreen"]')?.textContent; const os = p.querySelector('episode-num[system="onscreen"]')?.textContent;
if (os) { if (os) {
const m = os.match(/[Ss](\d+)[Ee](\d+)/); const m = os.match(/[Ss](\d+)[Ee](\d+)/);
@@ -97,6 +106,34 @@ function fmtEpisode(season, episode) {
return null; return null;
} }
function parseCategories(p) {
const seen = new Set();
const categories = [];
p.querySelectorAll('category').forEach(cat => {
const value = (cat.textContent || '').trim();
const key = value.toLowerCase();
if (!value || seen.has(key)) return;
seen.add(key);
categories.push(value);
});
return categories;
}
function formatCategories(categories) {
return Array.isArray(categories) && categories.length ? categories.join(' · ') : '';
}
function parseAirDate(value) {
if (!value) return null;
const match = String(value).trim().match(/^(\d{4})/);
return match ? match[1] : null;
}
function parseRatingValue(p) {
const rating = p.querySelector('rating > value')?.textContent || '';
return rating.trim() || null;
}
function parseAndRender(doc) { function parseAndRender(doc) {
channels = []; programs = {}; channels = []; programs = {};
doc.querySelectorAll('channel').forEach(ch => { doc.querySelectorAll('channel').forEach(ch => {
@@ -115,9 +152,13 @@ function parseAndRender(doc) {
const start = parseXMLTVDate(p.getAttribute('start')); const start = parseXMLTVDate(p.getAttribute('start'));
const stop = parseXMLTVDate(p.getAttribute('stop')); const stop = parseXMLTVDate(p.getAttribute('stop'));
const title = p.querySelector('title')?.textContent || ''; const title = p.querySelector('title')?.textContent || '';
const subtitle = p.querySelector('sub-title')?.textContent || '';
const desc = p.querySelector('desc')?.textContent || ''; const desc = p.querySelector('desc')?.textContent || '';
const categories = parseCategories(p);
const airDate = parseAirDate(p.querySelector('date')?.textContent || '');
const rating = parseRatingValue(p);
const epInfo = parseEpisode(p); const epInfo = parseEpisode(p);
if (start && stop) programs[chId].push({ start, stop, title, desc, ...epInfo }); if (start && stop) programs[chId].push({ start, stop, title, subtitle, desc, categories, airDate, rating, ...epInfo });
}); });
Object.keys(programs).forEach(id => programs[id].sort((a,b) => a.start - b.start)); Object.keys(programs).forEach(id => programs[id].sort((a,b) => a.start - b.start));
@@ -129,4 +170,3 @@ function renderAll() {
if (isMobile()) { renderMobile(); } if (isMobile()) { renderMobile(); }
else { renderGrid(); setTimeout(centerNow, 150); } else { renderGrid(); setTimeout(centerNow, 150); }
} }
+10 -2
View File
@@ -1,13 +1,21 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js"></script> <script src="/assets/vendor/hls.min.js"></script>
<script> <script>
<?php <?php
$js_dir = __DIR__ . '/../js/'; $js_dir = __DIR__ . '/../js/';
include $js_dir . 'config.js'; include $js_dir . 'config.js';
$js_modules = ['utils', 'favorites', 'epg', 'mobile', 'tooltip', 'sources', 'm3u', 'player', 'themes', 'search', 'program', 'live']; $js_modules = ['utils', 'favorites', 'epg', 'mobile', 'tooltip', 'sources', 'm3u', 'player', 'themes', 'search', 'program', 'updater', 'live'];
foreach ($js_modules as $mod) { foreach ($js_modules as $mod) {
echo file_get_contents($js_dir . $mod . '.js'); echo file_get_contents($js_dir . $mod . '.js');
} }
?> ?>
</script> </script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
</script>
</body> </body>
</html> </html>
+19 -867
View File
@@ -1,875 +1,27 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="fr"> <html lang="<?= $locale ?>">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GridTV <?= $group_name ?></title> <title>GridTV <?= $group_name ?></title>
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Barlow+Condensed:wght@300;400;600;700&display=swap" rel="stylesheet"> <link rel="manifest" href="/manifest.json">
<style> <link rel="icon" href="/assets/favicon.png" type="image/png">
/* Variables de base — surchargées par le fichier thème */ <link rel="apple-touch-icon" href="/assets/apple-touch-icon.png">
:root { <meta name="theme-color" content="#0a0b0d">
--bg: #0a0b0d; <meta name="mobile-web-app-capable" content="yes">
--surface: #111318; <meta name="apple-mobile-web-app-capable" content="yes">
--surface2: #181b22; <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
--border: #232733; <meta name="apple-mobile-web-app-title" content="GridTV">
--border-bright: #2e3444; <link rel="stylesheet" href="/assets/fonts/fonts.css">
--accent: #e8c842; <?php
--accent2: #4a9eff; $css_dir = __DIR__ . '/../css/';
--accent-live: #ff4444; $css_files = ['base', 'topbar', 'grid', 'mobile', 'player', 'modals', 'search', 'program', 'favorites', 'updater'];
--text: #c8cdd8; echo "<style>\n";
--text-dim: #5a6070; foreach ($css_files as $file) {
--text-bright: #eef0f5; echo file_get_contents($css_dir . $file . '.css') . "\n";
--prog-bg: #141820; }
--prog-border: #1e2a3a; echo "</style>\n";
--prog-live-bg: #0d2040; ?>
--prog-live-border: #4a9eff;
--font-ui: 'Barlow Condensed', sans-serif;
--font-mono: 'Share Tech Mono', monospace;
--channel-w: 160px;
--row-h: 80px;
--header-h: 52px;
}
html, body { height: 100%; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-ui);
font-size: 14px;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ═══════════════════════════════════════════
TOP BAR
═══════════════════════════════════════════ */
.topbar {
height: 50px;
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 16px;
gap: 12px;
flex-shrink: 0;
position: relative;
z-index: 100;
}
.logo {
font-family: 'Share Tech Mono', monospace;
font-size: 13px;
color: var(--accent);
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.logo span { color: var(--text-dim); margin: 0 5px; }
.logo-sub { display: inline; }
.live-dot {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-family: 'Share Tech Mono', monospace;
color: var(--accent-live);
letter-spacing: 0.1em;
margin-left: auto;
flex-shrink: 0;
}
.live-dot::before {
content: '';
width: 7px; height: 7px;
background: var(--accent-live);
border-radius: 50%;
animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse {
0%,100% { opacity:1; transform:scale(1); }
50% { opacity:0.3; transform:scale(0.7); }
}
.clock {
font-family: 'Share Tech Mono', monospace;
font-size: 20px;
color: var(--text-bright);
letter-spacing: 0.05em;
flex-shrink: 0;
}
.date-label {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
color: var(--text-dim);
letter-spacing: 0.1em;
text-transform: uppercase;
white-space: nowrap;
}
.nav-btns {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.nav-btn {
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
font-family: 'Barlow Condensed', sans-serif;
font-size: 12px;
letter-spacing: 0.06em;
padding: 4px 10px;
cursor: pointer;
text-transform: uppercase;
transition: all 0.15s;
white-space: nowrap;
}
.nav-btn:hover, .nav-btn.active {
background: var(--accent);
color: #000;
border-color: var(--accent);
}
.copy-btns {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.copy-btn {
background: transparent;
border: 1px solid var(--border-bright);
color: var(--text-dim);
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
letter-spacing: 0.1em;
padding: 4px 9px;
cursor: pointer;
text-transform: uppercase;
transition: all 0.15s;
white-space: nowrap;
position: relative;
}
.copy-btn:hover {
border-color: var(--accent2);
color: var(--accent2);
}
.copy-btn.copied {
border-color: var(--accent);
color: var(--accent);
}
/* ═══════════════════════════════════════════
GRID VIEW (desktop / tablet)
═══════════════════════════════════════════ */
.grid-wrapper {
flex: 1;
display: flex;
overflow: hidden;
position: relative;
min-height: 0;
}
.channels-col {
width: var(--channel-w);
flex-shrink: 0;
background: var(--surface);
border-right: 1px solid var(--border-bright);
z-index: 10;
display: flex;
flex-direction: column;
min-height: 0;
}
.channels-col-header {
height: var(--header-h);
border-bottom: 1px solid var(--border-bright);
display: flex;
align-items: center;
padding: 0 12px;
flex-shrink: 0;
}
.channels-col-header span {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
color: var(--text-dim);
letter-spacing: 0.15em;
text-transform: uppercase;
}
.channels-list {
overflow-y: auto;
flex: 1;
scrollbar-width: none;
}
.channels-list::-webkit-scrollbar { display: none; }
.channel-cell {
height: var(--row-h);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 12px;
gap: 8px;
flex-shrink: 0;
}
.channel-logo {
width: 32px; height: 32px;
border-radius: 4px;
object-fit: contain;
background: var(--surface2);
flex-shrink: 0;
}
.channel-logo-placeholder {
width: 32px; height: 32px;
border-radius: 4px;
background: var(--surface2);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
color: var(--text-dim);
flex-shrink: 0;
}
.channel-name {
font-size: 12px;
font-weight: 600;
color: var(--text-bright);
letter-spacing: 0.04em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-transform: uppercase;
}
.timeline-area {
flex: 1;
overflow: auto;
position: relative;
min-height: 0;
scrollbar-width: thin;
scrollbar-color: var(--border-bright) transparent;
}
.timeline-area::-webkit-scrollbar { height: 4px; width: 4px; }
.timeline-area::-webkit-scrollbar-track { background: transparent; }
.timeline-area::-webkit-scrollbar-thumb { background: var(--border-bright); border-radius: 2px; }
/* Conteneur unique — ruler + programmes scrollent ensemble */
.timeline-inner {
position: relative;
display: block;
min-width: 100%;
}
.time-ruler {
height: var(--header-h);
background: var(--surface);
border-bottom: 1px solid var(--border-bright);
position: sticky;
top: 0;
z-index: 20;
}
.time-ruler-inner {
position: relative;
height: 100%;
}
.time-tick {
position: absolute;
bottom: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.time-tick-label {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
color: var(--text-dim);
letter-spacing: 0.08em;
padding: 0 6px 6px;
white-space: nowrap;
}
.time-tick-label.hour { color: var(--text); font-size: 12px; }
.time-tick-line { width: 1px; height: 8px; background: var(--border-bright); }
.time-tick-line.hour { height: 14px; }
.now-line {
position: absolute;
top: 0;
width: 2px;
height: 100%;
background: var(--accent-live);
z-index: 50;
pointer-events: none;
}
.now-line::before {
content: '';
position: absolute;
top: 0; left: -4px;
width: 10px; height: 10px;
background: var(--accent-live);
border-radius: 50%;
}
.programs-inner {
position: relative;
background-image: repeating-linear-gradient(
to bottom,
transparent 0px, transparent 79px,
var(--border) 79px, var(--border) 80px
);
}
.program-row {
height: var(--row-h);
border-bottom: 1px solid var(--border);
position: absolute;
left: 0; right: 0;
}
.program-block {
position: absolute;
top: 5px; bottom: 5px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0 8px;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
cursor: default;
transition: background 0.12s, border-color 0.12s;
min-width: 2px;
}
.program-block:hover { background: var(--surface); border-color: var(--accent2); z-index: 5; }
.program-block.is-live { border-color: var(--accent2); background: #0d1a2e; }
.program-block.is-live::after {
content: '';
position: absolute;
bottom: 0; left: 0;
height: 2px;
background: var(--accent2);
width: var(--progress, 0%);
transition: width 1s linear;
}
.program-block.is-past { opacity: 0.35; }
.program-title {
font-size: 12px;
font-weight: 600;
color: var(--text-bright);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 0.02em;
}
.program-time {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
color: var(--text-dim);
letter-spacing: 0.06em;
white-space: nowrap;
}
/* ═══════════════════════════════════════════
MOBILE LIST VIEW
═══════════════════════════════════════════ */
.mobile-view {
display: none;
flex: 1;
overflow-y: auto;
min-height: 0;
-webkit-overflow-scrolling: touch;
}
.mobile-channel {
border-bottom: 2px solid var(--border-bright);
}
.mobile-channel-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--surface);
position: sticky;
top: 0;
z-index: 10;
border-bottom: 1px solid var(--border);
}
.mobile-programs {
padding: 6px 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
.mobile-program {
display: flex;
align-items: stretch;
border-radius: 4px;
overflow: hidden;
border: 1px solid var(--border);
background: var(--surface2);
position: relative;
}
.mobile-program.is-live { border-color: var(--accent2); background: #0d1a2e; }
.mobile-program.is-past { opacity: 0.4; }
.mobile-program-progress {
position: absolute;
bottom: 0; left: 0;
height: 2px;
background: var(--accent2);
}
.mobile-program-time {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
color: var(--text-dim);
padding: 8px 10px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
min-width: 72px;
flex-shrink: 0;
gap: 2px;
}
.mobile-program.is-live .mobile-program-time { color: var(--accent2); }
.mobile-program-info {
padding: 8px 10px;
flex: 1;
min-width: 0;
}
.mobile-program-title {
font-size: 13px;
font-weight: 600;
color: var(--text-bright);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mobile-program-dur {
font-size: 10px;
color: var(--text-dim);
margin-top: 2px;
font-family: 'Share Tech Mono', monospace;
}
.mobile-live-badge {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
color: var(--accent-live);
background: rgba(255,68,68,0.12);
border: 1px solid var(--accent-live);
border-radius: 2px;
padding: 2px 5px;
letter-spacing: 0.1em;
align-self: center;
margin-right: 8px;
flex-shrink: 0;
}
/* TOOLTIP */
.tooltip {
position: fixed;
background: var(--surface);
border: 1px solid var(--accent2);
border-radius: 4px;
padding: 10px 14px;
z-index: 999;
pointer-events: none;
max-width: 300px;
opacity: 0;
transition: opacity 0.15s;
}
.tooltip.visible { opacity: 1; }
.tooltip-title { font-size: 14px; font-weight: 700; color: var(--text-bright); margin-bottom: 4px; }
.tooltip-time { font-family: 'Share Tech Mono', monospace; font-size: 10px; color: var(--accent2); margin-bottom: 6px; }
.tooltip-desc { font-size: 12px; color: var(--text); line-height: 1.4; max-height: 80px; overflow: hidden; }
/* LOADING */
.loading {
position: fixed; inset: 0;
background: var(--bg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1000;
gap: 20px;
}
.loading-text {
font-family: 'Share Tech Mono', monospace;
font-size: 12px;
color: var(--text-dim);
letter-spacing: 0.2em;
text-transform: uppercase;
}
.loading-bar {
width: 200px; height: 2px;
background: var(--border);
position: relative;
overflow: hidden;
}
.loading-bar::after {
content: '';
position: absolute;
top: 0; left: -60%;
width: 60%; height: 100%;
background: var(--accent);
animation: loading-anim 1.2s ease-in-out infinite;
}
@keyframes loading-anim { to { left: 110%; } }
.error-msg {
position: fixed; inset: 0;
background: var(--bg);
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1000;
gap: 16px;
display: none;
padding: 20px;
}
.err-title { font-family: 'Share Tech Mono', monospace; font-size: 14px; color: var(--accent-live); letter-spacing: 0.1em; }
.err-sub { font-size: 13px; color: var(--text-dim); text-align: center; max-width: 400px; line-height: 1.5; }
.epg-input-bar { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; justify-content: center; }
.epg-input {
background: var(--surface2);
border: 1px solid var(--border-bright);
color: var(--text-bright);
font-family: 'Share Tech Mono', monospace;
font-size: 12px;
padding: 8px 12px;
width: 320px;
outline: none;
}
.epg-input:focus { border-color: var(--accent); }
.epg-btn {
background: var(--accent);
border: none; color: #000;
font-family: 'Barlow Condensed', sans-serif;
font-weight: 700; font-size: 13px;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 8px 18px;
cursor: pointer;
}
/* ═══════════════════════════════════════════
RESPONSIVE
═══════════════════════════════════════════ */
@media (max-width: 900px) {
:root { --channel-w: 110px; }
.logo-sub { display: none; }
.date-label { display: none; }
.clock { font-size: 16px; }
}
/* ═══════════════════════════════════════════
SÉLECTEUR DE THÈME
═══════════════════════════════════════════ */
.theme-select {
background: transparent;
border: 1px solid var(--border-bright);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
padding: 4px 8px;
cursor: pointer;
text-transform: uppercase;
transition: all 0.15s;
flex-shrink: 0;
-webkit-appearance: none;
appearance: none;
}
.theme-select:hover { border-color: var(--accent2); color: var(--accent2); }
.theme-select option { background: #111; color: #eee; font-family: sans-serif; }
@media (max-width: 600px) {
html, body { height: 100%; overflow: hidden; }
body { overflow: hidden; }
.topbar {
height: auto;
flex-wrap: wrap;
padding: 8px 12px;
gap: 8px;
}
.logo { font-size: 11px; }
.clock { font-size: 14px; }
.grid-wrapper { display: none; }
.mobile-view { display: block; }
.tooltip { display: none !important; }
#navBtns { display: none !important; }
}
/* ── MODAL PERSONAL EPG ─────────────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
z-index: 3000; display: none; align-items: center; justify-content: center;
}
.modal-overlay.visible { display: flex; }
.modal-card {
background: var(--surface); border: 1px solid var(--border-bright);
padding: 28px; width: 100%; max-width: 420px; margin: 16px;
}
.modal-title {
font-family: var(--font-mono); font-size: 13px; color: var(--accent);
letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 6px;
}
.modal-subtitle { font-size: 12px; color: var(--text-dim); margin-bottom: 20px; line-height: 1.5; }
.modal-field { margin-bottom: 14px; }
.modal-field label {
display: block; font-family: var(--font-mono); font-size: 10px;
letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-dim); margin-bottom: 6px;
}
.modal-field input {
width: 100%; background: var(--surface2); border: 1px solid var(--border-bright);
color: var(--text-bright); font-family: var(--font-mono); font-size: 12px;
padding: 9px 10px; outline: none; transition: border-color 0.15s;
}
.modal-field input:focus { border-color: var(--accent); }
.modal-field input::placeholder { color: var(--text-dim); opacity: 0.4; }
.modal-actions { display: flex; gap: 10px; margin-top: 20px; justify-content: flex-end; }
.modal-btn-cancel {
background: none; border: 1px solid var(--border-bright); color: var(--text-dim);
font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em;
text-transform: uppercase; padding: 8px 16px; cursor: pointer; transition: border-color 0.15s;
}
.modal-btn-cancel:hover { border-color: var(--text-dim); color: var(--text); }
.modal-btn-apply {
background: var(--accent); border: none; color: #000;
font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em;
text-transform: uppercase; padding: 8px 20px; cursor: pointer; transition: opacity 0.15s;
}
.modal-btn-apply:hover { opacity: 0.85; }
/* ── SOURCE SELECTOR ─────────────────────────────────────────────────────── */
.source-select {
background: var(--surface2);
border: 1px solid var(--border-bright);
color: var(--text);
font-family: var(--font-mono);
font-size: 11px;
padding: 5px 8px;
cursor: pointer;
letter-spacing: 0.06em;
height: 28px;
outline: none;
transition: border-color 0.15s, color 0.15s;
max-width: 130px;
}
.source-select:hover { border-color: var(--accent2); color: var(--accent2); }
.source-select option { background: #111; color: #eee; font-family: sans-serif; }
/* ═══════════════════════════════════════════
PLAYER PiP
═══════════════════════════════════════════ */
.pip {
position: fixed;
bottom: 24px;
right: 24px;
width: 380px;
background: #000;
border: 1px solid var(--border-bright);
box-shadow: 0 8px 32px rgba(0,0,0,0.8);
z-index: 2000;
display: none;
flex-direction: column;
}
.pip.visible { display: flex; }
.pip-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: var(--surface);
border-bottom: 1px solid var(--border);
gap: 8px;
flex-shrink: 0;
}
.pip-channel {
font-family: var(--font-mono);
font-size: 11px;
color: var(--accent);
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.pip-program {
font-size: 11px;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 2;
text-align: center;
}
.pip-close {
background: none;
border: none;
color: var(--text-dim);
font-size: 18px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
flex-shrink: 0;
transition: color 0.15s;
}
.pip-close:hover { color: var(--accent-live); }
.pip video {
width: 100%;
aspect-ratio: 16/9;
display: block;
background: #000;
}
.pip-error {
padding: 20px;
text-align: center;
font-family: var(--font-mono);
font-size: 11px;
color: var(--accent-live);
letter-spacing: 0.06em;
}
.channel-cell { cursor: pointer; }
.channel-cell:hover { background: var(--surface2); }
.program-block.is-live { cursor: pointer; }
@media (max-width: 600px) {
.pip { width: calc(100vw - 16px); bottom: 8px; right: 8px; left: 8px; }
}
/* ── SEARCH ───────────────────────────────────────────────────────────────── */
.search-btn {
background: none; border: 1px solid var(--border); border-radius: 4px;
color: var(--text-muted); cursor: pointer; font-size: 14px;
padding: 4px 8px; transition: color .2s, border-color .2s;
}
.search-btn:hover { color: var(--accent); border-color: var(--accent); }
.search-bar {
display: none; align-items: center; gap: 8px;
background: var(--bg-card); border-bottom: 1px solid var(--border);
padding: 8px 16px;
}
.search-bar.visible { display: flex; }
.search-input {
flex: 1; background: var(--bg); border: 1px solid var(--border);
border-radius: 4px; color: var(--text); font-family: inherit;
font-size: 14px; outline: none; padding: 6px 12px;
transition: border-color .2s;
}
.search-input:focus { border-color: var(--accent); }
.search-input::placeholder { color: var(--text-muted); }
.search-clear {
background: none; border: none; color: var(--text-muted);
cursor: pointer; font-size: 16px; padding: 4px 8px;
transition: color .2s;
}
.search-clear:hover { color: var(--accent); }
/* Highlight programmes qui matchent */
.program-block.search-match {
border: 1px solid var(--accent);
box-shadow: 0 0 6px var(--accent);
}
.mobile-program.search-match {
border-left: 3px solid var(--accent);
}
/* ── PROGRAM MODAL ───────────────────────────────────────────────────────── */
.program-card {
max-width: 520px; padding: 28px; position: relative;
}
.pm-close {
position: absolute; top: 14px; right: 14px;
background: none; border: none; color: var(--text-muted);
cursor: pointer; font-size: 18px; line-height: 1;
transition: color .2s;
}
.pm-close:hover { color: var(--accent); }
.pm-channel {
font-size: 11px; letter-spacing: .1em; text-transform: uppercase;
color: var(--accent); margin-bottom: 6px;
}
.pm-title {
font-size: 20px; font-weight: 700; color: var(--text);
margin-bottom: 8px; line-height: 1.2;
}
.pm-time {
font-size: 12px; color: var(--accent); margin-bottom: 14px;
}
.pm-desc {
font-size: 13px; color: var(--text-muted); line-height: 1.6;
margin-bottom: 20px; max-height: 140px; overflow-y: auto;
}
.pm-actions {
display: flex; gap: 10px; flex-wrap: wrap;
}
.pm-btn {
border-radius: 4px; cursor: pointer; font-family: inherit;
font-size: 13px; font-weight: 600; padding: 8px 16px;
text-decoration: none; transition: opacity .2s;
display: inline-flex; align-items: center; gap: 6px;
border: none;
}
.pm-btn:hover { opacity: .8; }
.pm-watch { background: var(--accent); color: var(--bg); }
.pm-imdb { background: #f5c518; color: #000; }
.pm-cancel { background: var(--bg); color: var(--text-muted); border: 1px solid var(--border); }
/* Date midnight dans la timebar */
.time-tick-label.midnight {
color: var(--accent); font-weight: 700; font-size: 11px;
white-space: nowrap;
}
.time-tick-line.midnight {
background: var(--accent); opacity: .4; width: 2px;
}
/* ── 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 id="themeStylesheet" rel="stylesheet" href="themes/default.css"> <link id="themeStylesheet" rel="stylesheet" href="themes/default.css">
</head> </head>
+3
View File
@@ -4,7 +4,10 @@
<button class="pm-close" onclick="closeProgramModal()">&#10005;</button> <button class="pm-close" onclick="closeProgramModal()">&#10005;</button>
<div class="pm-channel" id="pm-channel"></div> <div class="pm-channel" id="pm-channel"></div>
<div class="pm-title" id="pm-title"></div> <div class="pm-title" id="pm-title"></div>
<div class="pm-subtitle" id="pm-subtitle"></div>
<div class="pm-time" id="pm-time"></div> <div class="pm-time" id="pm-time"></div>
<div class="pm-meta" id="pm-meta"></div>
<div class="pm-genres" id="pm-genres"></div>
<div class="pm-desc" id="pm-desc"></div> <div class="pm-desc" id="pm-desc"></div>
<div class="pm-actions"> <div class="pm-actions">
<button class="pm-btn pm-watch" id="pm-watch">&#9654; <?= htmlspecialchars($L["watch_now"]) ?></button> <button class="pm-btn pm-watch" id="pm-watch">&#9654; <?= htmlspecialchars($L["watch_now"]) ?></button>
+16 -7
View File
@@ -14,20 +14,30 @@
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php if (count($epg_sources) > 1 || $allow_personal_epg): ?> <?php if (count($epg_sources) > 1 || $allow_personal_epg): ?>
<select class="source-select" id="sourceSelect" onchange="switchSource(this.value)" title="Source EPG"> <div class="source-dropdown" id="sourceDropdown">
<button class="source-dropdown-btn" onclick="toggleSourceDropdown()" id="sourceDropdownBtn">
<span id="sourceDropdownLabel"><?= htmlspecialchars($epg_sources[0]['name'] ?? 'EPG') ?></span>
<span class="source-dropdown-arrow">&#9660;</span>
</button>
<div class="source-dropdown-menu" id="sourceDropdownMenu">
<?php foreach ($epg_sources as $i => $src): ?> <?php foreach ($epg_sources as $i => $src): ?>
<option value="<?= $i ?>"><?= htmlspecialchars($src['name']) ?></option> <button class="source-dropdown-item<?= $i === 0 ? ' active' : '' ?>" onclick="switchSource(<?= $i ?>); closeSourceDropdown()">
<?= htmlspecialchars($src['name']) ?>
</button>
<?php endforeach; ?> <?php endforeach; ?>
<?php if ($allow_personal_epg): ?> <?php if ($allow_personal_epg): ?>
<option value="personal">✏ Personal EPG</option> <button class="source-dropdown-item source-dropdown-personal" onclick="switchSource('personal'); closeSourceDropdown()">
&#9998; Personal EPG
</button>
<?php endif; ?> <?php endif; ?>
</select> </div>
</div>
<?php endif; ?> <?php endif; ?>
<select class="theme-select" id="themeSelect" onchange="setTheme(this.value)" title="Thème"> <select class="theme-select" id="themeSelect" onchange="setTheme(this.value)" title="Thème">
<?php <?php
$theme_dir = __DIR__ . '/../../themes'; $theme_dir = __DIR__ . '/../../themes';
$theme_files = glob($theme_dir . '/*.css'); $theme_files = glob($theme_dir . '/*.css');
// default en premier, puis le reste par ordre alphabétique // Put the default theme first, then sort the rest alphabetically.
usort($theme_files, function($a, $b) { usort($theme_files, function($a, $b) {
$a_base = basename($a, '.css'); $a_base = basename($a, '.css');
$b_base = basename($b, '.css'); $b_base = basename($b, '.css');
@@ -38,7 +48,7 @@ usort($theme_files, function($a, $b) {
foreach ($theme_files as $file) { foreach ($theme_files as $file) {
$slug = basename($file, '.css'); $slug = basename($file, '.css');
$css = file_get_contents($file); $css = file_get_contents($file);
// Lire @name et @emoji dans le premier bloc commentaire // Read @name and @emoji from the first comment block.
preg_match('/@name\s+(.+)/u', $css, $m_name); preg_match('/@name\s+(.+)/u', $css, $m_name);
preg_match('/@emoji\s+(\S+)/u', $css, $m_emoji); preg_match('/@emoji\s+(\S+)/u', $css, $m_emoji);
$name = isset($m_name[1]) ? trim($m_name[1]) : ucfirst($slug); $name = isset($m_name[1]) ? trim($m_name[1]) : ucfirst($slug);
@@ -54,4 +64,3 @@ foreach ($theme_files as $file) {
<input class="search-input" id="searchInput" type="text" placeholder="<?= htmlspecialchars($L["search_placeholder"]) ?>" oninput="onSearchInput(this.value)" /> <input class="search-input" id="searchInput" type="text" placeholder="<?= htmlspecialchars($L["search_placeholder"]) ?>" oninput="onSearchInput(this.value)" />
<button class="search-clear" onclick="clearSearch()">&#10005;</button> <button class="search-clear" onclick="clearSearch()">&#10005;</button>
</div> </div>
+77
View File
@@ -0,0 +1,77 @@
const CACHE_NAME = 'gridtv-v2';
// Static assets cached during installation.
const STATIC_ASSETS = [
'/',
'/index.php',
'/manifest.json',
'/assets/fonts/fonts.css',
'/assets/icon-192.png',
'/assets/icon-512.png',
'/assets/vendor/hls.min.js'
];
// Install: cache the static assets.
self.addEventListener('install', e => {
e.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(STATIC_ASSETS))
.then(() => self.skipWaiting())
);
});
// Activate: remove outdated caches.
self.addEventListener('activate', e => {
e.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
).then(() => self.clients.claim())
);
});
// Fetch: choose a caching strategy based on resource type.
self.addEventListener('fetch', e => {
const url = new URL(e.request.url);
// EPG/M3U feeds, the GitHub API, and version.json stay network-only.
if (
url.pathname.includes('xmltv') ||
url.pathname.includes('.m3u') ||
url.hostname === 'api.github.com' ||
url.pathname.includes('proxy.php') ||
url.pathname.endsWith('version.json')
) {
return;
}
// Static assets use a cache-first strategy.
if (
e.request.destination === 'style' ||
e.request.destination === 'script' ||
e.request.destination === 'font' ||
e.request.destination === 'image' ||
url.pathname.endsWith('.json') ||
url.pathname.endsWith('.css') ||
url.pathname.endsWith('.js')
) {
e.respondWith(
caches.match(e.request).then(cached => cached || fetch(e.request).then(res => {
const clone = res.clone();
caches.open(CACHE_NAME).then(cache => cache.put(e.request, clone));
return res;
}))
);
return;
}
// PHP pages use network-first with cache fallback.
e.respondWith(
fetch(e.request)
.then(res => {
const clone = res.clone();
caches.open(CACHE_NAME).then(cache => cache.put(e.request, clone));
return res;
})
.catch(() => caches.match(e.request))
);
});
-2
View File
@@ -3,8 +3,6 @@
* Néons rose/cyan sur noir absolu. Blade Runner. * Néons rose/cyan sur noir absolu. Blade Runner.
*/ */
@import url('https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;600;700&display=swap');
:root { :root {
--bg: #000008; --bg: #000008;
--surface: #080010; --surface: #080010;
-2
View File
@@ -3,8 +3,6 @@
* Salle de régie sombre, la base. * Salle de régie sombre, la base.
*/ */
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Barlow+Condensed:wght@300;400;600;700&display=swap');
:root { :root {
--bg: #0a0b0d; --bg: #0a0b0d;
--surface: #111318; --surface: #111318;
-2
View File
@@ -3,8 +3,6 @@
* Papier jauni, encre rouge et bleue, machine à écrire. * Papier jauni, encre rouge et bleue, machine à écrire.
*/ */
@import url('https://fonts.googleapis.com/css2?family=Special+Elite&display=swap');
:root { :root {
--bg: #f2e8c8; --bg: #f2e8c8;
--surface: #ede0b0; --surface: #ede0b0;
-2
View File
@@ -3,8 +3,6 @@
* Brun chaud, cuivre, laiton. Très 1880. * Brun chaud, cuivre, laiton. Très 1880.
*/ */
@import url('https://fonts.googleapis.com/css2?family=IM+Fell+English:ital@0;1&display=swap');
:root { :root {
--bg: #160e04; --bg: #160e04;
--surface: #201408; --surface: #201408;
+3
View File
@@ -0,0 +1,3 @@
{
"version": "1.5.0"
}