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 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 = '▶ 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) {
|
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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue