Radio: Stream-Adresse ins Suchfeld einfügen; Favoriten-Stern reparieren (SW v50)
- 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:
parent
bca9d6b5e9
commit
3717640a4e
3 changed files with 76 additions and 29 deletions
101
static/js/app.js
101
static/js/app.js
|
|
@ -541,10 +541,17 @@ async function doSearch() {
|
|||
const tableEl = $('search-results-table');
|
||||
const tbody = $('search-results-body');
|
||||
|
||||
statusEl.textContent = 'Searching…';
|
||||
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 = 'Searching…';
|
||||
|
||||
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);
|
||||
|
|
@ -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 = '▶ Play';
|
||||
play.addEventListener('click', () => searchPlay(url, name, data));
|
||||
const save = document.createElement('button');
|
||||
save.className = 'btn btn-sm';
|
||||
save.innerHTML = '★ 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) {
|
||||
// Store station data on the window so saveCurrentStation() can use it
|
||||
window._pendingStationData = stationData;
|
||||
|
|
@ -729,7 +795,11 @@ function addSavedRow(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 {
|
||||
const res = await fetch(`/radio/favorite/${pk}/`, {
|
||||
method: 'POST',
|
||||
|
|
@ -739,8 +809,8 @@ async function toggleFav(pk) {
|
|||
const data = await res.json();
|
||||
|
||||
// Flip the star button state
|
||||
const btn = document.querySelector(`#saved-row-${pk} .fav-btn`);
|
||||
if (btn) btn.classList.toggle('active', data.is_favorite);
|
||||
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');
|
||||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* 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 SHELL = [
|
||||
'/static/css/app.css',
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@
|
|||
<!-- ===== SEARCH SUB-PANEL ===== -->
|
||||
<div class="sub-tab-panel" id="tab-search" style="display:none;">
|
||||
<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>
|
||||
</div>
|
||||
<div class="mood-chips" id="mood-chips"></div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue