699 lines
24 KiB
JavaScript
699 lines
24 KiB
JavaScript
'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 = '<p class="muted">Hör mehr Sender, dann gibt es hier Empfehlungen.</p>';
|
|
return;
|
|
}
|
|
const label = data.context;
|
|
let html = `<p class="recommendations-context">Was du ${label} oft hörst:</p><ul class="recommendations-list">`;
|
|
for (const r of data.recommendations) {
|
|
html += `<li>
|
|
<button class="btn btn-sm" onclick="playStation('${escapeAttr(r.station_url)}', '${escapeAttr(r.station_name)}', ${r.saved_id || 'null'})">
|
|
▶ ${escapeHtml(r.station_name)}
|
|
</button>
|
|
<span class="muted">${r.play_count}×</span>
|
|
</li>`;
|
|
}
|
|
html += '</ul>';
|
|
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 = `
|
|
<td class="history-time">${escapeHtml(formatDateTime(now))}</td>
|
|
<td>${escapeHtml(stationName)}</td>
|
|
<td>${escapeHtml(track)}</td>
|
|
<td></td>
|
|
<td><button class="btn-delete-history" onclick="deleteHistoryEntry(null, this)" title="Entfernen">✕</button></td>
|
|
`;
|
|
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 = `
|
|
<td title="${safeName}">${safeName}</td>
|
|
<td>${safeBr}</td>
|
|
<td>${safeCC}</td>
|
|
<td title="${escapeHtml(st.tags || '')}">${safeTags}</td>
|
|
<td>
|
|
<button class="btn btn-sm btn-play"
|
|
onclick='searchPlay(${JSON.stringify(safeUrl)}, ${JSON.stringify(safeName)}, ${JSON.stringify({
|
|
name: st.name,
|
|
url: st.url_resolved || st.url,
|
|
bitrate: st.bitrate ? String(st.bitrate) : '',
|
|
country: st.country || '',
|
|
tags: st.tags || '',
|
|
favicon_url: st.favicon || '',
|
|
uuid: st.stationuuid || '',
|
|
})})'>
|
|
▶ Abspielen
|
|
</button>
|
|
</td>
|
|
`;
|
|
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 = '<span class="muted">Eigene Adressen mit Konto: <a href="/accounts/login/">Anmelden</a> oder ' +
|
|
'<a href="/accounts/register/">registrieren</a></span>';
|
|
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 = `
|
|
<td>
|
|
<button class="btn-icon fav-btn${station.is_favorite ? ' active' : ''}"
|
|
onclick="toggleFav(${station.id}, this)"
|
|
title="Favorit an/aus">★</button>
|
|
</td>
|
|
<td class="station-name-cell">${safeName}</td>
|
|
<td>${safeBr}</td>
|
|
<td>${safeCC}</td>
|
|
<td class="notes-cell" onclick="editNotes(${station.id}, this.textContent.trim())" title="" style="cursor:pointer; color:#666; max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;"></td>
|
|
<td>
|
|
<button class="btn btn-sm"
|
|
onclick="playStation('${safeUrl}', '${safeName}', ${station.id})">
|
|
▶ Abspielen
|
|
</button>
|
|
</td>
|
|
<td>
|
|
<button class="btn btn-sm btn-danger"
|
|
onclick="removeStation(${station.id})">
|
|
Entfernen
|
|
</button>
|
|
</td>
|
|
`;
|
|
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 = '<td colspan="7" class="empty-msg">Noch keine gespeicherten Sender.</td>';
|
|
tbody.appendChild(tr);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('removeStation error:', err);
|
|
}
|
|
}
|