`;
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');
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;
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 = '