diora-web/static/js/home.js
mrwnslz b21f59f4eb Home: Hörbücher als eigener Block, ihre Kapitel nicht unter Podcasts
Ein laufendes Kapitel steht mit „Kapitel i/n · <Buch>“ unter Hörbücher, sonst
das zuletzt gehörte Buch mit ▶ Weiterhören (audiobooks.js __dioraAudiobookGo).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:42:20 +02:00

400 lines
24 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.

/**
* diora Home: what you were in the middle of, one click away — the book you're
* reading, the next episode, the news, your stations, Ambient.
*
* Built only from what the page already has: the book list (app.js), the
* podcast host (window.DioraPodcastHost in the browser and diora desktop,
* window.DioraNative in the Android app), the saved stations in the radio tab
* Drift (app.js __dioraAmbient*) and the feed reader (feeds.js __dioraFeeds*). Renders into #tab-home; the same file runs
* in the browser, diora desktop and the Android app (copied with static/js/).
* Written without ?. and ?? so old WebViews can still parse it.
*/
(function () {
'use strict';
const tab = document.getElementById('tab-home');
if (!tab) return;
const $ = id => document.getElementById(id);
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g,
c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]));
const parse = (s, fallback) => { try { return typeof s === 'string' ? JSON.parse(s) : (s || fallback); } catch (e) { return fallback; } };
const visible = () => tab.style.display !== 'none';
const online = () => navigator.onLine && !document.body.classList.contains('desktop-offline');
function minutes(sec) {
const m = Math.max(0, Math.round((sec || 0) / 60));
return m >= 60 ? Math.floor(m / 60) + ' h ' + String(m % 60).padStart(2, '0') + ' min' : m + ' min';
}
function bar(fraction) {
const pct = Math.max(0, Math.min(100, Math.round((fraction || 0) * 100)));
return '<span class="home-bar"><span style="width:' + pct + '%"></span></span>';
}
function podHost() {
if (window.DioraPodcastHost) return window.DioraPodcastHost;
if (window.DioraNative && window.DioraNative.podcastState) return window.DioraNative;
return null;
}
// ---- Weiterlesen ------------------------------------------------------------
let askedForBooks = false;
function booksSection() {
const books = typeof _lastBookListData !== 'undefined' ? _lastBookListData : [];
if (!books.length && !askedForBooks && typeof loadBookList === 'function') {
askedForBooks = true; // the list renders into the (hidden) books tab; renderBookList tells us
loadBookList();
}
const recent = typeof _recentlyReadBooks === 'function'
? _recentlyReadBooks(books).filter(b => b.keyOk !== false && !b.is_read) : [];
let body;
if (!recent.length) {
const locked = books.length && books.every(b => b.keyOk === false);
body = '<div class="home-card home-empty">' +
(locked ? 'Die Bücher lassen sich mit dem Schlüssel auf diesem Gerät nicht öffnen.'
: books.length ? 'Gerade kein Buch angefangen.' : 'Noch keine Bücher.') +
' <button type="button" class="btn btn-sm" data-act="tab" data-tab="books">Zu den Büchern</button></div>';
} else {
const b = recent[0];
body = '<button type="button" class="home-card home-main" data-act="book" data-id="' + esc(b.id) + '">' +
'<span class="home-kicker">Weiterlesen</span>' +
'<span class="home-title">' + esc(b.title) + '</span>' +
(b.author ? '<span class="home-sub">' + esc(b.author) + '</span>' : '') +
'<span class="home-state">' + bar(b.scroll_fraction) + '<span>' + Math.round((b.scroll_fraction || 0) * 100) + ' %</span></span>' +
'</button>';
const more = recent.slice(1, 4);
if (more.length) {
body += '<div class="home-list">' + more.map(o =>
'<button type="button" class="home-row" data-act="book" data-id="' + esc(o.id) + '">' +
'<span class="home-row-text">' + esc(o.title) + '</span>' +
'<span class="home-row-side">' + Math.round((o.scroll_fraction || 0) * 100) + ' %</span></button>').join('') + '</div>';
}
}
return section('Bücher', body, 'books');
}
// ---- Weiterhören ------------------------------------------------------------
function podcastSection() {
const h = podHost();
if (!h || !document.getElementById('tab-podcasts')) return '';
const state = parse(h.podcastState(), {}) || {};
const player = h.playerState ? parse(h.playerState(), {}) || {} : {};
const queue = state.queue || [];
const fresh = state.inbox_total != null ? state.inbox_total : (state.inbox || []).length;
let card = '';
if (player.remote && player.remote.kind === 'podcast') {
const r = player.remote;
card = podCard('Läuft auf ' + (r.device || 'einem anderen Gerät'), r.title || '', r.feed || '', r.artwork || '',
r.dur ? (r.dur - (r.pos || 0)) / 1000 : 0, r.dur ? (r.pos || 0) / r.dur : 0,
'<button type="button" class="btn btn-sm" data-act="pod-here">Hier weiterhören</button>');
} else if (player.kind === 'podcast' && !player.book) { // an audiobook's chapter: under Hörbücher
const left = player.dur ? (player.dur - (player.pos || 0)) / 1000 : 0;
card = podCard(player.playing ? 'Läuft gerade' : 'Weiterhören', player.title, player.feed, player.artwork, left,
player.dur ? (player.pos || 0) / player.dur : 0,
'<button type="button" class="btn btn-sm home-play" data-act="' + (player.playing ? 'pod-pause' : 'pod-resume') +
'" data-id="' + esc(player.episodeId) + '">' + (player.playing ? '■ Pause' : '▶ Weiter') + '</button>');
} else if (queue.length) {
const q = queue[0];
const dur = q.episode__duration_seconds || 0;
const pos = q.position_seconds || 0;
card = podCard(pos > 0 ? 'Weiterhören' : 'Als Nächstes', q.episode__title, q.episode__feed__title,
q.episode__artwork_url || q.episode__feed__artwork_url, dur ? dur - pos : 0, dur ? pos / dur : 0,
'<button type="button" class="btn btn-sm home-play" data-act="pod-queue">▶ Abspielen</button>');
} else {
card = '<div class="home-card home-empty">Die Warteschlange ist leer.</div>';
}
const extra = '<div class="home-chips">' +
(queue.length > 1 ? '<button type="button" class="home-chip" data-act="tab" data-tab="podcasts">' + queue.length + ' in der Warteschlange</button>' : '') +
(fresh ? '<button type="button" class="home-chip" data-act="pod-inbox">' + fresh + ' neue Folge' + (fresh === 1 ? '' : 'n') + '</button>' : '') +
'</div>';
return section('Podcasts', card + extra, 'podcasts');
}
function podCard(kicker, title, feed, art, leftSec, fraction, button) {
return '<div class="home-card home-main home-pod">' +
(art ? '<img class="home-art" alt="" loading="lazy" src="' + esc(art) + '">' : '<span class="home-art"></span>') +
'<span class="home-pod-text" data-act="pod-open">' +
'<span class="home-kicker">' + esc(kicker) + '</span>' +
'<span class="home-title">' + esc(title) + '</span>' +
'<span class="home-sub">' + esc(feed) + (leftSec > 30 ? ' · noch ' + minutes(leftSec) : '') + '</span>' +
'<span class="home-state">' + bar(fraction) + '</span></span>' +
button + '</div>';
}
// ---- Nachrichten --------------------------------------------------------------
// The briefing comes from the server, so it's fetched now and then, not every render.
let feeds = null;
let feedsAt = 0;
let feedsBusy = false;
function refreshFeeds(maxAgeMs) {
if (typeof window.__dioraFeedsSummary !== 'function' || feedsBusy || !online()) return;
if (Date.now() - feedsAt < maxAgeMs) return;
feedsBusy = true;
window.__dioraFeedsSummary().then(r => { feeds = r; }, () => {}).then(() => {
feedsAt = Date.now();
feedsBusy = false;
render();
});
}
function feedsSection() {
if (!document.getElementById('tab-feeds') || !feeds || !feeds.state) return '';
const s = feeds.state;
const sources = s.sources || [];
if (!sources.length) return '';
const hasNews = sources.some(x => x.kind === 'news');
const top = ((feeds.briefing && feeds.briefing.top) || []).slice(0, 4);
let body = '';
if (top.length) {
body = '<div class="home-card home-news">' + top.map(x => {
const lead = x.lead || {};
return '<button type="button" class="home-row home-story" data-act="story" data-id="' + esc(x.id) + '">' +
'<span class="home-story-title">' + esc(lead.title) + '</span>' +
'<span class="home-row-side">' + esc(lead.source_title) + (x.sources > 1 ? ' +' + (x.sources - 1) : '') + '</span></button>';
}).join('') + '</div>';
} else if (hasNews) {
body = '<div class="home-card home-empty">' + (s.news_new ? 'Keine Top-Themen seit dem letzten Mal.' : 'Nichts Neues seit dem letzten Mal.') + '</div>';
}
const chips = (s.news_new ? '<button type="button" class="home-chip" data-act="feeds" data-view="news">' +
s.news_new + (s.news_new === 1 ? ' neue Meldung' : ' neue Meldungen') + '</button>' : '') +
(s.blogs_unread ? '<button type="button" class="home-chip" data-act="feeds" data-view="blogs">' +
s.blogs_unread + (s.blogs_unread === 1 ? ' neuer Blogartikel' : ' neue Blogartikel') + '</button>' : '');
if (!body && !chips) return '';
return section(hasNews ? 'Nachrichten' : 'Blogs', body + (chips ? '<div class="home-chips">' + chips + '</div>' : ''), 'feeds');
}
function openFeeds(view) {
showTab('feeds');
const b = view && document.querySelector('#tab-feeds [data-view="' + view + '"]:not(.on)');
if (b) b.click();
}
// ---- Radio ------------------------------------------------------------------
function stationRows() {
return Array.prototype.slice.call(document.querySelectorAll('#saved-tbody tr[data-id]'));
}
function radioSection() {
if (!document.getElementById('tab-radio')) return '';
// The app shows the radio only where it has one (native-radio.js marks <html>).
if (window.DioraNative && !document.documentElement.classList.contains('nui-radio')) return '';
const rows = stationRows();
const favs = rows.filter(r => r.querySelector('.fav-btn.active'));
const pick = (favs.length ? favs : rows).slice(0, 8);
const cur = typeof currentStation !== 'undefined' ? currentStation : null;
const playing = typeof isPlaying !== 'undefined' && isPlaying;
let chips = '';
if (cur && !pick.some(r => r.dataset.url === cur.url)) {
chips += chip(cur.url, cur.name, playing, 'Zuletzt: ');
}
chips += pick.map(r => chip(r.dataset.url, r.dataset.name, playing && cur && cur.url === r.dataset.url, '')).join('');
let body;
if (!chips) {
body = '<div class="home-card home-empty">Noch keine Sender gespeichert. ' +
'<button type="button" class="btn btn-sm" data-act="tab" data-tab="radio">Sender suchen</button></div>';
} else {
body = '<div class="home-stations">' + chips + '</div>';
}
if (!online()) body += '<div class="home-note">Offline — Radio braucht eine Verbindung.</div>';
return section(favs.length ? 'Radio · Favoriten' : 'Radio', body, 'radio');
}
function chip(url, name, playing, prefix) {
return '<button type="button" class="home-station' + (playing ? ' home-on' : '') + '" data-act="station" data-url="' +
esc(url) + '" data-name="' + esc(name) + '">' +
'<span class="home-station-icon">' + (playing ? '■' : '▶') + '</span>' +
'<span class="home-station-name">' + esc(prefix) + esc(name) + '</span></button>';
}
// ---- Ambient ----------------------------------------------------------------
function ambientSection() {
if (!document.getElementById('tab-ambient') || typeof window.__dioraAmbientInfo !== 'function') return '';
if (window.DioraNative && !document.querySelector('#nui-tabbar [data-tab="ambient"]') &&
document.getElementById('nui-tabbar')) return '';
const info = window.__dioraAmbientInfo() || {};
let label = info.mode || '';
if (!label) { try { label = localStorage.getItem('diora_ambient_mode_label') || ''; } catch (e) {} }
const h = podHost();
const player = h && h.playerState ? parse(h.playerState(), {}) || {} : {};
const left = player.sleep && player.sleep.leftMs > 0 ? ' · ☾ ' + minutes(player.sleep.leftMs / 1000) : '';
const body = '<div class="home-card home-main home-amb">' +
'<span class="home-amb-icon">∿</span>' +
'<span class="home-pod-text" data-act="tab" data-tab="ambient">' +
'<span class="home-kicker">' + (info.playing ? 'Läuft gerade' : 'Drift') + '</span>' +
'<span class="home-title">' + esc(label || 'Ambient') + '</span>' +
'<span class="home-sub">Generative Klangwelten' + esc(left) + '</span></span>' +
(document.getElementById('nui-amb-sleep') ? '<button type="button" class="btn btn-sm" data-act="amb-sleep" title="Sleep-Timer">☾</button>' : '') +
'<button type="button" class="btn btn-sm home-play" data-act="' + (info.playing ? 'amb-stop' : 'amb-start') + '">' +
(info.playing ? '■ Stopp' : '▶ Starten') + '</button></div>';
return section('Ambient', body, 'ambient');
}
// ---- page ---------------------------------------------------------------------
// ---- Hörbücher (static/js/audiobooks.js) -------------------------------------------
function audiobookSection() {
const h = podHost();
if (!h || !document.getElementById('tab-audiobooks') || !window.__dioraAudiobookGo) return '';
const player = h.playerState ? parse(h.playerState(), {}) || {} : {};
let books = [];
try { books = JSON.parse(localStorage.getItem('diora_ab_books') || '[]') || []; } catch (e) {}
let card;
if (player.kind === 'podcast' && player.book) {
const b = player.book;
const left = player.dur ? (player.dur - (player.pos || 0)) / 1000 : 0;
card = podCard((player.playing ? 'Läuft gerade' : 'Weiterhören') + ' · Kapitel ' + (b.index + 1) + '/' + b.chapters,
player.title, b.title || player.feed, player.artwork, left, player.dur ? (player.pos || 0) / player.dur : 0,
'<button type="button" class="btn btn-sm home-play" data-act="' + (player.playing ? 'pod-pause' : 'pod-resume') +
'" data-id="' + esc(player.episodeId) + '">' + (player.playing ? '■ Pause' : '▶ Weiter') + '</button>');
} else {
const b = books.filter(x => x.next && x.last_played_at)[0];
if (!b) return '';
card = podCard('Weiterhören · Kapitel ' + (b.next.index + 1) + '/' + b.chapters, b.title, b.author || '',
b.artwork_url, b.total_seconds ? b.total_seconds - b.heard_seconds : 0,
b.total_seconds ? b.heard_seconds / b.total_seconds : 0,
'<button type="button" class="btn btn-sm home-play" data-act="ab-go" data-id="' + esc(b.feed_id) + '">▶ Weiterhören</button>');
}
return section('Hörbücher', card, 'audiobooks');
}
function section(title, body, tabName) {
return '<section class="home-section"><div class="home-head"><h2>' + esc(title) + '</h2>' +
'<button type="button" class="home-more" data-act="tab" data-tab="' + tabName + '">Alle ›</button></div>' + body + '</section>';
}
let lastHtml = '';
let host = null;
function render() {
if (!visible()) return;
if (!host) {
host = document.createElement('div');
host.className = 'home';
tab.appendChild(host);
}
let html = '<div class="home-brand">diora</div>';
refreshFeeds(3 * 60000);
for (const part of [booksSection, audiobookSection, podcastSection, feedsSection, radioSection, ambientSection]) {
try { html += part(); } catch (e) { console.warn('home', e); }
}
if (html !== lastHtml) {
lastHtml = html;
host.innerHTML = html;
}
}
function playStationFromHome(url, name) {
const cur = typeof currentStation !== 'undefined' ? currentStation : null;
if (typeof isPlaying !== 'undefined' && isPlaying && cur && cur.url === url) { stopPlayback(false); return; }
const row = stationRows().find(r => r.dataset.url === url);
const b = row && row.querySelector('button[onclick*="playStation"]');
if (b) b.click();
else playStation(url, name, row ? Number(row.dataset.id) || null : (cur && cur.url === url ? cur.id : null));
}
tab.addEventListener('click', e => {
const el = e.target.closest('[data-act]');
if (!el || !tab.contains(el)) return;
const act = el.dataset.act;
const h = podHost();
if (act === 'tab') showTab(el.dataset.tab);
else if (act === 'book') openBook(Number(el.dataset.id));
else if (act === 'story') {
const id = Number(el.dataset.id);
const story = feeds && feeds.briefing && (feeds.briefing.top || []).find(x => x.id === id);
if (story && window.__dioraFeedsOpenStory) { window.__dioraFeedsOpenStory(story); feedsAt = 0; }
else openFeeds('news');
} else if (act === 'feeds') { openFeeds(el.dataset.view); feedsAt = 0; }
else if (act === 'station') playStationFromHome(el.dataset.url, el.dataset.name);
else if (act === 'pod-queue' && h) {
const q = (parse(h.podcastState(), {}).queue || [])[0];
if (q) h.podcastPlayEpisode(JSON.stringify(q), 0);
} else if (act === 'pod-resume' && h) h.podcastPlay(Number(el.dataset.id));
else if (act === 'pod-pause' && h) h.podcastToggle();
else if (act === 'ab-go' && window.__dioraAudiobookGo) window.__dioraAudiobookGo(Number(el.dataset.id));
else if (act === 'pod-here' && h && h.playerTakeOver) h.playerTakeOver();
else if (act === 'pod-open') {
if (window.__dioraOpenPlayer && h && parse(h.playerState(), {}).kind) window.__dioraOpenPlayer('podcast');
else showTab('podcasts');
} else if (act === 'pod-inbox') {
showTab('podcasts');
if (window.__dioraPodcastView) window.__dioraPodcastView('inbox');
} else if (act === 'amb-start' && window.__dioraAmbientStart) window.__dioraAmbientStart();
else if (act === 'amb-stop' && window.__dioraAmbientCmd) window.__dioraAmbientCmd('stop');
else if (act === 'amb-sleep') { const b = $('nui-amb-sleep'); if (b) b.click(); }
setTimeout(render, 300);
});
// Fresh whenever it shows, and while it shows (players move on, the book list arrives).
window.__dioraHomeShow = function () {
refreshFeeds(30000);
render();
};
if (typeof window.renderBookList === 'function') {
const renderBookList = window.renderBookList;
window.renderBookList = function () {
const r = renderBookList.apply(this, arguments);
render();
return r;
};
}
setInterval(render, 2000);
// The start page may have opened Home before this file ran (podcasts-web.js, native.js).
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', render);
else render();
const style = document.createElement('style');
style.textContent = [
'#tab-home .home { max-width: 760px; margin: 0 auto; padding-bottom: 24px; }',
// The wordmark as in the web's navbar: red, bold, the page's monospace.
'#tab-home .home-brand { font-size: 26px; font-weight: 700; color: var(--accent, #e63946); margin: 4px 0 18px; letter-spacing: 0.05em; }',
'#tab-home .home-section { margin-bottom: 22px; }',
'#tab-home .home-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8px; }',
'#tab-home .home-head h2 { margin: 0; font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted, #9a9a9a); }',
'#tab-home .home-more { background: none; border: none; color: var(--muted, #9a9a9a); font: inherit; font-size: 12px; cursor: pointer; padding: 4px 0; }',
'#tab-home .home-card { display: flex; align-items: center; gap: 12px; width: 100%; box-sizing: border-box; padding: 14px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; background: var(--card, #0b0b0b); box-shadow: var(--shadow, none); color: inherit; font: inherit; text-align: left; }',
'#tab-home button.home-card, #tab-home .home-pod-text, #tab-home .home-row, #tab-home .home-station { cursor: pointer; }',
'#tab-home .home-main { flex-wrap: nowrap; }',
'#tab-home button.home-main { flex-direction: column; align-items: flex-start; gap: 3px; }',
'#tab-home .home-empty { color: var(--muted, #9a9a9a); justify-content: space-between; }',
'#tab-home .home-kicker { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent, #e63946); }',
'#tab-home .home-title { font-size: 16px; font-weight: 700; line-height: 1.3; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }',
'#tab-home .home-sub { font-size: 13px; color: var(--muted, #9a9a9a); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }',
'#tab-home .home-state { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 12px; color: var(--muted, #9a9a9a); }',
'#tab-home .home-bar { display: inline-block; width: 160px; max-width: 40vw; height: 4px; border-radius: 2px; background: var(--raised, #2a2a2a); overflow: hidden; }',
'#tab-home .home-bar > span { display: block; height: 100%; background: var(--accent, #e63946); }',
'#tab-home .home-pod-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }',
'#tab-home .home-art { width: 56px; height: 56px; flex: none; border-radius: 4px; object-fit: cover; background: var(--raised, #1a1a1a); }',
'#tab-home .home-amb-icon { width: 56px; height: 56px; flex: none; border-radius: 4px; background: #14102a; color: #b9a7ff; display: inline-flex; align-items: center; justify-content: center; font-size: 26px; }',
'#tab-home .home-play { flex: none; min-height: 36px; padding: 0 14px; }',
'#tab-home .home-list { margin-top: 6px; }',
'#tab-home .home-row { display: flex; justify-content: space-between; gap: 12px; width: 100%; padding: 10px 14px; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; font-size: 14px; text-align: left; }',
'#tab-home .home-row-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
'#tab-home .home-row-side { flex: none; color: var(--muted, #9a9a9a); font-size: 12px; }',
'#tab-home .home-news { flex-direction: column; align-items: stretch; gap: 0; padding: 4px 0; }',
'#tab-home .home-story { align-items: baseline; }',
'#tab-home .home-news .home-story:last-child { border-bottom: none; }',
'#tab-home .home-story-title { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-height: 1.35; }',
'#tab-home .home-story .home-row-side { max-width: 35%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
'#tab-home .home-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }',
'#tab-home .home-chip { padding: 6px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 16px; background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }',
'#tab-home .home-stations { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; }',
'#tab-home .home-station { display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 8px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; background: var(--card, #0b0b0b); box-shadow: var(--shadow, none); color: inherit; font: inherit; font-size: 14px; text-align: left; }',
'#tab-home .home-station-icon { color: var(--accent, #e63946); flex: none; }',
'#tab-home .home-station-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
'#tab-home .home-on { border-color: var(--accent, #e63946); }',
'#tab-home .home-note { margin-top: 8px; font-size: 13px; color: var(--muted, #9a9a9a); }',
'@media (hover: hover) { #tab-home .home-card:hover, #tab-home .home-station:hover, #tab-home .home-row:hover { background: var(--hover, #141414); } }',
'.reader-theme-bright #tab-home, body.eink #tab-home .home-card { background: #fff; }',
].join('\n');
document.head.appendChild(style);
})();