Radio: Stream-Adresse ins Suchfeld einfügen; Favoriten-Stern reparieren (SW v50)
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 1m35s

- Eine http(s)-Adresse im Suchfeld wird nicht gesucht, sondern als Treffer
  mit Play und Save (Name wählbar) angeboten. Der Query-String (oft ein
  Zugangstoken) wird nicht angezeigt, aber mitgespeichert.
- toggleFav gab es zweimal; die zweite Fassung verdeckte die erste und
  brauchte den Button als Argument, den die serverseitig gerenderten Zeilen
  nicht übergeben. Folge: Favorit auf dem Server gesetzt, Stern blieb leer,
  der nächste Klick nahm ihn wieder weg. Jetzt eine Fassung für beide Fälle,
  inklusive Umsortieren (Favoriten zuerst, wie der Server).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-09-29 20:33:34 +02:00
parent bca9d6b5e9
commit 3717640a4e
3 changed files with 76 additions and 29 deletions

View file

@ -541,10 +541,17 @@ async function doSearch() {
const tableEl = $('search-results-table'); const tableEl = $('search-results-table');
const tbody = $('search-results-body'); const tbody = $('search-results-body');
statusEl.textContent = 'Searching…';
tableEl.style.display = 'none'; tableEl.style.display = 'none';
tbody.innerHTML = ''; 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 = 'Searching…';
try { try {
const url = `https://de1.api.radio-browser.info/json/stations/search?name=${encodeURIComponent(query)}&limit=50&hidebroken=true&order=clickcount&reverse=true`; 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); const res = await fetch(url);
@ -595,6 +602,65 @@ async function doSearch() {
} }
} }
// 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 address';
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 = '&#9654; Play';
play.addEventListener('click', () => searchPlay(url, name, data));
const save = document.createElement('button');
save.className = 'btn btn-sm';
save.innerHTML = '&#9733; Save';
save.style.marginLeft = '4px';
save.addEventListener('click', async e => {
e.stopPropagation();
const chosen = await customPrompt('Name for this station', name);
if (chosen == null) return;
const finalName = chosen.trim() || name;
await saveStation({...data, name: finalName});
statusEl.textContent = `Saved as "${finalName}".`;
});
actions.append(play, save);
tr.appendChild(actions);
tbody.appendChild(tr);
tableEl.style.display = '';
}
function searchPlay(url, name, stationData) { function searchPlay(url, name, stationData) {
// Store station data on the window so saveCurrentStation() can use it // Store station data on the window so saveCurrentStation() can use it
window._pendingStationData = stationData; window._pendingStationData = stationData;
@ -729,7 +795,11 @@ function addSavedRow(station) {
// Remove station // Remove station
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function toggleFav(pk) { // 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 { try {
const res = await fetch(`/radio/favorite/${pk}/`, { const res = await fetch(`/radio/favorite/${pk}/`, {
method: 'POST', method: 'POST',
@ -739,8 +809,8 @@ async function toggleFav(pk) {
const data = await res.json(); const data = await res.json();
// Flip the star button state // Flip the star button state
const btn = document.querySelector(`#saved-row-${pk} .fav-btn`); const btn = btnEl || document.querySelector(`#saved-row-${pk} .fav-btn`);
if (btn) btn.classList.toggle('active', data.is_favorite); if (btn) btn.classList.toggle('active', !!data.is_favorite);
// Re-sort rows in the DOM: favorites first, then alphabetically // Re-sort rows in the DOM: favorites first, then alphabetically
const tbody = $('saved-tbody'); const tbody = $('saved-tbody');
@ -781,29 +851,6 @@ async function removeStation(pk) {
} }
} }
// ---------------------------------------------------------------------------
// Toggle favorite
// ---------------------------------------------------------------------------
async function toggleFav(pk, btnEl) {
try {
const res = await fetch(`/radio/favorite/${pk}/`, {
method: 'POST',
headers: { 'X-CSRFToken': getCsrfToken() },
});
if (res.ok) {
const data = await res.json();
if (data.is_favorite) {
btnEl.classList.add('active');
} else {
btnEl.classList.remove('active');
}
}
} catch (err) {
console.error('toggleFav error:', err);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Focus Timer // Focus Timer
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use. * diora service worker — caches the app shell for offline use.
*/ */
const CACHE = 'diora-v49'; const CACHE = 'diora-v50';
const PODCAST_CACHE = 'diora-podcast-v1'; const PODCAST_CACHE = 'diora-podcast-v1';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',

View file

@ -81,7 +81,7 @@
<!-- ===== SEARCH SUB-PANEL ===== --> <!-- ===== SEARCH SUB-PANEL ===== -->
<div class="sub-tab-panel" id="tab-search" style="display:none;"> <div class="sub-tab-panel" id="tab-search" style="display:none;">
<div class="search-bar"> <div class="search-bar">
<input type="text" id="search-input" class="search-input" placeholder="Search radio-browser.info…" onkeydown="if(event.key==='Enter') doSearch()"> <input type="text" id="search-input" class="search-input" placeholder="Search radio-browser.info… or paste a stream address" onkeydown="if(event.key==='Enter') doSearch()">
<button class="btn" onclick="doSearch()">Search</button> <button class="btn" onclick="doSearch()">Search</button>
</div> </div>
<div class="mood-chips" id="mood-chips"></div> <div class="mood-chips" id="mood-chips"></div>