8.6 KiB
Radio, Ambient, Fokus
The oldest part of diora (it began as a radio player): internet radio with saved stations, history and track titles, the generative Ambient player, and the web page's Fokus timer. Written down 2026-10-24 from the code; it is the reference for the three apps.
Server: the radio app
Models (radio/models.py):
| model | what |
|---|---|
SavedStation |
a user's station: name, url (unique per user), bitrate, country, tags, favicon_url (the logo), is_favorite, notes |
StationPlay |
one listening: station, started/ended; hour, weekday, weekend filled on save (for the recommendations) |
TrackHistory |
a track title heard (user, or session_key signed out), scrobbled |
FeaturedStation |
stations picked in the admin for everybody (order, active) |
FocusSession |
a finished Fokus round |
Also: podcasts.PlayerState with kind='radio' (the player sync between devices) and
accounts.UserProfile.focus_station_url/_name (the station the Fokus timer plays).
Endpoints (radio/urls.py):
GET /— the page (templates/radio/player.html). The saved stations, the history (50) and the featured stations come rendered into it (#saved-tbody,#history-tbody,.featured-section) and asINITIAL_SAVED(withplay_count),INITIAL_FEATURED,USER_FOCUS_STATION. The apps take these parts from it (see below).GET /radio/sse/?url=&station=— server-sent events with the track title,data: {"track": …}when it changes,{"error": "stream ended"}at the end. The server opens the stream itself withIcy-MetaData: 1and readsStreamTitle(radio/icy.py). Which addresses it opens (radio/streamcheck.py, since 2026-10-24): never one in a private network (loopback, 10.x, 172.16–31.x, 192.168.x, link-local, the Docker network); without an account only the directory's stations — the page's curated lists (read from16-stations.js), the featured stations, the creamfresh stream, and what radio-browser.info lists (station=its station id, which the page sends for search results; else by the address; kept a day). An address of one's own is for saving, and saving needs an account: signed out, a pasted address offers signing in instead of ▶.POST /radio/record/— a track into the history;scrobble: truesends it to Last.fm when the account has it linked (radio/lastfm.py; "Artist - Title", else the station as artist).POST /radio/save/(by url: create or update),/radio/remove/<pk>/,/radio/favorite/<pk>/,/radio/notes/<pk>/;GET /radio/history/(last 200),POST /radio/history/<pk>/delete/.POST /radio/play/start/→play_id,/radio/play/stop/;GET /radio/recommendations/— the five stations most played around this hour on this kind of day, with a German label.POST /radio/import/— an.m3u/.m3u8file into the saved stations (#EXTINFnames) →{added, skipped}.POST /radio/focus/record/,GET /radio/focus/stats/.GET /radio/affiliate/?track=— artwork/album from iTunes (an Amazon link only withAMAZON_AFFILIATE_ENABLED).GET /radio/creamfresh-stream/,POST /radio/creamfresh-feedback/— the user's own station (radio.creamfresh.xyz) through the server, which adds its login; 👍/👎 for the DJ.GET /radio/stream-player/— an old standalone player page; nothing links to it.
Station search is not on the server: the page asks radio-browser.info directly
(de1.api.radio-browser.info/json/stations/search, 50, by click count). A pasted http(s)://
address is offered to play or save as it is.
Web page (static/js/app/)
| file | what |
|---|---|
01-core.js |
the globals: audio (one <audio>), currentStation, currentTrack, sseSource, isPlaying, currentPlayId |
10-radio.js |
playStation(url, name, id), stopPlayback, togglePlayStop, the SSE (startMetadataSSE → recordTrack), volume (0–255, diora_volume), search, saving, favourites, recommendations, the creamfresh votes, Media Session |
16-stations.js |
mood chips (they search a tag), curated lists, featured stations, the occasional donation hint, notes |
29-m3u.js |
M3U import |
80-radio-sidebar.js |
the compact radio player of the sidebar |
24-ambient.js |
the Ambient tab (below) and showRadioTab |
14-focus.js |
Fokus (below) |
Sub-tabs RADIO_SUB_TABS = ['search', 'saved', 'history'] (20-tabs.js), hash #radio/saved,
remembered in diora_active_radio_tab. On an https page an http:// stream would be blocked:
the page opens it in a new tab — unless the host plays it (window.DIORA_PLAYS_HTTP_STREAMS,
set by the apps). No HLS: what <audio> plays.
Ambient (static/ambient/index.html, "Drift")
No sound files: a generative WebAudio synthesizer. Modes (MODES): ambient music (chords,
bells, grains), noise colours, nature (rain, storm, sea, wind, stream, fire, crickets),
machines (fan, cabin, train), focus and sleep (binaural delta–beta, singing bowls,
heartbeat); each with two sliders and seeded presets in the URL hash.
The page holds it in an iframe, loaded when first needed (24-ambient.js); they talk by
postMessage ({type: 'ambient', cmd: 'start'|'stop'|'mode'|'volume'} in,
{state: 'playing'|'stopped'} out). Mode remembered in diora_ambient_mode.
Hooks for the apps: __dioraAmbientStart/Stop/Cmd/Info/Playing.
One sound at a time: Ambient starting stops the radio (stopPlayback(false)) and pauses a
podcast (__dioraPausePlayer(); Android: podcastPause(), pause only, never toggle); the
radio starting stops Ambient.
Fokus (web page only)
A Pomodoro timer (25/5 min) in its own section (nav.js: focus, webOnly): it plays the
chosen focus station, records a FocusSession after each work phase, pauses the sound for the
break; with a do-not-disturb full-screen mode (toggleDND) and a statistics table.
Android (diora-android)
- One player for radio, podcasts and audiobooks:
RadioService.kt, a Media3MediaLibraryServicewith one ExoPlayer (15 s connect / 20 s read timeout; diora's own server gets the login headers — the creamfresh proxy). After a pause of more than 3 s a station jumps back to live. Android Auto: Saved, Featured, Queue, voice search by name. Resuming after a restart plays the last station. Radio always streams on mobile data (the mobile-data rules are for episodes). Radio.kt— the page's controller: play, stop,track(title)(new metadata without rebuffering), state to the page (window.__dioraRadio).RadioPage.kt— the saved stations, history and featured stations exist only in the rendered/: the app fetches it with its login, cuts those parts out intoradio-page.jsonand splices them into its own page at markers thattools/build_web.pyset (<!--diora:saved|history|featured-->,__DIORA_RADIO_*_JSON__). So they show offline too.LocalServer.ktpasses/radio/sse/through as a stream (padded with SSE comments: WebView hands intercepted data over only when its buffer is full); POSTs go throughnative.js.web-native/static/native-radio.js— turns the page'saudiointo a remote of the native player (radioPlay/radioStop, counters against stale echoes), shows errors ("Sender nicht erreichbar"), draws the dock/rail (fromnav.js), the mini player and the radio bar.AmbientService.kt— Ambient as a foreground media service (notification with Stopp, audio focus, stops when headphones are pulled); started and stopped from the page (ambientState). Widgets for Ambient and resuming (CardWidgets.kt).- Sleep timer (
SleepTimer.kt,SleepRules.kt): counts only playing time (Ambient too), fades out over the last 10 s, shaking resets it, can stop after N episodes, can switch itself on at night (22–6 h); one that counted nothing for 3 h is dropped. - Data usage: the stream counts as
RADIO_STREAM,/radio/and/asRADIO_DATA(DataUsage.kt).
Desktop (diora-desktop)
A copy of native-radio.js; src/web/desktop.js plays with a hidden Audio() of its own
(DioraNative.radioPlay/Stop/State/Refresh/Track; media keys: pause = stop) and re-reads /
like RadioPage.kt (at most every 20 s). http:// streams go through the local router
(/__stream?u=, src/main/router.js) without their ICY data; the SSE is passed through with the
login; backgroundThrottling: false keeps the titles coming.
Known weak spots
- The login for the creamfresh stream is written into
radio/views.py; it belongs in the environment like the other secrets. - Every listener's titles cost the server a second connection to the stream.
- No recording, no alarm; station logos only from radio-browser's favicon.