diff --git a/docs/navigation.md b/docs/navigation.md index 627de39..2e77a86 100644 --- a/docs/navigation.md +++ b/docs/navigation.md @@ -83,5 +83,8 @@ A tab/rail button calls `DioraNav.go(id)`; the active state comes from 1. Navigation (this document), Hörbücher as their own panel, Zeitschriften by folder. 2. "Abos & Quellen": podcasts, YouTube channels, news sources, blogs and the audiobook mark in one place; the everyday views without managing. -3. Library: audiobook series (e.g. Das Kapital, Band 1–3) and folders, - magazines marked per book. +3. Library (done): audiobook series are found in the titles ("Das Kapital – + Erster Band" …) and shown as one stack; audiobooks have folders + (`PodcastFeed.folder`); a book is marked as a magazine in its encrypted + meta (`magazine: true/false`; unset: the "Zeitschriften" folder decides), and + Zeitschriften groups the issues by periodical. diff --git a/podcasts/audiobooks.py b/podcasts/audiobooks.py index 707ccf9..c29892a 100644 --- a/podcasts/audiobooks.py +++ b/podcasts/audiobooks.py @@ -87,6 +87,7 @@ def _book(feed, chapters, last_played_at): nxt = _next(chapters) return { 'feed_id': feed.id, 'title': feed.title, 'author': feed.author, 'artwork_url': feed.artwork_url, + 'folder': feed.folder, 'chapters': len(chapters), 'done': done, 'total_seconds': total, 'heard_seconds': heard, 'next': nxt, 'last_played_at': last_played_at.isoformat() if last_played_at else None, } diff --git a/podcasts/migrations/0020_audiobook_folder.py b/podcasts/migrations/0020_audiobook_folder.py new file mode 100644 index 0000000..1f0133a --- /dev/null +++ b/podcasts/migrations/0020_audiobook_folder.py @@ -0,0 +1,18 @@ +# Generated by Django 6.1 on 2026-10-16 13:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('podcasts', '0019_audiobooks'), + ] + + operations = [ + migrations.AddField( + model_name='podcastfeed', + name='folder', + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/podcasts/models.py b/podcasts/models.py index 28dc2fd..222a4fc 100644 --- a/podcasts/models.py +++ b/podcasts/models.py @@ -75,6 +75,8 @@ class PodcastFeed(models.Model): # Skip silence in this podcast's episodes: null = as the device is set. skip_silence = models.BooleanField(null=True, blank=True) kind = models.CharField(max_length=10, choices=KINDS, default=PODCAST) + # An audiobook's folder in the library ('' = none; one level, like the e-books' folders). + folder = models.CharField(max_length=100, blank=True) # The vocabulary of episode_sort and episode_filter: the podcast page's # sort menu and filter chips (native-podcast.js EP_SORTS and FILTERS). diff --git a/podcasts/test_audiobooks.py b/podcasts/test_audiobooks.py index aa4631d..cc59cf8 100644 --- a/podcasts/test_audiobooks.py +++ b/podcasts/test_audiobooks.py @@ -187,3 +187,14 @@ class UnplayedTests(TestCase): content_type='application/json') self.assertEqual(EpisodeProgress.objects.get(episode=ep).position_seconds, 0) self.assertEqual(EpisodeProgress.objects.get(episode=half).position_seconds, 200) # its place stays + + +class AudiobookFolderTests(TestCase): + def test_a_book_has_a_folder(self): + user = User.objects.create_user(username='alice', password='pw12345678') + self.client.force_login(user) + feed = PodcastFeed.objects.create(user=user, rss_url='https://example.org/b', title='B', kind='audiobook') + r = self.client.post(f'/podcasts/feeds/{feed.pk}/settings/', json.dumps({'folder': ' Marx und Engels '}), + content_type='application/json') + self.assertEqual(r.json()['folder'], 'Marx und Engels') + self.assertEqual(self.client.get('/podcasts/audiobooks/').json()['books'][0]['folder'], 'Marx und Engels') diff --git a/podcasts/tests.py b/podcasts/tests.py index a11bfb4..258b44f 100644 --- a/podcasts/tests.py +++ b/podcasts/tests.py @@ -1074,6 +1074,7 @@ FEED_DEFAULTS = { 'playback_speed': None, 'skip_intro': 0, 'skip_outro': 0, 'volume': 1.0, 'new_episodes': 'inbox', 'include_words': '', 'exclude_words': '', 'min_duration': 0, 'auto_queue': False, 'auto_download': False, 'notify': False, 'episode_sort': '', 'episode_filter': [], 'skip_silence': None, 'kind': 'podcast', + 'folder': '', } @@ -1100,7 +1101,7 @@ class FeedSettingsTests(TestCase): 'feed_id': self.feed.id, 'playback_speed': 1.5, 'skip_intro': 30, 'skip_outro': 600, 'volume': 2.0, 'new_episodes': 'inbox', 'include_words': 'News, Talk', 'exclude_words': 'Teaser', 'min_duration': 10, 'auto_queue': False, 'auto_download': False, 'notify': False, - 'episode_sort': '', 'episode_filter': [], 'skip_silence': None, 'kind': 'podcast', + 'episode_sort': '', 'episode_filter': [], 'skip_silence': None, 'kind': 'podcast', 'folder': '', }) self.feed.refresh_from_db() self.assertEqual((self.feed.playback_speed, self.feed.skip_intro, self.feed.volume, self.feed.min_duration), diff --git a/podcasts/views.py b/podcasts/views.py index 5320b31..46d75da 100644 --- a/podcasts/views.py +++ b/podcasts/views.py @@ -787,7 +787,7 @@ def feed_list(request): FEED_SETTING_FIELDS = ( 'playback_speed', 'skip_intro', 'skip_outro', 'volume', 'new_episodes', 'include_words', 'exclude_words', 'min_duration', 'auto_queue', 'auto_download', - 'notify', 'episode_sort', 'episode_filter', 'skip_silence', 'kind', + 'notify', 'episode_sort', 'episode_filter', 'skip_silence', 'kind', 'folder', ) @@ -1025,6 +1025,7 @@ FEED_SETTING_PARSERS = { 'episode_sort': _episode_sort, 'episode_filter': _episode_filter, 'kind': _kind, + 'folder': lambda v: ' '.join(str(v or '').split())[:100], 'skip_silence': _optional_boolean, } diff --git a/static/js/app/52-book-list.js b/static/js/app/52-book-list.js index 32f4f79..fe44304 100644 --- a/static/js/app/52-book-list.js +++ b/static/js/app/52-book-list.js @@ -41,8 +41,8 @@ async function loadBookList() { try { const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct); const meta = JSON.parse(new TextDecoder().decode(metaBuf)); - bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', filename: meta.filename || '', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0}; - decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); + bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', filename: meta.filename || '', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, magazine: meta.magazine}; + decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, magazine: meta.magazine, scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); } catch (e) { bookMetaCache[b.id] = {title: `Buch #${b.id}`, author: '', type: 'epub', filename: '', folder: '', isbn: '', shelfTag: ''}; decrypted.push({id: b.id, title: `Buch #${b.id}`, author: '', type: 'epub', folder: '', isbn: '', shelfTag: '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); @@ -59,7 +59,7 @@ async function loadBookList() { const cachedBooks = (await _loadCachedBookMeta()).map(_withPendingProgress); if (cachedBooks.length) { cachedBooks.sort((a, b) => (_bookRecency(b) - _bookRecency(a)) || (b.uploaded_at || '').localeCompare(a.uploaded_at || '')); - for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub', filename: b.filename || '', folder: b.folder || '', isbn: b.isbn || '', shelfTag: b.shelfTag || '', pages: b.pages || 0}; + for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub', filename: b.filename || '', folder: b.folder || '', isbn: b.isbn || '', shelfTag: b.shelfTag || '', pages: b.pages || 0, magazine: b.magazine}; const uploadArea = $('book-upload-area'); if (uploadArea) uploadArea.style.display = 'none'; renderBookList(cachedBooks); @@ -145,6 +145,7 @@ function repairBook(bookId) { isbn: isbn || prevMeta.isbn || '', folder: prevMeta.folder || '', shelfTag: prevMeta.shelfTag || '', + magazine: prevMeta.magazine, })); const [metaEnc, dataEnc] = await Promise.all([ encryptBytes(key, metaJson), @@ -205,11 +206,33 @@ async function toggleBookRead(bookId, currentlyRead) { // free in /api/sync/. One level only: folders don't nest. let _currentBookFolder = null; // null = root view (folder tiles + unfiled books) -// Bücher and Zeitschriften are two sections of Lesen (static/js/nav.js), one list: the -// magazines are the books in a folder called "Zeitschriften" (or "Magazine"). +// Bücher and Zeitschriften are two sections of Lesen (static/js/nav.js), one list: a +// magazine is an issue marked as one (⋮ → Als Zeitschrift, kept in the encrypted meta), +// or, unmarked, a book in a folder called "Zeitschriften" (or "Magazine"). let _bookSection = 'books'; const _MAGAZINE_FOLDER = /^(zeitschriften|magazine|magazines)$/i; -const _isMagazine = b => _MAGAZINE_FOLDER.test((b.folder || '').trim()); +const _isMagazine = b => b.magazine === true || (b.magazine !== false && _MAGAZINE_FOLDER.test((b.folder || '').trim())); +window._isMagazine = _isMagazine; + +// The periodical an issue belongs to: its title up to the issue's number ("Jacobin #24", +// "New Left Review 157", "LuXemburg 1/2026"), else its author ("Vol 9 – Issue 2–3" by Catalyst). +function _periodicalOf(b) { + const m = /^(.*?)\s*(?:#|\bno\.|\bnr\.|\bheft\b|\bausgabe\b|\bissue\b|\bvol\b|\d)/i.exec(b.title || ''); + const name = (m ? m[1] : '').replace(/[\s–—:,-]+$/, '').trim(); + return name.length >= 3 ? name : (b.author || '').trim() || (b.title || '').trim(); +} + +async function toggleBookMagazine(bookId) { + const book = _lastBookListData.find(b => b.id === bookId); + if (!book) return; + try { + await _updateBookMeta(bookId, {magazine: !_isMagazine(book)}); + renderBookList(_lastBookListData); + } catch (e) { + customAlert('Das ließ sich nicht speichern: ' + e.message); + } +} +window.toggleBookMagazine = toggleBookMagazine; // The books of the section shown (all of them where there are no sections). function _bookSectionFilter(books) { @@ -254,6 +277,7 @@ function _renderBookItemHtml(b) { ${broken ? `` : ''} ${b.has_highlights ? `` : ''} + @@ -295,7 +319,7 @@ function renderBookList(allBooks) { if (!books.length) { listEl.innerHTML = window.DioraNav && allBooks.length ? (_bookSection === 'magazines' - ? '

Noch keine Zeitschriften. Hefte, die in einem Ordner „Zeitschriften“ liegen, stehen hier (⋮ → Ordner zuweisen).

' + ? '

Noch keine Zeitschriften. Ein Heft unter Bücher über ⋮ → „Als Zeitschrift führen“ hierher holen.

' : '

Alle Bücher liegen bei den Zeitschriften.

') : ''; return; @@ -324,11 +348,22 @@ function renderBookList(allBooks) { } const grouped = new Map(); + const magazines = _bookSection === 'magazines' && !!window.DioraNav; for (const b of visible) { - const key = _bookSection === 'magazines' && _isMagazine(b) ? '' : (b.folder || ''); + const key = magazines ? _periodicalOf(b) : (b.folder || ''); if (!grouped.has(key)) grouped.set(key, []); grouped.get(key).push(b); } + if (magazines) { + // A periodical with one issue is no tile; issues newest first. + const loose = grouped.get('') || []; + for (const [k, list] of [...grouped]) { + if (k !== '' && list.length < 2) { loose.push(...list); grouped.delete(k); } + } + grouped.set('', loose); + const issue = (a, b) => b.title.localeCompare(a.title, 'de', {numeric: true}); + for (const list of grouped.values()) list.sort(issue); + } // Folder emptied out (last book moved/deleted, or read-filter hid it) — fall back to root. if (_currentBookFolder !== null && !grouped.has(_currentBookFolder)) { _currentBookFolder = null; @@ -339,7 +374,7 @@ function renderBookList(allBooks) { if (folderNames.length) { html += '
' + folderNames.map(name => ``).join('') @@ -375,6 +410,7 @@ async function _updateBookMeta(bookId, overrides) { folder: cached.folder || book.folder || '', shelfTag: cached.shelfTag || book.shelfTag || '', pages: cached.pages || book.pages || 0, + magazine: cached.magazine !== undefined ? cached.magazine : book.magazine, ...overrides, }; const key = await getOrCreateEncKey(); diff --git a/static/js/audiobooks.js b/static/js/audiobooks.js index b6cd249..a923d2b 100644 --- a/static/js/audiobooks.js +++ b/static/js/audiobooks.js @@ -28,6 +28,8 @@ picking: false, // choosing a podcast to make an audiobook pickQuery: '', now: null, // playerState() of the last poll + folder: null, // the folder shown (null: all) + series: null, // the series shown (its key), inside the folder }; const shelf = document.createElement('div'); @@ -189,13 +191,7 @@ html += '

Hörbücher

' + (pods.length ? '' : '') + '
'; if (books.length) { - html += '
' + books.map(b => - '').join('') + '
'; + html += gridHtml(books); } else { html += '

Podcasts, die eigentlich Hörbücher oder Hörspiele sind, lassen sich hier ' + 'wie ein Buch hören: in Kapitelreihenfolge, mit Lesezeichen. „+ Hörbuch“ wählt einen aus.

'; @@ -206,6 +202,131 @@ if (input && document.activeElement !== input && st.pickFocus) { input.focus(); st.pickFocus = false; } } + // ---- organising: folders (set per book) and series (found in the titles) ---------------- + + // "Das Kapital – Erster Band", "Harry Potter: Teil 3", "Die drei ??? – Folge 12": + // the series' name and the volume's number, or null. + const VOLUME_WORD = /\b(band|bd\.|teil|buch|folge|staffel|episode|vol\.?|volume|part|book)\b/i; + const ORDINALS = ['erst', 'zweit', 'dritt', 'viert', 'fünft', 'sechst', 'siebt', 'acht', 'neunt', 'zehnt', + 'elft', 'zwölft']; + const ORDINALS_EN = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth', 'ninth', 'tenth']; + const ROMAN = {i: 1, ii: 2, iii: 3, iv: 4, v: 5, vi: 6, vii: 7, viii: 8, ix: 9, x: 10, xi: 11, xii: 12}; + + function volumeNumber(text) { + const t = text.toLowerCase(); + const digits = /(\d+)/.exec(t); + if (digits) return parseInt(digits[1], 10); + for (const word of t.split(/[^a-zäöüß]+/)) { + if (ROMAN[word]) return ROMAN[word]; + for (let i = 0; i < ORDINALS.length; i++) if (word.indexOf(ORDINALS[i]) === 0) return i + 1; + for (let i = 0; i < ORDINALS_EN.length; i++) if (word === ORDINALS_EN[i]) return i + 1; + } + return 999; + } + + function seriesOf(b) { + const m = /^(.+?)\s+[–—-]\s+(.+)$/.exec(b.title) || /^(.+?):\s+(.+)$/.exec(b.title) || + /^(.+?)\.\s+(.+)$/.exec(b.title); // "Der Zauberberg. Erster Band" + if (!m || !(VOLUME_WORD.test(m[2]) || /^\s*\d+\s*$/.test(m[2]))) return null; + const name = m[1].trim(); + return {key: (name + '|' + (b.author || '')).toLowerCase(), name, number: volumeNumber(m[2]), volume: m[2].trim()}; + } + + // The books of one level (all, or one folder) as cards: series of two or more volumes + // become one stack; folders come first (at the top level). + function entries(books) { + const groups = new Map(); + const single = []; + books.forEach(b => { + const sr = seriesOf(b); + if (!sr) { single.push({kind: 'book', b, sort: b.title}); return; } + if (!groups.has(sr.key)) groups.set(sr.key, {kind: 'series', key: sr.key, name: sr.name, books: [], sort: sr.name}); + groups.get(sr.key).books.push(Object.assign({}, b, {_volume: sr.volume, _number: sr.number})); + }); + const out = single; + groups.forEach(g => { + if (g.books.length < 2) { out.push({kind: 'book', b: g.books[0], sort: g.books[0].title}); return; } + g.books.sort((x, y) => x._number - y._number || x.title.localeCompare(y.title)); + out.push(g); + }); + // The last listened first (as the list comes), then the rest by name. + const recent = e => { + const list = e.kind === 'series' ? e.books : [e.b]; + return list.map(x => x.last_played_at || '').sort().pop() || ''; + }; + return out.sort((x, y) => (recent(y) > recent(x) ? 1 : recent(y) < recent(x) ? -1 : 0) || + x.sort.localeCompare(y.sort, 'de')); + } + + function bookCard(b, label) { + return ''; + } + + function seriesCard(g) { + const total = g.books.reduce((n, b) => n + (b.total_seconds || 0), 0); + const heard = g.books.reduce((n, b) => n + (b.heard_seconds || 0), 0); + const going = g.books.filter(b => b.next && b.last_played_at)[0]; + const done = g.books.filter(b => !b.next).length; + const meta = g.books.length + ' Bände' + (going ? ' · bei ' + going._volume + : done === g.books.length ? ' · fertig gehört' : done ? ' · ' + done + ' gehört' : ''); + return ''; + } + + function gridHtml(books) { + const folders = new Map(); + books.forEach(b => { if (b.folder) folders.set(b.folder, (folders.get(b.folder) || 0) + 1); }); + if (st.folder != null && !folders.has(st.folder)) st.folder = null; + const here = books.filter(b => st.folder == null ? !b.folder : b.folder === st.folder); + const list = entries(here); + let html = ''; + if (st.series != null) { + const g = list.filter(e => e.kind === 'series' && e.key === st.series)[0]; + if (g) { + return '
' + esc(g.name) + '
' + + '
' + g.books.map(b => bookCard(b, b._volume)).join('') + '
'; + } + st.series = null; + } + if (st.folder != null) { + html += '
' + + '' + esc(st.folder) + '
'; + } else if (folders.size) { + html += '
' + Array.from(folders.keys()).sort((a, b) => a.localeCompare(b, 'de')).map(f => + '').join('') + '
'; + } + return html + '
' + list.map(e => e.kind === 'series' ? seriesCard(e) : bookCard(e.b)).join('') + '
'; + } + + // Into a folder ('' = none): the server keeps it with the subscription. + async function assignFolder(feedId) { + const b = (st.books || []).filter(x => x.feed_id === feedId)[0]; + const names = Array.from(new Set((st.books || []).map(x => x.folder).filter(Boolean))).sort(); + const ask = 'Ordner für „' + (b ? b.title : 'Hörbuch') + '“' + (names.length ? ' (vorhanden: ' + names.join(', ') + ')' : '') + + ' – leer lassen für keinen:'; + const name = typeof customPrompt === 'function' ? await customPrompt(ask, b ? b.folder || '' : '') : window.prompt(ask, b ? b.folder || '' : ''); + if (name === null || name === undefined) return; + try { + await api('/podcasts/feeds/' + feedId + '/settings/', {folder: String(name)}); + toast(String(name).trim() ? 'In „' + String(name).trim() + '“' : 'Ohne Ordner'); + } catch (e) { + toast('Das braucht eine Verbindung.'); + } + loadList(); + if (st.open === feedId) loadDetail(feedId); + } + function pickHtml(pods) { const q = st.pickQuery.trim().toLowerCase(); const found = pods.filter(f => !q || (f.title + ' ' + (f.author || '')).toLowerCase().indexOf(q) >= 0) @@ -273,6 +394,7 @@ '' + '
' + cover(b.artwork_url, b.title, 'ab-cover ab-cover-l') + '

' + esc(b.title) + '

' + @@ -453,6 +575,7 @@ else if (act === 'mark') addBookmark(); else if (act === 'unplayed') markAll(false); else if (act === 'played') markAll(true); + else if (act === 'folder') assignFolder(st.open); else if (act === 'podcast') { const ok = typeof customConfirm === 'function' ? await customConfirm('„' + st.detail.book.title + '“ wieder als Podcast führen? Es verlässt die Bücher; Stellen und Lesezeichen bleiben.') @@ -473,6 +596,13 @@ if (!t) return; if (t.dataset.abGo != null) { e.stopPropagation(); goOn(Number(t.dataset.abGo)); return; } if (t.dataset.abOpen != null) { openBook(Number(t.dataset.abOpen)); return; } + if (t.dataset.abFolder != null) { st.folder = t.dataset.abFolder; st.series = null; renderShelf(); return; } + if (t.dataset.abSeries != null) { st.series = t.dataset.abSeries; renderShelf(); return; } + if (t.dataset.ab === 'up') { + if (st.series != null) st.series = null; else st.folder = null; + renderShelf(); + return; + } if (t.dataset.abMark != null) { const id = Number(t.dataset.abMark); st.picking = false; @@ -549,6 +679,11 @@ return true; } if (st.picking && tab.style.display !== 'none') { st.picking = false; renderShelf(); return true; } + if ((st.series != null || st.folder != null) && tab.style.display !== 'none') { + if (st.series != null) st.series = null; else st.folder = null; + renderShelf(); + return true; + } return back ? back() : false; }; document.addEventListener('keydown', e => { @@ -616,6 +751,17 @@ '.ab-chapter.ab-done .ab-title { color: var(--muted, #9a9a9a); }', '.ab-chapter.ab-now .ab-title, .ab-chapter.ab-now .ab-num { color: var(--accent, #e63946); }', '.ab-mark, .ab-chrow { display: flex; align-items: center; }', + '.ab-place { display: flex; align-items: center; gap: 12px; margin: 0 0 10px; }', + '.ab-place .ab-back { padding: 6px 0; }', + '.ab-folders { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; margin-bottom: 14px; }', + '.ab-folder { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 8px; background: var(--card, #0b0b0b); color: inherit; font: inherit; cursor: pointer; text-align: left; }', + '.ab-folder .ab-title { flex: 1; min-width: 0; }', + '.ab-folder-icon { color: var(--muted, #9a9a9a); }', + '.ab-stack-covers { position: relative; display: block; }', + '.ab-stack-covers::before, .ab-stack-covers::after { content: ""; position: absolute; left: 6%; right: 6%; height: 100%; border-radius: 6px; border: 1px solid var(--border-strong, #444); background: var(--panel, #161616); }', + '.ab-stack-covers::before { top: -8px; left: 12%; right: 12%; }', + '.ab-stack-covers::after { top: -4px; }', + '.ab-stack-covers .ab-cover { position: relative; z-index: 1; }', '.ab-chrow .ab-row { flex: 1; min-width: 0; }', '.ab-chrow { border-bottom: 1px solid var(--border, #2a2a2a); }', '.ab-chrow .ab-row { border-bottom: none; }', diff --git a/static/js/podcasts-web.js b/static/js/podcasts-web.js index 6e230df..d4a52ff 100644 --- a/static/js/podcasts-web.js +++ b/static/js/podcasts-web.js @@ -76,7 +76,7 @@ const SORT_RULES = ['', 'new', 'old', 'short', 'long', 'feed']; const FEED_SETTINGS = ['playback_speed', 'skip_intro', 'skip_outro', 'volume', 'new_episodes', 'include_words', 'exclude_words', 'min_duration', 'auto_download', 'notify', - 'episode_sort', 'episode_filter', 'skip_silence', 'kind']; + 'episode_sort', 'episode_filter', 'skip_silence', 'kind', 'folder']; // The account's playback settings (diora-web PodcastSettings) and what each may be. const SKIPS = [5, 10, 15, 20, 30, 45, 60]; const MIN_SPEED = 0.5; diff --git a/static/js/sw.js b/static/js/sw.js index 3b2f9bc..d585cf5 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-v117'; +const CACHE = 'diora-v118'; 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 c082c04..7a80727 100644 --- a/static/podcasts/native-podcast.js +++ b/static/podcasts/native-podcast.js @@ -1,4 +1,4 @@ -// Copied from diora-android 50c6f9c 2026-10-16: web-native/static/native-podcast.js +// Copied from diora-android 3eed256 2026-10-16: 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. @@ -1801,7 +1801,7 @@ const items = podcastFeeds().slice().sort((a, b) => sort.cmp(a, b) || byTitle(a, b)); const books = feeds().length - items.length; const booksHint = () => el('div', 'nui-pod-hint', - `${books === 1 ? 'Ein Hörbuch' : books + ' Hörbücher'}: in Bücher → Hörbücher`); + `${books === 1 ? 'Ein Hörbuch' : books + ' Hörbücher'}: unter Hören → Hörbücher`); if (!items.length) { empty('Noch keine Abos. Unter „Suche“ findest du Podcasts.'); if (books) root.append(booksHint()); @@ -2005,8 +2005,8 @@ // An audiobook: its episodes are chapters, listened to in the library (diora-web docs/audiobooks.md). if ('kind' in live && N.podcastFeedSettings) { const book = isBook(live); - items.push({icon: '▤', label: book ? 'Hörbuch (in Bücher)' : 'Als Hörbuch', - hint: book ? 'tippen: wieder als Podcast' : 'Folgen als Kapitel in der Bibliothek, nicht in „Neu“', + items.push({icon: '▤', label: book ? 'Hörbuch (unter Hören → Hörbücher)' : 'Als Hörbuch', + hint: book ? 'tippen: wieder als Podcast' : 'Folgen als Kapitel unter Hören → Hörbücher, nicht in „Neu“', active: book, action: () => save({kind: book ? 'podcast' : 'audiobook'})}); } // Skip silence for this podcast (server), where this player can skip silence at all. diff --git a/static/podcasts/podcast-ui.css b/static/podcasts/podcast-ui.css index 52389e4..1dbdce9 100644 --- a/static/podcasts/podcast-ui.css +++ b/static/podcasts/podcast-ui.css @@ -1,4 +1,4 @@ -/* Copied from diora-android 50c6f9c 2026-10-16: the podcast parts of web-native/static/native.css +/* Copied from diora-android 3eed256 2026-10-16: the podcast parts of web-native/static/native.css (bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here. The web's own additions are in podcasts-web.css. */