diff --git a/app/public/assets/css/layout.css b/app/public/assets/css/layout.css
index b04df36..83fed04 100644
--- a/app/public/assets/css/layout.css
+++ b/app/public/assets/css/layout.css
@@ -52,6 +52,221 @@
min-width: 0;
}
+.header-search-wrapper {
+ position: relative;
+ width: min(500px, 100%);
+}
+
+.header-search {
+ width: 100%;
+ min-height: 42px;
+
+ background: #020617;
+ color: var(--text);
+
+ border: 1px solid var(--border);
+ border-radius: 999px;
+
+ padding: 10px 18px;
+
+ font: inherit;
+ outline: none;
+
+ transition:
+ border-color 0.18s ease,
+ box-shadow 0.18s ease,
+ background 0.18s ease;
+}
+
+.header-search:hover {
+ border-color: #475569;
+}
+
+.header-search:focus {
+ background: #07101f;
+ border-color: var(--accent);
+ box-shadow:
+ 0 0 0 3px var(--accent-soft),
+ 0 12px 30px rgba(0, 0, 0, 0.22);
+}
+
+.header-search::placeholder {
+ color: var(--muted);
+}
+
+.guide-search-results {
+ position: absolute;
+ top: calc(100% + 10px);
+ left: 0;
+ right: 0;
+
+ max-height: min(520px, calc(100vh - 120px));
+ overflow-y: auto;
+ overscroll-behavior: contain;
+
+ padding: 7px;
+
+ background: rgba(15, 23, 42, 0.98);
+ backdrop-filter: blur(18px);
+
+ border: 1px solid var(--border);
+ border-radius: 16px;
+
+ box-shadow:
+ 0 22px 60px rgba(0, 0, 0, 0.45),
+ 0 4px 14px rgba(0, 0, 0, 0.25);
+
+ z-index: 300;
+}
+
+.guide-search-results[hidden] {
+ display: none;
+}
+
+.guide-search-result {
+ width: 100%;
+
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+
+ padding: 11px 12px;
+
+ color: var(--text);
+ background: transparent;
+
+ border: 0;
+ border-radius: 11px;
+
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+
+ transition:
+ background 0.14s ease,
+ transform 0.14s ease;
+}
+
+.guide-search-result:hover,
+.guide-search-result.is-active {
+ background: rgba(59, 130, 246, 0.16);
+}
+
+.guide-search-result:active {
+ transform: scale(0.992);
+}
+
+.guide-search-result.is-current {
+ background: rgba(34, 197, 94, 0.08);
+}
+
+.guide-search-result.is-current:hover,
+.guide-search-result.is-current.is-active {
+ background: rgba(34, 197, 94, 0.17);
+}
+
+.guide-search-type-icon {
+ flex: 0 0 auto;
+ width: 24px;
+ padding-top: 1px;
+
+ font-size: 1rem;
+ line-height: 1.3;
+ text-align: center;
+}
+
+.guide-search-result-content {
+ min-width: 0;
+
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.guide-search-result-content strong {
+ overflow: hidden;
+
+ color: var(--text);
+ font-size: 0.95rem;
+ font-weight: 700;
+
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.guide-search-result-content > span {
+ overflow: hidden;
+
+ color: var(--muted);
+ font-size: 0.8rem;
+
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.guide-search-result-content .guide-search-subtitle {
+ color: #cbd5e1;
+ font-size: 0.84rem;
+}
+
+.guide-search-channel {
+ border-bottom: 1px solid rgba(51, 65, 85, 0.55);
+}
+
+.guide-search-message {
+ padding: 18px 14px;
+
+ color: var(--muted);
+ font-size: 0.9rem;
+ text-align: center;
+}
+
+.guide-search-message.is-loading::before {
+ content: "";
+ display: inline-block;
+
+ width: 13px;
+ height: 13px;
+ margin-right: 9px;
+
+ vertical-align: -2px;
+
+ border: 2px solid rgba(148, 163, 184, 0.35);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+
+ animation: guide-search-spin 0.7s linear infinite;
+}
+
+.guide-search-message.is-error {
+ color: #fca5a5;
+}
+
+.search-channel-highlight {
+ animation: search-channel-highlight 1.6s ease;
+}
+
+@keyframes guide-search-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes search-channel-highlight {
+ 0%,
+ 100% {
+ box-shadow: none;
+ }
+
+ 25%,
+ 70% {
+ box-shadow:
+ inset 0 0 0 2px var(--accent),
+ 0 0 24px var(--accent-soft);
+ }
+}
+
.header-search {
width: min(500px, 100%);
background: #020617;
@@ -135,6 +350,14 @@
flex-basis: 100%;
}
+ .header-search-wrapper {
+ width: 100%;
+ }
+
+ .guide-search-results {
+ max-height: min(430px, calc(100vh - 190px));
+ }
+
.header-search {
width: 100%;
}
diff --git a/app/public/assets/js/modules/modal.js b/app/public/assets/js/modules/modal.js
index c0c62d6..204ba25 100644
--- a/app/public/assets/js/modules/modal.js
+++ b/app/public/assets/js/modules/modal.js
@@ -14,10 +14,7 @@ export function initProgrammeModal() {
const logo = document.getElementById("modalLogo");
const channel = document.getElementById("modalChannel");
- const openModal = (programmeElement) => {
- const id = programmeElement.dataset.programmeId;
- const programme = window.TVGUIDE_PROGRAMMES?.[id];
-
+ const openProgramme = (programme) => {
if (!programme) {
return;
}
@@ -25,77 +22,129 @@ export function initProgrammeModal() {
const metadata = programme.metadata || {};
title.textContent = programme.title || "";
+
subtitle.textContent =
- metadata.episode_title ||
- programme.subtitle ||
- "";
- description.textContent = programme.description || "Keine Beschreibung vorhanden.";
+ metadata.episode_title ||
+ programme.subtitle ||
+ "";
+
+ subtitle.hidden = !subtitle.textContent;
+
+ description.textContent =
+ programme.description ||
+ "Keine Beschreibung vorhanden.";
+
category.textContent = programme.category || "";
+ category.hidden = !category.textContent;
- channel.textContent =
- `Kanal ${programme.channel_number} · ${programme.channel}`;
+ const channelName =
+ programme.channel ||
+ programme.channel_name ||
+ "";
- logo.src = `/assets/logos/${encodeURIComponent(programme.channel_id)}.svg`;
+ const channelNumber = programme.channel_number;
+
+ channel.textContent = channelNumber && channelNumber !== 999999
+ ? `Kanal ${channelNumber} · ${channelName}`
+ : channelName;
+
+ logo.src =
+ `/assets/logos/${encodeURIComponent(programme.channel_id || "")}.svg`;
logo.onerror = () => {
- logo.onerror = null;
- logo.src = "/assets/logos/default.svg";
+ logo.onerror = null;
+ logo.src = "/assets/logos/default.svg";
};
const metaItems = [];
if (metadata.season && metadata.episode) {
- let text = `Staffel ${metadata.season} · Episode ${metadata.episode}`;
+ let text =
+ `Staffel ${metadata.season} · Episode ${metadata.episode}`;
- if (metadata.episode_total) {
- text += `/${metadata.episode_total}`;
- }
+ if (metadata.episode_total) {
+ text += `/${metadata.episode_total}`;
+ }
- metaItems.push(text);
+ metaItems.push(text);
}
if (metadata.country) {
- metaItems.push(metadata.country);
+ metaItems.push(metadata.country);
+ } else if (programme.country) {
+ metaItems.push(programme.country);
}
if (metadata.year) {
- metaItems.push(metadata.year);
+ metaItems.push(metadata.year);
+ } else if (programme.year) {
+ metaItems.push(programme.year);
}
if (metadata.age_rating) {
- metaItems.push(`FSK ${metadata.age_rating}`);
+ metaItems.push(`FSK ${metadata.age_rating}`);
+ } else if (programme.rating) {
+ metaItems.push(`FSK ${programme.rating}`);
}
if (Array.isArray(metadata.genres) && metadata.genres.length) {
- metaItems.push(metadata.genres.join(", "));
+ metaItems.push(metadata.genres.join(", "));
}
meta.textContent = metaItems.join(" • ");
meta.hidden = metaItems.length === 0;
- time.textContent = `${programme.start} – ${programme.stop}`;
+ const start =
+ programme.start_time ||
+ programme.start ||
+ "";
+
+ const stop =
+ programme.stop_time ||
+ programme.stop ||
+ "";
+
+ time.textContent = start && stop
+ ? `${start} – ${stop}`
+ : "";
modal.hidden = false;
document.body.classList.add("modal-open");
};
+ const openProgrammeElement = (programmeElement) => {
+ const id = programmeElement.dataset.programmeId;
+ const programme = window.TVGUIDE_PROGRAMMES?.[id];
+
+ openProgramme(programme);
+ };
+
const closeModal = () => {
modal.hidden = true;
meta.textContent = "";
document.body.classList.remove("modal-open");
};
+ /*
+ * Öffentliche Schnittstelle für die globale Suche.
+ */
+ window.TVGuideProgrammeModal = {
+ open: openProgramme,
+ close: closeModal
+ };
+
document.addEventListener("click", (event) => {
- const programme = event.target.closest(".timeline-programme, .dashboard-programme");
+ const programme = event.target.closest(
+ ".timeline-programme, .dashboard-programme"
+ );
if (!programme) {
return;
}
- openModal(programme);
+ openProgrammeElement(programme);
});
-
modal.querySelectorAll("[data-modal-close]").forEach((element) => {
element.addEventListener("click", closeModal);
});
diff --git a/app/public/assets/js/modules/search.js b/app/public/assets/js/modules/search.js
index a69b5e7..348e248 100644
--- a/app/public/assets/js/modules/search.js
+++ b/app/public/assets/js/modules/search.js
@@ -1,72 +1,424 @@
export function initGuideSearch() {
+ const wrapper = document.querySelector("[data-global-search]");
const search = document.getElementById("guideSearch");
const results = document.getElementById("guideSearchResults");
- const programmes = window.TVGUIDE_PROGRAMMES || {};
- if (!search || !results) {
+ if (!wrapper || !search || !results) {
return;
}
- const openProgrammeById = (id) => {
- const element = document.querySelector(`[data-programme-id="${CSS.escape(id)}"]`);
+ let debounceTimer = null;
+ let requestController = null;
+ let activeIndex = -1;
+ let currentItems = [];
- if (element) {
- element.click();
- }
+ const escapeText = (value) => String(value ?? "");
+
+ const closeResults = () => {
+ results.hidden = true;
+ results.innerHTML = "";
+ search.setAttribute("aria-expanded", "false");
+
+ currentItems = [];
+ activeIndex = -1;
};
- 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("");
-
+ const openResults = () => {
results.hidden = false;
- });
+ search.setAttribute("aria-expanded", "true");
+ };
- results.addEventListener("click", (event) => {
- const item = event.target.closest("[data-search-programme-id]");
+ const setMessage = (message, className = "") => {
+ results.innerHTML = "";
+
+ const element = document.createElement("div");
+ element.className = `guide-search-message ${className}`.trim();
+ element.textContent = message;
+
+ results.appendChild(element);
+ openResults();
+
+ currentItems = [];
+ activeIndex = -1;
+ };
+
+ const getResultElements = () => {
+ return Array.from(
+ results.querySelectorAll("[data-search-index]")
+ );
+ };
+
+ const updateActiveResult = () => {
+ const elements = getResultElements();
+
+ elements.forEach((element, index) => {
+ const active = index === activeIndex;
+
+ element.classList.toggle("is-active", active);
+ element.setAttribute(
+ "aria-selected",
+ active ? "true" : "false"
+ );
+
+ if (active) {
+ element.scrollIntoView({
+ block: "nearest"
+ });
+ }
+ });
+ };
+
+ const selectResult = (index) => {
+ const item = currentItems[index];
if (!item) {
return;
}
- openProgrammeById(item.dataset.searchProgrammeId);
- results.hidden = true;
- search.value = "";
+ if (item.type === "programme") {
+ window.TVGuideProgrammeModal?.open(item);
+
+ search.value = "";
+ closeResults();
+ return;
+ }
+
+ if (item.type === "channel") {
+ openChannel(item);
+ }
+ };
+
+ const openChannel = (item) => {
+ const channelId = String(item.channel_id || "");
+
+ /*
+ * Auf der Tagesansicht zuerst versuchen, den vorhandenen Sender
+ * direkt anzuspringen.
+ */
+ const channelElement =
+ document.querySelector(
+ `[data-channel-id="${CSS.escape(channelId)}"]`
+ ) ||
+ document.querySelector(
+ `[data-timeline-channel="${CSS.escape(channelId)}"]`
+ );
+
+ if (channelElement) {
+ channelElement.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ inline: "start"
+ });
+
+ channelElement.classList.add("search-channel-highlight");
+
+ window.setTimeout(() => {
+ channelElement.classList.remove("search-channel-highlight");
+ }, 1600);
+
+ search.value = "";
+ closeResults();
+ return;
+ }
+
+ /*
+ * Falls der Sender auf der aktuellen Ansicht nicht im DOM liegt,
+ * Tagesansicht mit Senderfilter öffnen.
+ */
+ window.location.href =
+ `/?view=day&channel=${encodeURIComponent(channelId)}`;
+ };
+
+ const createChannelResult = (item, index) => {
+ const button = document.createElement("button");
+
+ button.type = "button";
+ button.className =
+ "guide-search-result guide-search-channel";
+
+ button.dataset.searchIndex = String(index);
+ button.setAttribute("role", "option");
+ button.setAttribute("aria-selected", "false");
+
+ const icon = document.createElement("span");
+ icon.className = "guide-search-type-icon";
+ icon.textContent = "📺";
+
+ const content = document.createElement("span");
+ content.className = "guide-search-result-content";
+
+ const heading = document.createElement("strong");
+ heading.textContent =
+ escapeText(item.channel_name || "Unbekannter Sender");
+
+ const details = document.createElement("span");
+
+ const number =
+ item.channel_number &&
+ item.channel_number !== 999999
+ ? `Kanal ${item.channel_number}`
+ : "Sender";
+
+ details.textContent =
+ item.favorite
+ ? `${number} · Favorit ⭐`
+ : number;
+
+ content.append(heading, details);
+ button.append(icon, content);
+
+ return button;
+ };
+
+ const createProgrammeResult = (item, index) => {
+ const button = document.createElement("button");
+
+ button.type = "button";
+ button.className =
+ "guide-search-result guide-search-programme";
+
+ if (item.is_current) {
+ button.classList.add("is-current");
+ }
+
+ button.dataset.searchIndex = String(index);
+ button.setAttribute("role", "option");
+ button.setAttribute("aria-selected", "false");
+
+ const icon = document.createElement("span");
+ icon.className = "guide-search-type-icon";
+ icon.textContent = item.is_current ? "🔴" : "🎬";
+
+ const content = document.createElement("span");
+ content.className = "guide-search-result-content";
+
+ const heading = document.createElement("strong");
+ heading.textContent =
+ escapeText(item.title || "Ohne Titel");
+
+ const details = document.createElement("span");
+
+ const parts = [
+ item.channel_name || "",
+ item.start_time && item.stop_time
+ ? `${item.start_time}–${item.stop_time}`
+ : ""
+ ];
+
+ if (item.is_current) {
+ parts.push("läuft jetzt");
+ }
+
+ details.textContent = parts.filter(Boolean).join(" · ");
+
+ content.append(heading);
+
+ if (item.subtitle) {
+ const subtitle = document.createElement("span");
+ subtitle.className = "guide-search-subtitle";
+ subtitle.textContent = escapeText(item.subtitle);
+ content.append(subtitle);
+ }
+
+ content.append(details);
+ button.append(icon, content);
+
+ return button;
+ };
+
+ const renderResults = (items) => {
+ results.innerHTML = "";
+ currentItems = items;
+ activeIndex = -1;
+
+ if (!items.length) {
+ setMessage(
+ "Keine passenden Sender oder Sendungen gefunden."
+ );
+ return;
+ }
+
+ items.forEach((item, index) => {
+ const element =
+ item.type === "channel"
+ ? createChannelResult(item, index)
+ : createProgrammeResult(item, index);
+
+ results.appendChild(element);
+ });
+
+ openResults();
+ };
+
+ const performSearch = async () => {
+ const query = search.value.trim();
+
+ if (query.length < 2) {
+ closeResults();
+ return;
+ }
+
+ if (requestController) {
+ requestController.abort();
+ }
+
+ requestController = new AbortController();
+
+ setMessage("Suche läuft …", "is-loading");
+
+ try {
+ const response = await fetch(
+ `/?api=search&q=${encodeURIComponent(query)}&limit=20`,
+ {
+ method: "GET",
+ cache: "no-store",
+ signal: requestController.signal,
+ headers: {
+ Accept: "application/json"
+ }
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ /*
+ * Verhindert, dass ein älteres Ergebnis nach einer inzwischen
+ * geänderten Eingabe angezeigt wird.
+ */
+ if (search.value.trim() !== query) {
+ return;
+ }
+
+ if (!data.ok) {
+ throw new Error(data.error || "Unbekannter Fehler");
+ }
+
+ renderResults(
+ Array.isArray(data.results)
+ ? data.results
+ : []
+ );
+ } catch (error) {
+ if (error.name === "AbortError") {
+ return;
+ }
+
+ console.error("Programmsuche fehlgeschlagen:", error);
+
+ setMessage(
+ "Die Suche konnte gerade nicht geladen werden.",
+ "is-error"
+ );
+ }
+ };
+
+ search.addEventListener("input", () => {
+ window.clearTimeout(debounceTimer);
+
+ const query = search.value.trim();
+
+ if (query.length < 2) {
+ if (requestController) {
+ requestController.abort();
+ }
+
+ closeResults();
+ return;
+ }
+
+ debounceTimer = window.setTimeout(
+ performSearch,
+ 300
+ );
+ });
+
+ search.addEventListener("focus", () => {
+ if (
+ search.value.trim().length >= 2 &&
+ results.children.length
+ ) {
+ openResults();
+ }
+ });
+
+ search.addEventListener("keydown", (event) => {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ closeResults();
+ search.blur();
+ return;
+ }
+
+ if (results.hidden || !currentItems.length) {
+ return;
+ }
+
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+
+ activeIndex =
+ activeIndex < currentItems.length - 1
+ ? activeIndex + 1
+ : 0;
+
+ updateActiveResult();
+ return;
+ }
+
+ if (event.key === "ArrowUp") {
+ event.preventDefault();
+
+ activeIndex =
+ activeIndex > 0
+ ? activeIndex - 1
+ : currentItems.length - 1;
+
+ updateActiveResult();
+ return;
+ }
+
+ if (event.key === "Enter") {
+ if (activeIndex < 0) {
+ activeIndex = 0;
+ }
+
+ event.preventDefault();
+ selectResult(activeIndex);
+ }
+ });
+
+ results.addEventListener("mousemove", (event) => {
+ const element =
+ event.target.closest("[data-search-index]");
+
+ if (!element) {
+ return;
+ }
+
+ activeIndex = Number(element.dataset.searchIndex);
+ updateActiveResult();
+ });
+
+ results.addEventListener("click", (event) => {
+ const element =
+ event.target.closest("[data-search-index]");
+
+ if (!element) {
+ return;
+ }
+
+ selectResult(
+ Number(element.dataset.searchIndex)
+ );
});
document.addEventListener("click", (event) => {
- if (!event.target.closest(".guide-search")) {
- results.hidden = true;
+ if (!wrapper.contains(event.target)) {
+ closeResults();
}
});
}
diff --git a/app/src/Controllers/HomeController.php b/app/src/Controllers/HomeController.php
index a516b37..5bfcc1f 100644
--- a/app/src/Controllers/HomeController.php
+++ b/app/src/Controllers/HomeController.php
@@ -11,6 +11,11 @@ class HomeController
public function handle(array $get, array $post, string $method): void
{
+ if (($get['api'] ?? '') === 'search') {
+ $this->handleSearchApi($get, $method);
+ }
+
+
$this->xmltv->synchronizeChannels($this->config);
$view = $get['view'] ?? $this->config->get('default_view', 'today');
@@ -80,4 +85,44 @@ class HomeController
'nowMinute' => $nowMinute,
]);
}
+
+ private function handleSearchApi(array $get, string $method): void
+ {
+ if ($method !== 'GET') {
+ ApiResponse::error(
+ 'Für die Programmsuche ist nur GET erlaubt.',
+ 405
+ );
+ }
+
+ $query = trim((string)($get['q'] ?? ''));
+
+ if (mb_strlen($query) < 2) {
+ ApiResponse::json([
+ 'ok' => true,
+ 'query' => $query,
+ 'count' => 0,
+ 'results' => [],
+ ]);
+ }
+
+ $limit = isset($get['limit'])
+ ? (int)$get['limit']
+ : 20;
+
+ $limit = max(1, min(50, $limit));
+
+ $results = $this->xmltv->search(
+ $this->config,
+ $query,
+ $limit
+ );
+
+ ApiResponse::json([
+ 'ok' => true,
+ 'query' => $query,
+ 'count' => count($results),
+ 'results' => $results,
+ ]);
+ }
}
diff --git a/app/src/Http/ApiResponse.php b/app/src/Http/ApiResponse.php
deleted file mode 100644
index 4ecee58..0000000
--- a/app/src/Http/ApiResponse.php
+++ /dev/null
@@ -1,25 +0,0 @@
- false,
- 'error' => $message,
- ], $statusCode);
- }
-}
diff --git a/app/src/Services/ProgrammeService.php b/app/src/Services/ProgrammeService.php
index 3bf252c..542cc49 100644
--- a/app/src/Services/ProgrammeService.php
+++ b/app/src/Services/ProgrammeService.php
@@ -213,6 +213,146 @@ class ProgrammeService
return $items;
}
+ public function search(
+ Config $config,
+ array $data,
+ string $query,
+ int $limit = 20
+ ): array {
+ $query = trim($query);
+ $limit = max(1, min(50, $limit));
+
+ if (mb_strlen($query) < 2) {
+ return [];
+ }
+
+ $channelConfig = $config->getChannels();
+ $tz = new DateTimeZone($this->timezone);
+
+ $dayStart = new DateTime('today', $tz);
+ $dayEnd = clone $dayStart;
+ $dayEnd->modify('+1 day');
+
+ $startTs = $dayStart->getTimestamp();
+ $endTs = $dayEnd->getTimestamp();
+
+ $channelResults = [];
+ $programmeResults = [];
+
+ foreach ($data['channels'] ?? [] as $channelId => $channel) {
+ $cfg = $channelConfig[$channelId] ?? [];
+
+ if (($cfg['enabled'] ?? true) !== true) {
+ continue;
+ }
+
+ $channelName = $cfg['name'] ?? $channel['name'] ?? $channelId;
+ $channelNumber = (int)($cfg['number'] ?? 999999);
+ $channelIcon = $cfg['icon'] ?? $channel['icon'] ?? '';
+ $favorite = (bool)($cfg['favorite'] ?? false);
+
+ if (mb_stripos($channelName, $query) !== false) {
+ $channelResults[] = [
+ 'type' => 'channel',
+ 'channel_id' => $channelId,
+ 'channel_name' => $channelName,
+ 'channel_number' => $channelNumber,
+ 'channel_icon' => $channelIcon,
+ 'favorite' => $favorite,
+ ];
+ }
+
+ foreach ($channel['programmes'] ?? [] as $programme) {
+ $start = (int)($programme['start'] ?? 0);
+ $stop = (int)($programme['stop'] ?? 0);
+
+ // Nur Sendungen, die den heutigen Tag berühren
+ if ($start >= $endTs || $stop <= $startTs) {
+ continue;
+ }
+
+ $title = trim((string)($programme['title'] ?? ''));
+ $subtitle = trim((string)($programme['subtitle'] ?? ''));
+
+ $matches =
+ mb_stripos($title, $query) !== false ||
+ mb_stripos($subtitle, $query) !== false;
+
+ if (!$matches) {
+ continue;
+ }
+
+ $startDateTime = (new DateTime())
+ ->setTimestamp($start)
+ ->setTimezone($tz);
+
+ $stopDateTime = (new DateTime())
+ ->setTimestamp($stop)
+ ->setTimezone($tz);
+
+ $programmeResults[] = [
+ 'type' => 'programme',
+ 'id' => md5(
+ $channelId . '|' .
+ $start . '|' .
+ $stop . '|' .
+ $title
+ ),
+
+ 'channel_id' => $channelId,
+ 'channel_name' => $channelName,
+ 'channel_number' => $channelNumber,
+ 'channel_icon' => $channelIcon,
+ 'favorite' => $favorite,
+
+ 'title' => $title,
+ 'subtitle' => $subtitle,
+ 'description' => $programme['description'] ?? '',
+ 'category' => $programme['category'] ?? '',
+ 'metadata' => $programme['metadata'] ?? [],
+ 'year' => $programme['year'] ?? '',
+ 'rating' => $programme['rating'] ?? '',
+ 'episode' => $programme['episode'] ?? '',
+ 'icon' => $programme['icon'] ?? '',
+
+ 'start' => $start,
+ 'stop' => $stop,
+ 'start_time' => $startDateTime->format('H:i'),
+ 'stop_time' => $stopDateTime->format('H:i'),
+ 'date' => $startDateTime->format('Y-m-d'),
+ 'is_current' => time() >= $start && time() < $stop,
+ 'is_past' => $stop <= time(),
+ ];
+ }
+ }
+
+ usort($channelResults, function (array $a, array $b): int {
+ return [
+ $a['channel_number'],
+ mb_strtolower($a['channel_name']),
+ ] <=> [
+ $b['channel_number'],
+ mb_strtolower($b['channel_name']),
+ ];
+ });
+
+ usort($programmeResults, function (array $a, array $b): int {
+ return [
+ $a['start'],
+ $a['channel_number'],
+ ] <=> [
+ $b['start'],
+ $b['channel_number'],
+ ];
+ });
+
+ return array_slice(
+ array_merge($channelResults, $programmeResults),
+ 0,
+ $limit
+ );
+ }
+
private function getGenreIcon(string $category): string
{
$category = mb_strtolower($category);
diff --git a/app/src/XmlTv.php b/app/src/XmlTv.php
index 668c02d..acb30f3 100644
--- a/app/src/XmlTv.php
+++ b/app/src/XmlTv.php
@@ -50,11 +50,21 @@ class XmlTv
return $this->programmeService->getCurrentProgrammes($this->getCachedData(), $config);
}
-
-
public function getDayProgrammes(Config $config, string $date, ?string $channelId = null): array
{
return $this->programmeService->getDayProgrammes($config, $this->getCachedData(), $date, $channelId);
}
+ public function search(
+ Config $config,
+ string $query,
+ int $limit = 20
+ ): array {
+ return $this->programmeService->search(
+ $config,
+ $this->getCachedData(),
+ $query,
+ $limit
+ );
+ }
}
diff --git a/app/templates/header.php b/app/templates/header.php
index 47ff8bd..7456efc 100644
--- a/app/templates/header.php
+++ b/app/templates/header.php
@@ -6,11 +6,24 @@