Reminder, Watchlist
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
@@ -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']),
|
||||
];
|
||||
|
||||
@@ -18,5 +18,13 @@
|
||||
<div id="modalCategory" class="programme-modal-category"></div>
|
||||
<div id="modalMeta" class="programme-modal-meta"></div>
|
||||
<p id="modalDescription"></p>
|
||||
<div class="programme-modal-actions">
|
||||
<button
|
||||
id="modalWatchlistButton"
|
||||
class="programme-watchlist-button"
|
||||
type="button">
|
||||
☆ Zur Merkliste hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<a href="/?view=day" class="<?= ($view ?? '') === 'day' ? 'active' : '' ?>">📅 Tagesansicht</a>
|
||||
<a href="/?view=now" class="<?= ($view ?? '') === 'now' ? 'active' : '' ?>">▶ Jetzt</a>
|
||||
<a href="/?view=favorites" class="<?= ($view ?? '') === 'favorites' ? 'active' : '' ?>">⭐ Favoriten</a>
|
||||
<a href="/?view=watchlist" class="<?= ($view ?? '') === 'watchlist' ? 'active' : '' ?>"> 🔖 Merkliste<span id="watchlistCount" class="sidebar-count">0</span></a>
|
||||
<a href="/?view=channels" class="<?= ($view ?? '') === 'channels' ? 'active' : '' ?>">📺 Sender</a>
|
||||
<a href="/?view=settings" class="<?= ($view ?? '') === 'settings' ? 'active' : '' ?>">⚙ Einstellungen</a>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<section class="page-heading">
|
||||
<h2>Merkliste</h2>
|
||||
<p>Sendungen, die du später ansehen möchtest.</p>
|
||||
</section>
|
||||
|
||||
<section class="watchlist-notifications">
|
||||
<div>
|
||||
<strong>🔔 Erinnerungen</strong>
|
||||
|
||||
<p id="watchlistNotificationStatus">
|
||||
Erinnerung vor Sendungsbeginn aktivieren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="enableWatchlistNotifications"
|
||||
type="button">
|
||||
Erinnerungen aktivieren
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div id="watchlistEmpty" class="empty" hidden>
|
||||
Deine Merkliste ist noch leer.
|
||||
</div>
|
||||
|
||||
<div id="watchlistGrid" class="watchlist-grid"></div>
|
||||
|
||||
<?php require __DIR__ . '/partials/programme-modal.php'; ?>
|
||||
Reference in New Issue
Block a user