From 8b68ba2e9bd639ef7c66e5e6ef572e33916af531 Mon Sep 17 00:00:00 2001 From: Gogs Date: Fri, 10 Jul 2026 14:06:25 +0200 Subject: [PATCH] Reminder, Watchlist --- app/public/assets/css/layout.css | 293 +++++++++ app/public/assets/css/modal.css | 43 ++ app/public/assets/js/app.js | 7 +- app/public/assets/js/modules/modal.js | 147 +++-- app/public/assets/js/modules/watchlist.js | 677 +++++++++++++++++++++ app/src/Controllers/HomeController.php | 1 + app/templates/day.php | 3 + app/templates/partials/programme-modal.php | 8 + app/templates/sidebar.php | 1 + app/templates/watchlist.php | 28 + 10 files changed, 1170 insertions(+), 38 deletions(-) create mode 100644 app/public/assets/js/modules/watchlist.js create mode 100644 app/templates/watchlist.php diff --git a/app/public/assets/css/layout.css b/app/public/assets/css/layout.css index 6ca3d72..d15ec91 100644 --- a/app/public/assets/css/layout.css +++ b/app/public/assets/css/layout.css @@ -498,3 +498,296 @@ font: inherit; font-weight: 800; } + +.sidebar-nav a { + position: relative; +} + +.sidebar-count { + min-width: 22px; + margin-left: auto; + padding: 2px 7px; + + color: #cbd5e1; + background: rgba(51, 65, 85, 0.8); + + border-radius: 999px; + + font-size: 0.72rem; + line-height: 1.35; + text-align: center; +} + +.sidebar-nav a.active .sidebar-count { + color: white; + background: rgba(255, 255, 255, 0.2); +} + +.watchlist-notifications { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + + margin-bottom: 22px; + padding: 18px 20px; + + background: var(--panel); + border: 1px solid var(--border); + border-radius: 16px; +} + +.watchlist-notifications strong { + display: block; + margin-bottom: 5px; +} + +.watchlist-notifications p { + margin: 0; + color: var(--muted); + font-size: 0.88rem; +} + +.watchlist-notifications button { + flex: 0 0 auto; + + padding: 10px 15px; + + color: white; + background: var(--accent); + + border: 1px solid var(--accent); + border-radius: 11px; + + font-weight: 750; + cursor: pointer; +} + +.watchlist-grid { + display: grid; + grid-template-columns: + repeat(auto-fill, minmax(320px, 1fr)); + gap: 16px; +} + +.watchlist-card { + position: relative; + min-width: 0; + + display: flex; + align-items: stretch; + + background: var(--panel); + + border: 1px solid var(--border); + border-left-width: 5px; + border-radius: 16px; + + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + + overflow: hidden; + + transition: + border-color 0.16s ease, + background 0.16s ease, + transform 0.16s ease, + box-shadow 0.16s ease; +} + +.watchlist-card:hover { + background: var(--panel-2); + transform: translateY(-2px); + + box-shadow: 0 14px 32px rgba(0, 0, 0, 0.22); +} + +.watchlist-card-current { + border-left-color: #22c55e; +} + +.watchlist-card-soon { + border-left-color: #f59e0b; +} + +.watchlist-card-future { + border-left-color: #3b82f6; +} + +.watchlist-card-past { + border-left-color: #64748b; + opacity: 0.72; +} + +.watchlist-card-unknown { + border-left-color: var(--border); +} + +.watchlist-card-open { + min-width: 0; + + display: flex; + align-items: center; + flex: 1; + gap: 14px; + + padding: 16px 46px 16px 16px; + + color: var(--text); + background: transparent; + + border: 0; + + font: inherit; + text-align: left; + cursor: pointer; +} + +.watchlist-logo { + flex: 0 0 auto; + + width: 64px; + height: 48px; + padding: 5px; + + object-fit: contain; + + background: rgba(255, 255, 255, 0.94); + border-radius: 10px; +} + +.watchlist-card-body { + min-width: 0; + + display: flex; + flex: 1; + flex-direction: column; + gap: 5px; +} + +.watchlist-card-heading { + min-width: 0; + + display: flex; + align-items: center; + gap: 8px; +} + +.watchlist-card-heading strong { + min-width: 0; + flex: 1; + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.watchlist-subtitle, +.watchlist-details { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.watchlist-subtitle { + color: #cbd5e1; + font-size: 0.88rem; +} + +.watchlist-details { + color: var(--muted); + font-size: 0.8rem; +} + +.watchlist-status { + flex: 0 0 auto; + + padding: 3px 7px; + + border-radius: 999px; + + font-size: 0.66rem; + font-weight: 800; + line-height: 1.25; + white-space: nowrap; +} + +.watchlist-status-current { + color: #bbf7d0; + background: rgba(34, 197, 94, 0.18); + border: 1px solid rgba(34, 197, 94, 0.42); +} + +.watchlist-status-soon { + color: #fde68a; + background: rgba(245, 158, 11, 0.18); + border: 1px solid rgba(245, 158, 11, 0.42); +} + +.watchlist-status-future { + color: #bfdbfe; + background: rgba(59, 130, 246, 0.18); + border: 1px solid rgba(59, 130, 246, 0.42); +} + +.watchlist-status-past, +.watchlist-status-unknown { + color: #cbd5e1; + background: rgba(100, 116, 139, 0.18); + border: 1px solid rgba(100, 116, 139, 0.42); +} + +.watchlist-remove-button { + position: absolute; + top: 10px; + right: 10px; + + width: 30px; + height: 30px; + padding: 0; + + color: var(--muted); + background: rgba(15, 23, 42, 0.76); + + border: 1px solid var(--border); + border-radius: 999px; + + font-size: 1.2rem; + line-height: 1; + cursor: pointer; +} + +.watchlist-remove-button:hover { + color: white; + background: rgba(239, 68, 68, 0.75); + border-color: rgba(239, 68, 68, 0.85); +} + +@media (max-width: 620px) { + .watchlist-notifications { + align-items: stretch; + flex-direction: column; + } + + .watchlist-notifications button { + width: 100%; + } + + .watchlist-grid { + grid-template-columns: 1fr; + } + + .watchlist-status { + display: none; + } +} + +.watchlist-card-body strong, +.watchlist-card-body span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.watchlist-card-body strong { + font-size: 1rem; +} diff --git a/app/public/assets/css/modal.css b/app/public/assets/css/modal.css index b2cb5a1..8bffbcf 100644 --- a/app/public/assets/css/modal.css +++ b/app/public/assets/css/modal.css @@ -111,3 +111,46 @@ font-size: 1.05rem; font-weight: 700; } + +.programme-modal-actions { + display: flex; + justify-content: flex-end; + + margin-top: 22px; + padding-top: 18px; + + border-top: 1px solid var(--border); +} + +.programme-watchlist-button { + padding: 10px 16px; + + color: var(--text); + background: var(--panel-2); + + border: 1px solid var(--border); + border-radius: 12px; + + font-weight: 750; + cursor: pointer; + + transition: + background 0.16s ease, + border-color 0.16s ease, + transform 0.16s ease; +} + +.programme-watchlist-button:hover { + background: var(--accent-soft); + border-color: var(--accent); +} + +.programme-watchlist-button:active { + transform: scale(0.98); +} + +.programme-watchlist-button.is-saved { + color: #fef3c7; + background: rgba(245, 158, 11, 0.16); + border-color: rgba(245, 158, 11, 0.55); +} diff --git a/app/public/assets/js/app.js b/app/public/assets/js/app.js index 78e8432..2c6a3e1 100644 --- a/app/public/assets/js/app.js +++ b/app/public/assets/js/app.js @@ -1,10 +1,15 @@ import { initNowLine } from "./modules/now.js"; +import { initWatchlist } from "./modules/watchlist.js"; import { initProgrammeModal } from "./modules/modal.js"; import { initGuideSearch } from "./modules/search.js"; -import { initTimelineZoom, initTimelineDrag } from "./modules/timeline.js"; +import { + initTimelineZoom, + initTimelineDrag +} from "./modules/timeline.js"; document.addEventListener("DOMContentLoaded", () => { initNowLine(); + initWatchlist(); initProgrammeModal(); initGuideSearch(); initTimelineZoom(); diff --git a/app/public/assets/js/modules/modal.js b/app/public/assets/js/modules/modal.js index 204ba25..b168750 100644 --- a/app/public/assets/js/modules/modal.js +++ b/app/public/assets/js/modules/modal.js @@ -13,43 +13,79 @@ export function initProgrammeModal() { const meta = document.getElementById("modalMeta"); const logo = document.getElementById("modalLogo"); const channel = document.getElementById("modalChannel"); + const watchlistButton = + document.getElementById("modalWatchlistButton"); + + let currentProgramme = null; + + const updateWatchlistButton = () => { + if (!watchlistButton || !currentProgramme) { + return; + } + + const saved = + window.TVGuideWatchlist?.isSaved(currentProgramme) || + false; + + watchlistButton.classList.toggle("is-saved", saved); + + watchlistButton.textContent = saved + ? "★ Aus Merkliste entfernen" + : "☆ Zur Merkliste hinzufügen"; + + watchlistButton.setAttribute( + "aria-pressed", + saved ? "true" : "false" + ); + }; const openProgramme = (programme) => { if (!programme) { return; } - const metadata = programme.metadata || {}; + currentProgramme = + window.TVGuideWatchlist?.normalize(programme) || + programme; - title.textContent = programme.title || ""; + const metadata = currentProgramme.metadata || {}; + + title.textContent = + currentProgramme.title || ""; subtitle.textContent = metadata.episode_title || - programme.subtitle || + currentProgramme.subtitle || ""; subtitle.hidden = !subtitle.textContent; description.textContent = - programme.description || + currentProgramme.description || "Keine Beschreibung vorhanden."; - category.textContent = programme.category || ""; + category.textContent = + currentProgramme.category || ""; + category.hidden = !category.textContent; const channelName = - programme.channel || - programme.channel_name || + currentProgramme.channel || + currentProgramme.channel_name || ""; - const channelNumber = programme.channel_number; + const channelNumber = + currentProgramme.channel_number; - channel.textContent = channelNumber && channelNumber !== 999999 - ? `Kanal ${channelNumber} · ${channelName}` - : channelName; + channel.textContent = + channelNumber && channelNumber !== 999999 + ? `Kanal ${channelNumber} · ${channelName}` + : channelName; logo.src = - `/assets/logos/${encodeURIComponent(programme.channel_id || "")}.svg`; + `/assets/logos/${encodeURIComponent( + currentProgramme.channel_id || "" + )}.svg`; logo.onerror = () => { logo.onerror = null; @@ -71,23 +107,26 @@ export function initProgrammeModal() { if (metadata.country) { metaItems.push(metadata.country); - } else if (programme.country) { - metaItems.push(programme.country); + } else if (currentProgramme.country) { + metaItems.push(currentProgramme.country); } if (metadata.year) { metaItems.push(metadata.year); - } else if (programme.year) { - metaItems.push(programme.year); + } else if (currentProgramme.year) { + metaItems.push(currentProgramme.year); } if (metadata.age_rating) { metaItems.push(`FSK ${metadata.age_rating}`); - } else if (programme.rating) { - metaItems.push(`FSK ${programme.rating}`); + } else if (currentProgramme.rating) { + metaItems.push(`FSK ${currentProgramme.rating}`); } - if (Array.isArray(metadata.genres) && metadata.genres.length) { + if ( + Array.isArray(metadata.genres) && + metadata.genres.length + ) { metaItems.push(metadata.genres.join(", ")); } @@ -95,18 +134,21 @@ export function initProgrammeModal() { meta.hidden = metaItems.length === 0; const start = - programme.start_time || - programme.start || + currentProgramme.start_time || + currentProgramme.start || ""; const stop = - programme.stop_time || - programme.stop || + currentProgramme.stop_time || + currentProgramme.stop || ""; - time.textContent = start && stop - ? `${start} – ${stop}` - : ""; + time.textContent = + start && stop + ? `${start} – ${stop}` + : ""; + + updateWatchlistButton(); modal.hidden = false; document.body.classList.add("modal-open"); @@ -114,7 +156,8 @@ export function initProgrammeModal() { const openProgrammeElement = (programmeElement) => { const id = programmeElement.dataset.programmeId; - const programme = window.TVGUIDE_PROGRAMMES?.[id]; + const programme = + window.TVGUIDE_PROGRAMMES?.[id]; openProgramme(programme); }; @@ -122,12 +165,31 @@ export function initProgrammeModal() { const closeModal = () => { modal.hidden = true; meta.textContent = ""; + currentProgramme = null; + document.body.classList.remove("modal-open"); }; - /* - * Öffentliche Schnittstelle für die globale Suche. - */ + watchlistButton?.addEventListener("click", () => { + if ( + !currentProgramme || + !window.TVGuideWatchlist + ) { + return; + } + + window.TVGuideWatchlist.toggle( + currentProgramme + ); + + updateWatchlistButton(); + }); + + document.addEventListener( + "tvguide:watchlist-changed", + updateWatchlistButton + ); + window.TVGuideProgrammeModal = { open: openProgramme, close: closeModal @@ -145,13 +207,24 @@ export function initProgrammeModal() { openProgrammeElement(programme); }); - modal.querySelectorAll("[data-modal-close]").forEach((element) => { - element.addEventListener("click", closeModal); - }); + modal + .querySelectorAll("[data-modal-close]") + .forEach((element) => { + element.addEventListener( + "click", + closeModal + ); + }); - document.addEventListener("keydown", (event) => { - if (event.key === "Escape" && !modal.hidden) { - closeModal(); + document.addEventListener( + "keydown", + (event) => { + if ( + event.key === "Escape" && + !modal.hidden + ) { + closeModal(); + } } - }); + ); } diff --git a/app/public/assets/js/modules/watchlist.js b/app/public/assets/js/modules/watchlist.js new file mode 100644 index 0000000..2d283e2 --- /dev/null +++ b/app/public/assets/js/modules/watchlist.js @@ -0,0 +1,677 @@ +const STORAGE_KEY = "tvguide.watchlist.v1"; +const NOTIFIED_KEY = "tvguide.watchlist.notified.v1"; + +const REMINDER_MINUTES = 10; +const UPCOMING_MINUTES = 30; +const CLEANUP_AFTER_HOURS = 24; + +function readJson(key, fallback) { + try { + const value = JSON.parse( + localStorage.getItem(key) || JSON.stringify(fallback) + ); + + return value; + } catch (error) { + console.error(`LocalStorage konnte nicht gelesen werden: ${key}`, error); + return fallback; + } +} + +function writeJson(key, value) { + localStorage.setItem( + key, + JSON.stringify(value) + ); +} + +function readItems() { + const items = readJson(STORAGE_KEY, []); + return Array.isArray(items) ? items : []; +} + +function writeItems(items) { + writeJson(STORAGE_KEY, items); + + document.dispatchEvent( + new CustomEvent("tvguide:watchlist-changed", { + detail: { + items + } + }) + ); +} + +function readNotifiedIds() { + const ids = readJson(NOTIFIED_KEY, []); + return Array.isArray(ids) ? ids : []; +} + +function writeNotifiedIds(ids) { + writeJson(NOTIFIED_KEY, ids); +} + +function getTimestamp(programme, type) { + const directTimestamp = + programme[`${type}_timestamp`] ?? + programme[`${type}_ts`] ?? + ( + typeof programme[type] === "number" + ? programme[type] + : 0 + ); + + const parsedDirect = Number(directTimestamp); + + if (parsedDirect > 0) { + return parsedDirect; + } + + /* + * Fallback für Programme mit Datum und Uhrzeit, + * aber ohne Unix-Zeitstempel. + */ + const date = programme.date || ""; + const time = + programme[`${type}_time`] || + ( + typeof programme[type] === "string" + ? programme[type] + : "" + ); + + if (!date || !time) { + return 0; + } + + const parsedDate = new Date(`${date}T${time}:00`); + + if (Number.isNaN(parsedDate.getTime())) { + return 0; + } + + return Math.floor(parsedDate.getTime() / 1000); +} + +function normalizeProgramme(programme) { + const startTimestamp = + getTimestamp(programme, "start"); + + const stopTimestamp = + getTimestamp(programme, "stop"); + + const channelName = + programme.channel_name || + programme.channel || + ""; + + const startTime = + programme.start_time || + ( + typeof programme.start === "string" + ? programme.start + : "" + ); + + const stopTime = + programme.stop_time || + ( + typeof programme.stop === "string" + ? programme.stop + : "" + ); + + const id = + programme.id || + [ + programme.channel_id || "", + startTimestamp || startTime, + programme.title || "" + ].join("|"); + + return { + id, + + channel_id: programme.channel_id || "", + channel_name: channelName, + channel: channelName, + channel_number: programme.channel_number ?? "", + channel_icon: programme.channel_icon || "", + favorite: Boolean(programme.favorite), + + title: programme.title || "Ohne Titel", + subtitle: programme.subtitle || "", + description: programme.description || "", + category: programme.category || "", + metadata: programme.metadata || {}, + year: programme.year || "", + rating: programme.rating || "", + country: programme.country || "", + + date: programme.date || "", + + start: startTime, + stop: stopTime, + start_time: startTime, + stop_time: stopTime, + + start_timestamp: startTimestamp, + stop_timestamp: stopTimestamp + }; +} + +function cleanupExpiredItems() { + const now = Math.floor(Date.now() / 1000); + + const cleanupDelay = + CLEANUP_AFTER_HOURS * 60 * 60; + + const items = readItems(); + + const remainingItems = items.filter((item) => { + const stop = Number(item.stop_timestamp) || 0; + + /* + * Sendungen ohne Endzeit nicht automatisch entfernen. + */ + if (!stop) { + return true; + } + + return now < stop + cleanupDelay; + }); + + if (remainingItems.length !== items.length) { + writeItems(remainingItems); + } + + /* + * Auch die Liste bereits gesendeter Erinnerungen aufräumen. + */ + const remainingIds = new Set( + remainingItems.map((item) => item.id) + ); + + const notifiedIds = readNotifiedIds().filter( + (id) => remainingIds.has(id) + ); + + writeNotifiedIds(notifiedIds); +} + +function getItems() { + cleanupExpiredItems(); + + return readItems().sort((a, b) => { + const startA = + Number(a.start_timestamp) || + Number.MAX_SAFE_INTEGER; + + const startB = + Number(b.start_timestamp) || + Number.MAX_SAFE_INTEGER; + + return startA - startB; + }); +} + +function isSaved(programmeOrId) { + const id = + typeof programmeOrId === "string" + ? programmeOrId + : normalizeProgramme(programmeOrId).id; + + return readItems().some( + (item) => item.id === id + ); +} + +function add(programme) { + const normalized = + normalizeProgramme(programme); + + const items = readItems(); + + if (!items.some((item) => item.id === normalized.id)) { + items.push(normalized); + writeItems(items); + } + + return normalized; +} + +function remove(programmeOrId) { + const id = + typeof programmeOrId === "string" + ? programmeOrId + : normalizeProgramme(programmeOrId).id; + + const items = readItems().filter( + (item) => item.id !== id + ); + + writeItems(items); + + const notifiedIds = readNotifiedIds().filter( + (notifiedId) => notifiedId !== id + ); + + writeNotifiedIds(notifiedIds); +} + +function toggle(programme) { + const normalized = + normalizeProgramme(programme); + + if (isSaved(normalized.id)) { + remove(normalized.id); + return false; + } + + add(normalized); + return true; +} + +function logoUrl(channelId) { + return channelId + ? `/assets/logos/${encodeURIComponent(channelId)}.svg` + : "/assets/logos/default.svg"; +} + +function formatDate(item) { + if (!item.date) { + return ""; + } + + const date = new Date(`${item.date}T12:00:00`); + + if (Number.isNaN(date.getTime())) { + return item.date; + } + + return date.toLocaleDateString("de-AT", { + weekday: "short", + day: "2-digit", + month: "2-digit", + year: "numeric" + }); +} + +function getProgrammeStatus(item) { + const now = Math.floor(Date.now() / 1000); + const start = Number(item.start_timestamp) || 0; + const stop = Number(item.stop_timestamp) || 0; + + if (!start || !stop) { + return { + key: "unknown", + label: "Zeit unbekannt" + }; + } + + if (now >= start && now < stop) { + const remainingMinutes = + Math.max(1, Math.ceil((stop - now) / 60)); + + return { + key: "current", + label: `Läuft gerade · noch ${remainingMinutes} Min.` + }; + } + + if (now < start) { + const minutes = + Math.ceil((start - now) / 60); + + if (minutes <= UPCOMING_MINUTES) { + return { + key: "soon", + label: `Beginnt in ${minutes} Min.` + }; + } + + return { + key: "future", + label: "Vorgemerkt" + }; + } + + return { + key: "past", + label: "Bereits vorbei" + }; +} + +function createStatusBadge(item) { + const status = getProgrammeStatus(item); + + const badge = document.createElement("span"); + + badge.className = + `watchlist-status watchlist-status-${status.key}`; + + badge.textContent = status.label; + + return badge; +} + +function renderWatchlistPage() { + const grid = + document.getElementById("watchlistGrid"); + + const empty = + document.getElementById("watchlistEmpty"); + + if (!grid || !empty) { + return; + } + + const items = getItems(); + + grid.innerHTML = ""; + empty.hidden = items.length > 0; + + items.forEach((item) => { + const card = document.createElement("article"); + + const status = getProgrammeStatus(item); + + card.className = + `watchlist-card watchlist-card-${status.key}`; + + const openButton = document.createElement("button"); + + openButton.type = "button"; + openButton.className = "watchlist-card-open"; + + const logo = document.createElement("img"); + + logo.className = "watchlist-logo"; + logo.src = logoUrl(item.channel_id); + logo.alt = ""; + + logo.onerror = () => { + logo.onerror = null; + logo.src = "/assets/logos/default.svg"; + }; + + const body = document.createElement("span"); + + body.className = "watchlist-card-body"; + + const headingRow = document.createElement("span"); + + headingRow.className = + "watchlist-card-heading"; + + const title = document.createElement("strong"); + + title.textContent = item.title; + + const badge = createStatusBadge(item); + + headingRow.append(title, badge); + + const subtitle = document.createElement("span"); + + subtitle.className = "watchlist-subtitle"; + subtitle.textContent = item.subtitle; + subtitle.hidden = !item.subtitle; + + const details = document.createElement("span"); + + details.className = "watchlist-details"; + + const date = formatDate(item); + + const time = + item.start_time && item.stop_time + ? `${item.start_time}–${item.stop_time}` + : ""; + + details.textContent = [ + item.channel_name, + date, + time + ].filter(Boolean).join(" · "); + + body.append( + headingRow, + subtitle, + details + ); + + openButton.append(logo, body); + + openButton.addEventListener("click", () => { + window.TVGuideProgrammeModal?.open(item); + }); + + const removeButton = + document.createElement("button"); + + removeButton.type = "button"; + removeButton.className = + "watchlist-remove-button"; + + removeButton.textContent = "×"; + removeButton.title = "Aus Merkliste entfernen"; + removeButton.setAttribute( + "aria-label", + `${item.title} aus Merkliste entfernen` + ); + + removeButton.addEventListener("click", () => { + remove(item.id); + }); + + card.append( + openButton, + removeButton + ); + + grid.appendChild(card); + }); +} + +function updateSidebarCount() { + const count = + document.getElementById("watchlistCount"); + + if (!count) { + return; + } + + count.textContent = + String(getItems().length); +} + +function updateNotificationButton() { + const button = + document.getElementById("enableWatchlistNotifications"); + + const status = + document.getElementById("watchlistNotificationStatus"); + + if (!button || !status) { + return; + } + + if (!("Notification" in window)) { + button.hidden = true; + status.textContent = + "Dieser Browser unterstützt keine Systembenachrichtigungen."; + return; + } + + if (Notification.permission === "granted") { + button.hidden = true; + status.textContent = + `Erinnerungen sind aktiv: ${REMINDER_MINUTES} Minuten vor Beginn.`; + return; + } + + if (Notification.permission === "denied") { + button.hidden = true; + status.textContent = + "Benachrichtigungen wurden im Browser blockiert."; + return; + } + + button.hidden = false; + status.textContent = + `Erinnerung ${REMINDER_MINUTES} Minuten vor Beginn aktivieren.`; +} + +async function requestNotificationPermission() { + if (!("Notification" in window)) { + return; + } + + try { + await Notification.requestPermission(); + } catch (error) { + console.error( + "Benachrichtigungsfreigabe fehlgeschlagen:", + error + ); + } + + updateNotificationButton(); + checkReminders(); +} + +function showReminder(item) { + const time = + item.start_time + ? ` um ${item.start_time}` + : ""; + + const channel = + item.channel_name + ? ` auf ${item.channel_name}` + : ""; + + const notification = new Notification( + `${item.title} beginnt bald`, + { + body: + `${item.title} beginnt${time}${channel}.`, + icon: logoUrl(item.channel_id), + tag: `tvguide-${item.id}` + } + ); + + notification.onclick = () => { + window.focus(); + + window.TVGuideProgrammeModal?.open(item); + + notification.close(); + }; +} + +function checkReminders() { + if ( + !("Notification" in window) || + Notification.permission !== "granted" + ) { + return; + } + + const now = Math.floor(Date.now() / 1000); + + const reminderSeconds = + REMINDER_MINUTES * 60; + + const notifiedIds = + readNotifiedIds(); + + const notifiedSet = + new Set(notifiedIds); + + let changed = false; + + getItems().forEach((item) => { + const start = + Number(item.start_timestamp) || 0; + + if (!start || notifiedSet.has(item.id)) { + return; + } + + const secondsUntilStart = + start - now; + + /* + * Erinnerung innerhalb des Erinnerungsfensters. + * Bis zwei Minuten nach Sendungsbeginn wird ebenfalls + * erinnert, falls die Seite verspätet geöffnet wurde. + */ + if ( + secondsUntilStart <= reminderSeconds && + secondsUntilStart >= -120 + ) { + showReminder(item); + + notifiedSet.add(item.id); + changed = true; + } + }); + + if (changed) { + writeNotifiedIds( + Array.from(notifiedSet) + ); + } +} + +function refreshWatchlist() { + cleanupExpiredItems(); + renderWatchlistPage(); + updateSidebarCount(); + checkReminders(); +} + +export function initWatchlist() { + window.TVGuideWatchlist = { + getItems, + normalize: normalizeProgramme, + isSaved, + add, + remove, + toggle, + cleanup: cleanupExpiredItems, + checkReminders + }; + + const notificationButton = + document.getElementById( + "enableWatchlistNotifications" + ); + + notificationButton?.addEventListener( + "click", + requestNotificationPermission + ); + + refreshWatchlist(); + updateNotificationButton(); + + document.addEventListener( + "tvguide:watchlist-changed", + () => { + renderWatchlistPage(); + updateSidebarCount(); + checkReminders(); + } + ); + + /* + * Statusanzeigen und Erinnerungen regelmäßig aktualisieren. + */ + window.setInterval( + refreshWatchlist, + 30_000 + ); +} diff --git a/app/src/Controllers/HomeController.php b/app/src/Controllers/HomeController.php index 5bfcc1f..9a6636e 100644 --- a/app/src/Controllers/HomeController.php +++ b/app/src/Controllers/HomeController.php @@ -66,6 +66,7 @@ class HomeController 'now' => '/var/www/templates/now.php', 'favorites' => '/var/www/templates/now.php', 'day' => '/var/www/templates/day.php', + 'watchlist' => '/var/www/templates/watchlist.php', default => '/var/www/templates/home.php', }; diff --git a/app/templates/day.php b/app/templates/day.php index 14b232d..88b98e5 100644 --- a/app/templates/day.php +++ b/app/templates/day.php @@ -57,6 +57,9 @@ foreach ($dayProgrammes as $channel) { 'year' => $programme['metadata']['year'] ?? '', 'rating' => $programme['metadata']['age_rating'] ?? '', 'country' => $programme['metadata']['country'] ?? '', + 'date' => $selectedDate, + 'start_timestamp' => $programme['start'], + 'stop_timestamp' => $programme['stop'], 'start' => date('H:i', $programme['start']), 'stop' => date('H:i', $programme['stop']), ]; diff --git a/app/templates/partials/programme-modal.php b/app/templates/partials/programme-modal.php index 12c9443..30239fc 100644 --- a/app/templates/partials/programme-modal.php +++ b/app/templates/partials/programme-modal.php @@ -18,5 +18,13 @@

+
+ +
diff --git a/app/templates/sidebar.php b/app/templates/sidebar.php index 9aef069..16f12ff 100644 --- a/app/templates/sidebar.php +++ b/app/templates/sidebar.php @@ -4,6 +4,7 @@ 📅 Tagesansicht ▶ Jetzt ⭐ Favoriten + 🔖 Merkliste0 📺 Sender ⚙ Einstellungen diff --git a/app/templates/watchlist.php b/app/templates/watchlist.php new file mode 100644 index 0000000..dfef6fd --- /dev/null +++ b/app/templates/watchlist.php @@ -0,0 +1,28 @@ +
+

Merkliste

+

Sendungen, die du später ansehen möchtest.

+
+ +
+
+ 🔔 Erinnerungen + +

+ Erinnerung vor Sendungsbeginn aktivieren. +

+
+ + +
+ + + +
+ +