From cce22e3f0fe6857855fd1739b88c98a5ee0169af Mon Sep 17 00:00:00 2001 From: Gogs Date: Tue, 7 Jul 2026 07:55:55 +0200 Subject: [PATCH] Search --- app/cache/.gitkeep | 0 app/config/channels.json | 4 +- app/public/assets/css/main.css | 461 +++++++++++++++++++++++++++++++++ app/public/assets/js/app.js | 137 ++++++++++ app/public/index.php | 27 ++ app/public/logos/orf1.png.svg | 4 + app/public/logos/orf2.png.svg | 4 + app/src/XmlTv.php | 95 ++++++- app/templates/day.php | 229 ++++++++++++++++ app/templates/sidebar.php | 1 + 10 files changed, 959 insertions(+), 3 deletions(-) mode change 100644 => 100755 app/cache/.gitkeep create mode 100644 app/public/logos/orf1.png.svg create mode 100644 app/public/logos/orf2.png.svg create mode 100644 app/templates/day.php diff --git a/app/cache/.gitkeep b/app/cache/.gitkeep old mode 100644 new mode 100755 diff --git a/app/config/channels.json b/app/config/channels.json index ec0bc15..31157af 100644 --- a/app/config/channels.json +++ b/app/config/channels.json @@ -2,7 +2,7 @@ "orf1.at": { "uuid": "58a965b21f48319d1a4051476667f5e2", "name": "ORF 1", - "icon": "\/logos\/orf1.png", + "icon": "\/logos\/orf1.png.svg", "country": "", "language": "", "enabled": true, @@ -13,7 +13,7 @@ "orf2.at": { "uuid": "4c9dba34a821b5b25c8e1e2b65abc4df", "name": "ORF 2", - "icon": "\/logos\/orf2.png", + "icon": "\/logos\/orf2.png.svg", "country": "", "language": "", "enabled": true, diff --git a/app/public/assets/css/main.css b/app/public/assets/css/main.css index e9f1343..f591490 100644 --- a/app/public/assets/css/main.css +++ b/app/public/assets/css/main.css @@ -401,3 +401,464 @@ button { .favorite-star { color: #facc15; } + +.page-heading { + margin-bottom: 22px; +} + +.page-heading h2 { + margin: 0; + font-size: 1.8rem; +} + +.page-heading p { + color: var(--muted); + margin: 6px 0 0; +} + +.day-toolbar { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; + margin-bottom: 22px; + flex-wrap: wrap; +} + +.day-nav { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.button-link { + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 14px; + text-decoration: none; + font-weight: 800; +} + +.button-link:hover { + background: var(--accent-soft); +} + +.day-filter { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.day-filter input, +.day-filter select { + background: #020617; + color: var(--text); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 12px; +} + +.day-list { + display: grid; + gap: 20px; +} + +.day-channel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 18px; + overflow: hidden; +} + +.day-channel-header { + background: var(--panel-2); + padding: 15px 18px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.channel-number { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 34px; + height: 26px; + margin-right: 10px; + border-radius: 999px; + background: var(--panel-3); + color: #dbeafe; + font-size: 0.85rem; + font-weight: 800; +} + +.day-programme { + display: grid; + grid-template-columns: 92px 1fr; + gap: 16px; + padding: 16px 18px; + border-top: 1px solid var(--border); +} + +.day-programme.is-current { + background: rgba(34, 197, 94, 0.12); + border-left: 5px solid var(--success); +} + +.day-programme.is-past { + opacity: 0.55; +} + +.day-time { + color: var(--muted); + font-weight: 800; + display: flex; + flex-direction: column; + gap: 4px; +} + +.day-programme-body h3 { + margin: 0; + font-size: 1.08rem; +} + +.day-programme-body p { + color: #cbd5e1; + margin: 10px 0 0; + line-height: 1.45; +} + +@media (max-width: 700px) { + .day-programme { + grid-template-columns: 1fr; + gap: 8px; + } + + .day-time { + flex-direction: row; + } + + .day-time span:first-child::after { + content: "–"; + margin-left: 8px; + } +} + +.timeline-wrap { + --minute-width: 2px; + margin-bottom: 28px; + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 18px; + background: var(--panel); +} + +.timeline { + min-width: calc(180px + 1440 * var(--minute-width)); +} + +.timeline-header, +.timeline-row { + display: grid; + grid-template-columns: 180px calc(1440 * var(--minute-width)); +} + +.timeline-header { + position: sticky; + top: 0; + z-index: 5; + background: var(--panel-2); + border-bottom: 1px solid var(--border); +} + +.timeline-channel-head, +.timeline-channel { + position: sticky; + left: 0; + z-index: 4; + background: var(--panel-2); + border-right: 1px solid var(--border); +} + +.timeline-channel-head { + padding: 12px 14px; + font-weight: 800; + color: var(--muted); +} + +.timeline-hours { + position: relative; + height: 46px; + background: + repeating-linear-gradient( + to right, + transparent 0, + transparent calc(60 * var(--minute-width) - 1px), + rgba(148, 163, 184, 0.25) calc(60 * var(--minute-width)), + rgba(148, 163, 184, 0.25) calc(60 * var(--minute-width)) + ); +} + +.timeline-hour { + position: absolute; + top: 12px; + transform: translateX(6px); + color: var(--muted); + font-size: 0.8rem; + font-weight: 800; +} + +.timeline-row { + min-height: 72px; + border-bottom: 1px solid var(--border); +} + +.timeline-channel { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px; + font-weight: 800; +} + +.timeline-programmes { + position: relative; + min-height: 72px; + background: + repeating-linear-gradient( + to right, + transparent 0, + transparent calc(60 * var(--minute-width) - 1px), + rgba(148, 163, 184, 0.12) calc(60 * var(--minute-width)), + rgba(148, 163, 184, 0.12) calc(60 * var(--minute-width)) + ); +} + +.timeline-programme { + position: absolute; + top: 9px; + left: calc(var(--start) * var(--minute-width)); + width: calc(var(--duration) * var(--minute-width)); + height: 54px; + overflow: hidden; + border-radius: 12px; + padding: 8px 10px; + background: var(--panel-3); + border: 1px solid rgba(148, 163, 184, 0.25); + color: var(--text); +} + +.timeline-programme strong { + display: block; + font-size: 0.85rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.timeline-programme span { + display: block; + margin-top: 3px; + color: var(--muted); + font-size: 0.75rem; +} + +.timeline-programme.is-current { + background: rgba(34, 197, 94, 0.22); + border-color: var(--success); +} + +.timeline-programme.is-past { + opacity: 0.55; +} + +.timeline-now-line { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + background: #ef4444; + z-index: 100; + pointer-events: none; +} + +.timeline-now-line::before { + content: ""; + position: absolute; + left: -5px; + top: -5px; + width: 12px; + height: 12px; + border-radius: 50%; + background: #ef4444; +} + + +.channel-logo { + width: 34px; + height: 24px; + object-fit: contain; + flex: 0 0 auto; + background: rgba(255, 255, 255, 0.08); + border-radius: 6px; + padding: 2px; +} + +.timeline-channel-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.timeline-programme { + cursor: pointer; + transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease; +} + +.timeline-programme:hover { + transform: scale(1.015); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.35); + z-index: 20; +} + +.timeline-channel { + justify-content: flex-start; +} + +.timeline-channel .channel-number { + margin-right: 0; +} + +.programme-modal[hidden] { + display: none; +} + +.programme-modal { + position: fixed; + inset: 0; + z-index: 999; +} + +.programme-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(2, 6, 23, 0.78); + backdrop-filter: blur(3px); +} + +.programme-modal-card { + position: relative; + width: min(620px, calc(100vw - 32px)); + margin: 10vh auto; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 22px; + padding: 28px; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.55); +} + +.programme-modal-close { + position: absolute; + top: 14px; + right: 14px; + width: 38px; + height: 38px; + padding: 0; + border-radius: 999px; + font-size: 1.5rem; + line-height: 1; +} + +.programme-modal-time { + color: var(--muted); + font-weight: 800; + margin-bottom: 8px; +} + +.programme-modal-card h2 { + margin: 0 46px 8px 0; + font-size: 1.7rem; +} + +.programme-modal-subtitle { + color: #cbd5e1; + font-size: 1.05rem; + margin-bottom: 12px; +} + +.programme-modal-category { + display: inline-block; + margin-bottom: 14px; + padding: 5px 10px; + border-radius: 999px; + background: var(--panel-3); + color: #dbeafe; + font-size: 0.85rem; + font-weight: 700; +} + +.programme-modal-category:empty, +.programme-modal-subtitle:empty { + display: none; +} + +.programme-modal-card p { + color: #dbe4ef; + line-height: 1.55; + margin: 0; +} + +.guide-search { + position: relative; + margin-bottom: 22px; +} + +.guide-search input { + width: 100%; + background: #020617; + color: var(--text); + border: 1px solid var(--border); + border-radius: 14px; + padding: 13px 16px; + font-size: 1rem; +} + +.guide-search-results { + position: absolute; + z-index: 200; + top: calc(100% + 8px); + left: 0; + right: 0; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 16px; + overflow: hidden; + box-shadow: 0 20px 50px rgba(0,0,0,.45); +} + +.guide-search-result { + padding: 13px 16px; + border-bottom: 1px solid var(--border); + cursor: pointer; +} + +.guide-search-result:hover { + background: var(--accent-soft); +} + +.guide-search-result strong { + display: block; +} + +.guide-search-result span { + display: block; + margin-top: 4px; + color: var(--muted); + font-size: .88rem; +} diff --git a/app/public/assets/js/app.js b/app/public/assets/js/app.js index b0ed84b..7b46f0c 100644 --- a/app/public/assets/js/app.js +++ b/app/public/assets/js/app.js @@ -1 +1,138 @@ +document.addEventListener("DOMContentLoaded", () => { + + const wrap = document.querySelector(".timeline-wrap"); + const line = document.querySelector(".timeline-now-line"); + + if (!wrap || !line) + return; + + wrap.scrollLeft = Math.max( + 0, + line.offsetLeft - wrap.clientWidth / 3 + ); + +}); + +document.addEventListener("DOMContentLoaded", () => { + const modal = document.getElementById("programmeModal"); + + if (!modal) { + return; + } + + const title = document.getElementById("modalTitle"); + const subtitle = document.getElementById("modalSubtitle"); + const description = document.getElementById("modalDescription"); + const category = document.getElementById("modalCategory"); + const time = document.getElementById("modalTime"); + + const openModal = (programmeElement) => { + const id = programmeElement.dataset.programmeId; + const programme = window.TVGUIDE_PROGRAMMES?.[id]; + + if (!programme) { + return; + } + + title.textContent = programme.title || ""; + subtitle.textContent = programme.subtitle || ""; + description.textContent = programme.description || "Keine Beschreibung vorhanden."; + category.textContent = programme.category || ""; + time.textContent = `${programme.channel || ""} · ${programme.start || ""} – ${programme.stop || ""}`; + + modal.hidden = false; + document.body.classList.add("modal-open"); + }; + + const closeModal = () => { + modal.hidden = true; + document.body.classList.remove("modal-open"); + }; + + document.querySelectorAll(".timeline-programme").forEach((programme) => { + programme.addEventListener("click", () => openModal(programme)); + }); + + modal.querySelectorAll("[data-modal-close]").forEach((element) => { + element.addEventListener("click", closeModal); + }); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && !modal.hidden) { + closeModal(); + } + }); +}); + +document.addEventListener("DOMContentLoaded", () => { + const search = document.getElementById("guideSearch"); + const results = document.getElementById("guideSearchResults"); + const programmes = window.TVGUIDE_PROGRAMMES || {}; + + if (!search || !results) { + return; + } + + const openProgrammeById = (id) => { + const element = document.querySelector(`[data-programme-id="${CSS.escape(id)}"]`); + if (element) { + element.click(); + } + }; + + search.addEventListener("input", () => { + const query = search.value.trim().toLowerCase(); + + if (query.length < 2) { + results.hidden = true; + results.innerHTML = ""; + return; + } + + const matches = Object.values(programmes) + .filter((programme) => { + return [ + programme.title, + programme.subtitle, + programme.description, + programme.category, + programme.channel + ].some((value) => (value || "").toLowerCase().includes(query)); + }) + .slice(0, 12); + + if (!matches.length) { + results.innerHTML = `
Nichts gefunden
`; + results.hidden = false; + return; + } + + results.innerHTML = matches.map((programme) => ` +
+ ${programme.title || "Ohne Titel"} + ${programme.channel || ""} · ${programme.start || ""} – ${programme.stop || ""} +
+ `).join(""); + + results.hidden = false; + }); + + results.addEventListener("click", (event) => { + const item = event.target.closest("[data-search-programme-id]"); + if (!item) { + return; + } + + openProgrammeById(item.dataset.searchProgrammeId); + results.hidden = true; + search.value = ""; + }); + + document.addEventListener("click", (event) => { + if (!event.target.closest(".guide-search")) { + results.hidden = true; + } + }); +}); + console.log('TVGuide loaded'); diff --git a/app/public/index.php b/app/public/index.php index a6789e4..56b785d 100644 --- a/app/public/index.php +++ b/app/public/index.php @@ -44,9 +44,31 @@ if ($view === 'channels' && $_SERVER['REQUEST_METHOD'] === 'POST') { $currentProgrammes = $xmltv->getCurrentProgrammes($config); +$selectedDate = $_GET['date'] ?? date('Y-m-d'); + +$selectedChannel = $_GET['channel'] ?? null; +if ($selectedChannel === '') { + $selectedChannel = null; +} + +if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $selectedDate)) { + $selectedDate = date('Y-m-d'); +} + +$dayProgrammes = $xmltv->getDayProgrammes($config, $selectedDate, $selectedChannel); + +$isToday = ($selectedDate === date('Y-m-d')); + +$nowMinute = null; + +if ($isToday) { + $nowMinute = ((int)date('H') * 60) + (int)date('i'); +} + $contentTemplate = match ($view) { 'channels' => '/var/www/templates/channels.php', 'now' => '/var/www/templates/now.php', + 'day' => '/var/www/templates/day.php', default => '/var/www/templates/home.php', }; @@ -59,4 +81,9 @@ View::render('layout', [ 'channelConfig' => $config->getChannels(), 'saved' => isset($_GET['saved']), 'currentProgrammes' => $currentProgrammes, + 'selectedDate' => $selectedDate, + 'selectedChannel' => $selectedChannel, + 'dayProgrammes' => $dayProgrammes, + 'isToday' => $isToday, + 'nowMinute' => $nowMinute, ]); diff --git a/app/public/logos/orf1.png.svg b/app/public/logos/orf1.png.svg new file mode 100644 index 0000000..b7d15f7 --- /dev/null +++ b/app/public/logos/orf1.png.svg @@ -0,0 +1,4 @@ + + + ORF 1 + diff --git a/app/public/logos/orf2.png.svg b/app/public/logos/orf2.png.svg new file mode 100644 index 0000000..882bddb --- /dev/null +++ b/app/public/logos/orf2.png.svg @@ -0,0 +1,4 @@ + + + ORF 2a + diff --git a/app/src/XmlTv.php b/app/src/XmlTv.php index fc5014c..a99699f 100644 --- a/app/src/XmlTv.php +++ b/app/src/XmlTv.php @@ -43,7 +43,6 @@ class XmlTv ]; } else { $storedChannels[$id]['name'] = $channel['name'] ?? $storedChannels[$id]['name']; - $storedChannels[$id]['icon'] = $channel['icon'] ?? ($storedChannels[$id]['icon'] ?? ''); } } @@ -211,6 +210,7 @@ class XmlTv $items[] = [ 'channel_id' => $id, 'channel_name' => $cfg['name'] ?? $channel['name'] ?? $id, + 'channel_icon' => $cfg['icon'] ?? $channel['icon'] ?? '', 'channel_number' => (int)($cfg['number'] ?? 999999), 'favorite' => (bool)($cfg['favorite'] ?? false), 'title' => $p['title'] ?? '', @@ -258,4 +258,97 @@ class XmlTv $dt->setTimezone(new DateTimeZone($this->timezone)); return $dt; } + + public function getDayProgrammes(Config $config, string $date, ?string $channelId = null): array + { + $data = $this->getCachedData(); + $channelConfig = $config->getChannels(); + + $tz = new DateTimeZone($this->timezone); + $dayStart = new DateTime($date . ' 00:00:00', $tz); + $dayEnd = clone $dayStart; + $dayEnd->modify('+1 day'); + + $startTs = $dayStart->getTimestamp(); + $endTs = $dayEnd->getTimestamp(); + + $items = []; + + foreach ($data['channels'] ?? [] as $id => $channel) { + if ($channelId !== null && $id !== $channelId) { + continue; + } + + $cfg = $channelConfig[$id] ?? []; + + if (($cfg['enabled'] ?? true) !== true) { + continue; + } + + $programmes = []; + + foreach ($channel['programmes'] ?? [] as $p) { + $start = (int)($p['start'] ?? 0); + $stop = (int)($p['stop'] ?? 0); + + if ($start < $endTs && $stop > $startTs) { + + $visibleStart = max($start, $startTs); + $visibleStop = min($stop, $endTs); + + $startDt = (new DateTime())->setTimestamp($visibleStart)->setTimezone($tz); + $stopDt = (new DateTime())->setTimestamp($visibleStop)->setTimezone($tz); + + $startMinute = ((int)$startDt->format('H') * 60) + (int)$startDt->format('i'); + $stopMinute = ((int)$stopDt->format('H') * 60) + (int)$stopDt->format('i'); + + if ($visibleStop >= $endTs) { + $stopMinute = 1440; + } + + $durationMinutes = max(1, (int)(($visibleStop - $visibleStart) / 60)); + + $programmes[] = [ + 'id' => md5($id . '|' . $start . '|' . $stop . '|' . ($p['title'] ?? '')), + 'title' => $p['title'] ?? '', + 'subtitle' => $p['subtitle'] ?? '', + 'description' => $p['description'] ?? '', + 'category' => $p['category'] ?? '', + 'start' => $start, + 'stop' => $stop, + 'visible_start' => $visibleStart, + 'visible_stop' => $visibleStop, + 'start_minute' => $startMinute, + 'stop_minute' => $stopMinute, + 'duration_minutes' => $durationMinutes, + 'is_current' => time() >= $start && time() < $stop, + 'is_past' => $stop < time(), + ]; + } + } + + if (!$programmes) { + continue; + } + + usort($programmes, function ($a, $b) { + return $a['start'] <=> $b['start']; + }); + + $items[] = [ + 'channel_id' => $id, + 'channel_name' => $cfg['name'] ?? $channel['name'] ?? $id, + 'channel_icon' => $cfg['icon'] ?? $channel['icon'] ?? '', + 'channel_number' => (int)($cfg['number'] ?? 999999), + 'favorite' => (bool)($cfg['favorite'] ?? false), + 'programmes' => $programmes, + ]; + } + + usort($items, function ($a, $b) { + return $a['channel_number'] <=> $b['channel_number']; + }); + + return $items; + } } diff --git a/app/templates/day.php b/app/templates/day.php new file mode 100644 index 0000000..f3b4d1e --- /dev/null +++ b/app/templates/day.php @@ -0,0 +1,229 @@ +modify('-1 day')->format('Y-m-d'); +$nextDate = (clone $dateObj)->modify('+1 day')->format('Y-m-d'); +$today = (new DateTime('today', $tz))->format('Y-m-d'); + +$wochentage = [ + 'Monday' => 'Montag', + 'Tuesday' => 'Dienstag', + 'Wednesday' => 'Mittwoch', + 'Thursday' => 'Donnerstag', + 'Friday' => 'Freitag', + 'Saturday' => 'Samstag', + 'Sunday' => 'Sonntag', +]; + +$monate = [ + 1 => 'Jänner', + 2 => 'Februar', + 3 => 'März', + 4 => 'April', + 5 => 'Mai', + 6 => 'Juni', + 7 => 'Juli', + 8 => 'August', + 9 => 'September', + 10 => 'Oktober', + 11 => 'November', + 12 => 'Dezember', +]; + +$anzeigeDatum = + $wochentage[$dateObj->format('l')] . ', ' . + $dateObj->format('j') . '. ' . + $monate[(int)$dateObj->format('n')] . ' ' . + $dateObj->format('Y'); + +$programmeIndex = []; + +foreach ($dayProgrammes as $channel) { + foreach ($channel['programmes'] as $programme) { + $programmeIndex[$programme['id']] = [ + 'id' => $programme['id'], + 'channel' => $channel['channel_name'], + 'title' => $programme['title'], + 'subtitle' => $programme['subtitle'], + 'description' => $programme['description'], + 'category' => $programme['category'], + 'start' => date('H:i', $programme['start']), + 'stop' => date('H:i', $programme['stop']), + ]; + } +} +?> + +
+

TV Guide

+

+
+ +
+ + +
+ + + + + + +
+
+ + + + +
Für diesen Tag wurden keine Sendungen gefunden.
+ + + +
+
+
+
Sender
+ +
+ +
+ :00 +
+ +
+
+ + +
+ +
+ + + + + + + + + + + +
+ +
+ +
+
+ + + +
+ + - +
+ +
+
+ +
+
+ + +
+ +
+
+
+ + + + + + + + + +
+
+ + +
+
+ + +
+ +
+

+ + +
+ + + + + + + +

+ +
+
+ +
+ +
+ + diff --git a/app/templates/sidebar.php b/app/templates/sidebar.php index 56ff62e..9aef069 100644 --- a/app/templates/sidebar.php +++ b/app/templates/sidebar.php @@ -1,6 +1,7 @@