716 lines
14 KiB
JavaScript
716 lines
14 KiB
JavaScript
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
|
||
);
|
||
}
|