Compare commits

..

19 commits

Author SHA1 Message Date
8b9c28cda1 Home: neue Startseite mit Weiterlesen, Weiterhören, Radio-Favoriten und Ambient; sw v87
Some checks failed
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Has been cancelled
- static/js/home.js rendert #tab-home aus dem, was die Seite schon hat:
  zuletzt gelesenes Buch (und drei weitere), laufende oder nächste Folge mit
  Restzeit und „N neue Folgen“, Favoriten-Sender (sonst die ersten
  gespeicherten) und zuletzt gehörter Sender, Drift mit der zuletzt
  gewählten Klangwelt und Sleep-Timer. Läuft im Browser, in diora desktop und
  in der Android-App (dort mit static/js/ kopiert).
- app.js: Tab 'home' (TOP_TABS), window.__dioraAmbientStart() lädt Drift bei
  Bedarf und startet es; die gewählte Klangwelt wird gemerkt und beim Laden
  wiederhergestellt.
- podcasts-web.js: Startseite Home, wenn nichts anderes gewählt ist.
- native-podcast.js aus diora-android 7dcc0c6 (Home in der Startseiten-Wahl,
  __dioraPodcastView).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:34:02 +02:00
ae72b1d67a Stille-Suche: Folge erst in Python laden, ffmpeg liest die lokale Datei
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 7m4s
Die statische ffmpeg-Build stürzt im Container beim Auflösen von Hostnamen ab (SIGSEGV).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:45:19 +02:00
c1bbaccccf Stille überspringen im Browser/Desktop (Server findet die Pausen); Ambient-Rekorder im Audio-Thread; sw v86
Some checks failed
Build and push Docker image / build (push) Successful in 52s
Test / test (push) Has been cancelled
- podcasts/silence.py: ffmpeg (imageio-ffmpeg) findet die Pausen einer Folge einmal
  (silencedetect -35 dB, ab 0,4 s), im Hintergrund, höchstens 2 zugleich; gespeichert in
  PodcastEpisode.silences (Migration 0018); GET episodes/<id>/silences/
- podcasts-web.js: skip_silence als Einstellung (Gerät, pro Podcast vom Server), springt
  beim Abspielen über die Pausen (0,15 s bleiben)
- Drift: Ringpuffer für die Körner als AudioWorklet statt ScriptProcessor im Hauptthread
  (gehäufte Aussetzer am Desktop, wenn die Seite kurz stockte)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:38:06 +02:00
6cc62fd4d1 Podcasts: Stille-Schalter im Tempo-Menü, Leiste folgt dem, was spielt, podcastPause; sw v85
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 6m58s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:25:27 +02:00
45e498eb87 Ambient-Musik auf Telefonen leichter gegen Aussetzer; sw v84
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 6m57s
Auf dem S21 lagen App- und Renderer-Prozess bei je ~105 % CPU. Auf Telefonen jetzt:
höchstens 10 Stimmen zugleich, eine Säge und ein Wavetable-Zyklus pro Stimme, FM nur
deutlich, keine offline gerenderten Rückwärts-Swells (Rechenspitzen), Bild in halber
Auflösung mit ~20 Bildern/s und einfachen Partikeln.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:42:45 +02:00
14720b3669 Feeds abrufen: unveränderte Feeds nicht mehr parsen, ⟳ antwortet nach höchstens 40 s; sw v83
All checks were successful
Build and push Docker image / build (push) Successful in 17s
Test / test (push) Successful in 6m59s
Der stündliche Abruf der 502 Feeds dauerte 114 s, fast nur Parsen: viele Server
ignorieren ETag/Last-Modified und schicken jedes Mal den ganzen Feed (Lage der Nation
8,4 MB = 5,9 s). ⟳ in der App lief damit in ihr 60-s-Zeitlimit und schlug fehl.

- Fingerabdruck des Feed-Inhalts (ohne lastBuildDate/Kommentare, http_body_hash,
  Migration 0017): derselbe Inhalt zählt als unverändert und wird nicht geparst
- _refresh_feeds: ein Pool für alle Feeds (16 parallel), jeder Feed wird geschrieben,
  sobald er da ist, optionales Zeitlimit
- refresh-all wartet höchstens 40 s (done/left in der Antwort); der Browser-Host
  holt den Stand dann eine Minute später noch einmal, und alle 15 Minuten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:22:01 +02:00
986ccad5c9 Ambient: Zugfahrt – zweiter Klack jedes Paares 2 Halbtöne höher; sw v82
All checks were successful
Build and push Docker image / build (push) Successful in 12s
Test / test (push) Successful in 6m50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:28:44 +02:00
356a5299da Ambient: Zugfahrt „klack-klack … klack-klack“ (Drift v8, Diff eingespielt); sw v81
Some checks failed
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Has been cancelled
Zwei Achsen kurz nacheinander (3,1 m), 33 m Schiene bis zum nächsten Paar, dumpferer
Anschlag, Karosserie klingt mit zwei Moden nach. Dioras eigene Drift-Änderungen bleiben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:24:43 +02:00
6e28312947 Ambient in der Player-Leiste; weniger Aussetzer bei den Klangwelten auf Telefonen; sw v80
Some checks failed
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Has been cancelled
- app.js: __dioraAmbientInfo (spielt, zuletzt gestartet, Klangwelt), meldet Moduswechsel
  an die Leiste; Radio/Folge übernimmt sie wieder
- Drift (Telefone): Musik-Bett, Delay und Hall werden vom Ausgang getrennt, wenn eine
  Klangwelt ohne sie läuft (Braunes Rauschen …); Zugfahrt und Herzschlag planen 2,5 s voraus

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:18:06 +02:00
2c64342c53 Ambient: leichter Modus für Telefone gegen Ruckeln; sw v79
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 6m54s
Auf dem S21 lag die App mit Drift bei ~185 % CPU, der Ton stockte. Auf Telefonen jetzt:
Bild in einfacher Auflösung und ~30 Bilder/s, 3,5 s statt 7 s Hall, kein Oversampling,
kein ScriptProcessor (Körner) im Hauptthread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:09:28 +02:00
bd9193c869 Ambient: Frame misst sich neu, wenn die Leiste unten wächst oder schrumpft; Bedienfeld 960 px; sw v78
Some checks failed
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:07:07 +02:00
16a1e23fcc Ambient: Ton auf Android über den normalen Medienweg; dioras Lautstärke gilt für Drift; sw v77
Some checks failed
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Has been cancelled
- Drift: AudioContext mit latencyHint 'playback' – der Niedriglatenz-Weg (AAudio/MMAP)
  blieb auf dem Samsung des Nutzers stumm, obwohl Drift lief
- Drift: cmd 'volume' (0..1) von der Seite ist die Lautstärke; eingebettet ist der eigene
  Regler ausgeblendet
- app.js: setVolume() und das Laden des Frames schicken die Lautstärke an Drift (nicht in
  der Android-App: dort gelten die Lautstärketasten)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:01:03 +02:00
3ff998f9cf Ambient: Drift v4 – Bach klingt nach Bach (glucksende Bläschengruppen), Bedienfeld auf breiten Fenstern zentriert; sw v76
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 6m52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:41:48 +02:00
0fbb948c1a Ambient: Drift v3 – 23 Klangwelten (Rauschfarben, Natur, Technik, Fokus & Schlaf); sw v75
Some checks failed
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Has been cancelled
Aus der Session "Ambient-Synthesizer-Webapp": Ambient-Musik ist jetzt ein Modus neben
weißem/pinkem/braunem/blauem/violettem/grauem Rauschen, Regen, Gewitter, Meer, Wind, Bach,
Lagerfeuer, Sommernacht, Ventilator, Flugzeugkabine, Zugfahrt, binauralen Beats,
Klangschalen und Herzschlag. Alles live synthetisiert, offline; Schnittstelle zu diora
unverändert (neu: cmd 'mode').

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:37:09 +02:00
794dbadbf5 Ambient: Sleep-Timer zählt und stoppt Drift (Browser und App), ☾ im Ambient-Tab; sw v74
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 6m55s
- app.js: __dioraAmbientCmd, meldet Start/Stopp an die App (DioraNative.ambientState,
  sie hält Drift bei ausgeschaltetem Bildschirm am Laufen) und an den Browser-Host
- podcasts-web.js: der Sleep-Timer zählt auch, solange Ambient spielt, stoppt es beim
  Ablauf; Nachtautomatik auch beim Ambient-Start

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:22:00 +02:00
b7a81f3e26 Neuer Tab „Ambient“: Drift, ein generativer Ambient-Synthesizer; sw v73
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 6m55s
- static/ambient/index.html: Drift (aus der Session "Ambient-Synthesizer-Webapp",
  eine Datei, Web Audio, offline), eingebettet per iframe (?embed=1&bg=#000)
- app.js: Tab 'ambient', iframe lädt beim ersten Öffnen und füllt den Platz bis
  zur Leiste; Radio/Podcast und Ambient wechseln sich ab (postMessage
  {type:'ambient', cmd|state})
- sw.js: Ambient im Offline-Cache (ignoreSearch für die Shell-Dateien)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:08:57 +02:00
64e9d70647 Podcasts: mehrere Folgen nacheinander wegwischen, während „Rückgängig“ steht; sw v72
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 6m51s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:40:39 +02:00
c522b22cd9 Oberfläche eingedeutscht: Radio, Bücher/Reader, Fokus, Konto, Anmeldung; sw v71
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 6m56s
- app.js, Templates, Stream-Fenster, Manifest: alle sichtbaren Texte deutsch
  (Eingabefenster "Abbrechen", Markierungs-Export, Lesezeichen, Reader-Einstellungen …)
- LANGUAGE_CODE 'de': Formular-, Passwort- und Validierungsmeldungen von Django deutsch
- Empfehlungen: "Was du unter der Woche abends oft hörst:"
- Fehlercodes der API bleiben englisch (Vertrag für Clients und Tests); was die
  Seite davon zeigt, übersetzt _germanError()

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:53:01 +02:00
f12451f30e Sleep-Timer: eigene Zeit, Nachtfenster, auch fürs Radio, von anderen Geräten setzen; sw v70
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 6m54s
- Player-Sync: Aktion 'sleep' (Minuten, -n Folgen, 0 aus); Reports schicken
  sleep {left_ms, episodes} mit, der State zeigt ihn (Migration 0016)
- Browser-Host: Nachtfenster sleep_from/sleep_to, Automatik auch beim
  Radiostart (auch in diora desktop), Timer des anderen Geräts zählt mit,
  ein Timer, der stundenlang nicht lief, verfällt
- Eingabefenster (customPrompt) liegt über Player und Menüs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:59:16 +02:00
29 changed files with 2999 additions and 375 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"Could not connect to Last.fm: {exc}",
'lastfm_error': f"Verbindung zu Last.fm fehlgeschlagen: {exc}",
})

View file

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

View file

@ -0,0 +1,23 @@
# 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

@ -0,0 +1,18 @@
# 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

@ -0,0 +1,18 @@
# 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,6 +36,10 @@ 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)
@ -154,6 +158,9 @@ 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')
@ -347,6 +354,10 @@ 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):

113
podcasts/silence.py Normal file
View file

@ -0,0 +1,113 @@
"""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,6 +257,34 @@ 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})
self.assertEqual(r.json(), {'ok': True, 'refreshed': 1, 'failed': 1, 'new_episodes': 1, 'done': True, 'left': 0})
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,6 +315,27 @@ 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):
@ -1491,6 +1512,25 @@ 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'}))
@ -2628,3 +2668,57 @@ 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,6 +19,7 @@ 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,10 +1,12 @@
import hashlib
import json
import logging
import math
import re
import time
import urllib.parse
import uuid
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout, as_completed
from dataclasses import dataclass
from datetime import timedelta, timezone as dt_timezone
import xml.etree.ElementTree as ET
@ -23,6 +25,7 @@ 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 (
@ -37,7 +40,8 @@ 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 = 8 # feeds downloaded at the same time
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
MAX_DESCRIPTION = 50_000 # characters of shownotes HTML kept per episode
MAX_TRANSCRIPTS = 10
@ -302,14 +306,27 @@ 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=''):
def _fetch_feed(url, etag='', last_modified='', body_hash=''):
"""Download and parse a feed (with a timeout, unlike feedparser's own
fetch). With etag/last_modified it is a conditional GET. Returns a
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
FeedFetch; raises FeedFetchError when there is nothing usable."""
headers = {'User-Agent': 'diora podcast refresh'}
if etag:
@ -342,6 +359,10 @@ def _fetch_feed(url, etag='', last_modified=''):
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')
@ -352,7 +373,7 @@ def _fetch_feed(url, etag='', last_modified=''):
# 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,
r.url, permanent_url, new_hash,
)
@ -361,13 +382,14 @@ 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) for f in feeds}
pairs = {(f.http_etag, f.http_last_modified, f.http_body_hash) for f in feeds}
if len(pairs) != 1 or any(f.last_refreshed_at is None for f in feeds):
return {}
etag, last_modified = pairs.pop()
if not (etag or last_modified):
return {}
return {'etag': etag, 'last_modified': last_modified}
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
def _error_text(e):
@ -425,20 +447,21 @@ AUDIO_EXTENSIONS = ('.mp3', '.m4a', '.m4b', '.ogg', '.oga', '.opus', '.aac', '.f
VIDEO_EXTENSIONS = ('.mp4', '.m4v', '.mov', '.webm')
def _refresh_feeds(feeds, chunk=32, full=False):
def _refresh_feeds(feeds, full=False, deadline=None):
"""Refresh many feeds at once: the downloads run in parallel and each
feed URL is fetched once, even when several users subscribed to it; the
database writes then happen one by one. Returns (refreshed, failed, new).
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).
`full`: download every feed again (no 304) and take in episodes the
reader skipped before (see _apply_feed)."""
reader skipped before (see _apply_feed). `deadline` (time.monotonic()):
stop there; feeds not done by then stay as they are."""
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 part:
for f in feeds:
by_url.setdefault(f.rss_url, []).append(f)
results = {}
if not by_url:
return 0, 0, 0
def fetch(url):
try:
@ -446,13 +469,13 @@ def _refresh_feeds(feeds, chunk=32, full=False):
except Exception as e: # network, HTTP or parser trouble: report and go on
return url, None, e
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]
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]:
if err is not None:
failed += 1
log.warning('refreshing %s failed: %s', feed.rss_url, err)
@ -464,6 +487,10 @@ def _refresh_feeds(feeds, chunk=32, full=False):
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
@ -681,8 +708,9 @@ 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', *ERROR_FIELDS])
feed_obj.save(update_fields=['http_etag', 'http_last_modified', 'http_body_hash', *ERROR_FIELDS])
_move_to_permanent_url(feed_obj, fetched.permanent_url)
return new_count
@ -1142,6 +1170,24 @@ 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):
@ -1557,7 +1603,9 @@ 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."""
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."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
@ -1569,9 +1617,11 @@ 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 = 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})
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})
@csrf_exempt
@ -2127,7 +2177,9 @@ 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')
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_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
@ -2237,6 +2289,7 @@ 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),
}
@ -2311,6 +2364,13 @@ 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
@ -2327,6 +2387,8 @@ 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,
@ -2348,7 +2410,8 @@ 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."""
its place and so becomes the active one. sleep: its sleep timer, value =
minutes of playing time, -n = after n episodes, 0 = off."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
body, error = _player_body(request)
@ -2363,6 +2426,12 @@ 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('to subscribe to podcasts.', html)
self.assertIn('um Podcasts zu abonnieren.', html)
self.assertNotIn('dpw-loading', html)
def test_podcast_ui_files_are_there(self):

View file

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

View file

@ -17,3 +17,5 @@ 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

1374
static/ambient/index.html Normal file

File diff suppressed because it is too large Load diff

View file

@ -389,6 +389,15 @@ 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

311
static/js/home.js Normal file
View file

@ -0,0 +1,311 @@
/**
* 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,11 +164,15 @@
try {
if (fetchFeeds) {
if (!(await flush())) return false;
await fetch('/podcasts/feeds/refresh-all/', {
const rr = 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.
@ -429,7 +433,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}, load('prefs', {}));
sleep_last: 15, sleep_auto: false, sleep_from: 22, sleep_to: 6, skip_silence: false}, load('prefs', {}));
// Skip lengths and "counts as played": the account's where it has them, else this browser's.
function eff(name) {
@ -449,16 +453,23 @@
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; // skip_silence, headset_next: nothing a browser can do
return; // headset_next: nothing a browser can do
}
store('prefs', prefs);
}
// What the page shows as settings. No skip_silence / headset_next: the page leaves those out.
// What the page shows as settings. No headset_next: the page leaves it 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: {}};
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)};
}
function speedFor(feedId) {
@ -482,32 +493,57 @@
}
// ---- 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.leftMs <= 0 || !playing) return;
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;
// 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) {
@ -515,6 +551,8 @@
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));
@ -523,20 +561,40 @@
episodeFinished() {
if (this.episodes <= 0) return false;
this.episodes--;
if (this.episodes === 0) sleepChanged();
return this.episodes === 0;
},
queueEnded() { this.episodes = 0; },
// An episode starts playing: at night, the timer may switch itself on.
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.
maybeAutoEnable() {
if (this.active || !prefs.sleep_auto) return;
const hour = new Date().getHours();
if (hour >= 22 || hour < 6) this.set(prefs.sleep_last || 15, 0);
if (inSleepWindow(new Date().getHours(), sleepHour('sleep_from', 22), sleepHour('sleep_to', 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};
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)};
},
};
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
@ -882,14 +940,51 @@
if (playing && Date.now() - lastSaved > 10000) saveNow(id, true);
if (playing) skipOutro(id);
}
// The sleep timer counts while either plays (radio and podcasts share the player).
sleep.tick(playing || radioPlaying());
// The sleep timer counts while any of them plays (radio and podcasts share the player; Ambient).
sleep.tick(playing || radioPlaying() || ambientPlaying());
// 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() {
@ -920,6 +1015,7 @@
window.playStation = function () {
putAway();
const r = playStation.apply(this, arguments);
sleep.maybeAutoEnable();
radioChanged();
sync.start = true;
syncSoon();
@ -1087,11 +1183,22 @@
((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};
const o = {device: st.device_name || '', kind: st.kind, playing: !!st.playing, station: null, pos: 0, dur: 0,
sleep: remoteSleep(st)};
const radio = st.kind === 'radio';
if (radio) {
o.station = {name: st.station.name || '', url: st.station.url, logo: radioLogo(st.station)};
@ -1129,16 +1236,17 @@
return list;
}
// What this device plays, as a report has it.
// What this device plays, as a report has it (with the sleep timer).
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()};
position_ms: 0, playing: radioPlaying(), sleep: zz};
}
if (cur) {
return {kind: 'podcast', episode_id: cur.episode__id, position_ms: Math.max(0, Math.round(posMs())),
playing: wantPlay, speed: speedFor(curFeed())};
playing: wantPlay, speed: speedFor(curFeed()), sleep: zz};
}
return {kind: '', position_ms: 0, playing: false};
}
@ -1256,6 +1364,10 @@
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);
@ -1387,7 +1499,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') body.value = Math.round(Number(value) || 0);
if (action === 'seek' || action === 'skip' || action === 'sleep') 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)
@ -1587,6 +1699,7 @@
},
podcastNext: () => next(),
podcastToggle: () => (wantPlay ? pause() : resume()),
podcastPause: () => { if (wantPlay) pause(); },
podcastSeek: ms => seek(Number(ms)),
podcastSkip: forward => skip(!!forward),
podcastSpeed(speed) {
@ -1749,6 +1862,9 @@
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();
@ -1757,15 +1873,18 @@
// 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; none: the
// last used tab, the web's 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): Home
// unless chosen otherwise; '' = the last used tab, the web's old way. Opening diora shows
// it; reloading and back/forward keep the tab.
(function openStartPage() {
let start = '';
try { start = localStorage.getItem('nui_start_page') || ''; } catch (e) {}
let start = null;
try { start = localStorage.getItem('nui_start_page'); } catch (e) {}
if (start === null) start = document.getElementById('tab-home') ? 'home' : '';
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' : 'books';
const tab = start === 'queue' || start === 'inbox' ? 'podcasts' : start === 'radio' ? 'radio'
: start === 'home' && document.getElementById('tab-home') ? 'home' : 'books';
if (typeof _currentTopTab === 'undefined' || _currentTopTab !== tab) showTab(tab);
})();

View file

@ -2,15 +2,17 @@
* diora service worker — caches the app shell for offline use.
*/
const CACHE = 'diora-v69';
const CACHE = 'diora-v87';
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',
@ -77,7 +79,8 @@ self.addEventListener('fetch', function (event) {
const isShell = SHELL.some(function (s) { return url.pathname === s; });
if (isShell) {
event.respondWith(
caches.match(event.request).then(function (cached) {
// ignoreSearch: the Ambient frame loads with ?embed=1&bg=…
caches.match(event.request, {ignoreSearch: true}).then(function (cached) {
return cached || fetch(event.request);
})
);

View file

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

View file

@ -1,4 +1,4 @@
// Copied from diora-android ee1a910 2026-10-02: web-native/static/native-podcast.js
// Copied from diora-android 7dcc0c6 2026-10-04: 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,9 +34,11 @@
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;
const START_PAGES = [
!IN_APP && {id: '', label: 'Zuletzt benutzt', hint: 'Standard im Browser'},
{id: 'books', label: 'Bücher', hint: IN_APP ? 'Standard' : null},
// 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},
{id: 'queue', label: 'Podcasts: Warteschlange'},
{id: 'inbox', label: 'Podcasts: Neu'},
{id: 'radio', label: 'Radio'},
@ -44,7 +46,8 @@
function startPage() {
let v = null;
try { v = localStorage.getItem('nui_start_page'); } catch (e) {}
return START_PAGES.some(p => p.id === v) ? v : START_PAGES[0].id;
const pages = startPages();
return pages.some(p => p.id === v) ? v : pages[0].id;
}
const POSITIONS = [
{value: 'back', label: 'Ans Ende'},
@ -71,6 +74,8 @@
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) {}
@ -554,9 +559,9 @@
// Which page the app opens with (this device).
function startPageItem() {
const now = startPage();
const label = id => (START_PAGES.find(p => p.id === id) || START_PAGES[0]).label;
const label = id => (startPages().find(p => p.id === id) || startPages()[0]).label;
return {icon: '⌂', label: 'Startseite', hint: label(now) + (IN_APP ? ' · beim Öffnen der App' : ' · beim Öffnen von diora'),
action: () => openSheet('Beim Öffnen zeigen …', START_PAGES.filter(p => p.id !== 'radio' || !IN_APP ||
action: () => openSheet('Beim Öffnen zeigen …', startPages().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: () => {
@ -625,7 +630,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: () => '15 Minuten an / aus'},
{key: 'sleep', label: 'Sleep-Timer', hint: () => `${sleepMinutesLabel((player.sleep || {}).last || 15)} an / aus, auch beim Radio`},
];
function notifyItem() {
if (!N.podcastSetNotifyButtons || !Array.isArray(devicePrefs().notify_buttons)) return null;
@ -982,6 +987,9 @@
}
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();
@ -1174,6 +1182,7 @@
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;
@ -1217,6 +1226,11 @@
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;
@ -1257,7 +1271,9 @@
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';
commitSwipe();
// The swipe before stays undoable (its bar too) until this one is done (offerUndo).
holding = true;
swipingSince = Date.now();
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'));
@ -2785,7 +2801,8 @@
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 === 'radio' ? openRadioPlayer() : openPlayer()));
text.addEventListener('click', () => (miniKind === 'ambient' ? showTab('ambient')
: 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;
@ -2795,6 +2812,7 @@
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.
@ -2812,6 +2830,68 @@
}
}
// 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 = '';
@ -2835,10 +2915,14 @@
}
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;
@ -2852,7 +2936,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);
mini.querySelector('.nui-mini-track').textContent = remoteLine(r) + sleepSuffix();
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);
@ -2863,8 +2947,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';
mini.querySelector('.nui-mini-track').textContent = (radio.buffering ? 'Verbinde …'
: radio.playing ? (radio.track || '● Live') : 'Angehalten') + sleepSuffix();
const t = mini.querySelector('.nui-mini-toggle');
t.textContent = radio.playing ? '■' : '▶';
t.title = radio.playing ? 'Stopp' : 'Abspielen';
@ -2881,8 +2965,9 @@
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' : '');
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();
const t = mini.querySelector('.nui-mini-toggle');
t.textContent = player.playing ? '❚❚' : '▶';
t.title = player.playing ? 'Pause' : 'Abspielen';
@ -3405,6 +3490,17 @@
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;
@ -3485,53 +3581,184 @@
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()));
.concat(presetsEditItem(), silenceSpeedItems()));
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()));
].concat(presetsEditItem(), silenceSpeedItems()));
}
// ---- sleep timer (runs in the app, also with the screen off) ----
// „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;
}
function sleepLabel() {
const t = player.sleep || {};
// ---- 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);
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 '';
}
// 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 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);
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().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)});
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)))});
}
}
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)});
}
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)});
for (const m of SLEEP_MINUTES) {
items.push({icon: '☾', label: `${m} Minuten`, hint: !r && m === own.last && !on ? 'zuletzt' : null, action: () => set(m)});
}
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; }});
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});
}
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.
@ -3675,6 +3902,7 @@
}
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);
@ -3697,6 +3925,9 @@
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');
@ -4164,6 +4395,8 @@
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.
@ -4172,6 +4405,13 @@
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);
@ -4185,6 +4425,7 @@
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 ee1a910 2026-10-02: the podcast parts of web-native/static/native.css
/* Copied from diora-android 67a915e 2026-10-03: 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,3 +775,24 @@ 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 %}Login — diora{% endblock %}
{% block title %}Anmelden — diora{% endblock %}
{% block content %}
<div class="auth-container">
<h1 class="auth-title">Login</h1>
<h1 class="auth-title">Anmelden</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">Login</button>
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
</form>
<p class="auth-switch">
Don't have an account? <a href="{% url 'register' %}">Register</a>
Noch kein Konto? <a href="{% url 'register' %}">Registrieren</a>
</p>
</div>
{% endblock %}

View file

@ -1,9 +1,9 @@
{% extends "base.html" %}
{% block title %}Register — diora{% endblock %}
{% block title %}Registrieren — diora{% endblock %}
{% block content %}
<div class="auth-container">
<h1 class="auth-title">Create Account</h1>
<h1 class="auth-title">Konto anlegen</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">Create Account</button>
<button type="submit" class="btn btn-primary btn-full">Konto anlegen</button>
</form>
<p class="auth-switch">
Already have an account? <a href="{% url 'login' %}">Login</a>
Schon ein Konto? <a href="{% url 'login' %}">Anmelden</a>
</p>
</div>
{% endblock %}

View file

@ -1,9 +1,9 @@
{% extends "base.html" %}
{% block title %}Settings — diora{% endblock %}
{% block title %}Einstellungen — diora{% endblock %}
{% block content %}
<div class="settings-container">
<h1 class="settings-title">Settings</h1>
<h1 class="settings-title">Einstellungen</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">
Connected as <strong>{{ profile.lastfm_username }}</strong>
Verbunden als <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()">
Scrobble tracks to Last.fm
Titel an Last.fm scrobbeln
</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">Disconnect Last.fm</button>
<button type="submit" class="btn btn-danger">Last.fm trennen</button>
</form>
</div>
{% else %}
<p class="lastfm-description">
Connect your Last.fm account to automatically scrobble the tracks you listen to.
Verbinde dein Last.fm-Konto, damit gehörte Titel automatisch gescrobbelt werden.
</p>
<a href="{% url 'lastfm_connect' %}" class="btn btn-lastfm">Connect Last.fm</a>
<a href="{% url 'lastfm_connect' %}" class="btn btn-lastfm">Last.fm verbinden</a>
{% endif %}
</section>
<!-- Background section -->
<section class="settings-section">
<h2>Background</h2>
<h2>Hintergrund</h2>
{% if request.user.profile.background_encrypted %}
<p class="lastfm-description">Encrypted background is set. <span class="muted">(Preview not available — decrypted in browser on main page.)</span></p>
<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>
<form method="post" action="{% url 'delete_background' %}" class="inline-form">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Remove background</button>
<button type="submit" class="btn btn-danger">Hintergrund entfernen</button>
</form>
<p style="margin-top:12px;">Upload a new image to replace it:</p>
<p style="margin-top:12px;">Neues Bild hochladen, um ihn zu ersetzen:</p>
{% elif request.user.profile.background_image_data %}
<p>
<img src="{{ request.user.profile.background_image_data }}" class="bg-preview" alt="Your background">
<img src="{{ request.user.profile.background_image_data }}" class="bg-preview" alt="Dein Hintergrund">
</p>
<form method="post" action="{% url 'delete_background' %}" class="inline-form">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Remove background</button>
<button type="submit" class="btn btn-danger">Hintergrund entfernen</button>
</form>
<p style="margin-top:12px;">Upload a new image to replace it (will be stored encrypted):</p>
<p style="margin-top:12px;">Neues Bild hochladen, um ihn zu ersetzen (wird verschlüsselt gespeichert):</p>
{% else %}
<p class="lastfm-description">Upload a custom background image (JPG, PNG or WebP, max 5 MB). Stored end-to-end encrypted.</p>
<p class="lastfm-description">Eigenes Hintergrundbild hochladen (JPG, PNG oder WebP, höchstens 5 MB). Es wird Ende-zu-Ende-verschlüsselt gespeichert.</p>
{% endif %}
<div style="margin-top:8px; display:flex; align-items:center; gap:10px;">
<label class="btn" for="bg-upload-input">Choose file</label>
<label class="btn" for="bg-upload-input">Datei auswählen</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>Account</h2>
<p>Logged in as <strong>{{ request.user.username }}</strong></p>
<h2>Konto</h2>
<p>Angemeldet als <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;">Change password</summary>
<summary class="btn" style="display:inline-block;cursor:pointer;">Passwort ändern</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">Save</button></div>
<div><button type="submit" class="btn">Speichern</button></div>
</form>
</details>
<form method="post" action="{% url 'logout' %}" class="inline-form" style="margin-top:1rem;">
{% csrf_token %}
<button type="submit" class="btn">Logout</button>
<button type="submit" class="btn">Abmelden</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 = 'Only JPEG, PNG, or WebP images are allowed.';
status.textContent = 'Nur JPEG-, PNG- oder WebP-Bilder sind erlaubt.';
return;
}
if (file.size > 5 * 1024 * 1024) {
status.textContent = 'Image must be 5 MB or smaller.';
status.textContent = 'Das Bild darf höchstens 5 MB groß sein.';
return;
}
status.textContent = 'Encrypting…';
status.textContent = 'Verschlüssele …';
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 = 'Uploading…';
status.textContent = 'Lade hoch …';
const res = await fetch('/accounts/background/upload/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
@ -270,9 +270,10 @@ async function uploadBackground(input) {
});
const data = await res.json();
if (data.ok) { location.reload(); }
else { status.textContent = data.error || 'Upload failed'; }
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'); }
} catch (e) {
status.textContent = 'Upload failed: ' + e.message;
status.textContent = 'Hochladen fehlgeschlagen: ' + e.message;
}
input.value = '';
}

View file

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="de">
<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="Internet radio player">
<meta name="description" content="Bücher, Podcasts und Radio">
{% 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="Toggle contrast mode">◑</button>
<button class="btn-icon contrast-toggle" id="contrast-toggle" onclick="toggleContrast()" title="Kontrastmodus an/aus">◑</button>
{% if user.is_authenticated %}
<span class="navbar-user">{{ user.username }}</span>
<a href="{% url 'settings' %}">Settings</a>
<a href="{% url 'settings' %}">Einstellungen</a>
<form method="post" action="{% url 'logout' %}" class="inline-form">
{% csrf_token %}
<button type="submit" class="btn-link">Logout</button>
<button type="submit" class="btn-link">Abmelden</button>
</form>
{% else %}
<a href="{% url 'login' %}">Login</a>
<a href="{% url 'register' %}">Register</a>
<a href="{% url 'login' %}">Anmelden</a>
<a href="{% url 'register' %}">Registrieren</a>
{% endif %}
</div>
</nav>

View file

@ -1,71 +1,74 @@
{% extends "base.html" %}
{% block title %}diora — radio player{% endblock %}
{% block title %}diora — Radio{% 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">— no station —</span>
<span class="now-playing-station" id="now-playing-station">— kein Sender —</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; Play</button>
<button class="btn btn-play" id="play-stop-btn" onclick="togglePlayStop()" style="display:none;">&#9654; Abspielen</button>
<label class="volume-label">
<span>vol</span>
<span>Lautst.</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; Save</button>
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">&#9733; Speichern</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="Focus mode (hides UI, press Esc to exit)">⊙</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="focus-station-btn" onclick="openRadioSidebar()" title="Radio">◉</button>
</div>
<div class="timer-widget" id="timer-widget">
<span class="timer-phase" id="timer-phase-label">focus</span>
<span class="timer-phase" id="timer-phase-label">Fokus</span>
<span class="timer-display" id="timer-display">25:00</span>
<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>
<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>
<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="Toggle black background">☼</button>
<button class="btn-icon dnd-only" id="dnd-light-btn" onclick="toggleDNDLight()" title="Schwarzer Hintergrund an/aus">☼</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="Album art">
<img class="affiliate-artwork" id="affiliate-artwork" src="" alt="Albumcover">
<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">
Buy on Amazon Music
Bei Amazon Music kaufen
</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')">Focus</button>
<button class="tab-btn" onclick="showTab('focus')">Fokus</button>
<button class="tab-btn" onclick="showTab('podcasts')">Podcasts</button>
<button class="tab-btn" onclick="showTab('books')">Books</button>
<button class="tab-btn" onclick="showTab('books')">Bücher</button>
<button class="tab-btn" onclick="showTab('ambient')">Ambient</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')">Search</button>
<button class="tab-btn active" onclick="showRadioTab('saved')">Saved</button>
<button class="tab-btn" onclick="showRadioTab('history')">History</button>
<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>
</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="Search radio-browser.info… or paste a stream address" onkeydown="if(event.key==='Enter') doSearch()">
<button class="btn" onclick="doSearch()">Search</button>
<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>
</div>
<div class="mood-chips" id="mood-chips"></div>
<div id="curated-lists" class="curated-lists-container"></div>
@ -75,7 +78,7 @@
<tr>
<th>Name</th>
<th>Bitrate</th>
<th>Country</th>
<th>Land</th>
<th>Tags</th>
<th></th>
</tr>
@ -88,7 +91,7 @@
<div class="sub-tab-panel" id="tab-saved">
{% if featured_stations %}
<div class="featured-section">
<p class="featured-label">&#9733; Featured</p>
<p class="featured-label">&#9733; Empfohlen</p>
<ul class="featured-list">
{% for s in featured_stations %}
<li>
@ -107,8 +110,8 @@
<!-- populated by JS -->
</div>
<div class="import-bar">
<label class="btn btn-sm" for="m3u-file-input" title="Import .m3u / .m3u8 from the desktop app">
&#8679; Import M3U
<label class="btn btn-sm" for="m3u-file-input" title=".m3u / .m3u8 aus der Desktop-App importieren">
&#8679; M3U importieren
</label>
<input type="file" id="m3u-file-input" accept=".m3u,.m3u8" style="display:none;" onchange="importM3U(this)">
<span id="import-status" class="muted"></span>
@ -116,11 +119,11 @@
<table class="data-table" id="saved-table">
<thead>
<tr>
<th title="Favorite">&#9733;</th>
<th title="Favorit">&#9733;</th>
<th>Name</th>
<th>Bitrate</th>
<th>Country</th>
<th title="Notes">✎</th>
<th>Land</th>
<th title="Notizen">✎</th>
<th></th>
<th></th>
</tr>
@ -131,7 +134,7 @@
<td>
<button class="btn-icon fav-btn {% if station.is_favorite %}active{% endif %}"
onclick="toggleFav({{ station.id }})"
title="Toggle favorite">&#9733;</button>
title="Favorit an/aus">&#9733;</button>
</td>
<td class="station-name-cell">{{ station.name }}</td>
<td>{{ station.bitrate }}</td>
@ -140,25 +143,25 @@
<td>
<button class="btn btn-sm"
onclick="playStation('{{ station.url }}', '{{ station.name|escapejs }}', {{ station.id }})">
&#9654; Play
&#9654; Abspielen
</button>
</td>
<td>
<button class="btn btn-sm btn-danger"
onclick="removeStation({{ station.id }})">
Remove
Entfernen
</button>
</td>
</tr>
{% empty %}
<tr id="saved-empty-row"><td colspan="7" class="empty-msg">No saved stations yet.</td></tr>
<tr id="saved-empty-row"><td colspan="7" class="empty-msg">Noch keine gespeicherten Sender.</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="auth-prompt">
<a href="{% url 'login' %}">Log in</a> or <a href="{% url 'register' %}">register</a>
to save stations and sync across devices.
<a href="{% url 'login' %}">Anmelden</a> oder <a href="{% url 'register' %}">registrieren</a>,
um Sender zu speichern und zwischen Geräten abzugleichen.
</p>
{% endif %}
</div>
@ -168,9 +171,9 @@
<table class="data-table" id="history-table">
<thead>
<tr>
<th>Time</th>
<th>Station</th>
<th>Track</th>
<th>Zeit</th>
<th>Sender</th>
<th>Titel</th>
<th>&#9836;</th>
<th></th>
</tr>
@ -181,11 +184,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="Scrobbled to Last.fm">&#10003;</span>{% endif %}</td>
<td><button class="btn-delete-history" onclick="deleteHistoryEntry({{ entry.id }}, this)" title="Remove">✕</button></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>
</tr>
{% empty %}
<tr id="history-empty-row"><td colspan="5" class="empty-msg">No history yet.</td></tr>
<tr id="history-empty-row"><td colspan="5" class="empty-msg">Noch kein Verlauf.</td></tr>
{% endfor %}
</tbody>
</table>
@ -197,13 +200,13 @@
<table class="data-table" id="focus-table">
<thead>
<tr>
<th>Time</th>
<th>Station</th>
<th>Duration</th>
<th>Zeit</th>
<th>Sender</th>
<th>Dauer</th>
</tr>
</thead>
<tbody id="focus-tbody">
<tr><td colspan="3" class="empty-msg">No focus sessions yet. Start the timer!</td></tr>
<tr><td colspan="3" class="empty-msg">Noch keine Fokus-Sitzungen. Starte den Timer!</td></tr>
</tbody>
</table>
</section>
@ -215,8 +218,8 @@
<p class="muted dpw-loading">Podcasts werden geladen …</p>
{% else %}
<p class="auth-prompt">
<a href="{% url 'login' %}">Log in</a> or <a href="{% url 'register' %}">register</a>
to subscribe to podcasts.
<a href="{% url 'login' %}">Anmelden</a> oder <a href="{% url 'register' %}">registrieren</a>,
um Podcasts zu abonnieren.
</p>
{% endif %}
</section>
@ -226,7 +229,7 @@
{% if user.is_authenticated %}
<div id="book-upload-area">
<div class="book-drop-zone" id="book-drop-zone">
<span>Drop .epub or .pdf here or <label for="book-file-input" style="cursor:pointer;text-decoration:underline;">browse</label></span>
<span>.epub oder .pdf hierher ziehen oder <label for="book-file-input" style="cursor:pointer;text-decoration:underline;">auswählen</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>
@ -248,12 +251,24 @@
<div id="book-list" class="book-list"></div>
{% else %}
<p class="auth-prompt">
<a href="{% url 'login' %}">Log in</a> or <a href="{% url 'register' %}">register</a>
to use the encrypted ebook reader.
<a href="{% url 'login' %}">Anmelden</a> oder <a href="{% url 'register' %}">registrieren</a>,
um den verschlüsselten E-Book-Reader zu nutzen.
</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">
@ -263,30 +278,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="Search">⌕</button>
<button class="btn-icon" id="reader-settings-btn" onclick="toggleSettingsPanel()" title="Font &amp; layout">⚙</button>
<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>
<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="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" 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 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="Close (Esc)">✕</button>
<button class="btn-icon" onclick="closeReader()" title="Schließen (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">Notes</span>
<span class="reader-margin-title">Notizen</span>
<span class="reader-margin-header-actions">
<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>
<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>
</span>
</div>
<div id="reader-margin-markers" class="reader-margin-markers"></div>
@ -300,7 +315,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="Close">✕</button>
<button class="btn-icon sidebar-close" onclick="closeSidebar()" title="Schließen">✕</button>
</div>
<div id="sidebar-body" class="sidebar-body"></div>
</aside>
@ -311,7 +326,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;">Cancel</button>
<button type="button" id="modal-cancel-btn" class="btn" style="display:none;">Abbrechen</button>
<button type="button" id="modal-ok-btn" class="btn btn-primary">OK</button>
</div>
</div>
@ -329,4 +344,5 @@
</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="en">
<html lang="de">
<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; Play</button>
<button id="play-btn">&#9654; Abspielen</button>
<div class="vol-wrap">
<span>vol</span>
<span>Lautst.</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; close tab</button>
<button id="back-btn" onclick="window.close()">&#8592; Tab schließen</button>
<script>
const audio = new Audio();
@ -120,7 +120,7 @@
setVol(parseInt(slider.value, 10));
audio.play().then(() => {
playing = true;
playBtn.innerHTML = '&#9646;&#9646; Stop';
playBtn.innerHTML = '&#9646;&#9646; Stopp';
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; Play';
document.getElementById('track-name').textContent = 'Click Play to start';
playBtn.innerHTML = '&#9654; Abspielen';
document.getElementById('track-name').textContent = 'Zum Starten auf Abspielen tippen';
});
}
@ -146,7 +146,7 @@
audio.pause();
audio.src = '';
playing = false;
playBtn.innerHTML = '&#9654; Play';
playBtn.innerHTML = '&#9654; Abspielen';
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 = 'Click Play to start';
document.getElementById('track-name').textContent = 'Zum Starten auf Abspielen tippen';
</script>
</body>
</html>