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/`,
|
are private), the others on the server (`/podcasts/folders/`, `/feeds/folders/`,
|
||||||
`{order: [names]}`).
|
`{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)
|
### Shared code: `static/js/folders.js` (diora-web, every platform)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from unittest.mock import patch
|
||||||
|
|
||||||
import feedparser
|
import feedparser
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.core.cache import cache
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from .models import EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings
|
from .models import EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings
|
||||||
|
|
@ -756,6 +757,7 @@ class EpisodeListTests(TestCase):
|
||||||
"""GET /podcasts/episodes/: all feeds, filters, history, search, pages."""
|
"""GET /podcasts/episodes/: all feeds, filters, history, search, pages."""
|
||||||
|
|
||||||
def setUp(self):
|
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.user = User.objects.create_user(username='alice', password='pw12345678')
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
self.fa = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/a', title='Alpha',
|
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):
|
def ids(self, **params):
|
||||||
return [e['id'] for e in self.get(**params)['episodes']]
|
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):
|
def test_all_newest_first_without_date_last(self):
|
||||||
data = self.get()
|
data = self.get()
|
||||||
self.assertEqual([e['id'] for e in data['episodes']],
|
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}')
|
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')
|
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)
|
_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)
|
feeds = _feeds_data(self.user)
|
||||||
self.assertEqual(len(feeds), 13)
|
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})
|
self.assertEqual({f['unplayed_count'] for f in feeds if f['title'].startswith('N')}, {0})
|
||||||
|
|
||||||
def test_feed_page_has_favorites_and_info(self):
|
def test_feed_page_has_favorites_and_info(self):
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,12 @@ import xml.etree.ElementTree as ET
|
||||||
import feedparser
|
import feedparser
|
||||||
import requests
|
import requests
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
from django.db.models import (
|
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.http import HttpResponse, JsonResponse, StreamingHttpResponse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
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}}
|
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):
|
def _feeds_data(user):
|
||||||
"""The user's feeds with their settings and, per feed, how many episodes
|
"""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:
|
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.
|
neither played nor dismissed), and when the user last listened to one.
|
||||||
Two queries, however many feeds: the feeds with their episode count, and
|
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(
|
feeds = list(
|
||||||
user.podcast_feeds
|
user.podcast_feeds
|
||||||
.annotate(latest_episode_at=Max('episodes__pub_date'), episode_count=Count('episodes'))
|
.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:
|
for f in feeds:
|
||||||
f['feed_id'] = f['id'] # as in feeds/<id>/settings/
|
f['feed_id'] = f['id'] # as in feeds/<id>/settings/
|
||||||
|
f['interval_days'] = intervals.get(f['id'])
|
||||||
mine = listened.get(f['id'], {})
|
mine = listened.get(f['id'], {})
|
||||||
f['unplayed_count'] = max(f['episode_count'] - mine.get('played_count', 0), 0)
|
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)
|
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,
|
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
|
"""(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
|
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).
|
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
|
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
|
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
|
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 = filter_name.split(',') if isinstance(filter_name, str) else list(filter_name)
|
||||||
names = [n for n in names if n and n != 'all']
|
names = [n for n in names if n and n != 'all']
|
||||||
qs = (
|
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])
|
qs = qs.filter(EPISODE_FILTERS[name])
|
||||||
if feed_id is not None:
|
if feed_id is not None:
|
||||||
qs = qs.filter(feed_id=feed_id)
|
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:
|
if ids is not None:
|
||||||
qs = qs.filter(id__in=ids)
|
qs = qs.filter(id__in=ids)
|
||||||
if feed_id is None and ids is None: # audiobooks are listened to in the library
|
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,
|
"""Episodes of all the user's feeds, filtered (all, unplayed, in_progress,
|
||||||
played, favorites, history, queued; several joined by commas all have to
|
played, favorites, history, queued; several joined by commas all have to
|
||||||
match), optionally of one feed, of some episodes (`ids`, comma-separated)
|
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
|
or matching `q` (in the title; with notes=1 also in the shownotes), of the
|
||||||
by `sort` (new, old, long, short, title, played)."""
|
feeds in `folder` (empty: in none), sorted by `sort` (new, old, long, short,
|
||||||
|
title, played)."""
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
names = [n.strip() for n in request.GET.get('filter', '').split(',') if n.strip()]
|
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(
|
episodes, total = _episode_list_data(
|
||||||
request.user, names, feed_id, request.GET.get('q', '').strip(), offset, limit,
|
request.user, names, feed_id, request.GET.get('q', '').strip(), offset, limit,
|
||||||
sort=sort, ids=ids, notes=request.GET.get('notes') == '1',
|
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})
|
return JsonResponse({'episodes': episodes, 'offset': offset, 'limit': limit, 'total': total})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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-v133';
|
const CACHE = 'diora-v134';
|
||||||
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 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
|
// 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.
|
||||||
|
|
||||||
|
|
@ -997,6 +997,8 @@
|
||||||
// Filters that can't both be on: choosing one takes the others off.
|
// 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 EXCLUDES = {unplayed: ['played'], in_progress: ['played'], played: ['unplayed', 'in_progress']};
|
||||||
const EP_SORTS = [
|
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: '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: '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)},
|
{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';
|
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) {
|
function matchesAll(o, filters) {
|
||||||
return filters.every(id => { const f = FILTERS.find(x => x.id === id); return !f || f.test(o); });
|
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);
|
if (view === 'queue') folderChips(queue().map(ep), e => e.feedId);
|
||||||
else if (view === 'inbox') folderChips(inbox().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 === 'feeds') folderChips(podcastFeeds(), f => f.id);
|
||||||
|
else if (view === 'episodes' && !selected) folderChips(podcastFeeds(), f => f.id);
|
||||||
if (view === 'queue') renderQueue();
|
if (view === 'queue') renderQueue();
|
||||||
else if (view === 'inbox') renderInbox();
|
else if (view === 'inbox') renderInbox();
|
||||||
else if (view === 'episodes') renderEpisodes();
|
else if (view === 'episodes') renderEpisodes();
|
||||||
|
|
@ -1780,7 +1845,7 @@
|
||||||
root.append(head);
|
root.append(head);
|
||||||
}
|
}
|
||||||
const list = el('div', 'nui-pod-list');
|
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);
|
root.append(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2333,12 +2398,11 @@
|
||||||
}
|
}
|
||||||
if (!items.length) { empty(all.length ? noneText(filters) : 'Dieser Podcast hat noch keine Folgen.'); return; }
|
if (!items.length) { empty(all.length ? noneText(filters) : 'Dieser Podcast hat noch keine Folgen.'); return; }
|
||||||
const list = el('div', 'nui-pod-list');
|
const list = el('div', 'nui-pod-list');
|
||||||
for (const e of items) {
|
appendRows(list, items, sort, e => {
|
||||||
const o = e.raw;
|
|
||||||
const row = episodeRow(e, {showDate: true, list: downloadsOnly(filters) ? 'downloads' : 'feed', filters});
|
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', '★ '));
|
if (e.raw.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ '));
|
||||||
list.append(row);
|
return row;
|
||||||
}
|
});
|
||||||
root.append(list);
|
root.append(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2510,7 +2574,7 @@
|
||||||
// Filters combine (all have to match); "Geladen" asks for the episodes downloaded here.
|
// Filters combine (all have to match); "Geladen" asks for the episodes downloaded here.
|
||||||
|
|
||||||
let epFilters = [];
|
let epFilters = [];
|
||||||
let epSort = 'new';
|
let epSort = 'rare';
|
||||||
try {
|
try {
|
||||||
const kept = localStorage.getItem('nui_pod_filters');
|
const kept = localStorage.getItem('nui_pod_filters');
|
||||||
if (kept != null) epFilters = JSON.parse(kept);
|
if (kept != null) epFilters = JSON.parse(kept);
|
||||||
|
|
@ -2521,20 +2585,24 @@
|
||||||
if (old === 'history') epSort = 'played';
|
if (old === 'history') epSort = 'played';
|
||||||
}
|
}
|
||||||
epSort = localStorage.getItem('nui_pod_epsort') || epSort;
|
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) {}
|
} catch (e) {}
|
||||||
if (!Array.isArray(epFilters)) epFilters = [];
|
if (!Array.isArray(epFilters)) epFilters = [];
|
||||||
epFilters = epFilters.filter(id => FILTERS.some(f => f.id === id && (!f.app || DL)));
|
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}
|
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) {
|
function setEpisodeList(filters, sort) {
|
||||||
// The history goes by the last listening; leaving it, by date again.
|
// The history goes by the last listening; leaving it, by date again.
|
||||||
if (sort == null) {
|
if (sort == null) {
|
||||||
sort = epSort;
|
sort = epSort;
|
||||||
if (filters.includes('history') && !epFilters.includes('history') && sort === 'new') sort = 'played';
|
if (filters.includes('history') && !epFilters.includes('history') && (sort === 'new' || sort === 'rare')) sort = 'played';
|
||||||
if (!filters.includes('history') && epFilters.includes('history') && sort === 'played') sort = 'new';
|
if (!filters.includes('history') && epFilters.includes('history') && sort === 'played') sort = 'rare';
|
||||||
}
|
}
|
||||||
epFilters = filters;
|
epFilters = filters;
|
||||||
epSort = sort;
|
epSort = sort;
|
||||||
|
|
@ -2551,9 +2619,12 @@
|
||||||
if (!append) epList = {key, items: [], total: 0, loading: true};
|
if (!append) epList = {key, items: [], total: 0, loading: true};
|
||||||
else epList.loading = true;
|
else epList.loading = true;
|
||||||
if (!quiet) render();
|
if (!quiet) render();
|
||||||
|
const sorting = EP_SORTS.find(s => s.id === epSort) || EP_SORTS[0];
|
||||||
const params = new URLSearchParams({
|
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')) {
|
if (epFilters.includes('downloaded')) {
|
||||||
const ids = Object.keys(downloads).filter(id => downloads[id].state === 'done');
|
const ids = Object.keys(downloads).filter(id => downloads[id].state === 'done');
|
||||||
if (!ids.length) { epList = {key, items: [], total: 0}; render(); return; }
|
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 items = epList.items.filter(o => matchesAll(o, epFilters)).map(ep);
|
||||||
const total = Math.max(items.length, epList.total - (epList.items.length - items.length));
|
const total = Math.max(items.length, epList.total - (epList.items.length - items.length));
|
||||||
if (!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;
|
return;
|
||||||
}
|
}
|
||||||
if (selected) {
|
if (selected) {
|
||||||
|
|
@ -2597,7 +2668,7 @@
|
||||||
root.append(head);
|
root.append(head);
|
||||||
}
|
}
|
||||||
const list = el('div', 'nui-pod-list');
|
const list = el('div', 'nui-pod-list');
|
||||||
for (const e of items) {
|
appendRows(list, items, epSort, e => {
|
||||||
const o = e.raw;
|
const o = e.raw;
|
||||||
const row = episodeRow(e, {showFeed: true, showDate: epSort !== 'played', list: downloadsOnly(epFilters) ? 'downloads' : 'episodes',
|
const row = episodeRow(e, {showFeed: true, showDate: epSort !== 'played', list: downloadsOnly(epFilters) ? 'downloads' : 'episodes',
|
||||||
filters: epFilters});
|
filters: epFilters});
|
||||||
|
|
@ -2608,8 +2679,8 @@
|
||||||
if (mark) m.prepend(mark);
|
if (mark) m.prepend(mark);
|
||||||
}
|
}
|
||||||
if (o.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ '));
|
if (o.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ '));
|
||||||
list.append(row);
|
return row;
|
||||||
}
|
});
|
||||||
root.append(list);
|
root.append(list);
|
||||||
if (epList.items.length < epList.total) {
|
if (epList.items.length < epList.total) {
|
||||||
const more = el('button', 'btn nui-pod-more', epList.loading ? 'Wird geladen …' : 'Weitere laden');
|
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.
|
(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. */
|
||||||
|
|
||||||
|
|
@ -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-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; }
|
.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 {
|
.nui-pod-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue