diff --git a/docs/library.md b/docs/library.md index e839a36..257e978 100644 --- a/docs/library.md +++ b/docs/library.md @@ -115,8 +115,18 @@ knot, a spiral), karte (Geschichte), kosmos (Science-Fiction), ornament (Fantasy), raster (Politik, Soziologie), schatten (Krimi: a red line, a lit doorway), grusel (Horror), klassiker (patterned paper with a title plate), sakral (Religion & Mythologie), spiel (Kinder & Jugend), code (Informatik), -typo, wellen, botanik, berge, zellen — and bauhaus when nothing matches. A hash -of title and author picks palette, layout and every detail. +typo, wellen, botanik, berge, zellen, and since 2026-10-25 funk (talk and radio: waveform, +mast, speech bubbles), musik (record, staff), kunst (colour fields, brushstrokes), essen (plate +on a checked cloth, citrus), sport (track, pitch), geld (price chart, coins), reise (road, +route with compass) — and bauhaus when nothing matches (shapes, quarter-circle tiles, a +circle crossed by diagonals). Most concepts have two or three motifs. A hash of title and +author picks motif, palette, layout and every detail. + +**Square** (`{square: true}`, since 2026-10-25): audiobooks and podcasts without a picture, or +whose picture doesn't load (audiobooks.js; diora-android native-podcast.js `art()`, before the +initial letter). 300 × 300, three title lines at most; the band of the motif shown is centred +in what the text leaves free. Podcasts (`podcast: true`) nothing matches get funk, not +bauhaus; a Marx/Engels audiobook gets konstruktiv (the blue volume is a binding). ### Export as XML (since 2026-10-24) diff --git a/static/js/audiobooks.js b/static/js/audiobooks.js index 871292d..63d07ea 100644 --- a/static/js/audiobooks.js +++ b/static/js/audiobooks.js @@ -36,6 +36,11 @@ const shelf = document.createElement('div'); shelf.id = 'audiobook-shelf'; tab.appendChild(shelf); + // A cover that doesn't load gives way to the drawn one (cover(): data-gen; shelf and book view). + document.addEventListener('error', e => { + const img = e.target; + if (img.tagName === 'IMG' && img.dataset.gen && img.getAttribute('src') !== img.dataset.gen) img.src = img.dataset.gen; + }, true); // ---- helpers ------------------------------------------------------------------------ @@ -74,11 +79,12 @@ function cover(url, title, cls, author) { const art = String(url || '').replace(/^http:/, 'https:'); - if (!art && window.DioraGenCover) { // none: one drawn from the title (gencover.js) - return ''; - } + // None, or one that doesn't load (the error listener on the shelf): one drawn from the title + // (gencover.js), square like the others. + const gen = window.DioraGenCover ? DioraGenCover.url({title: title || '', author: author || ''}, {square: true}) : ''; + if (!art && gen) return ''; return art - ? '' + ? '' : '' + esc((title || '?').trim().charAt(0).toUpperCase()) + ''; } diff --git a/static/js/gencover.js b/static/js/gencover.js index 095a5dd..53322cb 100644 --- a/static/js/gencover.js +++ b/static/js/gencover.js @@ -9,8 +9,11 @@ * the palette, the layout and every detail of the motif, so no two books look alike and * each always looks the same. Nothing is stored or sent: it's an SVG, made when needed. * - * DioraGenCover.url(book, {gray}) a data: URL (book: {title, author, shelfTag, folders}) - * DioraGenCover.theme(book) the concept chosen (for tests) + * DioraGenCover.url(book, {gray, square, podcast}) a data: URL (book: {title, author, shelfTag, folders}) + * DioraGenCover.theme(book, opts) the concept chosen (for tests) + * + * square: 300 × 300 for audiobooks and podcasts (the motif's middle, the text above or below); + * podcast: a show nothing else fits gets the radio concept rather than the plain shapes. * * Chrome-61-safe: no ?. / ??. */ @@ -141,6 +144,36 @@ 'treasure', 'schatz', 'green gables', 'narnia', 'wardrobe', 'coraline', 'unendliche'], palettes: [['#fdf3e1', '#2d2a26', '#f2a23a', '#4fa3a5'], ['#e8f4f1', '#23343a', '#ef7b5a', '#f4c54b'], ['#2f3e6b', '#fff6e6', '#f6c445', '#ef8a6f'], ['#f6ecf2', '#2c2433', '#e0607e', '#5bb08f']]}, + // Talk and radio: what podcasts mostly are (and their default, see theme()). + {id: 'funk', font: SANS, + words: ['podcast', 'radio', 'talk', 'gespräch', 'interview', 'sendung', 'show$', 'funk$', 'rundfunk', 'deutschlandfunk', + 'hörspiel', 'feature', 'on air', 'live', 'plauder', 'unterhaltung', 'conversation'], + palettes: [['#f3ece0', '#20232a', '#e2573b', '#3f8fa0'], ['#1c2230', '#efe9df', '#f0a73c', '#7fb7c9'], ['#e9eef0', '#1f2a33', '#5a63c9', '#e9806a'], + ['#2a1f2d', '#f4e8dc', '#ef6f6c', '#f2c45a']]}, + {id: 'musik', font: SERIF, + words: ['musik', 'music', 'jazz', 'song', 'lied', 'klang', 'sound', 'oper', 'opera', 'symphon', 'sinfon', 'konzert', 'concert', + 'rock', 'pop$', 'hip hop', 'hip-hop', 'rap$', 'techno', 'beethoven', 'mozart', 'bach$', 'album', 'vinyl', 'melod', 'harmon'], + palettes: [['#151515', '#efe9dd', '#e0a43a', '#c4473c'], ['#f1e9dc', '#1f1d1b', '#b6382f', '#2f6c8a'], ['#20263a', '#efe6d6', '#e78f6a', '#9ec3c7']]}, + {id: 'kunst', font: SERIF, + words: ['kunst', 'art$', 'arts$', 'malerei', 'painting', 'maler', 'painter', 'ästhetik', 'aesthetic', 'museum', 'design', + 'foto', 'photo', 'film', 'kino', 'cinema', 'bild', 'image', 'skulptur', 'galerie', 'gallery', 'avantgarde', 'avant-garde'], + palettes: [['#efe7da', '#2a2622', '#c4492f', '#e2b04a'], ['#2a2230', '#efe5d8', '#d96a4a', '#5f8fb0'], ['#e6e9ea', '#232629', '#8a3d5c', '#e5a23e']]}, + {id: 'essen', font: SERIF, + words: ['kochen', 'koch$', 'cook', 'küche', 'kitchen', 'rezept', 'recipe', 'essen', 'food', 'backen', 'bake', 'baking', + 'wein', 'wine', 'kaffee', 'coffee', 'genuss', 'vegan', 'gemüse', 'brot', 'bread', 'mahl', 'meal'], + palettes: [['#f6efe2', '#2b2520', '#c8493a', '#e7b347'], ['#eef1e6', '#23301f', '#d2643c', '#6f9a54'], ['#2d2420', '#f3e9da', '#e09a3b', '#b9473a']]}, + {id: 'sport', font: NARROW, upper: true, + words: ['sport', 'fußball', 'fussball', 'football', 'soccer', 'laufen', 'running', 'marathon', 'fahrrad', 'cycling', + 'tennis', 'fitness', 'training', 'olymp', 'spieler', 'player', 'tor$', 'goal', 'liga', 'league', 'boxen', 'boxing'], + palettes: [['#e8efe6', '#1d2a22', '#d9483b', '#2f7a4f'], ['#1d2a3a', '#eef2f4', '#f2b23c', '#4f9fd0'], ['#f2ece2', '#22252a', '#c6462e', '#2e6aa0']]}, + {id: 'geld', font: SANS, + words: ['geld', 'money', 'finanz', 'financ', 'börse', 'market', 'markt', 'invest', 'bank$', 'banken', 'schulden', 'debt', + 'kredit', 'credit', 'zins', 'interest', 'aktie', 'stock', 'krise', 'crisis', 'inflation', 'reichtum', 'wealth', 'kapitalis', 'wirtschaft'], + palettes: [['#eef0e8', '#1e2a24', '#2f8a5f', '#c9a23f'], ['#1f2722', '#e9ece2', '#d9b24a', '#5fae86'], ['#f1ece3', '#24221f', '#b5462f', '#3f6f5a']]}, + {id: 'reise', font: SERIF, + words: ['reise', 'travel', 'journey', 'unterwegs', 'zug$', 'train', 'bahn$', 'eisenbahn', 'railway', 'straße', 'road', 'fahrt', + 'atlas', 'expedition', 'entdeck', 'discover', 'wander', 'pilger', 'pilgrim', 'odyssee', 'odyssey', 'kompass', 'compass', 'route'], + palettes: [['#f0e8d8', '#2a2a26', '#c0562f', '#3f7a8a'], ['#22303a', '#efe7d6', '#e3a04a', '#89b5bd'], ['#e7ece6', '#24302c', '#b0473a', '#d1a54a']]}, {id: 'code', font: MONO, words: ['program', 'comput', 'informatik', 'software', 'algorithm', 'linux', 'unix', 'python', 'javascript', 'rhcsa', 'red hat', 'interpretation of computer'], @@ -156,7 +189,9 @@ ['informatik', 'code'], ['schreib', 'typo'], ['gegenwartslit', 'typo'], ['roman', 'typo'], ['zeitschrift', 'typo'], ['religion', 'sakral'], ['mytholog', 'sakral'], ['klassiker', 'klassiker'], ['science', 'kosmos'], ['sci-fi', 'kosmos'], ['fantasy', 'ornament'], ['krimi', 'schatten'], ['thriller', 'schatten'], ['warhammer', 'schatten'], ['horror', 'grusel'], - ['kinder', 'spiel'], ['jugend', 'spiel'], + ['kinder', 'spiel'], ['jugend', 'spiel'], ['gespräch', 'funk'], ['podcast', 'funk'], ['radio', 'funk'], ['hörspiel', 'funk'], + ['comedy', 'funk'], ['musik', 'musik'], ['music', 'musik'], ['kunst', 'kunst'], ['film', 'kunst'], ['koch', 'essen'], + ['essen', 'essen'], ['sport', 'sport'], ['finanz', 'geld'], ['wirtschaft', 'geld'], ['reise', 'reise'], ]; const DEFAULT = {id: 'bauhaus', font: SANS, palettes: [['#efe9dd', '#24262a', '#d0533f', '#3f7fb0'], ['#26282d', '#efe8da', '#e3b04b', '#5b9aa8'], ['#e8ecef', '#22272c', '#c8553d', '#6b8f71'], @@ -198,10 +233,14 @@ const MARX_ENGELS = /(^|[^a-zäöü])(marx|engels)($|[^a-zäöü])/i; const BLAU = THEMES.filter(t => t.id === 'blau')[0]; - function theme(book) { + const byId = id => THEMES.filter(t => t.id === id)[0]; + + function theme(book, opts) { + // The blue volume is a book's binding: a square (audiobook, podcast) gets the red of the movement. + if (opts && opts.square && (MARX_ENGELS.test(book.author || '') || /mew|marx.engels.werke/i.test(book.series || ''))) return byId('konstruktiv'); if (MARX_ENGELS.test(book.author || '') || /mew|marx.engels.werke/i.test(book.series || '')) return BLAU; const s = scored(book).filter(x => x.t !== BLAU); - return s.length ? s[0].t : DEFAULT; + return s.length ? s[0].t : opts && opts.podcast ? byId('funk') : DEFAULT; } // ---- motifs: each draws into the whole cover, the text gets its own space later ----------- @@ -285,6 +324,16 @@ return s; }, wellen(r, p) { + if (r() < 0.4) { // the sun on the horizon, its light broken on the water + const hy = between(r, 250, 300), sx = between(r, 90, 210), sr = between(r, 40, 62); + let s = `` + + ``; + for (let y = hy + 10, i = 0; y < H - 10; y += 11 + i * 1.5, i++) { + const w = sr * 2 * (1 - i * 0.05) * between(r, 0.6, 1.1); + if (w > 6) s += ``; + } + return s; + } let s = ''; const n = 6 + Math.floor(r() * 5); const amp = between(r, 6, 16), len = between(r, 60, 120); @@ -298,6 +347,20 @@ return s; }, karte(r, p) { + if (r() < 0.4) { // a graticule, a coast, a route across it + let s = ''; + for (let x = 30; x < W; x += 40) s += ``; + for (let y = 30; y < H; y += 40) s += ``; + const cx = between(r, 100, 200), cy = between(r, 260, 330), ph1 = r() * 6, ph2 = r() * 6; + let d = ''; + for (let k = 0; k <= 48; k++) { + const a = k / 48 * Math.PI * 2, rad = 70 * (1 + 0.25 * Math.sin(a * 3 + ph1) + 0.12 * Math.sin(a * 7 + ph2)); + d += (k ? ' L' : 'M') + f1(cx + Math.cos(a) * rad * 1.2) + ' ' + f1(cy + Math.sin(a) * rad); + } + s += ``; + return s + ``; + } let s = ''; const cx = between(r, 80, 220), cy = between(r, 160, 320); for (let i = 1; i <= 9; i++) { @@ -313,6 +376,18 @@ return s; }, kosmos(r, p) { + if (r() < 0.4) { // the moon's phases in a row, under a few stars + let s = ''; + for (let i = 0; i < 40; i++) s += ``; + const y = between(r, 260, 320), rad = 22; + for (let i = 0; i < 5; i++) { + const x = 40 + i * 55; + s += ``; + if (i !== 2) s += ``; + s += ``; + } + return s; + } let s = ''; for (let i = 0; i < 70; i++) s += ``; const cx = between(r, 90, 210), cy = between(r, 170, 290), pr = between(r, 30, 55); @@ -334,6 +409,22 @@ return s; }, botanik(r, p) { + if (r() < 0.4) { // a fern frond, bending + const x0 = between(r, 110, 190), bend = between(r, -0.6, 0.6), n = 14, pts = []; + for (let i = 0; i <= n; i++) { + const t = i / n; + pts.push([x0 + Math.sin(t * 1.6) * bend * 90, H - 10 - t * 300]); + } + let s = ``; + pts.slice(1, -1).forEach((q, i) => { + const len = (1 - i / n) * between(r, 40, 60) + 8; + [-1, 1].forEach(side => { + s += ``; + }); + }); + return s; + } let s = ''; const n = 5 + Math.floor(r() * 4); for (let i = 0; i < n; i++) { @@ -345,6 +436,17 @@ return s; }, berge(r, p) { + if (r() < 0.4) { // one peak and its snow, the sky in bands + let s = ''; + for (let i = 0; i < 5; i++) s += ``; + const px = between(r, 100, 200), py = between(r, 170, 220), base = H - 30; + const lx = px - between(r, 150, 220), rx = px + between(r, 150, 220); + s += ``; + const k = between(r, 0.22, 0.32), sl = px + (lx - px) * k, sr = px + (rx - px) * k, sy = py + (base - py) * k; + s += ``; + return s + ``; + } let s = ``; for (let i = 0; i < 4; i++) { const base = 260 + i * 45; @@ -356,6 +458,17 @@ return s; }, zellen(r, p) { + if (r() < 0.4) { // the double helix, across + const cy = between(r, 250, 310), amp = between(r, 40, 60), len = between(r, 110, 150), ph = r() * 6; + let a = '', b = '', rungs = ''; + for (let x = -10; x <= W + 10; x += 6) { + const y = Math.sin(x / len * Math.PI * 2 + ph) * amp; + a += (x > -10 ? ' L' : 'M') + x + ' ' + f1(cy + y); + b += (x > -10 ? ' L' : 'M') + x + ' ' + f1(cy - y); + if ((x + 10) % 18 === 0) rungs += ``; + } + return rungs + ``; + } let s = ''; for (let i = 0; i < 16; i++) { const x = between(r, 20, 280), y = between(r, 140, 430), rad = between(r, 12, 42); @@ -401,6 +514,15 @@ ``; }, typo(r, p, book) { + if (r() < 0.45) { // a page of type with one line marked, a quotation mark + let s = `“`; + const mark = 4 + Math.floor(r() * 8); + for (let i = 0, y = 190; y < H - 30; i++, y += 15) { + const w = i % 7 === 6 ? between(r, 60, 160) : between(r, 200, 248); + s += ``; + } + return s; + } const ch = (book.title || '?').replace(/^[^A-Za-zÄÖÜäöüß0-9]+/, '').charAt(0).toUpperCase() || '?'; return `${esc(ch)}` + @@ -560,7 +682,184 @@ s += ``; return s; }, + funk(r, p) { + const v = r(); + if (v < 0.45) { // a voice as a waveform, mirrored about its line + const cy = between(r, 270, 320), n = 34, gap = (W - 30) / n; + let s = ``; + let level = between(r, 0.2, 0.6); + for (let i = 0; i < n; i++) { + level = Math.max(0.08, Math.min(1, level + between(r, -0.3, 0.3))); + const h = level * between(r, 60, 110), c = level > 0.75 ? p[2] : i % 9 === 4 ? p[3] : p[1]; + s += ``; + } + return s; + } + if (v < 0.75) { // a mast, its waves going out + const x = between(r, 60, 240), y = between(r, 300, 360); + let s = ''; + for (let i = 1; i <= 6; i++) { + s += ``; + } + return s + ``; + } + // two speech bubbles, one answering the other + const bubble = (x, y, w, h, left, fill, stroke) => ``; + const w1 = between(r, 150, 200), w2 = between(r, 130, 180); + return bubble(between(r, 20, 50), between(r, 190, 220), w1, between(r, 70, 90), true, p[2], 'none') + + bubble(W - 20 - w2 - between(r, 0, 30), between(r, 310, 330), w2, between(r, 60, 80), false, 'none', p[1]); + }, + musik(r, p) { + if (r() < 0.5) { // a record, half out of its sleeve + const cx = between(r, 170, 230), cy = between(r, 280, 320), R = between(r, 110, 130); + let s = `` + + ``; + for (let g = R - 8; g > R * 0.4; g -= between(r, 4, 9)) { + s += ``; + } + return s + ``; + } + // a staff with notes on it + const y0 = between(r, 220, 280), gap = 11; + let s = ''; + for (let k = 0; k < 5; k++) s += ``; + for (let x = 30; x < W - 20; x += between(r, 26, 44)) { + const y = y0 + 4 * gap - Math.floor(between(r, -3, 11)) * gap / 2, c = r() < 0.2 ? p[2] : p[1]; + s += `` + + ``; + } + return s + ``; + }, + kunst(r, p) { + if (r() < 0.5) { // colour fields one above the other, their edges soft + const top = between(r, 150, 180), gap = between(r, 10, 18), h1 = between(r, 110, 150); + const field = (y, h, c, o) => `` + + ``; + return field(top, h1, p[2], 0.95) + field(top + h1 + gap, H - 30 - (top + h1 + gap), pick(r, [p[3], p[1]]), 0.85); + } + // brushstrokes + let s = ''; + for (let i = 0; i < 4; i++) { + const x0 = between(r, -20, 80), y0 = between(r, 180, 420), x1 = between(r, 220, 320), y1 = y0 + between(r, -80, 80); + s += ``; + } + return s; + }, + essen(r, p) { + if (r() < 0.5) { // a plate on a checked cloth + const sz = pick(r, [20, 24, 28]), cx = between(r, 120, 180), cy = between(r, 280, 320); + return `` + + `` + + `` + + `` + + `` + + ``; + } + // citrus slices + let s = ''; + for (let i = 0; i < 5; i++) { + const x = between(r, 40, 260), y = between(r, 180, 410), R = between(r, 30, 52), n = pick(r, [8, 9, 10]), c = pick(r, [p[2], p[3]]); + s += ``; + for (let k = 0; k < n; k++) { + const a = k * Math.PI * 2 / n; + s += ``; + } + } + return s; + }, + sport(r, p) { + if (r() < 0.5) { // the bend of a running track, its lanes + const cx = pick(r, [40, W - 40]), cy = between(r, 300, 340); + let s = ``; + for (let i = 0; i <= 6; i++) s += ``; + return s + ``; + } + // a pitch from above + const x0 = 30, x1 = W - 30, y0 = 150, y1 = H - 20, my = (y0 + y1) / 2; + return `` + + `` + + `` + + `` + + ``; + }, + geld(r, p) { + if (r() < 0.55) { // a price over time, the area under it + let s = ''; + for (let y = 170; y < H - 20; y += 40) s += ``; + let v = between(r, 300, 380); + const pts = []; + for (let x = 20; x <= W - 20; x += 10) { v = Math.max(170, Math.min(H - 30, v + between(r, -26, 22))); pts.push([x, v]); } + const d = pts.map((q, i) => (i ? 'L' : 'M') + q[0] + ' ' + f1(q[1])).join(' '), last = pts[pts.length - 1]; + return s + `` + + `` + + ``; + } + // coins in stacks + let s = ''; + const n = 4 + Math.floor(r() * 3), w = (W - 40) / n; + for (let i = 0; i < n; i++) { + const cx = 20 + w * (i + 0.5), k = 2 + Math.floor(r() * 12); + for (let j = 0; j < k; j++) { + const y = H - 40 - j * 11; + s += `` + + ``; + } + } + return s; + }, + reise(r, p) { + if (r() < 0.5) { // a road to the horizon + const hy = between(r, 220, 260), vx = between(r, 110, 190); + let s = `` + + `` + + ``; + for (let t = 0.08; t < 1; t = t * 1.45 + 0.03) { + const y = hy + (H - hy) * t, x = vx + (W / 2 - vx) * t; + s += ``; + } + return s; + } + // a route across, its stops, a compass + const pts = []; + for (let i = 0; i < 5; i++) pts.push([between(r, 30, 270), 170 + i * 60 + between(r, -15, 15)]); + let s = ``; + pts.forEach((q, i) => { + const end = i === pts.length - 1; + s += ``; + }); + return s + `` + + `` + + ``; + }, bauhaus(r, p) { + const v = r(); + if (v < 0.3) { // tiles, each a quarter circle turned its own way + const sz = (W - 40) / 4; + let s = ''; + for (let i = 0; i < 4; i++) for (let j = 0; 170 + (j + 1) * sz <= H - 10; j++) { + const x = 20 + i * sz, y = 170 + j * sz, corner = Math.floor(r() * 4); + const dx = corner % 2 ? -1 : 1, dy = corner > 1 ? -1 : 1, cx = dx > 0 ? x : x + sz, cy = dy > 0 ? y : y + sz; + s += `` + + ``; + } + return s; + } + if (v < 0.55) { // a big circle off the edge, crossed by thin diagonals + const cx = pick(r, [0, W]), a = cx ? -1 : 1, n = 7 + Math.floor(r() * 5); + let s = ``; + for (let i = 0, x = a > 0 ? -160 : 40; i < n; i++, x += between(r, 34, 44)) { + s += ``; + } + return s; + } let s = ''; for (let i = 0; i < 4; i++) { const kind = Math.floor(r() * 3), x = between(r, 30, 270), y = between(r, 150, 410), size = between(r, 40, 110); @@ -608,7 +907,8 @@ return s.length >= 3 ? s : String(t || '?'); } - function textBlock(r, th, p, book) { + // h: the cover's height (a square's is its width); max: lines of title at most. + function textBlock(r, th, p, book, h, max) { const title = mainTitle(book.title); const upper = th.upper; const t = upper ? title.toUpperCase() : title; @@ -619,7 +919,7 @@ const em = upper ? 0.74 : 0.58; const size = Math.max(14, Math.min(len <= 10 ? 40 : len <= 18 ? 34 : len <= 30 ? 28 : len <= 48 ? 23 : 19, Math.floor(248 / (longest * em)))); - const ls = lines(t, size, 5, upper ? 0.72 : 0.56); + const ls = lines(t, size, max, upper ? 0.72 : 0.56); const lh = size * 1.12; const layout = pick(r, th.layouts || ['top', 'top', 'bottom', 'plate']); const author = String(book.author || '').split(/[;,]/)[0].trim(); @@ -629,8 +929,8 @@ y0 = 34; s += ``; } else if (layout === 'bottom') { - y0 = H - blockH + 20; - s += ``; + y0 = h - blockH + 20; + s += ``; } else { y0 = 70; s += ``; @@ -647,7 +947,8 @@ const asz = Math.max(8, Math.min(12, Math.floor((240 / author.length - spacing) / 0.7))); s += `${esc(author.toUpperCase())}`; } - return s; + // free: the band the text leaves to the motif (a square centres the motif in it). + return {svg: s, free: layout === 'top' ? [blockH, h] : layout === 'bottom' ? [0, h - blockH] : [y0 - 34 + blockH, h]}; } // ---- the cover --------------------------------------------------------------------------------------- @@ -659,24 +960,32 @@ return '#' + h + h + h; } + // A square shows a band of the 2:3 motif: its heart (about here, the motifs keep the top of the + // cover for the text) in the middle of what the text leaves free. + const MOTIF_HEART = 260; + function svg(book, opts) { - const th = theme(book); + const square = !!(opts && opts.square); + const th = theme(book, opts); const r = rng(hash(norm(book.title) + '|' + norm(book.author))); let p = pick(r, th.palettes); if (opts && opts.gray) p = p.map(gray); const motif = (M[th.id] || M.bauhaus)(r, p, book); - return `` + - `${motif}${th.ownText ? '' : textBlock(r, th, p, book)}`; + const h = square ? W : H; + const text = th.ownText ? {svg: '', free: [0, h]} : textBlock(r, th, p, book, h, square ? 3 : 5); + const shift = Math.max(0, Math.min(H - h, Math.round(MOTIF_HEART - (text.free[0] + text.free[1]) / 2))); + return `` + + `${square ? `${motif}` : motif}${text.svg}`; } const cache = new Map(); function url(book, opts) { const key = (book.title || '') + '|' + (book.author || '') + '|' + (book.shelfTag || '') + '|' + ((book.folders || []).join(',')) + '|' + (book.series || '') + '|' + (book.seriesIndex == null ? '' : book.seriesIndex) + '|' + - (opts && opts.gray ? 1 : 0); + (opts && opts.gray ? 1 : 0) + (opts && opts.square ? 's' : '') + (opts && opts.podcast ? 'p' : ''); if (!cache.has(key)) cache.set(key, 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg(book, opts))); return cache.get(key); } - window.DioraGenCover = {url, svg, theme: book => theme(book).id}; + window.DioraGenCover = {url, svg, theme: (book, opts) => theme(book, opts).id}; })(); diff --git a/static/js/sw.js b/static/js/sw.js index 945c4fa..e3547e3 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v151'; +const CACHE = 'diora-v152'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js', diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js index 07df488..70a6ab6 100644 --- a/static/podcasts/native-podcast.js +++ b/static/podcasts/native-podcast.js @@ -1,4 +1,4 @@ -// Copied from diora-android 9a6c059 2026-10-25: web-native/static/native-podcast.js +// Copied from diora-android 6cfec49 2026-10-25: web-native/static/native-podcast.js // by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android // and copy again. The browser host is static/js/podcasts-web.js. @@ -402,11 +402,14 @@ return f ? f.artwork_url : ''; } - // A cover: the episode's own, else its podcast's, else a tile with the podcast's initial. + // A cover: the episode's own, else its podcast's, else one drawn from the podcast's name + // (diora-web gencover.js, square), else a tile with its initial. function art(src, size, fallback, name) { const box = el('div', 'nui-pod-art' + (size ? ' nui-pod-art-' + size : '')); const srcs = [src, fallback].filter(Boolean).map(s => s.replace(/^http:/, 'https:')) .filter((s, i, all) => all.indexOf(s) === i); + const title = plain(name || '').trim(); + if (title && window.DioraGenCover) srcs.push(DioraGenCover.url({title}, {square: true, podcast: true})); const initial = () => { if (box.querySelector('img') || box.firstChild) return; const letter = (plain(name || '').replace(/^(the|der|die|das)\s+/i, '').match(/[0-9A-Za-zÀ-ÿ]/) || [''])[0];