diff --git a/public/sw.js b/public/sw.js index a291995..5160a3a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -8,6 +8,7 @@ const CACHE_NAME = 'radioplayer-pwa-v4'; const CORE_ASSETS = [ './', 'index.html', + 'data/radio-stations.json', 'stations.json', 'manifest.json', 'assets/radioplayer-logo-192.png', diff --git a/src/player.js b/src/player.js index f53740d..423853a 100644 --- a/src/player.js +++ b/src/player.js @@ -34,10 +34,14 @@ let stationCatalogState = 'idle'; let stationCatalogError = ''; let playbackError = ''; let stationCountryFilterOpen = false; +let artworkShineTimeoutId = null; +let artworkShineClearTimeoutId = null; +let stationTitleRafId = null; const STATION_LIBRARY_PAGE_SIZE = 12; +const ARTWORK_SHINE_EFFECTS = ['shine-sweep', 'shine-glint', 'shine-flare']; -const RADIO_PLACEHOLDER_LOGO = '/images/radio-placeholder.svg'; +const RADIO_PLACEHOLDER_LOGO = `${import.meta.env.BASE_URL}images/radio-placeholder.svg`; // UI Elements const stationNameEl = document.getElementById('station-name'); @@ -112,6 +116,54 @@ const castOutputText = document.getElementById('cast-output-text'); // ── Utilities ──────────────────────────────────────────────────────────────── +function prefersReducedMotion() { + try { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; + } catch (e) { + return false; + } +} + +function clearArtworkShineTimers() { + if (artworkShineTimeoutId) { + clearTimeout(artworkShineTimeoutId); + artworkShineTimeoutId = null; + } + if (artworkShineClearTimeoutId) { + clearTimeout(artworkShineClearTimeoutId); + artworkShineClearTimeoutId = null; + } + if (artworkPlaceholder) { + artworkPlaceholder.classList.remove(...ARTWORK_SHINE_EFFECTS); + } +} + +function scheduleArtworkShine() { + if (!artworkPlaceholder || prefersReducedMotion()) return; + + const trigger = () => { + artworkShineTimeoutId = null; + clearArtworkShineTimers(); + + if (!artworkPlaceholder || prefersReducedMotion()) return; + + const effect = ARTWORK_SHINE_EFFECTS[Math.floor(Math.random() * ARTWORK_SHINE_EFFECTS.length)]; + const effectDuration = 1800 + Math.floor(Math.random() * 900); + artworkPlaceholder.classList.add(effect); + + artworkShineClearTimeoutId = window.setTimeout(() => { + artworkPlaceholder.classList.remove(effect); + artworkShineClearTimeoutId = null; + }, effectDuration); + + const nextDelay = 7000 + Math.floor(Math.random() * 15000); + artworkShineTimeoutId = window.setTimeout(trigger, nextDelay); + }; + + clearArtworkShineTimers(); + trigger(); +} + const STATION_THEMES = [ { accent: '#4dd7c8', accent2: '#ffb45c', accent3: '#8fb3ff', page: '#171b22', panel: '#111821' }, { accent: '#ff7aa8', accent2: '#ffd166', accent3: '#8fb3ff', page: '#22151d', panel: '#1b131a' }, @@ -255,7 +307,18 @@ function getMetadataFetchUrl(url) { if (!url || typeof url !== 'string') return ''; const safeUrl = toHttpsIfHttp(url) || url; - if (!import.meta.env.DEV) return safeUrl; + if (!import.meta.env.DEV) { + try { + const parsed = new URL(safeUrl); + if (parsed.hostname === 'data.radio.si') { + return ''; + } + } catch (e) { + return safeUrl; + } + + return safeUrl; + } try { const parsed = new URL(safeUrl); @@ -538,6 +601,64 @@ function getStationTitle(station) { return station?.name || station?.title || station?.id || 'Station'; } +function isMobileTitleViewport() { + return window.matchMedia('(max-width: 760px)').matches; +} + +function renderStationTitle(title, shouldScroll = false) { + if (!stationNameEl) return; + + stationNameEl.replaceChildren(); + + if (!shouldScroll) { + stationNameEl.classList.remove('station-title-marquee'); + const plainTitle = document.createElement('span'); + plainTitle.className = 'station-title-text'; + plainTitle.textContent = title; + stationNameEl.appendChild(plainTitle); + return; + } + + stationNameEl.classList.add('station-title-marquee'); + const track = document.createElement('span'); + track.className = 'station-title-track'; + + const firstCopy = document.createElement('span'); + firstCopy.className = 'station-title-copy'; + firstCopy.textContent = title; + + const secondCopy = document.createElement('span'); + secondCopy.className = 'station-title-copy'; + secondCopy.setAttribute('aria-hidden', 'true'); + secondCopy.textContent = title; + + track.append(firstCopy, secondCopy); + stationNameEl.appendChild(track); +} + +function updateStationTitleLayout(title) { + if (!stationNameEl) return; + + const titleText = String(title || '').trim() || 'Station'; + + if (stationTitleRafId) { + cancelAnimationFrame(stationTitleRafId); + stationTitleRafId = null; + } + + renderStationTitle(titleText, false); + + if (!isMobileTitleViewport()) return; + + stationTitleRafId = window.requestAnimationFrame(() => { + stationTitleRafId = null; + if (!stationNameEl || !isMobileTitleViewport()) return; + if (stationNameEl.scrollWidth > stationNameEl.clientWidth + 2) { + renderStationTitle(titleText, true); + } + }); +} + function getStationCountry(station) { return station?.country || station?.countryCode || station?.region || ''; } @@ -1560,7 +1681,7 @@ function loadStation(index) { applyStationTheme(station); - if (stationNameEl) stationNameEl.textContent = station.name; + updateStationTitleLayout(station.name); if (stationSubtitleEl) stationSubtitleEl.textContent = getStationDetails(station) || 'Live stream'; if (nowPlayingEl) nowPlayingEl.classList.add('hidden'); if (nowArtistEl) nowArtistEl.textContent = ''; @@ -2131,7 +2252,10 @@ function setupEventListeners() { artworkPlaceholder?.addEventListener('click', openStationLibrary); castOutputBtn?.addEventListener('click', toggleCastBothMode); - window.addEventListener('resize', updateCoverflowTransforms); + window.addEventListener('resize', () => { + updateCoverflowTransforms(); + updateStationTitleLayout(getStationTitle(stations[currentIndex])); + }); document.addEventListener('click', (ev) => { if (!stationCountryFilterOpen) return; if (stationCountryFilterWrapEl?.contains(ev.target)) return; @@ -2202,6 +2326,7 @@ async function init() { await loadStations(); setupEventListeners(); ensureArtworkPointerFallback(); + scheduleArtworkShine(); updateUI(); // Update Media Session when station or song changes @@ -2228,7 +2353,7 @@ if ('serviceWorker' in navigator && import.meta.env.DEV) { }); } else if ('serviceWorker' in navigator) { window.addEventListener('load', () => { - navigator.serviceWorker.register('sw.js') + navigator.serviceWorker.register(`${import.meta.env.BASE_URL}sw.js`) .then((reg) => console.log('ServiceWorker registered:', reg.scope)) .catch((err) => console.debug('ServiceWorker registration failed:', err)); }); diff --git a/src/radio/loadManagedStations.ts b/src/radio/loadManagedStations.ts index ff00259..07ac7f7 100644 --- a/src/radio/loadManagedStations.ts +++ b/src/radio/loadManagedStations.ts @@ -1,5 +1,5 @@ export async function loadManagedStations(): Promise { - const response = await fetch('/stations.json'); + const response = await fetch(`${import.meta.env.BASE_URL}stations.json`); if (!response.ok) { throw new Error(`Failed to load managed stations: ${response.status}`); diff --git a/src/radio/loadRadioStations.ts b/src/radio/loadRadioStations.ts index 24321d5..eaa74f2 100644 --- a/src/radio/loadRadioStations.ts +++ b/src/radio/loadRadioStations.ts @@ -1,7 +1,7 @@ import type { RadioStation } from './radioTypes.js'; export async function loadRadioStations(): Promise { - const response = await fetch('/data/radio-stations.json'); + const response = await fetch(`${import.meta.env.BASE_URL}data/radio-stations.json`); if (!response.ok) { throw new Error(`Failed to load radio stations: ${response.status}`); diff --git a/src/styles.css b/src/styles.css index 281dde7..7851362 100644 --- a/src/styles.css +++ b/src/styles.css @@ -55,45 +55,50 @@ body::before { pointer-events: none; background-image: linear-gradient(rgba(255, 255, 255, 0.045) 1px, transparent 1px), - linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px); - background-size: 42px 42px; + linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px), + radial-gradient(circle, rgba(255, 255, 255, 0.18) 0 1px, transparent 1.6px), + radial-gradient(circle, rgba(var(--accent-3-rgb), 0.14) 0 1px, transparent 1.8px), + radial-gradient(circle, rgba(255, 255, 255, 0.12) 0 1px, transparent 1.7px); + background-size: 42px 42px, 42px 42px, 180px 180px, 240px 240px, 300px 300px; + background-position: 0 0, 0 0, 20px 28px, 80px 120px, 120px 56px; mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.62), transparent 78%); - animation: grid-drift 18s linear infinite; + opacity: 0.62; + animation: grid-drift 72s linear infinite; } body::after { content: ""; position: fixed; - inset: -28vmax; + inset: -24vmax; pointer-events: none; background: - radial-gradient(circle at 24% 32%, rgba(var(--accent-rgb), 0.22), transparent 22vmax), - radial-gradient(circle at 78% 64%, rgba(var(--accent-2-rgb), 0.18), transparent 24vmax), - radial-gradient(circle at 52% 82%, rgba(var(--accent-3-rgb), 0.16), transparent 20vmax); - filter: blur(28px); - opacity: 0.88; + radial-gradient(circle at 24% 32%, rgba(var(--accent-rgb), 0.16), transparent 22vmax), + radial-gradient(circle at 78% 64%, rgba(var(--accent-2-rgb), 0.12), transparent 24vmax), + radial-gradient(circle at 52% 82%, rgba(var(--accent-3-rgb), 0.1), transparent 20vmax); + filter: blur(36px); + opacity: 0.42; transform: translate3d(0, 0, 0); - animation: ambient-drift 22s ease-in-out infinite alternate; + animation: ambient-drift 84s ease-in-out infinite alternate; } @keyframes grid-drift { from { - background-position: 0 0, 0 0; + background-position: 0 0, 0 0, 20px 28px, 80px 120px, 120px 56px; } to { - background-position: 42px 42px, -42px 42px; + background-position: 12px 12px, -12px 12px, 32px 18px, 92px 128px, 108px 66px; } } @keyframes ambient-drift { 0% { - transform: translate3d(-1.5%, -1%, 0) scale(1); + transform: translate3d(-0.6%, -0.4%, 0) scale(1); } - 45% { - transform: translate3d(2%, 1.5%, 0) scale(1.035); + 50% { + transform: translate3d(0.8%, 0.6%, 0) scale(1.02); } 100% { - transform: translate3d(-0.5%, 2%, 0) scale(1.07); + transform: translate3d(0.2%, 0.9%, 0) scale(1.03); } } @@ -748,19 +753,7 @@ input:focus-visible, } .starfield { - position: absolute; - inset: 0; - z-index: 1; - overflow: hidden; - pointer-events: none; - perspective: 760px; - perspective-origin: 50% 48%; - opacity: 1; - border-radius: inherit; - background: - radial-gradient(circle at 50% 48%, rgba(var(--accent-rgb), 0.13), transparent 34%), - radial-gradient(circle at 76% 68%, rgba(var(--accent-2-rgb), 0.10), transparent 28%); - mask-image: radial-gradient(ellipse at center, rgba(0,0,0,0.98), rgba(0,0,0,0.88) 64%, transparent 98%); + display: none; } .starfield-plane { @@ -1036,6 +1029,7 @@ header { align-items: center; overflow: hidden; border-radius: 24px; + isolation: isolate; cursor: pointer; background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.9), rgba(var(--accent-3-rgb), 0.72) 48%, rgba(var(--accent-2-rgb), 0.86)); @@ -1047,6 +1041,7 @@ header { content: ""; position: absolute; inset: 0; + z-index: 1; background: linear-gradient(120deg, rgba(255,255,255,0.26), transparent 34%), repeating-linear-gradient(135deg, rgba(255,255,255,0.05) 0 1px, transparent 1px 12px); @@ -1054,6 +1049,95 @@ header { animation: artwork-sheen 9s ease-in-out infinite alternate; } +.artwork-placeholder::after { + content: ""; + position: absolute; + inset: -18%; + border-radius: inherit; + pointer-events: none; + z-index: 3; + opacity: 0; + mix-blend-mode: screen; + transform: translate3d(-10%, -10%, 0) rotate(0deg); + filter: blur(0.5px) drop-shadow(0 0 16px rgba(255,255,255,0.26)); + background: + linear-gradient(120deg, transparent 28%, rgba(255,255,255,0.24) 42%, rgba(255,255,255,0.86) 50%, rgba(255,255,255,0.26) 58%, transparent 72%), + radial-gradient(circle at 50% 50%, rgba(255,255,255,0.74), transparent 60%); +} + +.artwork-placeholder.shine-sweep::after { + opacity: 1; + animation: artwork-shine-sweep 2.6s ease-out forwards; +} + +.artwork-placeholder.shine-glint::after { + opacity: 1; + background: + radial-gradient(circle at 22% 28%, rgba(255,255,255,0.92) 0 8%, transparent 13%), + radial-gradient(circle at 72% 34%, rgba(var(--accent-3-rgb), 0.56) 0 10%, transparent 16%), + radial-gradient(circle at 52% 58%, rgba(255,255,255,0.24), transparent 36%); + animation: artwork-shine-glint 2.2s ease-out forwards; +} + +.artwork-placeholder.shine-flare::after { + opacity: 1; + background: + radial-gradient(circle at 48% 42%, rgba(255,255,255,0.95) 0 4%, transparent 12%), + radial-gradient(circle at 52% 48%, rgba(255,255,255,0.30) 0 18%, transparent 42%), + linear-gradient(135deg, transparent 35%, rgba(var(--accent-rgb), 0.34) 50%, transparent 66%); + animation: artwork-shine-flare 2.4s ease-out forwards; +} + +@keyframes artwork-shine-sweep { + 0% { + opacity: 0; + transform: translate3d(-26%, -10%, 0) rotate(11deg); + } + 12% { + opacity: 0.95; + } + 58% { + opacity: 0.8; + } + 100% { + opacity: 0; + transform: translate3d(26%, 10%, 0) rotate(11deg); + } +} + +@keyframes artwork-shine-glint { + 0% { + opacity: 0; + transform: scale(0.92); + } + 18% { + opacity: 1; + transform: scale(1); + } + 100% { + opacity: 0; + transform: scale(1.06); + } +} + +@keyframes artwork-shine-flare { + 0% { + opacity: 0; + transform: scale(0.84); + } + 18% { + opacity: 0.95; + transform: scale(1); + } + 60% { + opacity: 0.55; + } + 100% { + opacity: 0; + transform: scale(1.12); + } +} + @keyframes artwork-sheen { from { transform: translateX(-3%) translateY(-2%); @@ -1797,21 +1881,28 @@ input[type=range]::-webkit-slider-thumb { } @media (max-width: 760px) { + body { + overflow: hidden; + } + .app-container { - align-items: start; - padding: 10px; + align-items: stretch; + padding: 0; + min-height: 100dvh; } .player-layout { width: 100%; min-height: 0; + height: 100dvh; } .glass-card { - height: auto; - min-height: calc(100vh - 20px); + height: 100dvh; + min-height: 100dvh; width: 100%; grid-template-columns: 1fr; + grid-template-rows: auto minmax(0, 1fr) auto auto auto auto auto; grid-template-areas: "header" "artwork" @@ -1820,9 +1911,45 @@ input[type=range]::-webkit-slider-thumb { "controls" "volume" "quickpick"; - gap: 13px; - padding: 16px; + gap: 9px; + padding: 10px 12px 12px; border-radius: 22px; + overflow: hidden; + } + + .header-top-row { + gap: 8px; + } + + .brand-block { + gap: 8px; + } + + .brand-logo { + width: 36px; + height: 36px; + flex-basis: 36px; + border-radius: 11px; + } + + .app-title { + font-size: 0.96rem; + } + + .datetime { + gap: 5px; + } + + .datetime-time { + font-size: 0.88rem; + } + + .datetime-date { + font-size: 0.72rem; + } + + .header-icons-left { + gap: 4px; } .library-tabs { @@ -1930,6 +2057,11 @@ input[type=range]::-webkit-slider-thumb { gap: 6px; } + #edit-stations-btn, + #install-app-btn { + display: none; + } + .icon-btn { width: 40px; height: 40px; @@ -1949,27 +2081,37 @@ input[type=range]::-webkit-slider-thumb { } .artwork-section { - align-self: auto; + align-self: center; } .artwork-stack { width: 100%; - gap: 10px; + gap: 6px; } .artwork-container { - width: min(72vw, 280px); - padding: 8px; - border-radius: 26px; + width: min(34vw, 132px); + padding: 4px; + border-radius: 18px; } .artwork-placeholder { - border-radius: 20px; + border-radius: 16px; + } + + .station-logo-img { + width: 72%; + height: 72%; + padding: 6px; + } + + .station-logo-text { + font-size: clamp(1rem, 4.8vw, 1.55rem); } .artwork-coverflow { width: min(100%, calc(100vw - 42px)); - height: 92px; + height: 72px; margin-inline: auto; } @@ -1978,35 +2120,59 @@ input[type=range]::-webkit-slider-thumb { } .coverflow-item { - width: 72px; - height: 64px; - border-radius: 16px; + width: 60px; + height: 52px; + border-radius: 14px; } .coverflow-item.fallback { - font-size: 0.74rem; + font-size: 0.66rem; } .coverflow-arrow { - width: 36px; - height: 36px; + width: 32px; + height: 32px; background: rgba(12, 15, 20, 0.78); } .track-info { - min-height: 150px; + min-height: 108px; align-items: center; justify-content: center; text-align: center; } .track-info h2 { - font-size: clamp(1.75rem, 10vw, 2.8rem); + font-size: clamp(1.45rem, 8vw, 2.25rem); + overflow: hidden; + white-space: nowrap; + text-overflow: clip; + } + + .track-info h2.station-title-marquee { + overflow: hidden; + } + + .track-info h2.station-title-marquee .station-title-track { + display: inline-flex; + width: max-content; + animation: station-title-scroll 10s linear infinite; + will-change: transform; + } + + .track-info h2 .station-title-text, + .track-info h2 .station-title-copy { + display: inline-block; + white-space: nowrap; + } + + .track-info p { + display: none; } #now-playing { - min-height: 48px; - margin-top: 12px; + min-height: 34px; + margin-top: 8px; } .status-indicator-wrap, @@ -2015,26 +2181,39 @@ input[type=range]::-webkit-slider-thumb { } .controls-section { - grid-template-columns: 56px 82px 56px; + grid-template-columns: 50px 72px 50px; justify-content: center; - gap: 16px; + gap: 10px; } .control-btn.secondary { - width: 56px; - height: 56px; - border-radius: 17px; + width: 50px; + height: 50px; + border-radius: 15px; } .control-btn.primary { - width: 82px; - height: 82px; - border-radius: 24px; + width: 72px; + height: 72px; + border-radius: 20px; } .volume-section { - grid-template-columns: 40px minmax(0, 1fr) 44px; - padding-bottom: 4px; + grid-template-columns: 36px minmax(0, 1fr) 38px; + gap: 8px; + padding-bottom: 0; + } + + .volume-section #volume-value { + font-size: 0.78rem; + } + + .quickpick-section { + align-self: end; + } + + .quickpick-section .artwork-coverflow { + max-width: 100%; } .overlay { @@ -2072,6 +2251,15 @@ input[type=range]::-webkit-slider-thumb { } } +@keyframes station-title-scroll { + from { + transform: translate3d(0, 0, 0); + } + to { + transform: translate3d(-50%, 0, 0); + } +} + @media (min-width: 761px) and (max-width: 980px) { .glass-card { grid-template-columns: minmax(260px, 0.9fr) minmax(300px, 1.1fr); @@ -2093,7 +2281,7 @@ input[type=range]::-webkit-slider-thumb { } .artwork-container { - width: min(74vw, 238px); + width: min(47vw, 238px); } .artwork-coverflow {