diff --git a/health.php b/health.php index 7e95ee8..1e4f019 100644 --- a/health.php +++ b/health.php @@ -30,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, ]; @@ -57,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; @@ -64,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 { @@ -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_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; @@ -192,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), @@ -199,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, ]; } @@ -361,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 ? `
Channels
${report.epg_stats.channels}
Programmes
${report.epg_stats.programmes}
+
Channel logos
${report.epg_stats.channel_icons} (${report.epg_stats.channels_without_icon} missing)
+
Descriptions
${report.epg_stats.programmes_with_desc} (${percent(report.epg_stats.programmes_with_desc, report.epg_stats.programmes)})
+
Categories coverage
${report.epg_stats.programmes_with_category} (${percent(report.epg_stats.programmes_with_category, report.epg_stats.programmes)})
+
Subtitle coverage
${report.epg_stats.programmes_with_subtitle} (${percent(report.epg_stats.programmes_with_subtitle, report.epg_stats.programmes)})
+
Rating coverage
${report.epg_stats.programmes_with_rating} (${percent(report.epg_stats.programmes_with_rating, report.epg_stats.programmes)})
+
Date coverage
${report.epg_stats.programmes_with_date} (${percent(report.epg_stats.programmes_with_date, report.epg_stats.programmes)})
+
Stop time coverage
${report.epg_stats.programmes_with_stop} (${percent(report.epg_stats.programmes_with_stop, report.epg_stats.programmes)})
+
Unique categories
${report.epg_stats.unique_categories}
+
Programmes / channel
${report.epg_stats.avg_programmes_per_channel}
Sub-titles
${report.epg_stats.subtitles}
Ratings
${report.epg_stats.ratings}
Categories
${report.epg_stats.categories}
Date tags
${report.epg_stats.dates}
Window
${report.epg_stats.first_start || '—'} → ${report.epg_stats.last_stop || '—'}
+
Window (local)
${report.epg_stats.first_start_local || '—'} → ${report.epg_stats.last_stop_local || '—'}
Content-Type
${report.epg_fetch.content_type || '—'}
+
Final URL
${report.epg_fetch.final_url || report.epg_url}
+
Payload size
${report.epg_fetch.headers['content-length'] || report.epg_fetch.body_bytes || '—'} bytes
` : `
Parse
${report.epg_stats.error || 'Unable to read XMLTV'}
`; const m3uRows = !report.m3u_url ? `
No M3U URL configured for this source.
` : ` @@ -384,7 +482,12 @@ function renderSources(sources, labels) { ${report.m3u_fetch.ok && report.m3u_stats ? `
Streams
${report.m3u_stats.streams}
#EXTINF
${report.m3u_stats.extinf}
+
HTTPS streams
${report.m3u_stats.https_streams}
+
HTTP streams
${report.m3u_stats.http_streams}
+
Duplicate URLs
${report.m3u_stats.duplicate_urls}
+
Payload size
${report.m3u_fetch.headers['content-length'] || report.m3u_fetch.body_bytes || report.m3u_stats.size_bytes} bytes
Content-Type
${report.m3u_fetch.content_type || '—'}
+
Final URL
${report.m3u_fetch.final_url || report.m3u_url}
` : `
Parse
Unable to load playlist.
`} `; @@ -395,13 +498,14 @@ function renderSources(sources, labels) {
${report.name}
EPG host: ${report.epg_host || '—'}${report.m3u_host ? ' · M3U host: ' + report.m3u_host : ''}
- ${epgHealthy ? 'Healthy XMLTV' : 'Check source'} + ${epgHealthy ? `${qualityLabel(score)} XMLTV · ${score}%` : 'Check source'}

XMLTV

Status
${statusHtml(epgHealthy, labels)}
+
Quality score
${epgHealthy ? `${score}% · ${qualityLabel(score)}` : '—'}
URL
${report.epg_url}
HTTP
${report.epg_fetch.status}${report.epg_fetch.error ? ' · ' + report.epg_fetch.error : ''}
Response time
${Number(report.epg_fetch.time_total).toFixed(2)} s
diff --git a/locales/en.json b/locales/en.json index 2dc5931..20b6243 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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", diff --git a/locales/es.json b/locales/es.json index 3f84350..f365a40 100644 --- a/locales/es.json +++ b/locales/es.json @@ -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", diff --git a/locales/fr.json b/locales/fr.json index 0094539..5356344 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -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", diff --git a/setup.php b/setup.php index bb88005..973c570 100644 --- a/setup.php +++ b/setup.php @@ -216,7 +216,7 @@ $submitted_key = trim($_POST['admin_key_input'] ?? $_POST['admin_key_hidden']
GridTV is already configured.
Enter your admin key to edit the settings.
- +
@@ -349,6 +349,41 @@ function syncPersonalM3uToggle() { } 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); +} + + +try { + localStorage.setItem(GRIDTV_ADMIN_KEY_STORAGE, ); +} catch (_) {} + diff --git a/src/css/modals.css b/src/css/modals.css index f67416f..b66b593 100644 --- a/src/css/modals.css +++ b/src/css/modals.css @@ -45,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; + } diff --git a/src/css/topbar.css b/src/css/topbar.css index 143abff..d58f4a5 100644 --- a/src/css/topbar.css +++ b/src/css/topbar.css @@ -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; } diff --git a/src/js/config.js b/src/js/config.js index 2c3fe6d..98cc3fb 100644 --- a/src/js/config.js +++ b/src/js/config.js @@ -11,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(); diff --git a/src/js/live.js b/src/js/live.js index 0bc2d8b..766becf 100644 --- a/src/js/live.js +++ b/src/js/live.js @@ -6,6 +6,7 @@ window.addEventListener('resize', () => { }); window.addEventListener('load', () => { + refreshReminderSchedules(); const savedSource = localStorage.getItem('gridtv-active-source'); if (savedSource === 'personal') { const epg = localStorage.getItem('gridtv-personal-epg'); diff --git a/src/js/m3u.js b/src/js/m3u.js index b7d8dc5..4dfa9d8 100644 --- a/src/js/m3u.js +++ b/src/js/m3u.js @@ -6,7 +6,10 @@ function slugify(str) { } async function loadM3U(url) { - if (!url) return; + if (!url) { + m3uStreams = {}; + return; + } try { let text; try { diff --git a/src/js/program.js b/src/js/program.js index c3659ed..4f49d61 100644 --- a/src/js/program.js +++ b/src/js/program.js @@ -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. diff --git a/src/js/reminders.js b/src/js/reminders.js new file mode 100644 index 0000000..b5f0051 --- /dev/null +++ b/src/js/reminders.js @@ -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(); +} diff --git a/src/js/search.js b/src/js/search.js index 6ea8cbd..5ff54ce 100644 --- a/src/js/search.js +++ b/src/js/search.js @@ -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; - 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 programMatchesQuery(p, q) { + if (!q) return true; + if (p.title.toLowerCase().includes(q)) return true; + if (p.desc && p.desc.toLowerCase().includes(q)) return true; + if ((p.categories || []).some(cat => cat.toLowerCase().includes(q))) return true; + return false; } -function renderFiltered(filteredChannels, q) { +function programMatchesCategory(p, category) { + if (!category) return true; + const key = normalizeCategory(category); + return (p.categories || []).some(cat => normalizeCategory(cat) === key); +} + +function getFilteredChannels(q, category) { + const qNorm = (q || '').trim().toLowerCase(); + const nowStart = new Date(GRID_START); + const nowEnd = new Date(GRID_END); + + return channels.filter(ch => { + const channelMatchesQuery = qNorm ? ch.name.toLowerCase().includes(qNorm) : false; + const items = programs[ch.id] || []; + const matchingProgram = items.some(p => { + if (p.stop < nowStart || p.start > nowEnd) return false; + if (!programMatchesCategory(p, category)) return false; + if (!qNorm) return true; + return programMatchesQuery(p, qNorm); + }); + if (category) return matchingProgram; + return channelMatchesQuery || matchingProgram || (!qNorm && !category); + }); +} + +function applyFilters() { + const q = searchQuery; + const category = currentCategoryFilter; + if (!q && !category) { + renderUnfiltered(); + return; + } + renderFiltered(getFilteredChannels(q, category), q, category); +} + +function setCategoryFilter(value) { + currentCategoryFilter = value || ''; + const select = document.getElementById('categoryFilter'); + if (select && select.value !== currentCategoryFilter) select.value = currentCategoryFilter; + applyFilters(); +} + +function refreshCategoryFilterOptions() { + const select = document.getElementById('categoryFilter'); + if (!select) return; + const previous = currentCategoryFilter; + select.innerHTML = ''; + const baseOption = document.createElement('option'); + baseOption.value = ''; + baseOption.textContent = L.category_all || 'All categories'; + select.appendChild(baseOption); + availableCategories.forEach(category => { + const option = document.createElement('option'); + option.value = category; + option.textContent = category; + select.appendChild(option); + }); + if (previous && availableCategories.some(category => normalizeCategory(category) === normalizeCategory(previous))) { + select.value = availableCategories.find(category => normalizeCategory(category) === normalizeCategory(previous)) || ''; + currentCategoryFilter = select.value; + } else { + currentCategoryFilter = ''; + select.value = ''; + } +} + +function renderFiltered(filteredChannels, q, category) { if (isMobile()) { - renderMobileFiltered(filteredChannels, q); + renderMobileFiltered(filteredChannels, q, category); return; } @@ -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 = - p.title.toLowerCase().includes(q) || - (p.desc && p.desc.toLowerCase().includes(q)) || - (p.categories || []).some(cat => cat.toLowerCase().includes(q)); + !!q && ( + p.title.toLowerCase().includes(q) || + (p.desc && p.desc.toLowerCase().includes(q)) || + (p.categories || []).some(cat => cat.toLowerCase().includes(q)) + ); if (matchesProg) block.classList.add('search-match'); + if (category && !matchesProg && !channelMatchesQuery) block.classList.add('search-match'); if (p.stop < now) block.classList.add('is-past'); if (p.start <= now && p.stop > now) { @@ -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 = - p.title.toLowerCase().includes(q) || - (p.desc && p.desc.toLowerCase().includes(q)) || - (p.categories || []).some(cat => cat.toLowerCase().includes(q)); + !!q && ( + p.title.toLowerCase().includes(q) || + (p.desc && p.desc.toLowerCase().includes(q)) || + (p.categories || []).some(cat => cat.toLowerCase().includes(q)) + ); const item = document.createElement('div'); - item.className = 'mobile-program' + (isLive ? ' is-live' : '') + (isPast ? ' is-past' : '') + (matchesProg ? ' search-match' : ''); + item.className = 'mobile-program' + (isLive ? ' is-live' : '') + (isPast ? ' is-past' : '') + (matchesProg || (category && !channelMatchesQuery) ? ' search-match' : ''); if (isLive) { const bar = document.createElement('div'); bar.className = 'mobile-program-progress'; diff --git a/src/js/utils.js b/src/js/utils.js index 7717d1b..4064960 100644 --- a/src/js/utils.js +++ b/src/js/utils.js @@ -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(); +} diff --git a/src/lib/admin.php b/src/lib/admin.php index c969a86..9e7d8d5 100644 --- a/src/lib/admin.php +++ b/src/lib/admin.php @@ -30,6 +30,6 @@ function gridtv_require_admin(array $config, string $page_title = 'Admin'): arra } http_response_code(401); - echo 'GridTV — ' . htmlspecialchars($page_title) . '
Enter your admin key to access the ' . htmlspecialchars($page_title) . ' page.
' . ($error ? '
' . htmlspecialchars($error) . '
' : '') . '
'; + echo 'GridTV — ' . htmlspecialchars($page_title) . '
Enter your admin key to access the ' . htmlspecialchars($page_title) . ' page.
' . ($error ? '
' . htmlspecialchars($error) . '
' : '') . '
'; exit; } diff --git a/src/tpl/footer.php b/src/tpl/footer.php index bd1162b..95ffdc7 100644 --- a/src/tpl/footer.php +++ b/src/tpl/footer.php @@ -3,7 +3,7 @@
+
+ +
+
+
+
+
+ + +
+
+
🎥 diff --git a/src/tpl/topbar.php b/src/tpl/topbar.php index 9a2e318..ee54cde 100644 --- a/src/tpl/topbar.php +++ b/src/tpl/topbar.php @@ -58,6 +58,9 @@ foreach ($theme_files as $file) { } ?> +
diff --git a/sw.js b/sw.js index c312579..22b21df 100644 --- a/sw.js +++ b/sw.js @@ -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; + }) + ); +});