Compare commits

..

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

29 changed files with 375 additions and 2999 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -36,10 +36,6 @@ class PodcastFeed(models.Model):
# If-None-Match / If-Modified-Since so an unchanged feed costs a 304. # If-None-Match / If-Modified-Since so an unchanged feed costs a 304.
http_etag = models.CharField(max_length=500, blank=True) http_etag = models.CharField(max_length=500, blank=True)
http_last_modified = models.CharField(max_length=100, 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 # The last failed refresh (network, HTTP status, not a feed); cleared on
# success. error_count counts failures in a row and drives the backoff. # success. error_count counts failures in a row and drives the backoff.
last_error = models.CharField(max_length=500, blank=True) last_error = models.CharField(max_length=500, blank=True)
@ -158,9 +154,6 @@ class PodcastEpisode(models.Model):
chapters_fetched_at = models.DateTimeField(null=True, blank=True) chapters_fetched_at = models.DateTimeField(null=True, blank=True)
# [{url, type, language}] from <podcast:transcript>. # [{url, type, language}] from <podcast:transcript>.
transcripts = models.JSONField(default=list, blank=True) 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: class Meta:
unique_together = ('feed', 'guid') unique_together = ('feed', 'guid')
@ -354,10 +347,6 @@ class PlayerState(models.Model):
# The ids of the devices online at the last change, sorted: when the set # 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. # differs, seq goes up so every device sees others come and go.
online_devices = models.JSONField(default=list, blank=True) 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 @classmethod
def for_user(cls, user): def for_user(cls, user):

View file

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

View file

@ -257,34 +257,6 @@ class PlayerSyncTests(TestCase):
with self.subTest(body=body): with self.subTest(body=body):
self.assertEqual(self.post('command', body).status_code, 400) 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): def test_transfer_to_another_device(self):
self.report('a', True, pos=1000) self.report('a', True, pos=1000)
self.report('c', False, pos=0) # c is online (paused report: ignored) 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): def test_refresh_all_reports_counts_and_skips_recent(self):
with patch('podcasts.views._fetch_feed', self.fake_fetch): with patch('podcasts.views._fetch_feed', self.fake_fetch):
r = self.client.post('/podcasts/feeds/refresh-all/', '{}', content_type='application/json') r = self.client.post('/podcasts/feeds/refresh-all/', '{}', content_type='application/json')
self.assertEqual(r.json(), {'ok': True, 'refreshed': 1, 'failed': 1, 'new_episodes': 1, 'done': True, 'left': 0}) self.assertEqual(r.json(), {'ok': True, 'refreshed': 1, 'failed': 1, 'new_episodes': 1})
self.assertEqual(sorted(self.fetched), ['https://example.org/a', 'https://example.org/b']) 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. # A was just refreshed: a second call within max_age only retries B.
self.fetched.clear() self.fetched.clear()
@ -315,27 +315,6 @@ class RefreshAllTests(TestCase):
self.client.post('/podcasts/feeds/refresh-all/', '{}', content_type='application/json') self.client.post('/podcasts/feeds/refresh-all/', '{}', content_type='application/json')
self.assertEqual(self.fetched, ['https://example.org/b']) 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): def test_a_shared_feed_url_is_fetched_once(self):
from .views import _refresh_feeds from .views import _refresh_feeds
with patch('podcasts.views._fetch_feed', self.fake_fetch): with patch('podcasts.views._fetch_feed', self.fake_fetch):
@ -1512,25 +1491,6 @@ class FetchFeedTests(TestCase):
self.fetch(response) self.fetch(response)
return str(cm.exception) 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): def test_200_returns_parsed_feed_and_validators(self):
r, headers = self.fetch(_HttpResponse('https://example.org/feed', content=self.RSS, headers={ 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'})) 'ETag': '"v1"', 'Last-Modified': 'Mon, 05 Jan 2026 10:00:00 GMT'}))
@ -2668,57 +2628,3 @@ class DiscardTests(TestCase):
self.assertEqual(self.client.get('/podcasts/episodes/discard/').status_code, 405) self.assertEqual(self.client.get('/podcasts/episodes/discard/').status_code, 405)
self.client.logout() self.client.logout()
self.assertEqual(self.post({'episode_ids': [self.eps[0].id]}).status_code, 401) self.assertEqual(self.post({'episode_ids': [self.eps[0].id]}).status_code, 401)
# ---------------------------------------------------------------------------
# Skip silence in the browser: pauses found on the server (silence.py)
# ---------------------------------------------------------------------------
class SilenceTests(TestCase):
STDERR = """Input #0, mp3, from 'x':
[silencedetect @ 0x1] silence_start: 1.5
[silencedetect @ 0x1] silence_end: 2.4 | silence_duration: 0.9
[silencedetect @ 0x1] silence_start: 10
[silencedetect @ 0x1] silence_end: 10.2 | silence_duration: 0.2
[silencedetect @ 0x1] silence_start: -0.01
[silencedetect @ 0x1] silence_end: 0.75 | silence_duration: 0.76
[silencedetect @ 0x1] silence_start: 99.123456
"""
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/f', title='F')
self.ep = PodcastEpisode.objects.create(feed=feed, guid='e', title='E', audio_url='https://example.org/e.mp3')
def test_parse_keeps_pauses_long_enough(self):
from .silence import parse
# 0.2 s is too short; a start below 0 is 0; an open start at the end is dropped.
self.assertEqual(parse(self.STDERR), [[1.5, 2.4], [0.0, 0.75]])
def test_first_request_starts_the_search_then_answers_with_the_pauses(self):
with patch('podcasts.silence.ensure') as ensure:
r = self.client.get(f'/podcasts/episodes/{self.ep.id}/silences/')
self.assertEqual(r.json(), {'ready': False})
ensure.assert_called_once_with(self.ep.id)
with patch('podcasts.silence.analyze', return_value=[[1.5, 2.4]]):
from .silence import _work
_work(self.ep.id)
r = self.client.get(f'/podcasts/episodes/{self.ep.id}/silences/')
self.assertEqual(r.json(), {'ready': True, 'spans': [[1.5, 2.4]]})
def test_a_failure_is_remembered_not_retried(self):
from .silence import _work
with patch('podcasts.silence.analyze', side_effect=RuntimeError('HTTP error 404 Not Found')):
_work(self.ep.id)
with patch('podcasts.silence.ensure') as ensure:
r = self.client.get(f'/podcasts/episodes/{self.ep.id}/silences/')
ensure.assert_not_called()
self.assertEqual(r.json(), {'ready': True, 'spans': [], 'error': 'HTTP error 404 Not Found'})
def test_only_own_episodes(self):
bob = User.objects.create_user(username='bob', password='pw12345678')
self.client.force_login(bob)
self.assertEqual(self.client.get(f'/podcasts/episodes/{self.ep.id}/silences/').status_code, 404)
self.client.logout()
self.assertEqual(self.client.get(f'/podcasts/episodes/{self.ep.id}/silences/').status_code, 401)

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -389,15 +389,6 @@ a:hover {
min-height: 200px; 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 { .sub-tabs {
border-bottom-color: transparent; border-bottom-color: transparent;
margin-bottom: 1rem; margin-bottom: 1rem;

File diff suppressed because it is too large Load diff

View file

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

View file

@ -164,15 +164,11 @@
try { try {
if (fetchFeeds) { if (fetchFeeds) {
if (!(await flush())) return false; if (!(await flush())) return false;
const rr = await fetch('/podcasts/feeds/refresh-all/', { await fetch('/podcasts/feeds/refresh-all/', {
method: 'POST', credentials: 'same-origin', method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()}, headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()},
body: '{"max_age":60}', 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 // Progress is saved every few seconds while an episode plays; try again
// when it slipped in while the state was on its way. // when it slipped in while the state was on its way.
@ -433,7 +429,7 @@
smart_played: [0, 15, 30, 45, 60, 120, 300, 600], 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, const prefs = Object.assign({speed: 1, skip_back: 15, skip_forward: 30, remove_played: true, smart_played: 30,
sleep_last: 15, sleep_auto: false, sleep_from: 22, sleep_to: 6, skip_silence: false}, load('prefs', {})); sleep_last: 15, sleep_auto: false}, load('prefs', {}));
// Skip lengths and "counts as played": the account's where it has them, else this browser's. // Skip lengths and "counts as played": the account's where it has them, else this browser's.
function eff(name) { function eff(name) {
@ -453,23 +449,16 @@
if (name in (state.settings || {})) { setSettings({[name]: Math.round(value)}); flush(); } if (name in (state.settings || {})) { setSettings({[name]: Math.round(value)}); flush(); }
} else if (name === 'remove_played') { } else if (name === 'remove_played') {
prefs.remove_played = !!value; 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 { } else {
return; // headset_next: nothing a browser can do return; // skip_silence, headset_next: nothing a browser can do
} }
store('prefs', prefs); store('prefs', prefs);
} }
// What the page shows as settings. No headset_next: the page leaves it out. // What the page shows as settings. No skip_silence / headset_next: the page leaves those out.
function prefsJson() { function prefsJson() {
return {speed: prefs.speed, skip_back: eff('skip_back'), skip_forward: eff('skip_forward'), 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) { function speedFor(feedId) {
@ -493,57 +482,32 @@
} }
// ---- sleep timer (SleepTimer.kt): minutes count only while playing ---------------- // ---- 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 FADE_MS = 10000;
const MAX_GAP_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 = { const sleep = {
leftMs: 0, leftMs: 0,
episodes: 0, episodes: 0,
lastTick: 0, lastTick: 0,
lastCounted: 0,
get active() { return this.leftMs > 0 || this.episodes > 0; }, get active() { return this.leftMs > 0 || this.episodes > 0; },
set(minutes, episodes) { set(minutes, episodes) {
this.leftMs = minutes * 60000; this.leftMs = minutes * 60000;
this.episodes = minutes > 0 ? 0 : episodes; this.episodes = minutes > 0 ? 0 : episodes;
this.lastTick = 0; this.lastTick = 0;
this.lastCounted = this.active ? Date.now() : 0;
if (minutes > 0) { prefs.sleep_last = minutes; store('prefs', prefs); } if (minutes > 0) { prefs.sleep_last = minutes; store('prefs', prefs); }
setFade(1); setFade(1);
sleepChanged();
}, },
extend(minutes) { extend(minutes) {
if (this.leftMs <= 0) { this.set(minutes, 0); return; } if (this.leftMs <= 0) { this.set(minutes, 0); return; }
this.leftMs += minutes * 60000; this.leftMs += minutes * 60000;
this.lastCounted = Date.now();
setFade(1); setFade(1);
sleepChanged();
}, },
tick(playing) { tick(playing) {
const now = Date.now(); const now = Date.now();
const delta = this.lastTick ? now - this.lastTick : 0; const delta = this.lastTick ? now - this.lastTick : 0;
this.lastTick = now; this.lastTick = now;
if (!this.active) return; if (this.leftMs <= 0 || !playing) 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. // A gap means nothing was ticking (paused, tab asleep): not playing time.
if (delta > 0 && delta <= MAX_GAP_MS) this.leftMs -= delta; if (delta > 0 && delta <= MAX_GAP_MS) this.leftMs -= delta;
if (this.leftMs <= 0) { if (this.leftMs <= 0) {
@ -551,8 +515,6 @@
setFade(1); setFade(1);
pause(); pause();
stopRadio(); // the radio sleeps too (it plays in app.js's element) stopRadio(); // the radio sleeps too (it plays in app.js's element)
if (window.__dioraAmbientStop) window.__dioraAmbientStop(); // and Ambient
sleepChanged();
return; return;
} }
if (this.leftMs < FADE_MS) setFade(Math.max(this.leftMs / FADE_MS, 0.1)); if (this.leftMs < FADE_MS) setFade(Math.max(this.leftMs / FADE_MS, 0.1));
@ -561,40 +523,20 @@
episodeFinished() { episodeFinished() {
if (this.episodes <= 0) return false; if (this.episodes <= 0) return false;
this.episodes--; this.episodes--;
if (this.episodes === 0) sleepChanged();
return this.episodes === 0; return this.episodes === 0;
}, },
queueEnded() { queueEnded() { this.episodes = 0; },
if (!this.episodes) return; // An episode starts playing: at night, the timer may switch itself on.
this.episodes = 0;
sleepChanged();
},
// Playback starts (an episode or the radio): in the night window, the timer may switch itself on.
maybeAutoEnable() { maybeAutoEnable() {
if (this.active || !prefs.sleep_auto) return; if (this.active || !prefs.sleep_auto) return;
if (inSleepWindow(new Date().getHours(), sleepHour('sleep_from', 22), sleepHour('sleep_to', 6))) { const hour = new Date().getHours();
this.set(prefs.sleep_last || 15, 0); if (hour >= 22 || hour < 6) this.set(prefs.sleep_last || 15, 0);
}
}, },
json() { json() {
return {leftMs: Math.max(0, Math.round(this.leftMs)), episodes: this.episodes, auto: !!prefs.sleep_auto, return {leftMs: Math.max(0, Math.round(this.leftMs)), episodes: this.episodes, auto: !!prefs.sleep_auto, last: prefs.sleep_last || 15};
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) --------------------------------------- // ---- the player (RadioService.EpisodeTracker) ---------------------------------------
const audio = new Audio(); // the radio keeps app.js's own element const audio = new Audio(); // the radio keeps app.js's own element
@ -940,51 +882,14 @@
if (playing && Date.now() - lastSaved > 10000) saveNow(id, true); if (playing && Date.now() - lastSaved > 10000) saveNow(id, true);
if (playing) skipOutro(id); if (playing) skipOutro(id);
} }
// The sleep timer counts while any of them plays (radio and podcasts share the player; Ambient). // The sleep timer counts while either plays (radio and podcasts share the player).
sleep.tick(playing || radioPlaying() || ambientPlaying()); sleep.tick(playing || radioPlaying());
// While playing, the shared player hears from us every 10 s (its place, and that we're alive). // 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(); if ((playing || radioPlaying()) && Date.now() - sync.lastSent >= REPORT_EVERY_MS && !busy()) syncReport();
// Another device's place moves on in the bar too. // Another device's place moves on in the bar too.
if (playing || sleep.leftMs > 0 || otherActive(sync.state)) report(); if (playing || sleep.leftMs > 0 || otherActive(sync.state)) report();
}, 1000); }, 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 --------------------------------------------------- // ---- radio and podcasts take turns ---------------------------------------------------
function stopRadio() { function stopRadio() {
@ -1015,7 +920,6 @@
window.playStation = function () { window.playStation = function () {
putAway(); putAway();
const r = playStation.apply(this, arguments); const r = playStation.apply(this, arguments);
sleep.maybeAutoEnable();
radioChanged(); radioChanged();
sync.start = true; sync.start = true;
syncSoon(); syncSoon();
@ -1183,22 +1087,11 @@
((st.kind === 'podcast' && st.episode) || (st.kind === 'radio' && st.station))); ((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. // The page's player.remote: what the other device plays.
function remoteJson() { function remoteJson() {
const st = sync.state; const st = sync.state;
if (!otherActive(st)) return null; 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'; const radio = st.kind === 'radio';
if (radio) { if (radio) {
o.station = {name: st.station.name || '', url: st.station.url, logo: radioLogo(st.station)}; o.station = {name: st.station.name || '', url: st.station.url, logo: radioLogo(st.station)};
@ -1236,17 +1129,16 @@
return list; return list;
} }
// What this device plays, as a report has it (with the sleep timer). // What this device plays, as a report has it.
function mine() { function mine() {
const st = typeof currentStation !== 'undefined' ? currentStation : null; 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)) { if (st && (radioPlaying() || !cur)) {
return {kind: 'radio', station: {url: st.url, name: st.name || '', id: Number(st.id) || null}, return {kind: 'radio', station: {url: st.url, name: st.name || '', id: Number(st.id) || null},
position_ms: 0, playing: radioPlaying(), sleep: zz}; position_ms: 0, playing: radioPlaying()};
} }
if (cur) { if (cur) {
return {kind: 'podcast', episode_id: cur.episode__id, position_ms: Math.max(0, Math.round(posMs())), return {kind: 'podcast', episode_id: cur.episode__id, position_ms: Math.max(0, Math.round(posMs())),
playing: wantPlay, speed: speedFor(curFeed()), sleep: zz}; playing: wantPlay, speed: speedFor(curFeed())};
} }
return {kind: '', position_ms: 0, playing: false}; return {kind: '', position_ms: 0, playing: false};
} }
@ -1364,10 +1256,6 @@
try { try {
if (a === 'transfer') { if (a === 'transfer') {
takeOver(st); 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') { } else if (kind === 'radio') {
if (a === 'play' && !radioPlaying()) togglePlayStop(); if (a === 'play' && !radioPlaying()) togglePlayStop();
else if ((a === 'pause' || a === 'stop') && radioPlaying()) stopPlayback(false); else if ((a === 'pause' || a === 'stop') && radioPlaying()) stopPlayback(false);
@ -1499,7 +1387,7 @@
// Remote control: a command for the active device (or `to`). // Remote control: a command for the active device (or `to`).
function command(action, value, to) { function command(action, value, to) {
const body = {device_id: DEVICE_ID, device_name: DEVICE_NAME, action}; const body = {device_id: DEVICE_ID, device_name: DEVICE_NAME, action};
if (action === 'seek' || action === 'skip' || action === 'sleep') body.value = Math.round(Number(value) || 0); if (action === 'seek' || action === 'skip') body.value = Math.round(Number(value) || 0);
if (to) body.to_device_id = to; if (to) body.to_device_id = to;
else hintFor(action, body.value); else hintFor(action, body.value);
return post('/podcasts/player/command/', body) return post('/podcasts/player/command/', body)
@ -1699,7 +1587,6 @@
}, },
podcastNext: () => next(), podcastNext: () => next(),
podcastToggle: () => (wantPlay ? pause() : resume()), podcastToggle: () => (wantPlay ? pause() : resume()),
podcastPause: () => { if (wantPlay) pause(); },
podcastSeek: ms => seek(Number(ms)), podcastSeek: ms => seek(Number(ms)),
podcastSkip: forward => skip(!!forward), podcastSkip: forward => skip(!!forward),
podcastSpeed(speed) { podcastSpeed(speed) {
@ -1862,9 +1749,6 @@
if (!source) listen(); 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', () => { window.addEventListener('online', () => {
flush(); flush();
if (!source) listen(); if (!source) listen();
@ -1873,18 +1757,15 @@
// The shared player: what plays on the other devices, and from now on every change. // The shared player: what plays on the other devices, and from now on every change.
startSync(); startSync();
// The start page chosen in the podcasts' settings (nui_start_page, this browser): Home // The start page chosen in the podcasts' settings (nui_start_page, this browser; none: the
// unless chosen otherwise; '' = the last used tab, the web's old way. Opening diora shows // last used tab, the web's way). Opening diora shows it; reloading and back/forward keep the tab.
// it; reloading and back/forward keep the tab.
(function openStartPage() { (function openStartPage() {
let start = null; let start = '';
try { start = localStorage.getItem('nui_start_page'); } catch (e) {} try { start = localStorage.getItem('nui_start_page') || ''; } catch (e) {}
if (start === null) start = document.getElementById('tab-home') ? 'home' : '';
if (!start || typeof showTab !== 'function') return; if (!start || typeof showTab !== 'function') return;
const nav = performance.getEntriesByType ? performance.getEntriesByType('navigation')[0] : null; const nav = performance.getEntriesByType ? performance.getEntriesByType('navigation')[0] : null;
if (nav && nav.type !== 'navigate') return; if (nav && nav.type !== 'navigate') return;
const tab = start === 'queue' || start === 'inbox' ? 'podcasts' : start === 'radio' ? 'radio' const tab = start === 'queue' || start === 'inbox' ? 'podcasts' : start === 'radio' ? 'radio' : 'books';
: start === 'home' && document.getElementById('tab-home') ? 'home' : 'books';
if (typeof _currentTopTab === 'undefined' || _currentTopTab !== tab) showTab(tab); if (typeof _currentTopTab === 'undefined' || _currentTopTab !== tab) showTab(tab);
})(); })();

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
/* Copied from diora-android 67a915e 2026-10-03: the podcast parts of web-native/static/native.css /* Copied from diora-android ee1a910 2026-10-02: the podcast parts of web-native/static/native.css
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here. (bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
The web's own additions are in podcasts-web.css. */ The web's own additions are in podcasts-web.css. */
@ -775,24 +775,3 @@ body.eink .nui-pod-chapbox { border-color: #000; }
body.eink .nui-pod-chip.on { border-color: #000; color: #000; } body.eink .nui-pod-chip.on { border-color: #000; color: #000; }
body.eink .nui-pod-notes a, body.eink .nui-pod-notes a,
body.eink .nui-pod-facts a { text-decoration-color: #000; } 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" %} {% extends "base.html" %}
{% block title %}Anmelden — diora{% endblock %} {% block title %}Login — diora{% endblock %}
{% block content %} {% block content %}
<div class="auth-container"> <div class="auth-container">
<h1 class="auth-title">Anmelden</h1> <h1 class="auth-title">Login</h1>
<form method="post" class="auth-form"> <form method="post" class="auth-form">
{% csrf_token %} {% csrf_token %}
{% for field in form %} {% for field in form %}
@ -26,10 +26,10 @@
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
<button type="submit" class="btn btn-primary btn-full">Anmelden</button> <button type="submit" class="btn btn-primary btn-full">Login</button>
</form> </form>
<p class="auth-switch"> <p class="auth-switch">
Noch kein Konto? <a href="{% url 'register' %}">Registrieren</a> Don't have an account? <a href="{% url 'register' %}">Register</a>
</p> </p>
</div> </div>
{% endblock %} {% endblock %}

View file

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

View file

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

View file

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

View file

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

View file

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