diff --git a/app/public/api/live-now.php b/app/public/api/live-now.php
new file mode 100644
index 0000000..fbaaedd
--- /dev/null
+++ b/app/public/api/live-now.php
@@ -0,0 +1,44 @@
+get('timezone', 'Europe/Vienna');
+
+$cacheFile = '/var/www/cache/guide.json';
+
+if (!file_exists($cacheFile)) {
+ http_response_code(404);
+ echo json_encode([
+ 'error' => 'guide.json not found',
+ ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ exit;
+}
+
+$data = json_decode(file_get_contents($cacheFile), true);
+
+if (!is_array($data)) {
+ http_response_code(500);
+ echo json_encode([
+ 'error' => 'guide.json is invalid',
+ ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ exit;
+}
+
+$service = new ProgrammeService($timezone);
+$items = $service->getCurrentProgrammes($data, $config);
+
+$now = new DateTimeImmutable('now', new DateTimeZone($timezone));
+
+echo json_encode([
+ 'now' => $now->format(DateTimeInterface::ATOM),
+ 'timestamp' => $now->getTimestamp(),
+ 'items' => $items,
+], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
diff --git a/app/public/assets/js/live-now.js b/app/public/assets/js/live-now.js
new file mode 100644
index 0000000..5abbf4b
--- /dev/null
+++ b/app/public/assets/js/live-now.js
@@ -0,0 +1,61 @@
+function nowCreateCard(item) {
+ const article = document.createElement('article');
+ article.className = 'now-card';
+
+ article.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ nowUpdateCard(article, item);
+ return article;
+}
+
+function nowUpdateCard(card, item) {
+ card.querySelector('[data-live-channel-name]').textContent = item.channel_name;
+ card.querySelector('[data-live-favorite]').textContent = item.favorite ? '⭐' : '';
+ card.querySelector('[data-live-title]').textContent = item.title;
+
+ const subtitle = card.querySelector('[data-live-subtitle]');
+ subtitle.textContent = item.subtitle || '';
+ subtitle.style.display = item.subtitle ? '' : 'none';
+
+ nowTickCard(card, item);
+}
+
+function nowTickCard(card, item) {
+ card.querySelector('[data-live-time]').textContent =
+ `${TVGuideLive.formatTime(item.start)} – ${TVGuideLive.formatTime(item.stop)} · noch ${TVGuideLive.calcRemainingMinutes(item.stop)} Min.`;
+
+ card.querySelector('[data-live-progress]').style.width =
+ `${TVGuideLive.calcPercent(item.start, item.stop)}%`;
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+ if (!window.LiveEngine) return;
+
+ const liveNow = new LiveEngine({
+ url: '/api/live-now.php',
+ interval: 30000,
+ gridSelector: '[data-live-now-grid]',
+ itemKey: item => item.channel_id,
+ createItem: nowCreateCard,
+ updateItem: nowUpdateCard,
+ tickItem: nowTickCard,
+ emptyHtml: 'Aktuell wurden keine laufenden Sendungen gefunden.
'
+ });
+
+ liveNow.start();
+});
diff --git a/app/public/assets/js/live.js b/app/public/assets/js/live.js
new file mode 100644
index 0000000..c73fe51
--- /dev/null
+++ b/app/public/assets/js/live.js
@@ -0,0 +1,138 @@
+window.TVGuideLive = {
+ formatTime(timestamp) {
+ const date = new Date(timestamp * 1000);
+ return String(date.getHours()).padStart(2, '0') + ':' +
+ String(date.getMinutes()).padStart(2, '0');
+ },
+
+ calcPercent(start, stop) {
+ const now = Math.floor(Date.now() / 1000);
+ const duration = Math.max(1, stop - start);
+ const elapsed = Math.max(0, now - start);
+ return Math.min(100, Math.max(0, Math.round((elapsed / duration) * 100)));
+ },
+
+ calcRemainingMinutes(stop) {
+ const now = Math.floor(Date.now() / 1000);
+ return Math.max(0, Math.ceil((stop - now) / 60));
+ },
+
+ async fetchJson(url) {
+ const response = await fetch(url, { cache: 'no-store' });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ return await response.json();
+ }
+};
+
+window.LiveEngine = class LiveEngine {
+ constructor(options) {
+ this.url = options.url;
+ this.interval = options.interval ?? 30000;
+ this.gridSelector = options.gridSelector;
+ this.itemKey = options.itemKey;
+ this.createItem = options.createItem;
+ this.updateItem = options.updateItem;
+ this.tickItem = options.tickItem;
+ this.emptyHtml = options.emptyHtml ?? 'Keine Daten gefunden.
';
+
+ this.items = new Map();
+ this.initialized = false;
+ this.refreshTimer = null;
+ this.tickTimer = null;
+ }
+
+ getGrid() {
+ return document.querySelector(this.gridSelector);
+ }
+
+ async refresh() {
+ const grid = this.getGrid();
+ if (!grid) return;
+
+ try {
+ const data = await TVGuideLive.fetchJson(this.url);
+ const items = data.items ?? [];
+
+ if (items.length === 0) {
+ grid.innerHTML = this.emptyHtml;
+ this.items.clear();
+ this.initialized = true;
+ return;
+ }
+
+ if (!this.initialized) {
+ grid.innerHTML = '';
+ this.initialized = true;
+ }
+
+ const empty = grid.querySelector('.empty');
+ if (empty) empty.remove();
+
+ this.renderDiff(grid, items);
+ } catch (error) {
+ console.error('Live-EPG konnte nicht aktualisiert werden:', error);
+ }
+ }
+
+ renderDiff(grid, items) {
+ const seen = new Set();
+
+ for (const item of items) {
+ const key = String(this.itemKey(item));
+ seen.add(key);
+ this.items.set(key, item);
+
+ let element = grid.querySelector(`[data-live-key="${CSS.escape(key)}"]`);
+
+ if (!element) {
+ element = this.createItem(item);
+ element.dataset.liveKey = key;
+ grid.appendChild(element);
+ } else {
+ this.updateItem(element, item);
+ }
+ }
+
+ grid.querySelectorAll('[data-live-key]').forEach(element => {
+ const key = element.dataset.liveKey;
+
+ if (!seen.has(key)) {
+ element.remove();
+ this.items.delete(key);
+ }
+ });
+ }
+
+ tick() {
+ const grid = this.getGrid();
+ if (!grid || !this.tickItem) return;
+
+ grid.querySelectorAll('[data-live-key]').forEach(element => {
+ const key = element.dataset.liveKey;
+ const item = this.items.get(key);
+
+ if (item) {
+ this.tickItem(element, item);
+ }
+ });
+ }
+
+ start() {
+ this.refresh();
+
+ this.refreshTimer = setInterval(() => this.refresh(), this.interval);
+
+ if (this.tickItem) {
+ this.tickTimer = setInterval(() => this.tick(), 1000);
+ }
+ }
+
+ stop() {
+ if (this.refreshTimer) clearInterval(this.refreshTimer);
+ if (this.tickTimer) clearInterval(this.tickTimer);
+ }
+};
diff --git a/app/templates/layout.php b/app/templates/layout.php
index 516d066..0683af8 100644
--- a/app/templates/layout.php
+++ b/app/templates/layout.php
@@ -20,5 +20,8 @@
+
+
+