diff --git a/static/js/app.js b/static/js/app.js index 804775c..3982b5d 100644 --- a/static/js/app.js +++ b/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 // --------------------------------------------------------------------------- diff --git a/static/js/sw.js b/static/js/sw.js index e20bf19..0068104 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -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', diff --git a/templates/radio/player.html b/templates/radio/player.html index 9ac3540..b8dbc5c 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -81,7 +81,7 @@