Podcasts: Folgen einer Kategorie (?folder=), wie oft ein Podcast erscheint (interval_days) für „Nach Zeitraum, seltene zuerst“
- GET /podcasts/episodes/?folder=<name> ('' = ohne Ordner)
- /podcasts/state/: interval_days je Feed (mittlerer Abstand der neuesten 10 Folgen,
eine Abfrage per Window, eine Stunde gemerkt)
- Podcast-Oberfläche aus diora-android bd98557 (Zeiträume, Ordner-Chips in „Folgen“)
- docs/library.md, sw v134
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
86a6bb52d7
commit
ba3c6ff905
6 changed files with 175 additions and 27 deletions
|
|
@ -31,6 +31,17 @@ The folder order per area: e-books in the encrypted user settings blob (names
|
|||
are private), the others on the server (`/podcasts/folders/`, `/feeds/folders/`,
|
||||
`{order: [names]}`).
|
||||
|
||||
### Podcasts: a category's episodes, by time
|
||||
|
||||
The folder chips stand over „Folgen“ too: with one chosen it lists every episode of
|
||||
that category (`GET /podcasts/episodes/?folder=<name>`, empty: the podcasts in none).
|
||||
„Folgen“, „Neu“ and one podcast's page group their episodes by time — Heute, Gestern,
|
||||
Diese Woche (the last 7 days), Diesen Monat (30 days), then the months — after the
|
||||
Kino feed. In „Folgen“ (sort „Nach Zeitraum, seltene zuerst“, the default) and „Neu“
|
||||
the podcasts that seldom come go first within a group: `interval_days` of each feed
|
||||
in `/podcasts/state/`, the mean gap between its newest 10 episodes (one episode: its
|
||||
age, at least 30 days; kept an hour). Heard is a filter chip, not an order.
|
||||
|
||||
### Shared code: `static/js/folders.js` (diora-web, every platform)
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from unittest.mock import patch
|
|||
|
||||
import feedparser
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
|
||||
from .models import EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings
|
||||
|
|
@ -756,6 +757,7 @@ class EpisodeListTests(TestCase):
|
|||
"""GET /podcasts/episodes/: all feeds, filters, history, search, pages."""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear() # how often feeds come (_intervals) is kept per user id
|
||||
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
||||
self.client.force_login(self.user)
|
||||
self.fa = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/a', title='Alpha',
|
||||
|
|
@ -795,6 +797,19 @@ class EpisodeListTests(TestCase):
|
|||
def ids(self, **params):
|
||||
return [e['id'] for e in self.get(**params)['episodes']]
|
||||
|
||||
def test_folder_shows_its_feeds_episodes(self):
|
||||
self.fa.folders = ['Politik']
|
||||
self.fa.save()
|
||||
self.assertEqual(sorted(self.ids(folder='Politik')), sorted([self.a1.id, self.a2.id, self.a3.id, self.a4.id]))
|
||||
self.assertEqual(sorted(self.ids(folder='')), sorted([self.b1.id, self.b2.id])) # the ones in none
|
||||
self.assertEqual(self.ids(folder='Sport'), [])
|
||||
|
||||
def test_state_says_how_often_a_feed_comes(self):
|
||||
feeds = {f['id']: f for f in self.client.get('/podcasts/state/').json()['feeds']}
|
||||
# A1-A3 a day apart (A4 has no date); Beta's two a day apart too.
|
||||
self.assertAlmostEqual(feeds[self.fa.id]['interval_days'], (_at(1, 3) - _at(1, 1)).total_seconds() / 86400 / 2, places=1)
|
||||
self.assertAlmostEqual(feeds[self.fb.id]['interval_days'], (_at(1, 5) - _at(1, 4)).total_seconds() / 86400, places=1)
|
||||
|
||||
def test_all_newest_first_without_date_last(self):
|
||||
data = self.get()
|
||||
self.assertEqual([e['id'] for e in data['episodes']],
|
||||
|
|
@ -2403,9 +2418,12 @@ class FeedCountsTests(TestCase):
|
|||
f = PodcastFeed.objects.create(user=self.user, rss_url=f'https://example.org/n{i}', title=f'N{i}')
|
||||
e = PodcastEpisode.objects.create(feed=f, guid='e', title='E', audio_url='https://example.org/e.mp3')
|
||||
_progress(self.user, e, _at(2, 2), played=True)
|
||||
with self.assertNumQueries(2):
|
||||
cache.clear()
|
||||
with self.assertNumQueries(3): # feeds, progress counts, how often they come
|
||||
feeds = _feeds_data(self.user)
|
||||
self.assertEqual(len(feeds), 13)
|
||||
with self.assertNumQueries(2): # that one is kept
|
||||
_feeds_data(self.user)
|
||||
self.assertEqual({f['unplayed_count'] for f in feeds if f['title'].startswith('N')}, {0})
|
||||
|
||||
def test_feed_page_has_favorites_and_info(self):
|
||||
|
|
|
|||
|
|
@ -14,11 +14,12 @@ import xml.etree.ElementTree as ET
|
|||
import feedparser
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import (
|
||||
Case, Count, Exists, F, FilteredRelation, IntegerField, Max, OuterRef, Q, Sum, Value, When,
|
||||
Case, Count, Exists, F, FilteredRelation, IntegerField, Max, OuterRef, Q, Sum, Value, When, Window,
|
||||
)
|
||||
from django.db.models.functions import Coalesce, Lower
|
||||
from django.db.models.functions import Coalesce, Lower, RowNumber
|
||||
from django.http import HttpResponse, JsonResponse, StreamingHttpResponse
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
|
@ -809,12 +810,47 @@ def _feed_settings(feed):
|
|||
return {'feed_id': feed.id, **{name: getattr(feed, name) for name in FEED_SETTING_FIELDS}}
|
||||
|
||||
|
||||
INTERVAL_EPISODES = 10 # a feed's newest episodes that tell how often it comes
|
||||
INTERVAL_SINGLE_DAYS = 30
|
||||
|
||||
|
||||
def _intervals(user):
|
||||
"""How often each feed brings an episode, in days: the mean gap between its newest
|
||||
INTERVAL_EPISODES. One episode only: its age, at least INTERVAL_SINGLE_DAYS. The lists
|
||||
put the seldom ones first within a day or week (after Kino's feed; feeds.js does the
|
||||
same for blogs). One query (the newest episodes per feed by a window), kept an hour."""
|
||||
key = f'podcasts:intervals:{user.id}'
|
||||
got = cache.get(key)
|
||||
if got is not None:
|
||||
return got
|
||||
rows = (
|
||||
PodcastEpisode.objects.filter(feed__user=user, pub_date__isnull=False)
|
||||
.annotate(n=Window(RowNumber(), partition_by=[F('feed_id')], order_by=F('pub_date').desc()))
|
||||
.filter(n__lte=INTERVAL_EPISODES)
|
||||
.values_list('feed_id', 'pub_date')
|
||||
)
|
||||
dates = {}
|
||||
for feed_id, when in rows:
|
||||
dates.setdefault(feed_id, []).append(when)
|
||||
now = timezone.now()
|
||||
out = {}
|
||||
for feed_id, ds in dates.items():
|
||||
if len(ds) == 1:
|
||||
days = max((now - ds[0]).total_seconds() / 86400, INTERVAL_SINGLE_DAYS)
|
||||
else:
|
||||
days = (max(ds) - min(ds)).total_seconds() / 86400 / (len(ds) - 1)
|
||||
out[feed_id] = round(days, 2)
|
||||
cache.set(key, out, 3600)
|
||||
return out
|
||||
|
||||
|
||||
def _feeds_data(user):
|
||||
"""The user's feeds with their settings and, per feed, how many episodes
|
||||
it has, how many of them are unplayed and how many new (as in the inbox:
|
||||
neither played nor dismissed), and when the user last listened to one.
|
||||
Two queries, however many feeds: the feeds with their episode count, and
|
||||
the user's progress rows counted per feed."""
|
||||
the user's progress rows counted per feed (and, once an hour, how often each
|
||||
feed comes: _intervals)."""
|
||||
feeds = list(
|
||||
user.podcast_feeds
|
||||
.annotate(latest_episode_at=Max('episodes__pub_date'), episode_count=Count('episodes'))
|
||||
|
|
@ -834,8 +870,10 @@ def _feeds_data(user):
|
|||
)
|
||||
}
|
||||
|
||||
intervals = _intervals(user)
|
||||
for f in feeds:
|
||||
f['feed_id'] = f['id'] # as in feeds/<id>/settings/
|
||||
f['interval_days'] = intervals.get(f['id'])
|
||||
mine = listened.get(f['id'], {})
|
||||
f['unplayed_count'] = max(f['episode_count'] - mine.get('played_count', 0), 0)
|
||||
f['new_count'] = max(f['episode_count'] - mine.get('hidden_count', 0), 0)
|
||||
|
|
@ -1479,7 +1517,7 @@ def _int_param(request, name, default):
|
|||
|
||||
|
||||
def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, limit=EPISODE_LIST_LIMIT,
|
||||
sort=None, ids=None, notes=False):
|
||||
sort=None, ids=None, notes=False, folder=None):
|
||||
"""(episodes, total) for the episode list. Two queries, whatever the
|
||||
page size: the count and the page. The user's progress row comes in by
|
||||
a join, queue membership by a subquery. No shownotes (up to 50 KB each).
|
||||
|
|
@ -1488,7 +1526,8 @@ def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, li
|
|||
commas), which all have to match. sort: one of EPISODE_SORTS; by default
|
||||
the newest first, in the history the last listened first. ids: only these
|
||||
episodes (the ones downloaded on a device). notes: `q` also matches the
|
||||
shownotes; title matches come first."""
|
||||
shownotes; title matches come first. folder: only the feeds in this folder ('': the feeds
|
||||
in none)."""
|
||||
names = filter_name.split(',') if isinstance(filter_name, str) else list(filter_name)
|
||||
names = [n for n in names if n and n != 'all']
|
||||
qs = (
|
||||
|
|
@ -1506,6 +1545,10 @@ def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, li
|
|||
qs = qs.filter(EPISODE_FILTERS[name])
|
||||
if feed_id is not None:
|
||||
qs = qs.filter(feed_id=feed_id)
|
||||
if folder is not None:
|
||||
qs = qs.filter(feed_id__in=[
|
||||
fid for fid, fs in PodcastFeed.objects.filter(user=user).values_list('id', 'folders')
|
||||
if (folder in (fs or []) if folder else not fs)])
|
||||
if ids is not None:
|
||||
qs = qs.filter(id__in=ids)
|
||||
if feed_id is None and ids is None: # audiobooks are listened to in the library
|
||||
|
|
@ -1537,8 +1580,9 @@ def episode_list(request):
|
|||
"""Episodes of all the user's feeds, filtered (all, unplayed, in_progress,
|
||||
played, favorites, history, queued; several joined by commas all have to
|
||||
match), optionally of one feed, of some episodes (`ids`, comma-separated)
|
||||
or matching `q` (in the title; with notes=1 also in the shownotes), sorted
|
||||
by `sort` (new, old, long, short, title, played)."""
|
||||
or matching `q` (in the title; with notes=1 also in the shownotes), of the
|
||||
feeds in `folder` (empty: in none), sorted by `sort` (new, old, long, short,
|
||||
title, played)."""
|
||||
if not request.user.is_authenticated:
|
||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||
names = [n.strip() for n in request.GET.get('filter', '').split(',') if n.strip()]
|
||||
|
|
@ -1558,6 +1602,7 @@ def episode_list(request):
|
|||
episodes, total = _episode_list_data(
|
||||
request.user, names, feed_id, request.GET.get('q', '').strip(), offset, limit,
|
||||
sort=sort, ids=ids, notes=request.GET.get('notes') == '1',
|
||||
folder=folders_mod.clean_name(request.GET['folder']) if 'folder' in request.GET else None,
|
||||
)
|
||||
return JsonResponse({'episodes': episodes, 'offset': offset, 'limit': limit, 'total': total})
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* diora service worker — caches the app shell for offline use.
|
||||
*/
|
||||
|
||||
const CACHE = 'diora-v133';
|
||||
const CACHE = 'diora-v134';
|
||||
const SHELL = [
|
||||
'/static/css/app.css',
|
||||
'/static/js/app/01-core.js',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Copied from diora-android 449118c 2026-10-16: web-native/static/native-podcast.js
|
||||
// Copied from diora-android bd98557 2026-10-19: 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.
|
||||
|
||||
|
|
@ -997,6 +997,8 @@
|
|||
// Filters that can't both be on: choosing one takes the others off.
|
||||
const EXCLUDES = {unplayed: ['played'], in_progress: ['played'], played: ['unplayed', 'in_progress']};
|
||||
const EP_SORTS = [
|
||||
// Days and weeks (Heute, Gestern, …), in each the podcasts that seldom come first (timeSections).
|
||||
{id: 'rare', label: 'Nach Zeitraum, seltene zuerst', server: 'new', all: true, key: o => -(Date.parse(o.pub_date) || -Infinity)},
|
||||
{id: 'new', label: 'Neueste zuerst', key: o => -(Date.parse(o.pub_date) || -Infinity)},
|
||||
{id: 'old', label: 'Älteste zuerst', key: o => Date.parse(o.pub_date) || Infinity},
|
||||
{id: 'long', label: 'Längste zuerst', key: o => (o.duration_seconds ? -o.duration_seconds : Infinity)},
|
||||
|
|
@ -1010,6 +1012,68 @@
|
|||
return DL && !!downloads[id] && downloads[id].state === 'done';
|
||||
}
|
||||
|
||||
// ---- by time: Heute, Gestern, Diese Woche, Diesen Monat, then the months (after Kino's feed;
|
||||
// diora-web feeds.js the same for blogs). Within one, with rare: the podcasts that seldom come
|
||||
// first (feed.interval_days from the server: the mean gap of its newest episodes), so a weekly
|
||||
// show isn't lost among the daily news.
|
||||
|
||||
const MONTHS = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober',
|
||||
'November', 'Dezember'];
|
||||
|
||||
function timeLabel(iso, now) {
|
||||
const t = Date.parse(iso);
|
||||
if (!t) return 'Ohne Datum';
|
||||
const midnight = new Date(now);
|
||||
midnight.setHours(0, 0, 0, 0);
|
||||
const days = Math.floor((midnight.getTime() - t) / 86400000) + 1; // 0: today, 1: yesterday
|
||||
if (t > now) return 'Demnächst';
|
||||
if (t >= midnight.getTime()) return 'Heute';
|
||||
if (days <= 1) return 'Gestern';
|
||||
if (days < 7) return 'Diese Woche';
|
||||
if (days < 30) return 'Diesen Monat';
|
||||
const d = new Date(t);
|
||||
return MONTHS[d.getMonth()] + (d.getFullYear() === new Date(now).getFullYear() ? '' : ' ' + d.getFullYear());
|
||||
}
|
||||
|
||||
function intervalOf(feedId) {
|
||||
const f = feeds().find(x => x.id === feedId);
|
||||
return f && typeof f.interval_days === 'number' ? f.interval_days : 0;
|
||||
}
|
||||
|
||||
// items (ep() shapes), newest first already: [{label, items}].
|
||||
function timeSections(items, rare) {
|
||||
const now = Date.now();
|
||||
const out = [];
|
||||
for (const e of items) {
|
||||
const label = timeLabel(e.date, now);
|
||||
const last = out[out.length - 1];
|
||||
if (last && last.label === label) last.items.push(e);
|
||||
else out.push({label, items: [e]});
|
||||
}
|
||||
if (rare) {
|
||||
const gap = new Map();
|
||||
for (const sec of out) {
|
||||
sec.items = sec.items.map((e, i) => [e, i]).sort((a, b) => {
|
||||
for (const e of [a[0], b[0]]) if (!gap.has(e.feedId)) gap.set(e.feedId, intervalOf(e.feedId));
|
||||
return (gap.get(b[0].feedId) - gap.get(a[0].feedId)) || a[1] - b[1];
|
||||
}).map(x => x[0]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The list's rows, under headings by time when sorted so.
|
||||
function appendRows(list, items, sort, rowOf) {
|
||||
if (sort !== 'new' && sort !== 'rare') {
|
||||
for (const e of items) list.append(rowOf(e));
|
||||
return;
|
||||
}
|
||||
for (const sec of timeSections(items, sort === 'rare')) {
|
||||
list.append(el('div', 'nui-pod-when', sec.label));
|
||||
for (const e of sec.items) list.append(rowOf(e));
|
||||
}
|
||||
}
|
||||
|
||||
function matchesAll(o, filters) {
|
||||
return filters.every(id => { const f = FILTERS.find(x => x.id === id); return !f || f.test(o); });
|
||||
}
|
||||
|
|
@ -1148,6 +1212,7 @@
|
|||
if (view === 'queue') folderChips(queue().map(ep), e => e.feedId);
|
||||
else if (view === 'inbox') folderChips(inbox().map(ep), e => e.feedId);
|
||||
else if (view === 'feeds') folderChips(podcastFeeds(), f => f.id);
|
||||
else if (view === 'episodes' && !selected) folderChips(podcastFeeds(), f => f.id);
|
||||
if (view === 'queue') renderQueue();
|
||||
else if (view === 'inbox') renderInbox();
|
||||
else if (view === 'episodes') renderEpisodes();
|
||||
|
|
@ -1780,7 +1845,7 @@
|
|||
root.append(head);
|
||||
}
|
||||
const list = el('div', 'nui-pod-list');
|
||||
for (const e of items) list.append(episodeRow(e, {showFeed: true, showDate: true, list: 'inbox'}));
|
||||
appendRows(list, items, 'rare', e => episodeRow(e, {showFeed: true, showDate: true, list: 'inbox'}));
|
||||
root.append(list);
|
||||
}
|
||||
|
||||
|
|
@ -2333,12 +2398,11 @@
|
|||
}
|
||||
if (!items.length) { empty(all.length ? noneText(filters) : 'Dieser Podcast hat noch keine Folgen.'); return; }
|
||||
const list = el('div', 'nui-pod-list');
|
||||
for (const e of items) {
|
||||
const o = e.raw;
|
||||
appendRows(list, items, sort, e => {
|
||||
const row = episodeRow(e, {showDate: true, list: downloadsOnly(filters) ? 'downloads' : 'feed', filters});
|
||||
if (o.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ '));
|
||||
list.append(row);
|
||||
}
|
||||
if (e.raw.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ '));
|
||||
return row;
|
||||
});
|
||||
root.append(list);
|
||||
}
|
||||
|
||||
|
|
@ -2510,7 +2574,7 @@
|
|||
// Filters combine (all have to match); "Geladen" asks for the episodes downloaded here.
|
||||
|
||||
let epFilters = [];
|
||||
let epSort = 'new';
|
||||
let epSort = 'rare';
|
||||
try {
|
||||
const kept = localStorage.getItem('nui_pod_filters');
|
||||
if (kept != null) epFilters = JSON.parse(kept);
|
||||
|
|
@ -2521,20 +2585,24 @@
|
|||
if (old === 'history') epSort = 'played';
|
||||
}
|
||||
epSort = localStorage.getItem('nui_pod_epsort') || epSort;
|
||||
// Since 0.5.71 by time with the seldom podcasts first; who had the newest first gets that once.
|
||||
if (!localStorage.getItem('nui_pod_rare') && epSort === 'new') epSort = 'rare';
|
||||
localStorage.setItem('nui_pod_rare', '1');
|
||||
} catch (e) {}
|
||||
if (!Array.isArray(epFilters)) epFilters = [];
|
||||
epFilters = epFilters.filter(id => FILTERS.some(f => f.id === id && (!f.app || DL)));
|
||||
if (!EP_SORTS.some(s => s.id === epSort)) epSort = 'new';
|
||||
if (!EP_SORTS.some(s => s.id === epSort)) epSort = 'rare';
|
||||
let epList = null; // {key, items, total, loading, error}
|
||||
|
||||
const epKey = () => epFilters.slice().sort().join(',') + '|' + epSort;
|
||||
// The folder too: "Folgen" with a folder chosen is all episodes of that category.
|
||||
const epKey = () => epFilters.slice().sort().join(',') + '|' + epSort + '|' + JSON.stringify(podFolder);
|
||||
|
||||
function setEpisodeList(filters, sort) {
|
||||
// The history goes by the last listening; leaving it, by date again.
|
||||
if (sort == null) {
|
||||
sort = epSort;
|
||||
if (filters.includes('history') && !epFilters.includes('history') && sort === 'new') sort = 'played';
|
||||
if (!filters.includes('history') && epFilters.includes('history') && sort === 'played') sort = 'new';
|
||||
if (filters.includes('history') && !epFilters.includes('history') && (sort === 'new' || sort === 'rare')) sort = 'played';
|
||||
if (!filters.includes('history') && epFilters.includes('history') && sort === 'played') sort = 'rare';
|
||||
}
|
||||
epFilters = filters;
|
||||
epSort = sort;
|
||||
|
|
@ -2551,9 +2619,12 @@
|
|||
if (!append) epList = {key, items: [], total: 0, loading: true};
|
||||
else epList.loading = true;
|
||||
if (!quiet) render();
|
||||
const sorting = EP_SORTS.find(s => s.id === epSort) || EP_SORTS[0];
|
||||
const params = new URLSearchParams({
|
||||
filter: epFilters.filter(id => id !== 'downloaded').join(','), sort: epSort, limit: '100', offset: String(offset),
|
||||
filter: epFilters.filter(id => id !== 'downloaded').join(','), sort: sorting.server || sorting.id, limit: '100',
|
||||
offset: String(offset),
|
||||
});
|
||||
if (podFolder != null && hasFolders()) params.set('folder', podFolder);
|
||||
if (epFilters.includes('downloaded')) {
|
||||
const ids = Object.keys(downloads).filter(id => downloads[id].state === 'done');
|
||||
if (!ids.length) { epList = {key, items: [], total: 0}; render(); return; }
|
||||
|
|
@ -2582,7 +2653,7 @@
|
|||
const items = epList.items.filter(o => matchesAll(o, epFilters)).map(ep);
|
||||
const total = Math.max(items.length, epList.total - (epList.items.length - items.length));
|
||||
if (!items.length) {
|
||||
empty(epList.loading ? 'Wird geladen …' : noneText(epFilters));
|
||||
empty(epList.loading ? 'Wird geladen …' : podFolder != null && !epFilters.length ? folderEmpty('zu hören') : noneText(epFilters));
|
||||
return;
|
||||
}
|
||||
if (selected) {
|
||||
|
|
@ -2597,7 +2668,7 @@
|
|||
root.append(head);
|
||||
}
|
||||
const list = el('div', 'nui-pod-list');
|
||||
for (const e of items) {
|
||||
appendRows(list, items, epSort, e => {
|
||||
const o = e.raw;
|
||||
const row = episodeRow(e, {showFeed: true, showDate: epSort !== 'played', list: downloadsOnly(epFilters) ? 'downloads' : 'episodes',
|
||||
filters: epFilters});
|
||||
|
|
@ -2608,8 +2679,8 @@
|
|||
if (mark) m.prepend(mark);
|
||||
}
|
||||
if (o.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ '));
|
||||
list.append(row);
|
||||
}
|
||||
return row;
|
||||
});
|
||||
root.append(list);
|
||||
if (epList.items.length < epList.total) {
|
||||
const more = el('button', 'btn nui-pod-more', epList.loading ? 'Wird geladen …' : 'Weitere laden');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/* Copied from diora-android 449118c 2026-10-16: the podcast parts of web-native/static/native.css
|
||||
/* Copied from diora-android bd98557 2026-10-19: 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. */
|
||||
|
||||
|
|
@ -229,6 +229,9 @@ button.nui-pod-link { display: block; width: 100%; border: none; background: non
|
|||
.nui-pod-empty { padding: 32px 12px; color: var(--nui-muted); text-align: center; line-height: 1.5; font-family: var(--nui-sans); letter-spacing: normal; font-weight: 400; font-size: 14px; }
|
||||
|
||||
.nui-pod-list { display: flex; flex-direction: column; }
|
||||
/* Headings by time in the episode lists (Heute, Gestern, Diese Woche, …). */
|
||||
.nui-pod-when { padding: 14px 2px 4px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted); font-family: var(--nui-sans); }
|
||||
.nui-pod-when:first-child { padding-top: 4px; }
|
||||
.nui-pod-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue