14 Commits
Author SHA1 Message Date
Johnnybegood90 43bf96405d Merge remote-tracking branch 'origin/beta'
README.md Update
version.json upgraded.
2026-03-17 03:24:38 +01:00
Johnnybegood90 d1e15da33f Update README and bump version to 1.7.0 2026-03-17 03:23:37 +01:00
Johnnybegood90 d7560828f0 Merge remote-tracking branch 'origin/beta'
Add program reminders and category filtering to the guide UI
Enhance health diagnostics with EPG quality and source metrics
Store admin key in localStorage for admin and setup unlock forms
2026-03-17 03:19:13 +01:00
Johnnybegood90 eb9e3028cf Store admin key in localStorage for unlock forms 2026-03-17 03:16:35 +01:00
Johnnybegood90 9b068e7c74 Add program reminders and category filtering to the guide UI
Enhance health diagnostics with EPG quality and source metrics
2026-03-17 03:10:21 +01:00
Johnnybegood90 8cf3e6efa3 Adding endpoint for monitoring into health.php. 2026-03-15 17:51:19 +01:00
Johnnybegood90 9e543365d8 Ajoute endpoint probe health 2026-03-15 16:29:22 +01:00
Johnnybegood90 d9dae13a3e Ability to disable Personal M3U. 2026-03-15 14:19:44 +01:00
Johnnybegood90 e903a0b3d6 Ajouter fichier demo et info M3U 2026-03-15 14:17:31 +01:00
Johnnybegood90 ef374b2633 Documenter release et demo 2026-03-15 04:51:06 +01:00
Johnnybegood90 ff4ce5d876 README.md update. 2026-03-15 03:16:59 +01:00
Johnnybegood90 68f65f84b2 Documenter mode demo et export 2026-03-15 03:16:27 +01:00
Johnnybegood90 4ef5bcbb48 Adding a demo mode via a empty file '.demo' into root. 2026-03-15 03:09:46 +01:00
Johnnybegood90 bdf3b4797d Retire clé export et ajoute demo 2026-03-15 03:08:16 +01:00
25 changed files with 971 additions and 74 deletions
+1
View File
@@ -1,3 +1,4 @@
config.json
.demo
data/
.DS_Store
+117 -7
View File
@@ -18,7 +18,9 @@
A public demo instance is available here:
👉 https://guide.demo.johnnybegood.fr/
- Guide: [guide.demo.johnnybegood.fr](https://guide.demo.johnnybegood.fr/)
- Health: [guide.demo.johnnybegood.fr/health.php](https://guide.demo.johnnybegood.fr/health.php)
- Setup (read-only on demo): [guide.demo.johnnybegood.fr/setup.php](https://guide.demo.johnnybegood.fr/setup.php)
This demo runs with sample XMLTV feeds to showcase the interface.
@@ -33,18 +35,21 @@ This demo runs with sample XMLTV feeds to showcase the interface.
- 🕶️ **Past programs** automatically dimmed
- 📋 **Hover tooltip** — title, synopsis, season/episode, schedule, duration
- 🎬 **Program popup** — click any program for full details + IMDb search link
- 🔔 **Program reminders** — save a browser reminder from the program popup and get notified 15 minutes before airing
- 🔗 **One-click copy** buttons for EPG & M3U URLs in the topbar
- ▶️ **Built-in HLS player** — click any channel or live program to watch in a PiP overlay
- 🔀 **HTTP→HTTPS proxy** — streams Tunarr over HTTP transparently from an HTTPS page
- 🎨 **Theme system** — drop a CSS file in `themes/` and it appears in the menu automatically
- 📡 **Multi-EPG sources** — configure multiple EPG/M3U sources, switch from the topbar
- 👤 **Personal EPG** — optionally let visitors use your instance with their own EPG/M3U URLs (saved in localStorage)
- 🗂️ **Category filter** — narrow the guide to movies, sports, news, kids content, etc.
- 🔍 **Search** — filter the grid by channel name or program title/synopsis (debounced)
-**Favorites** — pin channels to the top of the grid, persisted in localStorage
- 🌍 **i18n** — auto-detects browser language, supports EN / FR / ES (add your own in `locales/`)
- ⚙️ **Re-editable setup** — protected by an admin key, no SSH required to update config
- 🩺 **Admin health page** — check config, XMLTV, M3U, response times, and metadata coverage
- ⚙️ **Re-editable setup** — protected by an admin key, no SSH required to update config, with browser-side admin key memory for convenience
- 🩺 **Admin health page** — check config, XMLTV, M3U, response times, redirects, payload size, and metadata coverage/quality
- 🖨️ **24h printable export** — generate a polished daily schedule for PDF/print and PNG export
- 🧪 **Demo mode** — drop a `.demo` file at the project root to expose admin pages safely in read-only mode
- 🔔 **Update notifications** — a badge appears in the topbar when a new release is available on GitHub
- 🔄 **Auto-reload** EPG every 30 minutes
- 0️⃣ **Zero build tooling** — vanilla PHP/JS/CSS, with bundled local assets
@@ -226,6 +231,7 @@ GridTV detects the missing config and automatically redirects you to the setup p
| **Personal EPG** | Toggle to allow visitors to use your instance with their own EPG/M3U |
Once submitted, `config.json` is created on the server. **The setup page becomes inaccessible until you re-enter your admin key.**
On the same browser, GridTV also remembers the admin key in `localStorage` so you do not need to type it again on every visit.
Admin tools are available here once unlocked with the same key:
@@ -234,6 +240,8 @@ http://guide.your-domain.com/health.php
http://guide.your-domain.com/export.php
```
`health.php` is admin-only by default. `export.php` is public so visitors can print or save the daily guide.
---
<details>
@@ -270,7 +278,8 @@ nano /var/www/gridtv/config.json
"m3u_url": ""
}
],
"allow_personal_epg": true
"allow_personal_epg": true,
"allow_personal_m3u": true
}
```
@@ -280,6 +289,106 @@ nano /var/www/gridtv/config.json
</details>
<details>
<summary>Health page</summary>
## 🩺 Health page
The health page gives you a quick operational view of your instance without needing SSH first:
- checks whether `config.json` exists and is writable by PHP
- shows the current PHP version and whether `php-curl` / `php-xml` are loaded
- tests each configured XMLTV source
- tests each configured M3U playlist when present
- shows response times, final URL after redirects, content type, and payload size
- highlights XMLTV coverage for things like `category`, `sub-title`, `rating`, `date`, `desc`, logos, and stop times
- gives you a rough XMLTV quality score per source
- shows M3U stream counts, HTTP vs HTTPS split, and duplicate stream URLs
By default, the page is protected by the same admin key as `setup.php`:
```text
http://guide.your-domain.com/health.php
```
If you enable demo mode with a `.demo` file, visitors can open the page without the key.
For monitoring tools such as Uptime Kuma, GridTV also exposes a lightweight probe endpoint:
```text
http://guide.your-domain.com/health.php?format=probe
```
- returns HTTP `200` when the instance looks healthy
- returns HTTP `503` when config, PHP extensions, or the first XMLTV source fail
- returns compact JSON suitable for external monitoring
If you prefer a plain text response:
```text
http://guide.your-domain.com/health.php?format=probe&plain=1
```
---
</details>
<details>
<summary>24h export</summary>
## 🖨️ 24h export
The export page builds a printable 24-hour TV guide from the current XMLTV source:
- filter by source
- choose the target date
- restrict the export to a time window (`00h-24h`, morning, afternoon, evening, prime time)
- limit the number of visible channels
- choose between a readable card layout or a dense timeline
- export to PDF via the browser print dialog
- export to PNG directly from the page
Public URL:
```text
http://guide.your-domain.com/export.php
```
This page is intentionally public so you can share a “TV guide sheet” with other people.
---
</details>
<details>
<summary>Demo mode</summary>
## 🧪 Demo mode
If you want to expose your instance publicly without letting visitors modify it, create an empty `.demo` file at the project root:
```bash
touch /var/www/gridtv/.demo
```
When `.demo` exists:
- `setup.php` becomes read-only
- `health.php` can be viewed without entering the admin key
- visitors can inspect the current configuration safely
To disable demo mode:
```bash
rm /var/www/gridtv/.demo
```
> `.demo` is ignored by Git, just like `config.json`.
---
</details>
<details>
<summary>Project structure</summary>
@@ -295,7 +404,7 @@ gridtv/
├── version.json # Current version (used for update check)
├── Dockerfile
├── docker-compose.yml
├── .gitignore # config.json excluded
├── .gitignore # local instance files excluded
├── locales/ # i18n translation files
│ ├── en.json
│ ├── fr.json
@@ -313,7 +422,7 @@ gridtv/
│ ├── grid.css # Grid, channels, programs, ruler, tooltip, loading
│ ├── mobile.css # Mobile list view
│ ├── player.css # HLS PiP player
│ ├── modals.css # Personal EPG modal
│ ├── modals.css # Program reminder modal + Personal EPG modal
│ ├── search.css # Search bar + highlight
│ ├── program.css # Program info modal + midnight marker
│ ├── favorites.css# Favorite button + separator
@@ -334,7 +443,8 @@ gridtv/
├── m3u.js # M3U parser
├── player.js # HLS PiP player
├── themes.js # Theme switcher
├── search.js # Search + filter (debounced)
├── search.js # Search + category filter
├── reminders.js # Program reminders (localStorage + browser notifications)
├── program.js # Program info modal + IMDb link
├── favorites.js # Favorites (localStorage)
├── updater.js # GitHub update checker
+212 -11
View File
@@ -1,10 +1,6 @@
<?php
require_once __DIR__ . '/src/lib/admin.php';
$config = gridtv_load_config();
gridtv_require_admin($config, 'Health');
[$locale, $L] = gridtv_load_locale($config);
function health_status_class(bool $ok): string {
return $ok ? 'ok' : 'ko';
}
@@ -34,24 +30,56 @@ function health_parse_xmltv(string $xml): array {
$subtitles = $xp->query('/tv/programme/sub-title');
$ratings = $xp->query('/tv/programme/rating');
$dates = $xp->query('/tv/programme/date');
$channel_icons = $xp->query('/tv/channel/icon');
$first = null;
$last = null;
$with_desc = 0;
$with_subtitle = 0;
$with_category = 0;
$with_rating = 0;
$with_date = 0;
$with_stop = 0;
$unique_categories = [];
foreach ($programmes as $programme) {
$start = $programme->getAttribute('start');
$stop = $programme->getAttribute('stop');
if ($start !== '' && ($first === null || strcmp($start, $first) < 0)) $first = $start;
if ($stop !== '' && ($last === null || strcmp($stop, $last) > 0)) $last = $stop;
if ($stop !== '') $with_stop++;
if (trim($xp->evaluate('string(desc)', $programme)) !== '') $with_desc++;
if (trim($xp->evaluate('string(sub-title)', $programme)) !== '') $with_subtitle++;
if (trim($xp->evaluate('string(category)', $programme)) !== '') $with_category++;
if (trim($xp->evaluate('string(rating/value)', $programme)) !== '') $with_rating++;
if (trim($xp->evaluate('string(date)', $programme)) !== '') $with_date++;
foreach ($xp->query('category', $programme) as $category) {
$value = trim($category->textContent);
if ($value !== '') $unique_categories[strtolower($value)] = $value;
}
}
$channel_count = $channels->length;
$programme_count = $programmes->length;
return [
'ok' => true,
'channels' => $channels->length,
'programmes' => $programmes->length,
'channels' => $channel_count,
'programmes' => $programme_count,
'categories' => $categories->length,
'subtitles' => $subtitles->length,
'ratings' => $ratings->length,
'dates' => $dates->length,
'channel_icons' => $channel_icons->length,
'channels_without_icon' => max(0, $channel_count - $channel_icons->length),
'programmes_with_desc' => $with_desc,
'programmes_with_subtitle' => $with_subtitle,
'programmes_with_category' => $with_category,
'programmes_with_rating' => $with_rating,
'programmes_with_date' => $with_date,
'programmes_with_stop' => $with_stop,
'unique_categories' => count($unique_categories),
'avg_programmes_per_channel' => $channel_count > 0 ? round($programme_count / $channel_count, 1) : 0,
'first_start' => $first,
'last_stop' => $last,
];
@@ -61,6 +89,10 @@ function health_parse_m3u(string $body): array {
$lines = preg_split('/\r\n|\r|\n/', $body);
$streams = 0;
$extinf = 0;
$http = 0;
$https = 0;
$duplicates = 0;
$seen_urls = [];
foreach ($lines as $line) {
$line = trim($line);
if ($line === '') continue;
@@ -68,9 +100,22 @@ function health_parse_m3u(string $body): array {
$extinf++;
continue;
}
if ($line[0] !== '#') $streams++;
if ($line[0] !== '#') {
$streams++;
if (stripos($line, 'https://') === 0) $https++;
if (stripos($line, 'http://') === 0) $http++;
if (isset($seen_urls[$line])) $duplicates++;
$seen_urls[$line] = true;
}
return ['streams' => $streams, 'extinf' => $extinf];
}
return [
'streams' => $streams,
'extinf' => $extinf,
'http_streams' => $http,
'https_streams' => $https,
'duplicate_urls' => $duplicates,
'size_bytes' => strlen($body),
];
}
function health_format_xmltv_stamp(?string $stamp): string {
@@ -80,6 +125,69 @@ function health_format_xmltv_stamp(?string $stamp): string {
return $dt->format('d/m/Y H:i');
}
function health_probe_payload(): array {
$config_path = gridtv_config_path();
$config_exists = file_exists($config_path);
$config_valid = false;
$config = [];
if ($config_exists) {
$decoded = json_decode(file_get_contents($config_path), true);
if (is_array($decoded)) {
$config = $decoded;
$config_valid = true;
}
}
$sources = $config_valid ? array_values($config['epg_sources'] ?? []) : [];
$first_source = $sources[0] ?? null;
$checks = [
'config_exists' => $config_exists,
'config_valid' => $config_valid,
'php_curl' => extension_loaded('curl'),
'php_xml' => class_exists('DOMDocument'),
'source_configured' => !empty($sources),
];
$details = [
'group_name' => $config['group_name'] ?? 'GridTV',
'source_count' => count($sources),
];
if ($first_source && !empty($first_source['epg_url'])) {
$fetch = gridtv_fetch_url((string) $first_source['epg_url'], 12);
$checks['xmltv_reachable'] = $fetch['ok'];
$details['xmltv_status'] = $fetch['status'];
$details['xmltv_time_total'] = round((float) $fetch['time_total'], 3);
if ($fetch['ok']) {
$stats = health_parse_xmltv($fetch['body']);
$checks['xmltv_valid'] = !empty($stats['ok']);
if (!empty($stats['ok'])) {
$details['xmltv_channels'] = $stats['channels'];
$details['xmltv_programmes'] = $stats['programmes'];
} else {
$details['xmltv_error'] = $stats['error'] ?? 'Invalid XMLTV';
}
} else {
$details['xmltv_error'] = $fetch['error'] ?: ('HTTP ' . $fetch['status']);
}
} else {
$checks['xmltv_reachable'] = false;
$details['xmltv_error'] = 'No XMLTV source configured';
}
$ok = !in_array(false, $checks, true);
return [
'ok' => $ok,
'status' => $ok ? 'ok' : 'degraded',
'timestamp' => gmdate('c'),
'checks' => $checks,
'details' => $details,
];
}
function health_build_payload(array $config, string $locale): array {
$config_path = gridtv_config_path();
$config_exists = file_exists($config_path);
@@ -126,6 +234,9 @@ function health_build_payload(array $config, string $locale): array {
$epg_fetch = $epg_url !== '' ? gridtv_fetch_url($epg_url, 25) : ['ok' => false, 'status' => 0, 'error' => 'Missing URL', 'body' => '', 'content_type' => '', 'final_url' => '', 'time_total' => 0.0];
$epg_stats = $epg_fetch['ok'] ? health_parse_xmltv($epg_fetch['body']) : ['ok' => false, 'error' => $epg_fetch['error'] ?: 'HTTP ' . $epg_fetch['status']];
$epg_fetch_public = $epg_fetch;
$epg_fetch_public['body_bytes'] = strlen((string) ($epg_fetch_public['body'] ?? ''));
unset($epg_fetch_public['body']);
$m3u_fetch = null;
$m3u_stats = null;
@@ -133,6 +244,16 @@ function health_build_payload(array $config, string $locale): array {
$m3u_fetch = gridtv_fetch_url($m3u_url, 20);
$m3u_stats = $m3u_fetch['ok'] ? health_parse_m3u($m3u_fetch['body']) : null;
}
$m3u_fetch_public = $m3u_fetch;
if (is_array($m3u_fetch_public)) {
$m3u_fetch_public['body_bytes'] = strlen((string) ($m3u_fetch_public['body'] ?? ''));
unset($m3u_fetch_public['body']);
}
if (!empty($epg_stats['ok'])) {
$epg_stats['first_start_local'] = health_format_xmltv_stamp($epg_stats['first_start'] ?? null);
$epg_stats['last_stop_local'] = health_format_xmltv_stamp($epg_stats['last_stop'] ?? null);
}
$source_reports[] = [
'name' => $source['name'] ?: 'Source ' . ($index + 1),
@@ -140,9 +261,9 @@ function health_build_payload(array $config, string $locale): array {
'm3u_url' => $m3u_url,
'epg_host' => gridtv_extract_host($epg_url),
'm3u_host' => gridtv_extract_host($m3u_url),
'epg_fetch' => $epg_fetch,
'epg_fetch' => $epg_fetch_public,
'epg_stats' => $epg_stats,
'm3u_fetch' => $m3u_fetch,
'm3u_fetch' => $m3u_fetch_public,
'm3u_stats' => $m3u_stats,
];
}
@@ -164,6 +285,23 @@ function health_build_payload(array $config, string $locale): array {
];
}
if (isset($_GET['format']) && $_GET['format'] === 'probe') {
$payload = health_probe_payload();
http_response_code($payload['ok'] ? 200 : 503);
if (isset($_GET['plain']) && $_GET['plain'] === '1') {
header('Content-Type: text/plain; charset=UTF-8');
echo $payload['ok'] ? 'OK' : 'KO';
} else {
header('Content-Type: application/json; charset=UTF-8');
echo json_encode($payload, JSON_UNESCAPED_SLASHES);
}
exit;
}
$config = gridtv_load_config();
gridtv_require_admin($config, 'Health');
[$locale, $L] = gridtv_load_locale($config);
if (isset($_GET['format']) && $_GET['format'] === 'json') {
header('Content-Type: application/json; charset=UTF-8');
echo json_encode(health_build_payload($config, $locale), JSON_UNESCAPED_SLASHES);
@@ -184,6 +322,12 @@ if (isset($_GET['format']) && $_GET['format'] === 'json') {
.logo{font-family:'Share Tech Mono',monospace;font-size:26px;letter-spacing:.12em;color:var(--accent);text-transform:uppercase}
.subtitle{color:var(--muted);font-size:14px;max-width:700px;line-height:1.5}
.actions{display:flex;gap:10px;flex-wrap:wrap}.btn{display:inline-flex;align-items:center;gap:8px;padding:11px 14px;border:1px solid var(--border);background:var(--surface);color:var(--text);text-decoration:none;font-size:13px;letter-spacing:.08em;text-transform:uppercase}.btn-primary{background:var(--accent);color:#000;border-color:var(--accent)}
.probe-box{margin-bottom:20px;padding:18px 20px;background:rgba(19,22,27,.95);border:1px solid var(--border)}
.probe-box h2{margin:0 0 10px;font-size:18px;letter-spacing:.06em;text-transform:uppercase}
.probe-box p{margin:0 0 12px;color:var(--muted);font-size:13px;line-height:1.5}
.probe-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px}
.probe-item{padding:12px;background:var(--surface2);border:1px solid var(--border)}
.probe-item .eyebrow{margin-bottom:6px}
.grid,.checks,.source-list{display:grid;gap:14px}.grid{grid-template-columns:repeat(auto-fit,minmax(220px,1fr));margin-bottom:22px}.checks{grid-template-columns:repeat(auto-fit,minmax(220px,1fr));margin-bottom:24px}.source-list{gap:18px}
.card,.source,.check{background:rgba(19,22,27,.95);border:1px solid var(--border)}.card,.check,.source{padding:16px}.source{padding:20px}
.eyebrow{font-family:'Share Tech Mono',monospace;font-size:11px;color:var(--accent2);letter-spacing:.12em;text-transform:uppercase;margin-bottom:10px}.value{font-size:28px;font-weight:700}.small{font-size:13px;color:var(--muted)}
@@ -218,6 +362,21 @@ if (isset($_GET['format']) && $_GET['format'] === 'json') {
</div>
</div>
<section class="probe-box">
<h2>Monitoring probe</h2>
<p>Use these public endpoints with Uptime Kuma or any HTTP monitoring tool. They return <code>200</code> when the instance is healthy and <code>503</code> when a critical check fails.</p>
<div class="probe-grid">
<div class="probe-item">
<div class="eyebrow">JSON probe</div>
<div class="mono"><?= htmlspecialchars(strtok($_SERVER['REQUEST_URI'] ?? '/health.php', '?')) ?>?format=probe</div>
</div>
<div class="probe-item">
<div class="eyebrow">Plain text probe</div>
<div class="mono"><?= htmlspecialchars(strtok($_SERVER['REQUEST_URI'] ?? '/health.php', '?')) ?>?format=probe&amp;plain=1</div>
</div>
</div>
</section>
<div class="grid" id="summaryGrid">
<div class="card skeleton"><div class="eyebrow">Group</div><div class="skeleton-line"></div><div class="skeleton-line short"></div></div>
<div class="card skeleton"><div class="eyebrow">Sources</div><div class="skeleton-line"></div><div class="skeleton-line short"></div></div>
@@ -264,18 +423,54 @@ function renderChecks(checks, labels) {
}
function renderSources(sources, labels) {
function percent(part, total) {
if (!total) return '0%';
return Math.round((part / total) * 100) + '%';
}
function qualityScore(stats) {
if (!stats || !stats.programmes) return 0;
const values = [
stats.programmes_with_desc / stats.programmes,
stats.programmes_with_category / stats.programmes,
stats.programmes_with_subtitle / stats.programmes,
stats.programmes_with_rating / stats.programmes
];
return Math.round((values.reduce((sum, value) => sum + value, 0) / values.length) * 100);
}
function qualityLabel(score) {
if (score >= 85) return 'Excellent';
if (score >= 70) return 'Good';
if (score >= 50) return 'Partial';
return 'Poor';
}
document.getElementById('sourceList').innerHTML = sources.map(report => {
const epgHealthy = report.epg_fetch.ok && report.epg_stats.ok;
const m3uHealthy = report.m3u_fetch ? report.m3u_fetch.ok : true;
const score = epgHealthy ? qualityScore(report.epg_stats) : 0;
const epgRows = epgHealthy ? `
<div>Channels</div><div>${report.epg_stats.channels}</div>
<div>Programmes</div><div>${report.epg_stats.programmes}</div>
<div>Channel logos</div><div>${report.epg_stats.channel_icons} (${report.epg_stats.channels_without_icon} missing)</div>
<div>Descriptions</div><div>${report.epg_stats.programmes_with_desc} (${percent(report.epg_stats.programmes_with_desc, report.epg_stats.programmes)})</div>
<div>Categories coverage</div><div>${report.epg_stats.programmes_with_category} (${percent(report.epg_stats.programmes_with_category, report.epg_stats.programmes)})</div>
<div>Subtitle coverage</div><div>${report.epg_stats.programmes_with_subtitle} (${percent(report.epg_stats.programmes_with_subtitle, report.epg_stats.programmes)})</div>
<div>Rating coverage</div><div>${report.epg_stats.programmes_with_rating} (${percent(report.epg_stats.programmes_with_rating, report.epg_stats.programmes)})</div>
<div>Date coverage</div><div>${report.epg_stats.programmes_with_date} (${percent(report.epg_stats.programmes_with_date, report.epg_stats.programmes)})</div>
<div>Stop time coverage</div><div>${report.epg_stats.programmes_with_stop} (${percent(report.epg_stats.programmes_with_stop, report.epg_stats.programmes)})</div>
<div>Unique categories</div><div>${report.epg_stats.unique_categories}</div>
<div>Programmes / channel</div><div>${report.epg_stats.avg_programmes_per_channel}</div>
<div>Sub-titles</div><div>${report.epg_stats.subtitles}</div>
<div>Ratings</div><div>${report.epg_stats.ratings}</div>
<div>Categories</div><div>${report.epg_stats.categories}</div>
<div>Date tags</div><div>${report.epg_stats.dates}</div>
<div>Window</div><div>${report.epg_stats.first_start || '—'} → ${report.epg_stats.last_stop || '—'}</div>
<div>Window (local)</div><div>${report.epg_stats.first_start_local || '—'} → ${report.epg_stats.last_stop_local || '—'}</div>
<div>Content-Type</div><div>${report.epg_fetch.content_type || '—'}</div>
<div>Final URL</div><div class="mono">${report.epg_fetch.final_url || report.epg_url}</div>
<div>Payload size</div><div>${report.epg_fetch.headers['content-length'] || report.epg_fetch.body_bytes || '—'} bytes</div>
` : `<div>Parse</div><div>${report.epg_stats.error || 'Unable to read XMLTV'}</div>`;
const m3uRows = !report.m3u_url ? `<div class="small">No M3U URL configured for this source.</div>` : `
@@ -287,7 +482,12 @@ function renderSources(sources, labels) {
${report.m3u_fetch.ok && report.m3u_stats ? `
<div>Streams</div><div>${report.m3u_stats.streams}</div>
<div>#EXTINF</div><div>${report.m3u_stats.extinf}</div>
<div>HTTPS streams</div><div>${report.m3u_stats.https_streams}</div>
<div>HTTP streams</div><div>${report.m3u_stats.http_streams}</div>
<div>Duplicate URLs</div><div>${report.m3u_stats.duplicate_urls}</div>
<div>Payload size</div><div>${report.m3u_fetch.headers['content-length'] || report.m3u_fetch.body_bytes || report.m3u_stats.size_bytes} bytes</div>
<div>Content-Type</div><div>${report.m3u_fetch.content_type || '—'}</div>
<div>Final URL</div><div class="mono">${report.m3u_fetch.final_url || report.m3u_url}</div>
` : `<div>Parse</div><div>Unable to load playlist.</div>`}
</div>`;
@@ -298,13 +498,14 @@ function renderSources(sources, labels) {
<div class="source-title">${report.name}</div>
<div class="small">EPG host: ${report.epg_host || '—'}${report.m3u_host ? ' · M3U host: ' + report.m3u_host : ''}</div>
</div>
<span class="pill">${epgHealthy ? 'Healthy XMLTV' : 'Check source'}</span>
<span class="pill">${epgHealthy ? `${qualityLabel(score)} XMLTV · ${score}%` : 'Check source'}</span>
</div>
<div class="split">
<div class="panel">
<h3>XMLTV</h3>
<div class="kv">
<div>Status</div><div>${statusHtml(epgHealthy, labels)}</div>
<div>Quality score</div><div>${epgHealthy ? `${score}% · ${qualityLabel(score)}` : '—'}</div>
<div>URL</div><div class="mono">${report.epg_url}</div>
<div>HTTP</div><div>${report.epg_fetch.status}${report.epg_fetch.error ? ' · ' + report.epg_fetch.error : ''}</div>
<div>Response time</div><div>${Number(report.epg_fetch.time_total).toFixed(2)} s</div>
+20
View File
@@ -32,10 +32,28 @@
"search_placeholder": "Search channel or program...",
"copy_epg": "EPG",
"copy_m3u": "M3U",
"category_filter_title": "Filter by category",
"category_all": "All categories",
"copied": "✓ copied",
"program_info": "Program info",
"watch_now": "Watch now",
"imdb_search": "Search on IMDb",
"reminder_button": "Reminder",
"reminder_title": "Notify me before broadcast",
"reminder_save": "Enable reminder",
"reminder_remove": "Remove reminder",
"reminder_when": "A browser notification will be sent {minutes} minutes before broadcast.",
"reminder_when_soon": "This program starts soon. The reminder will fire almost immediately.",
"reminder_status_idle": "No reminder active.",
"reminder_status_set": "Reminder active for {time}.",
"reminder_status_started": "This program is already airing.",
"reminder_saved": "Reminder enabled.",
"reminder_removed": "Reminder removed.",
"reminder_error_unsupported": "This browser does not support notifications.",
"reminder_error_denied": "Notifications are blocked in this browser.",
"reminder_error_generic": "Unable to enable reminder.",
"reminder_notification_title": "GridTV reminder",
"reminder_notification_body": "\"{title}\" starts at {time} on {channel}.",
"close": "Close",
"duration_min": "min",
"personal_epg_title": "Personal EPG",
@@ -43,6 +61,8 @@
"personal_epg_label": "EPG URL (XMLTV) *",
"personal_epg_m3u_label": "M3U URL",
"personal_epg_optional": "optional",
"personal_epg_m3u_enabled": "This instance allows a personal M3U playlist.",
"personal_epg_m3u_disabled": "The administrator of this instance has disabled personal M3U playlists.",
"cancel": "Cancel",
"apply": "Apply",
"setup_already_done": "Configuration already done. Use your admin key to access setup again.",
+20
View File
@@ -32,10 +32,28 @@
"search_placeholder": "Buscar canal o programa...",
"copy_epg": "EPG",
"copy_m3u": "M3U",
"category_filter_title": "Filtrar por categoría",
"category_all": "Todas las categorías",
"copied": "✓ copiado",
"program_info": "Info del programa",
"watch_now": "Ver ahora",
"imdb_search": "Buscar en IMDb",
"reminder_button": "Recordatorio",
"reminder_title": "Avísame antes de la emisión",
"reminder_save": "Activar recordatorio",
"reminder_remove": "Eliminar recordatorio",
"reminder_when": "Se enviará una notificación del navegador {minutes} minutos antes de la emisión.",
"reminder_when_soon": "Este programa empieza pronto. El recordatorio saldrá casi de inmediato.",
"reminder_status_idle": "No hay recordatorio activo.",
"reminder_status_set": "Recordatorio activo para las {time}.",
"reminder_status_started": "Este programa ya está en emisión.",
"reminder_saved": "Recordatorio activado.",
"reminder_removed": "Recordatorio eliminado.",
"reminder_error_unsupported": "Este navegador no admite notificaciones.",
"reminder_error_denied": "Las notificaciones están bloqueadas en este navegador.",
"reminder_error_generic": "No se puede activar el recordatorio.",
"reminder_notification_title": "Recordatorio GridTV",
"reminder_notification_body": "\"{title}\" empieza a las {time} en {channel}.",
"close": "Cerrar",
"duration_min": "min",
"personal_epg_title": "EPG Personal",
@@ -43,6 +61,8 @@
"personal_epg_label": "URL EPG (XMLTV) *",
"personal_epg_m3u_label": "URL M3U",
"personal_epg_optional": "opcional",
"personal_epg_m3u_enabled": "Esta instancia permite una lista M3U personal.",
"personal_epg_m3u_disabled": "El administrador de esta instancia ha desactivado las listas M3U personales.",
"cancel": "Cancelar",
"apply": "Aplicar",
"setup_already_done": "Configuración ya realizada. Usa tu clave de administrador para volver al setup.",
+20
View File
@@ -32,10 +32,28 @@
"search_placeholder": "Rechercher une chaîne ou un programme...",
"copy_epg": "EPG",
"copy_m3u": "M3U",
"category_filter_title": "Filtrer par catégorie",
"category_all": "Toutes catégories",
"copied": "✓ copié",
"program_info": "Infos programme",
"watch_now": "Regarder",
"imdb_search": "Rechercher sur IMDb",
"reminder_button": "Rappel",
"reminder_title": "Préviens-moi avant la diffusion",
"reminder_save": "Activer le rappel",
"reminder_remove": "Supprimer le rappel",
"reminder_when": "Une notification navigateur sera envoyée {minutes} minutes avant la diffusion.",
"reminder_when_soon": "Ce programme commence bientôt. Le rappel partira presque immédiatement.",
"reminder_status_idle": "Aucun rappel actif.",
"reminder_status_set": "Rappel actif pour {time}.",
"reminder_status_started": "Ce programme est déjà en cours.",
"reminder_saved": "Rappel activé.",
"reminder_removed": "Rappel supprimé.",
"reminder_error_unsupported": "Ce navigateur ne gère pas les notifications.",
"reminder_error_denied": "Les notifications sont bloquées dans ce navigateur.",
"reminder_error_generic": "Impossible d'activer le rappel.",
"reminder_notification_title": "Rappel GridTV",
"reminder_notification_body": "“{title}” commence à {time} sur {channel}.",
"close": "Fermer",
"duration_min": "min",
"personal_epg_title": "EPG Personnel",
@@ -43,6 +61,8 @@
"personal_epg_label": "URL EPG (XMLTV) *",
"personal_epg_m3u_label": "URL M3U",
"personal_epg_optional": "optionnel",
"personal_epg_m3u_enabled": "Cette instance autorise l'ajout d'une playlist M3U personnelle.",
"personal_epg_m3u_disabled": "L'administrateur de cette instance a désactivé l'ajout d'une playlist M3U personnelle.",
"cancel": "Annuler",
"apply": "Appliquer",
"setup_already_done": "Configuration déjà effectuée. Utilisez votre clé admin pour accéder à nouveau au setup.",
+81 -3
View File
@@ -1,5 +1,6 @@
<?php
$config_path = __DIR__ . '/config.json';
$demo_mode = file_exists(__DIR__ . '/.demo');
// Automatically migrate the legacy config format.
if (file_exists($config_path)) {
@@ -41,7 +42,9 @@ $error = '';
$new_key = '';
if (!$is_first_setup) {
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['admin_key_input'])) {
if ($demo_mode) {
$mode = 'setup';
} elseif ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['admin_key_input'])) {
if (trim($_POST['admin_key_input']) === $admin_key) {
$mode = 'setup';
} else {
@@ -61,8 +64,12 @@ if (!$is_first_setup) {
}
if ($mode === 'setup' && $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['group_name'])) {
if ($demo_mode) {
$error = 'Demo mode is enabled. Configuration is read-only on this instance.';
} else {
$group_name = trim($_POST['group_name'] ?? '');
$allow_personal_epg = isset($_POST['allow_personal_epg']);
$allow_personal_m3u = $allow_personal_epg && isset($_POST['allow_personal_m3u']);
$names = $_POST['source_name'] ?? [];
$epg_urls = $_POST['source_epg_url'] ?? [];
@@ -97,6 +104,7 @@ if ($mode === 'setup' && $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['
'group_name' => $group_name,
'epg_sources' => $epg_sources,
'allow_personal_epg' => $allow_personal_epg,
'allow_personal_m3u' => $allow_personal_m3u,
'admin_key' => $new_key,
];
$written = file_put_contents($config_path, json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
@@ -107,11 +115,15 @@ if ($mode === 'setup' && $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['
}
}
}
}
}
$prefill_group = $_POST['group_name'] ?? $existing['group_name'] ?? '';
$prefill_sources = $existing['epg_sources'] ?? [];
$prefill_epg = !empty($existing['allow_personal_epg']);
$prefill_personal_m3u = array_key_exists('allow_personal_m3u', $existing)
? !empty($existing['allow_personal_m3u'])
: $prefill_epg;
$submitted_key = trim($_POST['admin_key_input'] ?? $_POST['admin_key_hidden'] ?? '');
?>
<!DOCTYPE html>
@@ -182,12 +194,19 @@ $submitted_key = trim($_POST['admin_key_input'] ?? $_POST['admin_key_hidden']
.lock-form input{flex:1;}
.setup-note{margin-top:28px;padding-top:20px;border-top:1px solid var(--border);font-size:11px;color:var(--text-dim);line-height:1.6;}
.setup-note code{font-family:'Share Tech Mono',monospace;background:var(--surface2);padding:1px 5px;border:1px solid var(--border);color:var(--accent2);font-size:11px;}
.demo-banner{padding:12px 14px;font-size:13px;margin-bottom:24px;line-height:1.5;border-left:3px solid var(--accent2);background:rgba(74,158,255,.08);color:var(--text-bright);}
.readonly-wrap{opacity:.82;}
.readonly-hint{margin-top:12px;font-size:12px;color:var(--text-dim);line-height:1.5;}
</style>
</head>
<body>
<div class="setup-card">
<div class="setup-logo">Grid<span>/</span>TV</div>
<?php if ($demo_mode): ?>
<div class="demo-banner">Demo mode is enabled on this instance. Visitors can view the current configuration, but saving changes is disabled.</div>
<?php endif; ?>
<?php if ($mode === 'lock'): ?>
<div class="setup-subtitle">Enter your admin key to access the configuration.</div>
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
@@ -197,7 +216,7 @@ $submitted_key = trim($_POST['admin_key_input'] ?? $_POST['admin_key_hidden']
<div class="lock-sub">GridTV is already configured.<br>Enter your admin key to edit the settings.</div>
<form method="POST" action="setup.php">
<div class="lock-form">
<input type="password" name="admin_key_input" placeholder="Your admin key" autofocus autocomplete="off">
<input type="password" id="setupAdminKeyInput" name="admin_key_input" placeholder="Your admin key" autofocus autocomplete="off">
<button type="submit" class="btn-submit" style="width:auto;padding:10px 20px;">Unlock</button>
</div>
</form>
@@ -219,7 +238,7 @@ $submitted_key = trim($_POST['admin_key_input'] ?? $_POST['admin_key_hidden']
<?php else: ?>
<div class="setup-subtitle">
<?= $is_first_setup ? 'Welcome to GridTV. Fill in the fields below to get started.' : 'Edit your GridTV configuration.' ?>
<?= $is_first_setup ? 'Welcome to GridTV. Fill in the fields below to get started.' : ($demo_mode ? 'View the current GridTV configuration.' : 'Edit your GridTV configuration.') ?>
</div>
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
@@ -227,6 +246,7 @@ $submitted_key = trim($_POST['admin_key_input'] ?? $_POST['admin_key_hidden']
<?php if (!$is_first_setup): ?>
<input type="hidden" name="admin_key_hidden" value="<?= htmlspecialchars($submitted_key ?: $admin_key) ?>">
<?php endif; ?>
<?php if ($demo_mode): ?><div class="readonly-wrap"><fieldset disabled style="border:none;padding:0;margin:0"><?php endif; ?>
<div class="form-group">
<label>TV Group Name <span class="required">*</span></label>
@@ -274,7 +294,21 @@ $submitted_key = trim($_POST['admin_key_input'] ?? $_POST['admin_key_hidden']
</label>
</div>
<div class="toggle-row">
<div class="toggle-label">
Allow personal M3U
<small>Lets visitors add their own stream playlist in the Personal EPG modal.</small>
</div>
<label class="toggle">
<input type="checkbox" name="allow_personal_m3u" id="allowPersonalM3u"<?= $prefill_personal_m3u ? ' checked' : '' ?><?= !$prefill_epg ? ' disabled' : '' ?>>
<span class="toggle-slider"></span>
</label>
</div>
<?php if (!$demo_mode): ?>
<button type="submit" class="btn-submit">Save configuration</button>
<?php endif; ?>
<?php if ($demo_mode): ?></fieldset></div><div class="readonly-hint">Saving is disabled in demo mode. Remove the <code>.demo</code> file on the server to restore normal editing.</div><?php endif; ?>
</form>
<?php endif; ?>
@@ -306,6 +340,50 @@ function addSource() {
function removeSource(btn) { btn.closest('.source-block').remove(); renumberSources(); updateRemoveButtons(); }
function renumberSources() { document.querySelectorAll('.source-block').forEach((b,i) => b.querySelector('.source-num').textContent = `SOURCE ${i+1}`); }
function updateRemoveButtons() { const b = document.querySelectorAll('.source-block'); b.forEach(bl => bl.querySelector('.source-remove').style.display = b.length > 1 ? '' : 'none'); }
const allowPersonalEpgToggle = document.getElementById('allowPersonalEpg');
const allowPersonalM3uToggle = document.getElementById('allowPersonalM3u');
function syncPersonalM3uToggle() {
if (!allowPersonalEpgToggle || !allowPersonalM3uToggle) return;
allowPersonalM3uToggle.disabled = !allowPersonalEpgToggle.checked;
if (!allowPersonalEpgToggle.checked) allowPersonalM3uToggle.checked = false;
}
allowPersonalEpgToggle?.addEventListener('change', syncPersonalM3uToggle);
syncPersonalM3uToggle();
const GRIDTV_ADMIN_KEY_STORAGE = 'gridtv-admin-key';
const setupAdminKeyInput = document.getElementById('setupAdminKeyInput');
const setupAdminHiddenInput = document.querySelector('input[name="admin_key_hidden"]');
try {
const savedAdminKey = localStorage.getItem(GRIDTV_ADMIN_KEY_STORAGE) || '';
if (setupAdminKeyInput && savedAdminKey) setupAdminKeyInput.value = savedAdminKey;
if (setupAdminHiddenInput && savedAdminKey) setupAdminHiddenInput.value = savedAdminKey;
} catch (_) {}
if (setupAdminKeyInput?.form) {
setupAdminKeyInput.form.addEventListener('submit', () => {
try {
localStorage.setItem(GRIDTV_ADMIN_KEY_STORAGE, setupAdminKeyInput.value.trim());
} catch (_) {}
});
}
if (setupAdminHiddenInput?.form) {
const persistHiddenAdminKey = () => {
try {
const currentKey = setupAdminHiddenInput.value.trim();
if (currentKey) localStorage.setItem(GRIDTV_ADMIN_KEY_STORAGE, currentKey);
} catch (_) {}
};
persistHiddenAdminKey();
setupAdminHiddenInput.form.addEventListener('submit', persistHiddenAdminKey);
}
<?php if ($mode === 'success' && !empty($new_key)): ?>
try {
localStorage.setItem(GRIDTV_ADMIN_KEY_STORAGE, <?= json_encode($new_key) ?>);
} catch (_) {}
<?php endif; ?>
</script>
</body>
</html>
+3
View File
@@ -29,6 +29,9 @@ if (isset($config['epg_url']) && !isset($config['epg_sources'])) {
$group_name = htmlspecialchars($config['group_name'] ?? 'GridTV');
$epg_sources = $config['epg_sources'] ?? [];
$allow_personal_epg = !empty($config['allow_personal_epg']);
$allow_personal_m3u = array_key_exists('allow_personal_m3u', $config)
? !empty($config['allow_personal_m3u'])
: $allow_personal_epg;
// Default to the first configured source to keep the rest of the app compatible.
$first_source = $epg_sources[0] ?? [];
+57
View File
@@ -25,6 +25,13 @@
}
.modal-field input:focus { border-color: var(--accent); }
.modal-field input::placeholder { color: var(--text-dim); opacity: 0.4; }
.modal-field-note {
margin-top: 6px; font-size: 11px; line-height: 1.45; opacity: 0.82;
color: var(--text-dim);
}
.modal-field-note.is-disabled {
color: #ffb0a5; opacity: 1;
}
.modal-actions { display: flex; gap: 10px; margin-top: 20px; justify-content: flex-end; }
.modal-btn-cancel {
background: none; border: 1px solid var(--border-bright); color: var(--text-dim);
@@ -38,3 +45,53 @@
text-transform: uppercase; padding: 8px 20px; cursor: pointer; transition: opacity 0.15s;
}
.modal-btn-apply:hover { opacity: 0.85; }
.pm-reminder-panel {
margin-top: 14px;
border: 1px solid var(--border);
background: var(--surface2);
padding: 12px;
}
.pm-reminder-submenu {
display: none;
margin-top: 10px;
border-top: 1px solid var(--border);
padding-top: 10px;
}
.pm-reminder-submenu.visible { display: block; }
.pm-reminder-title {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 8px;
}
.pm-reminder-text,
.pm-reminder-status {
font-size: 12px;
line-height: 1.5;
color: var(--text-dim);
}
.pm-reminder-status { margin-top: 8px; }
.pm-reminder-status.active { color: var(--accent2); }
.pm-reminder-status.muted { opacity: 0.75; }
.pm-reminder-actions {
display: flex;
gap: 10px;
margin-top: 12px;
flex-wrap: wrap;
}
.pm-reminder-save {
background: var(--accent);
color: #000;
}
.pm-reminder-remove {
border: 1px solid var(--border-bright);
background: transparent;
color: var(--text);
}
.pm-reminder-save:disabled {
opacity: 0.45;
cursor: not-allowed;
}
+20
View File
@@ -149,6 +149,25 @@
.theme-select:hover { border-color: var(--accent2); color: var(--accent2); }
.theme-select option { background: #111; color: #eee; font-family: sans-serif; }
.category-filter {
background: transparent;
border: 1px solid var(--border-bright);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
padding: 4px 8px;
cursor: pointer;
text-transform: uppercase;
transition: all 0.15s;
flex-shrink: 0;
max-width: 180px;
-webkit-appearance: none;
appearance: none;
}
.category-filter:hover { border-color: var(--accent2); color: var(--accent2); }
.category-filter option { background: #111; color: #eee; font-family: sans-serif; }
body { overflow: hidden; }
.topbar {
@@ -159,6 +178,7 @@
}
.logo { font-size: 11px; }
.clock { font-size: 14px; }
.category-filter { max-width: 100%; }
.grid-wrapper { display: none; }
.mobile-view { display: block; }
+3
View File
@@ -1,6 +1,7 @@
const EPG_SOURCES = <?= json_encode(array_values($epg_sources)) ?>;
const L = <?= json_encode($L) ?>;
const ALLOW_PERSONAL_EPG = <?= $allow_personal_epg ? 'true' : 'false' ?>;
const ALLOW_PERSONAL_M3U = <?= $allow_personal_m3u ? 'true' : 'false' ?>;
// Index of the currently selected source.
let activeSourceIndex = 0;
@@ -10,6 +11,8 @@ const DEFAULT_M3U_URL = EPG_SOURCES[0]?.m3u_url ?? '';
const PX_PER_MIN = 5;
const GRID_HOURS = 72;
const CORS_PROXY = '/proxy.php?url=';
const REMINDER_STORAGE_KEY = 'gridtv-reminders';
const REMINDER_OFFSET_MS = 15 * 60 * 1000;
// Anchor the grid 2 hours before "now", rounded down to the previous quarter-hour.
const _d = new Date();
+2 -1
View File
@@ -6,10 +6,11 @@ window.addEventListener('resize', () => {
});
window.addEventListener('load', () => {
refreshReminderSchedules();
const savedSource = localStorage.getItem('gridtv-active-source');
if (savedSource === 'personal') {
const epg = localStorage.getItem('gridtv-personal-epg');
const m3u = localStorage.getItem('gridtv-personal-m3u') ?? '';
const m3u = ALLOW_PERSONAL_M3U ? (localStorage.getItem('gridtv-personal-m3u') ?? '') : '';
if (epg) {
currentEpgUrl = epg;
currentM3uUrl = m3u;
+4 -1
View File
@@ -6,7 +6,10 @@ function slugify(str) {
}
async function loadM3U(url) {
if (!url) return;
if (!url) {
m3uStreams = {};
return;
}
try {
let text;
try {
+2
View File
@@ -34,11 +34,13 @@ function openProgramModal(ch, p) {
document.getElementById('pm-imdb').href =
`https://www.imdb.com/find/?q=${encodeURIComponent(p.title)}&s=tt`;
bindReminderPanel(ch, p);
document.getElementById('programModal').classList.add('visible');
}
function closeProgramModal() {
document.getElementById('programModal').classList.remove('visible');
document.getElementById('pm-reminder-submenu')?.classList.remove('visible');
}
// Close when the overlay itself is clicked.
+202
View File
@@ -0,0 +1,202 @@
// ── PROGRAM REMINDERS ────────────────────────────────────────────────────────
let currentReminderProgram = null;
let reminderTimers = {};
function reminderProgramId(ch, p) {
return [currentEpgUrl || 'default', ch.id || ch.name, p.start?.getTime() || 0, p.title || ''].join('::');
}
function reminderPayload(ch, p) {
return {
id: reminderProgramId(ch, p),
source: currentEpgUrl || '',
channelId: ch.id || '',
channelName: ch.name || '',
title: p.title || '',
subtitle: p.subtitle || '',
start: p.start?.getTime() || 0,
stop: p.stop?.getTime() || 0,
notifyAt: Math.max(Date.now() + 5000, (p.start?.getTime() || 0) - REMINDER_OFFSET_MS)
};
}
function getStoredReminders() {
try {
const parsed = JSON.parse(localStorage.getItem(REMINDER_STORAGE_KEY) || '[]');
return Array.isArray(parsed) ? parsed : [];
} catch (_) {
return [];
}
}
function saveStoredReminders(reminders) {
localStorage.setItem(REMINDER_STORAGE_KEY, JSON.stringify(reminders));
}
function findStoredReminder(id) {
return getStoredReminders().find(item => item.id === id) || null;
}
function upsertReminder(reminder) {
const reminders = getStoredReminders().filter(item => item.id !== reminder.id);
reminders.push(reminder);
saveStoredReminders(reminders);
}
function removeReminder(id) {
if (reminderTimers[id]) {
clearTimeout(reminderTimers[id]);
delete reminderTimers[id];
}
saveStoredReminders(getStoredReminders().filter(item => item.id !== id));
updateReminderPanel();
}
function pruneExpiredReminders() {
const now = Date.now();
const reminders = getStoredReminders().filter(item => (item.stop || item.start || 0) > now);
saveStoredReminders(reminders);
return reminders;
}
async function ensureReminderPermission() {
if (!('Notification' in window)) {
throw new Error(L.reminder_error_unsupported || 'Notifications are not supported on this browser.');
}
if (Notification.permission === 'granted') return true;
if (Notification.permission === 'denied') {
throw new Error(L.reminder_error_denied || 'Notifications were blocked in this browser.');
}
const result = await Notification.requestPermission();
if (result !== 'granted') {
throw new Error(L.reminder_error_denied || 'Notifications were blocked in this browser.');
}
return true;
}
async function showReminderNotification(reminder) {
const title = L.reminder_notification_title || 'Program reminder';
const body = (L.reminder_notification_body || '“{title}” starts at {time} on {channel}.')
.replace('{title}', reminder.title || '')
.replace('{time}', fmtTime(new Date(reminder.start)))
.replace('{channel}', reminder.channelName || '');
try {
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.ready;
await registration.showNotification(title, {
body,
tag: reminder.id,
icon: '/assets/icon-192.png',
badge: '/assets/icon-192.png',
data: {
url: '/index.php',
reminderId: reminder.id
}
});
return;
}
} catch (_) {}
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(title, { body, icon: '/assets/icon-192.png', tag: reminder.id });
}
}
function scheduleReminder(reminder) {
if (!reminder?.id) return;
if (reminderTimers[reminder.id]) clearTimeout(reminderTimers[reminder.id]);
const delay = reminder.notifyAt - Date.now();
if (delay <= 0) {
showReminderNotification(reminder).finally(() => removeReminder(reminder.id));
return;
}
reminderTimers[reminder.id] = setTimeout(() => {
showReminderNotification(reminder).finally(() => removeReminder(reminder.id));
}, delay);
}
function refreshReminderSchedules() {
Object.keys(reminderTimers).forEach(id => {
clearTimeout(reminderTimers[id]);
delete reminderTimers[id];
});
pruneExpiredReminders().forEach(scheduleReminder);
updateReminderPanel();
}
function reminderTimingText(p) {
const startMs = p && typeof p.start === 'number'
? p.start
: (p?.start?.getTime ? p.start.getTime() : 0);
const notifyAt = Math.max(Date.now() + 5000, startMs - REMINDER_OFFSET_MS);
const deltaMinutes = Math.max(0, Math.round((startMs - notifyAt) / 60000));
if (deltaMinutes <= 1) return L.reminder_when_soon || 'This program starts soon. The reminder will fire almost immediately.';
return (L.reminder_when || 'A browser notification will be sent {minutes} minutes before broadcast.')
.replace('{minutes}', String(deltaMinutes));
}
function updateReminderPanel(message) {
const panel = document.getElementById('pm-reminder-panel');
const submenu = document.getElementById('pm-reminder-submenu');
const text = document.getElementById('pm-reminder-text');
const status = document.getElementById('pm-reminder-status');
const removeBtn = document.getElementById('pm-reminder-remove');
const saveBtn = document.getElementById('pm-reminder-save');
if (!panel || !submenu || !text || !status || !removeBtn || !saveBtn) return;
if (!currentReminderProgram) {
panel.style.display = 'none';
submenu.classList.remove('visible');
return;
}
panel.style.display = 'block';
const reminder = findStoredReminder(currentReminderProgram.id);
const hasStarted = currentReminderProgram.start <= Date.now();
text.textContent = reminderTimingText(currentReminderProgram);
status.textContent = message || (reminder
? (L.reminder_status_set || 'Reminder active for {time}.').replace('{time}', fmtTime(new Date(reminder.notifyAt)))
: (hasStarted ? (L.reminder_status_started || 'This program is already airing.') : (L.reminder_status_idle || 'No reminder active.')));
status.className = 'pm-reminder-status' + (reminder ? ' active' : '') + (hasStarted ? ' muted' : '');
removeBtn.style.display = reminder ? 'inline-flex' : 'none';
saveBtn.disabled = hasStarted;
}
async function saveCurrentReminder() {
if (!currentReminderProgram) return;
try {
await ensureReminderPermission();
upsertReminder(currentReminderProgram);
scheduleReminder(currentReminderProgram);
updateReminderPanel(L.reminder_saved || 'Reminder enabled.');
} catch (error) {
updateReminderPanel(error.message || (L.reminder_error_generic || 'Unable to enable reminder.'));
}
}
function toggleReminderSubmenu() {
const submenu = document.getElementById('pm-reminder-submenu');
if (!submenu || !currentReminderProgram) return;
submenu.classList.toggle('visible');
updateReminderPanel();
}
function bindReminderPanel(ch, p) {
currentReminderProgram = reminderPayload(ch, p);
const toggleBtn = document.getElementById('pm-reminder-toggle');
const saveBtn = document.getElementById('pm-reminder-save');
const removeBtn = document.getElementById('pm-reminder-remove');
const submenu = document.getElementById('pm-reminder-submenu');
if (!toggleBtn || !saveBtn || !removeBtn || !submenu) return;
submenu.classList.remove('visible');
toggleBtn.onclick = () => toggleReminderSubmenu();
saveBtn.onclick = () => saveCurrentReminder();
removeBtn.onclick = () => {
if (currentReminderProgram) removeReminder(currentReminderProgram.id);
updateReminderPanel(L.reminder_removed || 'Reminder removed.');
};
updateReminderPanel();
}
+94 -32
View File
@@ -2,6 +2,7 @@
let searchActive = false;
let searchQuery = '';
let searchTimer = null;
let currentCategoryFilter = '';
function toggleSearch() {
const bar = document.getElementById('searchBar');
@@ -18,48 +19,94 @@ function toggleSearch() {
function clearSearch() {
searchQuery = '';
document.getElementById('searchInput').value = '';
applySearch('');
applyFilters();
}
function onSearchInput(val) {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
searchQuery = val.trim().toLowerCase();
applySearch(searchQuery);
applyFilters();
}, 350);
}
function applySearch(q) {
if (!q) {
// Restore the full channel list when the query is cleared.
renderAll();
return;
}
const today = new Date();
today.setHours(0,0,0,0);
const tomorrow = new Date(today.getTime() + 24*3600000);
// Keep channels whose name matches or that have a matching program today.
const filtered = channels.filter(ch => {
// Match against the channel name.
if (ch.name.toLowerCase().includes(q)) return true;
// Match against today's programs (title + description).
return (programs[ch.id]||[]).some(p => {
if (p.stop < today || p.start > tomorrow) return false;
function programMatchesQuery(p, q) {
if (!q) return true;
if (p.title.toLowerCase().includes(q)) return true;
if (p.desc && p.desc.toLowerCase().includes(q)) return true;
if ((p.categories || []).some(cat => cat.toLowerCase().includes(q))) return true;
return false;
});
});
renderFiltered(filtered, q);
}
function renderFiltered(filteredChannels, q) {
function programMatchesCategory(p, category) {
if (!category) return true;
const key = normalizeCategory(category);
return (p.categories || []).some(cat => normalizeCategory(cat) === key);
}
function getFilteredChannels(q, category) {
const qNorm = (q || '').trim().toLowerCase();
const nowStart = new Date(GRID_START);
const nowEnd = new Date(GRID_END);
return channels.filter(ch => {
const channelMatchesQuery = qNorm ? ch.name.toLowerCase().includes(qNorm) : false;
const items = programs[ch.id] || [];
const matchingProgram = items.some(p => {
if (p.stop < nowStart || p.start > nowEnd) return false;
if (!programMatchesCategory(p, category)) return false;
if (!qNorm) return true;
return programMatchesQuery(p, qNorm);
});
if (category) return matchingProgram;
return channelMatchesQuery || matchingProgram || (!qNorm && !category);
});
}
function applyFilters() {
const q = searchQuery;
const category = currentCategoryFilter;
if (!q && !category) {
renderUnfiltered();
return;
}
renderFiltered(getFilteredChannels(q, category), q, category);
}
function setCategoryFilter(value) {
currentCategoryFilter = value || '';
const select = document.getElementById('categoryFilter');
if (select && select.value !== currentCategoryFilter) select.value = currentCategoryFilter;
applyFilters();
}
function refreshCategoryFilterOptions() {
const select = document.getElementById('categoryFilter');
if (!select) return;
const previous = currentCategoryFilter;
select.innerHTML = '';
const baseOption = document.createElement('option');
baseOption.value = '';
baseOption.textContent = L.category_all || 'All categories';
select.appendChild(baseOption);
availableCategories.forEach(category => {
const option = document.createElement('option');
option.value = category;
option.textContent = category;
select.appendChild(option);
});
if (previous && availableCategories.some(category => normalizeCategory(category) === normalizeCategory(previous))) {
select.value = availableCategories.find(category => normalizeCategory(category) === normalizeCategory(previous)) || '';
currentCategoryFilter = select.value;
} else {
currentCategoryFilter = '';
select.value = '';
}
}
function renderFiltered(filteredChannels, q, category) {
if (isMobile()) {
renderMobileFiltered(filteredChannels, q);
renderMobileFiltered(filteredChannels, q, category);
return;
}
@@ -102,7 +149,12 @@ function renderFiltered(filteredChannels, q) {
row.className = 'program-row';
row.style.cssText = `top:${i*ROW_H}px;position:absolute;left:0;right:0;`;
(programs[ch.id]||[]).filter(p => p.stop > GRID_START && p.start < GRID_END).forEach(p => {
const channelMatchesQuery = q ? ch.name.toLowerCase().includes(q) : false;
(programs[ch.id]||[]).filter(p => {
if (!(p.stop > GRID_START && p.start < GRID_END)) return false;
if (!programMatchesCategory(p, category)) return false;
return true;
}).forEach(p => {
const x = Math.max(0, msToX(p.start.getTime()));
const w = Math.min(TOTAL_WIDTH_PX, msToX(p.stop.getTime())) - x;
if (w < 2) return;
@@ -112,10 +164,13 @@ function renderFiltered(filteredChannels, q) {
// Highlight programs that match the current search query.
const matchesProg =
!!q && (
p.title.toLowerCase().includes(q) ||
(p.desc && p.desc.toLowerCase().includes(q)) ||
(p.categories || []).some(cat => cat.toLowerCase().includes(q));
(p.categories || []).some(cat => cat.toLowerCase().includes(q))
);
if (matchesProg) block.classList.add('search-match');
if (category && !matchesProg && !channelMatchesQuery) block.classList.add('search-match');
if (p.stop < now) block.classList.add('is-past');
if (p.start <= now && p.stop > now) {
@@ -144,7 +199,7 @@ function renderFiltered(filteredChannels, q) {
positionNowLine();
}
function renderMobileFiltered(filteredChannels, q) {
function renderMobileFiltered(filteredChannels, q, category) {
const container = document.getElementById('mobileView');
container.innerHTML = '';
const now = new Date();
@@ -152,7 +207,12 @@ function renderMobileFiltered(filteredChannels, q) {
const winEnd = new Date(now.getTime() + 4*3600000);
filteredChannels.forEach(ch => {
const progs = (programs[ch.id]||[]).filter(p => p.stop > winStart && p.start < winEnd);
const channelMatchesQuery = q ? ch.name.toLowerCase().includes(q) : false;
const progs = (programs[ch.id]||[]).filter(p => {
if (!(p.stop > winStart && p.start < winEnd)) return false;
if (!programMatchesCategory(p, category)) return false;
return true;
});
if (!progs.length) return;
const section = document.createElement('div'); section.className = 'mobile-channel';
@@ -173,11 +233,13 @@ function renderMobileFiltered(filteredChannels, q) {
const isPast = p.stop < now;
const dur = Math.round((p.stop - p.start) / 60000);
const matchesProg =
!!q && (
p.title.toLowerCase().includes(q) ||
(p.desc && p.desc.toLowerCase().includes(q)) ||
(p.categories || []).some(cat => cat.toLowerCase().includes(q));
(p.categories || []).some(cat => cat.toLowerCase().includes(q))
);
const item = document.createElement('div');
item.className = 'mobile-program' + (isLive ? ' is-live' : '') + (isPast ? ' is-past' : '') + (matchesProg ? ' search-match' : '');
item.className = 'mobile-program' + (isLive ? ' is-live' : '') + (isPast ? ' is-past' : '') + (matchesProg || (category && !channelMatchesQuery) ? ' search-match' : '');
if (isLive) {
const bar = document.createElement('div'); bar.className = 'mobile-program-progress';
+18 -2
View File
@@ -28,8 +28,20 @@ function openPersonalEpgModal() {
const saved_epg = localStorage.getItem('gridtv-personal-epg') ?? '';
const saved_m3u = localStorage.getItem('gridtv-personal-m3u') ?? '';
const modal = document.getElementById('personalEpgModal');
const m3uInput = document.getElementById('personalM3uInput');
const m3uHint = document.getElementById('personalM3uHint');
document.getElementById('personalEpgInput').value = saved_epg;
document.getElementById('personalM3uInput').value = saved_m3u;
if (m3uInput) {
m3uInput.value = ALLOW_PERSONAL_M3U ? saved_m3u : '';
m3uInput.disabled = !ALLOW_PERSONAL_M3U;
}
if (m3uHint) {
m3uHint.textContent = ALLOW_PERSONAL_M3U
? (m3uHint.dataset.enabledText ?? '')
: (m3uHint.dataset.disabledText ?? '');
m3uHint.classList.toggle('is-disabled', !ALLOW_PERSONAL_M3U);
m3uHint.style.display = '';
}
modal.classList.add('visible');
}
@@ -44,10 +56,14 @@ function closePersonalEpgModal(applied) {
function applyPersonalEpg() {
const epg = document.getElementById('personalEpgInput').value.trim();
const m3u = document.getElementById('personalM3uInput').value.trim();
const m3u = ALLOW_PERSONAL_M3U ? document.getElementById('personalM3uInput').value.trim() : '';
if (!epg) { alert('URL EPG obligatoire'); return; }
localStorage.setItem('gridtv-personal-epg', epg);
if (ALLOW_PERSONAL_M3U) {
localStorage.setItem('gridtv-personal-m3u', m3u);
} else {
localStorage.removeItem('gridtv-personal-m3u');
}
localStorage.setItem('gridtv-active-source', 'personal');
updateSourceDropdownLabel('✏ Personal EPG');
currentEpgUrl = epg;
+34 -1
View File
@@ -10,6 +10,7 @@ let channels = [];
let programs = {};
let m3uStreams = {}; // Normalized slug -> stream URL.
let scrollListenerAdded = false; // Keeps the channel column vertically synced with the timeline.
let availableCategories = [];
function pad(n) { return String(n).padStart(2,'0'); }
@@ -123,6 +124,27 @@ function formatCategories(categories) {
return Array.isArray(categories) && categories.length ? categories.join(' · ') : '';
}
function normalizeCategory(value) {
return String(value || '').trim().toLowerCase();
}
function collectAvailableCategories() {
const seen = new Set();
const list = [];
Object.values(programs).forEach(items => {
items.forEach(program => {
(program.categories || []).forEach(category => {
const label = String(category || '').trim();
const key = normalizeCategory(label);
if (!label || seen.has(key)) return;
seen.add(key);
list.push(label);
});
});
});
availableCategories = list.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
}
function parseAirDate(value) {
if (!value) return null;
const match = String(value).trim().match(/^(\d{4})/);
@@ -162,11 +184,22 @@ function parseAndRender(doc) {
});
Object.keys(programs).forEach(id => programs[id].sort((a,b) => a.start - b.start));
collectAvailableCategories();
if (typeof refreshCategoryFilterOptions === 'function') refreshCategoryFilterOptions();
renderAll();
if (typeof refreshReminderSchedules === 'function') refreshReminderSchedules();
startLiveUpdates();
}
function renderAll() {
function renderUnfiltered() {
if (isMobile()) { renderMobile(); }
else { renderGrid(); setTimeout(centerNow, 150); }
}
function renderAll() {
if (typeof applyFilters === 'function') {
applyFilters();
return;
}
renderUnfiltered();
}
+6 -2
View File
@@ -3,6 +3,10 @@
require_once __DIR__ . '/common.php';
function gridtv_require_admin(array $config, string $page_title = 'Admin'): array {
if (gridtv_is_demo_mode()) {
return ['unlocked' => true, 'error' => '', 'demo' => true];
}
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
@@ -12,7 +16,7 @@ function gridtv_require_admin(array $config, string $page_title = 'Admin'): arra
$error = '';
if ($admin_key !== '' && hash_equals($admin_key, (string) $session_key)) {
return ['unlocked' => true, 'error' => ''];
return ['unlocked' => true, 'error' => '', 'demo' => false];
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['admin_key_input'])) {
@@ -26,6 +30,6 @@ function gridtv_require_admin(array $config, string $page_title = 'Admin'): arra
}
http_response_code(401);
echo '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>GridTV — ' . htmlspecialchars($page_title) . '</title><link rel="stylesheet" href="/assets/fonts/fonts.css"><style>:root{--bg:#0a0b0d;--surface:#111318;--surface2:#181b22;--border:#232733;--border-bright:#2e3444;--accent:#e8c842;--text:#c8cdd8;--text-dim:#5a6070;--text-bright:#eef0f5;--error:#ff4444;}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:var(--bg);color:var(--text);font-family:"Barlow Condensed",sans-serif;padding:24px}.card{width:100%;max-width:460px;background:var(--surface);border:1px solid var(--border-bright);padding:34px}.logo{font-family:"Share Tech Mono",monospace;font-size:22px;color:var(--accent);letter-spacing:.1em;text-transform:uppercase;margin-bottom:8px}.sub{font-size:13px;color:var(--text-dim);line-height:1.5;margin-bottom:24px}.error{background:rgba(255,68,68,.08);border-left:3px solid var(--error);padding:12px 14px;color:var(--error);font-size:13px;margin-bottom:18px}label{display:block;font-family:"Share Tech Mono",monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--text-dim);margin-bottom:8px}input{width:100%;background:var(--surface2);border:1px solid var(--border-bright);color:var(--text-bright);font-family:"Share Tech Mono",monospace;font-size:13px;padding:11px 12px;margin-bottom:16px}button{width:100%;background:var(--accent);color:#000;border:none;font-family:"Barlow Condensed",sans-serif;font-size:14px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;padding:12px;cursor:pointer}.links{margin-top:16px;font-size:12px;color:var(--text-dim)}.links a{color:var(--accent);text-decoration:none}</style></head><body><div class="card"><div class="logo">GridTV</div><div class="sub">Enter your admin key to access the ' . htmlspecialchars($page_title) . ' page.</div>' . ($error ? '<div class="error">' . htmlspecialchars($error) . '</div>' : '') . '<form method="POST"><label for="admin_key_input">Admin key</label><input id="admin_key_input" type="password" name="admin_key_input" autocomplete="off" autofocus><button type="submit">Unlock</button></form><div class="links"><a href="/setup.php">Open setup</a></div></div></body></html>';
echo '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>GridTV — ' . htmlspecialchars($page_title) . '</title><link rel="stylesheet" href="/assets/fonts/fonts.css"><style>:root{--bg:#0a0b0d;--surface:#111318;--surface2:#181b22;--border:#232733;--border-bright:#2e3444;--accent:#e8c842;--text:#c8cdd8;--text-dim:#5a6070;--text-bright:#eef0f5;--error:#ff4444;}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:var(--bg);color:var(--text);font-family:"Barlow Condensed",sans-serif;padding:24px}.card{width:100%;max-width:460px;background:var(--surface);border:1px solid var(--border-bright);padding:34px}.logo{font-family:"Share Tech Mono",monospace;font-size:22px;color:var(--accent);letter-spacing:.1em;text-transform:uppercase;margin-bottom:8px}.sub{font-size:13px;color:var(--text-dim);line-height:1.5;margin-bottom:24px}.error{background:rgba(255,68,68,.08);border-left:3px solid var(--error);padding:12px 14px;color:var(--error);font-size:13px;margin-bottom:18px}label{display:block;font-family:"Share Tech Mono",monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--text-dim);margin-bottom:8px}input{width:100%;background:var(--surface2);border:1px solid var(--border-bright);color:var(--text-bright);font-family:"Share Tech Mono",monospace;font-size:13px;padding:11px 12px;margin-bottom:16px}button{width:100%;background:var(--accent);color:#000;border:none;font-family:"Barlow Condensed",sans-serif;font-size:14px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;padding:12px;cursor:pointer}.links{margin-top:16px;font-size:12px;color:var(--text-dim)}.links a{color:var(--accent);text-decoration:none}</style></head><body><div class="card"><div class="logo">GridTV</div><div class="sub">Enter your admin key to access the ' . htmlspecialchars($page_title) . ' page.</div>' . ($error ? '<div class="error">' . htmlspecialchars($error) . '</div>' : '') . '<form method="POST" id="gridtvAdminUnlockForm"><label for="admin_key_input">Admin key</label><input id="admin_key_input" type="password" name="admin_key_input" autocomplete="off" autofocus><button type="submit">Unlock</button></form><div class="links"><a href="/setup.php">Open setup</a></div></div><script>const GRIDTV_ADMIN_KEY_STORAGE="gridtv-admin-key";const adminInput=document.getElementById("admin_key_input");const adminForm=document.getElementById("gridtvAdminUnlockForm");try{const savedKey=localStorage.getItem(GRIDTV_ADMIN_KEY_STORAGE)||"";if(adminInput&&savedKey)adminInput.value=savedKey;}catch(_){}if(adminForm&&adminInput){adminForm.addEventListener("submit",()=>{try{localStorage.setItem(GRIDTV_ADMIN_KEY_STORAGE,adminInput.value.trim());}catch(_){}});}</script></body></html>';
exit;
}
+8
View File
@@ -4,6 +4,14 @@ function gridtv_config_path(): string {
return dirname(__DIR__, 2) . '/config.json';
}
function gridtv_demo_path(): string {
return dirname(__DIR__, 2) . '/.demo';
}
function gridtv_is_demo_mode(): bool {
return file_exists(gridtv_demo_path());
}
function gridtv_load_config(): array {
$config_path = gridtv_config_path();
if (!file_exists($config_path)) {
+1 -1
View File
@@ -3,7 +3,7 @@
<?php
$js_dir = __DIR__ . '/../js/';
include $js_dir . 'config.js';
$js_modules = ['utils', 'favorites', 'epg', 'mobile', 'tooltip', 'sources', 'm3u', 'player', 'themes', 'search', 'program', 'updater', 'live'];
$js_modules = ['utils', 'favorites', 'epg', 'mobile', 'tooltip', 'sources', 'm3u', 'player', 'themes', 'search', 'reminders', 'program', 'updater', 'live'];
foreach ($js_modules as $mod) {
echo file_get_contents($js_dir . $mod . '.js');
}
+13
View File
@@ -9,6 +9,18 @@
<div class="pm-meta" id="pm-meta"></div>
<div class="pm-genres" id="pm-genres"></div>
<div class="pm-desc" id="pm-desc"></div>
<div class="pm-reminder-panel" id="pm-reminder-panel">
<button class="pm-btn pm-reminder" id="pm-reminder-toggle" type="button"><?= htmlspecialchars($L["reminder_button"] ?? 'Reminder') ?></button>
<div class="pm-reminder-submenu" id="pm-reminder-submenu">
<div class="pm-reminder-title"><?= htmlspecialchars($L["reminder_title"] ?? 'Notify me before this program starts') ?></div>
<div class="pm-reminder-text" id="pm-reminder-text"></div>
<div class="pm-reminder-status" id="pm-reminder-status"></div>
<div class="pm-reminder-actions">
<button class="pm-btn pm-reminder-save" id="pm-reminder-save" type="button"><?= htmlspecialchars($L["reminder_save"] ?? 'Activate reminder') ?></button>
<button class="pm-btn pm-reminder-remove" id="pm-reminder-remove" type="button"><?= htmlspecialchars($L["reminder_remove"] ?? 'Remove reminder') ?></button>
</div>
</div>
</div>
<div class="pm-actions">
<button class="pm-btn pm-watch" id="pm-watch">&#9654; <?= htmlspecialchars($L["watch_now"]) ?></button>
<a class="pm-btn pm-imdb" id="pm-imdb" href="#" target="_blank" rel="noopener">&#127909; <?= htmlspecialchars($L["imdb_search"]) ?></a>
@@ -29,6 +41,7 @@
<div class="modal-field">
<label><?= htmlspecialchars($L["personal_epg_m3u_label"]) ?> <span style="opacity:.5;font-size:10px">(<?= htmlspecialchars($L["personal_epg_optional"]) ?>)</span></label>
<input type="url" id="personalM3uInput" placeholder="http://mon-serveur/channels.m3u">
<div id="personalM3uHint" class="modal-field-note" data-enabled-text="<?= htmlspecialchars($L["personal_epg_m3u_enabled"]) ?>" data-disabled-text="<?= htmlspecialchars($L["personal_epg_m3u_disabled"]) ?>"></div>
</div>
<div class="modal-actions">
<button class="modal-btn-cancel" onclick="closePersonalEpgModal()"><?= htmlspecialchars($L["cancel"]) ?></button>
+3
View File
@@ -58,6 +58,9 @@ foreach ($theme_files as $file) {
}
?>
</select>
<select class="category-filter" id="categoryFilter" onchange="setCategoryFilter(this.value)" title="<?= htmlspecialchars($L["category_filter_title"] ?? 'Filter by category') ?>">
<option value=""><?= htmlspecialchars($L["category_all"] ?? 'All categories') ?></option>
</select>
<button class="search-btn" onclick="toggleSearch()" title="Search">&#128269;</button>
<div class="live-dot"><?= htmlspecialchars($L["live"]) ?></div>
</div>
+17
View File
@@ -75,3 +75,20 @@ self.addEventListener('fetch', e => {
.catch(() => caches.match(e.request))
);
});
self.addEventListener('notificationclick', e => {
const url = e.notification?.data?.url || '/index.php';
e.notification.close();
e.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(windowClients => {
for (const client of windowClients) {
if ('focus' in client) {
client.navigate(url);
return client.focus();
}
}
if (clients.openWindow) return clients.openWindow(url);
return undefined;
})
);
});
+1 -1
View File
@@ -1,3 +1,3 @@
{
"version": "1.6.0"
"version": "1.7.0"
}