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 formatLocalTime(timestamp) { const value = Number(timestamp) || 0; if (!value) { return ""; } return new Date(value * 1000).toLocaleTimeString("de-AT", { hour: "2-digit", minute: "2-digit", hour12: false }); } function formatLocalDate(timestamp) { const value = Number(timestamp) || 0; if (!value) { return ""; } const date = new Date(value * 1000); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } function normalizeProgramme(programme) { const startTimestamp = getTimestamp(programme, "start"); const stopTimestamp = getTimestamp(programme, "stop"); const channelName = programme.channel_name || programme.channel || ""; const startTime = formatLocalTime(startTimestamp) || programme.start_time || ( typeof programme.start === "string" ? programme.start : "" ); const stopTime = formatLocalTime(stopTimestamp) || 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: formatLocalDate(startTimestamp) || 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() .map((item) => normalizeProgramme(item)) .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 ); }