Updated
@@ -7,6 +7,7 @@ RadioPlayer is a Vite + React web app for browsing, playing, and casting radio s
|
|||||||
- Station browser with search, categories, favourites, and recent stations
|
- Station browser with search, categories, favourites, and recent stations
|
||||||
- Audio playback with previous/next station controls
|
- Audio playback with previous/next station controls
|
||||||
- Cast support
|
- Cast support
|
||||||
|
- Production service worker for app-shell caching, offline launch support, and faster repeat visits
|
||||||
- App install prompt for supported browsers
|
- App install prompt for supported browsers
|
||||||
- Custom station editor
|
- Custom station editor
|
||||||
- Live station metadata and artwork rendering
|
- Live station metadata and artwork rendering
|
||||||
@@ -87,5 +88,6 @@ scripts/
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- The app uses a module-based frontend build, so `src/main.jsx` is the browser entry point.
|
- The app uses a module-based frontend build, so `src/main.jsx` is the browser entry point.
|
||||||
|
- The service worker is registered only in production builds. During development, existing service workers and caches are cleared automatically to avoid stale assets while iterating.
|
||||||
- The updater script uses the remote feed as the source of truth for the station list and writes the merged result into `public/stations.json`.
|
- The updater script uses the remote feed as the source of truth for the station list and writes the merged result into `public/stations.json`.
|
||||||
- If you add or edit stations manually, re-run `npm run update:stations` when you want to sync back to the remote catalog.
|
- If you add or edit stations manually, re-run `npm run update:stations` when you want to sync back to the remote catalog.
|
||||||
|
|||||||
BIN
RadioWebApp/assets/appIcon.png
Normal file
|
After Width: | Height: | Size: 682 KiB |
BIN
RadioWebApp/assets/favicon_io.zip
Normal file
BIN
RadioWebApp/assets/favicon_io/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
RadioWebApp/assets/favicon_io/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
RadioWebApp/assets/favicon_io/app-icon.png
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
RadioWebApp/assets/favicon_io/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
RadioWebApp/assets/favicon_io/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 859 B |
BIN
RadioWebApp/assets/favicon_io/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
RadioWebApp/assets/favicon_io/icon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
RadioWebApp/assets/favicon_io/site.webmanifest
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
1
RadioWebApp/assets/index-1777223363071-BIky9ODB.css
Normal file
9
RadioWebApp/assets/index-1777223363071-C0f4S1Tv.js
Normal file
1
RadioWebApp/assets/javascript.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#F7DF1E" d="M0 0h256v256H0V0Z"></path><path d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574"></path></svg>
|
||||||
|
After Width: | Height: | Size: 995 B |
10
RadioWebApp/assets/player-1777223363071-CnHYXmo1.js
Normal file
BIN
RadioWebApp/assets/radioplayer-logo-192.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
RadioWebApp/assets/radioplayer-logo-512.png
Normal file
|
After Width: | Height: | Size: 412 KiB |
BIN
RadioWebApp/assets/radioplayer-logo.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
4
RadioWebApp/assets/tauri.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="206" height="231" viewBox="0 0 206 231">
|
||||||
|
<!-- Wrapper SVG that embeds the PNG app icon so existing references to tauri.svg render the PNG -->
|
||||||
|
<image href="appIcon.png" width="206" height="231" preserveAspectRatio="xMidYMid slice" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 289 B |
68103
RadioWebApp/data/radio-stations.json
Normal file
19
RadioWebApp/images/radio-placeholder.svg
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">Radio placeholder</title>
|
||||||
|
<desc id="desc">Fallback icon for stations without a logo.</desc>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#203343"/>
|
||||||
|
<stop offset="100%" stop-color="#10141b"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#4dd7c8"/>
|
||||||
|
<stop offset="100%" stop-color="#8fb3ff"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="96" height="96" rx="24" fill="url(#bg)"/>
|
||||||
|
<rect x="17" y="20" width="62" height="56" rx="14" fill="none" stroke="url(#accent)" stroke-width="5"/>
|
||||||
|
<circle cx="36" cy="48" r="8" fill="url(#accent)"/>
|
||||||
|
<path d="M50 39h12M50 48h12M50 57h8" stroke="#f7f8fb" stroke-width="5" stroke-linecap="round"/>
|
||||||
|
<path d="M27 80c7-7 14-10 21-10s14 3 21 10" fill="none" stroke="#ffb45c" stroke-width="5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
104
RadioWebApp/index.html
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>RadioPlayer</title>
|
||||||
|
<link rel="manifest" href="manifest.json">
|
||||||
|
<meta name="description" content="RadioPlayer - stream and cast your favorite radio stations.">
|
||||||
|
<meta name="theme-color" content="#111318">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="RadioPlayer">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="assets/radioplayer-logo-192.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="512x512" href="assets/radioplayer-logo-512.png">
|
||||||
|
<link rel="apple-touch-icon" href="assets/radioplayer-logo-192.png">
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
background: #111318;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app-loading {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2147483647;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
color: #f7f8fb;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 8%, rgba(77, 215, 200, 0.24), transparent 34%),
|
||||||
|
radial-gradient(circle at 86% 82%, rgba(255, 180, 92, 0.2), transparent 32%),
|
||||||
|
linear-gradient(180deg, #111318 0%, #101218 58%, #111821 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-loading-card {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-loading-logo {
|
||||||
|
width: 92px;
|
||||||
|
height: 92px;
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-loading-title {
|
||||||
|
font: 800 1.2rem/1.1 Inter, "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-loading-subtitle {
|
||||||
|
color: rgba(247, 248, 251, 0.72);
|
||||||
|
font: 600 0.88rem/1.4 Inter, "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-loading-bar {
|
||||||
|
width: min(220px, 68vw);
|
||||||
|
height: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-loading-bar::before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
width: 42%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, #4dd7c8, #8fb3ff);
|
||||||
|
animation: app-loading-slide 1.1s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes app-loading-slide {
|
||||||
|
from { transform: translateX(-18%); }
|
||||||
|
to { transform: translateX(118%); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!-- Google Cast Web Sender SDK -->
|
||||||
|
<script src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"></script>
|
||||||
|
<script type="module" crossorigin src="./assets/index-1777223363071-C0f4S1Tv.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="./assets/index-1777223363071-BIky9ODB.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app-loading" role="status" aria-live="polite" aria-label="Loading RadioPlayer">
|
||||||
|
<div class="app-loading-card">
|
||||||
|
<img class="app-loading-logo" src="assets/radioplayer-logo-192.png" alt="RadioPlayer" />
|
||||||
|
<div class="app-loading-title">RadioPlayer</div>
|
||||||
|
<div class="app-loading-subtitle">Loading your stations...</div>
|
||||||
|
<div class="app-loading-bar" aria-hidden="true"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
42
RadioWebApp/manifest.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"id": "radioplayer",
|
||||||
|
"name": "RadioPlayer",
|
||||||
|
"short_name": "Radio",
|
||||||
|
"description": "RadioPlayer - stream and cast your favorite radio stations.",
|
||||||
|
"start_url": "./",
|
||||||
|
"scope": ".",
|
||||||
|
"display": "fullscreen",
|
||||||
|
"display_override": ["fullscreen", "window-controls-overlay", "standalone", "minimal-ui", "browser"],
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"background_color": "#111318",
|
||||||
|
"theme_color": "#111318",
|
||||||
|
"categories": ["music", "entertainment"],
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "assets/radioplayer-logo-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/radioplayer-logo-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Open RadioPlayer",
|
||||||
|
"short_name": "Open",
|
||||||
|
"description": "Launch RadioPlayer",
|
||||||
|
"url": "./",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "assets/radioplayer-logo-192.png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1696
RadioWebApp/stations.json
Normal file
106
RadioWebApp/sw.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// NOTE: This service worker is for the web/PWA build.
|
||||||
|
// For development we aggressively unregister SWs in `src/player.js`.
|
||||||
|
//
|
||||||
|
// This value is rewritten automatically before each build so deployed clients
|
||||||
|
// refresh to the newest shell and cached assets.
|
||||||
|
const CACHE_NAME = 'radioplayer-pwa-v5-1777223362895';
|
||||||
|
|
||||||
|
const CORE_ASSETS = [
|
||||||
|
'./',
|
||||||
|
'index.html',
|
||||||
|
'data/radio-stations.json',
|
||||||
|
'stations.json',
|
||||||
|
'manifest.json',
|
||||||
|
'assets/radioplayer-logo-192.png',
|
||||||
|
'assets/radioplayer-logo-512.png',
|
||||||
|
];
|
||||||
|
|
||||||
|
const CORE_PATHS = new Set(CORE_ASSETS.map((p) => new URL(p, self.registration.scope).pathname));
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
// Activate updated SW as soon as it's installed.
|
||||||
|
self.skipWaiting();
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then(async (cache) => {
|
||||||
|
const reqs = CORE_ASSETS.map((p) => new Request(p, { cache: 'reload' }));
|
||||||
|
await cache.addAll(reqs);
|
||||||
|
|
||||||
|
// Vite fingerprints JS/CSS assets in production. Parse the built HTML so
|
||||||
|
// the installed PWA can launch offline after its first install.
|
||||||
|
try {
|
||||||
|
const indexResp = await fetch(new Request('./', { cache: 'reload' }));
|
||||||
|
const indexText = await indexResp.clone().text();
|
||||||
|
await cache.put('./', indexResp);
|
||||||
|
|
||||||
|
const assetUrls = [...indexText.matchAll(/(?:src|href)="([^"]+)"/g)]
|
||||||
|
.map((match) => match[1])
|
||||||
|
.filter((assetPath) => assetPath.startsWith('./assets/') || assetPath.startsWith('assets/'))
|
||||||
|
.map((assetPath) => new URL(assetPath, self.registration.scope).href);
|
||||||
|
|
||||||
|
await Promise.all(assetUrls.map((assetUrl) => {
|
||||||
|
return cache.add(new Request(assetUrl, { cache: 'reload' })).catch(() => {});
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
// If HTML parsing fails, runtime caching below still catches assets.
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
Promise.all([
|
||||||
|
self.clients.claim(),
|
||||||
|
caches.keys().then((keys) => Promise.all(
|
||||||
|
keys.map((k) => { if (k !== CACHE_NAME) return caches.delete(k); return null; })
|
||||||
|
)),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
// Only handle GET requests
|
||||||
|
if (event.request.method !== 'GET') return;
|
||||||
|
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
|
||||||
|
// Don't cache cross-origin requests (station logos, APIs, etc.).
|
||||||
|
if (url.origin !== self.location.origin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCore = CORE_PATHS.has(url.pathname);
|
||||||
|
const isHtmlNavigation = event.request.mode === 'navigate' || (event.request.headers.get('accept') || '').includes('text/html');
|
||||||
|
|
||||||
|
// Network-first for navigations and core assets to prevent "old UI" issues.
|
||||||
|
if (isHtmlNavigation || isCore) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.then((networkResp) => {
|
||||||
|
const respClone = networkResp.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, respClone)).catch(() => {});
|
||||||
|
return networkResp;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(event.request).then((cached) => cached || caches.match('./') || caches.match('index.html')))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then((cached) => {
|
||||||
|
if (cached) return cached;
|
||||||
|
return fetch(event.request).then((networkResp) => {
|
||||||
|
// Optionally cache new resources (best-effort)
|
||||||
|
try {
|
||||||
|
const respClone = networkResp.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, respClone)).catch(()=>{});
|
||||||
|
} catch (e) {}
|
||||||
|
return networkResp;
|
||||||
|
}).catch(() => {
|
||||||
|
// If offline and HTML navigation, return cached index.html
|
||||||
|
if (event.request.mode === 'navigate') return caches.match('./') || caches.match('index.html');
|
||||||
|
return new Response('', { status: 503, statusText: 'Service Unavailable' });
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
76
index.html
@@ -15,12 +15,88 @@
|
|||||||
<link rel="icon" type="image/png" sizes="192x192" href="assets/radioplayer-logo-192.png">
|
<link rel="icon" type="image/png" sizes="192x192" href="assets/radioplayer-logo-192.png">
|
||||||
<link rel="icon" type="image/png" sizes="512x512" href="assets/radioplayer-logo-512.png">
|
<link rel="icon" type="image/png" sizes="512x512" href="assets/radioplayer-logo-512.png">
|
||||||
<link rel="apple-touch-icon" href="assets/radioplayer-logo-192.png">
|
<link rel="apple-touch-icon" href="assets/radioplayer-logo-192.png">
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
background: #111318;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app-loading {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2147483647;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
color: #f7f8fb;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 8%, rgba(77, 215, 200, 0.24), transparent 34%),
|
||||||
|
radial-gradient(circle at 86% 82%, rgba(255, 180, 92, 0.2), transparent 32%),
|
||||||
|
linear-gradient(180deg, #111318 0%, #101218 58%, #111821 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-loading-card {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-loading-logo {
|
||||||
|
width: 92px;
|
||||||
|
height: 92px;
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-loading-title {
|
||||||
|
font: 800 1.2rem/1.1 Inter, "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-loading-subtitle {
|
||||||
|
color: rgba(247, 248, 251, 0.72);
|
||||||
|
font: 600 0.88rem/1.4 Inter, "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-loading-bar {
|
||||||
|
width: min(220px, 68vw);
|
||||||
|
height: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-loading-bar::before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
width: 42%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, #4dd7c8, #8fb3ff);
|
||||||
|
animation: app-loading-slide 1.1s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes app-loading-slide {
|
||||||
|
from { transform: translateX(-18%); }
|
||||||
|
to { transform: translateX(118%); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
<!-- Google Cast Web Sender SDK -->
|
<!-- Google Cast Web Sender SDK -->
|
||||||
<script src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"></script>
|
<script src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"></script>
|
||||||
<script src="/src/main.jsx" defer type="module"></script>
|
<script src="/src/main.jsx" defer type="module"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<div id="app-loading" role="status" aria-live="polite" aria-label="Loading RadioPlayer">
|
||||||
|
<div class="app-loading-card">
|
||||||
|
<img class="app-loading-logo" src="assets/radioplayer-logo-192.png" alt="RadioPlayer" />
|
||||||
|
<div class="app-loading-title">RadioPlayer</div>
|
||||||
|
<div class="app-loading-subtitle">Loading your stations...</div>
|
||||||
|
<div class="app-loading-bar" aria-hidden="true"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"prebuild": "node scripts/bump-sw-cache-version.mjs",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"update:stations": "node scripts/update-stations.mjs",
|
"update:stations": "node scripts/update-stations.mjs",
|
||||||
|
|||||||
353
privacy.html
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="sl">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Pravilnik o zasebnosti | RadioPlayer</title>
|
||||||
|
<meta name="description" content="Pravilnik o zasebnosti za spletno aplikacijo RadioPlayer.">
|
||||||
|
<meta name="theme-color" content="#111318">
|
||||||
|
<link rel="manifest" href="manifest.json">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="assets/radioplayer-logo-192.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="512x512" href="assets/radioplayer-logo-512.png">
|
||||||
|
<link rel="apple-touch-icon" href="assets/radioplayer-logo-192.png">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--page-bg: #111318;
|
||||||
|
--panel: rgba(16, 20, 27, 0.82);
|
||||||
|
--panel-strong: rgba(12, 15, 20, 0.94);
|
||||||
|
--border: rgba(255, 255, 255, 0.12);
|
||||||
|
--text-main: #f7f8fb;
|
||||||
|
--text-muted: rgba(247, 248, 251, 0.72);
|
||||||
|
--accent: #4dd7c8;
|
||||||
|
--accent-2: #ffb45c;
|
||||||
|
--accent-3: #8fb3ff;
|
||||||
|
--shadow: 0 28px 72px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
min-height: 100%;
|
||||||
|
background: var(--page-bg);
|
||||||
|
font-family: Inter, "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--text-main);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 8%, rgba(77, 215, 200, 0.24), transparent 34%),
|
||||||
|
radial-gradient(circle at 86% 82%, rgba(255, 180, 92, 0.2), transparent 32%),
|
||||||
|
linear-gradient(180deg, #111318 0%, #101218 58%, #111821 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px);
|
||||||
|
background-size: 42px 42px;
|
||||||
|
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.72), transparent 82%);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-shell {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: min(960px, calc(100% - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 0 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand img {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 12px 28px rgba(77, 215, 200, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.02rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-subtitle {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--text-main);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover,
|
||||||
|
.back-link:focus-visible {
|
||||||
|
border-color: rgba(77, 215, 200, 0.45);
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.privacy-card {
|
||||||
|
padding: clamp(24px, 4vw, 42px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 28px;
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(77, 215, 200, 0.08), transparent 42%),
|
||||||
|
linear-gradient(315deg, rgba(255, 180, 92, 0.08), transparent 36%),
|
||||||
|
linear-gradient(180deg, rgba(16, 20, 27, 0.8), rgba(255, 255, 255, 0.035));
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(22px) saturate(130%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(2rem, 5vw, 3.25rem);
|
||||||
|
line-height: 0.98;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
max-width: 64ch;
|
||||||
|
margin: 18px 0 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-top: 18px;
|
||||||
|
padding: 0.45rem 0.8rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(143, 179, 255, 0.12);
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sections {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
padding: 18px 20px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.045);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
li {
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
p:last-child,
|
||||||
|
ul:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
margin-top: 22px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.page-shell {
|
||||||
|
width: min(100% - 20px, 960px);
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.privacy-card {
|
||||||
|
border-radius: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="page-shell">
|
||||||
|
<div class="topbar">
|
||||||
|
<a class="brand" href="./">
|
||||||
|
<img src="assets/radioplayer-logo-192.png" alt="RadioPlayer">
|
||||||
|
<span>
|
||||||
|
<span class="brand-title">RadioPlayer</span>
|
||||||
|
<span class="brand-subtitle">Spletni predvajalnik radijskih postaj</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a class="back-link" href="./">Nazaj v aplikacijo</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="privacy-card">
|
||||||
|
<p class="eyebrow">Pravilnik</p>
|
||||||
|
<h1>Pravilnik o zasebnosti</h1>
|
||||||
|
<p class="lead">
|
||||||
|
Ta stran pojasnjuje, katere podatke uporablja spletna aplikacija RadioPlayer, zakaj jih uporablja in
|
||||||
|
kje se ti podatki hranijo. Besedilo velja za uporabo spletne aplikacije in njene PWA namestitve.
|
||||||
|
</p>
|
||||||
|
<div class="meta">Velja od: 28. 4. 2026</div>
|
||||||
|
|
||||||
|
<div class="sections">
|
||||||
|
<section>
|
||||||
|
<h2>Kateri podatki nastanejo pri uporabi</h2>
|
||||||
|
<p>
|
||||||
|
RadioPlayer ne zahteva registracije ali ustvarjanja uporabniškega računa. Aplikacija pa lahko v vašem
|
||||||
|
brskalniku lokalno shrani nastavitve in sezname, ki jih ustvarite sami, da si jih ob naslednjem obisku
|
||||||
|
zapomni.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>glasnost predvajanja, zadnjo izbrano postajo in zadnji izbran filter države,</li>
|
||||||
|
<li>seznam priljubljenih postaj in lokalno statistiko predvajanja za prikaz nedavno poslušanih postaj,</li>
|
||||||
|
<li>postaje, ki jih dodate ročno v urejevalniku,</li>
|
||||||
|
<li>nastavitev načina oddajanja zvoka pri Google Cast povezavi.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Kje se podatki hranijo</h2>
|
||||||
|
<p>
|
||||||
|
Zgoraj navedeni podatki se hranijo lokalno v vašem brskalniku oziroma v podatkih spletnega mesta na vaši
|
||||||
|
napravi. RadioPlayer teh nastavitev ne pošilja na lasten strežnik in jih brez vašega dejanja ne deli z
|
||||||
|
drugimi uporabniki.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Aplikacija uporablja tudi predpomnjenje datotek aplikacije za hitrejši zagon in delovanje brez ponovnega
|
||||||
|
prenosa vseh statičnih datotek. To predpomnjenje lahko kadarkoli izbrišete v nastavitvah brskalnika.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Zunanji ponudniki in prenosi</h2>
|
||||||
|
<p>
|
||||||
|
Ko zaženete radijski tok, se vaš brskalnik poveže neposredno s strežnikom radijske postaje ali drugega
|
||||||
|
ponudnika toka. Ti zunanji ponudniki praviloma prejmejo tehnične podatke, ki so potrebni za prenos, na
|
||||||
|
primer IP naslov, podatke o brskalniku in čas zahteve.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Če uporabite funkcijo Google Cast, se za vzpostavitev in upravljanje oddajanja uporabi Google Cast Web
|
||||||
|
Sender SDK. Pri tem lahko Google oziroma naprava za predvajanje obdelujeta podatke, potrebne za sejo
|
||||||
|
oddajanja in usmerjanje medijev.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Piškotki in analitika</h2>
|
||||||
|
<p>
|
||||||
|
Trenutna različica aplikacije ne uporablja analitičnih skript za profiliranje uporabnikov in ne nastavlja
|
||||||
|
lastnih piškotkov za oglaševanje. Aplikacija za shranjevanje nastavitev uporablja lokalno hrambo brskalnika,
|
||||||
|
ne klasičnih piškotkov.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Dnevniki gostovanja</h2>
|
||||||
|
<p>
|
||||||
|
Gostovanje spletne strani lahko zaradi varnosti, stabilnosti ali odpravljanja napak samodejno vodi osnovne
|
||||||
|
tehnične dnevnike dostopa. Obseg teh dnevnikov je odvisen od ponudnika gostovanja in ni del nastavitev,
|
||||||
|
ki jih upravlja uporabniški vmesnik RadioPlayer.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Kako izbrišete lokalne podatke</h2>
|
||||||
|
<p>
|
||||||
|
Če želite odstraniti shranjene priljubljene postaje, uporabniške postaje ali druge nastavitve, izbrišite
|
||||||
|
podatke tega spletnega mesta v svojem brskalniku. S tem se izbrišejo lokalne nastavitve in predpomnjene
|
||||||
|
datoteke aplikacije na tej napravi.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Posodobitve pravilnika</h2>
|
||||||
|
<p>
|
||||||
|
Pravilnik se lahko posodobi, če se spremeni delovanje aplikacije, način gostovanja ali vključeni zunanji
|
||||||
|
ponudniki. Na tej strani bo vedno objavljena veljavna različica besedila.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="note">
|
||||||
|
Če aplikacija v prihodnje doda prijavo, obrazce ali dodatne zunanje storitve, je treba ta pravilnik ustrezno
|
||||||
|
dopolniti.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -3,26 +3,57 @@
|
|||||||
"name": "RadioPlayer",
|
"name": "RadioPlayer",
|
||||||
"short_name": "Radio",
|
"short_name": "Radio",
|
||||||
"description": "RadioPlayer - stream and cast your favorite radio stations.",
|
"description": "RadioPlayer - stream and cast your favorite radio stations.",
|
||||||
|
"lang": "en",
|
||||||
|
"dir": "ltr",
|
||||||
"start_url": "./",
|
"start_url": "./",
|
||||||
"scope": ".",
|
"scope": ".",
|
||||||
"display": "standalone",
|
"display": "fullscreen",
|
||||||
"display_override": ["window-controls-overlay", "standalone", "minimal-ui", "browser"],
|
"display_override": ["fullscreen", "window-controls-overlay", "standalone", "minimal-ui", "browser"],
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"background_color": "#111318",
|
"background_color": "#111318",
|
||||||
"theme_color": "#111318",
|
"theme_color": "#111318",
|
||||||
|
"prefer_related_applications": false,
|
||||||
"categories": ["music", "entertainment"],
|
"categories": ["music", "entertainment"],
|
||||||
|
"screenshots": [
|
||||||
|
{
|
||||||
|
"src": "screenshots/radioplayer-desktop.png",
|
||||||
|
"sizes": "2520x1792",
|
||||||
|
"type": "image/png",
|
||||||
|
"form_factor": "wide",
|
||||||
|
"label": "RadioPlayer desktop player and station library"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "screenshots/radioplayer-mobile.png",
|
||||||
|
"sizes": "683x1477",
|
||||||
|
"type": "image/png",
|
||||||
|
"form_factor": "narrow",
|
||||||
|
"label": "RadioPlayer mobile player and station library"
|
||||||
|
}
|
||||||
|
],
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "assets/radioplayer-logo-192.png",
|
"src": "assets/radioplayer-logo-192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "assets/radioplayer-logo-512.png",
|
"src": "assets/radioplayer-logo-512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/radioplayer-logo-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/radioplayer-logo-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"shortcuts": [
|
"shortcuts": [
|
||||||
|
|||||||
BIN
public/screenshots/radioplayer-desktop.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/screenshots/radioplayer-mobile.png
Normal file
|
After Width: | Height: | Size: 372 KiB |
@@ -929,71 +929,767 @@
|
|||||||
"metadata": {}
|
"metadata": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "us-kexp",
|
"id": "si-netfm",
|
||||||
"name": "KEXP 90.3",
|
"name": "Radio NET FM",
|
||||||
"slogan": "",
|
"slogan": "",
|
||||||
"category": "Alternative",
|
"category": "Regional",
|
||||||
"country": "US",
|
"country": "SI",
|
||||||
"language": "en",
|
"language": "sl",
|
||||||
"region": "International",
|
"region": "Maribor",
|
||||||
"tags": [
|
"tags": [
|
||||||
"indie",
|
"music",
|
||||||
"alternative",
|
"international",
|
||||||
"public"
|
"traffic"
|
||||||
],
|
],
|
||||||
"website": "https://www.kexp.org/",
|
"website": "https://www.radionet.si/",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"assets": {
|
"assets": {
|
||||||
"logo": ""
|
"logo": ""
|
||||||
},
|
},
|
||||||
"streams": {
|
"streams": {
|
||||||
"audio": "https://kexp.streamguys1.com/kexp160.aac"
|
"audio": "https://stream.radionet.si/stream.ogg"
|
||||||
},
|
},
|
||||||
"metadata": {}
|
"metadata": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "uk-nts-1",
|
"id": "capris-live",
|
||||||
"name": "NTS Radio 1",
|
"name": "Radio Capris LIVE",
|
||||||
"slogan": "",
|
"slogan": "",
|
||||||
"category": "Electronic",
|
"category": "Pop",
|
||||||
"country": "GB",
|
"country": "SI",
|
||||||
"language": "en",
|
"language": "sl",
|
||||||
"region": "International",
|
"region": "Koper",
|
||||||
"tags": [
|
"tags": [
|
||||||
"underground",
|
"capris",
|
||||||
"electronic",
|
"live",
|
||||||
"experimental"
|
"hits"
|
||||||
],
|
],
|
||||||
"website": "https://www.nts.live/",
|
"website": "https://www.radiocapris.si/si/onair/live",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"assets": {
|
"assets": {
|
||||||
"logo": ""
|
"logo": "https://www.radiocapris.si/data/images/streams/live.png?t=3"
|
||||||
},
|
},
|
||||||
"streams": {
|
"streams": {
|
||||||
"audio": "https://stream-relay-geo.ntslive.net/stream?client=direct"
|
"audio": "http://stream.exit.si/live"
|
||||||
},
|
},
|
||||||
"metadata": {}
|
"metadata": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "uk-nts-2",
|
"id": "capris-cafe",
|
||||||
"name": "NTS Radio 2",
|
"name": "Radio Capris CAFE",
|
||||||
"slogan": "",
|
"slogan": "",
|
||||||
"category": "Electronic",
|
"category": "Chill",
|
||||||
"country": "GB",
|
"country": "SI",
|
||||||
"language": "en",
|
"language": "sl",
|
||||||
"region": "International",
|
"region": "Koper",
|
||||||
"tags": [
|
"tags": [
|
||||||
"underground",
|
"capris",
|
||||||
"electronic",
|
"cafe",
|
||||||
"experimental"
|
"chill",
|
||||||
|
"lounge"
|
||||||
],
|
],
|
||||||
"website": "https://www.nts.live/",
|
"website": "https://www.radiocapris.si/si/onair/cafe",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": "https://www.radiocapris.si/data/images/streams/cafe.png?t=3"
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "http://stream.exit.si/CAFE"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "capris-ibiza",
|
||||||
|
"name": "Radio Capris IBIZA",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Dance",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "Koper",
|
||||||
|
"tags": [
|
||||||
|
"capris",
|
||||||
|
"ibiza",
|
||||||
|
"dance",
|
||||||
|
"electronic"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocapris.si/si/onair/ibiza",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": "https://www.radiocapris.si/data/images/streams/ibiza.png?t=4"
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "http://stream.exit.si/IBIZA"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "capris-hits",
|
||||||
|
"name": "Radio Capris HITS",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Pop",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "Koper",
|
||||||
|
"tags": [
|
||||||
|
"capris",
|
||||||
|
"hits",
|
||||||
|
"pop"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocapris.si/si/onair/hits",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": "https://www.radiocapris.si/data/images/streams/hits.png?t=3"
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "http://stream.exit.si/HITS"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "capris-80",
|
||||||
|
"name": "Radio Capris 80'",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Retro",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "Koper",
|
||||||
|
"tags": [
|
||||||
|
"capris",
|
||||||
|
"80s",
|
||||||
|
"retro"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocapris.si/si/onair/80",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": "https://www.radiocapris.si/data/images/streams/80.png?t=3"
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "http://stream.exit.si/80"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "capris-dalmacija",
|
||||||
|
"name": "Radio Capris DALMACIJA",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Regional",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "Koper",
|
||||||
|
"tags": [
|
||||||
|
"capris",
|
||||||
|
"dalmacija",
|
||||||
|
"croatian"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocapris.si/si/onair/dalmacija",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": "https://www.radiocapris.si/data/images/streams/dalmacija.png?t=3"
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "http://stream.exit.si/DALMACIJA"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "capris-exyu",
|
||||||
|
"name": "Radio Capris EXYU",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Regional",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "Koper",
|
||||||
|
"tags": [
|
||||||
|
"capris",
|
||||||
|
"exyu",
|
||||||
|
"balkan"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocapris.si/si/onair/exyu",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": "https://www.radiocapris.si/data/images/streams/exyu.png?t=3"
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "http://stream.exit.si/EXYU"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "capris-love",
|
||||||
|
"name": "Radio Capris LOVE",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Pop",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "Koper",
|
||||||
|
"tags": [
|
||||||
|
"capris",
|
||||||
|
"love",
|
||||||
|
"ballads"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocapris.si/si/onair/love",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": "https://www.radiocapris.si/data/images/streams/love.png?t=3"
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "http://stream.exit.si/LOVE"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "capris-spomini",
|
||||||
|
"name": "Radio Capris SPOMINI",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Retro",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "Koper",
|
||||||
|
"tags": [
|
||||||
|
"capris",
|
||||||
|
"spomini",
|
||||||
|
"oldies"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocapris.si/si/onair/spomini",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": "https://www.radiocapris.si/data/images/streams/spomini.png?t=3"
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "http://stream.exit.si/SPOMINI"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "capris-rock",
|
||||||
|
"name": "Radio Capris ROCK",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Rock",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "Koper",
|
||||||
|
"tags": [
|
||||||
|
"capris",
|
||||||
|
"rock"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocapris.si/si/onair/rock",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": "https://www.radiocapris.si/data/images/streams/rock.png?t=3"
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "http://stream.exit.si/ROCK"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "capris-poletje",
|
||||||
|
"name": "Radio Capris POLETJE",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Seasonal",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "Koper",
|
||||||
|
"tags": [
|
||||||
|
"capris",
|
||||||
|
"summer",
|
||||||
|
"poletje"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocapris.si/si/onair/poletje",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": "https://www.radiocapris.si/data/images/streams/poletje.png?t=3"
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "http://stream.exit.si/POLETJE"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "capris-no1",
|
||||||
|
"name": "Radio Capris NUMBER 1's",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Pop",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "Koper",
|
||||||
|
"tags": [
|
||||||
|
"capris",
|
||||||
|
"number-one",
|
||||||
|
"hits"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocapris.si/si/onair/no1",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": "https://www.radiocapris.si/data/images/streams/no1.png?t=3"
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "http://stream.exit.si/NO1"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "capris-90-pop",
|
||||||
|
"name": "Radio Capris 90's POP",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Retro",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "Koper",
|
||||||
|
"tags": [
|
||||||
|
"capris",
|
||||||
|
"90s",
|
||||||
|
"pop"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocapris.si/si/onair/90",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": "https://www.radiocapris.si/data/images/streams/90.png?t=3"
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "http://stream.exit.si/90"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "capris-megamix",
|
||||||
|
"name": "Radio Capris 90's DANCE / MEGAMIX",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Dance",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "Koper",
|
||||||
|
"tags": [
|
||||||
|
"capris",
|
||||||
|
"90s",
|
||||||
|
"dance",
|
||||||
|
"megamix"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocapris.si/si/onair/megamix",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": "https://www.radiocapris.si/data/images/streams/megamix.png?t=3"
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "http://stream.exit.si/MEGAMIX"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "capris-inthemix",
|
||||||
|
"name": "Radio Capris IN THE MIX",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Dance",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "Koper",
|
||||||
|
"tags": [
|
||||||
|
"capris",
|
||||||
|
"mix",
|
||||||
|
"dance"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocapris.si/si/onair/inthemix",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": "https://www.radiocapris.si/data/images/streams/inthemix.png?t=3"
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "http://stream.exit.si/INTHEMIX"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "capris-club",
|
||||||
|
"name": "Radio Capris CLUB",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Dance",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "Koper",
|
||||||
|
"tags": [
|
||||||
|
"capris",
|
||||||
|
"club",
|
||||||
|
"dance"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocapris.si/si/onair/club",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": "https://www.radiocapris.si/data/images/streams/club.png?t=3"
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "http://stream.exit.si/CLUB"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "capris-ita",
|
||||||
|
"name": "Radio Capris ITALIJA",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Regional",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "Koper",
|
||||||
|
"tags": [
|
||||||
|
"capris",
|
||||||
|
"italian",
|
||||||
|
"ita"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocapris.si/si/onair/ita",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": "https://www.radiocapris.si/data/images/streams/ita.png?t=3"
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "http://stream.exit.si/ITA"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "capris-slo",
|
||||||
|
"name": "Radio Capris SLOVENIJA",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Regional",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "Koper",
|
||||||
|
"tags": [
|
||||||
|
"capris",
|
||||||
|
"slovenian",
|
||||||
|
"slo"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocapris.si/si/onair/slo",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": "https://www.radiocapris.si/data/images/streams/slo.png?t=3"
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "http://stream.exit.si/SLO"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "capris-istra",
|
||||||
|
"name": "Radio Capris ISTRA",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Regional",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "Koper",
|
||||||
|
"tags": [
|
||||||
|
"capris",
|
||||||
|
"istra",
|
||||||
|
"local"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocapris.si/si/onair/istra",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": "https://www.radiocapris.si/data/images/streams/istra.png?t=3"
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "http://stream.exit.si/ISTRA"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "capris-xmas",
|
||||||
|
"name": "Radio Capris XMAS",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Seasonal",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "Koper",
|
||||||
|
"tags": [
|
||||||
|
"capris",
|
||||||
|
"xmas",
|
||||||
|
"christmas",
|
||||||
|
"seasonal"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocapris.si/si/onair/xmas",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": "https://www.radiocapris.si/data/images/streams/xmas.png?t=3"
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "http://stream.exit.si/XMAS"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hitradio-center",
|
||||||
|
"name": "Hitradio Center",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Pop",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "National",
|
||||||
|
"tags": [
|
||||||
|
"center",
|
||||||
|
"hits",
|
||||||
|
"pop"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocenter.si/live/hitradio-center",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"assets": {
|
"assets": {
|
||||||
"logo": ""
|
"logo": ""
|
||||||
},
|
},
|
||||||
"streams": {
|
"streams": {
|
||||||
"audio": "https://stream-relay-geo.ntslive.net/stream2?client=direct"
|
"audio": "https://stream2.nextmedia.si/hls/hrc/live.m3u8"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hitradio-center-top-100",
|
||||||
|
"name": "Center Top 100",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Pop",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "National",
|
||||||
|
"tags": [
|
||||||
|
"center",
|
||||||
|
"top-100",
|
||||||
|
"hits"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocenter.si/live/center-top-100",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": ""
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "https://stream.nextmedia.si/proxy/centerhit4?mp=/stream?oid=hrcweb"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hitradio-center-fresh-pop",
|
||||||
|
"name": "Center Fresh Pop",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Pop",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "National",
|
||||||
|
"tags": [
|
||||||
|
"center",
|
||||||
|
"fresh-pop",
|
||||||
|
"pop"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocenter.si/live/center-fresh-pop",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": ""
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "https://stream.nextmedia.si/proxy/centerpop4?mp=/stream"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hitradio-center-in-the-mix",
|
||||||
|
"name": "Center In The Mix / Megamix",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Dance",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "National",
|
||||||
|
"tags": [
|
||||||
|
"center",
|
||||||
|
"mix",
|
||||||
|
"megamix",
|
||||||
|
"dance"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocenter.si/live/center-megamix/",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": ""
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "https://stream.nextmedia.si/proxy/centermix4?mp=/stream"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hitradio-center-80s",
|
||||||
|
"name": "Center 80's X",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Retro",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "National",
|
||||||
|
"tags": [
|
||||||
|
"center",
|
||||||
|
"80s",
|
||||||
|
"retro"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocenter.si/live/center-80s",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": ""
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "https://stream2.nextmedia.si/hls/gex/live.m3u8?sid=1777222063788516308"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hitradio-center-latin",
|
||||||
|
"name": "Center Latin",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Latin",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "National",
|
||||||
|
"tags": [
|
||||||
|
"center",
|
||||||
|
"latin"
|
||||||
|
],
|
||||||
|
"website": "https://stream.nextmedia.si/proxy/center_latin?mp=/center",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": ""
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "http://stream3.radiocenter.si:8200/;stream/1"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hitradio-center-love",
|
||||||
|
"name": "Center Love",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Pop",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "National",
|
||||||
|
"tags": [
|
||||||
|
"center",
|
||||||
|
"love",
|
||||||
|
"ballads"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocenter.si/live/center-love",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": ""
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "https://stream.nextmedia.si/proxy/centerlove4?mp=/stream"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hitradio-center-yu",
|
||||||
|
"name": "Center YU",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Retro",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "National",
|
||||||
|
"tags": [
|
||||||
|
"center",
|
||||||
|
"yu",
|
||||||
|
"retro"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocenter.si/",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": ""
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "https://stream2.nextmedia.si/hls/jub/live.m3u8?sid=1777222063788516308"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hitradio-center-gen-x",
|
||||||
|
"name": "Center Gen X",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Retro",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "National",
|
||||||
|
"tags": [
|
||||||
|
"center",
|
||||||
|
"international",
|
||||||
|
"retro"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocenter.si/",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": ""
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "https://stream2.nextmedia.si/hls/gex/128k/gex-128.m3u8?sid=1777222063788516308"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hitradio-enter-radio",
|
||||||
|
"name": "Center Enter Radio",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Retro",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "National",
|
||||||
|
"tags": [
|
||||||
|
"center",
|
||||||
|
"international",
|
||||||
|
"retro"
|
||||||
|
],
|
||||||
|
"website": "https://www.radiocenter.si/",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": ""
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "https://stream2.nextmedia.si/hls/ent/live.m3u8?sid=1777222063788516308"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "si-radio-city",
|
||||||
|
"name": "Radio City",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Regional",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "Maribor",
|
||||||
|
"tags": [
|
||||||
|
"radio-city",
|
||||||
|
"maribor",
|
||||||
|
"pop",
|
||||||
|
"hits",
|
||||||
|
"local"
|
||||||
|
],
|
||||||
|
"website": "https://radiocity.si/",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": ""
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "https://stream1.radiocity.si/CityMp3128.mp3"
|
||||||
|
},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "si-radio-ekspres",
|
||||||
|
"name": "Radio Exspres",
|
||||||
|
"slogan": "",
|
||||||
|
"category": "Pop",
|
||||||
|
"country": "SI",
|
||||||
|
"language": "sl",
|
||||||
|
"region": "National",
|
||||||
|
"tags": [
|
||||||
|
"ekspres",
|
||||||
|
"pop",
|
||||||
|
"hits",
|
||||||
|
"slovenia"
|
||||||
|
],
|
||||||
|
"website": "https://www.radioekspres.si/",
|
||||||
|
"enabled": true,
|
||||||
|
"assets": {
|
||||||
|
"logo": ""
|
||||||
|
},
|
||||||
|
"streams": {
|
||||||
|
"audio": "https://stream.nextmedia.si/proxy/ekspres1?mp=/stream"
|
||||||
},
|
},
|
||||||
"metadata": {}
|
"metadata": {}
|
||||||
}
|
}
|
||||||
|
|||||||
168
public/sw.js
@@ -1,21 +1,104 @@
|
|||||||
// NOTE: This service worker is for the web/PWA build.
|
// NOTE: This service worker is for the web/PWA build.
|
||||||
// For development we aggressively unregister SWs in `src/player.js`.
|
// For development we aggressively unregister SWs in `src/player.js`.
|
||||||
//
|
//
|
||||||
// Bump this value whenever caching logic changes to guarantee clients don't
|
// This value is rewritten automatically before each build so deployed clients
|
||||||
// keep an old UI after updates.
|
// refresh to the newest shell and cached assets.
|
||||||
const CACHE_NAME = 'radioplayer-pwa-v4';
|
const CACHE_NAME = 'radioplayer-pwa-v5-1777404493334';
|
||||||
|
|
||||||
const CORE_ASSETS = [
|
const CORE_ASSETS = [
|
||||||
'./',
|
'./',
|
||||||
'index.html',
|
'index.html',
|
||||||
|
'privacy.html',
|
||||||
'data/radio-stations.json',
|
'data/radio-stations.json',
|
||||||
'stations.json',
|
'stations.json',
|
||||||
'manifest.json',
|
'manifest.json',
|
||||||
|
'images/radio-placeholder.svg',
|
||||||
'assets/radioplayer-logo-192.png',
|
'assets/radioplayer-logo-192.png',
|
||||||
'assets/radioplayer-logo-512.png',
|
'assets/radioplayer-logo-512.png',
|
||||||
];
|
];
|
||||||
|
|
||||||
const CORE_PATHS = new Set(CORE_ASSETS.map((p) => new URL(p, self.registration.scope).pathname));
|
const CORE_PATHS = new Set(CORE_ASSETS.map((p) => new URL(p, self.registration.scope).pathname));
|
||||||
|
const DATA_PATHS = new Set([
|
||||||
|
new URL('data/radio-stations.json', self.registration.scope).pathname,
|
||||||
|
new URL('stations.json', self.registration.scope).pathname,
|
||||||
|
new URL('manifest.json', self.registration.scope).pathname,
|
||||||
|
]);
|
||||||
|
const IMAGE_FALLBACK_PATH = new URL('images/radio-placeholder.svg', self.registration.scope).pathname;
|
||||||
|
|
||||||
|
function isCacheableResponse(response) {
|
||||||
|
return Boolean(response && response.ok && response.type === 'basic');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putInCache(request, response) {
|
||||||
|
if (!isCacheableResponse(response)) return;
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
await cache.put(request, response.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function precacheBuiltAssets(cache) {
|
||||||
|
try {
|
||||||
|
const indexResp = await fetch(new Request('./', { cache: 'reload' }));
|
||||||
|
if (isCacheableResponse(indexResp)) {
|
||||||
|
await cache.put('./', indexResp.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexText = await indexResp.text();
|
||||||
|
const assetUrls = [...indexText.matchAll(/(?:src|href)="([^"]+)"/g)]
|
||||||
|
.map((match) => match[1])
|
||||||
|
.filter((assetPath) => assetPath.startsWith('./assets/') || assetPath.startsWith('assets/'))
|
||||||
|
.map((assetPath) => new URL(assetPath, self.registration.scope).href);
|
||||||
|
|
||||||
|
await Promise.allSettled(assetUrls.map(async (assetUrl) => {
|
||||||
|
const assetResp = await fetch(new Request(assetUrl, { cache: 'reload' }));
|
||||||
|
if (!isCacheableResponse(assetResp)) return;
|
||||||
|
await cache.put(assetUrl, assetResp);
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
// If HTML parsing fails, runtime caching below still catches assets.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function networkFirst(request, fallbackRequest) {
|
||||||
|
try {
|
||||||
|
const networkResp = await fetch(request);
|
||||||
|
await putInCache(request, networkResp);
|
||||||
|
return networkResp;
|
||||||
|
} catch (error) {
|
||||||
|
const cached = await caches.match(request);
|
||||||
|
if (cached) return cached;
|
||||||
|
if (fallbackRequest) {
|
||||||
|
const fallback = await caches.match(fallbackRequest);
|
||||||
|
if (fallback) return fallback;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function staleWhileRevalidate(request, fallbackRequest) {
|
||||||
|
const cached = await caches.match(request);
|
||||||
|
|
||||||
|
const networkPromise = fetch(request)
|
||||||
|
.then(async (networkResp) => {
|
||||||
|
await putInCache(request, networkResp);
|
||||||
|
return networkResp;
|
||||||
|
})
|
||||||
|
.catch(async () => {
|
||||||
|
if (fallbackRequest) {
|
||||||
|
const fallback = await caches.match(fallbackRequest);
|
||||||
|
if (fallback) return fallback;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const networkResp = await networkPromise;
|
||||||
|
if (networkResp) return networkResp;
|
||||||
|
|
||||||
|
return new Response('', { status: 503, statusText: 'Service Unavailable' });
|
||||||
|
}
|
||||||
|
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
// Activate updated SW as soon as it's installed.
|
// Activate updated SW as soon as it's installed.
|
||||||
@@ -23,26 +106,15 @@ self.addEventListener('install', (event) => {
|
|||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME).then(async (cache) => {
|
caches.open(CACHE_NAME).then(async (cache) => {
|
||||||
const reqs = CORE_ASSETS.map((p) => new Request(p, { cache: 'reload' }));
|
const reqs = CORE_ASSETS.map((p) => new Request(p, { cache: 'reload' }));
|
||||||
await cache.addAll(reqs);
|
await Promise.allSettled(reqs.map(async (request) => {
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (!isCacheableResponse(response)) return;
|
||||||
|
await cache.put(request, response);
|
||||||
|
}));
|
||||||
|
|
||||||
// Vite fingerprints JS/CSS assets in production. Parse the built HTML so
|
// Vite fingerprints JS/CSS assets in production. Parse the built HTML so
|
||||||
// the installed PWA can launch offline after its first install.
|
// the installed PWA can launch offline after its first install.
|
||||||
try {
|
await precacheBuiltAssets(cache);
|
||||||
const indexResp = await fetch(new Request('./', { cache: 'reload' }));
|
|
||||||
const indexText = await indexResp.clone().text();
|
|
||||||
await cache.put('./', indexResp);
|
|
||||||
|
|
||||||
const assetUrls = [...indexText.matchAll(/(?:src|href)="([^"]+)"/g)]
|
|
||||||
.map((match) => match[1])
|
|
||||||
.filter((assetPath) => assetPath.startsWith('./assets/') || assetPath.startsWith('assets/'))
|
|
||||||
.map((assetPath) => new URL(assetPath, self.registration.scope).href);
|
|
||||||
|
|
||||||
await Promise.all(assetUrls.map((assetUrl) => {
|
|
||||||
return cache.add(new Request(assetUrl, { cache: 'reload' })).catch(() => {});
|
|
||||||
}));
|
|
||||||
} catch (e) {
|
|
||||||
// If HTML parsing fails, runtime caching below still catches assets.
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -61,6 +133,7 @@ self.addEventListener('activate', (event) => {
|
|||||||
self.addEventListener('fetch', (event) => {
|
self.addEventListener('fetch', (event) => {
|
||||||
// Only handle GET requests
|
// Only handle GET requests
|
||||||
if (event.request.method !== 'GET') return;
|
if (event.request.method !== 'GET') return;
|
||||||
|
if (event.request.headers.has('range')) return;
|
||||||
|
|
||||||
const url = new URL(event.request.url);
|
const url = new URL(event.request.url);
|
||||||
|
|
||||||
@@ -70,37 +143,46 @@ self.addEventListener('fetch', (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isCore = CORE_PATHS.has(url.pathname);
|
const isCore = CORE_PATHS.has(url.pathname);
|
||||||
|
const isDataRequest = DATA_PATHS.has(url.pathname);
|
||||||
const isHtmlNavigation = event.request.mode === 'navigate' || (event.request.headers.get('accept') || '').includes('text/html');
|
const isHtmlNavigation = event.request.mode === 'navigate' || (event.request.headers.get('accept') || '').includes('text/html');
|
||||||
|
const isStaticAsset = ['script', 'style', 'font', 'worker'].includes(event.request.destination)
|
||||||
|
|| url.pathname.includes('/assets/');
|
||||||
|
const isImage = event.request.destination === 'image';
|
||||||
|
|
||||||
|
if (url.pathname.endsWith('/sw.js')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Network-first for navigations and core assets to prevent "old UI" issues.
|
// Network-first for navigations and core assets to prevent "old UI" issues.
|
||||||
if (isHtmlNavigation || isCore) {
|
if (isHtmlNavigation) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
fetch(event.request)
|
networkFirst(event.request, './').catch(
|
||||||
.then((networkResp) => {
|
() => caches.match('./') || caches.match('index.html') || new Response('', { status: 503, statusText: 'Offline' })
|
||||||
const respClone = networkResp.clone();
|
)
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, respClone)).catch(() => {});
|
|
||||||
return networkResp;
|
|
||||||
})
|
|
||||||
.catch(() => caches.match(event.request).then((cached) => cached || caches.match('./') || caches.match('index.html')))
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isCore || isDataRequest) {
|
||||||
|
event.respondWith(
|
||||||
|
networkFirst(event.request).catch(
|
||||||
|
() => caches.match(event.request) || new Response('', { status: 503, statusText: 'Offline' })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStaticAsset) {
|
||||||
|
event.respondWith(staleWhileRevalidate(event.request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isImage) {
|
||||||
|
event.respondWith(staleWhileRevalidate(event.request, IMAGE_FALLBACK_PATH));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(event.request).then((cached) => {
|
staleWhileRevalidate(event.request)
|
||||||
if (cached) return cached;
|
|
||||||
return fetch(event.request).then((networkResp) => {
|
|
||||||
// Optionally cache new resources (best-effort)
|
|
||||||
try {
|
|
||||||
const respClone = networkResp.clone();
|
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, respClone)).catch(()=>{});
|
|
||||||
} catch (e) {}
|
|
||||||
return networkResp;
|
|
||||||
}).catch(() => {
|
|
||||||
// If offline and HTML navigation, return cached index.html
|
|
||||||
if (event.request.mode === 'navigate') return caches.match('./') || caches.match('index.html');
|
|
||||||
return new Response('', { status: 503, statusText: 'Service Unavailable' });
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
17
scripts/bump-sw-cache-version.mjs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
const swPath = resolve(process.cwd(), 'public', 'sw.js');
|
||||||
|
const buildStamp = `${Date.now()}`;
|
||||||
|
|
||||||
|
const source = await readFile(swPath, 'utf8');
|
||||||
|
const next = source.replace(
|
||||||
|
/const CACHE_NAME = 'radioplayer-pwa-v5(?:-[^']+)?';/,
|
||||||
|
`const CACHE_NAME = 'radioplayer-pwa-v5-${buildStamp}';`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (next === source) {
|
||||||
|
throw new Error('Failed to update service worker cache version.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(swPath, next, 'utf8');
|
||||||
27
src/App.jsx
@@ -150,6 +150,16 @@ function QuickPickCarousel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LegalLinks() {
|
||||||
|
return (
|
||||||
|
<nav className="legal-links" aria-label="Legal">
|
||||||
|
<a className="legal-link" href={`${import.meta.env.BASE_URL}privacy.html`}>
|
||||||
|
Pravilnik o zasebnosti
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TrackInfo() {
|
function TrackInfo() {
|
||||||
return (
|
return (
|
||||||
<section className="track-info">
|
<section className="track-info">
|
||||||
@@ -244,6 +254,21 @@ function VolumeControl() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InstallPromptBanner() {
|
||||||
|
return (
|
||||||
|
<section id="install-prompt-banner" className="install-prompt-banner hidden" aria-live="polite" aria-label="Install RadioPlayer">
|
||||||
|
<div className="install-prompt-copy">
|
||||||
|
<strong>Install RadioPlayer</strong>
|
||||||
|
<span>Run it like an app on your phone.</span>
|
||||||
|
</div>
|
||||||
|
<div className="install-prompt-actions">
|
||||||
|
<button id="install-prompt-action" className="btn cancel install-prompt-action" type="button">Install</button>
|
||||||
|
<button id="install-prompt-dismiss" className="btn secondary install-prompt-dismiss" type="button">Later</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function RetroStarfield() {
|
function RetroStarfield() {
|
||||||
const stars = Array.from({ length: 120 }, (_, index) => {
|
const stars = Array.from({ length: 120 }, (_, index) => {
|
||||||
const angle = (index * 137.508) % 360;
|
const angle = (index * 137.508) % 360;
|
||||||
@@ -455,9 +480,11 @@ export default function App() {
|
|||||||
<PlayerControls />
|
<PlayerControls />
|
||||||
<VolumeControl />
|
<VolumeControl />
|
||||||
<QuickPickCarousel />
|
<QuickPickCarousel />
|
||||||
|
<LegalLinks />
|
||||||
<StationsOverlay />
|
<StationsOverlay />
|
||||||
<EditorOverlay />
|
<EditorOverlay />
|
||||||
</main>
|
</main>
|
||||||
|
<InstallPromptBanner />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
50
src/main.jsx
@@ -4,6 +4,54 @@ import { flushSync } from 'react-dom';
|
|||||||
import App from './App.jsx';
|
import App from './App.jsx';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
|
const hadServiceWorkerControllerAtLoad = 'serviceWorker' in navigator
|
||||||
|
? Boolean(navigator.serviceWorker.controller)
|
||||||
|
: false;
|
||||||
|
let hasReloadedForServiceWorkerUpdate = false;
|
||||||
|
|
||||||
|
function setupServiceWorker() {
|
||||||
|
if (!('serviceWorker' in navigator)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
navigator.serviceWorker.getRegistrations()
|
||||||
|
.then((registrations) => Promise.all(registrations.map((reg) => reg.unregister())))
|
||||||
|
.catch((err) => console.debug('ServiceWorker dev cleanup failed:', err));
|
||||||
|
|
||||||
|
if ('caches' in window) {
|
||||||
|
caches.keys()
|
||||||
|
.then((keys) => Promise.all(keys.map((key) => caches.delete(key))))
|
||||||
|
.catch((err) => console.debug('Cache dev cleanup failed:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const swUrl = `${import.meta.env.BASE_URL}sw.js`;
|
||||||
|
navigator.serviceWorker.register(swUrl, { updateViaCache: 'none' })
|
||||||
|
.then(async (reg) => {
|
||||||
|
try {
|
||||||
|
await reg.update();
|
||||||
|
} catch (e) {
|
||||||
|
console.debug('ServiceWorker update check failed:', e);
|
||||||
|
}
|
||||||
|
console.log('ServiceWorker registered:', reg.scope);
|
||||||
|
})
|
||||||
|
.catch((err) => console.debug('ServiceWorker registration failed:', err));
|
||||||
|
|
||||||
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||||
|
if (!hadServiceWorkerControllerAtLoad || hasReloadedForServiceWorkerUpdate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasReloadedForServiceWorkerUpdate = true;
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupServiceWorker();
|
||||||
|
|
||||||
const rootEl = document.getElementById('root');
|
const rootEl = document.getElementById('root');
|
||||||
const root = createRoot(rootEl);
|
const root = createRoot(rootEl);
|
||||||
|
|
||||||
@@ -15,6 +63,8 @@ flushSync(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('app-loading')?.remove();
|
||||||
|
|
||||||
import('./player.js')
|
import('./player.js')
|
||||||
.then(({ initPlayer }) => initPlayer())
|
.then(({ initPlayer }) => initPlayer())
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ const stationLibraryPagePrevBtn = document.getElementById('station-library-page-
|
|||||||
const stationLibraryPageNextBtn = document.getElementById('station-library-page-next');
|
const stationLibraryPageNextBtn = document.getElementById('station-library-page-next');
|
||||||
const stationLibraryPageInfo = document.getElementById('station-library-page-info');
|
const stationLibraryPageInfo = document.getElementById('station-library-page-info');
|
||||||
const stationTabBtns = document.querySelectorAll('[data-station-tab]');
|
const stationTabBtns = document.querySelectorAll('[data-station-tab]');
|
||||||
|
const installPromptBannerEl = document.getElementById('install-prompt-banner');
|
||||||
|
const installPromptActionBtn = document.getElementById('install-prompt-action');
|
||||||
|
const installPromptDismissBtn = document.getElementById('install-prompt-dismiss');
|
||||||
|
|
||||||
const radioCountryCodeByName = new Map(radioCountries.map((country) => [country.name, country.code]));
|
const radioCountryCodeByName = new Map(radioCountries.map((country) => [country.name, country.code]));
|
||||||
const radioCountryNameByCode = new Map(radioCountries.map((country) => [country.code, country.name]));
|
const radioCountryNameByCode = new Map(radioCountries.map((country) => [country.code, country.name]));
|
||||||
@@ -124,6 +127,47 @@ function prefersReducedMotion() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMobileViewport() {
|
||||||
|
return window.matchMedia('(max-width: 760px)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStandalonePwa() {
|
||||||
|
return window.matchMedia('(display-mode: standalone)').matches
|
||||||
|
|| window.matchMedia('(display-mode: fullscreen)').matches
|
||||||
|
|| window.navigator.standalone === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lockPortraitOrientation() {
|
||||||
|
if (!isMobileViewport()) return;
|
||||||
|
if (!isStandalonePwa()) return;
|
||||||
|
if (!screen?.orientation?.lock) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await screen.orientation.lock('portrait');
|
||||||
|
} catch (e) {
|
||||||
|
// Some browsers require a user gesture or simply do not allow locking.
|
||||||
|
console.debug('Portrait orientation lock not available:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMobileInstallViewport() {
|
||||||
|
return window.matchMedia('(max-width: 760px)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideInstallPromptUI() {
|
||||||
|
installAppBtn?.classList.add('hidden');
|
||||||
|
installPromptBannerEl?.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showInstallPromptUI() {
|
||||||
|
if (!deferredInstallPrompt) return;
|
||||||
|
if (isMobileInstallViewport()) {
|
||||||
|
installPromptBannerEl?.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
installAppBtn?.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clearArtworkShineTimers() {
|
function clearArtworkShineTimers() {
|
||||||
if (artworkShineTimeoutId) {
|
if (artworkShineTimeoutId) {
|
||||||
clearTimeout(artworkShineTimeoutId);
|
clearTimeout(artworkShineTimeoutId);
|
||||||
@@ -2252,6 +2296,8 @@ function setupEventListeners() {
|
|||||||
|
|
||||||
artworkPlaceholder?.addEventListener('click', openStationLibrary);
|
artworkPlaceholder?.addEventListener('click', openStationLibrary);
|
||||||
castOutputBtn?.addEventListener('click', toggleCastBothMode);
|
castOutputBtn?.addEventListener('click', toggleCastBothMode);
|
||||||
|
installPromptActionBtn?.addEventListener('click', promptInstallApp);
|
||||||
|
installPromptDismissBtn?.addEventListener('click', hideInstallPromptUI);
|
||||||
window.addEventListener('resize', () => {
|
window.addEventListener('resize', () => {
|
||||||
updateCoverflowTransforms();
|
updateCoverflowTransforms();
|
||||||
updateStationTitleLayout(getStationTitle(stations[currentIndex]));
|
updateStationTitleLayout(getStationTitle(stations[currentIndex]));
|
||||||
@@ -2283,7 +2329,7 @@ async function promptInstallApp() {
|
|||||||
deferredInstallPrompt.prompt();
|
deferredInstallPrompt.prompt();
|
||||||
try { await deferredInstallPrompt.userChoice; } catch (e) { /* ignore */ }
|
try { await deferredInstallPrompt.userChoice; } catch (e) { /* ignore */ }
|
||||||
deferredInstallPrompt = null;
|
deferredInstallPrompt = null;
|
||||||
installAppBtn?.classList.add('hidden');
|
hideInstallPromptUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Media Session API (for OS media controls / lock screen) ──────────────────
|
// ── Media Session API (for OS media controls / lock screen) ──────────────────
|
||||||
@@ -2325,6 +2371,7 @@ async function init() {
|
|||||||
restoreLastStationCountry();
|
restoreLastStationCountry();
|
||||||
await loadStations();
|
await loadStations();
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
|
lockPortraitOrientation();
|
||||||
ensureArtworkPointerFallback();
|
ensureArtworkPointerFallback();
|
||||||
scheduleArtworkShine();
|
scheduleArtworkShine();
|
||||||
updateUI();
|
updateUI();
|
||||||
@@ -2337,37 +2384,19 @@ async function init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Service Worker registration (PWA) ────────────────────────────────────────
|
|
||||||
|
|
||||||
if ('serviceWorker' in navigator && import.meta.env.DEV) {
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
navigator.serviceWorker.getRegistrations()
|
|
||||||
.then((registrations) => Promise.all(registrations.map((reg) => reg.unregister())))
|
|
||||||
.catch((err) => console.debug('ServiceWorker dev cleanup failed:', err));
|
|
||||||
|
|
||||||
if ('caches' in window) {
|
|
||||||
caches.keys()
|
|
||||||
.then((keys) => Promise.all(keys.map((key) => caches.delete(key))))
|
|
||||||
.catch((err) => console.debug('Cache dev cleanup failed:', err));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if ('serviceWorker' in navigator) {
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
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));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('beforeinstallprompt', (event) => {
|
window.addEventListener('beforeinstallprompt', (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
deferredInstallPrompt = event;
|
deferredInstallPrompt = event;
|
||||||
installAppBtn?.classList.remove('hidden');
|
showInstallPromptUI();
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('appinstalled', () => {
|
window.addEventListener('appinstalled', () => {
|
||||||
deferredInstallPrompt = null;
|
deferredInstallPrompt = null;
|
||||||
installAppBtn?.classList.add('hidden');
|
hideInstallPromptUI();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('orientationchange', () => {
|
||||||
|
lockPortraitOrientation();
|
||||||
});
|
});
|
||||||
|
|
||||||
export function initPlayer() {
|
export function initPlayer() {
|
||||||
|
|||||||
130
src/styles.css
@@ -851,7 +851,8 @@ input:focus-visible,
|
|||||||
"artwork progress"
|
"artwork progress"
|
||||||
"artwork controls"
|
"artwork controls"
|
||||||
"artwork volume"
|
"artwork volume"
|
||||||
"quickpick quickpick";
|
"quickpick quickpick"
|
||||||
|
"legal legal";
|
||||||
gap: 18px 28px;
|
gap: 18px 28px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
padding: clamp(18px, 3vw, 34px);
|
padding: clamp(18px, 3vw, 34px);
|
||||||
@@ -1010,6 +1011,36 @@ header {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.legal-links {
|
||||||
|
grid-area: legal;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-link {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
transition: color 0.16s ease, border-color 0.16s ease, transform 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-link:hover {
|
||||||
|
color: var(--text-main);
|
||||||
|
border-bottom-color: rgba(var(--accent-rgb), 0.5);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-link:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 4px;
|
||||||
|
border-bottom-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.artwork-container {
|
.artwork-container {
|
||||||
width: min(100%, 360px);
|
width: min(100%, 360px);
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
@@ -1558,6 +1589,10 @@ input[type=range]::-webkit-slider-thumb {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.install-prompt-banner {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
@@ -1854,7 +1889,7 @@ input[type=range]::-webkit-slider-thumb {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: min(82vh, 720px);
|
max-height: min(82vh, 720px);
|
||||||
grid-template-rows: auto auto auto auto auto minmax(240px, 1fr);
|
grid-template-rows: auto auto auto auto auto minmax(0, 1fr);
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
transform: translateY(calc(100% + 24px));
|
transform: translateY(calc(100% + 24px));
|
||||||
@@ -1902,7 +1937,7 @@ input[type=range]::-webkit-slider-thumb {
|
|||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: auto minmax(0, 1fr) auto auto auto auto auto;
|
grid-template-rows: auto minmax(0, 1fr) auto auto auto auto auto auto;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"header"
|
"header"
|
||||||
"artwork"
|
"artwork"
|
||||||
@@ -1910,9 +1945,10 @@ input[type=range]::-webkit-slider-thumb {
|
|||||||
"progress"
|
"progress"
|
||||||
"controls"
|
"controls"
|
||||||
"volume"
|
"volume"
|
||||||
"quickpick";
|
"quickpick"
|
||||||
|
"legal";
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
padding: 10px 12px 12px;
|
padding: 10px 12px 108px;
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -1968,13 +2004,17 @@ input[type=range]::-webkit-slider-thumb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.station-library {
|
.station-library {
|
||||||
|
top: 8px;
|
||||||
left: 8px;
|
left: 8px;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
bottom: 8px;
|
bottom: 8px;
|
||||||
max-height: 86vh;
|
max-height: 86vh;
|
||||||
|
height: calc(100dvh - 16px);
|
||||||
|
max-height: calc(100dvh - 16px);
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
gap: 11px;
|
gap: 11px;
|
||||||
|
grid-template-rows: auto auto auto auto auto minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-top h2 {
|
.library-top h2 {
|
||||||
@@ -1994,6 +2034,13 @@ input[type=range]::-webkit-slider-thumb {
|
|||||||
max-height: 240px;
|
max-height: 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.library-list {
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
mask-image: none;
|
||||||
|
padding-right: 0;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.library-tab {
|
.library-tab {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
@@ -2216,6 +2263,79 @@ input[type=range]::-webkit-slider-thumb {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.legal-links {
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-link {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-prompt-banner {
|
||||||
|
display: flex;
|
||||||
|
position: fixed;
|
||||||
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
bottom: calc(12px + env(safe-area-inset-bottom));
|
||||||
|
z-index: 9999;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border: 1px solid rgba(var(--accent-rgb), 0.22);
|
||||||
|
border-radius: 16px;
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(var(--theme-panel-rgb), 0.94), rgba(255,255,255,0.06)),
|
||||||
|
linear-gradient(135deg, rgba(var(--accent-rgb), 0.14), rgba(var(--accent-3-rgb), 0.1));
|
||||||
|
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.34);
|
||||||
|
backdrop-filter: blur(20px) saturate(130%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-prompt-banner.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-prompt-copy {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-prompt-copy strong {
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-prompt-copy span {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-prompt-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-prompt-action,
|
||||||
|
.install-prompt-dismiss {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 11px;
|
||||||
|
border-radius: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-prompt-action {
|
||||||
|
color: #0c1819;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.overlay {
|
.overlay {
|
||||||
align-items: end;
|
align-items: end;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
28
sync.sh
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
localFolder='/mnt/d/Sites/RadioPlayer/web/dist/.'
|
||||||
|
remoteFolder='/opt/www/virtual/RadioPlayer/'
|
||||||
|
remoteServer='klevze@server.klevze.si'
|
||||||
|
|
||||||
|
rsync -avz \
|
||||||
|
--chmod=D755,F644 \
|
||||||
|
--exclude ".phpintel/" \
|
||||||
|
--exclude "bootstrap/cache/" \
|
||||||
|
--exclude ".env" \
|
||||||
|
--exclude "public/hot" \
|
||||||
|
--exclude "public/files" \
|
||||||
|
--exclude "node_modules" \
|
||||||
|
--exclude "aritmija_devTemplate" \
|
||||||
|
--exclude "web82/" \
|
||||||
|
--exclude "storage/" \
|
||||||
|
--exclude "oldSite/" \
|
||||||
|
--exclude "vendor" \
|
||||||
|
--exclude "resources/lang" \
|
||||||
|
--exclude ".git/" \
|
||||||
|
--exclude ".gemini" \
|
||||||
|
--exclude ".github" \
|
||||||
|
--exclude ".vscode" \
|
||||||
|
-e ssh \
|
||||||
|
$localFolder \
|
||||||
|
$remoteServer:$remoteFolder/
|
||||||
|
|
||||||
@@ -1,9 +1,25 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
const buildStamp = `${Date.now()}`;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
base: './',
|
base: './',
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: resolve(process.cwd(), 'index.html'),
|
||||||
|
privacy: resolve(process.cwd(), 'privacy.html'),
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
entryFileNames: `assets/[name]-${buildStamp}-[hash].js`,
|
||||||
|
chunkFileNames: `assets/[name]-${buildStamp}-[hash].js`,
|
||||||
|
assetFileNames: `assets/[name]-${buildStamp}-[hash][extname]`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|||||||