Podcasts: "Folge löschen" (episodes/discard/, discarded in state/), Folgen-Seite, sw v69
- EpisodeProgress.discarded_at (Migration 0015); POST /podcasts/episodes/discard/
{"episode_ids": [...]}: aus der Warteschlange (_dequeue, auch von queue/remove benutzt)
und aus "Neu" (dismissed), discarded_at = jetzt; gehört, Stelle, Stern und updated_at
bleiben; fremde Folgen werden ausgelassen (count)
- state/ liefert "discarded": [{id, at}] der letzten 30 Tage: jedes Gerät löscht seine
ältere Datei; wieder eingereiht (_enqueue) wird discarded_at gelöscht
- Browser-Host: podcastDiscard (pausiert geladene Folge verlässt den Player, laufende spielt
zu Ende), podcastPlayEpisodeAt (Kapitel auf der Folgen-Seite)
- static/podcasts aus diora-android ee1a910 (Folgen-Seite, Folge löschen, Datei löschen,
Wischtexte und Icons, Ziehgriff)
- Tests: DiscardTests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
461b42a5bf
commit
985e635f9a
9 changed files with 624 additions and 81 deletions
16
podcasts/migrations/0015_episodeprogress_discarded_at.py
Normal file
16
podcasts/migrations/0015_episodeprogress_discarded_at.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('podcasts', '0014_playback_routine'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='episodeprogress',
|
||||
name='discarded_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -201,6 +201,10 @@ class EpisodeProgress(models.Model):
|
|||
played = models.BooleanField(default=False)
|
||||
dismissed = models.BooleanField(default=False)
|
||||
favorite = models.BooleanField(default=False)
|
||||
# "Folge löschen": when the user threw the episode away (out of the queue
|
||||
# and "Neu", not played). Every device deletes its download of it if that
|
||||
# is older (state/ lists the recent ones); queueing it again clears it.
|
||||
discarded_at = models.DateTimeField(null=True, blank=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
|
|
|
|||
|
|
@ -2514,3 +2514,117 @@ class PlaybackSettingsTests(TestCase):
|
|||
self.client.logout()
|
||||
self.assertEqual(self.post({'skip_back': 10}).status_code, 401)
|
||||
self.assertEqual(self.client.get('/podcasts/settings/').status_code, 401)
|
||||
|
||||
|
||||
class DiscardTests(TestCase):
|
||||
"""POST /podcasts/episodes/discard/ ("Folge löschen"): out of the queue
|
||||
and "Neu", the downloads go on every device (state/ "discarded"), but
|
||||
not played: the place and the star stay."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
||||
self.client.force_login(self.user)
|
||||
feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/feed', title='Feed')
|
||||
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
self.eps = [
|
||||
PodcastEpisode.objects.create(feed=feed, guid=f'e{i}', title=f'E{i}', duration_seconds=3600,
|
||||
audio_url=f'https://example.org/e{i}.mp3', pub_date=base + timedelta(days=i))
|
||||
for i in range(5)
|
||||
]
|
||||
bob = User.objects.create_user(username='bob', password='pw12345678')
|
||||
fbob = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/feed', title='Feed')
|
||||
self.bob = bob
|
||||
self.bobs = PodcastEpisode.objects.create(feed=fbob, guid='x', title='X', audio_url='https://example.org/x.mp3')
|
||||
PodcastQueue.objects.create(user=bob, episode=self.bobs, position=0)
|
||||
|
||||
def post(self, body):
|
||||
return self.client.post('/podcasts/episodes/discard/', json.dumps(body), content_type='application/json')
|
||||
|
||||
def queue(self):
|
||||
return list(PodcastQueue.objects.filter(user=self.user).order_by('position', 'added_at', 'id')
|
||||
.values_list('episode_id', flat=True))
|
||||
|
||||
def state(self):
|
||||
return self.client.get('/podcasts/state/').json()
|
||||
|
||||
def test_out_of_queue_and_inbox_not_played(self):
|
||||
e0, e1, e2, e3, e4 = self.eps
|
||||
for i, e in enumerate((e0, e1, e2)):
|
||||
PodcastQueue.objects.create(user=self.user, episode=e, position=i)
|
||||
p1 = _progress(self.user, e1, _at(2, 1), position_seconds=754, favorite=True)
|
||||
self.assertIn(e3.id, [x['id'] for x in self.state()['inbox']])
|
||||
r = self.post({'episode_ids': [e1.id, e3.id]})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
d = r.json()
|
||||
self.assertEqual((d['ok'], d['count']), (True, 2))
|
||||
# The rest of the queue keeps its order.
|
||||
self.assertEqual(self.queue(), [e0.id, e2.id])
|
||||
# The place, the star and the listening time stay; not played.
|
||||
p1.refresh_from_db()
|
||||
self.assertEqual((p1.position_seconds, p1.favorite, p1.played, p1.dismissed), (754, True, False, True))
|
||||
self.assertEqual(p1.updated_at, _at(2, 1))
|
||||
self.assertIsNotNone(p1.discarded_at)
|
||||
# An episode without a row gets one: out of "Neu", not played, no place.
|
||||
p3 = EpisodeProgress.objects.get(user=self.user, episode=e3)
|
||||
self.assertEqual((p3.played, p3.dismissed, p3.position_seconds), (False, True, 0))
|
||||
s = self.state()
|
||||
inbox = [x['id'] for x in s['inbox']]
|
||||
self.assertNotIn(e3.id, inbox)
|
||||
self.assertIn(e4.id, inbox)
|
||||
self.assertEqual([q['episode__id'] for q in s['queue']], [e0.id, e2.id])
|
||||
# The state lists both for the other devices, with the time of the discard.
|
||||
self.assertEqual({x['id'] for x in s['discarded']}, {e1.id, e3.id})
|
||||
at = next(x['at'] for x in s['discarded'] if x['id'] == e1.id)
|
||||
self.assertRegex(at, r'^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$')
|
||||
self.assertEqual(at, d['at'])
|
||||
# Still unplayed in the episode lists, with its place.
|
||||
unplayed = self.client.get('/podcasts/episodes/', {'filter': 'unplayed'}).json()['episodes']
|
||||
row = next(x for x in unplayed if x['id'] == e1.id)
|
||||
self.assertEqual((row['position_seconds'], row['played'], row['in_queue']), (754, False, False))
|
||||
|
||||
def test_queued_again_works_and_clears_it(self):
|
||||
e0 = self.eps[0]
|
||||
PodcastQueue.objects.create(user=self.user, episode=e0, position=0)
|
||||
self.post({'episode_ids': [e0.id]})
|
||||
self.assertEqual(self.queue(), [])
|
||||
r = self.client.post('/podcasts/queue/add/', json.dumps({'episode_ids': [e0.id]}), content_type='application/json')
|
||||
self.assertEqual(r.json()['added'], [e0.id])
|
||||
self.assertEqual(self.queue(), [e0.id])
|
||||
self.assertIsNone(EpisodeProgress.objects.get(user=self.user, episode=e0).discarded_at)
|
||||
self.assertEqual(self.state()['discarded'], [])
|
||||
# Playing it saves its place as ever.
|
||||
self.client.post('/podcasts/progress/save/', json.dumps({'episode_id': e0.id, 'position_seconds': 99}),
|
||||
content_type='application/json')
|
||||
self.assertEqual(EpisodeProgress.objects.get(user=self.user, episode=e0).position_seconds, 99)
|
||||
|
||||
def test_kept_queue_order_stays_sorted(self):
|
||||
PodcastSettings.objects.update_or_create(user=self.user, defaults={'keep_sorted': 'old'})
|
||||
for e in self.eps:
|
||||
self.client.post('/podcasts/queue/add/', json.dumps({'episode_ids': [e.id]}), content_type='application/json')
|
||||
self.assertEqual(self.queue(), [e.id for e in self.eps])
|
||||
self.post({'episode_ids': [self.eps[2].id]})
|
||||
self.assertEqual(self.queue(), [self.eps[i].id for i in (0, 1, 3, 4)])
|
||||
|
||||
def test_old_discards_leave_the_state(self):
|
||||
e0, e1 = self.eps[:2]
|
||||
self.post({'episode_ids': [e0.id, e1.id]})
|
||||
EpisodeProgress.objects.filter(user=self.user, episode=e0).update(
|
||||
discarded_at=datetime.now(timezone.utc) - timedelta(days=31))
|
||||
self.assertEqual([x['id'] for x in self.state()['discarded']], [e1.id])
|
||||
|
||||
def test_other_users_episodes_are_left_alone(self):
|
||||
r = self.post({'episode_ids': [self.bobs.id, 999999]})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.json()['count'], 0)
|
||||
self.assertTrue(PodcastQueue.objects.filter(user=self.bob, episode=self.bobs).exists())
|
||||
self.assertFalse(EpisodeProgress.objects.filter(episode=self.bobs).exists())
|
||||
self.assertEqual(self.state()['discarded'], [])
|
||||
|
||||
def test_bad_requests(self):
|
||||
for body in ({}, {'episode_ids': []}, {'episode_ids': self.eps[0].id}, {'episode_ids': ['x']},
|
||||
{'episode_ids': [True]}, [1, 2]):
|
||||
with self.subTest(body=body):
|
||||
self.assertEqual(self.post(body).status_code, 400)
|
||||
self.assertEqual(self.client.get('/podcasts/episodes/discard/').status_code, 405)
|
||||
self.client.logout()
|
||||
self.assertEqual(self.post({'episode_ids': [self.eps[0].id]}).status_code, 401)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ urlpatterns = [
|
|||
path('feeds/<int:pk>/episodes/', views.feed_episodes, name='podcast_feed_episodes'),
|
||||
path('episodes/', views.episode_list, name='podcast_episode_list'),
|
||||
path('episodes/favorite/', views.set_favorites, name='podcast_set_favorites'),
|
||||
path('episodes/discard/', views.discard_episodes, name='podcast_discard_episodes'),
|
||||
path('episodes/<int:pk>/', views.episode_detail, name='podcast_episode_detail'),
|
||||
path('episodes/<int:pk>/transcript/', views.episode_transcript, name='podcast_episode_transcript'),
|
||||
path('episodes/<int:pk>/favorite/', views.set_favorite, name='podcast_set_favorite'),
|
||||
|
|
|
|||
|
|
@ -112,10 +112,18 @@ def _enqueue(user, episode_ids, where, current_hint=None):
|
|||
)
|
||||
_write_queue_order(user, queued[:at] + new + queued[at:])
|
||||
_leave_inbox(user, new)
|
||||
# Wanted again after "Folge löschen": devices keep (or fetch) their download.
|
||||
EpisodeProgress.objects.filter(user=user, episode_id__in=new, discarded_at__isnull=False).update(discarded_at=None)
|
||||
_keep_sorted(user)
|
||||
return new
|
||||
|
||||
|
||||
def _dequeue(user, episode_ids):
|
||||
"""Take episodes out of the user's queue. The others keep their order
|
||||
(a queue kept sorted stays sorted: taking some out doesn't change that)."""
|
||||
PodcastQueue.objects.filter(user=user, episode_id__in=episode_ids).delete()
|
||||
|
||||
|
||||
def _sort_key(rule):
|
||||
"""Sort key for one queue row ({pub_date, duration, position, feed_title})
|
||||
by a PodcastSettings.SORT_ORDERS rule. Ties keep their current order
|
||||
|
|
@ -1570,8 +1578,9 @@ def refresh_all_feeds(request):
|
|||
def podcast_state(request):
|
||||
"""Everything a client shows first, in one response: feeds, queue, the
|
||||
first page of the inbox (inbox_limit of inbox_total new episodes), the
|
||||
settings and how many AntennaPod downloads wait to be offered
|
||||
(import-downloads/)."""
|
||||
settings, how many AntennaPod downloads wait to be offered
|
||||
(import-downloads/) and the episodes discarded lately ("Folge löschen":
|
||||
a device deletes its older download of them)."""
|
||||
if not request.user.is_authenticated:
|
||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||
try:
|
||||
|
|
@ -1588,6 +1597,7 @@ def podcast_state(request):
|
|||
'inbox_limit': limit,
|
||||
'settings': _settings_data(s),
|
||||
'import_downloads': {'count': len(downloads), 'bytes': sum(e['size'] for e in downloads)},
|
||||
'discarded': _discarded_data(request.user),
|
||||
'server_time': timezone.now().isoformat(),
|
||||
})
|
||||
|
||||
|
|
@ -1680,8 +1690,7 @@ def queue_remove(request):
|
|||
except (json.JSONDecodeError, ValueError):
|
||||
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||
|
||||
episode_id = body.get('episode_id')
|
||||
PodcastQueue.objects.filter(user=request.user, episode_id=episode_id).delete()
|
||||
_dequeue(request.user, [body.get('episode_id')])
|
||||
return JsonResponse({'ok': True})
|
||||
|
||||
|
||||
|
|
@ -1940,6 +1949,62 @@ def dismiss_episodes(request):
|
|||
return JsonResponse({'ok': True, 'count': len(valid_ids)})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# "Folge löschen": out of the queue and "Neu", its downloads gone everywhere
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DISCARDED_DAYS = 30 # how long state/ lists a discard for devices still to delete their copy
|
||||
|
||||
|
||||
def _discarded_data(user, now=None):
|
||||
"""[{"id": episode id, "at": ISO ms}] of the episodes discarded lately:
|
||||
a device deletes its download of one if it finished before `at`."""
|
||||
since = (now or timezone.now()) - timedelta(days=DISCARDED_DAYS)
|
||||
rows = (EpisodeProgress.objects.filter(user=user, discarded_at__gte=since)
|
||||
.order_by('discarded_at').values_list('episode_id', 'discarded_at'))
|
||||
return [{'id': ep_id, 'at': _iso_ms(at)} for ep_id, at in rows]
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(['POST'])
|
||||
def discard_episodes(request):
|
||||
"""{"episode_ids": [...]}: the episodes leave the queue and "Neu" (like
|
||||
dismiss: not played) and every device deletes its download of them (they
|
||||
see them under "discarded" in state/). Played, the saved place and the
|
||||
star stay as they are; the listening time (updated_at) too. Episodes that
|
||||
aren't the user's are left out (count)."""
|
||||
if not request.user.is_authenticated:
|
||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||
try:
|
||||
body = json.loads(request.body or b'{}')
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||
if not isinstance(body, dict):
|
||||
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||
episode_ids = body.get('episode_ids')
|
||||
if not isinstance(episode_ids, list) or not episode_ids:
|
||||
return JsonResponse({'error': 'episode_ids (a list) required'}, status=400)
|
||||
if not all(isinstance(i, int) and not isinstance(i, bool) for i in episode_ids):
|
||||
return JsonResponse({'error': 'episode_ids must be numbers'}, status=400)
|
||||
wanted = set(episode_ids[:EPISODE_IDS_MAX])
|
||||
valid = sorted(
|
||||
PodcastEpisode.objects.filter(id__in=wanted, feed__user=request.user).values_list('id', flat=True)
|
||||
)
|
||||
now = timezone.now()
|
||||
with transaction.atomic():
|
||||
_dequeue(request.user, valid)
|
||||
# update() leaves updated_at alone: throwing an episode away isn't listening to it.
|
||||
rows = EpisodeProgress.objects.filter(user=request.user, episode_id__in=valid)
|
||||
rows.update(dismissed=True, discarded_at=now)
|
||||
have = set(rows.values_list('episode_id', flat=True))
|
||||
EpisodeProgress.objects.bulk_create(
|
||||
[EpisodeProgress(user=request.user, episode_id=i, dismissed=True, discarded_at=now)
|
||||
for i in valid if i not in have],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
return JsonResponse({'ok': True, 'count': len(valid), 'at': _iso_ms(now)})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inbox
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -357,6 +357,23 @@
|
|||
});
|
||||
}
|
||||
|
||||
// "Folge löschen": out of the queue and "Neu", not played (its place stays). The
|
||||
// server lists it under "discarded" so the devices with downloads delete theirs
|
||||
// (the browser has none). In the player here: paused, it leaves the player (see
|
||||
// dropDiscarded); playing, it goes on until it ends or another one starts.
|
||||
function discard(ids) {
|
||||
if (!ids.length) return;
|
||||
edit(s => {
|
||||
const set = new Set(ids);
|
||||
const order = queueIds(s);
|
||||
if (order.some(id => set.has(id))) setQueueOrder(s, order.filter(id => !set.has(id)));
|
||||
markInQueue(s, set, false);
|
||||
removeFromInbox(s, set);
|
||||
queueOp('/podcasts/episodes/discard/', {episode_ids: ids});
|
||||
});
|
||||
dropDiscarded(ids);
|
||||
}
|
||||
|
||||
function setSettings(changes) {
|
||||
edit(s => {
|
||||
const settings = s.settings || (s.settings = {});
|
||||
|
|
@ -742,12 +759,43 @@
|
|||
if (n) goTo(n, false, wantPlay, true);
|
||||
}
|
||||
|
||||
// A discarded episode loaded here and paused leaves the player: the queue's first one
|
||||
// takes its place (loaded, not playing), or nothing. Its place was kept on pause.
|
||||
function dropDiscarded(ids) {
|
||||
const id = curId();
|
||||
if (id == null || ids.indexOf(id) < 0 || wantPlay) return;
|
||||
const n = queueOf(state)[0] || null;
|
||||
if (!n) { putAway(); syncSoon(); return; }
|
||||
unload();
|
||||
cur = Object.assign({}, n);
|
||||
startAt = null;
|
||||
pausedAt = 0;
|
||||
buffering = false;
|
||||
error = null;
|
||||
lastDur = 0;
|
||||
lastPos = resumeMs();
|
||||
storePlayer();
|
||||
report();
|
||||
syncSoon();
|
||||
}
|
||||
|
||||
function playEpisode(id) {
|
||||
if (curId() === id) { resume(); return; }
|
||||
const e = entry(id);
|
||||
if (e) goTo(e, false, true);
|
||||
}
|
||||
|
||||
// From `ms` on (the episode screen: a chapter, a time in the shownotes).
|
||||
function playEpisodeAt(id, ms) {
|
||||
if (curId() === id) { seek(ms); resume(); return; }
|
||||
const e = entry(id);
|
||||
if (!e) return;
|
||||
goTo(e, false, false);
|
||||
startAt = ms;
|
||||
lastPos = ms;
|
||||
resume();
|
||||
}
|
||||
|
||||
// The podcast's outro is skipped: the episode counts as finished there.
|
||||
function skipOutro(id) {
|
||||
if (outroDone === id) return;
|
||||
|
|
@ -1512,6 +1560,7 @@
|
|||
},
|
||||
podcastMarkPlayedAll: sent((json, played) => markPlayedAll(ids(json), !!played, prefs.remove_played)),
|
||||
podcastDismiss: sent(json => dismiss(ids(json))),
|
||||
podcastDiscard: sent(json => discard(ids(json))),
|
||||
podcastSettings: sent(json => setSettings(parse(json, {}) || {})),
|
||||
podcastAutoQueue: sent((feedId, on) => setAutoQueue(feedId, !!on)),
|
||||
podcastFeedSettings: sent((feedId, json) => setFeedSettings(feedId, parse(json, {}) || {})),
|
||||
|
|
@ -1529,6 +1578,13 @@
|
|||
playEpisode(id);
|
||||
flush();
|
||||
},
|
||||
podcastPlayEpisodeAt(json, currentId, ms) {
|
||||
const ep = parse(json, {}) || {};
|
||||
const id = ep.episode__id != null ? ep.episode__id : ep.id;
|
||||
if (!entry(id)) enqueue([ep], null, currentId > 0 ? currentId : null);
|
||||
playEpisodeAt(id, Math.max(0, Number(ms) || 0));
|
||||
flush();
|
||||
},
|
||||
podcastNext: () => next(),
|
||||
podcastToggle: () => (wantPlay ? pause() : resume()),
|
||||
podcastSeek: ms => seek(Number(ms)),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* diora service worker — caches the app shell for offline use.
|
||||
*/
|
||||
|
||||
const CACHE = 'diora-v68';
|
||||
const CACHE = 'diora-v69';
|
||||
const SHELL = [
|
||||
'/static/css/app.css',
|
||||
'/static/js/app.js',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Copied from diora-android 339de8f 2026-10-02: web-native/static/native-podcast.js
|
||||
// Copied from diora-android ee1a910 2026-10-02: 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.
|
||||
|
||||
|
|
@ -135,8 +135,33 @@
|
|||
return b;
|
||||
}
|
||||
|
||||
// Line icons in the text's colour (black on e-ink): deleting is a bin, taking out of a
|
||||
// list a list with an x — the same in menus, swipes, the selection bar and the undo bar.
|
||||
const SVG_PATHS = {
|
||||
trash: '<path d="M4 7h16M9.5 7V4.5h5V7M6.5 7l1 12.5h9l1-12.5M10 11v5.5M14 11v5.5"/>',
|
||||
listx: '<path d="M4 6h11M4 11h7M4 16h6M14.5 13.5l5.5 5.5M20 13.5l-5.5 5.5"/>',
|
||||
};
|
||||
function svgIcon(name, size) {
|
||||
const span = el('span', 'nui-svg');
|
||||
const px = size || 20;
|
||||
span.innerHTML = `<svg viewBox="0 0 24 24" width="${px}" height="${px}" aria-hidden="true" fill="none" ` +
|
||||
`stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${SVG_PATHS[name]}</svg>`;
|
||||
return span;
|
||||
}
|
||||
|
||||
// The sheets (native-ui.js, or the browser host's copy) show text icons; an item's `svg`
|
||||
// replaces its icon afterwards (the rows come in the items' order).
|
||||
function openSheet(title, items) {
|
||||
if (window.DioraUI && DioraUI.openSheet) DioraUI.openSheet(title, items);
|
||||
if (!(window.DioraUI && DioraUI.openSheet)) return;
|
||||
DioraUI.openSheet(title, items);
|
||||
const shown = items.filter(it => it && !it.custom);
|
||||
if (!shown.some(it => it.svg)) return;
|
||||
const rows = document.querySelectorAll('#nui-sheet .nui-panel > .nui-row');
|
||||
if (rows.length !== shown.length) return;
|
||||
shown.forEach((it, i) => {
|
||||
const icon = it.svg && rows[i].querySelector('.nui-row-icon');
|
||||
if (icon) { icon.textContent = ''; icon.append(svgIcon(it.svg)); }
|
||||
});
|
||||
}
|
||||
|
||||
function clock(seconds) {
|
||||
|
|
@ -281,18 +306,58 @@
|
|||
const selectItem = (e, opts) => opts && opts.select &&
|
||||
{icon: '☐', label: 'Mehrere auswählen', hint: 'diese und weitere Folgen auf einmal', action: () => startSelecting(e.id)};
|
||||
|
||||
// „Folge löschen“: out of the queue and „Neu“, its download goes here and on every other
|
||||
// device (the server tells them), but not played: it keeps its place and stays in „Folgen“.
|
||||
const DISCARD_HINT = 'aus Warteschlange und Neu, Datei auf allen Geräten löschen – nicht als gehört';
|
||||
const discardItem = (e, opts) => N.podcastDiscard &&
|
||||
{icon: '⊠', svg: 'trash', label: 'Folge löschen', hint: DISCARD_HINT, action: () => discardOne(e, (opts && opts.list) || '', (opts && opts.filters) || [])};
|
||||
|
||||
// One episode: no question, 4 s „Rückgängig“ like a swipe; sent only then. False when its row leaves the list.
|
||||
function discardOne(e, list, filters) {
|
||||
const hide = list === 'queue' || list === 'inbox' || list === 'downloads' ||
|
||||
filters.includes('downloaded') || filters.includes('queued');
|
||||
offerUndo(e, 'Folge gelöscht', {hide, svg: 'trash', run: () => {
|
||||
discardNow([e.id]);
|
||||
if (!hide) render();
|
||||
}});
|
||||
return !hide;
|
||||
}
|
||||
|
||||
function discardNow(ids) {
|
||||
N.podcastDiscard(JSON.stringify(ids));
|
||||
// The lists fetched from the server (Folgen, a podcast) show it at once.
|
||||
const lists = [epList && epList.items, feedView && feedView.episodes];
|
||||
for (const l of lists) for (const o of l || []) if (ids.includes(o.id)) o.in_queue = false;
|
||||
}
|
||||
|
||||
// Several: asked first.
|
||||
function discardMany(ids) {
|
||||
openSheet(`${nEpisodes(ids.length)} löschen? Nicht als gehört markiert.`, [
|
||||
{icon: '⊠', svg: 'trash', label: 'Löschen', hint: DISCARD_HINT, danger: true, action: () => {
|
||||
discardNow(ids);
|
||||
toast(`${nEpisodes(ids.length)} gelöscht`);
|
||||
stopSelecting();
|
||||
}},
|
||||
{icon: '‹', label: 'Abbrechen', action: () => {}},
|
||||
]);
|
||||
}
|
||||
|
||||
const discardAction = () => N.podcastDiscard && {label: 'Löschen', svg: 'trash', danger: true, run: discardMany};
|
||||
|
||||
function queueMenu(e, opts) {
|
||||
const playing = currentId() === e.id;
|
||||
openSheet(e.title, [
|
||||
{icon: '▶', label: playing ? 'Player öffnen' : 'Abspielen', action: () => play(e)},
|
||||
!playing && {icon: '☰', label: 'Shownotes und Kapitel', action: () => openNotes(e)},
|
||||
!playing && {icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)},
|
||||
favoriteItem(e),
|
||||
!playing && {icon: '⤴', label: 'Als Nächstes', hint: 'direkt nach der laufenden Folge', action: () => moveTo(e, 'next')},
|
||||
{icon: '⇈', label: 'Ganz nach oben', action: () => moveTo(e, 'top')},
|
||||
{icon: '⇊', label: 'Ganz nach unten', action: () => moveTo(e, 'bottom')},
|
||||
{icon: '✓', label: 'Als gehört markieren', hint: 'verlässt die Warteschlange', action: () => markPlayed(e)},
|
||||
...dlMenuItems(e),
|
||||
{icon: '✕', label: 'Aus der Warteschlange nehmen', action: () => N.podcastDequeue(e.id)},
|
||||
{icon: '✕', svg: 'listx', label: 'Aus der Warteschlange nehmen', hint: dlDone(e.id) ? 'Datei bleibt' : null,
|
||||
action: () => N.podcastDequeue(e.id)},
|
||||
discardItem(e, opts),
|
||||
selectItem(e, opts),
|
||||
]);
|
||||
}
|
||||
|
|
@ -301,17 +366,19 @@
|
|||
const queued = inQueue(e.id);
|
||||
openSheet(e.title, [
|
||||
{icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt dafür in die Warteschlange', action: () => play(e)},
|
||||
{icon: '☰', label: 'Shownotes und Kapitel', action: () => openNotes(e)},
|
||||
{icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)},
|
||||
favoriteItem(e),
|
||||
!queued && {icon: '⤴', label: 'Als Nächstes', action: () => enqueue(e, 'after_current')},
|
||||
!queued && {icon: '⇈', label: 'An den Anfang der Warteschlange', action: () => enqueue(e, 'front')},
|
||||
!queued && {icon: '⇊', label: 'Ans Ende der Warteschlange', action: () => enqueue(e, 'back')},
|
||||
queued && {icon: '✕', label: 'Aus der Warteschlange nehmen', action: () => N.podcastDequeue(e.id)},
|
||||
queued && {icon: '✕', svg: 'listx', label: 'Aus der Warteschlange nehmen', hint: dlDone(e.id) ? 'Datei bleibt' : null,
|
||||
action: () => N.podcastDequeue(e.id)},
|
||||
!e.played && {icon: '✓', label: 'Als gehört markieren', action: () => markPlayed(e)},
|
||||
e.played && {icon: '↺', label: 'Als ungehört markieren', action: () => N.podcastMarkPlayed(e.id, false)},
|
||||
...dlMenuItems(e),
|
||||
{icon: '⊘', label: 'Ausblenden', hint: 'aus „Neu“ entfernen, ohne es als gehört zu zählen',
|
||||
{icon: '⊘', svg: 'listx', label: 'Ausblenden', hint: 'aus „Neu“ entfernen, ohne es als gehört zu zählen',
|
||||
action: () => N.podcastDismiss(JSON.stringify([e.id]))},
|
||||
discardItem(e, opts),
|
||||
selectItem(e, opts),
|
||||
]);
|
||||
}
|
||||
|
|
@ -444,8 +511,8 @@
|
|||
// AntennaPod's "delete removes from queue".
|
||||
function deleteDequeueItem() {
|
||||
if (!DL) return null;
|
||||
return accountSwitch('delete_dequeue', 'Löschen nimmt aus der Warteschlange',
|
||||
'an: wer einen Download löscht, nimmt die Folge auch aus der Warteschlange', 'aus: die Folge bleibt eingereiht', true);
|
||||
return accountSwitch('delete_dequeue', '„Datei löschen“ nimmt aus der Warteschlange',
|
||||
'an: wer die Datei einer Folge löscht, nimmt sie auch aus der Warteschlange', 'aus: die Folge bleibt eingereiht', true);
|
||||
}
|
||||
|
||||
// AntennaPod's "adjust media info to playback speed".
|
||||
|
|
@ -681,6 +748,7 @@
|
|||
const acts = el('div', 'nui-pod-selactions');
|
||||
for (const a of actions.filter(Boolean)) {
|
||||
const b = el('button', 'btn btn-sm' + (a.danger ? ' btn-danger' : ''), a.label);
|
||||
if (a.svg) b.prepend(svgIcon(a.svg, 16));
|
||||
b.type = 'button';
|
||||
b.disabled = !selected.size;
|
||||
b.addEventListener('click', () => {
|
||||
|
|
@ -715,12 +783,13 @@
|
|||
{icon: '☆', label: 'Kein Favorit mehr', action: () => favoriteMany(pick(ids), false)},
|
||||
])},
|
||||
// Only where some of them are new.
|
||||
items.some(e => fresh.has(e.id)) && {label: 'Aus „Neu“', run: ids => {
|
||||
items.some(e => fresh.has(e.id)) && {label: 'Aus „Neu“', svg: 'listx', run: ids => {
|
||||
const gone = ids.filter(id => fresh.has(id));
|
||||
if (gone.length) N.podcastDismiss(JSON.stringify(gone));
|
||||
toast(gone.length ? `${nEpisodes(gone.length)} aus „Neu“ entfernt` : 'Keine davon ist in „Neu“');
|
||||
stopSelecting();
|
||||
}},
|
||||
discardAction(),
|
||||
].filter(Boolean));
|
||||
}
|
||||
|
||||
|
|
@ -913,6 +982,7 @@
|
|||
}
|
||||
|
||||
function render() {
|
||||
episodeScreenParts();
|
||||
if (!root) return;
|
||||
const scroll = scrollTop();
|
||||
// Search results arriving while typing: the field keeps its text and the cursor.
|
||||
|
|
@ -1057,9 +1127,10 @@
|
|||
row.addEventListener('click', () => toggleSelected(e.id));
|
||||
return row;
|
||||
}
|
||||
const menu = () => (list === 'queue' ? queueMenu(e, {select: !opts.noSelect}) : episodeMenu(e, {select: !opts.noSelect}));
|
||||
const how = {select: !opts.noSelect, list, filters: opts.filters || []};
|
||||
const menu = () => (list === 'queue' ? queueMenu(e, how) : episodeMenu(e, how));
|
||||
row.append(primaryButton(e, list), button('⋯', 'Mehr', menu, 'nui-pod-menu'));
|
||||
row.addEventListener('click', () => play(e));
|
||||
row.addEventListener('click', () => openEpisode(e, how));
|
||||
// Swipes: touch only, not while the queue is locked.
|
||||
const swipes = list && SWIPE_LISTS[list] && !(list === 'queue' && locked) ? list : '';
|
||||
rowGestures(row, menu, swipes && ((act, dir) => swipeDone(e, swipes, act, opts.filters || [], dir)), swipes);
|
||||
|
|
@ -1128,8 +1199,22 @@
|
|||
bg.dataset.dir = d;
|
||||
bg.classList.toggle('armed', armed());
|
||||
const label = bg.querySelector(d === 'right' ? '.nui-pod-swipe-l' : '.nui-pod-swipe-r');
|
||||
label.textContent = act === 'none' ? '' : (d === 'right' ? `${a.icon} ${swipeLabel(a, row)}` : `${swipeLabel(a, row)} ${a.icon}`);
|
||||
bg.querySelector(d === 'right' ? '.nui-pod-swipe-r' : '.nui-pod-swipe-l').textContent = '';
|
||||
const other = bg.querySelector(d === 'right' ? '.nui-pod-swipe-r' : '.nui-pod-swipe-l');
|
||||
if (label.dataset.act !== act) {
|
||||
label.dataset.act = act;
|
||||
label.textContent = '';
|
||||
if (act !== 'none') {
|
||||
const t = swipeText(a, row);
|
||||
const text = el('span', 'nui-pod-swipe-text');
|
||||
text.append(el('b', null, t.title));
|
||||
if (t.hint) text.append(el('small', null, t.hint));
|
||||
const icon = a.svg ? svgIcon(a.svg, 22) : el('span', null, a.icon);
|
||||
icon.classList.add('nui-pod-swipe-icon');
|
||||
label.append(icon, text);
|
||||
}
|
||||
}
|
||||
other.textContent = '';
|
||||
delete other.dataset.act;
|
||||
};
|
||||
const finish = done => {
|
||||
row.classList.remove('nui-pod-swiping');
|
||||
|
|
@ -1144,14 +1229,17 @@
|
|||
}
|
||||
};
|
||||
row.addEventListener('pointerdown', ev => {
|
||||
if (ev.target.closest('.nui-pod-handle') || (ev.pointerType === 'mouse' && ev.button !== 0)) return;
|
||||
if (ev.pointerType === 'mouse' && ev.button !== 0) return;
|
||||
// On the queue's drag handle: a vertical move sorts (dragSort), a sideways one is the swipe.
|
||||
const onHandle = !!ev.target.closest('.nui-pod-handle');
|
||||
if (onHandle && !onSwipe) return;
|
||||
start = {x: ev.clientX, y: ev.clientY, id: ev.pointerId, touch: ev.pointerType === 'touch'};
|
||||
mode = '';
|
||||
dx = vx = 0;
|
||||
last = {x: ev.clientX, t: ev.timeStamp};
|
||||
swallow = false;
|
||||
stopTimer();
|
||||
if (!ev.target.closest('button')) {
|
||||
if (!onHandle && !ev.target.closest('button')) {
|
||||
timer = setTimeout(() => {
|
||||
timer = 0;
|
||||
if (mode || !start) return;
|
||||
|
|
@ -1230,9 +1318,10 @@
|
|||
row.addEventListener('contextmenu', ev => ev.preventDefault());
|
||||
}
|
||||
|
||||
// What swiping does, per list (this device). The defaults are an AntennaPod setup.
|
||||
// What swiping does, per list (this device). The defaults are an AntennaPod setup, except the
|
||||
// queue's left: „Folge löschen“ where the host can (it was „Datei löschen“).
|
||||
const SWIPE_LISTS = {
|
||||
queue: {label: 'Warteschlange', left: 'delete', right: 'dequeue'},
|
||||
queue: {label: 'Warteschlange', left: N.podcastDiscard ? 'discard' : 'delete', right: 'dequeue'},
|
||||
inbox: {label: 'Neu', left: 'dismiss', right: 'queue'},
|
||||
episodes: {label: 'Folgen', left: 'download', right: 'favorite'},
|
||||
feed: {label: 'Podcast-Seite', hint: 'die Folgen eines Abos', left: 'delete', right: 'download'},
|
||||
|
|
@ -1241,13 +1330,19 @@
|
|||
const SWIPE_ACTIONS = [
|
||||
{id: 'none', label: 'Nichts', icon: ''},
|
||||
{id: 'queue', label: 'Einreihen', icon: '+'},
|
||||
{id: 'dequeue', label: 'Aus der Warteschlange', icon: '✕'},
|
||||
// Where there are files (DL is known only further down: getters).
|
||||
{id: 'dequeue', get label() { return DL ? 'Aus Warteschlange · Datei bleibt' : 'Aus der Warteschlange'; },
|
||||
icon: '✕', svg: 'listx'},
|
||||
{id: 'download', label: 'Herunterladen', icon: '↓', dl: true},
|
||||
{id: 'delete', label: 'Download löschen', icon: '✕', dl: true},
|
||||
{id: 'played', label: 'Gehört / ungehört', icon: '✓'},
|
||||
{id: 'delete', label: 'Datei löschen', hint: 'nur die Datei auf diesem Gerät', icon: '✕', svg: 'trash', dl: true},
|
||||
{id: 'played', label: 'Gehört / ungehört', hint: 'als gehört markieren', icon: '✓'},
|
||||
{id: 'favorite', label: 'Favorit an / aus', icon: '★'},
|
||||
{id: 'dismiss', label: 'Aus „Neu“ entfernen', icon: '⊘'},
|
||||
{id: 'dismiss', label: 'Aus „Neu“ entfernen', icon: '⊘', svg: 'listx'},
|
||||
{id: 'discard', label: 'Folge löschen', hint: 'aus Warteschlange und Neu, Datei auf allen Geräten weg – nicht gehört',
|
||||
icon: '⊠', svg: 'trash', host: 'podcastDiscard'},
|
||||
];
|
||||
// An action the host can do (the browser has no downloads; an older host no „Folge löschen“).
|
||||
const canSwipe = a => (!a.dl || DL) && (!a.host || !!N[a.host]);
|
||||
|
||||
function swipePrefs() {
|
||||
let all = {};
|
||||
|
|
@ -1255,17 +1350,46 @@
|
|||
return all;
|
||||
}
|
||||
|
||||
// The queue's left swipe was „Download löschen“ by default: kept as such (stored when chosen),
|
||||
// it becomes „Folge löschen“ once; any other choice stays.
|
||||
(function newQueueDefault() {
|
||||
if (!N.podcastDiscard) return;
|
||||
const all = swipePrefs();
|
||||
if (all.v >= 2) return;
|
||||
if (all.queue && all.queue.left === 'delete') all.queue.left = 'discard';
|
||||
all.v = 2;
|
||||
try { localStorage.setItem('nui_pod_swipes', JSON.stringify(all)); } catch (e) {}
|
||||
})();
|
||||
|
||||
function swipeAction(list, dir) {
|
||||
const l = SWIPE_LISTS[list];
|
||||
if (!l) return 'none';
|
||||
const own = (swipePrefs()[list] || {})[dir];
|
||||
const id = own || l[dir];
|
||||
const a = SWIPE_ACTIONS.find(x => x.id === id);
|
||||
return a && (!a.dl || DL) ? id : 'none';
|
||||
return a && canSwipe(a) ? id : 'none';
|
||||
}
|
||||
|
||||
function swipeLabel(a) {
|
||||
return {played: 'Gehört', favorite: 'Favorit', dequeue: 'Entfernen', delete: 'Löschen', dismiss: 'Aus „Neu“'}[a.id] || a.label;
|
||||
// What the swipe says behind the moving row: the action by its full name (bold) and what
|
||||
// it means for this episode (small) — never a bare „Löschen“ or „Entfernen“.
|
||||
function swipeText(a, row) {
|
||||
const id = row && Number(row.dataset.id);
|
||||
switch (a.id) {
|
||||
case 'discard': return {title: 'Folge löschen', hint: 'nicht gehört'};
|
||||
case 'delete': return {title: 'Datei löschen', hint: id && deleteDequeues(id) ? 'auch aus Warteschlange' : 'nur auf diesem Gerät'};
|
||||
case 'dequeue': return {title: 'Aus Warteschlange', hint: DL ? 'Datei bleibt' : null};
|
||||
case 'dismiss': return {title: 'Aus Neu entfernen', hint: 'nicht gehört'};
|
||||
case 'queue': return {title: 'Einreihen'};
|
||||
case 'download': return {title: 'Laden'};
|
||||
case 'favorite': return {title: 'Favorit'};
|
||||
case 'played': return {title: row && row.classList.contains('nui-pod-played') ? 'Ungehört' : 'Gehört'};
|
||||
default: return {title: a.label};
|
||||
}
|
||||
}
|
||||
|
||||
// „Datei löschen“ takes the episode out of the queue too (the account's delete_dequeue, on by default).
|
||||
function deleteDequeues(id) {
|
||||
return 'delete_dequeue' in settings() && settings().delete_dequeue !== false && inQueue(id);
|
||||
}
|
||||
|
||||
function swipeSettings() {
|
||||
|
|
@ -1280,9 +1404,9 @@
|
|||
function swipeListSheet(list) {
|
||||
const l = SWIPE_LISTS[list];
|
||||
const pick = dir => openSheet(`${l.label}: nach ${dir === 'left' ? 'links' : 'rechts'} wischen`,
|
||||
SWIPE_ACTIONS.filter(a => !a.dl || DL).map(a => ({
|
||||
SWIPE_ACTIONS.filter(canSwipe).map(a => ({
|
||||
icon: swipeAction(list, dir) === a.id ? '●' : '○', label: a.label, active: swipeAction(list, dir) === a.id,
|
||||
hint: a.id === l[dir] ? 'Standard' : null,
|
||||
hint: [a.hint, a.id === l[dir] ? 'Standard' : null].filter(Boolean).join(' · ') || null,
|
||||
action: () => {
|
||||
const all = swipePrefs();
|
||||
all[list] = Object.assign({}, all[list], {[dir]: a.id});
|
||||
|
|
@ -1332,13 +1456,13 @@
|
|||
}
|
||||
|
||||
// The bar with „Rückgängig“ (the toast can't be tapped).
|
||||
function undoBar(text) {
|
||||
function undoBar(text, svg) {
|
||||
let bar = $id('nui-pod-undo');
|
||||
if (!text) { if (bar) bar.classList.remove('nui-show'); return; }
|
||||
if (!bar) {
|
||||
bar = el('div');
|
||||
bar.id = 'nui-pod-undo';
|
||||
bar.append(el('span', 'nui-pod-undo-text'));
|
||||
bar.append(el('span', 'nui-pod-undo-icon'), el('span', 'nui-pod-undo-text'));
|
||||
const b = el('button', 'nui-pod-undo-btn', 'Rückgängig');
|
||||
b.type = 'button';
|
||||
b.addEventListener('click', ev => { ev.stopPropagation(); undoSwipe(); });
|
||||
|
|
@ -1348,6 +1472,10 @@
|
|||
const t = $id('nui-radio-toast');
|
||||
if (t) t.classList.remove('nui-show');
|
||||
bar.querySelector('.nui-pod-undo-text').textContent = text;
|
||||
const icon = bar.querySelector('.nui-pod-undo-icon');
|
||||
icon.textContent = '';
|
||||
if (svg) icon.append(svgIcon(svg, 18));
|
||||
icon.hidden = !svg;
|
||||
bar.classList.add('nui-show');
|
||||
}
|
||||
|
||||
|
|
@ -1355,7 +1483,7 @@
|
|||
commitSwipe();
|
||||
pendingSwipe = Object.assign({id: e.id}, change);
|
||||
pendingSwipe.timer = setTimeout(commitSwipe, 4000);
|
||||
undoBar(text);
|
||||
undoBar(text, change.svg);
|
||||
if (change.hide) render();
|
||||
}
|
||||
|
||||
|
|
@ -1377,7 +1505,8 @@
|
|||
if (act === 'dequeue') {
|
||||
if (!inQueue(e.id)) { toast('Nicht in der Warteschlange'); return true; }
|
||||
const hide = list === 'queue';
|
||||
offerUndo(e, 'Aus der Warteschlange genommen', {hide, run: () => { N.podcastDequeue(e.id); o.in_queue = false; }});
|
||||
offerUndo(e, dlDone(e.id) ? 'Aus Warteschlange · Datei bleibt' : 'Aus Warteschlange',
|
||||
{hide, svg: 'listx', run: () => { N.podcastDequeue(e.id); o.in_queue = false; }});
|
||||
return !hide;
|
||||
}
|
||||
if (act === 'download') {
|
||||
|
|
@ -1394,9 +1523,10 @@
|
|||
if (!downloads[e.id]) { toast('Nicht heruntergeladen'); return true; }
|
||||
const running = !has('done') && !has('failed');
|
||||
// "Löschen nimmt aus der Warteschlange" (account): then it leaves the queue too.
|
||||
const out = !running && 'delete_dequeue' in settings() && settings().delete_dequeue !== false && inQueue(e.id);
|
||||
const out = !running && deleteDequeues(e.id);
|
||||
const hide = list === 'downloads' || filters.includes('downloaded') || (list === 'queue' && out);
|
||||
offerUndo(e, running ? 'Download abgebrochen' : 'Download gelöscht', {hide, run: () => dlDelete(e, running, true)});
|
||||
offerUndo(e, running ? 'Download abgebrochen' : out ? 'Datei gelöscht · auch aus Warteschlange' : 'Datei gelöscht',
|
||||
{hide, svg: running ? null : 'trash', run: () => dlDelete(e, running, true)});
|
||||
return !hide;
|
||||
}
|
||||
if (act === 'played') {
|
||||
|
|
@ -1424,9 +1554,10 @@
|
|||
// „Neu“ is every unplayed episode not taken out (the page knows only the first ones).
|
||||
if (o.played || e.played) { toast('Gehört – nicht in „Neu“'); return true; }
|
||||
const hide = list === 'inbox';
|
||||
offerUndo(e, 'Aus „Neu“ entfernt', {hide, run: () => N.podcastDismiss(JSON.stringify([e.id]))});
|
||||
offerUndo(e, 'Aus Neu entfernt', {hide, svg: 'listx', run: () => N.podcastDismiss(JSON.stringify([e.id]))});
|
||||
return !hide;
|
||||
}
|
||||
if (act === 'discard' && N.podcastDiscard) return discardOne(e, list, filters);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -1442,7 +1573,8 @@
|
|||
{label: 'Nach oben', run: ids => N.podcastReorder(JSON.stringify(ids.concat(queue().map(q => q.episode__id).filter(id => !ids.includes(id)))))},
|
||||
{label: 'Nach unten', run: ids => N.podcastReorder(JSON.stringify(queue().map(q => q.episode__id).filter(id => !ids.includes(id)).concat(ids)))},
|
||||
{label: 'Gehört', run: ids => { N.podcastMarkPlayedAll(JSON.stringify(ids), true); stopSelecting(); }},
|
||||
{label: 'Entfernen', run: ids => { N.podcastDequeueAll(JSON.stringify(ids)); stopSelecting(); }},
|
||||
{label: 'Aus Warteschlange', svg: 'listx', run: ids => { N.podcastDequeueAll(JSON.stringify(ids)); stopSelecting(); }},
|
||||
discardAction(),
|
||||
]));
|
||||
} else {
|
||||
const head = el('div', 'nui-pod-head');
|
||||
|
|
@ -1497,8 +1629,9 @@
|
|||
{icon: '⇊', label: 'Ans Ende', action: () => add(ids, 'back')},
|
||||
])},
|
||||
{label: 'Gehört', run: ids => { N.podcastMarkPlayedAll(JSON.stringify(ids), true); stopSelecting(); }},
|
||||
{label: 'Ausblenden', run: ids => { N.podcastDismiss(JSON.stringify(ids)); stopSelecting(); }},
|
||||
{label: 'Ausblenden', svg: 'listx', run: ids => { N.podcastDismiss(JSON.stringify(ids)); stopSelecting(); }},
|
||||
DL && {label: 'Laden', run: ids => downloadMany(chosen(ids))},
|
||||
discardAction(),
|
||||
]));
|
||||
} else {
|
||||
const head = el('div', 'nui-pod-head');
|
||||
|
|
@ -2571,19 +2704,37 @@
|
|||
|
||||
// ---- drag to reorder (touch) -----------------------------------------------------------
|
||||
|
||||
// Dragging the handle (≡) sorts the queue — once the move is mostly vertical (after the same
|
||||
// 12 px the swipe waits for); a mostly sideways one is left to the row's swipe (rowGestures).
|
||||
function dragSort(handle, row, list) {
|
||||
handle.addEventListener('pointerdown', ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
handle.setPointerCapture(ev.pointerId);
|
||||
const rows = [...list.children];
|
||||
const from = rows.indexOf(row);
|
||||
const rects = rows.map(r => r.getBoundingClientRect());
|
||||
if (ev.pointerType === 'mouse' && ev.button !== 0) return;
|
||||
if (ev.pointerType === 'mouse') ev.preventDefault(); // no text selection while dragging
|
||||
const id = ev.pointerId;
|
||||
const startX = ev.clientX;
|
||||
const startY = ev.clientY;
|
||||
let to = from;
|
||||
let rows = null;
|
||||
let from = -1;
|
||||
let rects = null;
|
||||
let to = -1;
|
||||
const begin = () => {
|
||||
rows = [...list.children];
|
||||
from = rows.indexOf(row);
|
||||
rects = rows.map(r => r.getBoundingClientRect());
|
||||
to = from;
|
||||
row.classList.add('nui-pod-dragging');
|
||||
try { handle.setPointerCapture(id); } catch (e) {}
|
||||
};
|
||||
const move = e => {
|
||||
if (e.pointerId !== id) return;
|
||||
const dy = e.clientY - startY;
|
||||
if (!rows) {
|
||||
const dx = e.clientX - startX;
|
||||
if (Math.abs(dx) <= 12 && Math.abs(dy) <= 12) return;
|
||||
if (Math.abs(dy) <= Math.abs(dx)) { stop(); return; }
|
||||
begin();
|
||||
}
|
||||
e.preventDefault();
|
||||
row.style.transform = `translateY(${dy}px)`;
|
||||
const mid = rects[from].top + rects[from].height / 2 + dy;
|
||||
to = rects.findIndex(r => mid < r.top + r.height / 2);
|
||||
|
|
@ -2596,10 +2747,16 @@
|
|||
r.style.transform = shift ? `translateY(${shift}px)` : '';
|
||||
});
|
||||
};
|
||||
const end = () => {
|
||||
handle.removeEventListener('pointermove', move);
|
||||
handle.removeEventListener('pointerup', end);
|
||||
handle.removeEventListener('pointercancel', end);
|
||||
// Listened to on the window: before the drag starts the finger may leave the narrow handle.
|
||||
const stop = () => {
|
||||
window.removeEventListener('pointermove', move);
|
||||
window.removeEventListener('pointerup', end);
|
||||
window.removeEventListener('pointercancel', end);
|
||||
};
|
||||
const end = e => {
|
||||
if (e.pointerId !== id) return;
|
||||
stop();
|
||||
if (!rows) return;
|
||||
rows.forEach(r => { r.style.transform = ''; });
|
||||
row.classList.remove('nui-pod-dragging');
|
||||
if (to !== from) {
|
||||
|
|
@ -2608,9 +2765,9 @@
|
|||
N.podcastMove(parseInt(row.dataset.id, 10), to);
|
||||
}
|
||||
};
|
||||
handle.addEventListener('pointermove', move);
|
||||
handle.addEventListener('pointerup', end);
|
||||
handle.addEventListener('pointercancel', end);
|
||||
window.addEventListener('pointermove', move);
|
||||
window.addEventListener('pointerup', end);
|
||||
window.addEventListener('pointercancel', end);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2806,8 +2963,9 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Chapters and shownotes into a box; with `live`, taps jump in the playing episode.
|
||||
async function showDetails(box, id, live) {
|
||||
// Chapters and shownotes into a box; with `live`, taps jump in the playing episode,
|
||||
// else with `jump(seconds)` (the episode screen) they start it there.
|
||||
async function showDetails(box, id, live, jump) {
|
||||
box.textContent = '';
|
||||
box.dataset.episode = String(id);
|
||||
let d = storedDetails(id);
|
||||
|
|
@ -2841,6 +2999,7 @@
|
|||
li.dataset.index = String(i);
|
||||
li.append(el('span', 'nui-pod-chapter-t', clock(c.start || 0)), el('span', 'nui-pod-chapter-title', plain(c.title || `Kapitel ${i + 1}`)));
|
||||
if (live) li.addEventListener('click', () => N.podcastSeek(Math.round((c.start || 0) * 1000)));
|
||||
else if (jump) li.addEventListener('click', () => jump(c.start || 0));
|
||||
ol.append(li);
|
||||
});
|
||||
body.append(ol);
|
||||
|
|
@ -2851,13 +3010,14 @@
|
|||
const notes = el('div', 'nui-pod-notes');
|
||||
notes.innerHTML = d.description || '<p>Keine Shownotes.</p>';
|
||||
notes.querySelectorAll('a[href]').forEach(a => a.setAttribute('target', '_blank'));
|
||||
if (live) {
|
||||
if (live || jump) {
|
||||
linkStamps(notes);
|
||||
notes.addEventListener('click', ev => {
|
||||
const a = ev.target.closest('a.nui-pod-stamp');
|
||||
if (!a) return;
|
||||
ev.preventDefault();
|
||||
N.podcastSeek(parseInt(a.dataset.t, 10) * 1000);
|
||||
if (live) N.podcastSeek(parseInt(a.dataset.t, 10) * 1000);
|
||||
else jump(parseInt(a.dataset.t, 10));
|
||||
});
|
||||
}
|
||||
body.append(notes);
|
||||
|
|
@ -2987,34 +3147,124 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Shownotes of an episode that isn't playing: its own sheet.
|
||||
// ---- the episode screen: a tap on a row opens it ----------------------------------------
|
||||
// Cover, title, podcast (→ its page), date, length, where it stands; one big button to play;
|
||||
// the actions with the swipes' names and icons; chapters (a tap plays from there) and notes.
|
||||
let notesSheet = null;
|
||||
let epScreen = null; // {e, ctx, actions, playBtn} while the episode screen is open
|
||||
|
||||
function openNotes(e) {
|
||||
function openEpisode(e, ctx) {
|
||||
closeNotes();
|
||||
ctx = ctx || {};
|
||||
notesSheet = el('div');
|
||||
notesSheet.id = 'nui-pod-player';
|
||||
notesSheet.className = 'nui-pod-notes-sheet';
|
||||
notesSheet.className = 'nui-pod-notes-sheet nui-pod-episode';
|
||||
const panel = el('div', 'nui-pod-player-panel');
|
||||
const top = el('div', 'nui-pod-player-top');
|
||||
top.append(button('⌄', 'Schließen', closeNotes));
|
||||
panel.append(top, el('div', 'nui-pod-player-title', e.title), el('div', 'nui-pod-player-feed', e.feed || ''));
|
||||
const play1 = el('button', 'btn nui-pod-notes-play', '▶ Abspielen');
|
||||
play1.type = 'button';
|
||||
play1.addEventListener('click', () => { closeNotes(); play(e); });
|
||||
panel.append(play1);
|
||||
const head = el('div', 'nui-pod-ephead');
|
||||
head.append(art(e.art, 'big', e.feedArt, e.feed));
|
||||
const info = el('div', 'nui-pod-main');
|
||||
info.append(el('div', 'nui-pod-player-title', e.title));
|
||||
const f = e.feedId ? feeds().find(x => x.id === e.feedId) : null;
|
||||
if (f) {
|
||||
const link = el('button', 'nui-pod-eplink', plain(f.title) + ' ›');
|
||||
link.type = 'button';
|
||||
link.title = 'Zur Podcast-Seite';
|
||||
link.addEventListener('click', () => {
|
||||
closeNotes();
|
||||
closePlayer();
|
||||
preview = null;
|
||||
if (typeof showTab === 'function') showTab('podcasts');
|
||||
openFeed(f);
|
||||
});
|
||||
info.append(link);
|
||||
} else if (e.feed) {
|
||||
info.append(el('div', 'nui-pod-player-feed', e.feed));
|
||||
}
|
||||
info.append(el('div', 'nui-pod-meta nui-pod-epfacts'));
|
||||
head.append(info);
|
||||
const playBtn = el('button', 'btn nui-pod-notes-play nui-pod-epplay');
|
||||
playBtn.type = 'button';
|
||||
playBtn.addEventListener('click', () => {
|
||||
if (currentId() === e.id) { N.podcastToggle(); return; }
|
||||
closeNotes();
|
||||
play(e);
|
||||
});
|
||||
const actions = el('div', 'nui-pod-epactions');
|
||||
const box = el('div', 'nui-pod-notesbox');
|
||||
panel.append(box);
|
||||
panel.append(top, head, playBtn, actions, box);
|
||||
notesSheet.append(panel);
|
||||
notesSheet.addEventListener('click', ev => { if (ev.target === notesSheet) closeNotes(); });
|
||||
document.body.append(notesSheet);
|
||||
showDetails(box, e.id, false);
|
||||
epScreen = {e, ctx, actions, playBtn, facts: info.querySelector('.nui-pod-epfacts')};
|
||||
episodeScreenParts();
|
||||
showDetails(box, e.id, false, seconds => playAt(e, seconds));
|
||||
}
|
||||
|
||||
// Start the episode at `seconds` (a chapter, a time in the notes).
|
||||
function playAt(e, seconds) {
|
||||
const ms = Math.round(seconds * 1000);
|
||||
if (currentId() === e.id) {
|
||||
N.podcastSeek(ms);
|
||||
if (!player.playing) N.podcastToggle();
|
||||
} else if (N.podcastPlayEpisodeAt) {
|
||||
N.podcastPlayEpisodeAt(JSON.stringify(e.raw), currentId(), ms);
|
||||
} else {
|
||||
play(e);
|
||||
}
|
||||
closeNotes();
|
||||
}
|
||||
|
||||
// The parts of the screen that follow the state: facts, the play button, the actions.
|
||||
function episodeScreenParts() {
|
||||
if (!epScreen || !notesSheet) return;
|
||||
const {e, ctx, actions, playBtn, facts} = epScreen;
|
||||
// The freshest place and queue state of the episode.
|
||||
const q = queue().find(x => x.episode__id === e.id);
|
||||
const pos = q ? q.position_seconds || 0 : e.raw.position_seconds || 0;
|
||||
const played = !!e.raw.played;
|
||||
const dur = e.duration || 0;
|
||||
const parts = [];
|
||||
if (e.date) parts.push(day(e.date));
|
||||
if (dur) parts.push(minutes(shown(dur, e.feedId)));
|
||||
if (played) parts.push('gehört');
|
||||
else if (pos > 30 && dur) parts.push('noch ' + minutes(shown(Math.max(0, dur - pos), e.feedId)));
|
||||
facts.textContent = parts.join(' · ');
|
||||
const now = currentId() === e.id;
|
||||
playBtn.textContent = now && player.playing ? '❚❚ Pause'
|
||||
: !played && pos > 5 ? `▶ Weiter ab ${clock(pos)}` : '▶ Abspielen';
|
||||
actions.textContent = '';
|
||||
const act = (svg, icon, label, run, cls) => {
|
||||
const b = el('button', 'nui-pod-epact' + (cls ? ' ' + cls : ''));
|
||||
b.type = 'button';
|
||||
b.append(svg ? svgIcon(svg, 22) : el('span', 'nui-pod-epact-icon', icon), el('span', null, label));
|
||||
b.addEventListener('click', ev => { ev.stopPropagation(); run(); setTimeout(episodeScreenParts, 150); });
|
||||
actions.append(b);
|
||||
};
|
||||
if (inQueue(e.id)) act('listx', '', 'Aus Warteschlange', () => { N.podcastDequeue(e.id); e.raw.in_queue = false; });
|
||||
else act(null, '+', 'Einreihen', () => { enqueue(e); e.raw.in_queue = true; });
|
||||
if (DL && (e.raw.audio_url || e.raw.episode__audio_url)) {
|
||||
const d = downloads[e.id];
|
||||
if (!d || d.state === 'failed') act(null, '↓', 'Laden', () => dlStart(e));
|
||||
else if (d.state === 'done') act('trash', '', 'Datei löschen', () => dlDelete(e));
|
||||
else act('trash', '', 'Download abbrechen', () => dlDelete(e, true));
|
||||
}
|
||||
if (N.podcastDiscard) {
|
||||
act('trash', '', 'Folge löschen', () => { closeNotes(); discardOne(e, ctx.list || '', ctx.filters || []); }, 'nui-pod-epact-danger');
|
||||
}
|
||||
const fav = e.raw.favorite;
|
||||
act(null, fav ? '★' : '☆', 'Favorit', () => toggleFavorite(e, star => { e.raw.favorite = star; episodeScreenParts(); }),
|
||||
fav ? 'on' : '');
|
||||
if (played) act(null, '↺', 'Ungehört', () => { N.podcastMarkPlayed(e.id, false); e.raw.played = false; });
|
||||
else act(null, '✓', 'Gehört', () => { markPlayed(e); e.raw.played = true; e.raw.position_seconds = 0; });
|
||||
}
|
||||
|
||||
function closeNotes() {
|
||||
if (!notesSheet) return false;
|
||||
notesSheet.remove();
|
||||
notesSheet = null;
|
||||
epScreen = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -3505,8 +3755,8 @@
|
|||
function dlDelete(e, running, quiet) {
|
||||
N.podcastDeleteDownload(e.id);
|
||||
// "Löschen nimmt aus der Warteschlange" (account): the app takes it out too.
|
||||
const out = !running && settings().delete_dequeue !== false && 'delete_dequeue' in settings() && inQueue(e.id);
|
||||
if (!quiet) toast(running ? 'Download abgebrochen' : out ? 'Download gelöscht, aus der Warteschlange genommen' : 'Download gelöscht');
|
||||
const out = !running && deleteDequeues(e.id);
|
||||
if (!quiet) toast(running ? 'Download abgebrochen' : out ? 'Datei gelöscht · auch aus Warteschlange' : 'Datei gelöscht');
|
||||
}
|
||||
|
||||
// The row's download button (the episode lists): ↓, a ring while it loads, ✓ when it's here.
|
||||
|
|
@ -3565,7 +3815,10 @@
|
|||
return [{icon: '↓', label: 'Herunterladen', hint: d ? 'hat eben nicht geklappt (' + (d.error || '?') + ')' : 'zum Hören ohne Verbindung',
|
||||
action: () => dlStart(e)}];
|
||||
}
|
||||
if (d.state === 'done') return [{icon: '✕', label: 'Download löschen', hint: size(d.bytes), action: () => dlDelete(e)}];
|
||||
if (d.state === 'done') {
|
||||
return [{icon: '✕', svg: 'trash', label: deleteDequeues(e.id) ? 'Datei löschen · auch aus der Warteschlange' : 'Datei löschen',
|
||||
hint: 'nur die Datei auf diesem Gerät · ' + size(d.bytes), action: () => dlDelete(e)}];
|
||||
}
|
||||
return [
|
||||
d.state === 'queued' && d.order > 0 && N.podcastDownloadFirst &&
|
||||
{icon: '⇈', label: 'Zuerst laden', hint: 'vor die anderen wartenden', action: () => N.podcastDownloadFirst(e.id)},
|
||||
|
|
@ -3614,11 +3867,11 @@
|
|||
action: () => openSheet('Downloads aufräumen', DL_CLEANUPS.map(c => ({
|
||||
icon: s.cleanup === c.value ? '●' : '○', label: c.label, hint: c.hint, active: s.cleanup === c.value,
|
||||
action: () => dlSet({cleanup: c.value})})))},
|
||||
done.length > 0 && {icon: '✕', label: `Downloads löschen (${size(bytes)})`,
|
||||
done.length > 0 && {icon: '✕', svg: 'trash', label: `Alle Dateien löschen (${size(bytes)})`,
|
||||
hint: done.length === 1 ? '1 Folge' : done.length + ' Folgen',
|
||||
action: () => openSheet(`Alle ${done.length} Downloads löschen?`, [
|
||||
{icon: '✕', label: 'Löschen', hint: size(bytes), danger: true,
|
||||
action: () => { N.podcastDeleteAllDownloads(); toast('Downloads gelöscht'); }},
|
||||
action: () => openSheet(`Alle ${done.length} Dateien löschen?`, [
|
||||
{icon: '✕', svg: 'trash', label: 'Alle Dateien löschen', hint: size(bytes), danger: true,
|
||||
action: () => { N.podcastDeleteAllDownloads(); toast('Dateien gelöscht'); }},
|
||||
{icon: '‹', label: 'Abbrechen', action: () => {}},
|
||||
])},
|
||||
];
|
||||
|
|
@ -3916,6 +4169,7 @@
|
|||
// The current episode changed: the queue view marks it.
|
||||
if (before.episodeId !== player.episodeId || before.kind !== player.kind) render();
|
||||
else if (!!before.playing !== !!player.playing) playButtons();
|
||||
if (epScreen && (before.episodeId !== player.episodeId || !!before.playing !== !!player.playing)) episodeScreenParts();
|
||||
};
|
||||
|
||||
// The app loads this in <head>; the browser may load it after the page is parsed.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/* Copied from diora-android 339de8f 2026-10-02: the podcast parts of web-native/static/native.css
|
||||
/* Copied from diora-android ee1a910 2026-10-02: 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. */
|
||||
|
||||
|
|
@ -269,10 +269,13 @@ html.nui-one-player #nui-mini { display: none !important; }
|
|||
}
|
||||
.nui-pod-queued { color: var(--green); }
|
||||
|
||||
/* Narrow, and off the screen's edge: a swipe starting at the very edge (or sideways on the
|
||||
handle, see dragSort) is the row's swipe; only a vertical drag on the handle sorts. */
|
||||
.nui-pod-handle {
|
||||
flex: none;
|
||||
width: var(--nui-tap);
|
||||
width: 28px;
|
||||
height: var(--nui-tap);
|
||||
margin-right: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -472,6 +475,20 @@ body.eink .nui-pod-sleep.on { color: #000 !important; }
|
|||
.nui-pod-notes table { display: block; overflow-x: auto; }
|
||||
.nui-pod-notes-sheet .nui-pod-player-panel { max-height: 88%; }
|
||||
.nui-pod-notes-play { align-self: center; min-height: var(--nui-tap); }
|
||||
/* The episode screen (a tap on a row): head, the big play button, the actions, then notes. */
|
||||
.nui-pod-episode .nui-pod-player-panel { max-height: 92%; }
|
||||
.nui-pod-ephead { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.nui-pod-ephead .nui-pod-art-big { width: 88px; height: 88px; border-radius: 6px; flex: none; }
|
||||
.nui-pod-ephead .nui-pod-player-title { text-align: left; font-size: 17px; }
|
||||
.nui-pod-eplink { align-self: flex-start; border: none; background: none; padding: 4px 0; color: var(--nui-muted); font: inherit; font-size: 14px; text-align: left; cursor: pointer; text-decoration: underline; text-decoration-color: #555; }
|
||||
.nui-pod-epplay { align-self: stretch; font-size: 16px; font-weight: 700; }
|
||||
.nui-pod-epactions { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 6px; }
|
||||
.nui-pod-epact { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; min-height: 60px; padding: 6px 4px; border: 1px solid var(--border); border-radius: 8px; background: none; color: inherit; font: inherit; font-size: 12.5px; line-height: 1.2; text-align: center; cursor: pointer; }
|
||||
.nui-pod-epact-icon { font-size: 20px; line-height: 22px; }
|
||||
.nui-pod-epact.on .nui-pod-epact-icon { color: var(--accent); }
|
||||
.nui-pod-epact-danger { color: var(--red, #e5484d); }
|
||||
body.eink .nui-pod-epact { border-color: #000; color: #000; }
|
||||
body.eink .nui-pod-epact-danger { color: #000; }
|
||||
body.eink .nui-pod-chapter.current,
|
||||
body.eink .nui-pod-chapter.current .nui-pod-chapter-t,
|
||||
body.eink .nui-pod-notes a { color: #000; }
|
||||
|
|
@ -637,14 +654,23 @@ body.eink .nui-pod-facts a { color: #000; text-decoration: underline; }
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 18px;
|
||||
padding: 0 14px;
|
||||
background: #161616;
|
||||
color: var(--nui-muted);
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
.nui-pod-swipebg.armed { background: #2c2c2c; color: var(--fg, #fff); font-weight: 700; }
|
||||
.nui-pod-swipebg.armed { background: #2c2c2c; color: var(--fg, #fff); }
|
||||
/* Behind the row: the icon, under it the action in bold and what it means for this episode
|
||||
(narrow, so it shows whole by the time the swipe is armed). */
|
||||
.nui-pod-swipe-l, .nui-pod-swipe-r { display: flex; flex-direction: column; align-items: flex-start; gap: 3px; min-width: 0; }
|
||||
.nui-pod-swipe-r { align-items: flex-end; }
|
||||
.nui-pod-swipe-icon { font-size: 18px; line-height: 22px; }
|
||||
.nui-pod-swipe-text { display: flex; flex-direction: column; line-height: 1.2; min-width: 0; font-family: var(--nui-sans); letter-spacing: normal; }
|
||||
.nui-pod-swipe-r .nui-pod-swipe-text { align-items: flex-end; text-align: right; }
|
||||
.nui-pod-swipe-text b { font-weight: 700; font-size: 13px; }
|
||||
.nui-pod-swipe-text small { font-size: 12px; font-weight: 400; opacity: 0.75; }
|
||||
#nui-pod-undo {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
|
|
@ -666,6 +692,13 @@ body.eink .nui-pod-facts a { color: #000; text-decoration: underline; }
|
|||
transition: opacity 0.18s;
|
||||
}
|
||||
#nui-pod-undo.nui-show { opacity: 1; pointer-events: auto; }
|
||||
/* Line icons (native-podcast.js svgIcon): stroke in the text's colour. */
|
||||
.nui-svg { display: inline-flex; align-items: center; justify-content: center; line-height: 0; }
|
||||
.nui-svg svg { display: block; }
|
||||
.nui-row-icon .nui-svg { width: 100%; }
|
||||
.nui-pod-selactions .btn .nui-svg { margin-right: 6px; vertical-align: -3px; }
|
||||
.nui-pod-undo-icon { display: inline-flex; margin-right: -6px; }
|
||||
.nui-pod-undo-icon[hidden] { display: none; }
|
||||
.nui-pod-undo-text { font-family: var(--nui-sans); letter-spacing: normal; font-weight: 400; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.nui-pod-undo-btn {
|
||||
min-height: 40px;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue