Compare commits

..

No commits in common. "8b9c28cda18cff39fce0f0d50afedb042ccf9fc4" and "985e635f9aa89bdfbfeb0ff855c67ddc87cdb708" have entirely different histories.

29 changed files with 375 additions and 2999 deletions

View file

@ -97,7 +97,7 @@ def lastfm_connect(request):
return render(request, 'accounts/settings.html', {
'profile': request.user.profile,
'has_lastfm': request.user.profile.has_lastfm(),
'lastfm_error': f"Verbindung zu Last.fm fehlgeschlagen: {exc}",
'lastfm_error': f"Could not connect to Last.fm: {exc}",
})

View file

@ -89,7 +89,7 @@ AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
LANGUAGE_CODE = 'de'
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True

View file

@ -1,23 +0,0 @@
# Generated by Django 6.1 on 2026-10-02 22:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('podcasts', '0015_episodeprogress_discarded_at'),
]
operations = [
migrations.AddField(
model_name='playerstate',
name='sleep_episodes',
field=models.PositiveSmallIntegerField(default=0),
),
migrations.AddField(
model_name='playerstate',
name='sleep_left_ms',
field=models.PositiveBigIntegerField(default=0),
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 6.1 on 2026-10-04 08:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('podcasts', '0016_playerstate_sleep'),
]
operations = [
migrations.AddField(
model_name='podcastfeed',
name='http_body_hash',
field=models.CharField(blank=True, max_length=64),
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 6.1 on 2026-10-04 10:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('podcasts', '0017_feed_body_hash'),
]
operations = [
migrations.AddField(
model_name='podcastepisode',
name='silences',
field=models.JSONField(blank=True, null=True),
),
]

View file

@ -36,10 +36,6 @@ class PodcastFeed(models.Model):
# If-None-Match / If-Modified-Since so an unchanged feed costs a 304.
http_etag = models.CharField(max_length=500, blank=True)
http_last_modified = models.CharField(max_length=100, blank=True)
# Fingerprint of the last feed body that was read: many servers ignore the
# validators above and send the whole feed every time; the same body again
# then counts as unchanged and isn't parsed (views._body_hash).
http_body_hash = models.CharField(max_length=64, blank=True)
# The last failed refresh (network, HTTP status, not a feed); cleared on
# success. error_count counts failures in a row and drives the backoff.
last_error = models.CharField(max_length=500, blank=True)
@ -158,9 +154,6 @@ class PodcastEpisode(models.Model):
chapters_fetched_at = models.DateTimeField(null=True, blank=True)
# [{url, type, language}] from <podcast:transcript>.
transcripts = models.JSONField(default=list, blank=True)
# The pauses for skipping silence in the browser (silence.py), found on
# first request: {"spans": [[start, end], …]} (+ "error"); null = not yet.
silences = models.JSONField(null=True, blank=True)
class Meta:
unique_together = ('feed', 'guid')
@ -354,10 +347,6 @@ class PlayerState(models.Model):
# The ids of the devices online at the last change, sorted: when the set
# differs, seq goes up so every device sees others come and go.
online_devices = models.JSONField(default=list, blank=True)
# The active device's sleep timer at updated_at: playing time left (counts
# down while it plays), or episodes left; both 0: off.
sleep_left_ms = models.PositiveBigIntegerField(default=0)
sleep_episodes = models.PositiveSmallIntegerField(default=0)
@classmethod
def for_user(cls, user):

View file

@ -1,113 +0,0 @@
"""Skip silence in the browser: the pauses of an episode, found once on the server.
Android's player skips silence itself. A browser can't listen to the audio of
most podcast servers (no CORS: Web Audio hears only zeros), so the server
reads the file once with ffmpeg's silencedetect and keeps the pauses on the
episode (PodcastEpisode.silences); the browser then jumps over them while it
plays (podcasts-web.js).
"""
import logging
import os
import re
import subprocess
import tempfile
import threading
import requests
from django.db import close_old_connections
log = logging.getLogger(__name__)
NOISE = '-35dB' # quieter than this counts as silence
MIN_SILENCE = 0.4 # seconds; shorter pauses stay (they belong to speech)
TIMEOUT = 900 # seconds for decoding one episode
MAX_BYTES = 800 * 1024 * 1024 # a larger file isn't an episode to analyse
MAX_RUNNING = 2 # episodes analysed at the same time
_START = re.compile(r'silence_start: (-?[\d.]+)')
_END = re.compile(r'silence_end: ([\d.]+)')
_running = set()
_lock = threading.Lock()
_slots = threading.BoundedSemaphore(MAX_RUNNING)
def parse(stderr):
"""ffmpeg's silencedetect output -> [[start, end], …] in seconds (2 decimals)."""
spans = []
start = None
for line in stderr.splitlines():
m = _START.search(line)
if m:
start = max(0.0, float(m.group(1)))
continue
m = _END.search(line)
if m and start is not None:
end = float(m.group(1))
if end - start >= MIN_SILENCE:
spans.append([round(start, 2), round(end, 2)])
start = None
return spans
def analyze(url):
"""The pauses of the audio at `url`. Downloaded here first: the static ffmpeg
build can't look up host names (it crashes doing so), it reads a local file."""
import imageio_ffmpeg
fd, path = tempfile.mkstemp(suffix='.audio')
try:
with os.fdopen(fd, 'wb') as out, requests.get(
url, stream=True, timeout=(10, 60), headers={'User-Agent': 'diora podcast silence'}) as r:
if r.status_code >= 400:
raise RuntimeError(f'HTTP {r.status_code}')
size = 0
for chunk in r.iter_content(1 << 20):
size += len(chunk)
if size > MAX_BYTES:
raise RuntimeError('file too large')
out.write(chunk)
proc = subprocess.run(
[imageio_ffmpeg.get_ffmpeg_exe(), '-nostdin', '-hide_banner', '-nostats',
'-vn', '-sn', '-dn', '-i', path,
'-af', f'silencedetect=noise={NOISE}:d={MIN_SILENCE}', '-f', 'null', '-'],
capture_output=True, text=True, timeout=TIMEOUT,
)
if proc.returncode != 0:
tail = (proc.stderr or '').strip().splitlines()[-1:] or [f'ffmpeg failed ({proc.returncode})']
raise RuntimeError(tail[0][:200])
return parse(proc.stderr)
finally:
try:
os.remove(path)
except OSError:
pass
def ensure(episode_id):
"""Find the episode's pauses in the background, unless that runs already."""
with _lock:
if episode_id in _running:
return
_running.add(episode_id)
threading.Thread(target=_work, args=(episode_id,), daemon=True).start()
def _work(episode_id):
from .models import PodcastEpisode
try:
with _slots:
close_old_connections()
ep = PodcastEpisode.objects.filter(pk=episode_id).only('id', 'audio_url').first()
if ep is None:
return
try:
result = {'spans': analyze(ep.audio_url)}
except Exception as e: # unreachable file, timeout, not audio: remembered, not retried
log.warning('silences of episode %s failed: %s', episode_id, e)
result = {'spans': [], 'error': str(e)[:200] or type(e).__name__}
PodcastEpisode.objects.filter(pk=episode_id).update(silences=result)
finally:
with _lock:
_running.discard(episode_id)
close_old_connections()

View file

@ -257,34 +257,6 @@ class PlayerSyncTests(TestCase):
with self.subTest(body=body):
self.assertEqual(self.post('command', body).status_code, 400)
def test_sleep_timer_travels_with_the_state(self):
self.report('phone', True, kind='radio', start=True, sleep={'left_ms': 900_000, 'episodes': 0})
d = self.client.get('/podcasts/player/').json()
self.assertEqual(d['sleep'], {'left_ms': 900_000, 'episodes': 0})
# Episodes left; nonsense is none; an older client without it: none.
self.report('phone', True, sleep={'left_ms': 0, 'episodes': 2})
self.assertEqual((self.state().sleep_left_ms, self.state().sleep_episodes), (0, 2))
self.report('phone', True, sleep={'left_ms': 'x', 'episodes': -4})
self.assertEqual((self.state().sleep_left_ms, self.state().sleep_episodes), (0, 0))
self.report('phone', True, sleep={'left_ms': 10 ** 12})
self.assertEqual(self.state().sleep_left_ms, views.SLEEP_MAX_MINUTES * 60_000)
self.report('phone', True)
self.assertEqual(self.state().sleep_left_ms, 0)
# Another device's ignored report doesn't change it.
self.report('phone', True, sleep={'left_ms': 60_000})
self.report('web', True, sleep={'left_ms': 5})
self.assertEqual(self.state().sleep_left_ms, 60_000)
def test_sleep_command(self):
self.report('phone', True, kind='radio', start=True)
r = self.command('web', 'sleep', 30)
self.assertEqual(r.status_code, 200)
self.assertEqual((r.json()['command']['action'], r.json()['command']['value']), ('sleep', 30))
self.assertEqual(self.command('web', 'sleep', -1).json()['command']['value'], -1)
self.assertEqual(self.command('web', 'sleep', 0).json()['command']['value'], 0)
for bad in (None, 'x', 601, -100):
self.assertEqual(self.command('web', 'sleep', bad).status_code, 400, bad)
def test_transfer_to_another_device(self):
self.report('a', True, pos=1000)
self.report('c', False, pos=0) # c is online (paused report: ignored)

View file

@ -307,7 +307,7 @@ class RefreshAllTests(TestCase):
def test_refresh_all_reports_counts_and_skips_recent(self):
with patch('podcasts.views._fetch_feed', self.fake_fetch):
r = self.client.post('/podcasts/feeds/refresh-all/', '{}', content_type='application/json')
self.assertEqual(r.json(), {'ok': True, 'refreshed': 1, 'failed': 1, 'new_episodes': 1, 'done': True, 'left': 0})
self.assertEqual(r.json(), {'ok': True, 'refreshed': 1, 'failed': 1, 'new_episodes': 1})
self.assertEqual(sorted(self.fetched), ['https://example.org/a', 'https://example.org/b'])
# A was just refreshed: a second call within max_age only retries B.
self.fetched.clear()
@ -315,27 +315,6 @@ class RefreshAllTests(TestCase):
self.client.post('/podcasts/feeds/refresh-all/', '{}', content_type='application/json')
self.assertEqual(self.fetched, ['https://example.org/b'])
def test_refresh_stops_at_its_deadline(self):
import time as _time
from .views import _refresh_feeds
def slow(url, **kw):
_time.sleep(.5)
return self.fake_fetch(url, **kw)
with patch('podcasts.views._fetch_feed', slow):
refreshed, failed, new = _refresh_feeds([self.a, self.b], deadline=_time.monotonic() + .05)
self.assertEqual((refreshed, failed), (0, 0))
self.a.refresh_from_db()
self.assertIsNone(self.a.last_refreshed_at) # not done: left as it was
def test_the_body_fingerprint_is_kept_and_sent_again(self):
from .views import _validators
with patch('podcasts.views._fetch_feed', lambda url, **kw: FeedFetch(200, _parse(_rss([('x', 'Mon, 05 Jan 2026 10:00:00 GMT')])), body_hash='abc')):
self.client.post('/podcasts/feeds/refresh-all/', '{"max_age":0}', content_type='application/json')
self.a.refresh_from_db()
self.assertEqual(self.a.http_body_hash, 'abc')
self.assertEqual(_validators([self.a]), {'body_hash': 'abc'})
def test_a_shared_feed_url_is_fetched_once(self):
from .views import _refresh_feeds
with patch('podcasts.views._fetch_feed', self.fake_fetch):
@ -1512,25 +1491,6 @@ class FetchFeedTests(TestCase):
self.fetch(response)
return str(cm.exception)
def test_the_same_body_again_counts_as_unchanged_without_parsing(self):
first, _ = self.fetch(_HttpResponse('https://example.org/feed', content=self.RSS))
self.assertEqual(first.status, 200)
self.assertTrue(first.body_hash)
# The server ignores validators and sends the feed again: a 304, unparsed.
with patch('podcasts.views.feedparser.parse') as parse:
again, _ = self.fetch(_HttpResponse('https://example.org/feed', content=self.RSS), body_hash=first.body_hash)
parse.assert_not_called()
self.assertEqual((again.status, again.parsed, again.body_hash), (304, None, first.body_hash))
# Only the build date or a comment differs: still the same.
stamped = self.RSS.replace(b'<channel>', b'<channel><lastBuildDate>Sun, 04 Oct 2026 08:00:00 GMT</lastBuildDate><!-- 0.12 s -->', 1)
same, _ = self.fetch(_HttpResponse('https://example.org/feed', content=stamped), body_hash=first.body_hash)
self.assertEqual(same.status, 304)
# A new episode: parsed.
newer = _rss([('b', 'Tue, 06 Jan 2026 10:00:00 GMT'), ('a', 'Mon, 05 Jan 2026 10:00:00 GMT')]).encode()
changed, _ = self.fetch(_HttpResponse('https://example.org/feed', content=newer), body_hash=first.body_hash)
self.assertEqual(changed.status, 200)
self.assertNotEqual(changed.body_hash, first.body_hash)
def test_200_returns_parsed_feed_and_validators(self):
r, headers = self.fetch(_HttpResponse('https://example.org/feed', content=self.RSS, headers={
'ETag': '"v1"', 'Last-Modified': 'Mon, 05 Jan 2026 10:00:00 GMT'}))
@ -2668,57 +2628,3 @@ class DiscardTests(TestCase):
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)
# ---------------------------------------------------------------------------
# Skip silence in the browser: pauses found on the server (silence.py)
# ---------------------------------------------------------------------------
class SilenceTests(TestCase):
STDERR = """Input #0, mp3, from 'x':
[silencedetect @ 0x1] silence_start: 1.5
[silencedetect @ 0x1] silence_end: 2.4 | silence_duration: 0.9
[silencedetect @ 0x1] silence_start: 10
[silencedetect @ 0x1] silence_end: 10.2 | silence_duration: 0.2
[silencedetect @ 0x1] silence_start: -0.01
[silencedetect @ 0x1] silence_end: 0.75 | silence_duration: 0.76
[silencedetect @ 0x1] silence_start: 99.123456
"""
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/f', title='F')
self.ep = PodcastEpisode.objects.create(feed=feed, guid='e', title='E', audio_url='https://example.org/e.mp3')
def test_parse_keeps_pauses_long_enough(self):
from .silence import parse
# 0.2 s is too short; a start below 0 is 0; an open start at the end is dropped.
self.assertEqual(parse(self.STDERR), [[1.5, 2.4], [0.0, 0.75]])
def test_first_request_starts_the_search_then_answers_with_the_pauses(self):
with patch('podcasts.silence.ensure') as ensure:
r = self.client.get(f'/podcasts/episodes/{self.ep.id}/silences/')
self.assertEqual(r.json(), {'ready': False})
ensure.assert_called_once_with(self.ep.id)
with patch('podcasts.silence.analyze', return_value=[[1.5, 2.4]]):
from .silence import _work
_work(self.ep.id)
r = self.client.get(f'/podcasts/episodes/{self.ep.id}/silences/')
self.assertEqual(r.json(), {'ready': True, 'spans': [[1.5, 2.4]]})
def test_a_failure_is_remembered_not_retried(self):
from .silence import _work
with patch('podcasts.silence.analyze', side_effect=RuntimeError('HTTP error 404 Not Found')):
_work(self.ep.id)
with patch('podcasts.silence.ensure') as ensure:
r = self.client.get(f'/podcasts/episodes/{self.ep.id}/silences/')
ensure.assert_not_called()
self.assertEqual(r.json(), {'ready': True, 'spans': [], 'error': 'HTTP error 404 Not Found'})
def test_only_own_episodes(self):
bob = User.objects.create_user(username='bob', password='pw12345678')
self.client.force_login(bob)
self.assertEqual(self.client.get(f'/podcasts/episodes/{self.ep.id}/silences/').status_code, 404)
self.client.logout()
self.assertEqual(self.client.get(f'/podcasts/episodes/{self.ep.id}/silences/').status_code, 401)

View file

@ -19,7 +19,6 @@ urlpatterns = [
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>/silences/', views.episode_silences, name='podcast_episode_silences'),
path('episodes/<int:pk>/favorite/', views.set_favorite, name='podcast_set_favorite'),
path('stats/', views.podcast_stats, name='podcast_stats'),
path('queue/', views.queue_get, name='podcast_queue_get'),

View file

@ -1,12 +1,10 @@
import hashlib
import json
import logging
import math
import re
import time
import urllib.parse
import uuid
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout, as_completed
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from datetime import timedelta, timezone as dt_timezone
import xml.etree.ElementTree as ET
@ -25,7 +23,6 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from . import chapters as chapters_mod
from . import silence as silence_mod
from . import feedext
from . import transcripts as transcripts_mod
from .models import (
@ -40,8 +37,7 @@ log = logging.getLogger(__name__)
FEED_TIMEOUT = 20 # seconds per feed download
NEW_ON_SUBSCRIBE = 1 # episodes of a new subscription that count as new
FEED_WORKERS = 16 # feeds downloaded at the same time
REFRESH_WAIT = 40 # seconds a refresh-all (⟳) waits; feeds not done by then wait for the hourly job
FEED_WORKERS = 8 # feeds downloaded at the same time
MAX_DESCRIPTION = 50_000 # characters of shownotes HTML kept per episode
MAX_TRANSCRIPTS = 10
@ -306,27 +302,14 @@ class FeedFetch:
last_modified: str = ''
final_url: str = ''
permanent_url: str = ''
body_hash: str = ''
# What changes in a feed on every request without anything in it changing:
# the build date, comments with a generation time.
_VOLATILE = re.compile(rb'<lastBuildDate>.*?</lastBuildDate>|<!--.*?-->', re.S)
def _body_hash(content):
"""Fingerprint of a feed body, without the parts that change on every request."""
return hashlib.sha256(_VOLATILE.sub(b'', content)).hexdigest()
PERMANENT_REDIRECTS = (301, 308)
def _fetch_feed(url, etag='', last_modified='', body_hash=''):
def _fetch_feed(url, etag='', last_modified=''):
"""Download and parse a feed (with a timeout, unlike feedparser's own
fetch). With etag/last_modified it is a conditional GET; with body_hash a
body like the last one counts as unchanged too (a 304), unparsed —
parsing is what takes the time (seconds for a big feed). Returns a
fetch). With etag/last_modified it is a conditional GET. Returns a
FeedFetch; raises FeedFetchError when there is nothing usable."""
headers = {'User-Agent': 'diora podcast refresh'}
if etag:
@ -359,10 +342,6 @@ def _fetch_feed(url, etag='', last_modified='', body_hash=''):
if r.status_code >= 400:
raise FeedFetchError(f'HTTP {r.status_code}')
new_hash = _body_hash(r.content)
if body_hash and new_hash == body_hash:
return FeedFetch(304, None, etag, last_modified, r.url, permanent_url, body_hash)
parsed = feedparser.parse(r.content, response_headers={'content-location': r.url, **r.headers})
if parsed.get('bozo') and not parsed.entries and not parsed.feed.get('title'):
raise FeedFetchError('not a feed')
@ -373,7 +352,7 @@ def _fetch_feed(url, etag='', last_modified='', body_hash=''):
# A validator too long to store is dropped (a truncated one never matches).
new_etag if len(new_etag) <= 500 else '',
new_modified if len(new_modified) <= 100 else '',
r.url, permanent_url, new_hash,
r.url, permanent_url,
)
@ -382,14 +361,13 @@ def _validators(feeds):
`feeds` (several users' subscriptions of the same URL): only when every
one of them was fetched before and stored the same validators, because a
304 then skips all of them. Otherwise {} (a full response)."""
pairs = {(f.http_etag, f.http_last_modified, f.http_body_hash) for f in feeds}
pairs = {(f.http_etag, f.http_last_modified) for f in feeds}
if len(pairs) != 1 or any(f.last_refreshed_at is None for f in feeds):
return {}
etag, last_modified, body_hash = pairs.pop()
out = {'etag': etag, 'last_modified': last_modified} if etag or last_modified else {}
if body_hash:
out['body_hash'] = body_hash
return out
etag, last_modified = pairs.pop()
if not (etag or last_modified):
return {}
return {'etag': etag, 'last_modified': last_modified}
def _error_text(e):
@ -447,21 +425,20 @@ AUDIO_EXTENSIONS = ('.mp3', '.m4a', '.m4b', '.ogg', '.oga', '.opus', '.aac', '.f
VIDEO_EXTENSIONS = ('.mp4', '.m4v', '.mov', '.webm')
def _refresh_feeds(feeds, full=False, deadline=None):
def _refresh_feeds(feeds, chunk=32, full=False):
"""Refresh many feeds at once: the downloads run in parallel and each
feed URL is fetched once, even when several users subscribed to it; each
download is written as soon as it's there (the writes one by one, while
the others still load). Returns (refreshed, failed, new).
feed URL is fetched once, even when several users subscribed to it; the
database writes then happen one by one. Returns (refreshed, failed, new).
`full`: download every feed again (no 304) and take in episodes the
reader skipped before (see _apply_feed). `deadline` (time.monotonic()):
stop there; feeds not done by then stay as they are."""
reader skipped before (see _apply_feed)."""
feeds = list(feeds)
refreshed = failed = new = 0
for start in range(0, len(feeds), chunk):
part = feeds[start:start + chunk]
by_url = {}
for f in feeds:
for f in part:
by_url.setdefault(f.rss_url, []).append(f)
if not by_url:
return 0, 0, 0
results = {}
def fetch(url):
try:
@ -469,13 +446,13 @@ def _refresh_feeds(feeds, full=False, deadline=None):
except Exception as e: # network, HTTP or parser trouble: report and go on
return url, None, e
pool = ThreadPoolExecutor(max_workers=min(FEED_WORKERS, len(by_url)))
futures = [pool.submit(fetch, url) for url in sorted(by_url)]
try:
wait = None if deadline is None else max(0.0, deadline - time.monotonic())
for future in as_completed(futures, timeout=wait):
url, fetched, err = future.result()
for feed in by_url[url]:
urls = sorted(by_url)
with ThreadPoolExecutor(max_workers=min(FEED_WORKERS, len(urls) or 1)) as pool:
for url, fetched, err in pool.map(fetch, urls):
results[url] = (fetched, err)
for feed in part:
fetched, err = results[feed.rss_url]
if err is not None:
failed += 1
log.warning('refreshing %s failed: %s', feed.rss_url, err)
@ -487,10 +464,6 @@ def _refresh_feeds(feeds, full=False, deadline=None):
except Exception as e:
failed += 1
log.warning('updating %s failed: %s', feed.rss_url, e)
except FuturesTimeout:
log.info('refresh stopped at its deadline: %d of %d feeds done', refreshed + failed, len(feeds))
finally:
pool.shutdown(wait=False, cancel_futures=True)
return refreshed, failed, new
@ -708,9 +681,8 @@ def _apply_feed(feed_obj, fetched, full=False):
# skips all of the above) and a clean error state.
feed_obj.http_etag = fetched.etag
feed_obj.http_last_modified = fetched.last_modified
feed_obj.http_body_hash = fetched.body_hash
_clear_error(feed_obj)
feed_obj.save(update_fields=['http_etag', 'http_last_modified', 'http_body_hash', *ERROR_FIELDS])
feed_obj.save(update_fields=['http_etag', 'http_last_modified', *ERROR_FIELDS])
_move_to_permanent_url(feed_obj, fetched.permanent_url)
return new_count
@ -1170,24 +1142,6 @@ def _has_transcript(ep):
return not (cached and cached[0] == sources and cached[1])
@csrf_exempt
@require_http_methods(['GET'])
def episode_silences(request, pk):
"""The episode's pauses for skipping silence in the browser:
{"ready": true, "spans": [[start, end], …]} once found; the first request
starts the search on the server ({"ready": false}, ask again later)."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
ep = PodcastEpisode.objects.filter(pk=pk, feed__user=request.user).only('id', 'silences').first()
if ep is None:
return JsonResponse({'error': 'not found'}, status=404)
if ep.silences is None:
silence_mod.ensure(ep.pk)
return JsonResponse({'ready': False})
return JsonResponse({'ready': True, 'spans': ep.silences.get('spans', []),
**({'error': ep.silences['error']} if ep.silences.get('error') else {})})
@csrf_exempt
@require_http_methods(['GET'])
def episode_transcript(request, pk):
@ -1603,9 +1557,7 @@ def refresh_all_feeds(request):
"""Refresh all of the user's feeds on the server in one go. Feeds the
hourly job (or an earlier call) refreshed within `max_age` seconds are
skipped. Clients then fetch the result once (state/, inbox/ …).
The user asked, so feeds the hourly job backs off from are tried too.
It answers within REFRESH_WAIT seconds: `left` feeds weren't done by
then (done: false) and wait for the next call or the hourly job."""
The user asked, so feeds the hourly job backs off from are tried too."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
@ -1617,11 +1569,9 @@ def refresh_all_feeds(request):
except (TypeError, ValueError):
return JsonResponse({'error': 'max_age must be a number'}, status=400)
cutoff = timezone.now() - timedelta(seconds=max_age)
feeds = list(request.user.podcast_feeds.filter(Q(last_refreshed_at__isnull=True) | Q(last_refreshed_at__lt=cutoff)))
refreshed, failed, new = _refresh_feeds(feeds, deadline=time.monotonic() + REFRESH_WAIT)
left = len(feeds) - refreshed - failed
return JsonResponse({'ok': True, 'refreshed': refreshed, 'failed': failed, 'new_episodes': new,
'done': left == 0, 'left': left})
feeds = request.user.podcast_feeds.filter(Q(last_refreshed_at__isnull=True) | Q(last_refreshed_at__lt=cutoff))
refreshed, failed, new = _refresh_feeds(feeds)
return JsonResponse({'ok': True, 'refreshed': refreshed, 'failed': failed, 'new_episodes': new})
@csrf_exempt
@ -2177,9 +2127,7 @@ def clear_import_downloads(request):
# ---------------------------------------------------------------------------
PLAYER_ONLINE_SECONDS = 45 # not seen for longer: the device is gone
PLAYER_ACTIONS = ('play', 'pause', 'seek', 'skip', 'next', 'stop', 'transfer', 'sleep')
SLEEP_MAX_MINUTES = 600 # a sleep timer: 1 min to 10 h, or after 1 to SLEEP_MAX_EPISODES
SLEEP_MAX_EPISODES = 99
PLAYER_ACTIONS = ('play', 'pause', 'seek', 'skip', 'next', 'stop', 'transfer')
PLAYER_DEVICES_SHOWN = 10 # online ones, then the last ones seen in DEVICES_DAYS
PLAYER_DEVICES_DAYS = 7
STREAM_SECONDS = 300 # one stream connection; clients reconnect with their seq
@ -2289,7 +2237,6 @@ def _player_json(user, p, now=None):
'updated_at': _iso_ms(p.updated_at),
'server_time': _iso_ms(now),
'command': p.command,
'sleep': {'left_ms': p.sleep_left_ms, 'episodes': p.sleep_episodes},
'devices': devices,
'active_online': _is_online(user, p.device_id, now),
}
@ -2364,13 +2311,6 @@ def player_report(request):
station = (st['url'].strip()[:1000], str(st.get('name') or '')[:300],
int(sid) if _number(sid) and sid == int(sid) else None)
# The sleep timer, if the device has one (older clients don't say: none).
sleep = body.get('sleep') if isinstance(body.get('sleep'), dict) else {}
sleep_left = sleep.get('left_ms', 0)
sleep_left = min(int(sleep_left), SLEEP_MAX_MINUTES * 60_000) if _finite(sleep_left) and sleep_left > 0 else 0
sleep_episodes = sleep.get('episodes', 0)
sleep_episodes = min(int(sleep_episodes), SLEEP_MAX_EPISODES) if _finite(sleep_episodes) and sleep_episodes > 0 else 0
device_id = body['device_id']
done = body.get('done_command')
start = body.get('start') is True
@ -2387,8 +2327,6 @@ def player_report(request):
position_ms=max(int(position), 0) if kind == PlayerState.PODCAST else 0,
playing=playing and bool(kind),
speed=float(speed),
sleep_left_ms=sleep_left,
sleep_episodes=sleep_episodes,
device_id=device_id,
device_name=body['device_name'],
updated_at=now,
@ -2410,8 +2348,7 @@ def player_command(request):
"""Remote control: a command for a device (to_device_id, else the active
one), which carries it out and reports it done (done_command). Only to a
device that is online. transfer: the device plays the shared item from
its place and so becomes the active one. sleep: its sleep timer, value =
minutes of playing time, -n = after n episodes, 0 = off."""
its place and so becomes the active one."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
body, error = _player_body(request)
@ -2426,12 +2363,6 @@ def player_command(request):
if not _finite(value):
return JsonResponse({'error': 'value must be a number'}, status=400)
value = max(int(value), 0) if action == 'seek' else int(value)
elif action == 'sleep':
value = body.get('value')
if not _finite(value) or not -SLEEP_MAX_EPISODES <= int(value) <= SLEEP_MAX_MINUTES:
return JsonResponse({'error': f'value must be minutes (up to {SLEEP_MAX_MINUTES}), '
f'-episodes or 0'}, status=400)
value = int(value)
to = body.get('to_device_id')
if to is not None and (not isinstance(to, str) or len(to) > 64):
return JsonResponse({'error': 'invalid to_device_id'}, status=400)

View file

@ -23,7 +23,7 @@ class PlayerPagePodcastsTests(TestCase):
def test_logged_out_page_asks_to_log_in(self):
html = self.client.get('/').content.decode()
self.assertIn('um Podcasts zu abonnieren.', html)
self.assertIn('to subscribe to podcasts.', html)
self.assertNotIn('dpw-loading', html)
def test_podcast_ui_files_are_there(self):

View file

@ -365,16 +365,15 @@ def stop_play(request):
# ---------------------------------------------------------------------------
def _time_context_label(hour, is_weekend):
# Shown as "Was du {label} oft hörst:".
prefix = 'am Wochenende' if is_weekend else 'unter der Woche'
prefix = 'weekend' if is_weekend else 'weekday'
if 5 <= hour <= 11:
period = 'morgens'
period = 'morning'
elif 12 <= hour <= 16:
period = 'nachmittags'
period = 'afternoon'
elif 17 <= hour <= 21:
period = 'abends'
period = 'evening'
else:
period = 'nachts'
period = 'night'
return f'{prefix} {period}'

View file

@ -17,5 +17,3 @@ gevent==26.8.0
gunicorn==26.2.0
packaging==26.3
piper-tts==1.7.0
# ffmpeg (a static binary in the wheel) finds the pauses for skipping silence in the browser.
imageio-ffmpeg==0.6.0

File diff suppressed because it is too large Load diff

View file

@ -389,15 +389,6 @@ a:hover {
min-height: 200px;
}
/* Ambient: the synthesizer's frame fills the room down to the player bar (app.js _ambientSize). */
.ambient-frame {
display: block;
width: 100%;
height: 70vh;
border: 0;
background: #000;
}
.sub-tabs {
border-bottom-color: transparent;
margin-bottom: 1rem;

File diff suppressed because it is too large Load diff

View file

@ -1,311 +0,0 @@
/**
* diora Home: what you were in the middle of, one click away — the book you're
* reading, the next episode, your stations, Ambient.
*
* Built only from what the page already has: the book list (app.js), the
* podcast host (window.DioraPodcastHost in the browser and diora desktop,
* window.DioraNative in the Android app), the saved stations in the radio tab
* and Drift (app.js __dioraAmbient*). Renders into #tab-home; the same file runs
* in the browser, diora desktop and the Android app (copied with static/js/).
* Written without ?. and ?? so old WebViews can still parse it.
*/
(function () {
'use strict';
const tab = document.getElementById('tab-home');
if (!tab) return;
const $ = id => document.getElementById(id);
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g,
c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]));
const parse = (s, fallback) => { try { return typeof s === 'string' ? JSON.parse(s) : (s || fallback); } catch (e) { return fallback; } };
const visible = () => tab.style.display !== 'none';
const online = () => navigator.onLine && !document.body.classList.contains('desktop-offline');
function minutes(sec) {
const m = Math.max(0, Math.round((sec || 0) / 60));
return m >= 60 ? Math.floor(m / 60) + ' h ' + String(m % 60).padStart(2, '0') + ' min' : m + ' min';
}
function bar(fraction) {
const pct = Math.max(0, Math.min(100, Math.round((fraction || 0) * 100)));
return '<span class="home-bar"><span style="width:' + pct + '%"></span></span>';
}
function podHost() {
if (window.DioraPodcastHost) return window.DioraPodcastHost;
if (window.DioraNative && window.DioraNative.podcastState) return window.DioraNative;
return null;
}
// ---- Weiterlesen ------------------------------------------------------------
let askedForBooks = false;
function booksSection() {
const books = typeof _lastBookListData !== 'undefined' ? _lastBookListData : [];
if (!books.length && !askedForBooks && typeof loadBookList === 'function') {
askedForBooks = true; // the list renders into the (hidden) books tab; renderBookList tells us
loadBookList();
}
const recent = typeof _recentlyReadBooks === 'function'
? _recentlyReadBooks(books).filter(b => b.keyOk !== false && !b.is_read) : [];
let body;
if (!recent.length) {
const locked = books.length && books.every(b => b.keyOk === false);
body = '<div class="home-card home-empty">' +
(locked ? 'Die Bücher lassen sich mit dem Schlüssel auf diesem Gerät nicht öffnen.'
: books.length ? 'Gerade kein Buch angefangen.' : 'Noch keine Bücher.') +
' <button type="button" class="btn btn-sm" data-act="tab" data-tab="books">Zu den Büchern</button></div>';
} else {
const b = recent[0];
body = '<button type="button" class="home-card home-main" data-act="book" data-id="' + esc(b.id) + '">' +
'<span class="home-kicker">Weiterlesen</span>' +
'<span class="home-title">' + esc(b.title) + '</span>' +
(b.author ? '<span class="home-sub">' + esc(b.author) + '</span>' : '') +
'<span class="home-state">' + bar(b.scroll_fraction) + '<span>' + Math.round((b.scroll_fraction || 0) * 100) + ' %</span></span>' +
'</button>';
const more = recent.slice(1, 4);
if (more.length) {
body += '<div class="home-list">' + more.map(o =>
'<button type="button" class="home-row" data-act="book" data-id="' + esc(o.id) + '">' +
'<span class="home-row-text">' + esc(o.title) + '</span>' +
'<span class="home-row-side">' + Math.round((o.scroll_fraction || 0) * 100) + ' %</span></button>').join('') + '</div>';
}
}
return section('Bücher', body, 'books');
}
// ---- Weiterhören ------------------------------------------------------------
function podcastSection() {
const h = podHost();
if (!h || !document.getElementById('tab-podcasts')) return '';
const state = parse(h.podcastState(), {}) || {};
const player = h.playerState ? parse(h.playerState(), {}) || {} : {};
const queue = state.queue || [];
const fresh = state.inbox_total != null ? state.inbox_total : (state.inbox || []).length;
let card = '';
if (player.remote && player.remote.kind === 'podcast') {
const r = player.remote;
card = podCard('Läuft auf ' + (r.device || 'einem anderen Gerät'), r.title || '', r.feed || '', r.artwork || '',
r.dur ? (r.dur - (r.pos || 0)) / 1000 : 0, r.dur ? (r.pos || 0) / r.dur : 0,
'<button type="button" class="btn btn-sm" data-act="pod-here">Hier weiterhören</button>');
} else if (player.kind === 'podcast') {
const left = player.dur ? (player.dur - (player.pos || 0)) / 1000 : 0;
card = podCard(player.playing ? 'Läuft gerade' : 'Weiterhören', player.title, player.feed, player.artwork, left,
player.dur ? (player.pos || 0) / player.dur : 0,
'<button type="button" class="btn btn-sm home-play" data-act="' + (player.playing ? 'pod-pause' : 'pod-resume') +
'" data-id="' + esc(player.episodeId) + '">' + (player.playing ? '■ Pause' : '▶ Weiter') + '</button>');
} else if (queue.length) {
const q = queue[0];
const dur = q.episode__duration_seconds || 0;
const pos = q.position_seconds || 0;
card = podCard(pos > 0 ? 'Weiterhören' : 'Als Nächstes', q.episode__title, q.episode__feed__title,
q.episode__artwork_url || q.episode__feed__artwork_url, dur ? dur - pos : 0, dur ? pos / dur : 0,
'<button type="button" class="btn btn-sm home-play" data-act="pod-queue">▶ Abspielen</button>');
} else {
card = '<div class="home-card home-empty">Die Warteschlange ist leer.</div>';
}
const extra = '<div class="home-chips">' +
(queue.length > 1 ? '<button type="button" class="home-chip" data-act="tab" data-tab="podcasts">' + queue.length + ' in der Warteschlange</button>' : '') +
(fresh ? '<button type="button" class="home-chip" data-act="pod-inbox">' + fresh + ' neue Folge' + (fresh === 1 ? '' : 'n') + '</button>' : '') +
'</div>';
return section('Podcasts', card + extra, 'podcasts');
}
function podCard(kicker, title, feed, art, leftSec, fraction, button) {
return '<div class="home-card home-main home-pod">' +
(art ? '<img class="home-art" alt="" loading="lazy" src="' + esc(art) + '">' : '<span class="home-art"></span>') +
'<span class="home-pod-text" data-act="pod-open">' +
'<span class="home-kicker">' + esc(kicker) + '</span>' +
'<span class="home-title">' + esc(title) + '</span>' +
'<span class="home-sub">' + esc(feed) + (leftSec > 30 ? ' · noch ' + minutes(leftSec) : '') + '</span>' +
'<span class="home-state">' + bar(fraction) + '</span></span>' +
button + '</div>';
}
// ---- Radio ------------------------------------------------------------------
function stationRows() {
return Array.prototype.slice.call(document.querySelectorAll('#saved-tbody tr[data-id]'));
}
function radioSection() {
if (!document.getElementById('tab-radio')) return '';
// The app shows the radio only where it has one (native-radio.js marks <html>).
if (window.DioraNative && !document.documentElement.classList.contains('nui-radio')) return '';
const rows = stationRows();
const favs = rows.filter(r => r.querySelector('.fav-btn.active'));
const pick = (favs.length ? favs : rows).slice(0, 8);
const cur = typeof currentStation !== 'undefined' ? currentStation : null;
const playing = typeof isPlaying !== 'undefined' && isPlaying;
let chips = '';
if (cur && !pick.some(r => r.dataset.url === cur.url)) {
chips += chip(cur.url, cur.name, playing, 'Zuletzt: ');
}
chips += pick.map(r => chip(r.dataset.url, r.dataset.name, playing && cur && cur.url === r.dataset.url, '')).join('');
let body;
if (!chips) {
body = '<div class="home-card home-empty">Noch keine Sender gespeichert. ' +
'<button type="button" class="btn btn-sm" data-act="tab" data-tab="radio">Sender suchen</button></div>';
} else {
body = '<div class="home-stations">' + chips + '</div>';
}
if (!online()) body += '<div class="home-note">Offline — Radio braucht eine Verbindung.</div>';
return section(favs.length ? 'Radio · Favoriten' : 'Radio', body, 'radio');
}
function chip(url, name, playing, prefix) {
return '<button type="button" class="home-station' + (playing ? ' home-on' : '') + '" data-act="station" data-url="' +
esc(url) + '" data-name="' + esc(name) + '">' +
'<span class="home-station-icon">' + (playing ? '■' : '▶') + '</span>' +
'<span class="home-station-name">' + esc(prefix) + esc(name) + '</span></button>';
}
// ---- Ambient ----------------------------------------------------------------
function ambientSection() {
if (!document.getElementById('tab-ambient') || typeof window.__dioraAmbientInfo !== 'function') return '';
if (window.DioraNative && !document.querySelector('#nui-tabbar [data-tab="ambient"]') &&
document.getElementById('nui-tabbar')) return '';
const info = window.__dioraAmbientInfo() || {};
let label = info.mode || '';
if (!label) { try { label = localStorage.getItem('diora_ambient_mode_label') || ''; } catch (e) {} }
const h = podHost();
const player = h && h.playerState ? parse(h.playerState(), {}) || {} : {};
const left = player.sleep && player.sleep.leftMs > 0 ? ' · ☾ ' + minutes(player.sleep.leftMs / 1000) : '';
const body = '<div class="home-card home-main home-amb">' +
'<span class="home-amb-icon">∿</span>' +
'<span class="home-pod-text" data-act="tab" data-tab="ambient">' +
'<span class="home-kicker">' + (info.playing ? 'Läuft gerade' : 'Drift') + '</span>' +
'<span class="home-title">' + esc(label || 'Ambient') + '</span>' +
'<span class="home-sub">Generative Klangwelten' + esc(left) + '</span></span>' +
(document.getElementById('nui-amb-sleep') ? '<button type="button" class="btn btn-sm" data-act="amb-sleep" title="Sleep-Timer">☾</button>' : '') +
'<button type="button" class="btn btn-sm home-play" data-act="' + (info.playing ? 'amb-stop' : 'amb-start') + '">' +
(info.playing ? '■ Stopp' : '▶ Starten') + '</button></div>';
return section('Ambient', body, 'ambient');
}
// ---- page ---------------------------------------------------------------------
function section(title, body, tabName) {
return '<section class="home-section"><div class="home-head"><h2>' + esc(title) + '</h2>' +
'<button type="button" class="home-more" data-act="tab" data-tab="' + tabName + '">Alle ›</button></div>' + body + '</section>';
}
function greeting() {
const h = new Date().getHours();
return h < 5 ? 'Gute Nacht' : h < 11 ? 'Guten Morgen' : h < 18 ? 'Hallo' : 'Guten Abend';
}
let lastHtml = '';
let host = null;
function render() {
if (!visible()) return;
if (!host) {
host = document.createElement('div');
host.className = 'home';
tab.appendChild(host);
}
let html = '<div class="home-hello">' + greeting() + '</div>';
for (const part of [booksSection, podcastSection, radioSection, ambientSection]) {
try { html += part(); } catch (e) { console.warn('home', e); }
}
if (html !== lastHtml) {
lastHtml = html;
host.innerHTML = html;
}
}
function playStationFromHome(url, name) {
const cur = typeof currentStation !== 'undefined' ? currentStation : null;
if (typeof isPlaying !== 'undefined' && isPlaying && cur && cur.url === url) { stopPlayback(false); return; }
const row = stationRows().find(r => r.dataset.url === url);
const b = row && row.querySelector('button[onclick*="playStation"]');
if (b) b.click();
else playStation(url, name, row ? Number(row.dataset.id) || null : (cur && cur.url === url ? cur.id : null));
}
tab.addEventListener('click', e => {
const el = e.target.closest('[data-act]');
if (!el || !tab.contains(el)) return;
const act = el.dataset.act;
const h = podHost();
if (act === 'tab') showTab(el.dataset.tab);
else if (act === 'book') openBook(Number(el.dataset.id));
else if (act === 'station') playStationFromHome(el.dataset.url, el.dataset.name);
else if (act === 'pod-queue' && h) {
const q = (parse(h.podcastState(), {}).queue || [])[0];
if (q) h.podcastPlayEpisode(JSON.stringify(q), 0);
} else if (act === 'pod-resume' && h) h.podcastPlay(Number(el.dataset.id));
else if (act === 'pod-pause' && h) h.podcastToggle();
else if (act === 'pod-here' && h && h.playerTakeOver) h.playerTakeOver();
else if (act === 'pod-open') {
if (window.__dioraOpenPlayer && h && parse(h.playerState(), {}).kind) window.__dioraOpenPlayer('podcast');
else showTab('podcasts');
} else if (act === 'pod-inbox') {
showTab('podcasts');
if (window.__dioraPodcastView) window.__dioraPodcastView('inbox');
} else if (act === 'amb-start' && window.__dioraAmbientStart) window.__dioraAmbientStart();
else if (act === 'amb-stop' && window.__dioraAmbientCmd) window.__dioraAmbientCmd('stop');
else if (act === 'amb-sleep') { const b = $('nui-amb-sleep'); if (b) b.click(); }
setTimeout(render, 300);
});
// Fresh whenever it shows, and while it shows (players move on, the book list arrives).
window.__dioraHomeShow = render;
if (typeof window.renderBookList === 'function') {
const renderBookList = window.renderBookList;
window.renderBookList = function () {
const r = renderBookList.apply(this, arguments);
render();
return r;
};
}
setInterval(render, 2000);
// The start page may have opened Home before this file ran (podcasts-web.js, native.js).
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', render);
else render();
const style = document.createElement('style');
style.textContent = [
'#tab-home .home { max-width: 760px; margin: 0 auto; padding-bottom: 24px; }',
'#tab-home .home-hello { font-size: 22px; font-weight: 700; margin: 4px 0 18px; letter-spacing: 0.02em; }',
'#tab-home .home-section { margin-bottom: 22px; }',
'#tab-home .home-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8px; }',
'#tab-home .home-head h2 { margin: 0; font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); }',
'#tab-home .home-more { background: none; border: none; color: var(--nui-muted, #9a9a9a); font: inherit; font-size: 12px; cursor: pointer; padding: 4px 0; }',
'#tab-home .home-card { display: flex; align-items: center; gap: 12px; width: 100%; box-sizing: border-box; padding: 14px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; background: #0b0b0b; color: inherit; font: inherit; text-align: left; }',
'#tab-home button.home-card, #tab-home .home-pod-text, #tab-home .home-row, #tab-home .home-station { cursor: pointer; }',
'#tab-home .home-main { flex-wrap: nowrap; }',
'#tab-home button.home-main { flex-direction: column; align-items: flex-start; gap: 3px; }',
'#tab-home .home-empty { color: var(--nui-muted, #9a9a9a); justify-content: space-between; }',
'#tab-home .home-kicker { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent, #e63946); }',
'#tab-home .home-title { font-size: 16px; font-weight: 700; line-height: 1.3; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }',
'#tab-home .home-sub { font-size: 13px; color: var(--nui-muted, #9a9a9a); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }',
'#tab-home .home-state { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 12px; color: var(--nui-muted, #9a9a9a); }',
'#tab-home .home-bar { display: inline-block; width: 160px; max-width: 40vw; height: 4px; border-radius: 2px; background: #2a2a2a; overflow: hidden; }',
'#tab-home .home-bar > span { display: block; height: 100%; background: var(--accent, #e63946); }',
'#tab-home .home-pod-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }',
'#tab-home .home-art { width: 56px; height: 56px; flex: none; border-radius: 4px; object-fit: cover; background: #1a1a1a; }',
'#tab-home .home-amb-icon { width: 56px; height: 56px; flex: none; border-radius: 4px; background: #14102a; color: #b9a7ff; display: inline-flex; align-items: center; justify-content: center; font-size: 26px; }',
'#tab-home .home-play { flex: none; min-height: 36px; padding: 0 14px; }',
'#tab-home .home-list { margin-top: 6px; }',
'#tab-home .home-row { display: flex; justify-content: space-between; gap: 12px; width: 100%; padding: 10px 14px; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; font-size: 14px; text-align: left; }',
'#tab-home .home-row-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
'#tab-home .home-row-side { flex: none; color: var(--nui-muted, #9a9a9a); font-size: 12px; }',
'#tab-home .home-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }',
'#tab-home .home-chip { padding: 6px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 16px; background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }',
'#tab-home .home-stations { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; }',
'#tab-home .home-station { display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 8px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; background: #0b0b0b; color: inherit; font: inherit; font-size: 14px; text-align: left; }',
'#tab-home .home-station-icon { color: var(--accent, #e63946); flex: none; }',
'#tab-home .home-station-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
'#tab-home .home-on { border-color: var(--accent, #e63946); }',
'#tab-home .home-note { margin-top: 8px; font-size: 13px; color: var(--nui-muted, #9a9a9a); }',
'@media (hover: hover) { #tab-home .home-card:hover, #tab-home .home-station:hover, #tab-home .home-row:hover { background: #141414; } }',
'.reader-theme-bright #tab-home, body.eink #tab-home .home-card { background: #fff; }',
].join('\n');
document.head.appendChild(style);
})();

View file

@ -164,15 +164,11 @@
try {
if (fetchFeeds) {
if (!(await flush())) return false;
const rr = await fetch('/podcasts/feeds/refresh-all/', {
await fetch('/podcasts/feeds/refresh-all/', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()},
body: '{"max_age":60}',
});
// The server answers after ~40 s at most; when it didn't get to every feed,
// the state once more a minute later.
const done = await rr.json().then(d => d.done !== false, () => true);
if (!done) setTimeout(() => refresh(false), 60000);
}
// Progress is saved every few seconds while an episode plays; try again
// when it slipped in while the state was on its way.
@ -433,7 +429,7 @@
smart_played: [0, 15, 30, 45, 60, 120, 300, 600],
};
const prefs = Object.assign({speed: 1, skip_back: 15, skip_forward: 30, remove_played: true, smart_played: 30,
sleep_last: 15, sleep_auto: false, sleep_from: 22, sleep_to: 6, skip_silence: false}, load('prefs', {}));
sleep_last: 15, sleep_auto: false}, load('prefs', {}));
// Skip lengths and "counts as played": the account's where it has them, else this browser's.
function eff(name) {
@ -453,23 +449,16 @@
if (name in (state.settings || {})) { setSettings({[name]: Math.round(value)}); flush(); }
} else if (name === 'remove_played') {
prefs.remove_played = !!value;
} else if (name === 'skip_silence') {
prefs.skip_silence = !!value; // the server finds the pauses, we jump over them (below)
} else if (name === 'sleep_from' || name === 'sleep_to') {
if (!(Number.isInteger(value) && value >= 0 && value <= 23)) return;
prefs[name] = value;
} else {
return; // headset_next: nothing a browser can do
return; // skip_silence, headset_next: nothing a browser can do
}
store('prefs', prefs);
}
// What the page shows as settings. No headset_next: the page leaves it out.
// What the page shows as settings. No skip_silence / headset_next: the page leaves those out.
function prefsJson() {
return {speed: prefs.speed, skip_back: eff('skip_back'), skip_forward: eff('skip_forward'),
remove_played: prefs.remove_played, smart_played: eff('smart_played'), feeds: {},
skip_silence: !!prefs.skip_silence,
sleep_from: sleepHour('sleep_from', 22), sleep_to: sleepHour('sleep_to', 6)};
remove_played: prefs.remove_played, smart_played: eff('smart_played'), feeds: {}};
}
function speedFor(feedId) {
@ -493,57 +482,32 @@
}
// ---- sleep timer (SleepTimer.kt): minutes count only while playing ----------------
// For episodes and the radio. A timer that hasn't counted for hours (set, then
// nothing played) is last night's: it's dropped (SleepRules.forgotten).
const FADE_MS = 10000;
const MAX_GAP_MS = 10000;
const IDLE_FORGET_MS = 3 * 3600000;
function sleepHour(name, fallback) {
const v = prefs[name];
return Number.isInteger(v) && v >= 0 && v <= 23 ? v : fallback;
}
// [from, to) may run over midnight; from == to: all day (SleepRules.inWindow).
function inSleepWindow(hour, from, to) {
if (from === to) return true;
return from < to ? hour >= from && hour < to : hour >= from || hour < to;
}
const sleep = {
leftMs: 0,
episodes: 0,
lastTick: 0,
lastCounted: 0,
get active() { return this.leftMs > 0 || this.episodes > 0; },
set(minutes, episodes) {
this.leftMs = minutes * 60000;
this.episodes = minutes > 0 ? 0 : episodes;
this.lastTick = 0;
this.lastCounted = this.active ? Date.now() : 0;
if (minutes > 0) { prefs.sleep_last = minutes; store('prefs', prefs); }
setFade(1);
sleepChanged();
},
extend(minutes) {
if (this.leftMs <= 0) { this.set(minutes, 0); return; }
this.leftMs += minutes * 60000;
this.lastCounted = Date.now();
setFade(1);
sleepChanged();
},
tick(playing) {
const now = Date.now();
const delta = this.lastTick ? now - this.lastTick : 0;
this.lastTick = now;
if (!this.active) return;
if (!playing) {
if (this.lastCounted && now - this.lastCounted > IDLE_FORGET_MS) this.set(0, 0);
return;
}
this.lastCounted = now;
if (this.leftMs <= 0) return;
if (this.leftMs <= 0 || !playing) return;
// A gap means nothing was ticking (paused, tab asleep): not playing time.
if (delta > 0 && delta <= MAX_GAP_MS) this.leftMs -= delta;
if (this.leftMs <= 0) {
@ -551,8 +515,6 @@
setFade(1);
pause();
stopRadio(); // the radio sleeps too (it plays in app.js's element)
if (window.__dioraAmbientStop) window.__dioraAmbientStop(); // and Ambient
sleepChanged();
return;
}
if (this.leftMs < FADE_MS) setFade(Math.max(this.leftMs / FADE_MS, 0.1));
@ -561,40 +523,20 @@
episodeFinished() {
if (this.episodes <= 0) return false;
this.episodes--;
if (this.episodes === 0) sleepChanged();
return this.episodes === 0;
},
queueEnded() {
if (!this.episodes) return;
this.episodes = 0;
sleepChanged();
},
// Playback starts (an episode or the radio): in the night window, the timer may switch itself on.
queueEnded() { this.episodes = 0; },
// An episode starts playing: at night, the timer may switch itself on.
maybeAutoEnable() {
if (this.active || !prefs.sleep_auto) return;
if (inSleepWindow(new Date().getHours(), sleepHour('sleep_from', 22), sleepHour('sleep_to', 6))) {
this.set(prefs.sleep_last || 15, 0);
}
const hour = new Date().getHours();
if (hour >= 22 || hour < 6) this.set(prefs.sleep_last || 15, 0);
},
json() {
return {leftMs: Math.max(0, Math.round(this.leftMs)), episodes: this.episodes, auto: !!prefs.sleep_auto,
last: prefs.sleep_last || 15, from: sleepHour('sleep_from', 22), to: sleepHour('sleep_to', 6)};
return {leftMs: Math.max(0, Math.round(this.leftMs)), episodes: this.episodes, auto: !!prefs.sleep_auto, last: prefs.sleep_last || 15};
},
};
const ambientPlaying = () => !!(window.__dioraAmbientPlaying && window.__dioraAmbientPlaying());
// Ambient starts or stops (app.js): at night the timer may switch itself on; the page shows it.
window.__dioraOnAmbient = function (on) {
if (on) sleep.maybeAutoEnable();
setTimeout(report, 100);
};
// Set, changed or done here: the other devices show it (when this one is the active one).
function sleepChanged() {
if (typeof sync !== 'undefined' && sync.state && sync.state.device_id === DEVICE_ID) syncSoon();
}
// ---- the player (RadioService.EpisodeTracker) ---------------------------------------
const audio = new Audio(); // the radio keeps app.js's own element
@ -940,51 +882,14 @@
if (playing && Date.now() - lastSaved > 10000) saveNow(id, true);
if (playing) skipOutro(id);
}
// The sleep timer counts while any of them plays (radio and podcasts share the player; Ambient).
sleep.tick(playing || radioPlaying() || ambientPlaying());
// The sleep timer counts while either plays (radio and podcasts share the player).
sleep.tick(playing || radioPlaying());
// While playing, the shared player hears from us every 10 s (its place, and that we're alive).
if ((playing || radioPlaying()) && Date.now() - sync.lastSent >= REPORT_EVERY_MS && !busy()) syncReport();
// Another device's place moves on in the bar too.
if (playing || sleep.leftMs > 0 || otherActive(sync.state)) report();
}, 1000);
// ---- skip silence ----------------------------------------------------------------------
// A browser can't listen to most podcast servers' audio (no CORS), so the server finds an
// episode's pauses once (podcasts/silence.py, it takes a little while the first time) and
// the player jumps over them, a little of each kept. The podcast's own choice, else this
// browser's (as in the app, PlaybackRules.skipSilence).
function skipSilenceFor(feedId) {
const f = feed(feedId);
return f && f.skip_silence != null ? !!f.skip_silence : !!prefs.skip_silence;
}
const silence = {id: null, spans: null, asked: 0};
function silenceSpans() {
const id = curId();
if (id == null || !skipSilenceFor(curFeed())) return null;
if (silence.id !== id) { silence.id = id; silence.spans = null; silence.asked = 0; }
if (!silence.spans && Date.now() - silence.asked > 20000) {
silence.asked = Date.now();
fetch(`/podcasts/episodes/${id}/silences/`, {credentials: 'same-origin', cache: 'no-store'})
.then(r => (r.ok ? r.json() : null))
.then(d => { if (d && d.ready && silence.id === id) silence.spans = d.spans || []; })
.catch(() => {});
}
return silence.spans;
}
audio.addEventListener('timeupdate', () => {
if (!loaded || audio.paused || audio.seeking) return;
const spans = silenceSpans();
if (!spans || !spans.length) return;
const t = audio.currentTime;
for (const [start, end] of spans) { // in order
if (start > t) break;
if (t < end - 0.3) { try { audio.currentTime = end - 0.15; } catch (e) {} return; }
}
});
// ---- radio and podcasts take turns ---------------------------------------------------
function stopRadio() {
@ -1015,7 +920,6 @@
window.playStation = function () {
putAway();
const r = playStation.apply(this, arguments);
sleep.maybeAutoEnable();
radioChanged();
sync.start = true;
syncSoon();
@ -1183,22 +1087,11 @@
((st.kind === 'podcast' && st.episode) || (st.kind === 'radio' && st.station)));
}
// The other device's sleep timer now: its minutes count down while it plays (real time).
function remoteSleep(st) {
const z = st.sleep || {};
let left = z.left_ms || 0;
if (left > 0 && st.playing && st.active_online && st.updated_at) {
left = Math.max(0, left - Math.max(0, serverNow(st) - isoMs(st.updated_at)));
}
return {leftMs: Math.round(left), episodes: z.episodes || 0};
}
// The page's player.remote: what the other device plays.
function remoteJson() {
const st = sync.state;
if (!otherActive(st)) return null;
const o = {device: st.device_name || '', kind: st.kind, playing: !!st.playing, station: null, pos: 0, dur: 0,
sleep: remoteSleep(st)};
const o = {device: st.device_name || '', kind: st.kind, playing: !!st.playing, station: null, pos: 0, dur: 0};
const radio = st.kind === 'radio';
if (radio) {
o.station = {name: st.station.name || '', url: st.station.url, logo: radioLogo(st.station)};
@ -1236,17 +1129,16 @@
return list;
}
// What this device plays, as a report has it (with the sleep timer).
// What this device plays, as a report has it.
function mine() {
const st = typeof currentStation !== 'undefined' ? currentStation : null;
const zz = {left_ms: Math.max(0, Math.round(sleep.leftMs)), episodes: sleep.episodes};
if (st && (radioPlaying() || !cur)) {
return {kind: 'radio', station: {url: st.url, name: st.name || '', id: Number(st.id) || null},
position_ms: 0, playing: radioPlaying(), sleep: zz};
position_ms: 0, playing: radioPlaying()};
}
if (cur) {
return {kind: 'podcast', episode_id: cur.episode__id, position_ms: Math.max(0, Math.round(posMs())),
playing: wantPlay, speed: speedFor(curFeed()), sleep: zz};
playing: wantPlay, speed: speedFor(curFeed())};
}
return {kind: '', position_ms: 0, playing: false};
}
@ -1364,10 +1256,6 @@
try {
if (a === 'transfer') {
takeOver(st);
} else if (a === 'sleep') {
// minutes; -n: after n episodes; 0: off
const v = Math.round(Number(cmd.value) || 0);
sleep.set(Math.max(v, 0), v < 0 ? -v : 0);
} else if (kind === 'radio') {
if (a === 'play' && !radioPlaying()) togglePlayStop();
else if ((a === 'pause' || a === 'stop') && radioPlaying()) stopPlayback(false);
@ -1499,7 +1387,7 @@
// Remote control: a command for the active device (or `to`).
function command(action, value, to) {
const body = {device_id: DEVICE_ID, device_name: DEVICE_NAME, action};
if (action === 'seek' || action === 'skip' || action === 'sleep') body.value = Math.round(Number(value) || 0);
if (action === 'seek' || action === 'skip') body.value = Math.round(Number(value) || 0);
if (to) body.to_device_id = to;
else hintFor(action, body.value);
return post('/podcasts/player/command/', body)
@ -1699,7 +1587,6 @@
},
podcastNext: () => next(),
podcastToggle: () => (wantPlay ? pause() : resume()),
podcastPause: () => { if (wantPlay) pause(); },
podcastSeek: ms => seek(Number(ms)),
podcastSkip: forward => skip(!!forward),
podcastSpeed(speed) {
@ -1862,9 +1749,6 @@
if (!source) listen();
}
});
// Open for long (diora desktop, a pinned tab): the state every 15 minutes too — the
// server fetches the feeds hourly, new episodes show up without a reload.
setInterval(() => { if (document.visibilityState === 'visible') refresh(false); }, 15 * 60000);
window.addEventListener('online', () => {
flush();
if (!source) listen();
@ -1873,18 +1757,15 @@
// The shared player: what plays on the other devices, and from now on every change.
startSync();
// The start page chosen in the podcasts' settings (nui_start_page, this browser): Home
// unless chosen otherwise; '' = the last used tab, the web's old way. Opening diora shows
// it; reloading and back/forward keep the tab.
// The start page chosen in the podcasts' settings (nui_start_page, this browser; none: the
// last used tab, the web's way). Opening diora shows it; reloading and back/forward keep the tab.
(function openStartPage() {
let start = null;
try { start = localStorage.getItem('nui_start_page'); } catch (e) {}
if (start === null) start = document.getElementById('tab-home') ? 'home' : '';
let start = '';
try { start = localStorage.getItem('nui_start_page') || ''; } catch (e) {}
if (!start || typeof showTab !== 'function') return;
const nav = performance.getEntriesByType ? performance.getEntriesByType('navigation')[0] : null;
if (nav && nav.type !== 'navigate') return;
const tab = start === 'queue' || start === 'inbox' ? 'podcasts' : start === 'radio' ? 'radio'
: start === 'home' && document.getElementById('tab-home') ? 'home' : 'books';
const tab = start === 'queue' || start === 'inbox' ? 'podcasts' : start === 'radio' ? 'radio' : 'books';
if (typeof _currentTopTab === 'undefined' || _currentTopTab !== tab) showTab(tab);
})();

View file

@ -2,17 +2,15 @@
* diora service worker — caches the app shell for offline use.
*/
const CACHE = 'diora-v87';
const CACHE = 'diora-v69';
const SHELL = [
'/static/css/app.css',
'/static/js/app.js',
'/static/js/podcasts-web.js',
'/static/js/home.js',
'/static/podcasts/native-podcast.js',
'/static/podcasts/podcast-ui.css',
'/static/podcasts/podcasts-web.css',
'/static/manifest.json',
'/static/ambient/index.html',
'/static/fonts/SourceSerif4-normal-latin.woff2',
'/static/fonts/SourceSerif4-normal-latin-ext.woff2',
'/static/fonts/SourceSerif4-italic-latin.woff2',
@ -79,8 +77,7 @@ self.addEventListener('fetch', function (event) {
const isShell = SHELL.some(function (s) { return url.pathname === s; });
if (isShell) {
event.respondWith(
// ignoreSearch: the Ambient frame loads with ?embed=1&bg=…
caches.match(event.request, {ignoreSearch: true}).then(function (cached) {
caches.match(event.request).then(function (cached) {
return cached || fetch(event.request);
})
);

View file

@ -1,7 +1,7 @@
{
"name": "diora",
"short_name": "diora",
"description": "Bücher, Podcasts und Radio",
"description": "Internet radio player",
"start_url": "/",
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone"],

View file

@ -1,4 +1,4 @@
// Copied from diora-android 7dcc0c6 2026-10-04: 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.
@ -34,11 +34,9 @@
const VIEW_IDS = VIEWS.map(v => v.id).concat('search');
// The app's start page (this device; native.js opens it, the browser host too).
const IN_APP = !window.DioraPodcastHost;
// A function: the app loads this file in <head>, before #tab-home exists.
const startPages = () => [
document.getElementById('tab-home') && {id: 'home', label: 'Home', hint: 'Standard'},
!IN_APP && {id: '', label: 'Zuletzt benutzt'},
{id: 'books', label: 'Bücher', hint: IN_APP && !document.getElementById('tab-home') ? 'Standard' : null},
const START_PAGES = [
!IN_APP && {id: '', label: 'Zuletzt benutzt', hint: 'Standard im Browser'},
{id: 'books', label: 'Bücher', hint: IN_APP ? 'Standard' : null},
{id: 'queue', label: 'Podcasts: Warteschlange'},
{id: 'inbox', label: 'Podcasts: Neu'},
{id: 'radio', label: 'Radio'},
@ -46,8 +44,7 @@
function startPage() {
let v = null;
try { v = localStorage.getItem('nui_start_page'); } catch (e) {}
const pages = startPages();
return pages.some(p => p.id === v) ? v : pages[0].id;
return START_PAGES.some(p => p.id === v) ? v : START_PAGES[0].id;
}
const POSITIONS = [
{value: 'back', label: 'Ans Ende'},
@ -74,8 +71,6 @@
let preview = null; // {id, url, data | choices | error} while a feed is looked at before subscribing
let previewSeq = 0;
let refreshing = false;
let swipingSince = 0; // a row is being swiped (since then): render waits (rowGestures)
let renderAfterSwipe = false;
let selected = null; // Set of episode ids while choosing several (queue or inbox)
let locked = false; // queue locked: no dragging
try { locked = localStorage.getItem('nui_pod_locked') === '1'; } catch (e) {}
@ -559,9 +554,9 @@
// Which page the app opens with (this device).
function startPageItem() {
const now = startPage();
const label = id => (startPages().find(p => p.id === id) || startPages()[0]).label;
const label = id => (START_PAGES.find(p => p.id === id) || START_PAGES[0]).label;
return {icon: '⌂', label: 'Startseite', hint: label(now) + (IN_APP ? ' · beim Öffnen der App' : ' · beim Öffnen von diora'),
action: () => openSheet('Beim Öffnen zeigen …', startPages().filter(p => p.id !== 'radio' || !IN_APP ||
action: () => openSheet('Beim Öffnen zeigen …', START_PAGES.filter(p => p.id !== 'radio' || !IN_APP ||
document.documentElement.classList.contains('nui-radio')).map(p => ({
icon: p.id === now ? '●' : '○', label: p.label, hint: p.hint || null, active: p.id === now,
action: () => {
@ -630,7 +625,7 @@
// The account's presets; without any, the app's own list.
{key: 'speed', label: 'Tempo', hint: () => (Array.isArray(settings().speed_presets) && settings().speed_presets.length
? presets() : [1, 1.2, 1.5, 1.75, 2]).map(speedLabel).join(' · ')},
{key: 'sleep', label: 'Sleep-Timer', hint: () => `${sleepMinutesLabel((player.sleep || {}).last || 15)} an / aus, auch beim Radio`},
{key: 'sleep', label: 'Sleep-Timer', hint: () => '15 Minuten an / aus'},
];
function notifyItem() {
if (!N.podcastSetNotifyButtons || !Array.isArray(devicePrefs().notify_buttons)) return null;
@ -987,9 +982,6 @@
}
function render() {
// A finger is swiping a row: redrawing now would pull the row away under it (and end
// the swipe). It redraws when the swipe ends — unless that never came (a lost pointer).
if (swipingSince && Date.now() - swipingSince < 8000) { renderAfterSwipe = true; return; }
episodeScreenParts();
if (!root) return;
const scroll = scrollTop();
@ -1182,7 +1174,6 @@
function rowGestures(row, onLong, onSwipe, list) {
let start = null;
let holding = false; // this row counts in swipingSince
let timer = 0;
let mode = '';
let dx = 0;
@ -1226,11 +1217,6 @@
delete other.dataset.act;
};
const finish = done => {
if (holding) {
holding = false;
swipingSince = 0;
if (renderAfterSwipe) { renderAfterSwipe = false; setTimeout(render, done ? 200 : 220); }
}
row.classList.remove('nui-pod-swiping');
const gone = bg;
bg = null;
@ -1271,9 +1257,7 @@
if (Math.abs(mx) > 8 || Math.abs(my) > 8) { stopTimer(); start.moved = true; }
if (onSwipe && start.touch && Math.abs(mx) > 12 && Math.abs(mx) > Math.abs(my) * 1.5) {
mode = 'swipe';
// The swipe before stays undoable (its bar too) until this one is done (offerUndo).
holding = true;
swipingSince = Date.now();
commitSwipe();
try { row.setPointerCapture(ev.pointerId); } catch (e) {}
bg = el('div', 'nui-pod-swipebg');
bg.append(el('span', 'nui-pod-swipe-l'), el('span', 'nui-pod-swipe-r'));
@ -2801,8 +2785,7 @@
const text = el('button', 'nui-mini-text');
text.type = 'button';
text.append(el('span', 'nui-mini-station'), el('span', 'nui-mini-track'));
text.addEventListener('click', () => (miniKind === 'ambient' ? showTab('ambient')
: miniKind === 'radio' ? openRadioPlayer() : openPlayer()));
text.addEventListener('click', () => (miniKind === 'radio' ? openRadioPlayer() : openPlayer()));
const devices = button('⇄', 'Wiedergabe auf …', deviceMenu, 'nui-mini-devices');
const here = button('⇩', 'Hier abspielen', takeOver, 'nui-mini-here');
devices.hidden = here.hidden = true;
@ -2812,7 +2795,6 @@
button('☆', 'Sender speichern', () => window.__dioraRadioSave && window.__dioraRadioSave(), 'nui-mini-save'),
devices, here,
button('▶', 'Abspielen', () => (miniKind === 'remote' ? remoteToggle()
: miniKind === 'ambient' ? ambientToggle()
: miniKind === 'radio' ? window.__dioraRadioToggle && window.__dioraRadioToggle() : N.podcastToggle()), 'nui-mini-toggle'));
dock.prepend(mini);
// One player for podcasts and radio: the radio's own bar steps aside.
@ -2830,68 +2812,6 @@
}
}
// Ambient (app.js's #tab-ambient): its sleep timer, a ☾ at the frame's top right corner.
function ambientSleepButton() {
const tab = $id('tab-ambient');
if (!tab || !N.podcastSleep || $id('nui-amb-sleep')) return;
const b = button('☾', 'Sleep-Timer', () => sleepMenu(true, true), 'nui-pod-sleep');
b.id = 'nui-amb-sleep';
tab.append(b);
}
function renderAmbientSleep() {
const b = $id('nui-amb-sleep');
if (!b) return;
b.textContent = sleepLabel(true) || '☾';
b.classList.toggle('on', !!sleepLabel(true));
}
// Ambient (app.js) started last: the bar is its — the sound world's name, ▶/■, a tap opens
// its tab — until the radio or an episode starts.
function ambientInfo() {
return window.__dioraAmbientInfo ? window.__dioraAmbientInfo() : null;
}
function ambientToggle() {
const a = ambientInfo();
if (a && window.__dioraAmbientCmd) window.__dioraAmbientCmd(a.playing ? 'stop' : 'start');
}
window.__dioraAmbientChanged = () => renderMini();
function renderAmbientMini() {
const a = ambientInfo();
if (!mini || !a || !a.last) return false;
// An episode or the radio plays here: the bar is theirs, whatever came before.
const r = radioNow();
if (!a.playing && ((player.playing && !player.remote) || (r && r.playing))) return false;
if (player.kind === 'podcast' && player.playing && !player.remote) return false;
miniKind = 'ambient';
document.documentElement.classList.remove('nui-pod-playing');
document.documentElement.classList.add('nui-pod-mini-open');
mini.hidden = false;
mini.classList.add('nui-mini-radio');
mini.classList.remove('nui-mini-remote');
for (const c of ['.nui-pod-skip', '.nui-mini-save', '.nui-mini-devices', '.nui-mini-here']) mini.querySelector(c).hidden = true;
mini.querySelector('.nui-mini-station').textContent = 'Ambient';
const z = sleepLabel(true);
mini.querySelector('.nui-mini-track').textContent = (a.mode || 'Drift') + (a.playing ? '' : ' · angehalten') + (z ? ' · ' + z : '');
const t = mini.querySelector('.nui-mini-toggle');
t.textContent = a.playing ? '■' : '▶';
t.title = a.playing ? 'Ambient stoppen' : 'Ambient abspielen';
const box = mini.querySelector('.nui-pod-art');
setArt(box, '');
if (!box.querySelector('.nui-amb-art')) { box.textContent = ''; box.append(el('span', 'nui-pod-initial nui-amb-art', '∿')); }
mini.querySelector('.nui-pod-mini-bar span').style.width = a.playing ? '100%' : '0';
return true;
}
// The bar names a running sleep timer: "… · ☾ 12 min".
function sleepSuffix() {
const l = sleepLabel();
return l ? ' · ' + l : '';
}
// What the bar shows: 'podcast', 'radio' (a station is chosen), 'remote'
// (another device is the active one) or ''.
let miniKind = '';
@ -2915,14 +2835,10 @@
}
function renderMini() {
renderAmbientSleep();
if (renderAmbientMini()) return;
const r = remote();
const isPod = player.kind === 'podcast';
document.documentElement.classList.toggle('nui-pod-playing', isPod || !!r);
if (!mini) return;
const ambArt = mini.querySelector('.nui-amb-art');
if (ambArt) ambArt.remove();
const radio = isPod || r ? null : radioNow();
miniKind = r ? 'remote' : isPod ? 'podcast' : radio ? 'radio' : '';
mini.hidden = !miniKind;
@ -2936,7 +2852,7 @@
if (r) {
const onRadio = r.kind === 'radio';
mini.querySelector('.nui-mini-station').textContent = (onRadio ? r.station && r.station.name : r.title) || '';
mini.querySelector('.nui-mini-track').textContent = remoteLine(r) + sleepSuffix();
mini.querySelector('.nui-mini-track').textContent = remoteLine(r);
const t = mini.querySelector('.nui-mini-toggle');
t.textContent = r.playing ? (onRadio ? '■' : '❚❚') : '▶';
t.title = r.playing ? (onRadio ? 'Stopp' : 'Pause') + ' auf ' + remoteName(r) : 'Abspielen auf ' + remoteName(r);
@ -2947,8 +2863,8 @@
}
if (miniKind === 'radio') {
mini.querySelector('.nui-mini-station').textContent = radio.name;
mini.querySelector('.nui-mini-track').textContent = (radio.buffering ? 'Verbinde …'
: radio.playing ? (radio.track || '● Live') : 'Angehalten') + sleepSuffix();
mini.querySelector('.nui-mini-track').textContent = radio.buffering ? 'Verbinde …'
: radio.playing ? (radio.track || '● Live') : 'Angehalten';
const t = mini.querySelector('.nui-mini-toggle');
t.textContent = radio.playing ? '■' : '▶';
t.title = radio.playing ? 'Stopp' : 'Abspielen';
@ -2965,9 +2881,8 @@
sk.title = `${devicePrefs().skip_back || 15} s zurück`;
sk.setAttribute('aria-label', sk.title);
}
mini.querySelector('.nui-mini-track').textContent = (player.buffering && player.playing ? 'Lädt …'
: (player.feed || '') + (player.dur ? ' · ' + clock(shown((player.dur - player.pos) / 1000, 0, player.speed || 1)) + ' übrig' : ''))
+ sleepSuffix();
mini.querySelector('.nui-mini-track').textContent = player.buffering && player.playing ? 'Lädt …'
: (player.feed || '') + (player.dur ? ' · ' + clock(shown((player.dur - player.pos) / 1000, 0, player.speed || 1)) + ' übrig' : '');
const t = mini.querySelector('.nui-mini-toggle');
t.textContent = player.playing ? '❚❚' : '▶';
t.title = player.playing ? 'Pause' : 'Abspielen';
@ -3490,17 +3405,6 @@
if (sheet && sheetKind === 'radio') renderRadioPlayer();
};
window.__dioraOpenPlayer = kind => (kind === 'radio' && !remote() ? openRadioPlayer() : openPlayer());
// Home (diora-web static/js/home.js): one of the lists, e.g. "inbox" for the new episodes.
window.__dioraPodcastView = function (id) {
if (VIEW_IDS.indexOf(id) < 0) return;
selected = null;
feedView = null;
preview = null;
view = id;
try { localStorage.setItem('nui_pod_view', view); } catch (e) {}
render();
scrollTop(0);
};
function renderPlayer() {
if (!sheet) return;
@ -3581,184 +3485,53 @@
label: speedLabel(s),
action: () => (forPodcast ? N.podcastSpeedForPodcast(s) : N.podcastSpeed(s)),
})).concat(forPodcast && own ? [{icon: '↺', label: 'Wie alle anderen Podcasts', action: () => N.podcastSpeedForPodcast(0)}] : [])
.concat(presetsEditItem(), silenceSpeedItems()));
.concat(presetsEditItem()));
if (!N.podcastSpeedForPodcast || !player.feedId) { pick(false); return; }
openSheet('Geschwindigkeit', [
{icon: '◎', label: 'Für alle Podcasts', hint: own ? null : speedLabel(player.speed || 1), action: () => pick(false)},
{icon: '◉', label: `Nur für „${plain(player.feed)}“`, hint: own ? speedLabel(player.speed || 1) : 'noch keine eigene',
action: () => pick(true)},
].concat(presetsEditItem(), silenceSpeedItems()));
].concat(presetsEditItem()));
}
// „Stille überspringen“ where AntennaPod has it: in the speed menu. For all podcasts (this
// device), and for the playing one its own choice (server) — where the player can skip silence.
function silenceSpeedItems() {
const prefs = devicePrefs();
if (!N.podcastSetPref || !('skip_silence' in prefs)) return [];
const device = !!prefs.skip_silence;
const live = player.feedId ? feeds().find(x => x.id === player.feedId) : null;
const mine = live && 'skip_silence' in live ? live.skip_silence : undefined;
const items = [{icon: device ? '☑' : '☐', label: 'Stille überspringen',
hint: (device ? 'an' : 'aus') + ' · alle Podcasts' + (mine === true ? ' · hier: immer' : mine === false ? ' · hier: nie' : ''),
action: () => { N.podcastSetPref('skip_silence', !device); toast(device ? 'Stille wird nicht mehr übersprungen' : 'Stille wird übersprungen'); }}];
if (mine !== undefined && N.podcastFeedSettings) {
const title = plain(player.feed);
const save = v => N.podcastFeedSettings(player.feedId, JSON.stringify({skip_silence: v}));
items.push({icon: '∿', label: `Stille bei „${title}“`, hint: mine === true ? 'immer überspringen' : mine === false ? 'nie überspringen' : 'wie alle Podcasts',
action: () => openSheet(`Stille bei „${title}“ …`, [
{icon: mine == null ? '●' : '○', label: 'Wie alle Podcasts', hint: device ? 'überspringen' : 'nicht überspringen', action: () => save(null)},
{icon: mine === true ? '●' : '○', label: 'Immer überspringen', action: () => save(true)},
{icon: mine === false ? '●' : '○', label: 'Nie überspringen', action: () => save(false)},
])});
}
return items;
}
// ---- sleep timer (runs in the app, also with the screen off) ----
// ---- sleep timer (runs in the app, also with the screen off; episodes and the radio) ----
// The timer that counts: the other device's while that one is the active one, else ours
// (`local`: ours anyway — Ambient's button, Ambient plays here).
function sleepState(local) {
const r = local ? null : remote();
return (r ? r.sleep : player.sleep) || {};
}
function sleepLabel(local) {
const t = sleepState(local);
function sleepLabel() {
const t = player.sleep || {};
if (t.episodes > 0) return t.episodes === 1 ? '☾ Folgenende' : `☾ ${t.episodes} Folgen`;
if (t.leftMs > 0) return `☾ ${Math.max(1, Math.ceil(t.leftMs / 60000))} min`;
return '';
}
const SLEEP_MINUTES = [5, 10, 15, 20, 30, 45, 60, 90];
const SLEEP_MAX = 600;
// A typed-in time: "25", "25 min", "1:30", "1,5 h" → minutes (1 to 600), else null.
function parseSleepMinutes(text) {
const t = String(text || '').trim().toLowerCase().replace(',', '.').replace(/\s*(minuten|min|m)$/, '');
let m;
const hm = /^(\d+):(\d{1,2})$/.exec(t);
const h = /^(\d+(?:\.\d+)?)\s*(h|std|stunden?)$/.exec(t);
if (hm) m = Number(hm[2]) < 60 ? Number(hm[1]) * 60 + Number(hm[2]) : NaN;
else if (h) m = Math.round(Number(h[1]) * 60);
else m = /^\d+(\.\d+)?$/.test(t) ? Math.round(Number(t)) : NaN;
return m >= 1 && m <= SLEEP_MAX ? m : null;
}
function sleepMinutesLabel(m) {
if (m === 1) return '1 Minute';
return m < 60 ? `${m} Minuten` : `${Math.floor(m / 60)} h` + (m % 60 ? ` ${m % 60} min` : '');
}
function hourOf(v, fallback) {
return Number.isInteger(v) && v >= 0 && v <= 23 ? v : fallback;
}
function sleepWindow() {
const p = devicePrefs();
const from = hourOf(p.sleep_from, 22);
const to = hourOf(p.sleep_to, 6);
return from === to ? 'ganztags' : `${from}–${to} Uhr`;
}
// Minutes count only while playing; shaking the phone starts them over. With another
// device playing, the timer is that device's (set there by the player sync).
function sleepMenu(radio, local) {
const r = local ? null : remote();
const set = r ? (m => remoteCmd('sleep', m)) : N.podcastSleep ? (m => N.podcastSleep(m)) : null;
if (!set) return;
const onRadio = r ? r.kind === 'radio' : radio === true;
const t = sleepState(local);
const own = player.sleep || {};
const on = !!sleepLabel(local);
// Minutes count only while playing; shaking the phone starts them over.
function sleepMenu(radio) {
if (!N.podcastSleep) return;
const t = player.sleep || {};
const on = !!sleepLabel();
const items = [];
if (r) items.push({custom: el('div', 'nui-pod-meta nui-pod-devnote', `Der Sleep-Timer gilt für ${remoteName(r)}.`)});
if (on) {
items.push({icon: '✕', label: 'Ausschalten', hint: sleepLabel(local).replace('☾ ', 'läuft: '), action: () => set(0)});
if (t.leftMs > 0) {
for (const m of [5, 10]) {
items.push({icon: '+', label: `${m} Minuten länger`, action: () => (!r && N.podcastSleepExtend
? N.podcastSleepExtend(m) : set(Math.min(SLEEP_MAX, Math.ceil(t.leftMs / 60000) + m)))});
items.push({icon: '✕', label: 'Ausschalten', hint: sleepLabel().replace('☾ ', 'läuft: '), action: () => N.podcastSleep(0)});
if (t.leftMs > 0 && N.podcastSleepExtend) {
items.push({icon: '+', label: '5 Minuten länger', action: () => N.podcastSleepExtend(5)});
items.push({icon: '+', label: '10 Minuten länger', action: () => N.podcastSleepExtend(10)});
}
}
for (const m of [5, 10, 15, 30, 45, 60, 90]) {
items.push({icon: '☾', label: `${m} Minuten`, hint: m === t.last && !on ? 'zuletzt' : null, action: () => N.podcastSleep(m)});
}
for (const m of SLEEP_MINUTES) {
items.push({icon: '☾', label: `${m} Minuten`, hint: !r && m === own.last && !on ? 'zuletzt' : null, action: () => set(m)});
if (radio !== true) {
items.push({icon: '⏹', label: 'Am Ende der Folge', action: () => N.podcastSleep(-1)});
items.push({icon: '⏹', label: 'Nach 2 Folgen', action: () => N.podcastSleep(-2)});
items.push({icon: '⏹', label: 'Nach 3 Folgen', action: () => N.podcastSleep(-3)});
}
if (typeof customPrompt === 'function') {
// A time of one's own that was set last comes back as its own line.
const lastOwn = !r && own.last && !SLEEP_MINUTES.includes(own.last) ? own.last : 0;
if (lastOwn) items.push({icon: '☾', label: sleepMinutesLabel(lastOwn), hint: on ? null : 'zuletzt', action: () => set(lastOwn)});
items.push({icon: '✎', label: 'Eigene Zeit …', hint: 'z. B. 25, 2 h oder 1:30', action: () => customSleep(set)});
}
if (!onRadio) {
items.push({icon: '⏹', label: 'Am Ende der Folge', action: () => set(-1)});
items.push({icon: '⏹', label: 'Nach 2 Folgen', action: () => set(-2)});
items.push({icon: '⏹', label: 'Nach 3 Folgen', action: () => set(-3)});
}
if (!r && N.podcastSleepAuto) {
items.push({icon: own.auto ? '☑' : '☐', label: 'Nachts von selbst',
hint: own.auto ? `an: ${sleepWindow()}, ${sleepMinutesLabel(own.last || 15)}, sobald etwas startet`
: `aus · ${sleepWindow()}`,
action: () => {
N.podcastSleepAuto(!own.auto);
own.auto = !own.auto;
toast(own.auto ? `Sleep-Timer nachts (${sleepWindow()}) von selbst` : 'Sleep-Timer nachts: aus');
}});
items.push({icon: '⚙', label: 'Einstellungen', hint: sleepSettingsHint(), action: sleepSettings});
if (N.podcastSleepAuto) {
items.push({icon: t.auto ? '☑' : '☐', label: 'Nachts von selbst (22–6 Uhr)',
hint: t.auto ? `an: ${t.last || 15} Minuten, wenn eine Folge startet` : 'aus',
action: () => { N.podcastSleepAuto(!t.auto); t.auto = !t.auto; }});
}
openSheet('Sleep-Timer', items);
}
async function customSleep(set) {
const v = await customPrompt('Sleep-Timer: wie lange? (Minuten, z. B. 25, 2 h oder 1:30)', '');
if (v == null || !String(v).trim()) return;
const m = parseSleepMinutes(v);
if (!m) { toast('Bitte 1 Minute bis 10 Stunden, z. B. 25, 2 h oder 1:30'); return; }
set(m);
toast(`Sleep-Timer: ${sleepMinutesLabel(m)}`);
}
function sleepSettingsHint() {
const p = devicePrefs();
const parts = [`nachts = ${sleepWindow()}`];
if ('sleep_shake' in p) parts.push(p.sleep_shake ? 'schütteln an' : 'schütteln aus');
if (p.sleep_vibrate) parts.push('vibrieren');
return parts.join(' · ');
}
// The night window and, in the app, shaking and buzzing.
function sleepSettings() {
const p = devicePrefs();
const from = hourOf(p.sleep_from, 22);
const to = hourOf(p.sleep_to, 6);
const again = () => setTimeout(sleepSettings, 60);
const setHour = (key, h) => { if (N.podcastSetPrefValue) N.podcastSetPrefValue(key, h); again(); };
const items = [
{icon: '◐', label: 'Nachts beginnt um', hint: `${from} Uhr`,
action: () => hourPicker('Nachts beginnt um …', from, h => setHour('sleep_from', h))},
{icon: '◑', label: 'Nachts endet um', hint: `${to} Uhr`,
action: () => hourPicker('Nachts endet um …', to, h => setHour('sleep_to', h))},
];
if ('sleep_shake' in p && N.podcastSetPref) {
items.push({icon: p.sleep_shake ? '☑' : '☐', label: 'Schütteln startet die Zeit neu',
hint: p.sleep_shake ? 'an: Handy schütteln, und die Minuten laufen von vorn' : 'aus',
action: () => { N.podcastSetPref('sleep_shake', !p.sleep_shake); again(); }});
}
if ('sleep_vibrate' in p && N.podcastSetPref) {
items.push({icon: p.sleep_vibrate ? '☑' : '☐', label: 'Vor dem Ende vibrieren',
hint: p.sleep_vibrate ? 'an: kurz, wenn die letzten 10 s leiser werden' : 'aus',
action: () => { N.podcastSetPref('sleep_vibrate', !p.sleep_vibrate); again(); }});
}
openSheet('Sleep-Timer: Einstellungen', items);
}
function hourPicker(title, now, done) {
const hours = [];
for (let i = 0; i < 24; i++) hours.push((18 + i) % 24);
openSheet(title, hours.map(h => ({icon: h === now ? '●' : '○', label: `${h} Uhr`, action: () => done(h)})));
}
// ⏭: the next episode plays. The host decides about this one ("keep skipped"): kept
// unplayed in the queue, or played. With nothing after it, it only counts as played
// when skipped episodes aren't kept.
@ -3902,7 +3675,6 @@
}
const extra = el('div', 'nui-pod-extra');
if (!onRadio) extra.append(button('⏭', 'Nächste Folge', () => remoteCmd('next'), 'nui-pod-next'));
extra.append(button('☾', `Sleep-Timer auf ${remoteName(r)}`, () => sleepMenu(onRadio), 'nui-pod-sleep'));
if (N.playerTransfer) extra.append(onRadio ? devicesButton() : button('⇄', 'Wiedergabe auf …', deviceMenu, 'nui-pod-devices'));
if (!onRadio) extra.append(button('≡', 'Warteschlange', showQueue));
panel.append(extra);
@ -3925,9 +3697,6 @@
const p = sheet.querySelector('.nui-pod-play');
p.textContent = r.playing ? (onRadio ? '■' : '❚❚') : '▶';
p.title = (r.playing ? (onRadio ? 'Stopp' : 'Pause') : 'Abspielen') + ' auf ' + remoteName(r);
const sl = sheet.querySelector('.nui-pod-sleep');
sl.textContent = sleepLabel() || '☾';
sl.classList.toggle('on', !!sleepLabel());
if (onRadio) return;
sheet.querySelector('.nui-pod-remote-feed').textContent = r.feed || '';
const range = sheet.querySelector('.nui-pod-seek');
@ -4395,8 +4164,6 @@
window.__dioraPlayer = function (p) {
const before = player;
player = p || {kind: null};
// Something starts playing here (an episode, the radio): Ambient stops (app.js).
if (player.playing && !before.playing && !player.remote && window.__dioraAmbientStop) window.__dioraAmbientStop();
renderMini();
renderPlayer();
// The current episode changed: the queue view marks it.
@ -4405,13 +4172,6 @@
if (epScreen && (before.episodeId !== player.episodeId || !!before.playing !== !!player.playing)) episodeScreenParts();
};
// Ambient starts (app.js): an episode playing here pauses.
window.__dioraPausePlayer = function () {
if (player.kind !== 'podcast' || !player.playing || player.remote) return;
// Pause, not toggle: Android's audio focus may have paused it a moment before.
if (N.podcastPause) N.podcastPause(); else if (N.podcastToggle) N.podcastToggle();
};
// The app loads this in <head>; the browser may load it after the page is parsed.
function start(fn) {
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn);
@ -4425,7 +4185,6 @@
window.refreshAllFeeds = async function () {};
build();
buildMini();
ambientSleepButton();
player = parse(N.playerState(), {kind: null});
moveFeedListsToServer();
render();

View file

@ -1,4 +1,4 @@
/* Copied from diora-android 67a915e 2026-10-03: 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. */
@ -775,24 +775,3 @@ body.eink .nui-pod-chapbox { border-color: #000; }
body.eink .nui-pod-chip.on { border-color: #000; color: #000; }
body.eink .nui-pod-notes a,
body.eink .nui-pod-facts a { text-decoration-color: #000; }
/* A prompt (customPrompt: an own sleep time, an own speed) opened from the player or a
sheet comes above both (app.css has it at 500/501, under them). */
.modal-overlay { z-index: 990; }
.modal-dialog { z-index: 991; }
/* Ambient's sleep timer: a ☾ on the synthesizer's frame, top right (native-podcast.js). */
#tab-ambient { position: relative; }
#nui-amb-sleep {
position: absolute;
top: 10px;
right: 10px;
z-index: 2;
min-width: 44px;
height: 36px;
padding: 0 12px;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(0, 0, 0, 0.55);
color: #ddd;
}

View file

@ -1,9 +1,9 @@
{% extends "base.html" %}
{% block title %}Anmelden — diora{% endblock %}
{% block title %}Login — diora{% endblock %}
{% block content %}
<div class="auth-container">
<h1 class="auth-title">Anmelden</h1>
<h1 class="auth-title">Login</h1>
<form method="post" class="auth-form">
{% csrf_token %}
{% for field in form %}
@ -26,10 +26,10 @@
{% endfor %}
</div>
{% endif %}
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
<button type="submit" class="btn btn-primary btn-full">Login</button>
</form>
<p class="auth-switch">
Noch kein Konto? <a href="{% url 'register' %}">Registrieren</a>
Don't have an account? <a href="{% url 'register' %}">Register</a>
</p>
</div>
{% endblock %}

View file

@ -1,9 +1,9 @@
{% extends "base.html" %}
{% block title %}Registrieren — diora{% endblock %}
{% block title %}Register — diora{% endblock %}
{% block content %}
<div class="auth-container">
<h1 class="auth-title">Konto anlegen</h1>
<h1 class="auth-title">Create Account</h1>
<form method="post" class="auth-form">
{% csrf_token %}
{% for field in form %}
@ -29,10 +29,10 @@
{% endfor %}
</div>
{% endif %}
<button type="submit" class="btn btn-primary btn-full">Konto anlegen</button>
<button type="submit" class="btn btn-primary btn-full">Create Account</button>
</form>
<p class="auth-switch">
Schon ein Konto? <a href="{% url 'login' %}">Anmelden</a>
Already have an account? <a href="{% url 'login' %}">Login</a>
</p>
</div>
{% endblock %}

View file

@ -1,9 +1,9 @@
{% extends "base.html" %}
{% block title %}Einstellungen — diora{% endblock %}
{% block title %}Settings — diora{% endblock %}
{% block content %}
<div class="settings-container">
<h1 class="settings-title">Einstellungen</h1>
<h1 class="settings-title">Settings</h1>
{% if lastfm_error %}
<div class="message message-error">{{ lastfm_error }}</div>
@ -15,7 +15,7 @@
{% if has_lastfm %}
<div class="lastfm-connected">
<p class="connected-status">
Verbunden als <strong>{{ profile.lastfm_username }}</strong>
Connected as <strong>{{ profile.lastfm_username }}</strong>
</p>
<form method="post" class="settings-form" id="scrobble-form">
{% csrf_token %}
@ -23,46 +23,46 @@
<input type="checkbox" name="lastfm_scrobble" id="lastfm_scrobble"
{% if profile.lastfm_scrobble %}checked{% endif %}
onchange="document.getElementById('scrobble-form').submit()">
Titel an Last.fm scrobbeln
Scrobble tracks to Last.fm
</label>
</form>
<form method="post" action="{% url 'lastfm_disconnect' %}" class="inline-form" style="margin-top: 1rem;">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Last.fm trennen</button>
<button type="submit" class="btn btn-danger">Disconnect Last.fm</button>
</form>
</div>
{% else %}
<p class="lastfm-description">
Verbinde dein Last.fm-Konto, damit gehörte Titel automatisch gescrobbelt werden.
Connect your Last.fm account to automatically scrobble the tracks you listen to.
</p>
<a href="{% url 'lastfm_connect' %}" class="btn btn-lastfm">Last.fm verbinden</a>
<a href="{% url 'lastfm_connect' %}" class="btn btn-lastfm">Connect Last.fm</a>
{% endif %}
</section>
<!-- Background section -->
<section class="settings-section">
<h2>Hintergrund</h2>
<h2>Background</h2>
{% if request.user.profile.background_encrypted %}
<p class="lastfm-description">Ein verschlüsselter Hintergrund ist gesetzt. <span class="muted">(Keine Vorschau – er wird erst auf der Hauptseite im Browser entschlüsselt.)</span></p>
<p class="lastfm-description">Encrypted background is set. <span class="muted">(Preview not available — decrypted in browser on main page.)</span></p>
<form method="post" action="{% url 'delete_background' %}" class="inline-form">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Hintergrund entfernen</button>
<button type="submit" class="btn btn-danger">Remove background</button>
</form>
<p style="margin-top:12px;">Neues Bild hochladen, um ihn zu ersetzen:</p>
<p style="margin-top:12px;">Upload a new image to replace it:</p>
{% elif request.user.profile.background_image_data %}
<p>
<img src="{{ request.user.profile.background_image_data }}" class="bg-preview" alt="Dein Hintergrund">
<img src="{{ request.user.profile.background_image_data }}" class="bg-preview" alt="Your background">
</p>
<form method="post" action="{% url 'delete_background' %}" class="inline-form">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Hintergrund entfernen</button>
<button type="submit" class="btn btn-danger">Remove background</button>
</form>
<p style="margin-top:12px;">Neues Bild hochladen, um ihn zu ersetzen (wird verschlüsselt gespeichert):</p>
<p style="margin-top:12px;">Upload a new image to replace it (will be stored encrypted):</p>
{% else %}
<p class="lastfm-description">Eigenes Hintergrundbild hochladen (JPG, PNG oder WebP, höchstens 5 MB). Es wird Ende-zu-Ende-verschlüsselt gespeichert.</p>
<p class="lastfm-description">Upload a custom background image (JPG, PNG or WebP, max 5 MB). Stored end-to-end encrypted.</p>
{% endif %}
<div style="margin-top:8px; display:flex; align-items:center; gap:10px;">
<label class="btn" for="bg-upload-input">Datei auswählen</label>
<label class="btn" for="bg-upload-input">Choose file</label>
<input type="file" id="bg-upload-input" accept=".jpg,.jpeg,.png,.webp" style="display:none;" onchange="uploadBackground(this)">
<span id="bg-upload-status" style="font-size:0.85rem; color:#888;"></span>
</div>
@ -70,11 +70,11 @@
<!-- Account section -->
<section class="settings-section">
<h2>Konto</h2>
<p>Angemeldet als <strong>{{ request.user.username }}</strong></p>
<h2>Account</h2>
<p>Logged in as <strong>{{ request.user.username }}</strong></p>
<details {% if password_form_open %}open{% endif %} style="margin-top:1rem;">
<summary class="btn" style="display:inline-block;cursor:pointer;">Passwort ändern</summary>
<summary class="btn" style="display:inline-block;cursor:pointer;">Change password</summary>
<form id="pw-change-form" method="post" action="{% url 'change_password' %}" style="margin-top:1rem; display:flex; flex-direction:column; gap:0.6rem; max-width:320px;">
{% csrf_token %}
{% for field in password_form %}
@ -90,13 +90,13 @@
<div class="message message-error" style="padding:4px 8px; font-size:0.8rem;">{{ password_form.non_field_errors|join:", " }}</div>
{% endif %}
<div id="pw-change-status" style="font-size:0.85rem; color:#888;"></div>
<div><button type="submit" class="btn">Speichern</button></div>
<div><button type="submit" class="btn">Save</button></div>
</form>
</details>
<form method="post" action="{% url 'logout' %}" class="inline-form" style="margin-top:1rem;">
{% csrf_token %}
<button type="submit" class="btn">Abmelden</button>
<button type="submit" class="btn">Logout</button>
</form>
</section>
@ -249,20 +249,20 @@ async function uploadBackground(input) {
const status = document.getElementById('bg-upload-status');
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
status.textContent = 'Nur JPEG-, PNG- oder WebP-Bilder sind erlaubt.';
status.textContent = 'Only JPEG, PNG, or WebP images are allowed.';
return;
}
if (file.size > 5 * 1024 * 1024) {
status.textContent = 'Das Bild darf höchstens 5 MB groß sein.';
status.textContent = 'Image must be 5 MB or smaller.';
return;
}
status.textContent = 'Verschlüssele …';
status.textContent = 'Encrypting…';
try {
const key = await _getOrCreateEncKey();
const buf = await file.arrayBuffer();
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, key, buf);
status.textContent = 'Lade hoch …';
status.textContent = 'Uploading…';
const res = await fetch('/accounts/background/upload/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
@ -270,10 +270,9 @@ async function uploadBackground(input) {
});
const data = await res.json();
if (data.ok) { location.reload(); }
else { status.textContent = data.error === 'only jpeg, png, or webp allowed' ? 'Nur JPEG, PNG oder WebP erlaubt.'
: data.error === 'file too large (max 5 MB)' ? 'Datei zu groß (höchstens 5 MB).' : (data.error || 'Hochladen fehlgeschlagen'); }
else { status.textContent = data.error || 'Upload failed'; }
} catch (e) {
status.textContent = 'Hochladen fehlgeschlagen: ' + e.message;
status.textContent = 'Upload failed: ' + e.message;
}
input.value = '';
}

View file

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="de">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@ -7,7 +7,7 @@
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="diora">
<meta name="description" content="Bücher, Podcasts und Radio">
<meta name="description" content="Internet radio player">
{% if user.is_authenticated %}<meta name="username" content="{{ user.username }}">{% endif %}
<link rel="manifest" href="/static/manifest.json">
<link rel="apple-touch-icon" href="/static/icon-192.png">
@ -22,17 +22,17 @@
<nav class="navbar">
<a href="/" class="navbar-brand">diora</a>
<div class="navbar-links">
<button class="btn-icon contrast-toggle" id="contrast-toggle" onclick="toggleContrast()" title="Kontrastmodus an/aus">◑</button>
<button class="btn-icon contrast-toggle" id="contrast-toggle" onclick="toggleContrast()" title="Toggle contrast mode">◑</button>
{% if user.is_authenticated %}
<span class="navbar-user">{{ user.username }}</span>
<a href="{% url 'settings' %}">Einstellungen</a>
<a href="{% url 'settings' %}">Settings</a>
<form method="post" action="{% url 'logout' %}" class="inline-form">
{% csrf_token %}
<button type="submit" class="btn-link">Abmelden</button>
<button type="submit" class="btn-link">Logout</button>
</form>
{% else %}
<a href="{% url 'login' %}">Anmelden</a>
<a href="{% url 'register' %}">Registrieren</a>
<a href="{% url 'login' %}">Login</a>
<a href="{% url 'register' %}">Register</a>
{% endif %}
</div>
</nav>

View file

@ -1,74 +1,71 @@
{% extends "base.html" %}
{% block title %}diora — Radio{% endblock %}
{% block title %}diora — radio player{% endblock %}
{% block content %}
<!-- ===== NOW PLAYING BAR ===== -->
<section class="now-playing-bar" id="now-playing-bar">
<div class="now-playing-info">
<span class="now-playing-station" id="now-playing-station">— kein Sender —</span>
<span class="now-playing-station" id="now-playing-station">— no station —</span>
<span class="now-playing-track" id="now-playing-track"></span>
</div>
<div class="now-playing-controls">
<button class="btn btn-play" id="play-stop-btn" onclick="togglePlayStop()" style="display:none;">&#9654; Abspielen</button>
<button class="btn btn-play" id="play-stop-btn" onclick="togglePlayStop()" style="display:none;">&#9654; Play</button>
<label class="volume-label">
<span>Lautst.</span>
<span>vol</span>
<input type="range" id="volume" min="0" max="255" value="102" class="volume-slider">
<input type="number" id="volume-num" min="0" max="255" value="102" class="volume-num">
</label>
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">&#9733; Speichern</button>
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">&#9733; Save</button>
<button class="btn-icon" id="creamfresh-vote-up-btn" style="display:none;" onclick="creamfreshVote('up')" title="Gefällt mir">&#128077;</button>
<button class="btn-icon" id="creamfresh-vote-down-btn" style="display:none;" onclick="creamfreshVote('down')" title="Gefällt mir nicht">&#128078;</button>
<button class="btn-icon" id="dnd-btn" onclick="toggleDND()" title="Fokusmodus (blendet die Oberfläche aus, Esc beendet ihn)">⊙</button>
<button class="btn-icon" id="dnd-btn" onclick="toggleDND()" title="Focus mode (hides UI, press Esc to exit)">⊙</button>
<button class="btn-icon" id="focus-station-btn" onclick="openRadioSidebar()" title="Radio">◉</button>
</div>
<div class="timer-widget" id="timer-widget">
<span class="timer-phase" id="timer-phase-label">Fokus</span>
<span class="timer-phase" id="timer-phase-label">focus</span>
<span class="timer-display" id="timer-display">25:00</span>
<button class="btn-icon" id="timer-toggle-btn" onclick="toggleTimer()" title="Timer starten/anhalten">▶</button>
<button class="btn-icon" id="timer-reset-btn" onclick="resetTimer()" title="Timer zurücksetzen">↺</button>
<button class="btn-icon" id="timer-toggle-btn" onclick="toggleTimer()" title="Start/pause timer">▶</button>
<button class="btn-icon" id="timer-reset-btn" onclick="resetTimer()" title="Reset timer">↺</button>
<span class="focus-today" id="focus-today-widget" style="display:none;"></span>
<button class="btn-icon dnd-only" id="dnd-light-btn" onclick="toggleDNDLight()" title="Schwarzer Hintergrund an/aus">☼</button>
<button class="btn-icon dnd-only" id="dnd-light-btn" onclick="toggleDNDLight()" title="Toggle black background">☼</button>
</div>
</section>
<!-- ===== AFFILIATE / TRACK INFO ===== -->
<section class="affiliate-section" id="affiliate-section" style="display:none;"{% if not amazon_enabled %} data-disabled="true"{% endif %}>
<img class="affiliate-artwork" id="affiliate-artwork" src="" alt="Albumcover">
<img class="affiliate-artwork" id="affiliate-artwork" src="" alt="Album art">
<div class="affiliate-info">
<div class="affiliate-track" id="affiliate-track-name"></div>
<div class="affiliate-artist" id="affiliate-artist-name"></div>
<div class="affiliate-album" id="affiliate-album-name"></div>
<a class="btn btn-amazon" id="affiliate-amazon-link" href="#" target="_blank" rel="noopener noreferrer">
Bei Amazon Music kaufen
Buy on Amazon Music
</a>
</div>
</section>
<!-- ===== TABS ===== -->
<div class="tabs" id="tabs">
{# Order = app.js TOP_TABS. Home needs an account: hidden, but kept in place for the order. #}
<button class="tab-btn" onclick="showTab('home')"{% if not user.is_authenticated %} style="display:none;"{% endif %}>Home</button>
<button class="tab-btn active" onclick="showTab('radio')">Radio</button>
<button class="tab-btn" onclick="showTab('focus')">Fokus</button>
<button class="tab-btn" onclick="showTab('focus')">Focus</button>
<button class="tab-btn" onclick="showTab('podcasts')">Podcasts</button>
<button class="tab-btn" onclick="showTab('books')">Bücher</button>
<button class="tab-btn" onclick="showTab('ambient')">Ambient</button>
<button class="tab-btn" onclick="showTab('books')">Books</button>
</div>
<!-- ===== RADIO TAB ===== -->
<section class="tab-panel" id="tab-radio">
<div class="tabs sub-tabs" id="radio-sub-tabs">
<button class="tab-btn" onclick="showRadioTab('search')">Suche</button>
<button class="tab-btn active" onclick="showRadioTab('saved')">Gespeichert</button>
<button class="tab-btn" onclick="showRadioTab('history')">Verlauf</button>
<button class="tab-btn" onclick="showRadioTab('search')">Search</button>
<button class="tab-btn active" onclick="showRadioTab('saved')">Saved</button>
<button class="tab-btn" onclick="showRadioTab('history')">History</button>
</div>
<!-- ===== SEARCH SUB-PANEL ===== -->
<div class="sub-tab-panel" id="tab-search" style="display:none;">
<div class="search-bar">
<input type="text" id="search-input" class="search-input" placeholder="Auf radio-browser.info suchen … oder Stream-Adresse einfügen" onkeydown="if(event.key==='Enter') doSearch()">
<button class="btn" onclick="doSearch()">Suchen</button>
<input type="text" id="search-input" class="search-input" placeholder="Search radio-browser.info… or paste a stream address" onkeydown="if(event.key==='Enter') doSearch()">
<button class="btn" onclick="doSearch()">Search</button>
</div>
<div class="mood-chips" id="mood-chips"></div>
<div id="curated-lists" class="curated-lists-container"></div>
@ -78,7 +75,7 @@
<tr>
<th>Name</th>
<th>Bitrate</th>
<th>Land</th>
<th>Country</th>
<th>Tags</th>
<th></th>
</tr>
@ -91,7 +88,7 @@
<div class="sub-tab-panel" id="tab-saved">
{% if featured_stations %}
<div class="featured-section">
<p class="featured-label">&#9733; Empfohlen</p>
<p class="featured-label">&#9733; Featured</p>
<ul class="featured-list">
{% for s in featured_stations %}
<li>
@ -110,8 +107,8 @@
<!-- populated by JS -->
</div>
<div class="import-bar">
<label class="btn btn-sm" for="m3u-file-input" title=".m3u / .m3u8 aus der Desktop-App importieren">
&#8679; M3U importieren
<label class="btn btn-sm" for="m3u-file-input" title="Import .m3u / .m3u8 from the desktop app">
&#8679; Import M3U
</label>
<input type="file" id="m3u-file-input" accept=".m3u,.m3u8" style="display:none;" onchange="importM3U(this)">
<span id="import-status" class="muted"></span>
@ -119,11 +116,11 @@
<table class="data-table" id="saved-table">
<thead>
<tr>
<th title="Favorit">&#9733;</th>
<th title="Favorite">&#9733;</th>
<th>Name</th>
<th>Bitrate</th>
<th>Land</th>
<th title="Notizen">✎</th>
<th>Country</th>
<th title="Notes">✎</th>
<th></th>
<th></th>
</tr>
@ -134,7 +131,7 @@
<td>
<button class="btn-icon fav-btn {% if station.is_favorite %}active{% endif %}"
onclick="toggleFav({{ station.id }})"
title="Favorit an/aus">&#9733;</button>
title="Toggle favorite">&#9733;</button>
</td>
<td class="station-name-cell">{{ station.name }}</td>
<td>{{ station.bitrate }}</td>
@ -143,25 +140,25 @@
<td>
<button class="btn btn-sm"
onclick="playStation('{{ station.url }}', '{{ station.name|escapejs }}', {{ station.id }})">
&#9654; Abspielen
&#9654; Play
</button>
</td>
<td>
<button class="btn btn-sm btn-danger"
onclick="removeStation({{ station.id }})">
Entfernen
Remove
</button>
</td>
</tr>
{% empty %}
<tr id="saved-empty-row"><td colspan="7" class="empty-msg">Noch keine gespeicherten Sender.</td></tr>
<tr id="saved-empty-row"><td colspan="7" class="empty-msg">No saved stations yet.</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="auth-prompt">
<a href="{% url 'login' %}">Anmelden</a> oder <a href="{% url 'register' %}">registrieren</a>,
um Sender zu speichern und zwischen Geräten abzugleichen.
<a href="{% url 'login' %}">Log in</a> or <a href="{% url 'register' %}">register</a>
to save stations and sync across devices.
</p>
{% endif %}
</div>
@ -171,9 +168,9 @@
<table class="data-table" id="history-table">
<thead>
<tr>
<th>Zeit</th>
<th>Sender</th>
<th>Titel</th>
<th>Time</th>
<th>Station</th>
<th>Track</th>
<th>&#9836;</th>
<th></th>
</tr>
@ -184,11 +181,11 @@
<td class="history-time">{{ entry.played_at|slice:":16"|cut:"T" }}</td>
<td>{{ entry.station_name }}</td>
<td>{{ entry.track }}</td>
<td>{% if entry.scrobbled %}<span title="An Last.fm gescrobbelt">&#10003;</span>{% endif %}</td>
<td><button class="btn-delete-history" onclick="deleteHistoryEntry({{ entry.id }}, this)" title="Entfernen">✕</button></td>
<td>{% if entry.scrobbled %}<span title="Scrobbled to Last.fm">&#10003;</span>{% endif %}</td>
<td><button class="btn-delete-history" onclick="deleteHistoryEntry({{ entry.id }}, this)" title="Remove">✕</button></td>
</tr>
{% empty %}
<tr id="history-empty-row"><td colspan="5" class="empty-msg">Noch kein Verlauf.</td></tr>
<tr id="history-empty-row"><td colspan="5" class="empty-msg">No history yet.</td></tr>
{% endfor %}
</tbody>
</table>
@ -200,13 +197,13 @@
<table class="data-table" id="focus-table">
<thead>
<tr>
<th>Zeit</th>
<th>Sender</th>
<th>Dauer</th>
<th>Time</th>
<th>Station</th>
<th>Duration</th>
</tr>
</thead>
<tbody id="focus-tbody">
<tr><td colspan="3" class="empty-msg">Noch keine Fokus-Sitzungen. Starte den Timer!</td></tr>
<tr><td colspan="3" class="empty-msg">No focus sessions yet. Start the timer!</td></tr>
</tbody>
</table>
</section>
@ -218,8 +215,8 @@
<p class="muted dpw-loading">Podcasts werden geladen …</p>
{% else %}
<p class="auth-prompt">
<a href="{% url 'login' %}">Anmelden</a> oder <a href="{% url 'register' %}">registrieren</a>,
um Podcasts zu abonnieren.
<a href="{% url 'login' %}">Log in</a> or <a href="{% url 'register' %}">register</a>
to subscribe to podcasts.
</p>
{% endif %}
</section>
@ -229,7 +226,7 @@
{% if user.is_authenticated %}
<div id="book-upload-area">
<div class="book-drop-zone" id="book-drop-zone">
<span>.epub oder .pdf hierher ziehen oder <label for="book-file-input" style="cursor:pointer;text-decoration:underline;">auswählen</label></span>
<span>Drop .epub or .pdf here or <label for="book-file-input" style="cursor:pointer;text-decoration:underline;">browse</label></span>
<input type="file" id="book-file-input" accept=".epub,.pdf" style="display:none;" onchange="bookFileSelected(this)">
<span id="book-upload-status" class="muted"></span>
</div>
@ -251,24 +248,12 @@
<div id="book-list" class="book-list"></div>
{% else %}
<p class="auth-prompt">
<a href="{% url 'login' %}">Anmelden</a> oder <a href="{% url 'register' %}">registrieren</a>,
um den verschlüsselten E-Book-Reader zu nutzen.
<a href="{% url 'login' %}">Log in</a> or <a href="{% url 'register' %}">register</a>
to use the encrypted ebook reader.
</p>
{% endif %}
</section>
<!-- ===== HOME TAB ===== -->
{% comment %}Filled by static/js/home.js: the book in reading, the next episode, stations, Ambient.{% endcomment %}
<section class="tab-panel" id="tab-home" style="display:none;"></section>
<!-- ===== AMBIENT TAB ===== -->
{% comment %}"Drift", a generative ambient synthesizer (static/ambient/), in a frame of its own: its
script and styles stay apart from the page's. Loaded the first time the tab opens.{% endcomment %}
<section class="tab-panel" id="tab-ambient" style="display:none;">
<iframe id="ambient-frame" class="ambient-frame" title="Ambient" allow="autoplay; clipboard-write"
data-src="/static/ambient/index.html?embed=1&amp;bg=%23000000"></iframe>
</section>
<!-- ===== READER OVERLAY ===== -->
<div id="reader-overlay" class="reader-overlay" style="display:none;">
<div class="reader-header">
@ -278,30 +263,30 @@
<input type="text" inputmode="decimal" id="reader-progress-input" class="volume-num" value="0" style="display:none;">
<span id="reader-progress-suffix" class="muted"></span>
</span>
<button class="btn-icon" id="reader-search-btn" onclick="toggleReaderSearch()" title="Suchen">⌕</button>
<button class="btn-icon" id="reader-settings-btn" onclick="toggleSettingsPanel()" title="Schrift &amp; Layout">⚙</button>
<button class="btn-icon" id="reader-search-btn" onclick="toggleReaderSearch()" title="Search">⌕</button>
<button class="btn-icon" id="reader-settings-btn" onclick="toggleSettingsPanel()" title="Font &amp; layout">⚙</button>
<select id="reader-tts-lang" class="tts-lang-select" title="Vorlese-Sprache" onchange="setTtsLang(this.value)">
<option value="de">DE</option>
<option value="en">EN</option>
</select>
<button class="btn-icon" id="reader-tts-btn" onclick="toggleReadAloud()" title="Vorlesen">▶</button>
<button class="btn-icon" id="reader-bookmark-btn" onclick="addBookmark()" title="Lesezeichen setzen">★</button>
<button class="btn-icon" id="reader-bm-list-btn" onclick="openBookmarksSidebar()" title="Lesezeichen">▤</button>
<button class="btn-icon" id="reader-toc-btn" onclick="openTocSidebar()" title="Inhaltsverzeichnis">≡</button>
<button class="btn-icon" id="reader-margin-btn" onclick="toggleAnnotationsMargin()" title="Notizen &amp; Markierungen">✎</button>
<button class="btn-icon" id="reader-bookmark-btn" onclick="addBookmark()" title="Bookmark">★</button>
<button class="btn-icon" id="reader-bm-list-btn" onclick="openBookmarksSidebar()" title="Bookmarks">▤</button>
<button class="btn-icon" id="reader-toc-btn" onclick="openTocSidebar()" title="Table of contents">≡</button>
<button class="btn-icon" id="reader-margin-btn" onclick="toggleAnnotationsMargin()" title="Notes &amp; highlights">✎</button>
<button class="btn-icon marker-mode-btn reader-marker-btn-mobile" id="reader-marker-btn-mobile" onclick="toggleMarkerMode()" title="Text markieren">✒</button>
<button class="btn-icon" id="reader-reset-pos-btn" onclick="saveReaderProgress(true)" title="Diese Position als Lesefortschritt setzen (überschreibt gespeicherten Fortschritt)">⚑</button>
<button class="btn-icon" onclick="closeReader()" title="Schließen (Esc)">✕</button>
<button class="btn-icon" onclick="closeReader()" title="Close (Esc)">✕</button>
</div>
</div>
<div id="reader-body-row" class="reader-body-row">
<aside id="reader-margin" class="reader-margin">
<div class="reader-margin-header">
<span class="reader-margin-title">Notizen</span>
<span class="reader-margin-title">Notes</span>
<span class="reader-margin-header-actions">
<button class="btn-icon marker-mode-btn" id="reader-marker-btn" onclick="toggleMarkerMode()" title="Text markieren">✒</button>
<button class="btn-icon" id="reader-margin-list-btn" onclick="openAnnotationsSidebar()" title="Alle Markierungen &amp; Notizen auflisten und hinspringen">▦</button>
<button class="btn-icon" id="reader-margin-export-btn" onclick="exportAnnotations()" title="Als Textdatei exportieren">⭳</button>
<button class="btn-icon marker-mode-btn" id="reader-marker-btn" onclick="toggleMarkerMode()" title="Mark text">✒</button>
<button class="btn-icon" id="reader-margin-list-btn" onclick="openAnnotationsSidebar()" title="List all highlights &amp; notes, jump to any of them">▦</button>
<button class="btn-icon" id="reader-margin-export-btn" onclick="exportAnnotations()" title="Export as text file">⭳</button>
</span>
</div>
<div id="reader-margin-markers" class="reader-margin-markers"></div>
@ -315,7 +300,7 @@
<aside id="sidebar" class="sidebar">
<div class="sidebar-header">
<span id="sidebar-title" class="sidebar-title"></span>
<button class="btn-icon sidebar-close" onclick="closeSidebar()" title="Schließen">✕</button>
<button class="btn-icon sidebar-close" onclick="closeSidebar()" title="Close">✕</button>
</div>
<div id="sidebar-body" class="sidebar-body"></div>
</aside>
@ -326,7 +311,7 @@
<p id="modal-message" class="modal-message"></p>
<input type="text" id="modal-input" class="search-input modal-input" style="display:none;">
<div class="modal-actions">
<button type="button" id="modal-cancel-btn" class="btn" style="display:none;">Abbrechen</button>
<button type="button" id="modal-cancel-btn" class="btn" style="display:none;">Cancel</button>
<button type="button" id="modal-ok-btn" class="btn btn-primary">OK</button>
</div>
</div>
@ -344,5 +329,4 @@
</script>
<script src="/static/js/app.js"></script>
<script src="/static/js/podcasts-web.js"></script>
<script src="/static/js/home.js"></script>
{% endblock %}

View file

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="de">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@ -80,15 +80,15 @@
<div id="track-name"></div>
<div class="controls">
<button id="play-btn">&#9654; Abspielen</button>
<button id="play-btn">&#9654; Play</button>
<div class="vol-wrap">
<span>Lautst.</span>
<span>vol</span>
<input type="range" id="vol-slider" min="0" max="255" value="{{ stream_vol }}">
<input type="number" id="vol-num" min="0" max="255" value="{{ stream_vol }}">
</div>
</div>
<button id="back-btn" onclick="window.close()">&#8592; Tab schließen</button>
<button id="back-btn" onclick="window.close()">&#8592; close tab</button>
<script>
const audio = new Audio();
@ -120,7 +120,7 @@
setVol(parseInt(slider.value, 10));
audio.play().then(() => {
playing = true;
playBtn.innerHTML = '&#9646;&#9646; Stopp';
playBtn.innerHTML = '&#9646;&#9646; Stop';
if (sse) sse.close();
sse = new EventSource('/radio/sse/?url=' + encodeURIComponent(streamUrl));
@ -137,8 +137,8 @@
// Autoplay blocked — reset state, prompt user to click
playing = false;
audio.src = '';
playBtn.innerHTML = '&#9654; Abspielen';
document.getElementById('track-name').textContent = 'Zum Starten auf Abspielen tippen';
playBtn.innerHTML = '&#9654; Play';
document.getElementById('track-name').textContent = 'Click Play to start';
});
}
@ -146,7 +146,7 @@
audio.pause();
audio.src = '';
playing = false;
playBtn.innerHTML = '&#9654; Abspielen';
playBtn.innerHTML = '&#9654; Play';
document.getElementById('track-name').textContent = '';
document.title = stationName;
if (sse) { sse.close(); sse = null; }
@ -156,7 +156,7 @@
if (playing) stopPlay(); else startPlay();
});
document.getElementById('track-name').textContent = 'Zum Starten auf Abspielen tippen';
document.getElementById('track-name').textContent = 'Click Play to start';
</script>
</body>
</html>