Files
2026-07-14 16:37:20 +02:00

716 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
);
}