Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f83055198 | ||
|
|
c46ca14f4c | ||
|
|
ea7407f1d8 | ||
|
|
674c13ae26 | ||
|
|
96601b76b6 | ||
|
|
fc608ee0e0 | ||
|
|
45cf0975b0 | ||
|
|
90679e9221 | ||
|
|
7f500ff9ee | ||
|
|
bc5c9e296a | ||
|
|
985a417ccb | ||
|
|
27ef739845 | ||
|
|
77f02361ab | ||
|
|
6f379fd18e | ||
|
|
8dabd2d190 | ||
|
|
1402d9b5fc | ||
|
|
062954a780 | ||
|
|
ad56bd85cc | ||
|
|
1959734b56 | ||
|
|
3d0b4796b2 | ||
|
|
de1f5a336c | ||
|
|
4f0e1f55ba | ||
|
|
8ba8c3013e | ||
|
|
9609f096ef | ||
|
|
8c81c05bca | ||
|
|
ff998d379d | ||
|
|
62732ab2c6 | ||
|
|
25dabc97b2 | ||
|
|
e81cd8b64d | ||
|
|
30d8a24927 | ||
|
|
d6532d3501 | ||
|
|
17db9f56f7 |
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
*.swp
|
||||
data
|
||||
assets/preview.png
|
||||
assets/.DS_Store
|
||||
src/.DS_Store
|
||||
.version.json.swp
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Deploy GridTV
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- beta
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Start SSH agent
|
||||
uses: webfactory/ssh-agent@v0.9.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.SSH_KEY }}
|
||||
|
||||
- name: Add server to known_hosts
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Deploy main
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} main
|
||||
|
||||
- name: Deploy beta
|
||||
if: github.ref == 'refs/heads/beta'
|
||||
run: |
|
||||
ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} beta
|
||||
@@ -1,2 +1,3 @@
|
||||
config.json
|
||||
data/
|
||||
.DS_Store
|
||||
+12
-2
@@ -4,8 +4,18 @@ RUN docker-php-ext-install curl && \
|
||||
a2enmod rewrite
|
||||
|
||||
COPY . /var/www/html/
|
||||
COPY docker/docker-entrypoint.sh /usr/local/bin/gridtv-entrypoint.sh
|
||||
|
||||
RUN chown -R www-data:www-data /var/www/html && \
|
||||
chmod -R 775 /var/www/html
|
||||
RUN mkdir -p /data && \
|
||||
chown -R www-data:www-data /var/www/html /data && \
|
||||
find /var/www/html -type d -exec chmod 755 {} \; && \
|
||||
find /var/www/html -type f -exec chmod 644 {} \; && \
|
||||
chmod 755 /usr/local/bin/gridtv-entrypoint.sh
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD php -r "exit(@file_get_contents('http://127.0.0.1/') === false ? 1 : 0);"
|
||||
|
||||
ENTRYPOINT ["gridtv-entrypoint.sh"]
|
||||
CMD ["apache2-foreground"]
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@@ -45,7 +45,7 @@ This demo runs with sample XMLTV feeds to showcase the interface.
|
||||
- ⚙️ **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
|
||||
- 0️⃣ **Zero JS dependencies** — vanilla PHP, hls.js loaded from CDN
|
||||
- 0️⃣ **Zero build tooling** — vanilla PHP/JS/CSS, with bundled local assets
|
||||
|
||||
---
|
||||
|
||||
@@ -70,15 +70,19 @@ docker compose up -d
|
||||
|
||||
Then open `http://localhost:8080` and follow the setup wizard.
|
||||
|
||||
Docker stores the generated configuration in `./data/config.json` on the host.
|
||||
|
||||
To run on a custom port:
|
||||
|
||||
```bash
|
||||
PORT=9000 docker compose up -d
|
||||
```
|
||||
|
||||
To back up or migrate your Docker setup, keep the `data/` directory.
|
||||
|
||||
---
|
||||
|
||||
### Option B — Apache / Nginx
|
||||
### Option B — Native PHP host
|
||||
|
||||
#### 1. Clone the repo
|
||||
|
||||
@@ -94,7 +98,25 @@ chmod 775 /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
|
||||
server {
|
||||
@@ -115,13 +137,16 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
> 💡 Adjust the PHP version (`php8.2-fpm`) to match the one installed on your server.
|
||||
|
||||
```bash
|
||||
nginx -t && systemctl reload nginx
|
||||
```
|
||||
|
||||
#### 3b. Configure Apache
|
||||
> Adjust `php8.2-fpm.sock` to match the PHP-FPM version installed on your server.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Apache vhost</summary>
|
||||
|
||||
```apache
|
||||
<VirtualHost *:80>
|
||||
@@ -140,16 +165,52 @@ a2enmod php8.4 rewrite
|
||||
systemctl reload apache2
|
||||
```
|
||||
|
||||
#### 4. Install php-curl
|
||||
</details>
|
||||
|
||||
The built-in player uses `proxy.php` to relay HTTP streams over HTTPS. This requires the **php-curl** extension:
|
||||
<details>
|
||||
<summary>Caddy</summary>
|
||||
|
||||
```caddy
|
||||
guide.your-domain.com {
|
||||
root * /var/www/gridtv
|
||||
php_fastcgi unix//run/php/php8.2-fpm.sock
|
||||
file_server
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu — adjust version to match your PHP
|
||||
apt install php8.4-curl
|
||||
systemctl reload apache2 # or: systemctl reload nginx
|
||||
systemctl reload caddy
|
||||
```
|
||||
|
||||
> If you use `xcaddy` or a distro package, keep the same site block and only adapt the PHP-FPM socket path.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Traefik (Docker labels)</summary>
|
||||
|
||||
Use this if GridTV runs in Docker and Traefik is your front proxy:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
gridtv:
|
||||
build: .
|
||||
volumes:
|
||||
- ./data:/data
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.gridtv.rule=Host(`guide.your-domain.com`)"
|
||||
- "traefik.http.routers.gridtv.entrypoints=websecure"
|
||||
- "traefik.http.routers.gridtv.tls=true"
|
||||
- "traefik.http.services.gridtv.loadbalancer.server.port=80"
|
||||
```
|
||||
|
||||
You still need a running Traefik instance with `websecure` configured and DNS pointing to it.
|
||||
|
||||
</details>
|
||||
|
||||
</details>
|
||||
|
||||
#### 5. First launch — Setup
|
||||
|
||||
Open your browser at `http://guide.your-domain.com`.
|
||||
@@ -166,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
|
||||
|
||||
You can re-open the setup page at any time using the admin key generated during first setup:
|
||||
@@ -205,6 +269,11 @@ nano /var/www/gridtv/config.json
|
||||
|
||||
---
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Project structure</summary>
|
||||
|
||||
## 📁 Project structure
|
||||
|
||||
```
|
||||
@@ -266,6 +335,11 @@ gridtv/
|
||||
|
||||
---
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Themes</summary>
|
||||
|
||||
## 🎨 Themes
|
||||
|
||||
GridTV ships with 4 built-in themes. To add your own, create a CSS file in `themes/` with these metadata comments at the top:
|
||||
@@ -286,6 +360,11 @@ Drop it in `themes/` — it appears in the theme selector automatically.
|
||||
|
||||
---
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Multiple EPG Sources</summary>
|
||||
|
||||
## 📡 Multiple EPG Sources
|
||||
|
||||
Configure as many EPG/M3U sources as you want in `config.json`. A dropdown appears in the topbar when more than one source is defined.
|
||||
@@ -294,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
|
||||
|
||||
Click on any **channel name** or **currently airing program** to open a PiP player in the bottom-right corner.
|
||||
@@ -302,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
|
||||
|
||||
Tweak these constants in `src/js/config.js`:
|
||||
@@ -314,6 +403,11 @@ const ROW_H = 80; // Channel row height (px)
|
||||
|
||||
---
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>EPG Compatibility</summary>
|
||||
|
||||
## 🧩 EPG Compatibility
|
||||
|
||||
GridTV parses the standard **XMLTV format**. Tested with:
|
||||
@@ -326,6 +420,11 @@ GridTV parses the standard **XMLTV format**. Tested with:
|
||||
|
||||
---
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Internationalization</summary>
|
||||
|
||||
## 🌍 Internationalization
|
||||
|
||||
GridTV auto-detects the visitor's browser language and serves the matching locale. Supported out of the box: **English**, **French**, **Spanish**.
|
||||
@@ -341,18 +440,33 @@ GridTV will automatically pick it up for browsers with that language set — no
|
||||
|
||||
---
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Contributing</summary>
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
PRs are welcome! Got an idea, a fix, or a feature request — open an issue or send a PR.
|
||||
|
||||
---
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>License</summary>
|
||||
|
||||
## 📄 License
|
||||
|
||||
GNU Affero General Public License v3.0 (AGPLv3)
|
||||
|
||||
---
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Roadmap</summary>
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
### ✅ Done
|
||||
@@ -376,3 +490,4 @@ GNU Affero General Public License v3.0 (AGPLv3)
|
||||
### 🔜 Coming soon
|
||||
- **Dark/Light auto mode** — follow system preference when using the default theme
|
||||
- **Grid export** — export today's schedule as PDF or image
|
||||
</details>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,87 @@
|
||||
@font-face {
|
||||
font-family: 'Barlow Condensed';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url('/assets/fonts/barlow-condensed-300.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Barlow Condensed';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/assets/fonts/barlow-condensed-400.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Barlow Condensed';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/assets/fonts/barlow-condensed-600.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Barlow Condensed';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/assets/fonts/barlow-condensed-700.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Share Tech Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/assets/fonts/share-tech-mono-400.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Rajdhani';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/assets/fonts/rajdhani-400.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Rajdhani';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/assets/fonts/rajdhani-600.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Rajdhani';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/assets/fonts/rajdhani-700.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IM Fell English';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/assets/fonts/im-fell-english-400.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IM Fell English';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/assets/fonts/im-fell-english-400-italic.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Special Elite';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/assets/fonts/special-elite-400.ttf') format('truetype');
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+2
File diff suppressed because one or more lines are too long
+1
-1
@@ -4,5 +4,5 @@ services:
|
||||
ports:
|
||||
- "${PORT:-8080}:80"
|
||||
volumes:
|
||||
- ./config.json:/var/www/html/config.json
|
||||
- ./data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
mkdir -p /data
|
||||
chown -R www-data:www-data /data
|
||||
chmod 775 /data
|
||||
|
||||
rm -f /var/www/html/config.json
|
||||
ln -s /data/config.json /var/www/html/config.json
|
||||
|
||||
exec docker-php-entrypoint "$@"
|
||||
@@ -1,81 +1,216 @@
|
||||
<?php
|
||||
/**
|
||||
* GridTV — proxy.php (Apache + cURL)
|
||||
* GridTV — proxy.php
|
||||
*
|
||||
* HTTP->HTTPS proxy restricted to hosts explicitly configured in config.json.
|
||||
* Private and LAN IPs are allowed only when the administrator configured them.
|
||||
* Redirects are followed manually and the host is revalidated at every hop.
|
||||
*/
|
||||
|
||||
$url = $_GET['url'] ?? '';
|
||||
if (empty($url) || !preg_match('#^https?://#i', $url)) {
|
||||
http_response_code(400); die('Invalid URL');
|
||||
// ── Allowlist: authorized hosts extracted from config.json ────────────────────
|
||||
$config_path = __DIR__ . '/config.json';
|
||||
$allowed_hosts = [];
|
||||
|
||||
if (file_exists($config_path)) {
|
||||
$config = json_decode(file_get_contents($config_path), true) ?? [];
|
||||
foreach ($config['epg_sources'] ?? [] as $src) {
|
||||
foreach (['epg_url', 'm3u_url'] as $key) {
|
||||
if (!empty($src[$key])) {
|
||||
$host = strtolower(parse_url($src[$key], PHP_URL_HOST) ?? '');
|
||||
if ($host !== '') $allowed_hosts[] = $host;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$base = preg_replace('#[^/]*(\?.*)?$#', '', $url);
|
||||
$origin = parse_url($url, PHP_URL_SCHEME) . '://' . parse_url($url, PHP_URL_HOST);
|
||||
$port = parse_url($url, PHP_URL_PORT);
|
||||
if ($port) $origin .= ':' . $port;
|
||||
$path = parse_url($url, PHP_URL_PATH) ?? '';
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function is_allowed_url(string $url, array $allowed_hosts): bool {
|
||||
if (!preg_match('#^https?://#i', $url)) return false;
|
||||
$host = strtolower(parse_url($url, PHP_URL_HOST) ?? '');
|
||||
return $host !== '' && in_array($host, $allowed_hosts, true);
|
||||
}
|
||||
|
||||
function normalize_path(string $path): string {
|
||||
$segments = explode('/', $path);
|
||||
$normalized = [];
|
||||
|
||||
foreach ($segments as $segment) {
|
||||
if ($segment === '' || $segment === '.') {
|
||||
continue;
|
||||
}
|
||||
if ($segment === '..') {
|
||||
array_pop($normalized);
|
||||
continue;
|
||||
}
|
||||
$normalized[] = $segment;
|
||||
}
|
||||
|
||||
return '/' . implode('/', $normalized);
|
||||
}
|
||||
|
||||
function resolve_url(string $base, string $location): string {
|
||||
if (preg_match('#^https?://#i', $location)) return $location;
|
||||
|
||||
$parts = parse_url($base);
|
||||
$origin = $parts['scheme'] . '://' . $parts['host'];
|
||||
if (!empty($parts['port'])) $origin .= ':' . $parts['port'];
|
||||
|
||||
if (strpos($location, '//') === 0) {
|
||||
return $parts['scheme'] . ':' . $location;
|
||||
}
|
||||
|
||||
if ($location === '') {
|
||||
return $base;
|
||||
}
|
||||
|
||||
if ($location[0] === '#') {
|
||||
$without_fragment = preg_replace('/#.*$/', '', $base);
|
||||
return $without_fragment . $location;
|
||||
}
|
||||
|
||||
if ($location[0] === '?') {
|
||||
$path = $parts['path'] ?? '/';
|
||||
return $origin . $path . $location;
|
||||
}
|
||||
|
||||
$location_parts = parse_url($location);
|
||||
$path = $location_parts['path'] ?? '';
|
||||
$query = isset($location_parts['query']) ? '?' . $location_parts['query'] : '';
|
||||
$fragment = isset($location_parts['fragment']) ? '#' . $location_parts['fragment'] : '';
|
||||
|
||||
if ($path !== '' && $path[0] === '/') {
|
||||
return $origin . normalize_path($path) . $query . $fragment;
|
||||
}
|
||||
|
||||
$base_dir = dirname($parts['path'] ?? '/');
|
||||
if ($base_dir === '\\' || $base_dir === '.') {
|
||||
$base_dir = '/';
|
||||
}
|
||||
$combined = rtrim($base_dir, '/') . '/' . $path;
|
||||
|
||||
return $origin . normalize_path($combined) . $query . $fragment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with manual redirect handling so every Location header is checked
|
||||
* against the allowlist before following it.
|
||||
*/
|
||||
function fetch_with_checked_redirects(string $url, array $allowed_hosts, bool $head_only = false): array {
|
||||
$max_redirects = 5;
|
||||
|
||||
for ($i = 0; $i <= $max_redirects; $i++) {
|
||||
if (!is_allowed_url($url, $allowed_hosts)) {
|
||||
http_response_code(403); die('Host not allowed after redirect');
|
||||
}
|
||||
|
||||
$ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0';
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_HEADER => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_NOBODY => $head_only,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_USERAGENT => $ua,
|
||||
CURLOPT_HTTPHEADER => ['Accept: */*'],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false) {
|
||||
http_response_code(502); die('Upstream error');
|
||||
}
|
||||
|
||||
$raw_headers = substr($response, 0, $header_size);
|
||||
$body = substr($response, $header_size);
|
||||
|
||||
// Follow redirects manually so each hop is rechecked against the allowlist.
|
||||
if ($code >= 300 && $code < 400) {
|
||||
if (!preg_match('/^Location:\s*(.+)$/mi', $raw_headers, $m)) {
|
||||
http_response_code(502); die('Invalid redirect');
|
||||
}
|
||||
$url = resolve_url($url, trim($m[1]));
|
||||
continue;
|
||||
}
|
||||
|
||||
return [$code, $raw_headers, $body, $url];
|
||||
}
|
||||
|
||||
http_response_code(508); die('Too many redirects');
|
||||
}
|
||||
|
||||
// ── Validate the initial URL ───────────────────────────────────────────────────
|
||||
$url = $_GET['url'] ?? '';
|
||||
|
||||
if (!is_allowed_url($url, $allowed_hosts)) {
|
||||
http_response_code(empty($allowed_hosts) ? 503 : 403);
|
||||
die(empty($allowed_hosts) ? 'No sources configured' : 'Host not allowed');
|
||||
}
|
||||
|
||||
// ── Detect the resource type ───────────────────────────────────────────────────
|
||||
$path = parse_url($url, PHP_URL_PATH) ?? '';
|
||||
$is_segment = preg_match('#\.(ts|aac|mp4|m4s|fmp4)(\?|$)#i', $path);
|
||||
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Cache-Control: no-cache');
|
||||
|
||||
$ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0';
|
||||
|
||||
// ── Binary segment: stream chunk by chunk ──────────────────────────────────────
|
||||
if ($is_segment) {
|
||||
// Segment binaire — stream via cURL chunk par chunk
|
||||
header('Content-Type: video/MP2T');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
if (ob_get_level()) ob_end_clean();
|
||||
|
||||
$ch = curl_init($url);
|
||||
// Resolve redirects with HEAD first so we do not download the segment twice.
|
||||
// Some HLS/CDN servers reject HEAD on media objects even when GET works.
|
||||
[$code, , , $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, true);
|
||||
if ($code === 403 || $code === 405) {
|
||||
[, , , $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, false);
|
||||
$code = 200;
|
||||
}
|
||||
|
||||
if ($code >= 400) { http_response_code($code); die(); }
|
||||
|
||||
// Stream the validated final URL.
|
||||
$ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0';
|
||||
$ch = curl_init($final_url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_USERAGENT => $ua,
|
||||
CURLOPT_HTTPHEADER => ['Accept: */*'],
|
||||
CURLOPT_RETURNTRANSFER => false,
|
||||
CURLOPT_WRITEFUNCTION => function($ch, $data) {
|
||||
echo $data;
|
||||
flush();
|
||||
return strlen($data);
|
||||
echo $data; flush(); return strlen($data);
|
||||
},
|
||||
CURLOPT_HEADERFUNCTION => function($ch, $header) {
|
||||
$h = trim($header);
|
||||
if (preg_match('/^Content-Type:/i', $h)) {
|
||||
header($h);
|
||||
}
|
||||
if (preg_match('/^Content-Type:/i', $h)) header($h);
|
||||
return strlen($header);
|
||||
},
|
||||
]);
|
||||
|
||||
$ok = curl_exec($ch);
|
||||
$ok = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
if (!$ok || $code >= 400) {
|
||||
http_response_code($code ?: 502);
|
||||
}
|
||||
if (!$ok || $code >= 400) http_response_code($code ?: 502);
|
||||
curl_close($ch);
|
||||
|
||||
// ── m3u8 playlist: fetch and rewrite URLs ──────────────────────────────────────
|
||||
} else {
|
||||
// Playlist .m3u8 — fetch + réécriture URLs
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_USERAGENT => $ua,
|
||||
CURLOPT_HTTPHEADER => ['Accept: */*'],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
[$code, , $body, $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, false);
|
||||
|
||||
if ($body === false || $code >= 400) {
|
||||
http_response_code($code ?: 502);
|
||||
die("Upstream error $code");
|
||||
}
|
||||
if ($code >= 400) { http_response_code($code); die("Upstream error $code"); }
|
||||
|
||||
header('Content-Type: application/vnd.apple.mpegurl');
|
||||
|
||||
$final_parts = parse_url($final_url);
|
||||
$origin = $final_parts['scheme'] . '://' . $final_parts['host'];
|
||||
if (!empty($final_parts['port'])) $origin .= ':' . $final_parts['port'];
|
||||
$base = preg_replace('#[^/]*(\?.*)?$#', '', $final_url);
|
||||
|
||||
$proxy_base = (isset($_SERVER['HTTPS']) ? 'https' : 'http')
|
||||
. '://' . $_SERVER['HTTP_HOST']
|
||||
. strtok($_SERVER['REQUEST_URI'], '?')
|
||||
@@ -85,7 +220,7 @@ if ($is_segment) {
|
||||
foreach (explode("\n", $body) as $line) {
|
||||
$line = rtrim($line);
|
||||
if ($line === '' || $line[0] === '#') {
|
||||
$line = preg_replace_callback('/URI="([^"]+)"/', function($m) use ($base, $proxy_base) {
|
||||
$line = preg_replace_callback('/URI="([^"]+)"/', function($m) use ($base, $origin, $proxy_base) {
|
||||
$seg = strpos($m[1], 'http') === 0 ? $m[1]
|
||||
: ($m[1][0] === '/' ? $origin . $m[1] : $base . $m[1]);
|
||||
return 'URI="' . $proxy_base . urlencode($seg) . '"';
|
||||
|
||||
@@ -1,28 +1,39 @@
|
||||
<?php
|
||||
$config_path = __DIR__ . '/config.json';
|
||||
|
||||
// Migration automatique ancien format
|
||||
// Automatically migrate the legacy config format.
|
||||
if (file_exists($config_path)) {
|
||||
$existing = json_decode(file_get_contents($config_path), true);
|
||||
if (isset($existing['epg_url']) && !isset($existing['epg_sources'])) {
|
||||
$existing['epg_sources'] = [[
|
||||
'name' => 'Main',
|
||||
'epg_url' => $existing['epg_url'],
|
||||
'm3u_url' => $existing['m3u_url'] ?? '',
|
||||
]];
|
||||
$existing['allow_personal_epg'] = false;
|
||||
unset($existing['epg_url'], $existing['m3u_url']);
|
||||
file_put_contents($config_path, json_encode($existing, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
// Bootstrap : config existante sans cle admin -> on en genere une maintenant
|
||||
if (empty($existing['admin_key'])) {
|
||||
$existing['admin_key'] = bin2hex(random_bytes(16));
|
||||
file_put_contents($config_path, json_encode($existing, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
if (is_array($existing)) {
|
||||
if (isset($existing['epg_url']) && !isset($existing['epg_sources'])) {
|
||||
$existing['epg_sources'] = [[
|
||||
'name' => 'Main',
|
||||
'epg_url' => $existing['epg_url'],
|
||||
'm3u_url' => $existing['m3u_url'] ?? '',
|
||||
]];
|
||||
$existing['allow_personal_epg'] = false;
|
||||
unset($existing['epg_url'], $existing['m3u_url']);
|
||||
file_put_contents($config_path, json_encode($existing, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
// Bootstrap an admin key for older configs that do not have one yet.
|
||||
if (empty($existing['admin_key'])) {
|
||||
$existing['admin_key'] = bin2hex(random_bytes(16));
|
||||
file_put_contents($config_path, json_encode($existing, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$is_first_setup = !file_exists($config_path);
|
||||
$existing = $is_first_setup ? [] : json_decode(file_get_contents($config_path), true);
|
||||
$existing = [];
|
||||
$corrupt_config = false;
|
||||
if (!$is_first_setup) {
|
||||
$existing = json_decode(file_get_contents($config_path), true);
|
||||
if (!is_array($existing)) {
|
||||
$existing = [];
|
||||
$corrupt_config = true;
|
||||
$is_first_setup = true;
|
||||
}
|
||||
}
|
||||
$admin_key = $existing['admin_key'] ?? null;
|
||||
|
||||
$mode = 'setup';
|
||||
@@ -109,7 +120,7 @@ $submitted_key = trim($_POST['admin_key_input'] ?? $_POST['admin_key_hidden']
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GridTV — Setup</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Barlow+Condensed:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/assets/fonts/fonts.css">
|
||||
<style>
|
||||
:root {
|
||||
--bg:#0a0b0d;--surface:#111318;--surface2:#181b22;
|
||||
|
||||
+15
-4
@@ -8,8 +8,12 @@ if (!file_exists($config_path)) {
|
||||
}
|
||||
|
||||
$config = json_decode(file_get_contents($config_path), true);
|
||||
if (!is_array($config)) {
|
||||
http_response_code(500);
|
||||
die('<h2>GridTV — Configuration error</h2><p><code>config.json</code> is invalid or corrupted.<br>Please delete it and re-run <a href="setup.php">setup</a>, or fix it manually via SSH.</p>');
|
||||
}
|
||||
|
||||
// ── Migration automatique ancien format → nouveau ─────────────────────────────
|
||||
// ── Automatically migrate the legacy config format to the current one ─────────
|
||||
if (isset($config['epg_url']) && !isset($config['epg_sources'])) {
|
||||
$config['epg_sources'] = [[
|
||||
'name' => 'Main',
|
||||
@@ -26,21 +30,28 @@ $group_name = htmlspecialchars($config['group_name'] ?? 'GridTV');
|
||||
$epg_sources = $config['epg_sources'] ?? [];
|
||||
$allow_personal_epg = !empty($config['allow_personal_epg']);
|
||||
|
||||
// Première source par défaut (pour compatibilité avec le reste du code)
|
||||
// Default to the first configured source to keep the rest of the app compatible.
|
||||
$first_source = $epg_sources[0] ?? [];
|
||||
$epg_url = htmlspecialchars($first_source['epg_url'] ?? '');
|
||||
$m3u_url = htmlspecialchars($first_source['m3u_url'] ?? '');
|
||||
|
||||
// ── Locale detection ─────────────────────────────────────────────────────────
|
||||
$supported_locales = ['en', 'fr', 'es'];
|
||||
// Auto-discover available locales from the locales/ directory.
|
||||
$locale_files = glob(__DIR__ . '/../locales/*.json') ?: [];
|
||||
$supported_locales = array_map(fn($f) => basename($f, '.json'), $locale_files);
|
||||
$locale = 'en';
|
||||
$accept = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? 'en';
|
||||
foreach (explode(',', $accept) as $lang) {
|
||||
$code = strtolower(substr(trim($lang), 0, 2));
|
||||
if (in_array($code, $supported_locales)) { $locale = $code; break; }
|
||||
if (in_array($code, $supported_locales, true)) { $locale = $code; break; }
|
||||
}
|
||||
$locale_path = __DIR__ . "/../locales/{$locale}.json";
|
||||
$L = json_decode(file_get_contents($locale_path), true);
|
||||
if (!is_array($L)) {
|
||||
// Fallback to English if the locale file is missing or corrupted.
|
||||
$fallback_path = __DIR__ . '/../locales/en.json';
|
||||
$L = is_file($fallback_path) ? (json_decode(file_get_contents($fallback_path), true) ?? []) : [];
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
/* Variables de base — surchargées par le fichier thème */
|
||||
/* Base variables overridden by the active theme file */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0a0b0d;
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@
|
||||
.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 */
|
||||
/* Single container so the ruler and programs scroll together */
|
||||
.timeline-inner {
|
||||
position: relative;
|
||||
display: block;
|
||||
|
||||
+13
-2
@@ -17,8 +17,19 @@
|
||||
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: 14px;
|
||||
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;
|
||||
@@ -39,7 +50,7 @@
|
||||
.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 */
|
||||
/* Midnight date marker in the timebar */
|
||||
.time-tick-label.midnight {
|
||||
color: var(--accent); font-weight: 700; font-size: 11px;
|
||||
white-space: nowrap;
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@
|
||||
}
|
||||
.search-clear:hover { color: var(--accent); }
|
||||
|
||||
/* Highlight programmes qui matchent */
|
||||
/* Highlight programs that match the current query */
|
||||
.program-block.search-match {
|
||||
border: 1px solid var(--accent);
|
||||
box-shadow: 0 0 6px var(--accent);
|
||||
|
||||
+1
-2
@@ -128,7 +128,7 @@
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
SÉLECTEUR DE THÈME
|
||||
THEME SELECTOR
|
||||
═══════════════════════════════════════════ */
|
||||
.theme-select {
|
||||
background: transparent;
|
||||
@@ -222,4 +222,3 @@
|
||||
.source-dropdown-item:hover { background: var(--surface2); color: var(--accent2); }
|
||||
.source-dropdown-item.active { color: var(--accent); }
|
||||
.source-dropdown-personal { color: var(--accent2); }
|
||||
|
||||
|
||||
+3
-3
@@ -2,16 +2,16 @@ const EPG_SOURCES = <?= json_encode(array_values($epg_sources)) ?>;
|
||||
const L = <?= json_encode($L) ?>;
|
||||
const ALLOW_PERSONAL_EPG = <?= $allow_personal_epg ? 'true' : 'false' ?>;
|
||||
|
||||
// Source active (index)
|
||||
// Index of the currently selected source.
|
||||
let activeSourceIndex = 0;
|
||||
|
||||
const DEFAULT_EPG_URL = EPG_SOURCES[0]?.epg_url ?? '';
|
||||
const DEFAULT_M3U_URL = EPG_SOURCES[0]?.m3u_url ?? '';
|
||||
const PX_PER_MIN = 5;
|
||||
const GRID_HOURS = 72;
|
||||
const CORS_PROXY = 'https://corsproxy.io/?';
|
||||
const CORS_PROXY = '/proxy.php?url=';
|
||||
|
||||
// Ancre : 2h avant maintenant, arrondi au quart d'heure inférieur
|
||||
// Anchor the grid 2 hours before "now", rounded down to the previous quarter-hour.
|
||||
const _d = new Date();
|
||||
_d.setMinutes(Math.floor(_d.getMinutes()/15)*15, 0, 0);
|
||||
const GRID_START = new Date(_d.getTime() - 2*3600000);
|
||||
|
||||
+1
-2
@@ -73,7 +73,7 @@ function renderPrograms() {
|
||||
inner.style.width = TOTAL_WIDTH_PX+'px';
|
||||
inner.style.height = totalH+'px';
|
||||
|
||||
// Remettre la now-line (détruite par innerHTML='')
|
||||
// Recreate the now-line because it was removed by innerHTML = ''.
|
||||
const nowLine = document.createElement('div');
|
||||
nowLine.className = 'now-line'; nowLine.id = 'nowLine';
|
||||
nowLine.style.height = totalH + 'px';
|
||||
@@ -141,4 +141,3 @@ function shiftView(mins) {
|
||||
area.scrollLeft = Math.max(0, area.scrollLeft + mins * PX_PER_MIN);
|
||||
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
|
||||
}
|
||||
|
||||
|
||||
+5
-6
@@ -1,8 +1,8 @@
|
||||
// ── M3U PARSER ────────────────────────────────────────────────────────────────
|
||||
function slugify(str) {
|
||||
return str.toLowerCase()
|
||||
.replace(/^\d+\s*/, '') // virer le numéro de chaîne en début
|
||||
.replace(/[^a-z0-9]/g, ''); // garder que alphanum
|
||||
.replace(/^\d+\s*/, '') // Strip any leading channel number.
|
||||
.replace(/[^a-z0-9]/g, ''); // Keep only alphanumeric characters.
|
||||
}
|
||||
|
||||
async function loadM3U(url) {
|
||||
@@ -25,7 +25,7 @@ function parseM3U(text) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (line.startsWith('#EXTINF')) {
|
||||
// Extraire le nom : dernière partie après la virgule
|
||||
// Extract the display name from the last comma-separated segment.
|
||||
const comma = line.lastIndexOf(',');
|
||||
currentName = comma !== -1 ? line.slice(comma + 1).trim() : null;
|
||||
} else if (line && !line.startsWith('#') && currentName) {
|
||||
@@ -42,16 +42,15 @@ function getStreamUrl(channelName) {
|
||||
if (m3uStreams[slug]) {
|
||||
url = m3uStreams[slug];
|
||||
} else {
|
||||
// Fallback : chercher une clé qui contient le slug ou l'inverse
|
||||
// Fallback: try a partial match in either direction.
|
||||
for (const [key, u] of Object.entries(m3uStreams)) {
|
||||
if (key.includes(slug) || slug.includes(key)) { url = u; break; }
|
||||
}
|
||||
}
|
||||
if (!url) return null;
|
||||
// Passer par proxy.php pour éviter le mixed content HTTP/HTTPS
|
||||
// Route plain HTTP streams through proxy.php to avoid mixed-content issues.
|
||||
if (url.startsWith('http://')) {
|
||||
return 'proxy.php?url=' + encodeURIComponent(url);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -39,12 +39,12 @@ function renderMobile() {
|
||||
}
|
||||
|
||||
const timeCol = document.createElement('div'); timeCol.className='mobile-program-time';
|
||||
timeCol.innerHTML = `<span>${fmtTime(p.start)}</span><span>${fmtTime(p.stop)}</span>`;
|
||||
timeCol.innerHTML = `<span>${esc(fmtTime(p.start))}</span><span>${esc(fmtTime(p.stop))}</span>`;
|
||||
item.appendChild(timeCol);
|
||||
|
||||
const info = document.createElement('div'); info.className='mobile-program-info';
|
||||
const ep = fmtEpisode(p.season, p.episode);
|
||||
info.innerHTML = `<div class="mobile-program-title">${p.title}</div><div class="mobile-program-dur">${ep ? ep + ' · ' : ''}${dur} min</div>`;
|
||||
info.innerHTML = `<div class="mobile-program-title">${esc(p.title)}</div><div class="mobile-program-dur">${ep ? esc(ep) + ' · ' : ''}${dur} min</div>`;
|
||||
item.appendChild(info);
|
||||
|
||||
if (isLive) {
|
||||
|
||||
+2
-3
@@ -12,7 +12,7 @@ function openPip(channelName, programTitle) {
|
||||
err.style.display = 'none';
|
||||
pip.classList.add('visible');
|
||||
|
||||
// Détruire l'instance HLS précédente
|
||||
// Tear down the previous HLS instance before opening another stream.
|
||||
if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; }
|
||||
video.src = '';
|
||||
|
||||
@@ -39,7 +39,7 @@ function openPip(channelName, programTitle) {
|
||||
}
|
||||
});
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
// Safari natif HLS
|
||||
// Safari can play HLS natively without hls.js.
|
||||
video.src = url;
|
||||
video.addEventListener('loadedmetadata', () => video.play().catch(() => {}));
|
||||
} else {
|
||||
@@ -55,4 +55,3 @@ function closePip() {
|
||||
if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; }
|
||||
video.pause(); video.src = '';
|
||||
}
|
||||
|
||||
|
||||
+12
-4
@@ -4,16 +4,24 @@ function openProgramModal(ch, p) {
|
||||
const isLive = p.start <= now && p.stop > now;
|
||||
const dur = Math.round((p.stop - p.start) / 60000);
|
||||
const ep = fmtEpisode(p.season, p.episode);
|
||||
const genres = formatCategories(p.categories);
|
||||
const meta = [p.airDate, p.rating].filter(Boolean).join(' · ');
|
||||
|
||||
document.getElementById('pm-title').textContent = p.title;
|
||||
document.getElementById('pm-channel').textContent = ch.name;
|
||||
document.getElementById('pm-subtitle').textContent = p.subtitle || '';
|
||||
document.getElementById('pm-subtitle').style.display = p.subtitle ? 'block' : 'none';
|
||||
document.getElementById('pm-time').textContent =
|
||||
`${fmtTime(p.start)} — ${fmtTime(p.stop)} · ${dur} ${L.duration_min}` +
|
||||
(ep ? ` · ${ep}` : '');
|
||||
document.getElementById('pm-meta').textContent = meta;
|
||||
document.getElementById('pm-meta').style.display = meta ? 'block' : 'none';
|
||||
document.getElementById('pm-genres').textContent = genres;
|
||||
document.getElementById('pm-genres').style.display = genres ? 'block' : 'none';
|
||||
document.getElementById('pm-desc').textContent = p.desc || '';
|
||||
document.getElementById('pm-desc').style.display = p.desc ? 'block' : 'none';
|
||||
|
||||
// Bouton Watch now — seulement si live ET M3U fourni pour la source active
|
||||
// Only show "Watch now" when the program is live and the active source has M3U data.
|
||||
const watchBtn = document.getElementById('pm-watch');
|
||||
if (isLive && currentM3uUrl) {
|
||||
watchBtn.style.display = 'inline-flex';
|
||||
@@ -22,7 +30,7 @@ function openProgramModal(ch, p) {
|
||||
watchBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
// Bouton IMDb
|
||||
// IMDb search shortcut.
|
||||
document.getElementById('pm-imdb').href =
|
||||
`https://www.imdb.com/find/?q=${encodeURIComponent(p.title)}&s=tt`;
|
||||
|
||||
@@ -33,13 +41,13 @@ function closeProgramModal() {
|
||||
document.getElementById('programModal').classList.remove('visible');
|
||||
}
|
||||
|
||||
// Fermer sur clic overlay
|
||||
// Close when the overlay itself is clicked.
|
||||
document.addEventListener('click', e => {
|
||||
const modal = document.getElementById('programModal');
|
||||
if (e.target === modal) closeProgramModal();
|
||||
});
|
||||
|
||||
// Fermer sur Escape
|
||||
// Close on Escape.
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') closeProgramModal();
|
||||
});
|
||||
|
||||
+19
-12
@@ -31,7 +31,7 @@ function onSearchInput(val) {
|
||||
|
||||
function applySearch(q) {
|
||||
if (!q) {
|
||||
// Remettre toutes les chaines
|
||||
// Restore the full channel list when the query is cleared.
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
@@ -40,15 +40,16 @@ function applySearch(q) {
|
||||
today.setHours(0,0,0,0);
|
||||
const tomorrow = new Date(today.getTime() + 24*3600000);
|
||||
|
||||
// Filtrer les chaines : nom OU programme aujourd'hui correspond
|
||||
// Keep channels whose name matches or that have a matching program today.
|
||||
const filtered = channels.filter(ch => {
|
||||
// Match sur le nom de la chaine
|
||||
// Match against the channel name.
|
||||
if (ch.name.toLowerCase().includes(q)) return true;
|
||||
// Match sur les programmes d'aujourd'hui (titre + desc)
|
||||
// Match against today's programs (title + description).
|
||||
return (programs[ch.id]||[]).some(p => {
|
||||
if (p.stop < today || p.start > tomorrow) return false;
|
||||
if (p.title.toLowerCase().includes(q)) return true;
|
||||
if (p.desc && p.desc.toLowerCase().includes(q)) return true;
|
||||
if ((p.categories || []).some(cat => cat.toLowerCase().includes(q))) return true;
|
||||
return false;
|
||||
});
|
||||
});
|
||||
@@ -72,7 +73,7 @@ function renderFiltered(filteredChannels, q) {
|
||||
inner.style.width = TOTAL_WIDTH_PX + 'px';
|
||||
inner.style.height = (filteredChannels.length * ROW_H) + 'px';
|
||||
|
||||
// Remettre la now-line
|
||||
// Recreate the now-line after clearing the container.
|
||||
const nowLine = document.createElement('div');
|
||||
nowLine.className = 'now-line'; nowLine.id = 'nowLine';
|
||||
nowLine.style.height = (filteredChannels.length * ROW_H) + 'px';
|
||||
@@ -81,7 +82,7 @@ function renderFiltered(filteredChannels, q) {
|
||||
const now = new Date();
|
||||
|
||||
filteredChannels.forEach((ch, i) => {
|
||||
// Colonne chaîne
|
||||
// Channel column.
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'channel-cell';
|
||||
if (ch.icon) {
|
||||
@@ -96,7 +97,7 @@ function renderFiltered(filteredChannels, q) {
|
||||
cell.addEventListener('click', () => openPip(ch.name, getNowPlaying(ch.id)));
|
||||
list.appendChild(cell);
|
||||
|
||||
// Programmes
|
||||
// Programs row.
|
||||
const row = document.createElement('div');
|
||||
row.className = 'program-row';
|
||||
row.style.cssText = `top:${i*ROW_H}px;position:absolute;left:0;right:0;`;
|
||||
@@ -109,8 +110,11 @@ function renderFiltered(filteredChannels, q) {
|
||||
const block = document.createElement('div');
|
||||
block.className = 'program-block';
|
||||
|
||||
// Highlight si le programme matche la recherche
|
||||
const matchesProg = p.title.toLowerCase().includes(q) || (p.desc && p.desc.toLowerCase().includes(q));
|
||||
// Highlight programs that match the current search query.
|
||||
const matchesProg =
|
||||
p.title.toLowerCase().includes(q) ||
|
||||
(p.desc && p.desc.toLowerCase().includes(q)) ||
|
||||
(p.categories || []).some(cat => cat.toLowerCase().includes(q));
|
||||
if (matchesProg) block.classList.add('search-match');
|
||||
|
||||
if (p.stop < now) block.classList.add('is-past');
|
||||
@@ -168,7 +172,10 @@ function renderMobileFiltered(filteredChannels, q) {
|
||||
const isLive = p.start <= now && p.stop > now;
|
||||
const isPast = p.stop < now;
|
||||
const dur = Math.round((p.stop - p.start) / 60000);
|
||||
const matchesProg = p.title.toLowerCase().includes(q) || (p.desc && p.desc.toLowerCase().includes(q));
|
||||
const matchesProg =
|
||||
p.title.toLowerCase().includes(q) ||
|
||||
(p.desc && p.desc.toLowerCase().includes(q)) ||
|
||||
(p.categories || []).some(cat => cat.toLowerCase().includes(q));
|
||||
const item = document.createElement('div');
|
||||
item.className = 'mobile-program' + (isLive ? ' is-live' : '') + (isPast ? ' is-past' : '') + (matchesProg ? ' search-match' : '');
|
||||
|
||||
@@ -179,12 +186,12 @@ function renderMobileFiltered(filteredChannels, q) {
|
||||
}
|
||||
|
||||
const timeCol = document.createElement('div'); timeCol.className = 'mobile-program-time';
|
||||
timeCol.innerHTML = `<span>${fmtTime(p.start)}</span><span>${fmtTime(p.stop)}</span>`;
|
||||
timeCol.innerHTML = `<span>${esc(fmtTime(p.start))}</span><span>${esc(fmtTime(p.stop))}</span>`;
|
||||
item.appendChild(timeCol);
|
||||
|
||||
const info = document.createElement('div'); info.className = 'mobile-program-info';
|
||||
const ep = fmtEpisode(p.season, p.episode);
|
||||
info.innerHTML = `<div class="mobile-program-title">${p.title}</div><div class="mobile-program-dur">${ep ? ep + ' · ' : ''}${dur} min</div>`;
|
||||
info.innerHTML = `<div class="mobile-program-title">${esc(p.title)}</div><div class="mobile-program-dur">${ep ? esc(ep) + ' · ' : ''}${dur} min</div>`;
|
||||
item.appendChild(info);
|
||||
|
||||
if (isLive) {
|
||||
|
||||
+4
-4
@@ -21,7 +21,7 @@ function switchSource(value) {
|
||||
}
|
||||
|
||||
function updateCopyButtons() {
|
||||
// Rien à faire visuellement, copyUrl() lit currentEpgUrl/currentM3uUrl
|
||||
// No visual update is needed here because copyUrl() reads the current URLs directly.
|
||||
}
|
||||
|
||||
function openPersonalEpgModal() {
|
||||
@@ -35,7 +35,7 @@ function openPersonalEpgModal() {
|
||||
|
||||
function closePersonalEpgModal(applied) {
|
||||
document.getElementById('personalEpgModal').classList.remove('visible');
|
||||
// Si annulé (pas applied), remettre le select sur la source précédente
|
||||
// If the modal was cancelled, restore the previously selected source.
|
||||
if (!applied) {
|
||||
const sel = document.getElementById('sourceSelect');
|
||||
if (sel) sel.value = localStorage.getItem('gridtv-active-source') === 'personal' ? 'personal' : activeSourceIndex;
|
||||
@@ -67,7 +67,7 @@ function closeSourceDropdown() {
|
||||
document.getElementById('sourceDropdownMenu')?.classList.remove('open');
|
||||
}
|
||||
|
||||
// Fermer si clic en dehors
|
||||
// Close the dropdown when clicking outside of it.
|
||||
document.addEventListener('click', e => {
|
||||
if (!e.target.closest('.source-dropdown')) closeSourceDropdown();
|
||||
});
|
||||
@@ -75,7 +75,7 @@ document.addEventListener('click', e => {
|
||||
function updateSourceDropdownLabel(label) {
|
||||
const el = document.getElementById('sourceDropdownLabel');
|
||||
if (el) el.textContent = label;
|
||||
// Mettre à jour la classe active
|
||||
// Update the active item styling in the dropdown.
|
||||
document.querySelectorAll('.source-dropdown-item').forEach((btn, i) => {
|
||||
btn.classList.toggle('active', i === activeSourceIndex);
|
||||
});
|
||||
|
||||
+1
-2
@@ -1,4 +1,4 @@
|
||||
// ── THÈMES ────────────────────────────────────────────────────────────────────
|
||||
// ── THEMES ────────────────────────────────────────────────────────────────────
|
||||
function setTheme(theme) {
|
||||
document.getElementById('themeStylesheet').href = 'themes/' + theme + '.css';
|
||||
localStorage.setItem('gridtv-theme', theme);
|
||||
@@ -17,4 +17,3 @@ function startLiveUpdates() {
|
||||
setInterval(() => { if (channels.length) renderAll(); }, 60000);
|
||||
setInterval(() => loadEPG(DEFAULT_EPG_URL), 30*60*1000);
|
||||
}
|
||||
|
||||
|
||||
+7
-4
@@ -8,8 +8,13 @@ function getNowPlaying(chId) {
|
||||
// ── TOOLTIP ───────────────────────────────────────────────────────────────────
|
||||
function showTooltip(e, p) {
|
||||
const ep = fmtEpisode(p.season, p.episode);
|
||||
document.getElementById('tt-title').textContent = p.title + (ep ? ' ' + ep : '');
|
||||
document.getElementById('tt-time').textContent = `${fmtTime(p.start)} — ${fmtTime(p.stop)} · ${Math.round((p.stop-p.start)/60000)} min`;
|
||||
const genres = formatCategories(p.categories);
|
||||
const title = p.title + (p.subtitle ? ` - ${p.subtitle}` : '') + (ep ? ' ' + ep : '');
|
||||
const meta = [p.airDate, p.rating, genres].filter(Boolean).join(' · ');
|
||||
document.getElementById('tt-title').textContent = title;
|
||||
document.getElementById('tt-time').textContent =
|
||||
`${fmtTime(p.start)} — ${fmtTime(p.stop)} · ${Math.round((p.stop-p.start)/60000)} min` +
|
||||
(meta ? ` · ${meta}` : '');
|
||||
document.getElementById('tt-desc').textContent = p.desc || '—';
|
||||
document.getElementById('tooltip').classList.add('visible');
|
||||
moveTooltip(e);
|
||||
@@ -23,5 +28,3 @@ function moveTooltip(e) {
|
||||
function hideTooltip() { document.getElementById('tooltip').classList.remove('visible'); }
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+46
-6
@@ -1,8 +1,15 @@
|
||||
|
||||
// Escape EPG-provided strings before injecting them into HTML.
|
||||
function esc(str) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = str || '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
let channels = [];
|
||||
let programs = {};
|
||||
let m3uStreams = {}; // slug normalisé → url stream
|
||||
let scrollListenerAdded = false; // sync scroll vertical chaînes
|
||||
let m3uStreams = {}; // Normalized slug -> stream URL.
|
||||
let scrollListenerAdded = false; // Keeps the channel column vertically synced with the timeline.
|
||||
|
||||
function pad(n) { return String(n).padStart(2,'0'); }
|
||||
|
||||
@@ -28,6 +35,8 @@ function parseXMLTVDate(str) {
|
||||
if (!str) return null;
|
||||
const m = str.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{4})?/);
|
||||
if (!m) return null;
|
||||
// XMLTV timestamps use a compact timezone suffix like +0200, so we rebuild
|
||||
// the UTC instant manually before letting Date convert it to local time.
|
||||
const offset = m[7] ? parseInt(m[7]) : 0;
|
||||
const offsetMs = (Math.floor(Math.abs(offset)/100)*60 + (Math.abs(offset)%100)) * 60000 * (offset<0?-1:1);
|
||||
return new Date(Date.UTC(+m[1],+m[2]-1,+m[3],+m[4],+m[5],+m[6]) - offsetMs);
|
||||
@@ -71,7 +80,7 @@ function loadFromInput() {
|
||||
}
|
||||
|
||||
function parseEpisode(p) {
|
||||
// Format xmltv_ns : "S.E.part" (0-indexed) ex: "0.2.0/1" = S01E03
|
||||
// xmltv_ns format: "S.E.part" (0-indexed), e.g. "0.2.0/1" -> S01E03
|
||||
const ns = p.querySelector('episode-num[system="xmltv_ns"]')?.textContent;
|
||||
if (ns) {
|
||||
const parts = ns.split('.');
|
||||
@@ -80,7 +89,7 @@ function parseEpisode(p) {
|
||||
const e = ePart !== '' ? parseInt(ePart) + 1 : null;
|
||||
if (s !== null || e !== null) return { season: s, episode: e };
|
||||
}
|
||||
// Format onscreen : "S01E03" ou "s1e3"
|
||||
// onscreen format: "S01E03" or "s1e3"
|
||||
const os = p.querySelector('episode-num[system="onscreen"]')?.textContent;
|
||||
if (os) {
|
||||
const m = os.match(/[Ss](\d+)[Ee](\d+)/);
|
||||
@@ -97,6 +106,34 @@ function fmtEpisode(season, episode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCategories(p) {
|
||||
const seen = new Set();
|
||||
const categories = [];
|
||||
p.querySelectorAll('category').forEach(cat => {
|
||||
const value = (cat.textContent || '').trim();
|
||||
const key = value.toLowerCase();
|
||||
if (!value || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
categories.push(value);
|
||||
});
|
||||
return categories;
|
||||
}
|
||||
|
||||
function formatCategories(categories) {
|
||||
return Array.isArray(categories) && categories.length ? categories.join(' · ') : '';
|
||||
}
|
||||
|
||||
function parseAirDate(value) {
|
||||
if (!value) return null;
|
||||
const match = String(value).trim().match(/^(\d{4})/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function parseRatingValue(p) {
|
||||
const rating = p.querySelector('rating > value')?.textContent || '';
|
||||
return rating.trim() || null;
|
||||
}
|
||||
|
||||
function parseAndRender(doc) {
|
||||
channels = []; programs = {};
|
||||
doc.querySelectorAll('channel').forEach(ch => {
|
||||
@@ -115,9 +152,13 @@ function parseAndRender(doc) {
|
||||
const start = parseXMLTVDate(p.getAttribute('start'));
|
||||
const stop = parseXMLTVDate(p.getAttribute('stop'));
|
||||
const title = p.querySelector('title')?.textContent || '';
|
||||
const subtitle = p.querySelector('sub-title')?.textContent || '';
|
||||
const desc = p.querySelector('desc')?.textContent || '';
|
||||
const categories = parseCategories(p);
|
||||
const airDate = parseAirDate(p.querySelector('date')?.textContent || '');
|
||||
const rating = parseRatingValue(p);
|
||||
const epInfo = parseEpisode(p);
|
||||
if (start && stop) programs[chId].push({ start, stop, title, desc, ...epInfo });
|
||||
if (start && stop) programs[chId].push({ start, stop, title, subtitle, desc, categories, airDate, rating, ...epInfo });
|
||||
});
|
||||
Object.keys(programs).forEach(id => programs[id].sort((a,b) => a.start - b.start));
|
||||
|
||||
@@ -129,4 +170,3 @@ function renderAll() {
|
||||
if (isMobile()) { renderMobile(); }
|
||||
else { renderGrid(); setTimeout(centerNow, 150); }
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js"></script>
|
||||
<script src="/assets/vendor/hls.min.js"></script>
|
||||
<script>
|
||||
<?php
|
||||
$js_dir = __DIR__ . '/../js/';
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="GridTV">
|
||||
<link 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">
|
||||
<?php
|
||||
$css_dir = __DIR__ . '/../css/';
|
||||
$css_files = ['base', 'topbar', 'grid', 'mobile', 'player', 'modals', 'search', 'program', 'favorites', 'updater'];
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
<button class="pm-close" onclick="closeProgramModal()">✕</button>
|
||||
<div class="pm-channel" id="pm-channel"></div>
|
||||
<div class="pm-title" id="pm-title"></div>
|
||||
<div class="pm-subtitle" id="pm-subtitle"></div>
|
||||
<div class="pm-time" id="pm-time"></div>
|
||||
<div class="pm-meta" id="pm-meta"></div>
|
||||
<div class="pm-genres" id="pm-genres"></div>
|
||||
<div class="pm-desc" id="pm-desc"></div>
|
||||
<div class="pm-actions">
|
||||
<button class="pm-btn pm-watch" id="pm-watch">▶ <?= htmlspecialchars($L["watch_now"]) ?></button>
|
||||
|
||||
+2
-3
@@ -37,7 +37,7 @@
|
||||
<?php
|
||||
$theme_dir = __DIR__ . '/../../themes';
|
||||
$theme_files = glob($theme_dir . '/*.css');
|
||||
// default en premier, puis le reste par ordre alphabétique
|
||||
// Put the default theme first, then sort the rest alphabetically.
|
||||
usort($theme_files, function($a, $b) {
|
||||
$a_base = basename($a, '.css');
|
||||
$b_base = basename($b, '.css');
|
||||
@@ -48,7 +48,7 @@ usort($theme_files, function($a, $b) {
|
||||
foreach ($theme_files as $file) {
|
||||
$slug = basename($file, '.css');
|
||||
$css = file_get_contents($file);
|
||||
// Lire @name et @emoji dans le premier bloc commentaire
|
||||
// Read @name and @emoji from the first comment block.
|
||||
preg_match('/@name\s+(.+)/u', $css, $m_name);
|
||||
preg_match('/@emoji\s+(\S+)/u', $css, $m_emoji);
|
||||
$name = isset($m_name[1]) ? trim($m_name[1]) : ucfirst($slug);
|
||||
@@ -64,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)" />
|
||||
<button class="search-clear" onclick="clearSearch()">✕</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
const CACHE_NAME = 'gridtv-v1';
|
||||
const CACHE_NAME = 'gridtv-v2';
|
||||
|
||||
// Ressources statiques à mettre en cache immédiatement
|
||||
// Static assets cached during installation.
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/index.php',
|
||||
'/manifest.json',
|
||||
'/assets/fonts/fonts.css',
|
||||
'/assets/icon-192.png',
|
||||
'/assets/icon-512.png',
|
||||
'https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Barlow+Condensed:wght@300;400;600;700&display=swap',
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js'
|
||||
'/assets/vendor/hls.min.js'
|
||||
];
|
||||
|
||||
// Install : mise en cache des assets statiques
|
||||
// Install: cache the static assets.
|
||||
self.addEventListener('install', e => {
|
||||
e.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
@@ -20,7 +20,7 @@ self.addEventListener('install', e => {
|
||||
);
|
||||
});
|
||||
|
||||
// Activate : purger les anciens caches
|
||||
// Activate: remove outdated caches.
|
||||
self.addEventListener('activate', e => {
|
||||
e.waitUntil(
|
||||
caches.keys().then(keys =>
|
||||
@@ -29,21 +29,22 @@ self.addEventListener('activate', e => {
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch : stratégie selon le type de ressource
|
||||
// Fetch: choose a caching strategy based on resource type.
|
||||
self.addEventListener('fetch', e => {
|
||||
const url = new URL(e.request.url);
|
||||
|
||||
// Flux EPG/M3U et API GitHub : network only (pas de cache)
|
||||
// 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.includes('proxy.php') ||
|
||||
url.pathname.endsWith('version.json')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Assets statiques : cache first
|
||||
// Static assets use a cache-first strategy.
|
||||
if (
|
||||
e.request.destination === 'style' ||
|
||||
e.request.destination === 'script' ||
|
||||
@@ -63,7 +64,7 @@ self.addEventListener('fetch', e => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pages PHP (index.php) : network first, fallback cache
|
||||
// PHP pages use network-first with cache fallback.
|
||||
e.respondWith(
|
||||
fetch(e.request)
|
||||
.then(res => {
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Néons rose/cyan sur noir absolu. Blade Runner.
|
||||
*/
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
--bg: #000008;
|
||||
--surface: #080010;
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Salle de régie sombre, la base.
|
||||
*/
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Barlow+Condensed:wght@300;400;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
--bg: #0a0b0d;
|
||||
--surface: #111318;
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Papier jauni, encre rouge et bleue, machine à écrire.
|
||||
*/
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Special+Elite&display=swap');
|
||||
|
||||
:root {
|
||||
--bg: #f2e8c8;
|
||||
--surface: #ede0b0;
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Brun chaud, cuivre, laiton. Très 1880.
|
||||
*/
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=IM+Fell+English:ital@0;1&display=swap');
|
||||
|
||||
:root {
|
||||
--bg: #160e04;
|
||||
--surface: #201408;
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "1.4.0"
|
||||
"version": "1.5.0"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user