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
This commit is contained in:
+111
-7
@@ -30,24 +30,56 @@ function health_parse_xmltv(string $xml): array {
|
|||||||
$subtitles = $xp->query('/tv/programme/sub-title');
|
$subtitles = $xp->query('/tv/programme/sub-title');
|
||||||
$ratings = $xp->query('/tv/programme/rating');
|
$ratings = $xp->query('/tv/programme/rating');
|
||||||
$dates = $xp->query('/tv/programme/date');
|
$dates = $xp->query('/tv/programme/date');
|
||||||
|
$channel_icons = $xp->query('/tv/channel/icon');
|
||||||
|
|
||||||
$first = null;
|
$first = null;
|
||||||
$last = 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) {
|
foreach ($programmes as $programme) {
|
||||||
$start = $programme->getAttribute('start');
|
$start = $programme->getAttribute('start');
|
||||||
$stop = $programme->getAttribute('stop');
|
$stop = $programme->getAttribute('stop');
|
||||||
if ($start !== '' && ($first === null || strcmp($start, $first) < 0)) $first = $start;
|
if ($start !== '' && ($first === null || strcmp($start, $first) < 0)) $first = $start;
|
||||||
if ($stop !== '' && ($last === null || strcmp($stop, $last) > 0)) $last = $stop;
|
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 [
|
return [
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'channels' => $channels->length,
|
'channels' => $channel_count,
|
||||||
'programmes' => $programmes->length,
|
'programmes' => $programme_count,
|
||||||
'categories' => $categories->length,
|
'categories' => $categories->length,
|
||||||
'subtitles' => $subtitles->length,
|
'subtitles' => $subtitles->length,
|
||||||
'ratings' => $ratings->length,
|
'ratings' => $ratings->length,
|
||||||
'dates' => $dates->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,
|
'first_start' => $first,
|
||||||
'last_stop' => $last,
|
'last_stop' => $last,
|
||||||
];
|
];
|
||||||
@@ -57,6 +89,10 @@ function health_parse_m3u(string $body): array {
|
|||||||
$lines = preg_split('/\r\n|\r|\n/', $body);
|
$lines = preg_split('/\r\n|\r|\n/', $body);
|
||||||
$streams = 0;
|
$streams = 0;
|
||||||
$extinf = 0;
|
$extinf = 0;
|
||||||
|
$http = 0;
|
||||||
|
$https = 0;
|
||||||
|
$duplicates = 0;
|
||||||
|
$seen_urls = [];
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
$line = trim($line);
|
$line = trim($line);
|
||||||
if ($line === '') continue;
|
if ($line === '') continue;
|
||||||
@@ -64,9 +100,22 @@ function health_parse_m3u(string $body): array {
|
|||||||
$extinf++;
|
$extinf++;
|
||||||
continue;
|
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 {
|
function health_format_xmltv_stamp(?string $stamp): string {
|
||||||
@@ -185,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_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_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_fetch = null;
|
||||||
$m3u_stats = null;
|
$m3u_stats = null;
|
||||||
@@ -192,6 +244,16 @@ function health_build_payload(array $config, string $locale): array {
|
|||||||
$m3u_fetch = gridtv_fetch_url($m3u_url, 20);
|
$m3u_fetch = gridtv_fetch_url($m3u_url, 20);
|
||||||
$m3u_stats = $m3u_fetch['ok'] ? health_parse_m3u($m3u_fetch['body']) : null;
|
$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[] = [
|
$source_reports[] = [
|
||||||
'name' => $source['name'] ?: 'Source ' . ($index + 1),
|
'name' => $source['name'] ?: 'Source ' . ($index + 1),
|
||||||
@@ -199,9 +261,9 @@ function health_build_payload(array $config, string $locale): array {
|
|||||||
'm3u_url' => $m3u_url,
|
'm3u_url' => $m3u_url,
|
||||||
'epg_host' => gridtv_extract_host($epg_url),
|
'epg_host' => gridtv_extract_host($epg_url),
|
||||||
'm3u_host' => gridtv_extract_host($m3u_url),
|
'm3u_host' => gridtv_extract_host($m3u_url),
|
||||||
'epg_fetch' => $epg_fetch,
|
'epg_fetch' => $epg_fetch_public,
|
||||||
'epg_stats' => $epg_stats,
|
'epg_stats' => $epg_stats,
|
||||||
'm3u_fetch' => $m3u_fetch,
|
'm3u_fetch' => $m3u_fetch_public,
|
||||||
'm3u_stats' => $m3u_stats,
|
'm3u_stats' => $m3u_stats,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -361,18 +423,54 @@ function renderChecks(checks, labels) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSources(sources, 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 => {
|
document.getElementById('sourceList').innerHTML = sources.map(report => {
|
||||||
const epgHealthy = report.epg_fetch.ok && report.epg_stats.ok;
|
const epgHealthy = report.epg_fetch.ok && report.epg_stats.ok;
|
||||||
const m3uHealthy = report.m3u_fetch ? report.m3u_fetch.ok : true;
|
const m3uHealthy = report.m3u_fetch ? report.m3u_fetch.ok : true;
|
||||||
|
const score = epgHealthy ? qualityScore(report.epg_stats) : 0;
|
||||||
const epgRows = epgHealthy ? `
|
const epgRows = epgHealthy ? `
|
||||||
<div>Channels</div><div>${report.epg_stats.channels}</div>
|
<div>Channels</div><div>${report.epg_stats.channels}</div>
|
||||||
<div>Programmes</div><div>${report.epg_stats.programmes}</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>Sub-titles</div><div>${report.epg_stats.subtitles}</div>
|
||||||
<div>Ratings</div><div>${report.epg_stats.ratings}</div>
|
<div>Ratings</div><div>${report.epg_stats.ratings}</div>
|
||||||
<div>Categories</div><div>${report.epg_stats.categories}</div>
|
<div>Categories</div><div>${report.epg_stats.categories}</div>
|
||||||
<div>Date tags</div><div>${report.epg_stats.dates}</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</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>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>`;
|
` : `<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>` : `
|
const m3uRows = !report.m3u_url ? `<div class="small">No M3U URL configured for this source.</div>` : `
|
||||||
@@ -384,7 +482,12 @@ function renderSources(sources, labels) {
|
|||||||
${report.m3u_fetch.ok && report.m3u_stats ? `
|
${report.m3u_fetch.ok && report.m3u_stats ? `
|
||||||
<div>Streams</div><div>${report.m3u_stats.streams}</div>
|
<div>Streams</div><div>${report.m3u_stats.streams}</div>
|
||||||
<div>#EXTINF</div><div>${report.m3u_stats.extinf}</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>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>Parse</div><div>Unable to load playlist.</div>`}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
@@ -395,13 +498,14 @@ function renderSources(sources, labels) {
|
|||||||
<div class="source-title">${report.name}</div>
|
<div class="source-title">${report.name}</div>
|
||||||
<div class="small">EPG host: ${report.epg_host || '—'}${report.m3u_host ? ' · M3U host: ' + report.m3u_host : ''}</div>
|
<div class="small">EPG host: ${report.epg_host || '—'}${report.m3u_host ? ' · M3U host: ' + report.m3u_host : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="pill">${epgHealthy ? 'Healthy XMLTV' : 'Check source'}</span>
|
<span class="pill">${epgHealthy ? `${qualityLabel(score)} XMLTV · ${score}%` : 'Check source'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="split">
|
<div class="split">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h3>XMLTV</h3>
|
<h3>XMLTV</h3>
|
||||||
<div class="kv">
|
<div class="kv">
|
||||||
<div>Status</div><div>${statusHtml(epgHealthy, labels)}</div>
|
<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>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>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>
|
<div>Response time</div><div>${Number(report.epg_fetch.time_total).toFixed(2)} s</div>
|
||||||
|
|||||||
@@ -32,10 +32,28 @@
|
|||||||
"search_placeholder": "Search channel or program...",
|
"search_placeholder": "Search channel or program...",
|
||||||
"copy_epg": "EPG",
|
"copy_epg": "EPG",
|
||||||
"copy_m3u": "M3U",
|
"copy_m3u": "M3U",
|
||||||
|
"category_filter_title": "Filter by category",
|
||||||
|
"category_all": "All categories",
|
||||||
"copied": "✓ copied",
|
"copied": "✓ copied",
|
||||||
"program_info": "Program info",
|
"program_info": "Program info",
|
||||||
"watch_now": "Watch now",
|
"watch_now": "Watch now",
|
||||||
"imdb_search": "Search on IMDb",
|
"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",
|
"close": "Close",
|
||||||
"duration_min": "min",
|
"duration_min": "min",
|
||||||
"personal_epg_title": "Personal EPG",
|
"personal_epg_title": "Personal EPG",
|
||||||
|
|||||||
@@ -32,10 +32,28 @@
|
|||||||
"search_placeholder": "Buscar canal o programa...",
|
"search_placeholder": "Buscar canal o programa...",
|
||||||
"copy_epg": "EPG",
|
"copy_epg": "EPG",
|
||||||
"copy_m3u": "M3U",
|
"copy_m3u": "M3U",
|
||||||
|
"category_filter_title": "Filtrar por categoría",
|
||||||
|
"category_all": "Todas las categorías",
|
||||||
"copied": "✓ copiado",
|
"copied": "✓ copiado",
|
||||||
"program_info": "Info del programa",
|
"program_info": "Info del programa",
|
||||||
"watch_now": "Ver ahora",
|
"watch_now": "Ver ahora",
|
||||||
"imdb_search": "Buscar en IMDb",
|
"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",
|
"close": "Cerrar",
|
||||||
"duration_min": "min",
|
"duration_min": "min",
|
||||||
"personal_epg_title": "EPG Personal",
|
"personal_epg_title": "EPG Personal",
|
||||||
|
|||||||
@@ -32,10 +32,28 @@
|
|||||||
"search_placeholder": "Rechercher une chaîne ou un programme...",
|
"search_placeholder": "Rechercher une chaîne ou un programme...",
|
||||||
"copy_epg": "EPG",
|
"copy_epg": "EPG",
|
||||||
"copy_m3u": "M3U",
|
"copy_m3u": "M3U",
|
||||||
|
"category_filter_title": "Filtrer par catégorie",
|
||||||
|
"category_all": "Toutes catégories",
|
||||||
"copied": "✓ copié",
|
"copied": "✓ copié",
|
||||||
"program_info": "Infos programme",
|
"program_info": "Infos programme",
|
||||||
"watch_now": "Regarder",
|
"watch_now": "Regarder",
|
||||||
"imdb_search": "Rechercher sur IMDb",
|
"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",
|
"close": "Fermer",
|
||||||
"duration_min": "min",
|
"duration_min": "min",
|
||||||
"personal_epg_title": "EPG Personnel",
|
"personal_epg_title": "EPG Personnel",
|
||||||
|
|||||||
@@ -216,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>
|
<div class="lock-sub">GridTV is already configured.<br>Enter your admin key to edit the settings.</div>
|
||||||
<form method="POST" action="setup.php">
|
<form method="POST" action="setup.php">
|
||||||
<div class="lock-form">
|
<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>
|
<button type="submit" class="btn-submit" style="width:auto;padding:10px 20px;">Unlock</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -349,6 +349,41 @@ function syncPersonalM3uToggle() {
|
|||||||
}
|
}
|
||||||
allowPersonalEpgToggle?.addEventListener('change', syncPersonalM3uToggle);
|
allowPersonalEpgToggle?.addEventListener('change', syncPersonalM3uToggle);
|
||||||
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -45,3 +45,53 @@
|
|||||||
text-transform: uppercase; padding: 8px 20px; cursor: pointer; transition: opacity 0.15s;
|
text-transform: uppercase; padding: 8px 20px; cursor: pointer; transition: opacity 0.15s;
|
||||||
}
|
}
|
||||||
.modal-btn-apply:hover { opacity: 0.85; }
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -149,6 +149,25 @@
|
|||||||
.theme-select:hover { border-color: var(--accent2); color: var(--accent2); }
|
.theme-select:hover { border-color: var(--accent2); color: var(--accent2); }
|
||||||
.theme-select option { background: #111; color: #eee; font-family: sans-serif; }
|
.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; }
|
body { overflow: hidden; }
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
@@ -159,6 +178,7 @@
|
|||||||
}
|
}
|
||||||
.logo { font-size: 11px; }
|
.logo { font-size: 11px; }
|
||||||
.clock { font-size: 14px; }
|
.clock { font-size: 14px; }
|
||||||
|
.category-filter { max-width: 100%; }
|
||||||
|
|
||||||
.grid-wrapper { display: none; }
|
.grid-wrapper { display: none; }
|
||||||
.mobile-view { display: block; }
|
.mobile-view { display: block; }
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const DEFAULT_M3U_URL = EPG_SOURCES[0]?.m3u_url ?? '';
|
|||||||
const PX_PER_MIN = 5;
|
const PX_PER_MIN = 5;
|
||||||
const GRID_HOURS = 72;
|
const GRID_HOURS = 72;
|
||||||
const CORS_PROXY = '/proxy.php?url=';
|
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.
|
// Anchor the grid 2 hours before "now", rounded down to the previous quarter-hour.
|
||||||
const _d = new Date();
|
const _d = new Date();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ window.addEventListener('resize', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
|
refreshReminderSchedules();
|
||||||
const savedSource = localStorage.getItem('gridtv-active-source');
|
const savedSource = localStorage.getItem('gridtv-active-source');
|
||||||
if (savedSource === 'personal') {
|
if (savedSource === 'personal') {
|
||||||
const epg = localStorage.getItem('gridtv-personal-epg');
|
const epg = localStorage.getItem('gridtv-personal-epg');
|
||||||
|
|||||||
+4
-1
@@ -6,7 +6,10 @@ function slugify(str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadM3U(url) {
|
async function loadM3U(url) {
|
||||||
if (!url) return;
|
if (!url) {
|
||||||
|
m3uStreams = {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
let text;
|
let text;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -34,11 +34,13 @@ function openProgramModal(ch, p) {
|
|||||||
document.getElementById('pm-imdb').href =
|
document.getElementById('pm-imdb').href =
|
||||||
`https://www.imdb.com/find/?q=${encodeURIComponent(p.title)}&s=tt`;
|
`https://www.imdb.com/find/?q=${encodeURIComponent(p.title)}&s=tt`;
|
||||||
|
|
||||||
|
bindReminderPanel(ch, p);
|
||||||
document.getElementById('programModal').classList.add('visible');
|
document.getElementById('programModal').classList.add('visible');
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeProgramModal() {
|
function closeProgramModal() {
|
||||||
document.getElementById('programModal').classList.remove('visible');
|
document.getElementById('programModal').classList.remove('visible');
|
||||||
|
document.getElementById('pm-reminder-submenu')?.classList.remove('visible');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close when the overlay itself is clicked.
|
// Close when the overlay itself is clicked.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
+102
-40
@@ -2,6 +2,7 @@
|
|||||||
let searchActive = false;
|
let searchActive = false;
|
||||||
let searchQuery = '';
|
let searchQuery = '';
|
||||||
let searchTimer = null;
|
let searchTimer = null;
|
||||||
|
let currentCategoryFilter = '';
|
||||||
|
|
||||||
function toggleSearch() {
|
function toggleSearch() {
|
||||||
const bar = document.getElementById('searchBar');
|
const bar = document.getElementById('searchBar');
|
||||||
@@ -18,48 +19,94 @@ function toggleSearch() {
|
|||||||
function clearSearch() {
|
function clearSearch() {
|
||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
document.getElementById('searchInput').value = '';
|
document.getElementById('searchInput').value = '';
|
||||||
applySearch('');
|
applyFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSearchInput(val) {
|
function onSearchInput(val) {
|
||||||
clearTimeout(searchTimer);
|
clearTimeout(searchTimer);
|
||||||
searchTimer = setTimeout(() => {
|
searchTimer = setTimeout(() => {
|
||||||
searchQuery = val.trim().toLowerCase();
|
searchQuery = val.trim().toLowerCase();
|
||||||
applySearch(searchQuery);
|
applyFilters();
|
||||||
}, 350);
|
}, 350);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySearch(q) {
|
function programMatchesQuery(p, q) {
|
||||||
if (!q) {
|
if (!q) return true;
|
||||||
// Restore the full channel list when the query is cleared.
|
if (p.title.toLowerCase().includes(q)) return true;
|
||||||
renderAll();
|
if (p.desc && p.desc.toLowerCase().includes(q)) return true;
|
||||||
return;
|
if ((p.categories || []).some(cat => cat.toLowerCase().includes(q))) return true;
|
||||||
}
|
return false;
|
||||||
|
|
||||||
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;
|
|
||||||
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()) {
|
if (isMobile()) {
|
||||||
renderMobileFiltered(filteredChannels, q);
|
renderMobileFiltered(filteredChannels, q, category);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +149,12 @@ function renderFiltered(filteredChannels, q) {
|
|||||||
row.className = 'program-row';
|
row.className = 'program-row';
|
||||||
row.style.cssText = `top:${i*ROW_H}px;position:absolute;left:0;right:0;`;
|
row.style.cssText = `top:${i*ROW_H}px;position:absolute;left:0;right:0;`;
|
||||||
|
|
||||||
(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 x = Math.max(0, msToX(p.start.getTime()));
|
||||||
const w = Math.min(TOTAL_WIDTH_PX, msToX(p.stop.getTime())) - x;
|
const w = Math.min(TOTAL_WIDTH_PX, msToX(p.stop.getTime())) - x;
|
||||||
if (w < 2) return;
|
if (w < 2) return;
|
||||||
@@ -112,10 +164,13 @@ function renderFiltered(filteredChannels, q) {
|
|||||||
|
|
||||||
// Highlight programs that match the current search query.
|
// Highlight programs that match the current search query.
|
||||||
const matchesProg =
|
const matchesProg =
|
||||||
p.title.toLowerCase().includes(q) ||
|
!!q && (
|
||||||
(p.desc && p.desc.toLowerCase().includes(q)) ||
|
p.title.toLowerCase().includes(q) ||
|
||||||
(p.categories || []).some(cat => cat.toLowerCase().includes(q));
|
(p.desc && p.desc.toLowerCase().includes(q)) ||
|
||||||
|
(p.categories || []).some(cat => cat.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
if (matchesProg) block.classList.add('search-match');
|
if (matchesProg) block.classList.add('search-match');
|
||||||
|
if (category && !matchesProg && !channelMatchesQuery) block.classList.add('search-match');
|
||||||
|
|
||||||
if (p.stop < now) block.classList.add('is-past');
|
if (p.stop < now) block.classList.add('is-past');
|
||||||
if (p.start <= now && p.stop > now) {
|
if (p.start <= now && p.stop > now) {
|
||||||
@@ -144,7 +199,7 @@ function renderFiltered(filteredChannels, q) {
|
|||||||
positionNowLine();
|
positionNowLine();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMobileFiltered(filteredChannels, q) {
|
function renderMobileFiltered(filteredChannels, q, category) {
|
||||||
const container = document.getElementById('mobileView');
|
const container = document.getElementById('mobileView');
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -152,7 +207,12 @@ function renderMobileFiltered(filteredChannels, q) {
|
|||||||
const winEnd = new Date(now.getTime() + 4*3600000);
|
const winEnd = new Date(now.getTime() + 4*3600000);
|
||||||
|
|
||||||
filteredChannels.forEach(ch => {
|
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;
|
if (!progs.length) return;
|
||||||
|
|
||||||
const section = document.createElement('div'); section.className = 'mobile-channel';
|
const section = document.createElement('div'); section.className = 'mobile-channel';
|
||||||
@@ -173,11 +233,13 @@ function renderMobileFiltered(filteredChannels, q) {
|
|||||||
const isPast = p.stop < now;
|
const isPast = p.stop < now;
|
||||||
const dur = Math.round((p.stop - p.start) / 60000);
|
const dur = Math.round((p.stop - p.start) / 60000);
|
||||||
const matchesProg =
|
const matchesProg =
|
||||||
p.title.toLowerCase().includes(q) ||
|
!!q && (
|
||||||
(p.desc && p.desc.toLowerCase().includes(q)) ||
|
p.title.toLowerCase().includes(q) ||
|
||||||
(p.categories || []).some(cat => cat.toLowerCase().includes(q));
|
(p.desc && p.desc.toLowerCase().includes(q)) ||
|
||||||
|
(p.categories || []).some(cat => cat.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'mobile-program' + (isLive ? ' is-live' : '') + (isPast ? ' is-past' : '') + (matchesProg ? ' search-match' : '');
|
item.className = 'mobile-program' + (isLive ? ' is-live' : '') + (isPast ? ' is-past' : '') + (matchesProg || (category && !channelMatchesQuery) ? ' search-match' : '');
|
||||||
|
|
||||||
if (isLive) {
|
if (isLive) {
|
||||||
const bar = document.createElement('div'); bar.className = 'mobile-program-progress';
|
const bar = document.createElement('div'); bar.className = 'mobile-program-progress';
|
||||||
|
|||||||
+34
-1
@@ -10,6 +10,7 @@ let channels = [];
|
|||||||
let programs = {};
|
let programs = {};
|
||||||
let m3uStreams = {}; // Normalized slug -> stream URL.
|
let m3uStreams = {}; // Normalized slug -> stream URL.
|
||||||
let scrollListenerAdded = false; // Keeps the channel column vertically synced with the timeline.
|
let scrollListenerAdded = false; // Keeps the channel column vertically synced with the timeline.
|
||||||
|
let availableCategories = [];
|
||||||
|
|
||||||
function pad(n) { return String(n).padStart(2,'0'); }
|
function pad(n) { return String(n).padStart(2,'0'); }
|
||||||
|
|
||||||
@@ -123,6 +124,27 @@ function formatCategories(categories) {
|
|||||||
return Array.isArray(categories) && categories.length ? categories.join(' · ') : '';
|
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) {
|
function parseAirDate(value) {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const match = String(value).trim().match(/^(\d{4})/);
|
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));
|
Object.keys(programs).forEach(id => programs[id].sort((a,b) => a.start - b.start));
|
||||||
|
|
||||||
|
collectAvailableCategories();
|
||||||
|
if (typeof refreshCategoryFilterOptions === 'function') refreshCategoryFilterOptions();
|
||||||
renderAll();
|
renderAll();
|
||||||
|
if (typeof refreshReminderSchedules === 'function') refreshReminderSchedules();
|
||||||
startLiveUpdates();
|
startLiveUpdates();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAll() {
|
function renderUnfiltered() {
|
||||||
if (isMobile()) { renderMobile(); }
|
if (isMobile()) { renderMobile(); }
|
||||||
else { renderGrid(); setTimeout(centerNow, 150); }
|
else { renderGrid(); setTimeout(centerNow, 150); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderAll() {
|
||||||
|
if (typeof applyFilters === 'function') {
|
||||||
|
applyFilters();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderUnfiltered();
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -30,6 +30,6 @@ function gridtv_require_admin(array $config, string $page_title = 'Admin'): arra
|
|||||||
}
|
}
|
||||||
|
|
||||||
http_response_code(401);
|
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;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<?php
|
<?php
|
||||||
$js_dir = __DIR__ . '/../js/';
|
$js_dir = __DIR__ . '/../js/';
|
||||||
include $js_dir . 'config.js';
|
include $js_dir . 'config.js';
|
||||||
$js_modules = ['utils', 'favorites', 'epg', 'mobile', 'tooltip', 'sources', 'm3u', 'player', 'themes', 'search', 'program', 'updater', 'live'];
|
$js_modules = ['utils', 'favorites', 'epg', 'mobile', 'tooltip', 'sources', 'm3u', 'player', 'themes', 'search', 'reminders', 'program', 'updater', 'live'];
|
||||||
foreach ($js_modules as $mod) {
|
foreach ($js_modules as $mod) {
|
||||||
echo file_get_contents($js_dir . $mod . '.js');
|
echo file_get_contents($js_dir . $mod . '.js');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,18 @@
|
|||||||
<div class="pm-meta" id="pm-meta"></div>
|
<div class="pm-meta" id="pm-meta"></div>
|
||||||
<div class="pm-genres" id="pm-genres"></div>
|
<div class="pm-genres" id="pm-genres"></div>
|
||||||
<div class="pm-desc" id="pm-desc"></div>
|
<div class="pm-desc" id="pm-desc"></div>
|
||||||
|
<div class="pm-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">
|
<div class="pm-actions">
|
||||||
<button class="pm-btn pm-watch" id="pm-watch">▶ <?= htmlspecialchars($L["watch_now"]) ?></button>
|
<button class="pm-btn pm-watch" id="pm-watch">▶ <?= htmlspecialchars($L["watch_now"]) ?></button>
|
||||||
<a class="pm-btn pm-imdb" id="pm-imdb" href="#" target="_blank" rel="noopener">🎥 <?= htmlspecialchars($L["imdb_search"]) ?></a>
|
<a class="pm-btn pm-imdb" id="pm-imdb" href="#" target="_blank" rel="noopener">🎥 <?= htmlspecialchars($L["imdb_search"]) ?></a>
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ foreach ($theme_files as $file) {
|
|||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
</select>
|
</select>
|
||||||
|
<select class="category-filter" id="categoryFilter" onchange="setCategoryFilter(this.value)" title="<?= htmlspecialchars($L["category_filter_title"] ?? 'Filter by category') ?>">
|
||||||
|
<option value=""><?= htmlspecialchars($L["category_all"] ?? 'All categories') ?></option>
|
||||||
|
</select>
|
||||||
<button class="search-btn" onclick="toggleSearch()" title="Search">🔍</button>
|
<button class="search-btn" onclick="toggleSearch()" title="Search">🔍</button>
|
||||||
<div class="live-dot"><?= htmlspecialchars($L["live"]) ?></div>
|
<div class="live-dot"><?= htmlspecialchars($L["live"]) ?></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -75,3 +75,20 @@ self.addEventListener('fetch', e => {
|
|||||||
.catch(() => caches.match(e.request))
|
.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;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user