Compare commits
No commits in common. "8b9c28cda18cff39fce0f0d50afedb042ccf9fc4" and "985e635f9aa89bdfbfeb0ff855c67ddc87cdb708" have entirely different histories.
8b9c28cda1
...
985e635f9a
29 changed files with 375 additions and 2999 deletions
|
|
@ -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}",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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,50 +425,45 @@ 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
|
||||||
by_url = {}
|
for start in range(0, len(feeds), chunk):
|
||||||
for f in feeds:
|
part = feeds[start:start + chunk]
|
||||||
by_url.setdefault(f.rss_url, []).append(f)
|
by_url = {}
|
||||||
if not by_url:
|
for f in part:
|
||||||
return 0, 0, 0
|
by_url.setdefault(f.rss_url, []).append(f)
|
||||||
|
results = {}
|
||||||
|
|
||||||
def fetch(url):
|
def fetch(url):
|
||||||
try:
|
try:
|
||||||
return url, _fetch_feed(url, **({} if full else _validators(by_url[url]))), None
|
return url, _fetch_feed(url, **({} if full else _validators(by_url[url]))), 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)
|
||||||
_record_error(feed, err)
|
_record_error(feed, err)
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
new += _refresh_feed(feed, fetched, full=full)
|
new += _refresh_feed(feed, fetched, full=full)
|
||||||
refreshed += 1
|
refreshed += 1
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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}'
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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;
|
||||||
|
|
|
||||||
429
static/js/app.js
429
static/js/app.js
File diff suppressed because it is too large
Load diff
|
|
@ -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 => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[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);
|
|
||||||
})();
|
|
||||||
|
|
@ -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);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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 SLEEP_MINUTES) {
|
for (const m of [5, 10, 15, 30, 45, 60, 90]) {
|
||||||
items.push({icon: '☾', label: `${m} Minuten`, hint: !r && m === own.last && !on ? 'zuletzt' : null, action: () => set(m)});
|
items.push({icon: '☾', label: `${m} Minuten`, hint: m === t.last && !on ? 'zuletzt' : null, action: () => N.podcastSleep(m)});
|
||||||
}
|
}
|
||||||
if (typeof customPrompt === 'function') {
|
if (radio !== true) {
|
||||||
// A time of one's own that was set last comes back as its own line.
|
items.push({icon: '⏹', label: 'Am Ende der Folge', action: () => N.podcastSleep(-1)});
|
||||||
const lastOwn = !r && own.last && !SLEEP_MINUTES.includes(own.last) ? own.last : 0;
|
items.push({icon: '⏹', label: 'Nach 2 Folgen', action: () => N.podcastSleep(-2)});
|
||||||
if (lastOwn) items.push({icon: '☾', label: sleepMinutesLabel(lastOwn), hint: on ? null : 'zuletzt', action: () => set(lastOwn)});
|
items.push({icon: '⏹', label: 'Nach 3 Folgen', action: () => N.podcastSleep(-3)});
|
||||||
items.push({icon: '✎', label: 'Eigene Zeit …', hint: 'z. B. 25, 2 h oder 1:30', action: () => customSleep(set)});
|
|
||||||
}
|
}
|
||||||
if (!onRadio) {
|
if (N.podcastSleepAuto) {
|
||||||
items.push({icon: '⏹', label: 'Am Ende der Folge', action: () => set(-1)});
|
items.push({icon: t.auto ? '☑' : '☐', label: 'Nachts von selbst (22–6 Uhr)',
|
||||||
items.push({icon: '⏹', label: 'Nach 2 Folgen', action: () => set(-2)});
|
hint: t.auto ? `an: ${t.last || 15} Minuten, wenn eine Folge startet` : 'aus',
|
||||||
items.push({icon: '⏹', label: 'Nach 3 Folgen', action: () => set(-3)});
|
action: () => { N.podcastSleepAuto(!t.auto); t.auto = !t.auto; }});
|
||||||
}
|
|
||||||
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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 = '';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;">▶ Abspielen</button>
|
<button class="btn btn-play" id="play-stop-btn" onclick="togglePlayStop()" style="display:none;">▶ 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()">★ Speichern</button>
|
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">★ Save</button>
|
||||||
<button class="btn-icon" id="creamfresh-vote-up-btn" style="display:none;" onclick="creamfreshVote('up')" title="Gefällt mir">👍</button>
|
<button class="btn-icon" id="creamfresh-vote-up-btn" style="display:none;" onclick="creamfreshVote('up')" title="Gefällt mir">👍</button>
|
||||||
<button class="btn-icon" id="creamfresh-vote-down-btn" style="display:none;" onclick="creamfreshVote('down')" title="Gefällt mir nicht">👎</button>
|
<button class="btn-icon" id="creamfresh-vote-down-btn" style="display:none;" onclick="creamfreshVote('down')" title="Gefällt mir nicht">👎</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">★ Empfohlen</p>
|
<p class="featured-label">★ 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">
|
||||||
⇧ M3U importieren
|
⇧ 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">★</th>
|
<th title="Favorite">★</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">★</button>
|
title="Toggle favorite">★</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 }})">
|
||||||
▶ Abspielen
|
▶ 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>♬</th>
|
<th>♬</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">✓</span>{% endif %}</td>
|
<td>{% if entry.scrobbled %}<span title="Scrobbled to Last.fm">✓</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&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 & Layout">⚙</button>
|
<button class="btn-icon" id="reader-settings-btn" onclick="toggleSettingsPanel()" title="Font & 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 & Markierungen">✎</button>
|
<button class="btn-icon" id="reader-margin-btn" onclick="toggleAnnotationsMargin()" title="Notes & 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 & Notizen auflisten und hinspringen">▦</button>
|
<button class="btn-icon" id="reader-margin-list-btn" onclick="openAnnotationsSidebar()" title="List all highlights & 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 %}
|
||||||
|
|
|
||||||
|
|
@ -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">▶ Abspielen</button>
|
<button id="play-btn">▶ 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()">← Tab schließen</button>
|
<button id="back-btn" onclick="window.close()">← 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 = '▮▮ Stopp';
|
playBtn.innerHTML = '▮▮ 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 = '▶ Abspielen';
|
playBtn.innerHTML = '▶ 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 = '▶ Abspielen';
|
playBtn.innerHTML = '▶ 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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue