Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs
Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9b0848c30c
commit
76a164709d
11 changed files with 3817 additions and 1518 deletions
40
radio/tests.py
Normal file
40
radio/tests.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
class PlayerPagePodcastsTests(TestCase):
|
||||
"""The podcast tab is the app's podcast page (static/podcasts/, copied from
|
||||
diora-android) on a browser host (static/js/podcasts-web.js)."""
|
||||
|
||||
def test_logged_in_page_loads_the_podcast_host(self):
|
||||
user = User.objects.create_user('pod', password='pw-for-tests-only')
|
||||
self.client.force_login(user)
|
||||
html = self.client.get('/').content.decode()
|
||||
self.assertIn('id="tab-podcasts"', html)
|
||||
self.assertIn('<script src="/static/js/podcasts-web.js"></script>', html)
|
||||
self.assertIn('dpw-loading', html)
|
||||
# The old podcast tab and its player bar are gone.
|
||||
self.assertNotIn('podcast-toolbar', html)
|
||||
self.assertNotIn('podcast-seek-bar', html)
|
||||
self.assertNotIn('INITIAL_PODCAST_FEEDS', html)
|
||||
|
||||
def test_logged_out_page_asks_to_log_in(self):
|
||||
html = self.client.get('/').content.decode()
|
||||
self.assertIn('to subscribe to podcasts.', html)
|
||||
self.assertNotIn('dpw-loading', html)
|
||||
|
||||
def test_podcast_ui_files_are_there(self):
|
||||
static = Path(settings.BASE_DIR) / 'static'
|
||||
host = (static / 'js' / 'podcasts-web.js').read_text(encoding='utf-8')
|
||||
for name in ('native-podcast.js', 'podcast-ui.css', 'podcasts-web.css'):
|
||||
path = static / 'podcasts' / name
|
||||
self.assertTrue(path.exists(), name)
|
||||
if name != 'podcasts-web.css':
|
||||
self.assertIn('Copied from diora-android', path.read_text(encoding='utf-8')[:300])
|
||||
self.assertIn('/static/podcasts/' + name, host)
|
||||
sw = (static / 'js' / 'sw.js').read_text(encoding='utf-8')
|
||||
for path in ('/static/js/podcasts-web.js', '/static/podcasts/native-podcast.js'):
|
||||
self.assertIn(path, sw)
|
||||
|
|
@ -63,13 +63,6 @@ def index(request):
|
|||
)
|
||||
)
|
||||
|
||||
initial_podcast_feeds = []
|
||||
if request.user.is_authenticated:
|
||||
from podcasts.models import PodcastFeed
|
||||
initial_podcast_feeds = list(
|
||||
request.user.podcast_feeds.values('id', 'title', 'artwork_url', 'rss_url')
|
||||
)
|
||||
|
||||
focus_station = None # null in JS means "never configured, use default"
|
||||
encrypted_bg = {}
|
||||
if request.user.is_authenticated:
|
||||
|
|
@ -91,7 +84,6 @@ def index(request):
|
|||
'amazon_enabled': settings.AMAZON_AFFILIATE_ENABLED,
|
||||
'featured_stations': featured,
|
||||
'featured_stations_json': json.dumps(featured, cls=DjangoJSONEncoder),
|
||||
'initial_podcast_feeds': json.dumps(initial_podcast_feeds, cls=DjangoJSONEncoder),
|
||||
'focus_station': focus_station,
|
||||
'focus_station_json': json.dumps(focus_station, cls=DjangoJSONEncoder),
|
||||
'encrypted_bg': encrypted_bg,
|
||||
|
|
|
|||
|
|
@ -1251,241 +1251,6 @@ body.dnd-mode .timer-display {
|
|||
.volume-num::-webkit-outer-spin-button { display: none; }
|
||||
.volume-num { -moz-appearance: textfield; }
|
||||
|
||||
/* ===== PODCAST COMPONENTS ===== */
|
||||
|
||||
.podcast-seek-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.skip-btn {
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.8;
|
||||
white-space: nowrap;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.skip-btn:hover { opacity: 1; }
|
||||
|
||||
.speed-btns {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.speed-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border, #444);
|
||||
color: var(--fg, #fff);
|
||||
border-radius: 3px;
|
||||
font-size: 0.68rem;
|
||||
padding: 1px 4px;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.speed-btn:hover { opacity: 1; }
|
||||
.speed-btn.active {
|
||||
opacity: 1;
|
||||
border-color: var(--accent, #e63946);
|
||||
color: var(--accent, #e63946);
|
||||
}
|
||||
|
||||
.seek-slider {
|
||||
flex: 1;
|
||||
accent-color: var(--accent, #e63946);
|
||||
cursor: pointer;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.seek-time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #888);
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.podcast-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px 0 12px;
|
||||
border-bottom: 1px solid var(--border, #333);
|
||||
margin-bottom: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.podcast-pane { }
|
||||
|
||||
.podcast-feed-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.podcast-feed-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border, #222);
|
||||
}
|
||||
|
||||
.podcast-feed-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.podcast-feed-title {
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.podcast-feed-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.podcast-thumb {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.podcast-thumb-lg {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.podcast-thumb-placeholder {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--surface, #1e1e2e);
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.podcast-unplayed-badge {
|
||||
background: var(--accent, #e63946);
|
||||
color: #fff;
|
||||
border-radius: 99px;
|
||||
padding: 1px 7px;
|
||||
font-size: 0.7rem;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.episode-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.episode-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 0;
|
||||
border-bottom: 1px solid var(--border, #222);
|
||||
}
|
||||
|
||||
.episode-item.episode-played {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.episode-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.episode-title {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.episode-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.podcast-search-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.podcast-search-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border, #222);
|
||||
}
|
||||
|
||||
.podcast-search-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.podcast-queue-ol {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.podcast-feed-header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.podcast-feed-header-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.feed-refresh-btn { margin-left: auto; flex-shrink: 0; }
|
||||
|
||||
/* Clickable episode title (episode list) */
|
||||
.ep-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.ep-clickable:hover {
|
||||
color: var(--accent, #e63946);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Clickable episode title / feed name in the now-playing bar */
|
||||
.podcast-track-link,
|
||||
.podcast-station-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
.podcast-track-link:hover,
|
||||
.podcast-station-link:hover {
|
||||
color: var(--accent, #e63946);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ===== SIDEBAR ===== */
|
||||
|
||||
.sidebar-overlay {
|
||||
|
|
@ -1588,90 +1353,6 @@ body.dnd-mode .timer-display {
|
|||
margin: 12px 0 4px;
|
||||
}
|
||||
|
||||
/* Inbox toolbar + checkboxes */
|
||||
.inbox-toolbar {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 0 0 8px; flex-wrap: wrap; min-height: 32px;
|
||||
}
|
||||
.inbox-select-all-label {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
font-size: 0.82rem; color: var(--text-muted, #888); cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.inbox-bulk-actions {
|
||||
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
|
||||
}
|
||||
.inbox-load-more {
|
||||
display: flex; align-items: center; gap: 6px; margin-left: auto;
|
||||
}
|
||||
.inbox-checkbox-label {
|
||||
display: flex; align-items: center; flex-shrink: 0;
|
||||
padding-right: 4px; cursor: pointer;
|
||||
}
|
||||
.inbox-checkbox-label input[type="checkbox"] {
|
||||
width: 15px; height: 15px; cursor: pointer; accent-color: var(--accent, #e63946);
|
||||
}
|
||||
.episode-item.inbox-selected { background: var(--surface-alt, #2a2a3e); }
|
||||
|
||||
/* Sleep timer */
|
||||
.sleep-timer-btn {
|
||||
font-size: 0.68rem; padding: 1px 5px; white-space: nowrap;
|
||||
border: 1px solid var(--border, #444); border-radius: 3px; opacity: 0.7;
|
||||
}
|
||||
.sleep-timer-btn:hover { opacity: 1; }
|
||||
.sleep-timer-menu {
|
||||
position: absolute; background: var(--surface, #1e1e2e);
|
||||
border: 1px solid var(--border, #444); border-radius: 6px; padding: 4px;
|
||||
display: flex; flex-direction: column; gap: 2px; z-index: 200;
|
||||
bottom: calc(100% + 4px); right: 0;
|
||||
}
|
||||
.sleep-timer-option {
|
||||
background: none; border: none; color: var(--fg, #fff);
|
||||
padding: 5px 12px; text-align: left; cursor: pointer;
|
||||
font-size: 0.85rem; border-radius: 4px;
|
||||
}
|
||||
.sleep-timer-option:hover { background: var(--accent, #e63946); color: #fff; }
|
||||
|
||||
/* Episode filter */
|
||||
.episode-search-bar { padding: 6px 0 8px; }
|
||||
.episode-search-bar .search-input { width: 100%; box-sizing: border-box; }
|
||||
|
||||
/* Episode meta line */
|
||||
.episode-meta {
|
||||
display: flex; gap: 8px; font-size: 0.78rem;
|
||||
color: var(--text-muted, #888); margin-top: 2px; flex-wrap: wrap;
|
||||
}
|
||||
.episode-date { font-weight: 500; color: var(--fg, #ccc); }
|
||||
.episode-feed-link { cursor: pointer; }
|
||||
.episode-feed-link:hover { text-decoration: underline; color: var(--accent, #e63946); }
|
||||
.episode-dur { color: var(--text-muted, #888); }
|
||||
|
||||
/* Episode progress bar */
|
||||
.episode-progress-bar {
|
||||
height: 3px; background: var(--border, #333);
|
||||
border-radius: 2px; margin-top: 5px; overflow: hidden;
|
||||
}
|
||||
.episode-progress-fill {
|
||||
height: 100%; background: var(--accent, #e63946); border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Queue drag-and-drop */
|
||||
.drag-handle { cursor: grab; color: var(--text-muted, #666); padding: 0 6px; user-select: none; }
|
||||
.drag-handle:active { cursor: grabbing; }
|
||||
#podcast-queue-ol li.dragging { opacity: 0.4; border: 1px dashed var(--accent, #e63946); }
|
||||
|
||||
/* Feed list toolbar */
|
||||
.feed-list-toolbar {
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
padding: 0 0 10px; flex-wrap: wrap;
|
||||
}
|
||||
.feed-list-toolbar .search-input { flex: 1; min-width: 140px; }
|
||||
.feed-sort-select {
|
||||
background: var(--surface, #1e1e2e); color: var(--fg, #fff);
|
||||
border: 1px solid var(--border, #444); border-radius: 4px;
|
||||
padding: 4px 6px; font-size: 0.82rem; cursor: pointer;
|
||||
}
|
||||
|
||||
.tts-lang-select {
|
||||
background: var(--surface, #1e1e2e); color: var(--fg, #fff);
|
||||
border: 1px solid var(--border, #444); border-radius: 4px;
|
||||
|
|
@ -1682,11 +1363,6 @@ body.dnd-mode .timer-display {
|
|||
|
||||
@media (max-width: 600px) {
|
||||
.sidebar { width: 100vw; }
|
||||
.podcast-seek-bar { padding: 0 6px; }
|
||||
.podcast-thumb { width: 40px; height: 40px; }
|
||||
.podcast-thumb-lg { width: 60px; height: 60px; }
|
||||
.podcast-feed-actions { flex-direction: column; }
|
||||
.episode-actions { flex-direction: column; }
|
||||
|
||||
/* Reader auf Mobile: Header kompakter, Fortschrittsfeld versteckt */
|
||||
.reader-header { padding: 6px 8px; gap: 4px; }
|
||||
|
|
|
|||
1073
static/js/app.js
1073
static/js/app.js
File diff suppressed because it is too large
Load diff
1069
static/js/podcasts-web.js
Normal file
1069
static/js/podcasts-web.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2,11 +2,14 @@
|
|||
* diora service worker — caches the app shell for offline use.
|
||||
*/
|
||||
|
||||
const CACHE = 'diora-v51';
|
||||
const PODCAST_CACHE = 'diora-podcast-v1';
|
||||
const CACHE = 'diora-v52';
|
||||
const SHELL = [
|
||||
'/static/css/app.css',
|
||||
'/static/js/app.js',
|
||||
'/static/js/podcasts-web.js',
|
||||
'/static/podcasts/native-podcast.js',
|
||||
'/static/podcasts/podcast-ui.css',
|
||||
'/static/podcasts/podcasts-web.css',
|
||||
'/static/manifest.json',
|
||||
'/static/fonts/SourceSerif4-normal-latin.woff2',
|
||||
'/static/fonts/SourceSerif4-normal-latin-ext.woff2',
|
||||
|
|
@ -25,12 +28,13 @@ self.addEventListener('install', function (event) {
|
|||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate: remove stale caches from previous versions
|
||||
// Activate: remove stale caches from previous versions (also the old
|
||||
// 'diora-podcast-v1' of episodes the web app used to download)
|
||||
self.addEventListener('activate', function (event) {
|
||||
event.waitUntil(
|
||||
caches.keys().then(function (keys) {
|
||||
return Promise.all(
|
||||
keys.filter(function (key) { return key !== CACHE && key !== PODCAST_CACHE; })
|
||||
keys.filter(function (key) { return key !== CACHE; })
|
||||
.map(function (key) { return caches.delete(key); })
|
||||
);
|
||||
}).then(function () {
|
||||
|
|
@ -68,21 +72,6 @@ self.addEventListener('fetch', function (event) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Podcast audio: serve from podcast cache first, then network
|
||||
if (event.request.destination === 'audio') {
|
||||
event.respondWith(
|
||||
caches.open(PODCAST_CACHE).then(function (cache) {
|
||||
return cache.match(event.request).then(function (cached) {
|
||||
if (cached) return cached;
|
||||
return fetch(event.request).then(function (response) {
|
||||
return response;
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache-first for pre-defined shell assets
|
||||
const isShell = SHELL.some(function (s) { return url.pathname === s; });
|
||||
if (isShell) {
|
||||
|
|
|
|||
1901
static/podcasts/native-podcast.js
Normal file
1901
static/podcasts/native-podcast.js
Normal file
File diff suppressed because it is too large
Load diff
529
static/podcasts/podcast-ui.css
Normal file
529
static/podcasts/podcast-ui.css
Normal file
|
|
@ -0,0 +1,529 @@
|
|||
/* Copied from diora-android 62f3a69 2026-10-02: the podcast parts of web-native/static/native.css
|
||||
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
|
||||
The web's own additions are in podcasts-web.css. */
|
||||
|
||||
/* ---- bottom sheet ---------------------------------------------------------------- */
|
||||
|
||||
#nui-sheet {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 900;
|
||||
}
|
||||
|
||||
.nui-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.nui-panel {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
background: var(--nui-panel);
|
||||
border-top: 1px solid var(--border);
|
||||
border-radius: 12px 12px 0 0;
|
||||
padding: 8px 0 12px;
|
||||
animation: nui-up 0.18s ease-out;
|
||||
}
|
||||
|
||||
@keyframes nui-up {
|
||||
from { transform: translateY(40px); opacity: 0; }
|
||||
to { transform: none; opacity: 1; }
|
||||
}
|
||||
|
||||
.nui-sheet-title {
|
||||
padding: 10px 20px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--nui-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nui-row,
|
||||
.nui-row-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
padding: 6px 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nui-row:active,
|
||||
.nui-row-main:active {
|
||||
background: #1d1d1d;
|
||||
}
|
||||
|
||||
.nui-row-icon {
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.nui-row-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nui-row-hint {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--nui-muted);
|
||||
}
|
||||
|
||||
.nui-danger { color: var(--accent); }
|
||||
.nui-active .nui-row-label { color: var(--accent); }
|
||||
|
||||
.nui-row-split {
|
||||
padding: 0 16px 0 0;
|
||||
}
|
||||
.nui-row-split .nui-row-main {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.nui-seg {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.nui-seg button {
|
||||
min-width: 44px;
|
||||
min-height: 34px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--nui-muted);
|
||||
font: inherit;
|
||||
}
|
||||
.nui-seg button.nui-seg-on {
|
||||
background: var(--fg);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
/* ---- podcasts (phones only: html.nui-podcasts, see native-podcast.js) ------------ */
|
||||
|
||||
html.nui-podcasts #tab-podcasts > :not(#nui-pod) { display: none !important; }
|
||||
html.nui-pod-playing #nui-mini { display: none !important; }
|
||||
html.nui-radio.nui-pod-mini-open body {
|
||||
padding-bottom: calc(124px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
#nui-pod-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin: 0 -4px 8px 0;
|
||||
}
|
||||
#nui-pod-bar .native-brand { font-size: 22px; font-weight: 700; letter-spacing: 0.04em; }
|
||||
.nui-pod-status { font-size: 12px; font-weight: 400; color: var(--nui-muted); }
|
||||
|
||||
.nui-pod-tabs {
|
||||
display: flex;
|
||||
margin: 0 0 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.nui-pod-tab {
|
||||
flex: 1 0 auto;
|
||||
min-height: var(--nui-tap);
|
||||
padding: 0 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: none;
|
||||
color: var(--nui-muted);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nui-pod-tab.active { color: inherit; font-weight: 700; border-bottom-color: var(--accent); }
|
||||
.nui-pod-count {
|
||||
min-width: 20px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
background: #2a2a2a;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
.nui-pod-tab.active .nui-pod-count { background: var(--accent); color: #fff; }
|
||||
|
||||
.nui-pod-summary { padding: 6px 2px 10px; font-size: 12px; color: var(--nui-muted); }
|
||||
.nui-pod-empty { padding: 32px 12px; color: var(--nui-muted); text-align: center; line-height: 1.5; }
|
||||
|
||||
.nui-pod-list { display: flex; flex-direction: column; }
|
||||
.nui-pod-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 72px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.nui-pod-row:active { background: #141414; }
|
||||
.nui-pod-row.nui-pod-played .nui-pod-title { color: var(--nui-muted); font-weight: 400; }
|
||||
.nui-pod-row.nui-pod-current .nui-pod-title { color: var(--accent); }
|
||||
.nui-pod-row.nui-pod-current .nui-pod-title::before { content: '▶ '; }
|
||||
|
||||
.nui-pod-art {
|
||||
flex: none;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #1c1c1c;
|
||||
}
|
||||
.nui-pod-art img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.nui-pod-art-big { width: 72px; height: 72px; }
|
||||
.nui-pod-art-mini { width: 44px; height: 44px; margin-left: 10px; }
|
||||
.nui-pod-art-player { width: min(72vw, 320px); height: min(72vw, 320px); border-radius: 8px; align-self: center; }
|
||||
|
||||
.nui-pod-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||
.nui-pod-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.nui-pod-meta {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--nui-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nui-pod-progress { height: 3px; margin-top: 4px; border-radius: 2px; background: #2a2a2a; overflow: hidden; max-width: 160px; }
|
||||
.nui-pod-progress > span { display: block; height: 100%; background: var(--accent); }
|
||||
.nui-pod-badge {
|
||||
flex: none;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
color: var(--nui-muted);
|
||||
}
|
||||
.nui-pod-queued { color: var(--green); }
|
||||
|
||||
.nui-pod-handle {
|
||||
flex: none;
|
||||
width: var(--nui-tap);
|
||||
height: var(--nui-tap);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
color: var(--nui-muted);
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
}
|
||||
.nui-pod-queue .nui-pod-row { transition: transform 0.15s ease; }
|
||||
.nui-pod-queue .nui-pod-row.nui-pod-dragging {
|
||||
transition: none;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
background: #1a1a1a;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.nui-pod-feedhead { display: flex; align-items: center; gap: 10px; margin: 4px 0 10px; }
|
||||
.nui-pod-switch {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
column-gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 14px;
|
||||
}
|
||||
.nui-pod-switch input { width: 20px; height: 20px; accent-color: var(--accent); }
|
||||
.nui-pod-switch .nui-pod-meta { grid-column: 2; }
|
||||
|
||||
.nui-pod-search { display: flex; gap: 8px; margin: 4px 0 10px; }
|
||||
.nui-pod-search input { flex: 1; min-width: 0; min-height: var(--nui-tap); font-size: 16px; }
|
||||
.nui-pod-search .btn { min-height: var(--nui-tap); }
|
||||
|
||||
/* mini player in the bottom bar */
|
||||
#nui-pod-mini {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: 64px;
|
||||
padding: 0 4px 0 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
#nui-pod-mini[hidden] { display: none; }
|
||||
.nui-pod-mini-bar { position: absolute; left: 0; right: 0; top: -1px; height: 2px; background: #2a2a2a; }
|
||||
.nui-pod-mini-bar span { display: block; height: 100%; background: var(--accent); }
|
||||
.nui-pod-skip { font-size: 20px; }
|
||||
|
||||
/* full player */
|
||||
#nui-pod-player {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 880;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.nui-pod-player-panel {
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
background: #0b0b0b;
|
||||
border-top: 1px solid var(--border);
|
||||
border-radius: 14px 14px 0 0;
|
||||
padding: 8px 20px calc(24px + env(safe-area-inset-bottom, 0px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.nui-pod-player-top { display: flex; justify-content: center; }
|
||||
.nui-pod-player-top .nui-icon { font-size: 26px; color: var(--nui-muted); }
|
||||
.nui-pod-player-title { font-size: 18px; font-weight: 700; line-height: 1.3; text-align: center; }
|
||||
.nui-pod-player-feed { margin-top: -6px; font-size: 13px; color: var(--nui-muted); text-align: center; }
|
||||
.nui-pod-seek { width: 100%; accent-color: var(--accent); height: 28px; }
|
||||
.nui-pod-times { display: flex; justify-content: space-between; margin-top: -8px; font-size: 12px; color: var(--nui-muted); font-variant-numeric: tabular-nums; }
|
||||
.nui-pod-controls { display: flex; align-items: center; justify-content: center; gap: 28px; }
|
||||
.nui-pod-controls .nui-icon { font-size: 18px; font-weight: 700; }
|
||||
.nui-pod-back::before { content: '↺'; font-size: 26px; margin-right: 2px; }
|
||||
.nui-pod-fwd::after { content: '↻'; font-size: 26px; margin-left: 2px; }
|
||||
.nui-pod-play {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent) !important;
|
||||
color: #fff !important;
|
||||
font-size: 26px !important;
|
||||
}
|
||||
.nui-pod-extra { display: flex; justify-content: space-around; }
|
||||
.nui-pod-speed { font-size: 15px !important; font-weight: 700; min-width: 64px; }
|
||||
|
||||
/* e-reader mode on a phone: black on white */
|
||||
body.eink .nui-pod-row { background: #fff; }
|
||||
body.eink .nui-pod-row:active { background: #ddd; }
|
||||
body.eink .nui-pod-count { background: #ddd; color: #000; }
|
||||
body.eink .nui-pod-tab.active { border-bottom-color: #000; color: #000; }
|
||||
body.eink .nui-pod-tab.active .nui-pod-count { background: #000; color: #fff; }
|
||||
body.eink .nui-pod-progress { background: #ccc; }
|
||||
body.eink .nui-pod-progress > span,
|
||||
body.eink .nui-pod-mini-bar span { background: #000; }
|
||||
body.eink .nui-pod-row.nui-pod-current .nui-pod-title { color: #000; }
|
||||
body.eink .nui-pod-player-panel { background: #fff; border-top: 2px solid #000; }
|
||||
body.eink .nui-pod-play { background: #000 !important; }
|
||||
body.eink .nui-pod-art { background: #eee; }
|
||||
|
||||
/* podcast queue tools: sort, choose several, lock */
|
||||
.nui-pod-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.nui-pod-head .nui-pod-summary { padding: 6px 2px; }
|
||||
.nui-pod-tools { display: flex; align-items: center; margin-right: -6px; }
|
||||
.nui-pod-tools .nui-icon { font-size: 18px; color: var(--nui-muted); }
|
||||
.nui-pod-lock.on { color: var(--accent) !important; }
|
||||
.nui-pod-selbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 6px;
|
||||
margin: 0 -12px 4px;
|
||||
padding: 6px 12px 8px;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.nui-pod-selcount { flex: 1; min-width: 0; font-size: 14px; font-weight: 700; }
|
||||
.nui-pod-selactions { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.nui-pod-selactions .btn { min-height: 38px; flex: 1 0 auto; }
|
||||
.nui-pod-selactions .btn:disabled { opacity: 0.4; }
|
||||
.nui-pod-check { flex: none; width: 28px; font-size: 22px; color: var(--nui-muted); text-align: center; }
|
||||
.nui-pod-check.on { color: var(--accent); }
|
||||
.nui-pod-row.nui-pod-selecting { user-select: none; -webkit-user-select: none; }
|
||||
body.eink .nui-pod-check.on,
|
||||
body.eink .nui-pod-lock.on { color: #000 !important; }
|
||||
body.eink .nui-pod-selbar { background: #fff; }
|
||||
|
||||
/* podcast playback: per-podcast chips, sleep timer */
|
||||
.nui-pod-feedprefs { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 0; border-bottom: 1px solid var(--border); }
|
||||
.nui-pod-chip {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
min-height: 36px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
background: none;
|
||||
color: var(--nui-muted);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
.nui-pod-chip strong { color: inherit; font-weight: 700; }
|
||||
.nui-pod-chip.on { border-color: var(--accent); color: inherit; }
|
||||
.nui-pod-chip.on strong { color: var(--accent); }
|
||||
.nui-pod-sleep { font-size: 15px !important; white-space: nowrap; }
|
||||
.nui-pod-sleep.on { color: var(--accent) !important; font-weight: 700; }
|
||||
/* unsubscribing: a quiet link, asked once more in place */
|
||||
.nui-pod-unsub { display: flex; justify-content: flex-end; padding: 6px 0; border-bottom: 1px solid var(--border); }
|
||||
.nui-pod-unsub-btn {
|
||||
min-height: var(--nui-tap, 44px);
|
||||
padding: 0 4px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--nui-muted);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
.nui-pod-unsub-ask { display: block; padding: 12px 0; }
|
||||
.nui-pod-unsub-ask .nui-pod-title { display: block; overflow: visible; }
|
||||
.nui-pod-unsub-ask .nui-pod-meta { margin-top: 4px; line-height: 1.4; white-space: normal; overflow: visible; }
|
||||
.nui-pod-unsub-actions { display: flex; gap: 8px; margin-top: 10px; }
|
||||
.nui-pod-unsub-actions .btn { min-height: var(--nui-tap, 44px); }
|
||||
.nui-pod-unsub-yes { color: var(--accent); border-color: var(--accent); font-weight: 700; }
|
||||
body.eink .nui-pod-unsub-yes { color: #000; border-color: #000; }
|
||||
body.eink .nui-pod-chip.on { border-color: #000; }
|
||||
body.eink .nui-pod-chip.on strong,
|
||||
body.eink .nui-pod-sleep.on { color: #000 !important; }
|
||||
|
||||
/* podcast chapters and shownotes */
|
||||
.nui-pod-notesbox { margin-top: 6px; border-top: 1px solid var(--border); padding-top: 6px; min-height: 40px; }
|
||||
.nui-pod-subtabs { display: flex; gap: 4px; margin-bottom: 6px; }
|
||||
.nui-pod-subtab {
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: none;
|
||||
color: var(--nui-muted);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
.nui-pod-subtab.active { color: inherit; font-weight: 700; border-bottom-color: var(--accent); }
|
||||
.nui-pod-chapters { list-style: none; margin: 0; padding: 0; }
|
||||
.nui-pod-chapter {
|
||||
display: grid;
|
||||
grid-template-columns: 4.5em 1fr;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
padding: 10px 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.nui-pod-chapter-t { color: var(--nui-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.nui-pod-chapter.current { color: var(--accent); font-weight: 700; }
|
||||
.nui-pod-chapter.current .nui-pod-chapter-t { color: var(--accent); }
|
||||
.nui-pod-notes { font-size: 14px; line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.nui-pod-notes img { max-width: 100%; height: auto; }
|
||||
.nui-pod-notes a { color: var(--accent); }
|
||||
.nui-pod-notes .nui-pod-stamp { font-weight: 700; text-decoration: none; border-bottom: 1px dotted currentColor; }
|
||||
.nui-pod-notes table { display: block; overflow-x: auto; }
|
||||
.nui-pod-notes-sheet .nui-pod-player-panel { max-height: 88%; }
|
||||
.nui-pod-notes-play { align-self: center; min-height: var(--nui-tap); }
|
||||
body.eink .nui-pod-chapter.current,
|
||||
body.eink .nui-pod-chapter.current .nui-pod-chapter-t,
|
||||
body.eink .nui-pod-notes a { color: #000; }
|
||||
body.eink .nui-pod-subtab.active { border-bottom-color: #000; color: #000; }
|
||||
|
||||
/* ---- podcast downloads (Downloads.kt; the "downloads" block of native-podcast.js) ---- */
|
||||
.nui-pod-dl { min-width: 36px !important; margin-left: -6px; font-size: 22px; color: var(--nui-muted); }
|
||||
.nui-pod-dl-failed { color: var(--yellow); }
|
||||
/* ✓ in a ring: set apart from the plain green ✓ of "in der Warteschlange" */
|
||||
.nui-pod-dl.nui-pod-dl-done {
|
||||
position: relative;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--green);
|
||||
}
|
||||
.nui-pod-dl-done::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
margin: -11px 0 0 -11px;
|
||||
border: 1px solid var(--green);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.nui-pod-dl-ring > span {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: var(--nui-muted);
|
||||
background:
|
||||
radial-gradient(closest-side, var(--bg) 78%, transparent 80% 100%),
|
||||
conic-gradient(var(--accent) var(--p, 0%), #2a2a2a 0);
|
||||
}
|
||||
body.eink .nui-pod-dl-done { color: #000; }
|
||||
body.eink .nui-pod-dl-done::after { border-color: #000; }
|
||||
body.eink .nui-pod-dl-ring > span {
|
||||
color: #000;
|
||||
background:
|
||||
radial-gradient(closest-side, #fff 78%, transparent 80% 100%),
|
||||
conic-gradient(#000 var(--p, 0%), #ccc 0);
|
||||
}
|
||||
|
||||
/* tab row scrolls sideways without a visible bar; the lock emoji in the app's greys */
|
||||
.nui-pod-tabs::-webkit-scrollbar { display: none; }
|
||||
.nui-pod-lock { filter: grayscale(1); opacity: 0.6; }
|
||||
.nui-pod-lock.on { opacity: 1; filter: grayscale(1) brightness(1.4); }
|
||||
|
||||
/* episode filters: one row that scrolls sideways */
|
||||
.nui-pod-filters { display: flex; gap: 6px; overflow-x: auto; padding: 6px 0 10px; scrollbar-width: none; }
|
||||
.nui-pod-filters::-webkit-scrollbar { display: none; }
|
||||
.nui-pod-filters .nui-pod-chip { flex: none; }
|
||||
.nui-pod-more { display: block; margin: 12px auto; min-height: var(--nui-tap); }
|
||||
.nui-pod-star { color: var(--yellow); }
|
||||
.nui-pod-fav.on { color: var(--yellow) !important; }
|
||||
.nui-pod-stat-total { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 12px 0 18px; }
|
||||
.nui-pod-stat-total strong { font-size: 30px; }
|
||||
.nui-pod-stat-total span { font-size: 13px; color: var(--nui-muted); }
|
||||
.nui-pod-stat-row { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border); }
|
||||
.nui-pod-stat-bar { max-width: none; height: 5px; }
|
||||
body.eink .nui-pod-star, body.eink .nui-pod-fav.on { color: #000 !important; }
|
||||
|
||||
/* feed errors, chapter navigation, transcript */
|
||||
.nui-pod-feederr { font-size: 12px; color: var(--yellow); }
|
||||
.nui-pod-feederr-box { padding: 8px 10px; margin: 4px 0 8px; border: 1px solid var(--yellow); border-radius: 6px; line-height: 1.4; }
|
||||
.nui-pod-chapnav { display: flex; align-items: center; gap: 4px; margin-bottom: 4px; }
|
||||
.nui-pod-chapnow { flex: 1; min-width: 0; text-align: center; font-size: 13px; color: var(--nui-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nui-pod-transcript { max-height: 60vh; overflow-y: auto; padding-right: 4px; }
|
||||
.nui-pod-seg { margin: 0; padding: 6px 4px; font-size: 14px; line-height: 1.5; color: var(--nui-muted); border-radius: 4px; cursor: pointer; }
|
||||
.nui-pod-seg.current { color: inherit; background: #1a1a1a; }
|
||||
.nui-pod-speaker { color: inherit; }
|
||||
body.eink .nui-pod-feederr { color: #000; }
|
||||
body.eink .nui-pod-feederr-box { border-color: #000; }
|
||||
body.eink .nui-pod-seg { color: #444; }
|
||||
body.eink .nui-pod-seg.current { color: #000; background: #e6e6e6; }
|
||||
189
static/podcasts/podcasts-web.css
Normal file
189
static/podcasts/podcasts-web.css
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
/* Podcasts in the browser (static/js/podcasts-web.js): the app's podcast page
|
||||
— its styles are podcast-ui.css, copied from diora-android's native.css by
|
||||
tools/sync_podcast_ui.py — set into the web layout. The app's look stays
|
||||
(black, monospace, red accent); on wide screens the podcast page, its mini
|
||||
player and its sheets keep to one centred column. Only loaded in browsers;
|
||||
everything is scoped to html.dpw. */
|
||||
|
||||
html.dpw {
|
||||
--nui-tap: 44px;
|
||||
--nui-muted: #9a9a9a;
|
||||
--nui-panel: #111;
|
||||
--dpw-surface: #0b0b0b;
|
||||
--dpw-hover: #111;
|
||||
--dpw-col: 760px;
|
||||
}
|
||||
|
||||
/* The placeholder shown until the page is there. */
|
||||
html.nui-podcasts #tab-podcasts > :not(#nui-pod) { display: none !important; }
|
||||
.dpw-loading { padding: 2rem 0; text-align: center; }
|
||||
|
||||
html.dpw #nui-pod {
|
||||
max-width: var(--dpw-col);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ---- what the app has in its other native styles ---------------------------------- */
|
||||
|
||||
html.dpw .nui-icon {
|
||||
min-width: var(--nui-tap);
|
||||
min-height: var(--nui-tap);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
padding: 0;
|
||||
}
|
||||
html.dpw .nui-icon:hover { color: var(--fg); }
|
||||
|
||||
html.dpw .nui-bar-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---- the dock: mini player right above the radio bar ----------------------------- */
|
||||
|
||||
html.dpw #nui-dock {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: var(--dpw-bar-h, 72px);
|
||||
z-index: 190; /* under the reader (200), the sidebar and the sheets */
|
||||
max-width: var(--dpw-col);
|
||||
margin: 0 auto;
|
||||
}
|
||||
html.dpw body.dnd-mode #nui-dock { display: none; }
|
||||
html.dpw #nui-pod-mini {
|
||||
background: var(--dpw-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-bottom: none;
|
||||
border-radius: 10px 10px 0 0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 -6px 18px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
html.dpw .nui-pod-mini-bar { top: 0; }
|
||||
html.dpw.nui-pod-mini-open .main-content {
|
||||
padding-bottom: calc(var(--dpw-bar-h, 72px) + 64px + 2rem);
|
||||
}
|
||||
|
||||
html.dpw .nui-mini-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
padding: 0 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
html.dpw .nui-mini-station,
|
||||
html.dpw .nui-mini-track {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
html.dpw .nui-mini-station { font-size: 15px; font-weight: 700; }
|
||||
html.dpw .nui-mini-track { font-size: 12px; font-weight: 400; color: var(--nui-muted); }
|
||||
html.dpw .nui-mini-track:empty { display: none; }
|
||||
html.dpw .nui-mini-toggle { font-size: 22px; color: var(--accent); }
|
||||
|
||||
#nui-radio-toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(var(--dpw-bar-h, 72px) + 80px);
|
||||
transform: translateX(-50%);
|
||||
z-index: 950;
|
||||
max-width: calc(100% - 32px);
|
||||
padding: 10px 16px;
|
||||
border-radius: 6px;
|
||||
background: #222;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
text-shadow: none;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
#nui-radio-toast.nui-show { opacity: 1; }
|
||||
|
||||
/* ---- a mouse: hover states the phone doesn't need --------------------------------- */
|
||||
|
||||
@media (hover: hover) {
|
||||
html.dpw #nui-pod .nui-pod-row { cursor: pointer; }
|
||||
html.dpw #nui-pod .nui-pod-row:hover { background: var(--dpw-hover); }
|
||||
html.dpw .nui-row:hover { background: #1a1a1a; }
|
||||
html.dpw .nui-pod-chip:hover,
|
||||
html.dpw .nui-pod-tab:hover,
|
||||
html.dpw .nui-pod-subtab:hover { color: var(--fg); }
|
||||
html.dpw .nui-pod-chapter:hover,
|
||||
html.dpw .nui-pod-seg:hover { background: #141414; }
|
||||
}
|
||||
html.dpw .nui-row,
|
||||
html.dpw .nui-pod-tab,
|
||||
html.dpw .nui-pod-chip,
|
||||
html.dpw .nui-pod-subtab { cursor: pointer; }
|
||||
|
||||
/* ---- wide screens: sheets and the player in the column ------------------------------ */
|
||||
|
||||
@media (min-width: 761px) {
|
||||
html.dpw .nui-panel {
|
||||
margin: 0 auto;
|
||||
max-width: 560px;
|
||||
border: 1px solid var(--border);
|
||||
border-bottom: none;
|
||||
}
|
||||
html.dpw #nui-pod-player { justify-content: center; }
|
||||
html.dpw .nui-pod-player-panel {
|
||||
max-width: var(--dpw-col);
|
||||
max-height: 92vh;
|
||||
border: 1px solid var(--border);
|
||||
border-bottom: none;
|
||||
}
|
||||
html.dpw .nui-pod-art-player { width: 240px; height: 240px; }
|
||||
html.dpw .nui-pod-selbar { margin: 0 0 4px; padding: 6px 0 8px; }
|
||||
}
|
||||
|
||||
/* Phones: the dock spans the screen like in the app. */
|
||||
@media (max-width: 760px) {
|
||||
html.dpw #nui-pod-mini { border-left: none; border-right: none; border-radius: 0; box-shadow: none; }
|
||||
html.dpw .nui-pod-selbar { margin: 0 -0.75rem 4px; padding: 6px 0.75rem 8px; }
|
||||
}
|
||||
|
||||
/* ---- the web's light scheme (◑, body.bright-bg) ------------------------------------- */
|
||||
|
||||
html.dpw body.bright-bg {
|
||||
--nui-muted: #555;
|
||||
--nui-panel: #f4f4f4;
|
||||
--dpw-surface: #f7f7f7;
|
||||
--dpw-hover: #f0f0f0;
|
||||
}
|
||||
/* app.css resolves --text-muted (the colour of .btn-icon) on :root, so it stays
|
||||
white under body.bright-bg; inside the podcast parts it follows --fg. */
|
||||
html.dpw body.bright-bg #nui-pod,
|
||||
html.dpw body.bright-bg #nui-dock,
|
||||
html.dpw body.bright-bg #nui-sheet,
|
||||
html.dpw body.bright-bg #nui-pod-player { --text-muted: var(--fg); --text: var(--fg); }
|
||||
html.dpw body.bright-bg .nui-pod-player-panel { background: var(--dpw-surface); }
|
||||
html.dpw body.bright-bg .nui-pod-row:active,
|
||||
html.dpw body.bright-bg .nui-row:active,
|
||||
html.dpw body.bright-bg .nui-row:hover,
|
||||
html.dpw body.bright-bg .nui-pod-chapter:hover,
|
||||
html.dpw body.bright-bg .nui-pod-seg:hover,
|
||||
html.dpw body.bright-bg .nui-pod-seg.current,
|
||||
html.dpw body.bright-bg .nui-pod-queue .nui-pod-row.nui-pod-dragging { background: #e8e8e8; }
|
||||
html.dpw body.bright-bg .nui-pod-art { background: #e4e4e4; }
|
||||
html.dpw body.bright-bg .nui-pod-count,
|
||||
html.dpw body.bright-bg .nui-pod-progress,
|
||||
html.dpw body.bright-bg .nui-pod-mini-bar { background: #d4d4d4; }
|
||||
html.dpw body.bright-bg .nui-pod-tab.active .nui-pod-count { background: var(--accent); color: #fff; }
|
||||
html.dpw body.bright-bg #nui-pod-mini { box-shadow: 0 -4px 14px rgba(0, 0, 0, 0.12); }
|
||||
|
|
@ -22,23 +22,6 @@
|
|||
<button class="btn-icon" id="dnd-btn" onclick="toggleDND()" title="Focus mode (hides UI, press Esc to exit)">⊙</button>
|
||||
<button class="btn-icon" id="focus-station-btn" onclick="openRadioSidebar()" title="Radio">◉</button>
|
||||
</div>
|
||||
<div class="podcast-seek-bar" id="podcast-seek-bar" style="display:none;">
|
||||
<button class="btn-icon skip-btn" onclick="skipBack()" title="Back 15s">« 15</button>
|
||||
<span class="seek-time" id="seek-current">0:00</span>
|
||||
<input type="range" id="seek-slider" class="seek-slider" min="0" max="100" value="0">
|
||||
<span class="seek-time" id="seek-duration">0:00</span>
|
||||
<button class="btn-icon skip-btn" onclick="skipForward()" title="Forward 30s">30 »</button>
|
||||
<div class="speed-btns" id="speed-btns">
|
||||
<button class="speed-btn" onclick="setPlaybackRate(0.75)">¾×</button>
|
||||
<button class="speed-btn active" onclick="setPlaybackRate(1)">1×</button>
|
||||
<button class="speed-btn" onclick="setPlaybackRate(1.25)">1¼×</button>
|
||||
<button class="speed-btn" onclick="setPlaybackRate(1.5)">1½×</button>
|
||||
<button class="speed-btn" onclick="setPlaybackRate(1.75)">1¾×</button>
|
||||
<button class="speed-btn" onclick="setPlaybackRate(2)">2×</button>
|
||||
<button class="speed-btn" onclick="setPlaybackRate(2.5)">2½×</button>
|
||||
</div>
|
||||
<button class="btn-icon sleep-timer-btn" id="sleep-timer-btn" onclick="openSleepTimerMenu()" title="Sleep timer">Sleep</button>
|
||||
</div>
|
||||
<div class="timer-widget" id="timer-widget">
|
||||
<span class="timer-phase" id="timer-phase-label">focus</span>
|
||||
<span class="timer-display" id="timer-display">25:00</span>
|
||||
|
|
@ -228,82 +211,8 @@
|
|||
<!-- ===== PODCASTS TAB ===== -->
|
||||
<section class="tab-panel" id="tab-podcasts" style="display:none;">
|
||||
{% if user.is_authenticated %}
|
||||
<div class="podcast-toolbar">
|
||||
<button class="btn btn-sm" onclick="showPodcastView('feeds')">Feeds</button>
|
||||
<button class="btn btn-sm" onclick="showPodcastView('inbox')">Inbox</button>
|
||||
<button class="btn btn-sm" onclick="showPodcastView('queue')">Queue</button>
|
||||
<button class="btn btn-sm" onclick="podcastSearchOpen()">+ Search</button>
|
||||
<button class="btn btn-sm" id="refresh-all-btn" onclick="refreshAllFeedsBtn(this)" title="Refresh all feeds">↻ All</button>
|
||||
<label class="btn btn-sm" for="opml-file-input">Import OPML</label>
|
||||
<input type="file" id="opml-file-input" accept=".opml,.xml" style="display:none;" onchange="importOPML(this)">
|
||||
<span id="opml-status" class="muted"></span>
|
||||
</div>
|
||||
|
||||
<!-- Search pane -->
|
||||
<div class="podcast-pane" id="podcast-search-pane" style="display:none;">
|
||||
<div class="search-bar">
|
||||
<input type="text" id="podcast-search-input" class="search-input" placeholder="Search podcasts…"
|
||||
onkeydown="if(event.key==='Enter') doPodcastSearch()">
|
||||
<button class="btn" onclick="doPodcastSearch()">Search</button>
|
||||
</div>
|
||||
<div id="podcast-search-status" class="status-msg"></div>
|
||||
<div id="podcast-search-list" class="podcast-search-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Feeds pane -->
|
||||
<div class="podcast-pane" id="podcast-feeds-pane">
|
||||
<div class="feed-list-toolbar">
|
||||
<input type="text" id="feed-filter-input" class="search-input" placeholder="Search subscriptions…"
|
||||
oninput="filterFeeds(this.value)">
|
||||
<select id="feed-sort-select" onchange="sortFeeds(this.value)" class="feed-sort-select">
|
||||
<option value="alpha">A–Z</option>
|
||||
<option value="alpha-desc">Z–A</option>
|
||||
<option value="added">Recently added</option>
|
||||
<option value="latest_episode">Most recent episode</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="podcast-feed-list" class="podcast-feed-list">
|
||||
<p class="muted">Loading…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inbox pane -->
|
||||
<div class="podcast-pane" id="podcast-inbox-pane" style="display:none;">
|
||||
<div class="inbox-toolbar">
|
||||
<label class="inbox-select-all-label">
|
||||
<input type="checkbox" id="inbox-select-all" onchange="inboxSelectAll(this.checked)">
|
||||
<span>All</span>
|
||||
</label>
|
||||
<div class="inbox-bulk-actions" id="inbox-bulk-actions" style="display:none;">
|
||||
<span id="inbox-selection-count" class="muted"></span>
|
||||
<button class="btn btn-sm" onclick="inboxBulkQueueAdd()">+Queue</button>
|
||||
<button class="btn btn-sm" onclick="inboxBulkMarkPlayed()">✓ Played</button>
|
||||
<button class="btn btn-sm" onclick="inboxBulkDownload()">⬇ Download</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="inboxBulkDismiss()">✕ Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="podcast-inbox-list" class="episode-list"></div>
|
||||
<div class="inbox-load-more" id="inbox-load-more-bar" style="display:none;">
|
||||
<button class="btn btn-sm" onclick="inboxLoadMore()">Load more</button>
|
||||
<span id="inbox-count-label" class="muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Episodes pane -->
|
||||
<div class="podcast-pane" id="podcast-episodes-pane" style="display:none;">
|
||||
<div class="episode-search-bar" id="episode-search-bar" style="display:none;">
|
||||
<input type="text" id="episode-filter-input" class="search-input"
|
||||
placeholder="Filter episodes…" oninput="filterEpisodes(this.value)">
|
||||
</div>
|
||||
<div id="podcast-feed-header" class="podcast-feed-header"></div>
|
||||
<div id="podcast-episode-list" class="episode-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Queue pane -->
|
||||
<div class="podcast-pane" id="podcast-queue-pane" style="display:none;">
|
||||
<ol id="podcast-queue-ol" class="podcast-queue-ol"></ol>
|
||||
</div>
|
||||
|
||||
{# Filled by static/js/podcasts-web.js with the app's podcast page (static/podcasts/). #}
|
||||
<p class="muted dpw-loading">Podcasts werden geladen …</p>
|
||||
{% else %}
|
||||
<p class="auth-prompt">
|
||||
<a href="{% url 'login' %}">Log in</a> or <a href="{% url 'register' %}">register</a>
|
||||
|
|
@ -415,9 +324,9 @@
|
|||
const INITIAL_SAVED = {{ saved_stations_json|safe }};
|
||||
const INITIAL_FEATURED = {{ featured_stations_json|safe }};
|
||||
const IS_AUTHENTICATED = {{ user.is_authenticated|yesno:"true,false" }};
|
||||
const INITIAL_PODCAST_FEEDS = {{ initial_podcast_feeds|safe }};
|
||||
window.USER_ID = {{ user.id|default:"null" }};
|
||||
let USER_FOCUS_STATION = {{ focus_station_json|safe }};
|
||||
</script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script src="/static/js/podcasts-web.js"></script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
78
tools/sync_podcast_ui.py
Normal file
78
tools/sync_podcast_ui.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""Copy the phone app's podcast page into the web app.
|
||||
|
||||
The browser shows the same podcast UI as the Android app: diora-android's
|
||||
web-native/static/native-podcast.js, unchanged, on top of a browser host
|
||||
(static/js/podcasts-web.js). This copies that file and the podcast parts of
|
||||
native.css (the bottom sheet and everything from the podcast section on) to
|
||||
static/podcasts/, with the source commit in a header comment.
|
||||
|
||||
Usage: python tools/sync_podcast_ui.py [path/to/diora-android]
|
||||
(default: ../diora-android next to this checkout)
|
||||
|
||||
Afterwards bump CACHE in static/js/sw.js so browsers fetch the new files.
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DEST = ROOT / 'static' / 'podcasts'
|
||||
|
||||
# Sections of native.css the podcast page needs: (start marker, end marker or None = to the end).
|
||||
CSS_SECTIONS = [
|
||||
('/* ---- bottom sheet', '/* ---- reader'),
|
||||
('/* ---- podcasts (phones only', None),
|
||||
]
|
||||
|
||||
|
||||
def source_line(android, rel):
|
||||
"""'diora-android <commit> <date>' of the file, plus a note if it has uncommitted changes."""
|
||||
def git(*args):
|
||||
return subprocess.run(['git', '-C', str(android), *args], capture_output=True, text=True).stdout.strip()
|
||||
line = 'diora-android ' + (git('log', '-1', '--format=%h %cs', '--', rel) or 'unknown commit')
|
||||
if git('status', '--short', '--', rel):
|
||||
line += ' + uncommitted changes'
|
||||
return line
|
||||
|
||||
|
||||
def cut(text, start, end):
|
||||
i = text.find(start)
|
||||
if i < 0:
|
||||
sys.exit(f'sync_podcast_ui: {start!r} not found in native.css')
|
||||
j = text.find(end, i) if end else len(text)
|
||||
if j < 0:
|
||||
sys.exit(f'sync_podcast_ui: {end!r} not found after {start!r} in native.css')
|
||||
return text[i:j].rstrip() + '\n'
|
||||
|
||||
|
||||
def main():
|
||||
android = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT.parent / 'diora-android'
|
||||
static = android / 'web-native' / 'static'
|
||||
if not (static / 'native-podcast.js').exists():
|
||||
sys.exit(f'sync_podcast_ui: no diora-android checkout at {android}')
|
||||
DEST.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
js_src = source_line(android, 'web-native/static/native-podcast.js')
|
||||
js = (static / 'native-podcast.js').read_text(encoding='utf-8')
|
||||
(DEST / 'native-podcast.js').write_text(
|
||||
f'// Copied from {js_src}: web-native/static/native-podcast.js\n'
|
||||
'// by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android\n'
|
||||
'// and copy again. The browser host is static/js/podcasts-web.js.\n\n' + js,
|
||||
encoding='utf-8', newline='\n')
|
||||
|
||||
css_src = source_line(android, 'web-native/static/native.css')
|
||||
css = (static / 'native.css').read_text(encoding='utf-8')
|
||||
parts = [cut(css, start, end) for start, end in CSS_SECTIONS]
|
||||
(DEST / 'podcast-ui.css').write_text(
|
||||
f'/* Copied from {css_src}: the podcast parts of web-native/static/native.css\n'
|
||||
' (bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.\n'
|
||||
' The web\'s own additions are in podcasts-web.css. */\n\n' + '\n'.join(parts),
|
||||
encoding='utf-8', newline='\n')
|
||||
|
||||
print(f'native-podcast.js: {js_src}')
|
||||
print(f'podcast-ui.css: {css_src}')
|
||||
print('Now bump CACHE in static/js/sw.js.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Loading…
Add table
Reference in a new issue