Bibliothek ordnen (Navigation Phase 3): Hörbuch-Reihen und -Ordner, Zeitschriften je Heft
- Hörbücher: Reihen werden aus den Titeln erkannt („Das Kapital – Erster Band“, „…: Teil 3“, „… – Folge 12“, „Der Zauberberg. Erster Band“) und als Stapel gezeigt, darin die Bände in Reihenfolge. Ordner je Hörbuch (PodcastFeed.folder, Migration 0020), im Buch über ⋮ → Ordner zuweisen. - Zeitschriften: ein Heft wird als Zeitschrift markiert (⋮ → Als Zeitschrift führen, im verschlüsselten Meta); unmarkiert entscheidet weiter der Ordner „Zeitschriften“. Die Hefte stehen nach Zeitschrift gruppiert, neueste zuerst. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
143950df94
commit
d7930fcdb9
13 changed files with 246 additions and 27 deletions
|
|
@ -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.
|
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
|
2. "Abos & Quellen": podcasts, YouTube channels, news sources, blogs and the
|
||||||
audiobook mark in one place; the everyday views without managing.
|
audiobook mark in one place; the everyday views without managing.
|
||||||
3. Library: audiobook series (e.g. Das Kapital, Band 1–3) and folders,
|
3. Library (done): audiobook series are found in the titles ("Das Kapital –
|
||||||
magazines marked per book.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ def _book(feed, chapters, last_played_at):
|
||||||
nxt = _next(chapters)
|
nxt = _next(chapters)
|
||||||
return {
|
return {
|
||||||
'feed_id': feed.id, 'title': feed.title, 'author': feed.author, 'artwork_url': feed.artwork_url,
|
'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,
|
'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,
|
'next': nxt, 'last_played_at': last_played_at.isoformat() if last_played_at else None,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
podcasts/migrations/0020_audiobook_folder.py
Normal file
18
podcasts/migrations/0020_audiobook_folder.py
Normal file
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -75,6 +75,8 @@ class PodcastFeed(models.Model):
|
||||||
# Skip silence in this podcast's episodes: null = as the device is set.
|
# Skip silence in this podcast's episodes: null = as the device is set.
|
||||||
skip_silence = models.BooleanField(null=True, blank=True)
|
skip_silence = models.BooleanField(null=True, blank=True)
|
||||||
kind = models.CharField(max_length=10, choices=KINDS, default=PODCAST)
|
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
|
# The vocabulary of episode_sort and episode_filter: the podcast page's
|
||||||
# sort menu and filter chips (native-podcast.js EP_SORTS and FILTERS).
|
# sort menu and filter chips (native-podcast.js EP_SORTS and FILTERS).
|
||||||
|
|
|
||||||
|
|
@ -187,3 +187,14 @@ class UnplayedTests(TestCase):
|
||||||
content_type='application/json')
|
content_type='application/json')
|
||||||
self.assertEqual(EpisodeProgress.objects.get(episode=ep).position_seconds, 0)
|
self.assertEqual(EpisodeProgress.objects.get(episode=ep).position_seconds, 0)
|
||||||
self.assertEqual(EpisodeProgress.objects.get(episode=half).position_seconds, 200) # its place stays
|
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')
|
||||||
|
|
|
||||||
|
|
@ -1074,6 +1074,7 @@ FEED_DEFAULTS = {
|
||||||
'playback_speed': None, 'skip_intro': 0, 'skip_outro': 0, 'volume': 1.0, 'new_episodes': 'inbox',
|
'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,
|
'include_words': '', 'exclude_words': '', 'min_duration': 0, 'auto_queue': False, 'auto_download': False,
|
||||||
'notify': False, 'episode_sort': '', 'episode_filter': [], 'skip_silence': None, 'kind': 'podcast',
|
'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,
|
'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,
|
'new_episodes': 'inbox', 'include_words': 'News, Talk', 'exclude_words': 'Teaser', 'min_duration': 10,
|
||||||
'auto_queue': False, 'auto_download': False, 'notify': False,
|
'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.feed.refresh_from_db()
|
||||||
self.assertEqual((self.feed.playback_speed, self.feed.skip_intro, self.feed.volume, self.feed.min_duration),
|
self.assertEqual((self.feed.playback_speed, self.feed.skip_intro, self.feed.volume, self.feed.min_duration),
|
||||||
|
|
|
||||||
|
|
@ -787,7 +787,7 @@ def feed_list(request):
|
||||||
FEED_SETTING_FIELDS = (
|
FEED_SETTING_FIELDS = (
|
||||||
'playback_speed', 'skip_intro', 'skip_outro', 'volume',
|
'playback_speed', 'skip_intro', 'skip_outro', 'volume',
|
||||||
'new_episodes', 'include_words', 'exclude_words', 'min_duration', 'auto_queue', 'auto_download',
|
'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_sort': _episode_sort,
|
||||||
'episode_filter': _episode_filter,
|
'episode_filter': _episode_filter,
|
||||||
'kind': _kind,
|
'kind': _kind,
|
||||||
|
'folder': lambda v: ' '.join(str(v or '').split())[:100],
|
||||||
'skip_silence': _optional_boolean,
|
'skip_silence': _optional_boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,8 @@ async function loadBookList() {
|
||||||
try {
|
try {
|
||||||
const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct);
|
const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct);
|
||||||
const meta = JSON.parse(new TextDecoder().decode(metaBuf));
|
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};
|
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, 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});
|
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) {
|
} catch (e) {
|
||||||
bookMetaCache[b.id] = {title: `Buch #${b.id}`, author: '', type: 'epub', filename: '', folder: '', isbn: '', shelfTag: ''};
|
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});
|
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);
|
const cachedBooks = (await _loadCachedBookMeta()).map(_withPendingProgress);
|
||||||
if (cachedBooks.length) {
|
if (cachedBooks.length) {
|
||||||
cachedBooks.sort((a, b) => (_bookRecency(b) - _bookRecency(a)) || (b.uploaded_at || '').localeCompare(a.uploaded_at || ''));
|
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');
|
const uploadArea = $('book-upload-area');
|
||||||
if (uploadArea) uploadArea.style.display = 'none';
|
if (uploadArea) uploadArea.style.display = 'none';
|
||||||
renderBookList(cachedBooks);
|
renderBookList(cachedBooks);
|
||||||
|
|
@ -145,6 +145,7 @@ function repairBook(bookId) {
|
||||||
isbn: isbn || prevMeta.isbn || '',
|
isbn: isbn || prevMeta.isbn || '',
|
||||||
folder: prevMeta.folder || '',
|
folder: prevMeta.folder || '',
|
||||||
shelfTag: prevMeta.shelfTag || '',
|
shelfTag: prevMeta.shelfTag || '',
|
||||||
|
magazine: prevMeta.magazine,
|
||||||
}));
|
}));
|
||||||
const [metaEnc, dataEnc] = await Promise.all([
|
const [metaEnc, dataEnc] = await Promise.all([
|
||||||
encryptBytes(key, metaJson),
|
encryptBytes(key, metaJson),
|
||||||
|
|
@ -205,11 +206,33 @@ async function toggleBookRead(bookId, currentlyRead) {
|
||||||
// free in /api/sync/. One level only: folders don't nest.
|
// free in /api/sync/. One level only: folders don't nest.
|
||||||
let _currentBookFolder = null; // null = root view (folder tiles + unfiled books)
|
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
|
// Bücher and Zeitschriften are two sections of Lesen (static/js/nav.js), one list: a
|
||||||
// magazines are the books in a folder called "Zeitschriften" (or "Magazine").
|
// 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';
|
let _bookSection = 'books';
|
||||||
const _MAGAZINE_FOLDER = /^(zeitschriften|magazine|magazines)$/i;
|
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).
|
// The books of the section shown (all of them where there are no sections).
|
||||||
function _bookSectionFilter(books) {
|
function _bookSectionFilter(books) {
|
||||||
|
|
@ -254,6 +277,7 @@ function _renderBookItemHtml(b) {
|
||||||
${broken ? `<button class="book-menu-item book-menu-item--danger book-broken-btn" onclick="repairBook(${b.id})">🔧 Reparieren (Datei erneut hochladen)</button>` : ''}
|
${broken ? `<button class="book-menu-item book-menu-item--danger book-broken-btn" onclick="repairBook(${b.id})">🔧 Reparieren (Datei erneut hochladen)</button>` : ''}
|
||||||
${b.has_highlights ? `<button class="book-menu-item" onclick="downloadBookAnnotationsFromList(${b.id}, this)">⭳ Markierungen & Notizen herunterladen</button>` : ''}
|
${b.has_highlights ? `<button class="book-menu-item" onclick="downloadBookAnnotationsFromList(${b.id}, this)">⭳ Markierungen & Notizen herunterladen</button>` : ''}
|
||||||
<button class="book-menu-item" onclick="assignBookFolder(${b.id})">📁 Ordner zuweisen</button>
|
<button class="book-menu-item" onclick="assignBookFolder(${b.id})">📁 Ordner zuweisen</button>
|
||||||
|
<button class="book-menu-item" onclick="toggleBookMagazine(${b.id})">${_isMagazine(b) ? '▤ Als Buch führen' : '📰 Als Zeitschrift führen'}</button>
|
||||||
<button class="book-menu-item" onclick="lookupBookMetadata(${b.id})">🏷️ Metadaten abrufen (Regal)</button>
|
<button class="book-menu-item" onclick="lookupBookMetadata(${b.id})">🏷️ Metadaten abrufen (Regal)</button>
|
||||||
<button class="book-menu-item" onclick="toggleBookRead(${b.id}, ${!!b.is_read})">${b.is_read ? '↺ Als ungelesen markieren' : '✓ Als gelesen markieren'}</button>
|
<button class="book-menu-item" onclick="toggleBookRead(${b.id}, ${!!b.is_read})">${b.is_read ? '↺ Als ungelesen markieren' : '✓ Als gelesen markieren'}</button>
|
||||||
<button class="book-menu-item book-menu-item--danger" onclick="deleteBook(${b.id})">🗑 Löschen</button>
|
<button class="book-menu-item book-menu-item--danger" onclick="deleteBook(${b.id})">🗑 Löschen</button>
|
||||||
|
|
@ -295,7 +319,7 @@ function renderBookList(allBooks) {
|
||||||
if (!books.length) {
|
if (!books.length) {
|
||||||
listEl.innerHTML = window.DioraNav && allBooks.length
|
listEl.innerHTML = window.DioraNav && allBooks.length
|
||||||
? (_bookSection === 'magazines'
|
? (_bookSection === 'magazines'
|
||||||
? '<p class="muted">Noch keine Zeitschriften. Hefte, die in einem Ordner „Zeitschriften“ liegen, stehen hier (⋮ → Ordner zuweisen).</p>'
|
? '<p class="muted">Noch keine Zeitschriften. Ein Heft unter Bücher über ⋮ → „Als Zeitschrift führen“ hierher holen.</p>'
|
||||||
: '<p class="muted">Alle Bücher liegen bei den Zeitschriften.</p>')
|
: '<p class="muted">Alle Bücher liegen bei den Zeitschriften.</p>')
|
||||||
: '';
|
: '';
|
||||||
return;
|
return;
|
||||||
|
|
@ -324,11 +348,22 @@ function renderBookList(allBooks) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const grouped = new Map();
|
const grouped = new Map();
|
||||||
|
const magazines = _bookSection === 'magazines' && !!window.DioraNav;
|
||||||
for (const b of visible) {
|
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, []);
|
if (!grouped.has(key)) grouped.set(key, []);
|
||||||
grouped.get(key).push(b);
|
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.
|
// Folder emptied out (last book moved/deleted, or read-filter hid it) — fall back to root.
|
||||||
if (_currentBookFolder !== null && !grouped.has(_currentBookFolder)) {
|
if (_currentBookFolder !== null && !grouped.has(_currentBookFolder)) {
|
||||||
_currentBookFolder = null;
|
_currentBookFolder = null;
|
||||||
|
|
@ -339,7 +374,7 @@ function renderBookList(allBooks) {
|
||||||
if (folderNames.length) {
|
if (folderNames.length) {
|
||||||
html += '<div class="book-folder-grid">'
|
html += '<div class="book-folder-grid">'
|
||||||
+ folderNames.map(name => `<button class="book-folder-tile" data-folder="${escapeHtml(name)}">
|
+ folderNames.map(name => `<button class="book-folder-tile" data-folder="${escapeHtml(name)}">
|
||||||
<span class="book-folder-tile-icon">📁</span>
|
<span class="book-folder-tile-icon">${magazines ? '📰' : '📁'}</span>
|
||||||
<span class="book-folder-tile-name">${escapeHtml(name)}</span>
|
<span class="book-folder-tile-name">${escapeHtml(name)}</span>
|
||||||
<span class="book-folder-tile-count">${grouped.get(name).length}</span>
|
<span class="book-folder-tile-count">${grouped.get(name).length}</span>
|
||||||
</button>`).join('')
|
</button>`).join('')
|
||||||
|
|
@ -375,6 +410,7 @@ async function _updateBookMeta(bookId, overrides) {
|
||||||
folder: cached.folder || book.folder || '',
|
folder: cached.folder || book.folder || '',
|
||||||
shelfTag: cached.shelfTag || book.shelfTag || '',
|
shelfTag: cached.shelfTag || book.shelfTag || '',
|
||||||
pages: cached.pages || book.pages || 0,
|
pages: cached.pages || book.pages || 0,
|
||||||
|
magazine: cached.magazine !== undefined ? cached.magazine : book.magazine,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
const key = await getOrCreateEncKey();
|
const key = await getOrCreateEncKey();
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@
|
||||||
picking: false, // choosing a podcast to make an audiobook
|
picking: false, // choosing a podcast to make an audiobook
|
||||||
pickQuery: '',
|
pickQuery: '',
|
||||||
now: null, // playerState() of the last poll
|
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');
|
const shelf = document.createElement('div');
|
||||||
|
|
@ -189,13 +191,7 @@
|
||||||
html += '<div class="ab-head"><h3 class="ab-h">Hörbücher</h3>' +
|
html += '<div class="ab-head"><h3 class="ab-h">Hörbücher</h3>' +
|
||||||
(pods.length ? '<button type="button" class="ab-link" data-ab="pick">+ Hörbuch</button>' : '') + '</div>';
|
(pods.length ? '<button type="button" class="ab-link" data-ab="pick">+ Hörbuch</button>' : '') + '</div>';
|
||||||
if (books.length) {
|
if (books.length) {
|
||||||
html += '<div class="ab-grid">' + books.map(b =>
|
html += gridHtml(books);
|
||||||
'<button type="button" class="ab-card" data-ab-open="' + b.feed_id + '">' +
|
|
||||||
cover(b.artwork_url, b.title, 'ab-cover') +
|
|
||||||
'<span class="ab-card-text"><span class="ab-title">' + esc(b.title) + '</span>' +
|
|
||||||
(b.author ? '<span class="ab-meta">' + esc(b.author) + '</span>' : '') +
|
|
||||||
'<span class="ab-meta">' + esc(progressText(b)) + '</span>' +
|
|
||||||
'<span class="ab-bar"><span style="width:' + pct(b) + '%"></span></span></span></button>').join('') + '</div>';
|
|
||||||
} else {
|
} else {
|
||||||
html += '<p class="ab-empty">Podcasts, die eigentlich Hörbücher oder Hörspiele sind, lassen sich hier ' +
|
html += '<p class="ab-empty">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.</p>';
|
'wie ein Buch hören: in Kapitelreihenfolge, mit Lesezeichen. „+ Hörbuch“ wählt einen aus.</p>';
|
||||||
|
|
@ -206,6 +202,131 @@
|
||||||
if (input && document.activeElement !== input && st.pickFocus) { input.focus(); st.pickFocus = false; }
|
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 '<button type="button" class="ab-card" data-ab-open="' + b.feed_id + '">' +
|
||||||
|
cover(b.artwork_url, b.title, 'ab-cover') +
|
||||||
|
'<span class="ab-card-text"><span class="ab-title">' + esc(label || b.title) + '</span>' +
|
||||||
|
(b.author && !label ? '<span class="ab-meta">' + esc(b.author) + '</span>' : '') +
|
||||||
|
'<span class="ab-meta">' + esc(progressText(b)) + '</span>' +
|
||||||
|
'<span class="ab-bar"><span style="width:' + pct(b) + '%"></span></span></span></button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 '<button type="button" class="ab-card ab-stack" data-ab-series="' + esc(g.key) + '">' +
|
||||||
|
'<span class="ab-stack-covers">' + cover(g.books[0].artwork_url, g.name, 'ab-cover') + '</span>' +
|
||||||
|
'<span class="ab-card-text"><span class="ab-title">' + esc(g.name) + '</span>' +
|
||||||
|
(g.books[0].author ? '<span class="ab-meta">' + esc(g.books[0].author) + '</span>' : '') +
|
||||||
|
'<span class="ab-meta">' + esc(meta) + '</span>' +
|
||||||
|
'<span class="ab-bar"><span style="width:' + (total ? Math.round(100 * heard / total) : 0) + '%"></span></span></span></button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 '<div class="ab-place"><button type="button" class="ab-back" data-ab="up">‹ ' +
|
||||||
|
esc(st.folder == null ? 'Alle Hörbücher' : st.folder) + '</button><strong>' + esc(g.name) + '</strong></div>' +
|
||||||
|
'<div class="ab-grid">' + g.books.map(b => bookCard(b, b._volume)).join('') + '</div>';
|
||||||
|
}
|
||||||
|
st.series = null;
|
||||||
|
}
|
||||||
|
if (st.folder != null) {
|
||||||
|
html += '<div class="ab-place"><button type="button" class="ab-back" data-ab="up">‹ Alle Hörbücher</button>' +
|
||||||
|
'<strong>' + esc(st.folder) + '</strong></div>';
|
||||||
|
} else if (folders.size) {
|
||||||
|
html += '<div class="ab-folders">' + Array.from(folders.keys()).sort((a, b) => a.localeCompare(b, 'de')).map(f =>
|
||||||
|
'<button type="button" class="ab-folder" data-ab-folder="' + esc(f) + '"><span class="ab-folder-icon">▤</span>' +
|
||||||
|
'<span class="ab-title">' + esc(f) + '</span><span class="ab-meta">' + folders.get(f) + '</span></button>').join('') + '</div>';
|
||||||
|
}
|
||||||
|
return html + '<div class="ab-grid">' + list.map(e => e.kind === 'series' ? seriesCard(e) : bookCard(e.b)).join('') + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
function pickHtml(pods) {
|
||||||
const q = st.pickQuery.trim().toLowerCase();
|
const q = st.pickQuery.trim().toLowerCase();
|
||||||
const found = pods.filter(f => !q || (f.title + ' ' + (f.author || '')).toLowerCase().indexOf(q) >= 0)
|
const found = pods.filter(f => !q || (f.title + ' ' + (f.author || '')).toLowerCase().indexOf(q) >= 0)
|
||||||
|
|
@ -273,6 +394,7 @@
|
||||||
'<div class="ab-menu" hidden>' +
|
'<div class="ab-menu" hidden>' +
|
||||||
'<button type="button" data-ab="unplayed">Alles als ungehört markieren</button>' +
|
'<button type="button" data-ab="unplayed">Alles als ungehört markieren</button>' +
|
||||||
'<button type="button" data-ab="played">Alles als gehört markieren</button>' +
|
'<button type="button" data-ab="played">Alles als gehört markieren</button>' +
|
||||||
|
'<button type="button" data-ab="folder">Ordner zuweisen …</button>' +
|
||||||
'<button type="button" data-ab="podcast">Wieder als Podcast führen</button></div>' +
|
'<button type="button" data-ab="podcast">Wieder als Podcast führen</button></div>' +
|
||||||
'<div class="ab-hero">' + cover(b.artwork_url, b.title, 'ab-cover ab-cover-l') +
|
'<div class="ab-hero">' + cover(b.artwork_url, b.title, 'ab-cover ab-cover-l') +
|
||||||
'<div class="ab-hero-text"><h2 class="ab-book-title">' + esc(b.title) + '</h2>' +
|
'<div class="ab-hero-text"><h2 class="ab-book-title">' + esc(b.title) + '</h2>' +
|
||||||
|
|
@ -453,6 +575,7 @@
|
||||||
else if (act === 'mark') addBookmark();
|
else if (act === 'mark') addBookmark();
|
||||||
else if (act === 'unplayed') markAll(false);
|
else if (act === 'unplayed') markAll(false);
|
||||||
else if (act === 'played') markAll(true);
|
else if (act === 'played') markAll(true);
|
||||||
|
else if (act === 'folder') assignFolder(st.open);
|
||||||
else if (act === 'podcast') {
|
else if (act === 'podcast') {
|
||||||
const ok = typeof customConfirm === 'function'
|
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.')
|
? 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) return;
|
||||||
if (t.dataset.abGo != null) { e.stopPropagation(); goOn(Number(t.dataset.abGo)); 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.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) {
|
if (t.dataset.abMark != null) {
|
||||||
const id = Number(t.dataset.abMark);
|
const id = Number(t.dataset.abMark);
|
||||||
st.picking = false;
|
st.picking = false;
|
||||||
|
|
@ -549,6 +679,11 @@
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (st.picking && tab.style.display !== 'none') { st.picking = false; renderShelf(); 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;
|
return back ? back() : false;
|
||||||
};
|
};
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
|
|
@ -616,6 +751,17 @@
|
||||||
'.ab-chapter.ab-done .ab-title { color: var(--muted, #9a9a9a); }',
|
'.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-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-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 .ab-row { flex: 1; min-width: 0; }',
|
||||||
'.ab-chrow { border-bottom: 1px solid var(--border, #2a2a2a); }',
|
'.ab-chrow { border-bottom: 1px solid var(--border, #2a2a2a); }',
|
||||||
'.ab-chrow .ab-row { border-bottom: none; }',
|
'.ab-chrow .ab-row { border-bottom: none; }',
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@
|
||||||
const SORT_RULES = ['', 'new', 'old', 'short', 'long', 'feed'];
|
const SORT_RULES = ['', 'new', 'old', 'short', 'long', 'feed'];
|
||||||
const FEED_SETTINGS = ['playback_speed', 'skip_intro', 'skip_outro', 'volume',
|
const FEED_SETTINGS = ['playback_speed', 'skip_intro', 'skip_outro', 'volume',
|
||||||
'new_episodes', 'include_words', 'exclude_words', 'min_duration', 'auto_download', 'notify',
|
'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.
|
// The account's playback settings (diora-web PodcastSettings) and what each may be.
|
||||||
const SKIPS = [5, 10, 15, 20, 30, 45, 60];
|
const SKIPS = [5, 10, 15, 20, 30, 45, 60];
|
||||||
const MIN_SPEED = 0.5;
|
const MIN_SPEED = 0.5;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* diora service worker — caches the app shell for offline use.
|
* diora service worker — caches the app shell for offline use.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE = 'diora-v117';
|
const CACHE = 'diora-v118';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/static/css/app.css',
|
'/static/css/app.css',
|
||||||
'/static/js/app/01-core.js',
|
'/static/js/app/01-core.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
|
// 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.
|
// 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 items = podcastFeeds().slice().sort((a, b) => sort.cmp(a, b) || byTitle(a, b));
|
||||||
const books = feeds().length - items.length;
|
const books = feeds().length - items.length;
|
||||||
const booksHint = () => el('div', 'nui-pod-hint',
|
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) {
|
if (!items.length) {
|
||||||
empty('Noch keine Abos. Unter „Suche“ findest du Podcasts.');
|
empty('Noch keine Abos. Unter „Suche“ findest du Podcasts.');
|
||||||
if (books) root.append(booksHint());
|
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).
|
// An audiobook: its episodes are chapters, listened to in the library (diora-web docs/audiobooks.md).
|
||||||
if ('kind' in live && N.podcastFeedSettings) {
|
if ('kind' in live && N.podcastFeedSettings) {
|
||||||
const book = isBook(live);
|
const book = isBook(live);
|
||||||
items.push({icon: '▤', label: book ? 'Hörbuch (in Bücher)' : 'Als Hörbuch',
|
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 in der Bibliothek, nicht in „Neu“',
|
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'})});
|
active: book, action: () => save({kind: book ? 'podcast' : 'audiobook'})});
|
||||||
}
|
}
|
||||||
// Skip silence for this podcast (server), where this player can skip silence at all.
|
// Skip silence for this podcast (server), where this player can skip silence at all.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
|
||||||
The web's own additions are in podcasts-web.css. */
|
The web's own additions are in podcasts-web.css. */
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue