This commit is contained in:
2026-04-29 07:39:29 +02:00
parent 41a5472582
commit b866845b6a
40 changed files with 71735 additions and 111 deletions

View File

@@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 682 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View 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

File diff suppressed because one or more lines are too long

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

106
RadioWebApp/sw.js Normal file
View 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' });
});
})
);
});

View File

@@ -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>

View File

@@ -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
View 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>

View File

@@ -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": [

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

View File

@@ -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": {}
} }

View File

@@ -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' });
});
})
); );
}); });

View 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');

View File

@@ -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>
); );

View File

@@ -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) => {

View File

@@ -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() {

View File

@@ -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
View 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/

View File

@@ -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,