'use strict'; // --------------------------------------------------------------------------- // Play / Stop // --------------------------------------------------------------------------- function playStation(url, name, stationId) { stopPlayback(false); if (window.__dioraAmbientStop) window.__dioraAmbientStop(); // A secure page can't load plain-http streams (mixed content). Native shells // that play the stream themselves set window.DIORA_PLAYS_HTTP_STREAMS. if (location.protocol === 'https:' && url.startsWith('http://') && !window.DIORA_PLAYS_HTTP_STREAMS) { window.open(url, '_blank'); return; } currentStation = { url, name, id: stationId || null }; isPlaying = true; audio.src = url; const volSlider = document.getElementById('volume'); if (volSlider) audio.volume = volSlider.value / 255; audio.play().catch(() => { // Browser may block autoplay; the user needs to interact first console.warn('Audio play blocked by browser policy.'); }); $('now-playing-station').textContent = name; $('now-playing-track').textContent = ''; $('play-stop-btn').style.display = ''; $('play-stop-btn').textContent = '⏹ Stopp'; $('play-stop-btn').classList.add('playing'); $('save-station-btn').style.display = ''; const isCreamfresh = url === CREAMFRESH_RADIO_URL; $('creamfresh-vote-up-btn').style.display = isCreamfresh ? '' : 'none'; $('creamfresh-vote-down-btn').style.display = isCreamfresh ? '' : 'none'; $('creamfresh-vote-up-btn').classList.remove('active'); $('creamfresh-vote-down-btn').classList.remove('active'); startMetadataSSE(url); startPlaySession(name, url); maybeShowDonationHint(url, name); if ('mediaSession' in navigator) { navigator.mediaSession.metadata = new MediaMetadata({title: name, artist: 'Radio'}); navigator.mediaSession.setActionHandler('play', () => { audio.play(); isPlaying = true; }); navigator.mediaSession.setActionHandler('pause', () => { audio.pause(); isPlaying = false; }); navigator.mediaSession.setActionHandler('stop', () => stopPlayback(true)); try { navigator.mediaSession.setActionHandler('seekbackward', null); } catch (_) {} try { navigator.mediaSession.setActionHandler('seekforward', null); } catch (_) {} try { navigator.mediaSession.setActionHandler('nexttrack', null); } catch (_) {} try { navigator.mediaSession.setActionHandler('previoustrack',null); } catch (_) {} navigator.mediaSession.playbackState = 'playing'; } } function stopPlayback(clearStation = true) { audio.pause(); audio.src = ''; isPlaying = false; if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'none'; if (sseSource) { sseSource.close(); sseSource = null; } $('play-stop-btn').textContent = '▶ Abspielen'; $('play-stop-btn').classList.remove('playing'); $('save-station-btn').style.display = 'none'; $('creamfresh-vote-up-btn').style.display = 'none'; $('creamfresh-vote-down-btn').style.display = 'none'; $('affiliate-section').style.display = 'none'; stopPlaySession(); const stationEl = $('now-playing-station'); const trackEl = $('now-playing-track'); if (clearStation) { currentStation = null; currentTrack = ''; stationEl.textContent = '— kein Sender —'; trackEl.textContent = ''; $('play-stop-btn').style.display = 'none'; } } function togglePlayStop() { if (isPlaying) { stopPlayback(true); } else if (currentStation) { playStation(currentStation.url, currentStation.name, currentStation.id); } } // --------------------------------------------------------------------------- // Play session tracking // --------------------------------------------------------------------------- async function startPlaySession(stationName, stationUrl) { try { const res = await fetch('/radio/play/start/', { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body: JSON.stringify({station_name: stationName, station_url: stationUrl}) }); if (res.ok) { const data = await res.json(); currentPlayId = data.play_id; } } catch (e) {} } async function stopPlaySession() { // Take the id before awaiting: playStation() calls this without waiting and // starts the next session right away, whose id must not be cleared here. const playId = currentPlayId; if (!playId) return; currentPlayId = null; try { await fetch('/radio/play/stop/', { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body: JSON.stringify({play_id: playId}) }); } catch (e) {} } window.addEventListener('beforeunload', () => { if (currentPlayId) { navigator.sendBeacon('/radio/play/stop/', JSON.stringify({play_id: currentPlayId})); } // Flush cached encrypted payloads for reader data (encryption is async so we // use pre-computed blobs stored by the debounced savers) // The last position written (it's in the outbox too, in case the beacon is lost). if (_lastProgressBeacon) navigator.sendBeacon(`/books/${_lastProgressBeacon.book}/progress/`, _progressBody(_lastProgressBeacon)); if (_lastBookmarkBeacon) navigator.sendBeacon(_lastBookmarkBeacon.url, _lastBookmarkBeacon.body); if (_lastHighlightBeacon) navigator.sendBeacon(_lastHighlightBeacon.url, _lastHighlightBeacon.body); }); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden' && currentBookId) { if (bookmarksDirty) saveBookmarks(); if (highlightsDirty) saveHighlights(); saveReaderProgress(); } // Back in front: maybe the book was read on another device meanwhile. if (document.visibilityState === 'visible' && currentBookId) _checkNewerPositionElsewhere(); }); // Cached beacon payloads — updated after each successful encrypt in save functions let _lastBookmarkBeacon = null; let _lastHighlightBeacon = null; let _lastProgressBeacon = null; // --------------------------------------------------------------------------- // Recommendations // --------------------------------------------------------------------------- async function loadRecommendations() { const container = document.getElementById('recommendations'); if (!container) return; try { const res = await fetch('/radio/recommendations/'); const data = await res.json(); if (!data.recommendations.length) { container.innerHTML = '

Hör mehr Sender, dann gibt es hier Empfehlungen.

'; return; } const label = data.context; let html = `

Was du ${label} oft hörst:

'; container.innerHTML = html; } catch (e) {} } function escapeAttr(s) { return String(s).replace(/'/g, "\\'").replace(/"/g, '"'); } // --------------------------------------------------------------------------- // Volume // --------------------------------------------------------------------------- function setVolume(val) { val = Math.max(0, Math.min(255, Math.round(val))); audio.volume = val / 255; localStorage.setItem('diora_volume', val); const slider = $('volume'); const numInput = $('volume-num'); if (slider) slider.value = val; if (numInput) numInput.value = val; _ambientVolume(val); } const volSliderEl = document.getElementById('volume'); if (volSliderEl) { ['input', 'change', 'mousemove', 'touchmove'].forEach(evt => volSliderEl.addEventListener(evt, function () { setVolume(this.value); }) ); } const volNumEl = document.getElementById('volume-num'); if (volNumEl) { volNumEl.addEventListener('change', function () { setVolume(this.value); }); volNumEl.addEventListener('input', function () { setVolume(this.value); }); volNumEl.addEventListener('click', function () { this.select(); }); } const volSliderEl2 = document.getElementById('volume'); const volWheelTarget = volSliderEl2 || volNumEl; if (volWheelTarget) { volWheelTarget.addEventListener('wheel', function (e) { e.preventDefault(); const current = parseInt(document.getElementById('volume').value, 10); setVolume(current + (e.deltaY < 0 ? 4 : -4)); }, { passive: false }); } // --------------------------------------------------------------------------- // SSE metadata // --------------------------------------------------------------------------- function startMetadataSSE(streamUrl) { if (sseSource) { sseSource.close(); sseSource = null; } // A station from the directory says which (radio-browser's id): without an account the // server opens only the directory's stations (radio/streamcheck.py). const dir = window._directoryStation; const endpoint = '/radio/sse/?url=' + encodeURIComponent(streamUrl) + (dir && dir.url === streamUrl && dir.uuid ? '&station=' + encodeURIComponent(dir.uuid) : ''); sseSource = new EventSource(endpoint); sseSource.onmessage = function (e) { let data; try { data = JSON.parse(e.data); } catch (_) { return; } if (data.error) { console.warn('SSE stream ended:', data.error); return; } if (data.track && data.track !== currentTrack) { currentTrack = data.track; updateNowPlayingUI(data.track); recordTrack(currentStation ? currentStation.name : '', data.track); fetchAffiliateLinks(data.track); } }; sseSource.onerror = function () { // Connection dropped; the browser will attempt to reconnect automatically console.warn('SSE connection error, browser will retry.'); }; } function updateNowPlayingUI(track) { $('now-playing-track').textContent = track; } // --------------------------------------------------------------------------- // Record track // --------------------------------------------------------------------------- async function recordTrack(stationName, track) { try { const res = await fetch('/radio/record/', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken(), }, body: JSON.stringify({ station_name: stationName, track, scrobble: true }), }); if (res.ok) { addHistoryRow(stationName, track); } } catch (err) { console.error('recordTrack error:', err); } } function addHistoryRow(stationName, track) { const tbody = $('history-tbody'); if (!tbody) return; // Remove the "no history" placeholder row if present const emptyRow = $('history-empty-row'); if (emptyRow) emptyRow.remove(); const tr = document.createElement('tr'); const now = new Date().toISOString(); tr.innerHTML = ` ${escapeHtml(formatDateTime(now))} ${escapeHtml(stationName)} ${escapeHtml(track)} `; tbody.insertBefore(tr, tbody.firstChild); } async function deleteHistoryEntry(id, btn) { const tr = btn.closest('tr'); if (!id) { tr.remove(); return; } try { const res = await fetch(`/radio/history/${id}/delete/`, { method: 'POST', headers: {'X-CSRFToken': getCsrfToken()}, }); if (res.ok) tr.remove(); } catch (e) {} } // --------------------------------------------------------------------------- // Affiliate links // --------------------------------------------------------------------------- async function fetchAffiliateLinks(track) { const section = $('affiliate-section'); if (section && section.dataset.disabled) return; try { const res = await fetch('/radio/affiliate/?track=' + encodeURIComponent(track)); if (!res.ok) return; const data = await res.json(); const itunes = data.itunes_data || {}; $('affiliate-track-name').textContent = itunes.name || track; $('affiliate-artist-name').textContent = itunes.artist || ''; $('affiliate-album-name').textContent = itunes.album || ''; const artEl = $('affiliate-artwork'); if (itunes.artwork) { artEl.src = itunes.artwork; artEl.style.display = ''; } else { artEl.style.display = 'none'; } const amzLink = $('affiliate-amazon-link'); if (data.amazon_url) { amzLink.href = data.amazon_url; amzLink.style.display = ''; } else { amzLink.style.display = 'none'; } section.style.display = 'flex'; } catch (err) { console.error('fetchAffiliateLinks error:', err); section.style.display = 'none'; } } // --------------------------------------------------------------------------- // Search (radio-browser.info) // --------------------------------------------------------------------------- async function doSearch() { const query = $('search-input').value.trim(); if (!query) return; const statusEl = $('search-status'); const tableEl = $('search-results-table'); const tbody = $('search-results-body'); tableEl.style.display = 'none'; tbody.innerHTML = ''; // A stream address instead of a name: offer to play or save it directly. if (/^https?:\/\/\S+$/i.test(query)) { showStreamAddressResult(query); return; } statusEl.textContent = 'Suche …'; try { const url = `https://de1.api.radio-browser.info/json/stations/search?name=${encodeURIComponent(query)}&limit=50&hidebroken=true&order=clickcount&reverse=true`; const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); const stations = await res.json(); if (!stations.length) { statusEl.textContent = 'Keine Sender gefunden.'; return; } statusEl.textContent = stations.length === 1 ? '1 Treffer' : `${stations.length} Treffer`; tableEl.style.display = ''; const curated = document.getElementById('curated-lists'); if (curated) curated.style.display = 'none'; stations.forEach(st => { const tr = document.createElement('tr'); const safeName = escapeHtml(st.name || ''); const safeUrl = escapeHtml(st.url_resolved || st.url || ''); const safeBr = escapeHtml(st.bitrate ? st.bitrate + ' kbps' : ''); const safeCC = escapeHtml(st.countrycode || st.country || ''); const safeTags = escapeHtml((st.tags || '').split(',').slice(0, 3).join(', ')); tr.innerHTML = ` ${safeName} ${safeBr} ${safeCC} ${safeTags} `; tbody.appendChild(tr); }); } catch (err) { statusEl.textContent = 'Suche fehlgeschlagen: ' + err.message; } } // A name to start from for a pasted address: "klassik.mp3" on radio.example.org // -> "Klassik (radio.example.org)". The query string is left out on purpose — // it often carries an access token, which shouldn't end up on screen. function _streamAddressName(url) { try { const u = new URL(url); const file = decodeURIComponent(u.pathname.split('/').filter(Boolean).pop() || ''); const base = file.replace(/\.(mp3|aac|aacp|ogg|opus|m3u8?|pls|flac)$/i, '').replace(/[-_]+/g, ' ').trim(); const nice = base ? base.charAt(0).toUpperCase() + base.slice(1) : ''; return nice ? `${nice} (${u.host})` : u.host; } catch (e) { return url; } } function showStreamAddressResult(url) { const statusEl = $('search-status'); const tableEl = $('search-results-table'); const tbody = $('search-results-body'); const name = _streamAddressName(url); const shown = url.split('?')[0]; const data = {name, url, bitrate: '', country: '', tags: '', favicon_url: ''}; statusEl.textContent = 'Stream-Adresse'; const curated = document.getElementById('curated-lists'); if (curated) curated.style.display = 'none'; // Same five columns as a radio-browser result: name, bitrate, country, tags, actions. const tr = document.createElement('tr'); tr.className = 'stream-address-row'; for (let i = 0; i < 4; i++) tr.appendChild(document.createElement('td')); tr.children[0].textContent = name; tr.children[0].title = shown; tr.children[3].textContent = shown; tr.children[3].title = shown; const actions = document.createElement('td'); // An address of one's own is there to be saved, and saving needs an account; without one // the directory is what plays (docs/radio.md). if (typeof IS_AUTHENTICATED !== 'undefined' && !IS_AUTHENTICATED) { actions.innerHTML = 'Eigene Adressen mit Konto: Anmelden oder ' + 'registrieren'; tr.appendChild(actions); tbody.appendChild(tr); tableEl.style.display = ''; return; } const play = document.createElement('button'); play.className = 'btn btn-sm btn-play'; play.innerHTML = '▶ Abspielen'; play.addEventListener('click', () => searchPlay(url, name, data)); const save = document.createElement('button'); save.className = 'btn btn-sm'; save.innerHTML = '★ Speichern'; save.style.marginLeft = '4px'; save.addEventListener('click', async e => { e.stopPropagation(); const chosen = await customPrompt('Name für diesen Sender', name); if (chosen == null) return; const finalName = chosen.trim() || name; await saveStation({...data, name: finalName}); statusEl.textContent = `Gespeichert als „${finalName}“.`; }); actions.append(play, save); tr.appendChild(actions); tbody.appendChild(tr); tableEl.style.display = ''; } function searchPlay(url, name, stationData) { // Store station data on the window so saveCurrentStation() can use it window._pendingStationData = stationData; window._directoryStation = stationData && stationData.uuid ? {url, uuid: stationData.uuid} : null; playStation(url, name, null); } // --------------------------------------------------------------------------- // Save current station // --------------------------------------------------------------------------- async function saveCurrentStation() { if (!currentStation) return; // Use the rich data from search results if available, otherwise minimal data const data = window._pendingStationData || { name: currentStation.name, url: currentStation.url, bitrate: '', country: '', tags: '', favicon_url: '', }; await saveStation(data); } // --------------------------------------------------------------------------- // creamfresh radio: DJ feedback (hardcoded to this one station, see // CREAMFRESH_RADIO_URL above) // --------------------------------------------------------------------------- async function creamfreshVote(direction) { const upBtn = $('creamfresh-vote-up-btn'); const downBtn = $('creamfresh-vote-down-btn'); upBtn.disabled = true; downBtn.disabled = true; try { const res = await fetch('/radio/creamfresh-feedback/', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken(), }, body: JSON.stringify({ vote: direction }), }); if (!res.ok) throw new Error(`feedback returned ${res.status}`); upBtn.classList.toggle('active', direction === 'up'); downBtn.classList.toggle('active', direction === 'down'); } catch (err) { console.warn('creamfresh vote failed', err); } finally { upBtn.disabled = false; downBtn.disabled = false; } } async function saveStation(station) { try { const res = await fetch('/radio/save/', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken(), }, body: JSON.stringify(station), }); if (res.status === 401) { await customAlert('Bitte anmelden, um Sender zu speichern.'); return; } const data = await res.json(); if (data.ok) { if (data.created) { addSavedRow({ id: data.id, ...station, is_favorite: false }); } } } catch (err) { console.error('saveStation error:', err); } } function addSavedRow(station) { const tbody = $('saved-tbody'); if (!tbody) return; const emptyRow = $('saved-empty-row'); if (emptyRow) emptyRow.remove(); // Check for duplicate if (document.getElementById(`saved-row-${station.id}`)) return; const tr = document.createElement('tr'); tr.id = `saved-row-${station.id}`; tr.dataset.id = station.id; tr.dataset.url = station.url; tr.dataset.name = station.name; const safeName = escapeHtml(station.name || ''); const safeBr = escapeHtml(station.bitrate || ''); const safeCC = escapeHtml(station.country || ''); const safeUrl = escapeHtml(station.url || ''); tr.innerHTML = ` ${safeName} ${safeBr} ${safeCC} `; tbody.appendChild(tr); } // --------------------------------------------------------------------------- // Remove station // --------------------------------------------------------------------------- // The only toggleFav: a second definition further down (expecting the button // as an argument) used to shadow this one, so the server-rendered rows — which // pass no button — toggled the favourite on the server without the star ever // changing, and a second click silently took it back. async function toggleFav(pk, btnEl) { try { const res = await fetch(`/radio/favorite/${pk}/`, { method: 'POST', headers: { 'X-CSRFToken': getCsrfToken() }, }); if (!res.ok) return; const data = await res.json(); // Flip the star button state const btn = btnEl || document.querySelector(`#saved-row-${pk} .fav-btn`); if (btn) btn.classList.toggle('active', !!data.is_favorite); // Re-sort rows in the DOM: favorites first, then alphabetically const tbody = $('saved-tbody'); if (!tbody) return; const rows = Array.from(tbody.querySelectorAll('tr[data-id]')); rows.sort((a, b) => { const aFav = a.querySelector('.fav-btn')?.classList.contains('active') ? 0 : 1; const bFav = b.querySelector('.fav-btn')?.classList.contains('active') ? 0 : 1; if (aFav !== bFav) return aFav - bFav; return a.dataset.name.localeCompare(b.dataset.name); }); rows.forEach(row => tbody.appendChild(row)); } catch (err) { console.error('toggleFav error', err); } } async function removeStation(pk) { try { const res = await fetch(`/radio/remove/${pk}/`, { method: 'POST', headers: { 'X-CSRFToken': getCsrfToken() }, }); if (res.ok) { const row = $(`saved-row-${pk}`); if (row) row.remove(); const tbody = $('saved-tbody'); if (tbody && tbody.querySelectorAll('tr').length === 0) { const tr = document.createElement('tr'); tr.id = 'saved-empty-row'; tr.innerHTML = 'Noch keine gespeicherten Sender.'; tbody.appendChild(tr); } } } catch (err) { console.error('removeStation error:', err); } }